<?php
/**
 * Order Repository.
 *
 * All order read/write operations. Called exclusively by CheckoutManager.
 * Orders Module (Module 5) will extend read operations here.
 *
 * Uses a denormalized address snapshot pattern: billing and shipping
 * addresses are snapshot-copied onto the order row at creation time so
 * they're immune to future address changes.
 *
 * @package TitanCart\Modules\Checkout
 */

namespace TitanCart\Modules\Checkout;

use TitanCart\DB\Database;

defined( 'ABSPATH' ) || exit;

class OrderRepository {

    private Database $db;

    public function __construct() {
        $this->db = titancart_db();
    }

    // ── Write ─────────────────────────────────────────────────────────────────

    /**
     * Insert a new order row and return the new order ID.
     *
     * @param  array $data  Fully assembled order data from CheckoutManager.
     * @return int  New order ID, 0 on failure.
     */
    public function create_order( array $data ): int {
        // Resolve status handle → status_id.
        $status_handle = $data['status'] ?? 'pending';
        $status_id     = $this->resolve_status_id( $status_handle );

        $order_id = $this->db->insert( 'orders', [
            // Identity
            'customer_id'        => $data['customer_id'],
            'order_number'       => $data['order_number'],
            'order_key'          => $data['order_key'] ?? self::generate_order_key(),
            'status_id'          => $status_id,
            'ip_address'         => $data['ip_address'],
            'user_agent'         => $data['user_agent'],

            // Billing address (denormalized snapshot)
            'billing_first_name' => $data['billing']['first_name'],
            'billing_last_name'  => $data['billing']['last_name'],
            'billing_company'    => $data['billing']['company']   ?? '',
            'billing_email'      => $data['billing']['email'],
            'billing_phone'      => $data['billing']['phone']     ?? '',
            'billing_address_1'  => $data['billing']['address_1'],
            'billing_address_2'  => $data['billing']['address_2'] ?? '',
            'billing_city'       => $data['billing']['city'],
            'billing_state'      => $data['billing']['state']     ?? '',
            'billing_postcode'   => $data['billing']['postcode'],
            'billing_country'    => $data['billing']['country'],

            // Shipping address (denormalized snapshot)
            'shipping_first_name' => $data['shipping']['first_name'],
            'shipping_last_name'  => $data['shipping']['last_name'],
            'shipping_company'    => $data['shipping']['company']   ?? '',
            'shipping_address_1'  => $data['shipping']['address_1'],
            'shipping_address_2'  => $data['shipping']['address_2'] ?? '',
            'shipping_city'       => $data['shipping']['city'],
            'shipping_state'      => $data['shipping']['state']     ?? '',
            'shipping_postcode'   => $data['shipping']['postcode'],
            'shipping_country'    => $data['shipping']['country'],

            // Financials
            'currency'           => $data['currency'],
            'subtotal'           => $data['subtotal'],
            'discount'           => $data['discount'],
            'shipping_cost'      => $data['shipping_cost'],
            'tax_total'          => $data['tax_total'],
            'payment_surcharge'  => $data['payment_surcharge'],
            'tip_total'          => $data['tip_total'] ?? 0,
            'total'              => $data['total'],

            // Payment / shipping method
            'payment_method_id'  => $data['payment_method_id']  ?? null,
            'shipping_method_id' => $data['shipping_method_id'] ?? null,
            // Realtime/zone shipping service snapshot (KI-32). JSON-encoded like
            // promotions_applied; NULL when the order doesn't ship or no snapshot was
            // captured. Durable record of the exact service + charged rate.
            'shipping_info'      => ! empty( $data['shipping_info'] ) ? wp_json_encode( $data['shipping_info'] ) : null,
            // Payment gateway snapshot (KI-97, S424) — the exact mirror of
            // shipping_info above. Until S424 tc_orders.payment_info was a DEAD
            // column: defined in Schema.php and referenced nowhere else in src,
            // so the payment method name on an order was resolved ONLY by live
            // join against tc_payment_methods. That made the gateway row load-
            // bearing for order history, and deleting one blanked the method
            // name on every past order that used it. This snapshot ends that
            // coupling the same way KI-32 ended it for shipping.
            'payment_info'       => ! empty( $data['payment_info'] ) ? wp_json_encode( $data['payment_info'] ) : null,

            // Extra
            'coupon_code'        => $data['coupon_code'] ?? null,
            'coupon_discount'    => $data['discount'] ?? 0,
            'gift_card_code'     => $data['gift_card_code'] ?? null,
            'gift_card_discount' => $data['gift_card_discount'] ?? 0,
            'points_redeemed'    => $data['points_redeemed'] ?? 0,
            'points_discount'    => $data['points_discount'] ?? 0,
            'promotions_applied' => ! empty( $data['promotions_applied'] ) ? wp_json_encode( $data['promotions_applied'] ) : null,
            'customer_note'      => $data['notes']       ?? null,
            'referral_source'    => $data['referral_source'] ?? null,

            // Timestamps — KI-24: set explicitly in UTC. Previously relied on the
            // MySQL DEFAULT CURRENT_TIMESTAMP (= the DB session tz, Eastern on
            // demoxpert), so order timestamps rendered ~4h early (prev-day after
            // ~8 PM ET) against the UTC-assuming display layer. Phase 3 drops the
            // column defaults; this is the explicit-PHP write the contract requires.
            'created_at'         => titancart_now_utc(),
            'updated_at'         => titancart_now_utc(),
        ] );

        return (int) $order_id;
    }

