<?php
/**
 * Invoice numbering and PDF generation.
 *
 * Added in 4.4.3 alongside YATOON_Tax. Together they close the compliance gap
 * that stopped the plugin being usable by EU businesses, which must be able to
 * issue a numbered document stating the VAT charged.
 *
 * DESIGN NOTES
 * ------------
 * - Invoice numbers are allocated atomically and are gap-free per year. Tax
 *   authorities in several jurisdictions require a sequential series, so we
 *   cannot simply reuse the booking ID (bookings can be deleted, and IDs are
 *   shared across test data).
 * - A number is allocated on FIRST ISSUE and then frozen on the booking row.
 *   Re-downloading an invoice must produce the same document, not a new one.
 * - The PDF is generated on demand rather than stored, so there is no file to
 *   leak, back up, or clean up. It reuses the dependency-free PDF writer
 *   pattern already used by YATOON_Reporting rather than pulling in a library.
 *
 * @package Yatoon_Booking_System
 * @since 4.4.3
 */

if ( ! defined( 'ABSPATH' ) ) {
    exit;
}

class YATOON_Invoice {

    /**
     * Register hooks.
     *
     * @return void
     */
    public static function init() {
        add_action( 'admin_post_yatoon_download_invoice', array( __CLASS__, 'handle_download' ) );
        add_action( 'admin_post_nopriv_yatoon_download_invoice', array( __CLASS__, 'handle_download' ) );
    }

    /**
     * Is invoicing switched on?
     *
     * @return bool
     */
    public static function is_enabled() {
        return get_option( 'yatoon_invoice_enabled', '0' ) === '1';
    }

    /**
     * Allocate (or return the existing) invoice number for a booking.
     *
     * Uses a MySQL named lock so two simultaneous downloads cannot claim the
     * same sequence number -- the same class of race the 4.4.3 booking work
     * addressed, and duplicate invoice numbers are an audit failure.
     *
     * @param int $booking_id
     * @return string|false Invoice number, or false on failure.
     */
    public static function ensure_number( $booking_id ) {
        global $wpdb;

        $booking_id = (int) $booking_id;
        if ( $booking_id <= 0 || ! YATOON_Tax::columns_exist() ) {
            return false;
        }

        $table = $wpdb->prefix . 'yatoon_bookings';

        $existing = $wpdb->get_var( $wpdb->prepare( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
            "SELECT invoice_number FROM {$table} WHERE id = %d",
            $booking_id
        ) );
        if ( ! empty( $existing ) ) {
            return $existing;
        }

        $lock_key = 'yatoon_invoice_seq_' . md5( home_url() );
        $got_lock = (int) $wpdb->get_var( $wpdb->prepare( 'SELECT GET_LOCK(%s, %d)', $lock_key, 5 ) );
        if ( 1 !== $got_lock ) {
            return false;
        }

        try {
            // Re-read inside the lock: another request may have just issued one.
            $existing = $wpdb->get_var( $wpdb->prepare( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
                "SELECT invoice_number FROM {$table} WHERE id = %d",
                $booking_id
            ) );
            if ( ! empty( $existing ) ) {
                return $existing;
            }

            $prefix = (string) get_option( 'yatoon_invoice_prefix', 'INV' );
            $prefix = preg_replace( '/[^A-Za-z0-9\-]/', '', $prefix );
            $prefix = '' !== $prefix ? $prefix : 'INV';

            $year    = (int) wp_date( 'Y' );
            $seq_key = 'yatoon_invoice_seq_' . $year;
            $next    = (int) get_option( $seq_key, 0 ) + 1;
            update_option( $seq_key, $next, false );

            $number = sprintf( '%s-%d-%05d', $prefix, $year, $next );

            $updated = $wpdb->update( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
                $table,
                array(
                    'invoice_number'    => $number,
                    'invoice_issued_at' => current_time( 'mysql' ),
                ),
                array( 'id' => $booking_id ),
                array( '%s', '%s' ),
                array( '%d' )
            );

            return ( false === $updated ) ? false : $number;
        } finally {
            $wpdb->get_var( $wpdb->prepare( 'SELECT RELEASE_LOCK(%s)', $lock_key ) );
        }
    }

    /**
     * Signed download URL for a booking's invoice.
     *
     * Reuses the manage-booking token so a customer can fetch their own
     * invoice without an account, and nobody can enumerate other people's by
     * incrementing an ID.
     *
     * @param int    $booking_id
     * @param string $manage_token
     * @return string
     */
    public static function get_download_url( $booking_id, $manage_token = '' ) {
        $args = array(
            'action'  => 'yatoon_download_invoice',
            'booking' => (int) $booking_id,
        );
        if ( $manage_token ) {
            $args['token'] = $manage_token;
        }
        return add_query_arg( $args, admin_url( 'admin-post.php' ) );
    }

