<?php
/**
 * Orders Module — Module 5 bootstrapper.
 *
 * Wires:
 *   - REST routes (admin orders CRUD)
 *   - [titancart_my_orders] shortcode (storefront account page)
 *   - Email hooks (order placed → confirmation + admin notification)
 *   - Status-changed hook → status-update email
 *   - Activation seed for default order statuses
 *
 * @package TitanCart\Modules\Orders
 */

namespace TitanCart\Modules\Orders;

use TitanCart\REST\Orders\OrdersController;
use TitanCart\Modules\Products\ProductRepository;

defined( 'ABSPATH' ) || exit;

class OrdersModule {

    private OrderManager $order_manager;
    private Mailer       $mailer;

    public function __construct() {
        $this->order_manager = new OrderManager();
        $this->mailer        = new Mailer();

        // REST API.
        add_action( 'rest_api_init', [ $this, 'register_routes' ] );

        // Shortcodes.
        add_shortcode( 'titancart_my_orders', [ $this, 'my_orders_shortcode' ] );

        // Email hooks — fire after PaymentsModule has run process_payment().
        // S115: moved from titancart_order_placed to titancart_payment_processed
        // so declined customers no longer receive "Order Confirmed" emails.
        add_action( 'titancart_payment_processed', [ $this, 'on_payment_processed' ], 10, 3 );

        // KI-05 fix: async gateways defer the confirmation + admin notification
        // until the payment actually confirms. Stripe fires this action after
        // the PaymentIntent succeeds and the order has advanced to 'processing'.
        add_action( 'titancart_stripe_payment_intent_succeeded', [ $this, 'on_async_payment_confirmed' ], 10, 3 );

        // Email hook — fires after OrderManager::update_status().
        add_action( 'titancart_order_status_changed', [ $this, 'on_status_changed' ], 10, 4 );

        // S266 KI-16 — restore-only inventory listener. When an order enters a
        // status configured with stock_action='restore' (e.g. cancelled/refunded),
        // return the committed stock to inventory exactly once, netting out any
        // quantities already restored by completed RMA returns. The 'reduce'
        // direction is intentionally NOT wired here — stock is decremented once at
        // payment commit (CheckoutManager), so a status-driven reduce would
        // double-count. Guarded by tc_orders.stock_state (Migration 52).
        add_action( 'titancart_order_status_changed', [ $this, 'maybe_restore_stock' ], 8, 4 );

        // S134 Phase 4: when Downloadables issues keys for an order, route to
        // the Mailer. The listener decides between two destinations:
        //   - STASH for inline embed (sync-gateway path — confirmation email
        //     hasn't composed yet in this same request).
        //   - STANDALONE "Downloads Ready" send (async-gateway capture, or
        //     admin "Resend downloads email" action).
        // The 3-arg signature is required: (order_id, issued_map, context).
        add_action( 'titancart_downloadable_keys_issued', [ $this->mailer, 'on_keys_issued' ], 10, 3 );

        // Seed default statuses once per activation (also safe to re-run).
        add_action( 'titancart_loaded', [ $this, 'maybe_seed_statuses' ] );

        // S116: register the orphan-pending-order cleanup cron handler.
        // Schedule itself is registered in Installer::schedule_cron(); this
        // just hooks the action so WP-Cron can dispatch when fired.
        OrphanOrderCleanup::init();
    }

    // ══════════════════════════════════════════════════════════════════════════
    // REST ROUTES
    // ══════════════════════════════════════════════════════════════════════════

    public function register_routes(): void {
        ( new OrdersController( $this->order_manager ) )->register_routes();
    }

    // ══════════════════════════════════════════════════════════════════════════
    // SHORTCODE — [titancart_my_orders]
    // ══════════════════════════════════════════════════════════════════════════