    /**
     * Look up an order status ID by its handle (e.g. 'pending', 'paid').
     * Falls back to 0 if the handle isn't found.
     */
    private function resolve_status_id( string $handle ): int {
        static $cache = [];
        if ( isset( $cache[ $handle ] ) ) return $cache[ $handle ];

        $row = $this->db->getRow(
            "SELECT id FROM {$this->db->table('order_statuses')} WHERE handle = ?s LIMIT 1",
            $handle
        );
        $cache[ $handle ] = (int) ( $row['id'] ?? 0 );
        return $cache[ $handle ];
    }

    /**
     * Insert all line items for an order.
     *
     * Each line passes through the `titancart_order_item_data` filter
     * before insert, allowing extensions to stamp additional columns
     * (e.g. marketplace vendor_id from B4 Path B1). See the filter
     * docblock inline below for the contract.
     *
     * @param int   $order_id
     * @param array $line_items  Hydrated line items from CartTotals.
     */
    public function create_order_items( int $order_id, array $line_items ): void {
        foreach ( $line_items as $line ) {
            $line_total     = (float) ( $line['line_total'] ?? 0 );
            $discount_amt   = (float) ( $line['discount_amount'] ?? 0 );
            $tax_amt        = (float) ( $line['tax_amount'] ?? 0 );
            $subtotal       = $line_total; // pre-discount subtotal = qty × effective_price
            $total          = $subtotal - $discount_amt + $tax_amt;

            $item_data = [
                'order_id'     => $order_id,
                'product_id'   => (int) $line['product_id'],
                'variation_id' => $line['variation_id'] ?? null,
                'name'         => $line['name'],
                'sku'          => $line['sku']      ?? '',
                'quantity'     => (int) $line['quantity'],
                'unit_price'   => $line['effective_price'],
                'subtotal'     => $subtotal,
                'discount'     => $discount_amt,
                'tax'          => $tax_amt,
                'total'        => $total,
                'meta'         => ! empty( $line['meta'] ) ? wp_json_encode( is_string( $line['meta'] ) ? json_decode( $line['meta'], true ) : $line['meta'] ) : null,
            ];

            /**
             * Filter: titancart_order_item_data
             *
             * Lets extensions stamp additional columns onto an order line
             * BEFORE the insert. Used by the marketplace extension at B4
             * (S190+) to write `vendor_id` from the active tc_vendor_products
             * junction, giving every line item immutable vendor provenance
             * for B5 commission math and B6 Stripe Connect destination
             * routing.
             *
             * Pattern #158 third instance — cross-extension filter hook
             * (after S185 `titancart_products_list_response` response-layer
             * enrichment and S187 `titancart_orders_list_where` query-layer
             * scoping). This hook is the write-layer twin: a listener
             * returns the modified data array and the insert proceeds with
             * it.
             *
             * Contract:
             *   - Listeners MAY ADD keys for columns that exist on
             *     tc_order_items (the column must already be in the schema
             *     after the listener's own migration, or the insert will
             *     fail with "Unknown column"). NULL is a valid value.
             *   - Listeners MUST NOT REMOVE existing keys. Listeners SHOULD
             *     treat the array as additive — overwriting a TC-core key
             *     with a wrong type or invalid value will silently break
             *     the line (db->insert returns 0, order_item_options for
             *     that line are skipped).
             *   - Listeners SHOULD be idempotent. The filter may fire on
             *     order replay paths in the future.
             *   - $line is the CartTotals snapshot of the cart row + product
             *     hydration. Read-only context — mutating it has no effect.
             *   - $order_id is the freshly-created order row's ID. Useful
             *     for any listener needing to write companion rows in their
             *     own table keyed by order_id.
             *
             * Filter failure mode:
             *   If a listener returns something other than an array, or
             *   removes the order_id / product_id keys, the insert will
             *   either fail (wpdb->insert returns false → 0) or write a
             *   malformed row. There is no defense-in-depth guard here —
             *   listeners must respect the contract. This matches the
             *   discipline already established by Pattern #158's prior two
             *   instances.
             *
             * @since 0.12.x  S190 (Track B Module B4 — order splitting foundation)
             *
             * @param array $item_data  Resolved insert-ready data array.
             * @param array $line       CartTotals line snapshot (read-only context).
             * @param int   $order_id   Newly-created order's primary key.
             */
            $item_data = apply_filters(
                'titancart_order_item_data',
                $item_data,
                $line,
                $order_id
            );

            $item_id = $this->db->insert( 'order_items', $item_data );

            // Insert selected option snapshots.
            if ( ! empty( $line['options'] ) && $item_id ) {
                foreach ( $line['options'] as $opt ) {
                    $this->db->insert( 'order_item_options', [
                        'order_item_id'  => $item_id,
                        'option_name'    => $opt['option_name']  ?? '',
                        'variant_name'   => $opt['variant_name'] ?? '',
                        'price_modifier' => (float) ( $opt['modifier'] ?? 0 ),
                    ] );
                }
            }
        }
    }