    /**
     * Serve an invoice PDF.
     *
     * @return void
     */
    public static function handle_download() {
        $booking_id = isset( $_GET['booking'] ) ? absint( $_GET['booking'] ) : 0;
        $token      = isset( $_GET['token'] ) ? sanitize_text_field( wp_unslash( $_GET['token'] ) ) : '';

        if ( ! $booking_id ) {
            wp_die( esc_html__( 'Invoice not found.', 'yatoon-booking-system' ), '', array( 'response' => 404 ) );
        }

        global $wpdb;
        $booking = $wpdb->get_row( $wpdb->prepare( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
            "SELECT b.*, s.name AS service_name, st.name AS staff_name
               FROM {$wpdb->prefix}yatoon_bookings b
               LEFT JOIN {$wpdb->prefix}yatoon_services s ON s.id = b.service_id
               LEFT JOIN {$wpdb->prefix}yatoon_staff st ON st.id = b.staff_id
              WHERE b.id = %d",
            $booking_id
        ) );

        if ( ! $booking ) {
            wp_die( esc_html__( 'Invoice not found.', 'yatoon-booking-system' ), '', array( 'response' => 404 ) );
        }

        // Either an administrator, or the customer holding a valid manage token
        // for THIS booking. Never a bare booking ID -- otherwise anyone could
        // walk the sequence and read every customer's name, email and spend.
        $is_admin = current_user_can( 'manage_options' );
        $is_owner = ! $is_admin && self::token_matches_booking( $token, $booking );

        if ( ! $is_admin && ! $is_owner ) {
            wp_die( esc_html__( 'You do not have permission to view this invoice.', 'yatoon-booking-system' ), '', array( 'response' => 403 ) );
        }

        $number = self::ensure_number( $booking_id );
        if ( ! $number ) {
            wp_die( esc_html__( 'Could not issue an invoice for this booking.', 'yatoon-booking-system' ), '', array( 'response' => 500 ) );
        }

        self::render_pdf( $booking, $number );
    }

    /**
     * Does this manage token belong to this booking, and is it still valid?
     *
     * Yatoon_Manage_Booking::get_booking_by_token() is a private instance
     * method, so the check is reimplemented here against the same columns.
     * Comparison is constant-time and the token is matched against the row we
     * already loaded, so a valid token for booking A cannot open booking B.
     *
     * @since 4.4.3
     * @param string $token
     * @param object $booking Already-loaded booking row.
     * @return bool
     */
    private static function token_matches_booking( $token, $booking ) {
        $token = (string) $token;

        // Same minimum length the manage-booking flow enforces; tokens are
        // 48-char hex.
        if ( strlen( $token ) < 40 || empty( $booking->manage_token ) ) {
            return false;
        }

        if ( ! hash_equals( (string) $booking->manage_token, $token ) ) {
            return false;
        }

        // manage_token_expires is written with gmdate() and read with
        // strtotime(), both in PHP's default zone -- keep that contract.
        if ( ! empty( $booking->manage_token_expires )
            && strtotime( $booking->manage_token_expires ) < time() ) {
            return false;
        }

        return true;
    }