    /**
     * Render the customer's order history page.
     *
     * Checks for ?tc_order=TC-XXXXXXXX-XXXX query param to show a single order
     * detail view; otherwise renders the paginated order list.
     *
     * @return string HTML output.
     */
    public function my_orders_shortcode(): string {
        if ( ! is_user_logged_in() ) {
            return '<p class="tc-login-notice">'
                . sprintf(
                    /* translators: %s: login URL */
                    wp_kses_post( __( 'Please <a href="%s">log in</a> to view your orders.', 'titancart-lite' ) ),
                    esc_url( titancart_login_url() )
                )
                . '</p>';
        }

        // Resolve WP user → tc_customers.id.
        // orders.customer_id stores the TitanCart customer id (tc_customers.id),
        // NOT the WordPress user_id. Using get_current_user_id() directly would
        // either return zero results or, on accidental id overlap, expose the
        // wrong customer's orders.
        $repo         = new \TitanCart\Modules\Accounts\CustomerRepository();
        $customer_row = $repo->find_by_wp_user( get_current_user_id() );
        if ( ! $customer_row ) {
            // Logged-in WP user with no tc_customers row — show empty list, not a
            // misleading "log in" notice. Most likely cause: legacy WP user predating
            // the on_user_register hook in AccountsModule.
            return \TitanCart\Storefront\TemplateLoader::get_template(
                'account/my-orders.php',
                [ 'orders' => [], 'total' => 0, 'pages' => 1, 'current_page' => 1 ],
                true
            );
        }
        $customer_id = (int) $customer_row['id'];

        // Single order detail view.
        $order_number = sanitize_text_field( wp_unslash( $_GET['tc_order'] ?? '' ) );
        if ( $order_number ) {
            $order = $this->order_manager->get_by_number( $order_number );

            // Ownership check — customers may only view their own orders.
            if ( ! $order || (int) $order['customer_id'] !== $customer_id ) {
                return '<p class="tc-error">'
                    . esc_html__( 'Order not found.', 'titancart-lite' )
                    . '</p>';
            }

            // S282: surface fulfillment/tracking to the customer. get_by_order()
            // does NOT join the shipping-method translations table, so it's
            // unaffected by the smt column bug fixed this session. Enrich each
            // shipment with a carrier tracking URL via the existing helper so the
            // template stays logic-free.
            $shipment_mgr = new \TitanCart\Modules\Shipping\ShipmentManager();
            $shipments    = $shipment_mgr->get_by_order( (int) $order['id'] );
            foreach ( $shipments as &$_sh ) {
                $_sh['tracking_url'] = $shipment_mgr->get_tracking_url(
                    (string) ( $_sh['carrier'] ?? '' ),
                    (string) ( $_sh['tracking_number'] ?? '' )
                );
            }
            unset( $_sh );

            return \TitanCart\Storefront\TemplateLoader::get_template(
                'account/order-detail.php',
                [ 'order' => $order, 'items' => $order['items'], 'notes' => $order['notes'], 'shipments' => $shipments ],
                true
            );
        }

        // Order list.
        $page   = max( 1, absint( wp_unslash( $_GET['tc_page'] ?? 1 ) ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- idempotent GET view-state (pagination), see reviewer notes §6.
        $result = $this->order_manager->get_customer_orders( $customer_id, $page, 10 );

        return \TitanCart\Storefront\TemplateLoader::get_template(
            'account/my-orders.php',
            [
                'orders'      => $result['orders'],
                'total'       => $result['total'],
                'pages'       => $result['pages'],
                'current_page'=> $page,
            ],
            true
        );
    }

    // ══════════════════════════════════════════════════════════════════════════
    // EMAIL HOOKS
    // ══════════════════════════════════════════════════════════════════════════

    /**
     * Hook: titancart_payment_processed
     * Fires from PaymentsModule::process_payment_for_order() AFTER the gateway's
     * process_payment() returns. This includes both genuine charges (sync gateways
     * like Authorize.net and Square) and intent-creation responses (async gateways
     * like Stripe Card Elements).
     *
     * GATING
     * ------
     * Only sends emails when the gateway reports success. Skips for declined
     * payments and for any non-success response. This is the core fix — declined
     * customers no longer receive "Order Confirmed" emails (S115 carry-forward
     * from S113).
     *
     * STRIPE INLINE EDGE CASE
     * -----------------------
     * For Stripe Card Elements, $result['success']=true means the PaymentIntent
     * was created — the actual charge happens client-side via stripe.confirmCardPayment().
     * If the customer's card later declines client-side, they will have already
     * received a confirmation email. Acceptable trade-off vs the previous bug
     * where ALL declined customers got confirmation emails. A future
     * `titancart_order_committed` event fired from CheckoutManager::finalize_order()
     * would close this gap precisely.
     *
     * @param array $result      { success, transaction_id, message, payment_data }
     * @param int   $order_id    Order ID.
     * @param array $full_order  Full order row merged with cart totals.
     */
    public function on_payment_processed( array $result, int $order_id, array $full_order ): void {
        // Skip emails when payment failed.
        if ( empty( $result['success'] ) ) {
            return;
        }

        // KI-05 fix: async gateways (Stripe Card Elements) return success=true
        // at PaymentIntent CREATION — success here means "intent created", and
        // payment_data.requires_action signals the card is confirmed
        // client-side afterward and may still decline. Sending the confirmation
        // + admin notification now is premature (the bug: declined customers
        // received "Order Confirmed" emails). Defer to the real payment-
        // confirmed event instead: titancart_stripe_payment_intent_succeeded →
        // on_async_payment_confirmed(). Sync gateways never set requires_action,
        // so they continue to email here exactly as before.
        if ( ! empty( $result['payment_data']['requires_action'] ) ) {
            return;
        }

        // Re-load from DB so we get the fully hydrated order (with status join).
        $order = $this->order_manager->get_order( $order_id );
        if ( ! $order ) {
            return;
        }

        $items = $order['items'] ?? [];

        // Admin notification.
        $this->mailer->send_new_order_admin( $order, $items );

        // Customer confirmation.
        $this->mailer->send_order_confirmation( $order, $items );
    }

    /**
     * Hook: titancart_stripe_payment_intent_succeeded
     *
     * KI-05 fix. For async gateways (Stripe Card Elements) on_payment_processed
     * intentionally skips emails because success=true there only means the
     * PaymentIntent was CREATED, not that the card cleared. This handler fires
     * once the PaymentIntent actually succeeds (the order has advanced to
     * 'processing'), which is the correct moment to send the customer
     * confirmation + admin notification.
     *
     * Idempotent: gated on the tc_order_confirm_sent transient so a duplicate
     * Stripe webhook delivery (at-least-once) cannot double-send.
     *
     * @param array $intent    The Stripe PaymentIntent object (unused).
     * @param array $txn       The matching tc_payment_transactions row (unused).
     * @param int   $order_id  The order's primary key.
     */
    public function on_async_payment_confirmed( array $intent, array $txn, int $order_id ): void {
        if ( $order_id <= 0 ) {
            return;
        }

        // Already handled by a sync path or a prior webhook delivery — bail
        // before sending either email.
        if ( get_transient( 'titancart_order_confirm_sent_' . $order_id ) ) {
            return;
        }

        $order = $this->order_manager->get_order( $order_id );
        if ( ! $order ) {
            return;
        }

        $items = $order['items'] ?? [];

        // Admin notification.
        $this->mailer->send_new_order_admin( $order, $items );

        // Customer confirmation (sets the tc_order_confirm_sent transient).
        $this->mailer->send_order_confirmation( $order, $items );
    }

    /**
     * Hook: titancart_order_status_changed
     * Fires from OrderManager::update_status() after a status transition.
     *
     * @param int    $order_id
     * @param string $new_handle
     * @param string $old_handle
     * @param bool   $notify      Whether the caller requested customer notification.
     */
    public function on_status_changed( int $order_id, string $new_handle, string $old_handle, bool $notify ): void {
        // KI-05 follow-up: the first pending -> processing transition is the exact
        // moment the order-confirmation email is sent (on_async_payment_confirmed
        // for async gateways, on_payment_processed for sync), and that email
        // already tells the customer their order has been received and is being
        // processed. Sending a separate "Status: Processing" update on the SAME
        // transition emails the customer twice at once. Suppress the status-update
        // for this one transition only; every later transition (shipped, completed,
        // cancelled, refunded, on-hold, ...) still notifies. Filterable so a site
        // can opt back into the old double-send.
        if ( 'pending' === $old_handle && 'processing' === $new_handle
            && ! apply_filters( 'titancart_status_update_email_on_initial_processing', false, $order_id, $new_handle, $old_handle ) ) {
            return;
        }

        // Only email the customer if the new status has notify_customer = 1
        // OR if the caller explicitly requested notification.
        $status_row = titancart_db()->getRow(
            "SELECT notify_customer FROM {$this->order_manager_db_table('order_statuses')}
             WHERE handle = ?s LIMIT 1",
            $new_handle
        );

        $should_notify = $notify || ( isset( $status_row['notify_customer'] ) && (bool) $status_row['notify_customer'] );

        if ( ! $should_notify ) {
            return;
        }

        $order = $this->order_manager->get_order( $order_id );
        if ( ! $order ) {
            return;
        }

        $this->mailer->send_status_update( $order, $old_handle );
    }

    /**
     * Hook: titancart_order_status_changed (priority 8).
     *
     * Restore-only inventory adjustment. When an order transitions INTO a status
     * whose stock_action is 'restore' (cancelled/refunded by default, admin-
     * configurable per status), return the committed line-item quantities to
     * product stock.
     *
     * KI-16 reconciliation — three invariants keep stock single-counted:
     *   1. RESTORE ONLY. Stock is decremented once at payment commit
     *      (CheckoutManager::place_order / finalize_order), NOT per status, so we
     *      never re-decrement here — a status-driven 'reduce' would double-count.
     *   2. FIRE ONCE, ONLY IF TAKEN. tc_orders.stock_state is 'reduced' exactly
     *      between the commit decrement and the first restore. 'none' (order never
     *      committed → no stock taken) and 'restored' (already returned) are both
     *      no-ops, so cancelling an unpaid order restores nothing and re-entering
     *      a restore status can't restore twice.
     *   3. NET OUT RMA. RmaManager::restore_stock() already returns items to stock
     *      when a return completes, so we subtract completed-RMA quantities per
     *      product — a partially-returned order that is later fully refunded
     *      restores only the units that weren't already given back.
     *
     * @param int    $order_id
     * @param string $new_handle
     * @param string $old_handle
     * @param bool   $notify
     */
    public function maybe_restore_stock( int $order_id, string $new_handle, string $old_handle, bool $notify ): void {
        $db = titancart_db();

        // (1) Only act when the destination status is configured to restore.
        $action = $db->getField(
            "SELECT stock_action FROM {$this->order_manager_db_table('order_statuses')}
             WHERE handle = ?s LIMIT 1",
            $new_handle
        );
        if ( $action !== 'restore' ) {
            return;
        }

        // (2) Only restore stock that was actually taken, and only once.
        $order = $db->getRow(
            "SELECT id, stock_state FROM {$this->order_manager_db_table('orders')}
             WHERE id = ?i AND deleted_at IS NULL LIMIT 1",
            $order_id
        );
        if ( ! $order || ( $order['stock_state'] ?? 'none' ) !== 'reduced' ) {
            return;
        }

        // Committed quantity per product. Gift-card lines (product_id = 0) carry
        // no stock and are excluded.
        $ordered = $db->getArray(
            "SELECT product_id, SUM(quantity) AS qty
             FROM {$this->order_manager_db_table('order_items')}
             WHERE order_id = ?i AND product_id > 0
             GROUP BY product_id",
            $order_id
        );

        $restored_units = 0;

        if ( $ordered ) {
            // (3) Quantities already returned to stock by COMPLETED RMA returns.
            $rma_rows = $db->getArray(
                "SELECT ri.product_id AS product_id, SUM(ri.quantity) AS qty
                 FROM {$this->order_manager_db_table('return_items')} ri
                 INNER JOIN {$this->order_manager_db_table('returns')} r ON r.id = ri.return_id
                 WHERE r.order_id = ?i AND r.status = 'completed'
                 GROUP BY ri.product_id",
                $order_id
            );
            $already = [];
            foreach ( $rma_rows as $row ) {
                $already[ (int) $row['product_id'] ] = (int) $row['qty'];
            }

            $repo = new ProductRepository();
            foreach ( $ordered as $row ) {
                $pid = (int) $row['product_id'];
                $net = (int) $row['qty'] - ( $already[ $pid ] ?? 0 );
                if ( $net > 0 ) {
                    // update_stock() filters per-product on track_inventory = 1,
                    // recomputes stock_status, and emits the low-stock log — the
                    // same primitive the commit decrement uses.
                    $repo->update_stock( $pid, $net );
                    $restored_units += $net;
                }
            }
        }

        // Close the marker so a later restore-status transition is a no-op.
        // Set unconditionally: even when net is 0 (e.g. the order was already
        // fully returned via completed RMAs), the committed stock has now been
        // fully accounted for, so re-evaluation on a later restoring status
        // should be a no-op.
        $db->update( 'orders', [ 'stock_state' => 'restored' ], [ 'id' => $order_id ] );

        // Only note an actual restore. A net of 0 (fully RMA-returned, or only
        // untracked/gift-card lines) moved no stock, so the "stock returned" note
        // would be misleading.
        if ( $restored_units > 0 ) {
            $this->order_manager->add_note(
                $order_id,
                __( 'Stock returned to inventory (order entered a stock-restoring status).', 'titancart-lite' ),
                'system'
            );
        }
    }

    // ══════════════════════════════════════════════════════════════════════════
    // ACTIVATION
    // ══════════════════════════════════════════════════════════════════════════

    /**
     * Seed default order statuses once. Skips if already populated.
     */
    public function maybe_seed_statuses(): void {
        // Use a transient so we only run the COUNT query occasionally.
        if ( get_transient( 'titancart_statuses_seeded' ) ) {
            return;
        }

        $count = (int) titancart_db()->getField(
            "SELECT COUNT(*) FROM {$this->order_manager_db_table('order_statuses')}"
        );

        if ( $count === 0 ) {
            $this->order_manager->seed_default_statuses();
        }

        set_transient( 'titancart_statuses_seeded', 1, DAY_IN_SECONDS );
    }

    // ══════════════════════════════════════════════════════════════════════════
    // HELPERS
    // ══════════════════════════════════════════════════════════════════════════

    /**
     * Convenience: fully prefixed table name via titancart_db().
     *
     * @param string $name  Short table name.
     * @return string
     */
    private function order_manager_db_table( string $name ): string {
        return titancart_db()->table( $name );
    }

    // ══════════════════════════════════════════════════════════════════════════
    // BOOTSTRAP
    // ══════════════════════════════════════════════════════════════════════════

    public static function init(): void {
        new self();
    }
}