    /**
     * Update the status of an existing order.
     *
     * @param int    $order_id
     * @param string $status
     */
    public function update_status( int $order_id, string $status ): void {
        $this->db->update( 'orders', [ 'status' => $status ], [ 'id' => $order_id ] );
    }

    // ── Read ──────────────────────────────────────────────────────────────────

    /**
     * Get a single order by its primary key.
     *
     * @param  int $order_id
     * @return array|null
     */
    public function get_by_id( int $order_id ): ?array {
        return $this->db->getRow(
            "SELECT * FROM {$this->db->table('orders')} WHERE id = ?i LIMIT 1",
            $order_id
        ) ?: null;
    }

    /**
     * Get a single order by its human-readable order number.
     *
     * @param  string $order_number  e.g. 'TC-20260311-0042'
     * @return array|null
     */
    public function get_by_number( string $order_number ): ?array {
        return $this->db->getRow(
            "SELECT * FROM {$this->db->table('orders')} WHERE order_number = ?s LIMIT 1",
            strtoupper( $order_number )
        ) ?: null;
    }

    /**
     * Get all items for an order.
     *
     * @param  int $order_id
     * @return array
     */
    public function get_items( int $order_id ): array {
        $items = $this->db->getArray(
            "SELECT oi.*, GROUP_CONCAT(
                CONCAT(oio.option_name, ': ', oio.variant_name)
                ORDER BY oio.id SEPARATOR ', '
             ) AS options_summary
             FROM {$this->db->table('order_items')} oi
             LEFT JOIN {$this->db->table('order_item_options')} oio ON oio.order_item_id = oi.id
             WHERE oi.order_id = ?i
             GROUP BY oi.id
             ORDER BY oi.id ASC",
            $order_id
        );

        return $items;
    }

    // ── Helpers ───────────────────────────────────────────────────────────────

    /**
     * Generate a unique, human-readable order number.
     * Format: TC-YYYYMMDD-XXXX  (e.g. TC-20260311-0042)
     *
     * @return string
     */
    public function generate_order_number(): string {
        $date   = gmdate( 'Ymd' );

        // Order number prefix is configurable (Settings → Checkout → Checkout
        // Flow → "Order number prefix"; default "TC"). Sanitized to URL-safe
        // uppercase alphanumerics + hyphen, since the number appears in the
        // public /order-received/{number} URL. Empty prefix → date-only numbers
        // (e.g. 20260628-0001). Changing it only affects orders placed from now
        // on; existing order_numbers are immutable on their rows.
        $raw    = \TitanCart\Services\SettingsService::get( 'checkout', 'order_number_prefix', 'TC' );
        $token  = trim( strtoupper( (string) preg_replace( '/[^A-Za-z0-9\-]/', '', $raw ) ), '-' );
        $token  = substr( $token, 0, 12 );
        $prefix = ( $token === '' ? '' : $token . '-' ) . $date . '-';

        // Find the highest sequence for today.
        $last = $this->db->getField(
            "SELECT order_number FROM {$this->db->table('orders')}
             WHERE order_number LIKE ?s
             ORDER BY id DESC LIMIT 1",
            $prefix . '%'
        );

        if ( $last ) {
            $seq = (int) substr( $last, strrpos( $last, '-' ) + 1 );
        } else {
            // Check if store has ANY orders at all — if not, use initial_order_id setting
            $total = (int) $this->db->getField(
                "SELECT COUNT(*) FROM {$this->db->table('orders')}"
            );
            if ( $total === 0 ) {
                $initial = \TitanCart\Services\SettingsService::int( 'checkout', 'initial_order_id', 0 );
                $seq = max( 0, $initial - 1 ); // will be incremented below
            } else {
                $seq = 0;
            }
        }

        return $prefix . str_pad( $seq + 1, 4, '0', STR_PAD_LEFT );
    }

    /**
     * Generate a non-enumerable order key for the order_key column.
     *
     * 32 hex chars (~128 bits of entropy from random_bytes, the PHP CSPRNG).
     * Required to view the order via the public thank-you page or the REST
     * GET /orders/{number} endpoint when the caller is not an admin and not
     * the authenticated owner. Stops the predictable order_number format
     * (TC-YYYYMMDD-NNNN) from being a public PII enumeration vector.
     */
    public static function generate_order_key(): string {
        try {
            return bin2hex( random_bytes( 16 ) );
        } catch ( \Throwable $e ) {
            // random_bytes throws if the system entropy pool is unavailable.
            // wp_generate_password uses random_int internally on modern WP
            // and will only fall back to mt_rand if that itself fails — which
            // implies a much deeper system problem.
            return wp_generate_password( 32, false, false );
        }
    }
}