    /**
     * Build and stream the invoice PDF.
     *
     * @param object $booking
     * @param string $number
     * @return void
     */
    private static function render_pdf( $booking, $number ) {
        $business = (string) get_option( 'yatoon_business_name', get_bloginfo( 'name' ) );
        $address  = (string) get_option( 'yatoon_business_address', '' );
        $vat_id   = (string) get_option( 'yatoon_tax_registration_id', '' );

        $gross = (float) $booking->total_price;
        $tax   = isset( $booking->tax_amount ) && null !== $booking->tax_amount ? (float) $booking->tax_amount : 0.0;
        $net   = isset( $booking->net_amount ) && null !== $booking->net_amount ? (float) $booking->net_amount : $gross - $tax;
        $rate  = isset( $booking->tax_rate ) ? (float) $booking->tax_rate : 0.0;

        $lines = array(
            strtoupper( __( 'INVOICE', 'yatoon-booking-system' ) ),
            '',
            __( 'Invoice number', 'yatoon-booking-system' ) . ': ' . $number,
            __( 'Issue date', 'yatoon-booking-system' ) . ': ' . yatoon_format_local( 'Y-m-d', $booking->invoice_issued_at ?: current_time( 'mysql' ) ),
            '',
            $business,
        );

        foreach ( array_filter( array_map( 'trim', explode( "\n", $address ) ) ) as $address_line ) {
            $lines[] = $address_line;
        }
        if ( '' !== trim( $vat_id ) ) {
            $lines[] = __( 'Tax registration', 'yatoon-booking-system' ) . ': ' . $vat_id;
        }

        $lines[] = '';
        $lines[] = __( 'Billed to', 'yatoon-booking-system' ) . ': ' . $booking->customer_name;
        if ( ! empty( $booking->customer_email ) ) {
            $lines[] = $booking->customer_email;
        }
        $lines[] = '';
        $lines[] = str_repeat( '-', 60 );
        $lines[] = __( 'Description', 'yatoon-booking-system' );
        $lines[] = str_repeat( '-', 60 );
        $lines[] = ( $booking->service_name ?: __( 'Appointment', 'yatoon-booking-system' ) )
            . '  ' . yatoon_format_local( 'Y-m-d H:i', $booking->start_time );
        if ( ! empty( $booking->staff_name ) ) {
            $lines[] = '  ' . __( 'Staff', 'yatoon-booking-system' ) . ': ' . $booking->staff_name;
        }
        $lines[] = str_repeat( '-', 60 );

        if ( $tax > 0 ) {
            $rate_display = rtrim( rtrim( number_format( $rate, 2 ), '0' ), '.' );
            $lines[]      = sprintf( '%-42s %s', __( 'Subtotal (net)', 'yatoon-booking-system' ), YATOON_Tax::format_money( $net ) );
            $lines[]      = sprintf( '%-42s %s', YATOON_Tax::label() . ' (' . $rate_display . '%)', YATOON_Tax::format_money( $tax ) );
        }
        $lines[] = sprintf( '%-42s %s', __( 'Total', 'yatoon-booking-system' ), YATOON_Tax::format_money( $gross ) );
        $lines[] = '';
        $lines[] = __( 'Payment status', 'yatoon-booking-system' ) . ': ' . ( $booking->payment_status ?: 'unpaid' );

        if ( $tax <= 0 && YATOON_Tax::is_enabled() ) {
            $lines[] = '';
            $lines[] = __( 'No tax was charged on this booking.', 'yatoon-booking-system' );
        }

        $pdf      = self::build_pdf( $lines );
        $filename = 'invoice-' . preg_replace( '/[^A-Za-z0-9\-]/', '', $number ) . '.pdf';

        nocache_headers();
        header( 'Content-Type: application/pdf' );
        header( 'Content-Disposition: attachment; filename=' . $filename );
        header( 'Content-Length: ' . strlen( $pdf ) );
        echo $pdf; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- binary PDF payload.
        exit;
    }

    /**
     * Minimal single-page PDF writer.
     *
     * Mirrors the approach in YATOON_Reporting::pdf_response() rather than
     * adding a PDF library: the plugin already ships this pattern, invoices
     * are simple documents, and a dependency-free writer keeps the package
     * small and the attack surface flat.
     *
     * Text is transliterated to Windows-1252 because the base-14 Helvetica
     * font referenced below has no Unicode coverage. Sites needing CJK or
     * Cyrillic invoices should filter 'yatoon_invoice_pdf' and supply their own
     * renderer with an embedded font.
     *
     * @param string[] $lines
     * @return string Raw PDF bytes.
     */
    private static function build_pdf( array $lines ) {
        /**
         * Replace the built-in invoice PDF renderer.
         *
         * @since 4.4.3
         * @param string|null $pdf   Return raw PDF bytes to take over rendering.
         * @param string[]    $lines Prepared text lines.
         */
        $custom = apply_filters( 'yatoon_invoice_pdf', null, $lines );
        if ( is_string( $custom ) && '' !== $custom ) {
            return $custom;
        }

        $content = "BT\n/F1 11 Tf\n50 760 Td\n14 TL\n";
        foreach ( $lines as $line ) {
            $converted = function_exists( 'iconv' ) ? iconv( 'UTF-8', 'Windows-1252//TRANSLIT', $line ) : $line;
            $line      = str_replace( array( '\\', '(', ')' ), array( '\\\\', '\\(', '\\)' ), $converted ?: $line );
            $content  .= '(' . $line . ") Tj\nT*\n";
        }
        $content .= 'ET';

        $objects = array(
            '<< /Type /Catalog /Pages 2 0 R >>',
            '<< /Type /Pages /Kids [3 0 R] /Count 1 >>',
            '<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >>',
            '<< /Length ' . strlen( $content ) . ">>\nstream\n" . $content . "\nendstream",
            '<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>',
        );

        $pdf     = "%PDF-1.4\n";
        $offsets = array( 0 );
        foreach ( $objects as $i => $object ) {
            $offsets[] = strlen( $pdf );
            $pdf      .= ( $i + 1 ) . " 0 obj\n{$object}\nendobj\n";
        }
        $xref = strlen( $pdf );
        $pdf .= "xref\n0 " . ( count( $objects ) + 1 ) . "\n0000000000 65535 f \n";
        for ( $i = 1; $i <= count( $objects ); $i++ ) {
            $pdf .= sprintf( "%010d 00000 n \n", $offsets[ $i ] );
        }
        $pdf .= 'trailer << /Size ' . ( count( $objects ) + 1 ) . " /Root 1 0 R >>\nstartxref\n{$xref}\n%%EOF";

        return $pdf;
    }
}

YATOON_Invoice::init();
