<?php
namespace TitanCart\REST\Downloadables;

defined( 'ABSPATH' ) || exit;

/**
 * DownloadableController — REST API for the Downloadable Products feature.
 *
 * Phase 2 status (S130):
 *   - All admin routes are LIVE (file CRUD + agreement CRUD + per-order
 *     downloads view + manual revoke + admin test-download).
 *
 * Phase 3 status (S131):
 *   - Customer-facing routes LIVE: token streaming, My Account list,
 *     per-order list, in-app stream button.
 *
 * Phase 4 status (S134 — this layer):
 *   - Email integration LIVE. resend-downloads-email replaces the previous
 *     501 stub; calls Manager::reissue_keys_for_order() which fires
 *     titancart_downloadable_keys_issued with context='admin_resend' →
 *     Mailer listener → standalone "Downloads Ready" email. Inline embed
 *     in order-confirmation.php is wired through the Mailer's static
 *     stash for sync-gateway captures.
 *
 * Phase 5 status (S138 — this layer):
 *   - Agreement gating LIVE. get_product_agreement resolves per-product →
 *     global default fallback and returns the agreement plus a sha256 hash
 *     of the current content. accept_agreement records the binding
 *     acceptance row with full chargeback evidence (customer/email,
 *     order/order_item refs, IP, UA, server-canonical content hash).
 *     PDP awareness gate runs in cart.js; checkout-side binding gate
 *     runs in CheckoutManager::place_order.
 *
 * Phase mapping:
 *   Phase 2 (Admin UI):
 *     POST   /admin/products/{id}/files           Upload a file
 *     GET    /admin/products/{id}/files           List files (with stats)
 *     PUT    /admin/products/{id}/files/{fid}     Update file metadata (JSON only)
 *     POST   /admin/products/{id}/files/{fid}/replace  Replace file/preview (multipart)
 *     DELETE /admin/products/{id}/files/{fid}     Delete file (disk + DB row)
 *     PUT    /admin/products/{id}/files/reorder   Bulk position update
 *     GET    /admin/products/{id}/files/{fid}/test-download  Admin-only test stream
 *     GET    /admin/agreements                    List agreements
 *     POST   /admin/agreements                    Create agreement
 *     GET    /admin/agreements/{id}               Single agreement (with version history)
 *     PUT    /admin/agreements/{id}               Edit (creates new version)
 *     DELETE /admin/agreements/{id}               Archive
 *     GET    /admin/orders/{id}/downloads         Per-order keys + acceptances
 *     POST   /admin/keys/{id}/revoke              Manual revoke
 *
 *   Phase 3 (Customer UI + streaming):
 *     GET    /downloads/{token}                          Stream the file (token = auth)
 *     GET    /account/downloads                          Customer's My Downloads list
 *     GET    /account/downloads/order/{id}               Per-order download view (logged-in owner)
 *     GET    /account/downloads/key/{key_id}/stream      In-app download button (logged-in only)
 *
 *   Phase 4 (Email):
 *     POST   /admin/orders/{id}/resend-downloads-email
 *
 *   Phase 5 (Gating):
 *     POST   /agreements/accept              Frontend records acceptance
 *     GET    /products/{id}/agreement        Resolve which agreement applies
 *
 * @package TitanCart\REST\Downloadables
 */

use WP_REST_Server;
use WP_REST_Request;
use WP_REST_Response;
use WP_Error;
use TitanCart\Modules\Downloadables\DownloadableManager;
use TitanCart\Modules\Downloadables\DownloadableRepository;
use TitanCart\Modules\Downloadables\DownloadableUploadService;

class DownloadableController {

    private string $namespace = 'titancart/v1';

    /** Lazily instantiated. */
    private ?DownloadableManager $manager = null;

    /** Lazily instantiated. */
    private ?DownloadableRepository $repo = null;

    /* ──────────────────────────────────────────────────────────────────────────
     * Route registration
     * ────────────────────────────────────────────────────────────────────────── */

    public function register_routes(): void {

        // ── PUBLIC: Phase 3 streaming endpoint (token = auth) ──────────────────
        // No auth required — the token IS the auth. validate_key() in the
        // service layer rejects revoked / expired / quota-exhausted tokens.
        register_rest_route( $this->namespace, '/downloads/(?P<token>[a-f0-9]+)', [
            [
                'methods'             => WP_REST_Server::READABLE,
                'callback'            => [ $this, 'download_by_token' ],
                'permission_callback' => '__return_true', // Public by design: the unguessable download token IS the credential — expiry/limit/ownership verified inside the handler.
                'args'                => [ 'token' => [ 'type' => 'string', 'required' => true ] ],
            ],
        ] );

        // ── CUSTOMER: Phase 3 — list current customer's download keys ──────────
        // Auth: must be a logged-in WP user with a tc_customers row.
        register_rest_route( $this->namespace, '/account/downloads', [
            [
                'methods'             => WP_REST_Server::READABLE,
                'callback'            => [ $this, 'list_customer_downloads' ],
                'permission_callback' => [ $this, 'logged_in_only' ],
                'args'                => [
                    'include_inactive' => [ 'type' => 'integer', 'default' => 1 ],
                ],
            ],
        ] );

        // ── CUSTOMER: Phase 3 — keys for a specific order ──────────────────────
        // Auth: logged-in customer who OWNS the order. WP.org R2 item 3: the
        // FULL auth model — login AND order ownership — lives in the
        // permission_callback (customer_owns_order) so the registration itself
        // shows it. Ownership mismatch returns 404 (not 403) so foreign order
        // ids are not disclosed. The vestigial `email` guest arg was removed in
        // R1; guests use the signed /downloads/{token} URLs from their order
        // email. The handler keeps equivalent checks as defense-in-depth for
        // direct (non-REST-dispatched) calls.
        register_rest_route( $this->namespace, '/account/downloads/order/(?P<order_id>[\d]+)', [
            [
                'methods'             => WP_REST_Server::READABLE,
                'callback'            => [ $this, 'list_order_downloads_customer' ],
                'permission_callback' => [ $this, 'customer_owns_order' ],
                'args'                => [
                    'order_id' => [ 'type' => 'integer', 'required' => true ],
                ],
            ],
        ] );

        // ── CUSTOMER: Phase 3 — in-app stream for the My Account download button
        // Auth: logged-in WP user whose tc_customers.id matches key.customer_id.
        // Email-only users (guest checkouts) MUST use the /downloads/{token}
        // URL from their order email; this endpoint is logged-in only.
        register_rest_route( $this->namespace, '/account/downloads/key/(?P<key_id>[\d]+)/stream', [
            [
                'methods'             => WP_REST_Server::READABLE,
                'callback'            => [ $this, 'stream_in_app' ],
                'permission_callback' => [ $this, 'logged_in_only' ],
                'args'                => [
                    'key_id' => [ 'type' => 'integer', 'required' => true ],
                ],
            ],
        ] );

        // ── PUBLIC: Phase 5 agreement resolution + acceptance ──────────────────
        // GET /products/{id}/agreement returns the applicable agreement for a
        // downloadable product (per-product override → global default fallback).
        // Returns { agreement: null } when no agreement applies — callers should
        // NOT show a gate in that case. Public, no nonce — read-only.
        register_rest_route( $this->namespace, '/products/(?P<id>[\d]+)/agreement', [
            [
                'methods'             => WP_REST_Server::READABLE,
                'callback'            => [ $this, 'get_product_agreement' ],
                'permission_callback' => '__return_true', // Public by design: anonymous storefront read — the license agreement text shown on the public product page.
                'args'                => [
                    'id'            => [ 'type' => 'integer', 'required' => true ],
                    'language_code' => [ 'type' => 'string' ],
                ],
            ],
        ] );

        // POST /agreements/accept records a customer's acceptance with full
        // chargeback evidence — sha256 hash of the EXACT text we serve at
        // record-time (server canonical; we don't trust the client's hash for
        // the stored value, only for tamper-detection logging). Public + nonce.
        register_rest_route( $this->namespace, '/agreements/accept', [
            [
                'methods'             => WP_REST_Server::CREATABLE,
                'callback'            => [ $this, 'accept_agreement' ],
                'permission_callback' => [ $this, 'public_with_nonce' ],
                'args'                => [
                    'agreement_id'  => [ 'type' => 'integer', 'required' => true ],
                    'content_hash'  => [ 'type' => 'string' ],
                    'email'         => [ 'type' => 'string' ],
                    'order_id'      => [ 'type' => 'integer' ],
                    'order_item_id' => [ 'type' => 'integer' ],
                ],
            ],
        ] );

        // ── ADMIN: file management on a product ─────────────────────────────────
        register_rest_route( $this->namespace, '/admin/products/(?P<id>[\d]+)/files', [
            [
                'methods'             => WP_REST_Server::READABLE,
                'callback'            => [ $this, 'list_files' ],
                'permission_callback' => [ $this, 'admin_only' ],
                'args'                => [ 'id' => [ 'type' => 'integer', 'required' => true ] ],
            ],
            [
                'methods'             => WP_REST_Server::CREATABLE,
                'callback'            => [ $this, 'create_file' ],
                'permission_callback' => [ $this, 'admin_only' ],
                'args'                => [ 'id' => [ 'type' => 'integer', 'required' => true ] ],
            ],
        ] );

        // The /reorder route MUST be registered BEFORE the {file_id} route so it
        // doesn't get captured by the (?P<file_id>[\d]+) match. WordPress's
        // rest dispatcher matches in registration order on tie.
        register_rest_route( $this->namespace, '/admin/products/(?P<id>[\d]+)/files/reorder', [
            [
                'methods'             => WP_REST_Server::EDITABLE,
                'callback'            => [ $this, 'reorder_files' ],
                'permission_callback' => [ $this, 'admin_only' ],
            ],
        ] );

        register_rest_route( $this->namespace, '/admin/products/(?P<id>[\d]+)/files/(?P<file_id>[\d]+)', [
            [
                'methods'             => WP_REST_Server::EDITABLE,
                'callback'            => [ $this, 'update_file' ],
                'permission_callback' => [ $this, 'admin_only' ],
            ],
            [
                'methods'             => WP_REST_Server::DELETABLE,
                'callback'            => [ $this, 'delete_file' ],
                'permission_callback' => [ $this, 'admin_only' ],
            ],
        ] );

        // Replace the underlying file on an existing file row. Separate POST
        // route because PHP does NOT populate $_FILES on PUT requests, only POST.
        register_rest_route( $this->namespace, '/admin/products/(?P<id>[\d]+)/files/(?P<file_id>[\d]+)/replace', [
            [
                'methods'             => WP_REST_Server::CREATABLE,
                'callback'            => [ $this, 'replace_file' ],
                'permission_callback' => [ $this, 'admin_only' ],
            ],
        ] );

        register_rest_route( $this->namespace, '/admin/products/(?P<id>[\d]+)/files/(?P<file_id>[\d]+)/test-download', [
            [
                'methods'             => WP_REST_Server::READABLE,
                'callback'            => [ $this, 'test_download_file' ],
                'permission_callback' => [ $this, 'admin_only' ],
            ],
        ] );

        // ── ADMIN: agreements ───────────────────────────────────────────────────
        register_rest_route( $this->namespace, '/admin/agreements', [
            [
                'methods'             => WP_REST_Server::READABLE,
                'callback'            => [ $this, 'list_agreements' ],
                'permission_callback' => [ $this, 'admin_only' ],
            ],
            [
                'methods'             => WP_REST_Server::CREATABLE,
                'callback'            => [ $this, 'create_agreement' ],
                'permission_callback' => [ $this, 'admin_only' ],
            ],
        ] );

        register_rest_route( $this->namespace, '/admin/agreements/(?P<id>[\d]+)', [
            [
                'methods'             => WP_REST_Server::READABLE,
                'callback'            => [ $this, 'get_agreement' ],
                'permission_callback' => [ $this, 'admin_only' ],
            ],
            [
                'methods'             => WP_REST_Server::EDITABLE,
                'callback'            => [ $this, 'update_agreement' ],
                'permission_callback' => [ $this, 'admin_only' ],
            ],
            [
                'methods'             => WP_REST_Server::DELETABLE,
                'callback'            => [ $this, 'archive_agreement' ],
                'permission_callback' => [ $this, 'admin_only' ],
            ],
        ] );

        // ── ADMIN: per-order download management ────────────────────────────────
        register_rest_route( $this->namespace, '/admin/orders/(?P<id>[\d]+)/downloads', [
            [
                'methods'             => WP_REST_Server::READABLE,
                'callback'            => [ $this, 'get_order_downloads' ],
                'permission_callback' => [ $this, 'admin_only' ],
            ],
        ] );

        register_rest_route( $this->namespace, '/admin/keys/(?P<id>[\d]+)/revoke', [
            [
                'methods'             => WP_REST_Server::CREATABLE,
                'callback'            => [ $this, 'revoke_key' ],
                'permission_callback' => [ $this, 'admin_only' ],
            ],
        ] );

        register_rest_route( $this->namespace, '/admin/orders/(?P<id>[\d]+)/resend-downloads-email', [
            [
                'methods'             => WP_REST_Server::CREATABLE,
                'callback'            => [ $this, 'resend_downloads_email' ],
                'permission_callback' => [ $this, 'admin_only' ],
            ],
        ] );
    }

    // ══════════════════════════════════════════════════════════════════════════
    // ADMIN — FILES
    // ══════════════════════════════════════════════════════════════════════════

    /**
     * GET /admin/products/{id}/files
     *
     * Returns ALL files for the product (active + disabled), each row joined
     * to rollup stats from tc_downloadable_keys.
     */
    public function list_files( WP_REST_Request $request ) {
        $product_id = (int) $request->get_param( 'id' );
        if ( $product_id <= 0 ) {
            return $this->error_response( 'tc_invalid_product', __( 'Invalid product id.', 'titancart-lite' ), 400 );
        }
        $files = $this->get_repo()->get_files_for_product_admin( $product_id );
        return new WP_REST_Response( [ 'files' => array_map( [ $this, 'shape_file_row' ], $files ) ], 200 );
    }

    /**
     * POST /admin/products/{id}/files
     *
     * Multipart upload. Expects:
     *   $_FILES['file']     — required
     *   $_FILES['preview']  — optional preview/sample file
     *   POST['variation_id']  — int|null|"" (empty/null = all variations)
     *   POST['readme']        — optional admin notes
     *   POST['position']      — int sort order
     *   POST['status']        — 'active' | 'disabled' (default 'active')
     */
    public function create_file( WP_REST_Request $request ) {
        $product_id = (int) $request->get_param( 'id' );
        if ( $product_id <= 0 ) {
            return $this->error_response( 'tc_invalid_product', __( 'Invalid product id.', 'titancart-lite' ), 400 );
        }

        // phpcs:disable WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- REST route: auth is the permission_callback + X-WP-Nonce cookie check; $_FILES members are sanitized inside normalize_files_entry() (#631 seam).
        $file = isset( $_FILES['file'] ) ? $this->normalize_files_entry( $_FILES['file'] ) : null;
        // phpcs:enable
        if ( ! $file ) {
            return $this->error_response( 'tc_no_file', __( 'No file was uploaded.', 'titancart-lite' ), 400 );
        }

        $upload = DownloadableUploadService::handle_upload( $file );
        if ( is_wp_error( $upload ) ) {
            return $this->error_response_from_wp_error( $upload );
        }

        // Optional preview file.
        $preview_path = null;
        // phpcs:disable WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- see upload_file(): REST permission_callback + normalize_files_entry() seam.
        if ( isset( $_FILES['preview'] ) ) {
            $preview = $this->normalize_files_entry( $_FILES['preview'] );
            // phpcs:enable
            if ( $preview && ! empty( $preview['name'] ) ) {
                $preview_upload = DownloadableUploadService::handle_upload( $preview );
                if ( is_wp_error( $preview_upload ) ) {
                    // Roll back the main file upload to avoid an orphaned download.
                    DownloadableUploadService::delete_file( $upload['file_path'] );
                    return $this->error_response_from_wp_error( $preview_upload );
                }
                $preview_path = $preview_upload['file_path'];
            }
        }

        $variation_id = $request->get_param( 'variation_id' );
        $variation_id = ( $variation_id === null || $variation_id === '' || (int) $variation_id <= 0 ) ? null : (int) $variation_id;

        $readme   = (string) ( $request->get_param( 'readme' ) ?? '' );
        $position = (int) ( $request->get_param( 'position' ) ?? 0 );
        $status   = $this->sanitize_file_status( $request->get_param( 'status' ) );

        $new_id = $this->get_repo()->create_file( [
            'product_id'    => $product_id,
            'variation_id'  => $variation_id,
            'filename'      => $upload['filename'],
            'original_name' => $upload['original_name'],
            'file_path'     => $upload['file_path'],
            'file_size'     => $upload['file_size'],
            'mime_type'     => $upload['mime_type'],
            'preview_path'  => $preview_path,
            'readme'        => $readme !== '' ? $readme : null,
            'position'      => $position,
            'status'        => $status,
        ] );

        if ( ! $new_id ) {
            // Insert failed — clean up disk so we don't leak files.
            DownloadableUploadService::delete_file( $upload['file_path'] );
            if ( $preview_path ) {
                DownloadableUploadService::delete_file( $preview_path );
            }
            return $this->error_response( 'tc_dl_db_insert_failed', __( 'Could not save the file record.', 'titancart-lite' ), 500 );
        }

        $row = $this->get_repo()->get_file( $new_id );
        return new WP_REST_Response( [ 'file' => $this->shape_file_row( $row ) ], 201 );
    }

    /**
     * PUT /admin/products/{id}/files/{file_id}
     *
     * Metadata-only update. Accepts a JSON body with any of:
     *   { variation_id, readme, position, status, original_name,
     *     preview_path: null }   ← null clears the preview file
     *
     * For replacing the underlying file or preview asset, use the dedicated
     * POST /admin/products/{id}/files/{file_id}/replace endpoint — PHP does
     * NOT populate $_FILES on PUT requests, so multipart can't be handled
     * here.
     */
    public function update_file( WP_REST_Request $request ) {
        $product_id = (int) $request->get_param( 'id' );
        $file_id    = (int) $request->get_param( 'file_id' );
        if ( $product_id <= 0 || $file_id <= 0 ) {
            return $this->error_response( 'tc_invalid_ids', __( 'Invalid product or file id.', 'titancart-lite' ), 400 );
        }

        $row = $this->get_repo()->get_file( $file_id );
        if ( ! $row || (int) $row['product_id'] !== $product_id ) {
            return $this->error_response( 'tc_file_not_found', __( 'File not found.', 'titancart-lite' ), 404 );
        }

        $body = $request->get_json_params();
        if ( ! is_array( $body ) ) {
            return $this->error_response( 'tc_invalid_body', __( 'Body must be a JSON object.', 'titancart-lite' ), 400 );
        }

        $patch = [];

        foreach ( [ 'readme', 'original_name' ] as $string_key ) {
            if ( array_key_exists( $string_key, $body ) ) {
                $patch[ $string_key ] = $body[ $string_key ] === null ? null : (string) $body[ $string_key ];
            }
        }
        if ( array_key_exists( 'variation_id', $body ) ) {
            $vid = $body['variation_id'];
            $patch['variation_id'] = ( $vid === null || $vid === '' || (int) $vid <= 0 ) ? null : (int) $vid;
        }
        if ( array_key_exists( 'position', $body ) ) {
            $patch['position'] = (int) $body['position'];
        }
        if ( array_key_exists( 'status', $body ) ) {
            $patch['status'] = $this->sanitize_file_status( $body['status'] );
        }
        if ( array_key_exists( 'preview_path', $body ) && $body['preview_path'] === null ) {
            // Clear-preview signal — only honored for explicit JSON null.
            $existing = (string) ( $row['preview_path'] ?? '' );
            if ( $existing !== '' ) {
                DownloadableUploadService::delete_file( $existing );
            }
            $patch['preview_path'] = null;
        }

        if ( ! $patch ) {
            return $this->error_response( 'tc_no_changes', __( 'No fields to update.', 'titancart-lite' ), 400 );
        }

        $this->get_repo()->update_file( $file_id, $patch );
        $updated = $this->get_repo()->get_file( $file_id );
        return new WP_REST_Response( [ 'file' => $this->shape_file_row( $updated ) ], 200 );
    }

    /**
     * POST /admin/products/{id}/files/{file_id}/replace
     *
     * Multipart upload to replace the underlying file (and/or preview file)
     * on an existing file row. Preserves file_id so all issued keys keep
     * working — only the on-disk path + filename + size + mime change.
     *
     * Multipart fields (any combination):
     *   $_FILES['file']     — replaces the main file
     *   $_FILES['preview']  — replaces (or creates) the preview file
     */
    public function replace_file( WP_REST_Request $request ) {
        $product_id = (int) $request->get_param( 'id' );
        $file_id    = (int) $request->get_param( 'file_id' );
        if ( $product_id <= 0 || $file_id <= 0 ) {
            return $this->error_response( 'tc_invalid_ids', __( 'Invalid product or file id.', 'titancart-lite' ), 400 );
        }
        $row = $this->get_repo()->get_file( $file_id );
        if ( ! $row || (int) $row['product_id'] !== $product_id ) {
            return $this->error_response( 'tc_file_not_found', __( 'File not found.', 'titancart-lite' ), 404 );
        }

        $patch = [];

        // Replace main file (optional).
        // phpcs:disable WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- REST permission_callback + normalize_files_entry() seam (#631).
        if ( isset( $_FILES['file'] ) ) {
            $new_file = $this->normalize_files_entry( $_FILES['file'] );
            // phpcs:enable
            if ( $new_file && ! empty( $new_file['name'] ) ) {
                $upload = DownloadableUploadService::replace_file( (string) $row['file_path'], $new_file );
                if ( is_wp_error( $upload ) ) {
                    return $this->error_response_from_wp_error( $upload );
                }
                $patch['filename']      = $upload['filename'];
                $patch['original_name'] = $upload['original_name'];
                $patch['file_path']     = $upload['file_path'];
                $patch['file_size']     = $upload['file_size'];
                $patch['mime_type']     = $upload['mime_type'];
            }
        }

        // Replace preview file (optional).
        // phpcs:disable WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- REST permission_callback + normalize_files_entry() seam (#631).
        if ( isset( $_FILES['preview'] ) ) {
            $new_preview = $this->normalize_files_entry( $_FILES['preview'] );
            // phpcs:enable
            if ( $new_preview && ! empty( $new_preview['name'] ) ) {
                $existing = (string) ( $row['preview_path'] ?? '' );
                $upload   = $existing
                    ? DownloadableUploadService::replace_file( $existing, $new_preview )
                    : DownloadableUploadService::handle_upload( $new_preview );
                if ( is_wp_error( $upload ) ) {
                    return $this->error_response_from_wp_error( $upload );
                }
                $patch['preview_path'] = $upload['file_path'];
            }
        }

        if ( ! $patch ) {
            return $this->error_response( 'tc_no_file', __( 'No replacement file was uploaded.', 'titancart-lite' ), 400 );
        }

        $this->get_repo()->update_file( $file_id, $patch );
        $updated = $this->get_repo()->get_file( $file_id );
        return new WP_REST_Response( [ 'file' => $this->shape_file_row( $updated ) ], 200 );
    }

    /**
     * DELETE /admin/products/{id}/files/{file_id}
     *
     * Removes the file from disk AND deletes the DB row. Existing keys
     * pointing to this file are left in place but will fail validation
     * with `tc_dl_file_unavailable` (file missing) on next attempt — admins
     * can manually revoke them from the order detail panel if desired.
     */
    public function delete_file( WP_REST_Request $request ) {
        $product_id = (int) $request->get_param( 'id' );
        $file_id    = (int) $request->get_param( 'file_id' );
        if ( $product_id <= 0 || $file_id <= 0 ) {
            return $this->error_response( 'tc_invalid_ids', __( 'Invalid product or file id.', 'titancart-lite' ), 400 );
        }
        $row = $this->get_repo()->get_file( $file_id );
        if ( ! $row || (int) $row['product_id'] !== $product_id ) {
            return $this->error_response( 'tc_file_not_found', __( 'File not found.', 'titancart-lite' ), 404 );
        }

        // Disk first — a row pointing to a missing file is recoverable; a file
        // on disk with no row is leaked storage.
        DownloadableUploadService::delete_file( (string) $row['file_path'] );
        if ( ! empty( $row['preview_path'] ) ) {
            DownloadableUploadService::delete_file( (string) $row['preview_path'] );
        }
        $this->get_repo()->delete_file( $file_id );

        return new WP_REST_Response( [ 'deleted' => true, 'file_id' => $file_id ], 200 );
    }

    /**
     * PUT /admin/products/{id}/files/reorder
     *
     * Body: { "order": [file_id1, file_id2, ...] }
     * Only files belonging to this product are reordered; unknown ids are ignored.
     */
    public function reorder_files( WP_REST_Request $request ) {
        $product_id = (int) $request->get_param( 'id' );
        if ( $product_id <= 0 ) {
            return $this->error_response( 'tc_invalid_product', __( 'Invalid product id.', 'titancart-lite' ), 400 );
        }

        $body  = $request->get_json_params();
        $order = ( is_array( $body ) && isset( $body['order'] ) && is_array( $body['order'] ) ) ? $body['order'] : null;
        if ( ! $order ) {
            return $this->error_response( 'tc_invalid_body', __( 'Body must include an "order" array of file ids.', 'titancart-lite' ), 400 );
        }

        // Build set of valid file ids for this product (defense against
        // cross-product reorder).
        $rows         = $this->get_repo()->get_files_for_product_admin( $product_id );
        $valid_ids    = array_column( $rows, 'id' );
        $valid_lookup = array_flip( array_map( 'intval', $valid_ids ) );

        $position = 0;
        $updated  = 0;
        foreach ( $order as $fid ) {
            $fid = (int) $fid;
            if ( ! isset( $valid_lookup[ $fid ] ) ) {
                continue;
            }
            $this->get_repo()->update_file( $fid, [ 'position' => $position ] );
            $position++;
            $updated++;
        }

        return new WP_REST_Response( [ 'reordered' => $updated ], 200 );
    }

    /**
     * GET /admin/products/{id}/files/{file_id}/test-download
     *
     * Admin-only direct stream — bypasses key validation entirely so the
     * admin can verify a file uploaded correctly without going through a
     * real purchase. Permission is gated by manage_options on the route.
     *
     * NEVER returns — stream_file() exits the request.
     */
    public function test_download_file( WP_REST_Request $request ) {
        $product_id = (int) $request->get_param( 'id' );
        $file_id    = (int) $request->get_param( 'file_id' );
        if ( $product_id <= 0 || $file_id <= 0 ) {
            return $this->error_response( 'tc_invalid_ids', __( 'Invalid product or file id.', 'titancart-lite' ), 400 );
        }
        $row = $this->get_repo()->get_file( $file_id );
        if ( ! $row || (int) $row['product_id'] !== $product_id ) {
            return $this->error_response( 'tc_file_not_found', __( 'File not found.', 'titancart-lite' ), 404 );
        }

        DownloadableUploadService::stream_file(
            (string) $row['file_path'],
            (string) ( $row['original_name'] ?: $row['filename'] ),
            (string) $row['mime_type']
        );
        exit; // belt-and-suspenders — stream_file already exits.
    }

    // ══════════════════════════════════════════════════════════════════════════
    // ADMIN — AGREEMENTS
    // ══════════════════════════════════════════════════════════════════════════

    /**
     * GET /admin/agreements
     *
     * Query params: include_archived (0|1), product_id, is_global, language_code.
     */
    public function list_agreements( WP_REST_Request $request ) {
        $filters = [
            'include_archived' => (bool) $request->get_param( 'include_archived' ),
            'product_id'       => null,
            'is_global'        => null,
            'language_code'    => $request->get_param( 'language_code' ) ?: null,
        ];
        $pid = $request->get_param( 'product_id' );
        if ( $pid !== null && $pid !== '' ) {
            $filters['product_id'] = (int) $pid;
        }
        $ig = $request->get_param( 'is_global' );
        if ( $ig !== null && $ig !== '' ) {
            $filters['is_global'] = (int) $ig ? 1 : 0;
        }

        $rows = $this->get_repo()->list_agreements_admin( $filters );
        return new WP_REST_Response( [
            'agreements' => array_map( [ $this, 'shape_agreement_row' ], $rows ),
        ], 200 );
    }

    /**
     * POST /admin/agreements
     *
     * Body: { is_global, product_id|null, title, content, language_code,
     *         required, status }
     *
     * Always creates a NEW row (version=1 if no prior version for this scope,
     * else max_version+1). Editing an existing agreement uses PUT.
     */
    public function create_agreement( WP_REST_Request $request ) {
        $body = $request->get_json_params();
        if ( ! is_array( $body ) ) {
            return $this->error_response( 'tc_invalid_body', __( 'Body must be a JSON object.', 'titancart-lite' ), 400 );
        }

        $is_global     = ! empty( $body['is_global'] ) ? 1 : 0;
        $product_id    = $is_global ? null : ( isset( $body['product_id'] ) ? (int) $body['product_id'] : 0 );
        $title         = trim( (string) ( $body['title'] ?? '' ) );
        $content       = (string) ( $body['content'] ?? '' );
        $language_code = strtolower( substr( (string) ( $body['language_code'] ?? 'en' ), 0, 2 ) );
        $required      = isset( $body['required'] ) ? ( ! empty( $body['required'] ) ? 1 : 0 ) : 1;
        $status        = $this->sanitize_agreement_status( $body['status'] ?? 'active' );

        if ( $title === '' ) {
            return $this->error_response( 'tc_missing_title', __( 'Agreement title is required.', 'titancart-lite' ), 400 );
        }
        if ( $content === '' ) {
            return $this->error_response( 'tc_missing_content', __( 'Agreement content is required.', 'titancart-lite' ), 400 );
        }
        if ( ! $is_global && ( ! $product_id || $product_id <= 0 ) ) {
            return $this->error_response( 'tc_missing_product', __( 'Product-specific agreements require a product id.', 'titancart-lite' ), 400 );
        }

        $repo        = $this->get_repo();
        $max_version = $repo->get_max_version_for_scope( $is_global ? null : $product_id, (bool) $is_global, $language_code );

        $new_id = $repo->create_agreement( [
            'product_id'    => $is_global ? null : $product_id,
            'is_global'     => $is_global,
            'version'       => $max_version + 1,
            'title'         => $title,
            'content'       => $content,
            'language_code' => $language_code,
            'required'      => $required,
            'status'        => $status,
        ] );

        if ( ! $new_id ) {
            return $this->error_response( 'tc_dl_db_insert_failed', __( 'Could not save the agreement.', 'titancart-lite' ), 500 );
        }

        $row = $repo->get_agreement( $new_id );
        return new WP_REST_Response( [ 'agreement' => $this->shape_agreement_row( $row ) ], 201 );
    }

    /**
     * GET /admin/agreements/{id}
     *
     * Returns the requested agreement plus its full version history within
     * the same scope (global or product-specific).
     */
    public function get_agreement( WP_REST_Request $request ) {
        $id  = (int) $request->get_param( 'id' );
        if ( $id <= 0 ) {
            return $this->error_response( 'tc_invalid_id', __( 'Invalid agreement id.', 'titancart-lite' ), 400 );
        }
        $row = $this->get_repo()->get_agreement( $id );
        if ( ! $row ) {
            return $this->error_response( 'tc_not_found', __( 'Agreement not found.', 'titancart-lite' ), 404 );
        }

        $history = $this->get_repo()->get_version_history(
            $row['product_id'] !== null ? (int) $row['product_id'] : null,
            (bool) $row['is_global'],
            (string) ( $row['language_code'] ?: 'en' )
        );

        return new WP_REST_Response( [
            'agreement' => $this->shape_agreement_row( $row ),
            'history'   => array_map( [ $this, 'shape_agreement_row' ], $history ),
        ], 200 );
    }

    /**
     * PUT /admin/agreements/{id}
     *
     * Editing an agreement creates a NEW row with version+1 in the same scope
     * and archives the existing row. Acceptances tied to the old version stay
     * valid against that snapshot. Body fields: title, content, required, status.
     */
    public function update_agreement( WP_REST_Request $request ) {
        $id = (int) $request->get_param( 'id' );
        if ( $id <= 0 ) {
            return $this->error_response( 'tc_invalid_id', __( 'Invalid agreement id.', 'titancart-lite' ), 400 );
        }
        $existing = $this->get_repo()->get_agreement( $id );
        if ( ! $existing ) {
            return $this->error_response( 'tc_not_found', __( 'Agreement not found.', 'titancart-lite' ), 404 );
        }

        $body = $request->get_json_params();
        if ( ! is_array( $body ) ) {
            return $this->error_response( 'tc_invalid_body', __( 'Body must be a JSON object.', 'titancart-lite' ), 400 );
        }

        $title    = isset( $body['title'] )    ? trim( (string) $body['title'] )    : (string) $existing['title'];
        $content  = isset( $body['content'] )  ? (string) $body['content']           : (string) $existing['content'];
        $required = isset( $body['required'] ) ? ( ! empty( $body['required'] ) ? 1 : 0 ) : (int) $existing['required'];
        $status   = isset( $body['status'] )   ? $this->sanitize_agreement_status( $body['status'] ) : (string) $existing['status'];

        if ( $title === '' ) {
            return $this->error_response( 'tc_missing_title', __( 'Agreement title is required.', 'titancart-lite' ), 400 );
        }
        if ( $content === '' ) {
            return $this->error_response( 'tc_missing_content', __( 'Agreement content is required.', 'titancart-lite' ), 400 );
        }

        $repo          = $this->get_repo();
        $is_global     = (bool) $existing['is_global'];
        $product_id    = $existing['product_id'] !== null ? (int) $existing['product_id'] : null;
        $language_code = (string) ( $existing['language_code'] ?: 'en' );
        $max_version   = $repo->get_max_version_for_scope( $product_id, $is_global, $language_code );

        $new_id = $repo->create_agreement( [
            'product_id'    => $product_id,
            'is_global'     => $is_global ? 1 : 0,
            'version'       => $max_version + 1,
            'title'         => $title,
            'content'       => $content,
            'language_code' => $language_code,
            'required'      => $required,
            'status'        => $status,
        ] );
        if ( ! $new_id ) {
            return $this->error_response( 'tc_dl_db_insert_failed', __( 'Could not save the new agreement version.', 'titancart-lite' ), 500 );
        }

        // Archive the row that was edited (kept readable for chargeback evidence).
        if ( $existing['status'] === 'active' ) {
            $repo->archive_agreement( $id );
        }

        $new_row = $repo->get_agreement( $new_id );
        $history = $repo->get_version_history( $product_id, $is_global, $language_code );

        return new WP_REST_Response( [
            'agreement' => $this->shape_agreement_row( $new_row ),
            'history'   => array_map( [ $this, 'shape_agreement_row' ], $history ),
        ], 200 );
    }

    /**
     * DELETE /admin/agreements/{id}
     *
     * Soft delete — sets status='archived'. Existing acceptances stay valid.
     */
    public function archive_agreement( WP_REST_Request $request ) {
        $id = (int) $request->get_param( 'id' );
        if ( $id <= 0 ) {
            return $this->error_response( 'tc_invalid_id', __( 'Invalid agreement id.', 'titancart-lite' ), 400 );
        }
        $row = $this->get_repo()->get_agreement( $id );
        if ( ! $row ) {
            return $this->error_response( 'tc_not_found', __( 'Agreement not found.', 'titancart-lite' ), 404 );
        }
        $this->get_repo()->archive_agreement( $id );
        return new WP_REST_Response( [ 'archived' => true, 'agreement_id' => $id ], 200 );
    }

    // ══════════════════════════════════════════════════════════════════════════
    // ADMIN — PER-ORDER + PER-KEY
    // ══════════════════════════════════════════════════════════════════════════

    /**
     * GET /admin/orders/{id}/downloads
     *
     * Returns the keys + acceptances for an order, joined to file/product
     * info. Used by the order detail Downloads panel + chargeback evidence
     * panel.
     */
    public function get_order_downloads( WP_REST_Request $request ) {
        $order_id = (int) $request->get_param( 'id' );
        if ( $order_id <= 0 ) {
            return $this->error_response( 'tc_invalid_order', __( 'Invalid order id.', 'titancart-lite' ), 400 );
        }
        $repo = $this->get_repo();
        $keys = $repo->get_keys_for_order_admin( $order_id );
        $acc  = $repo->get_acceptances_for_order_admin( $order_id );

        return new WP_REST_Response( [
            'keys'        => array_map( [ $this, 'shape_admin_key_row' ], $keys ),
            'acceptances' => array_map( [ $this, 'shape_acceptance_row' ], $acc ),
        ], 200 );
    }

    /**
     * POST /admin/keys/{id}/revoke
     *
     * Body (optional): { "reason": "..." }
     */
    public function revoke_key( WP_REST_Request $request ) {
        $key_id = (int) $request->get_param( 'id' );
        if ( $key_id <= 0 ) {
            return $this->error_response( 'tc_invalid_key', __( 'Invalid key id.', 'titancart-lite' ), 400 );
        }
        $body   = $request->get_json_params();
        $reason = is_array( $body ) && isset( $body['reason'] ) ? (string) $body['reason'] : 'admin_manual_revoke';

        $ok = $this->get_manager()->revoke_key( $key_id, $reason, true );
        if ( ! $ok ) {
            return $this->error_response( 'tc_revoke_failed', __( 'Key could not be revoked (not found, or already inactive).', 'titancart-lite' ), 409 );
        }
        return new WP_REST_Response( [ 'revoked' => true, 'key_id' => $key_id ], 200 );
    }

    // ══════════════════════════════════════════════════════════════════════════
    // CUSTOMER (Phase 3)
    // ══════════════════════════════════════════════════════════════════════════

    /**
     * GET /downloads/{token}
     *
     * Public endpoint — the token IS the auth. validate_key() checks revoked /
     * expired / quota status and returns a WP_Error with the right HTTP slug.
     * On success, the response is the raw file bytes; this method exits the
     * request before WP REST has a chance to serialize a JSON envelope.
     */
    public function download_by_token( WP_REST_Request $request ) {
        $token = (string) $request->get_param( 'token' );
        if ( $token === '' || ! ctype_xdigit( $token ) ) {
            return $this->error_response( 'tc_invalid_token', __( 'Missing or malformed download token.', 'titancart-lite' ), 400 );
        }

        $result = $this->get_manager()->validate_key( $token );
        if ( is_wp_error( $result ) ) {
            return $this->error_response_from_wp_error( $result );
        }

        // stream_validated() exits the request on success. On the file-missing
        // edge case it returns a WP_REST_Response — propagate that, don't
        // overwrite it with a 204.
        return $this->stream_validated( $result['key'], $result['file'] );
    }

    /**
     * GET /account/downloads
     *
     * Returns the current customer's download keys. Joins file metadata +
     * order_items.name (via the repo) so each row is renderable without a
     * second round trip.
     *
     * Query params:
     *   include_inactive  (int, default 1) — include revoked/expired keys.
     *                     Customers usually want to see their full history.
     */
    public function list_customer_downloads( WP_REST_Request $request ) {
        $acct_mgr = new \TitanCart\Modules\Accounts\AccountManager();
        $customer = $acct_mgr->get_current_customer();
        if ( ! $customer ) {
            return $this->error_response( 'tc_no_customer', __( 'No customer record found for the current user.', 'titancart-lite' ), 404 );
        }

        $include_inactive = (bool) (int) $request->get_param( 'include_inactive' );
        $rows = $this->get_repo()->get_keys_for_customer(
            (int) $customer['id'],
            (string) $customer['email'],
            $include_inactive
        );

        $shaped = array_map( [ $this, 'shape_customer_key_row' ], $rows );
        return new WP_REST_Response( [
            'keys'  => $shaped,
            'total' => count( $shaped ),
        ], 200 );
    }

    /**
     * GET /account/downloads/order/{order_id}
     *
     * Per-order keys list. Logged-in owner only — guests must use the token
     * URLs from their order email (Phase 4). WP.org R2 item 3: the full auth
     * model (login + order ownership) lives in the route's permission_callback
     * (customer_owns_order, mirrored error codes); the checks below are kept
     * as defense-in-depth for direct (non-REST-dispatched) calls.
     */
    public function list_order_downloads_customer( WP_REST_Request $request ) {
        if ( ! is_user_logged_in() ) {
            return $this->error_response(
                'tc_login_required',
                __( 'You must be logged in to view order downloads. Guest customers should use the download links emailed at checkout.', 'titancart-lite' ),
                401
            );
        }

        $order_id = (int) $request->get_param( 'order_id' );
        if ( $order_id <= 0 ) {
            return $this->error_response( 'tc_invalid_order', __( 'Invalid order id.', 'titancart-lite' ), 400 );
        }

        $acct_mgr = new \TitanCart\Modules\Accounts\AccountManager();
        $customer = $acct_mgr->get_current_customer();
        if ( ! $customer ) {
            return $this->error_response( 'tc_no_customer', __( 'No customer record found for the current user.', 'titancart-lite' ), 404 );
        }

        $om    = new \TitanCart\Modules\Orders\OrderManager();
        $order = $om->get_order( $order_id );
        if ( ! $order || (int) ( $order['customer_id'] ?? 0 ) !== (int) $customer['id'] ) {
            // 404 not 403 — don't disclose that the order exists but belongs to someone else.
            return $this->error_response( 'tc_order_not_found', __( 'Order not found.', 'titancart-lite' ), 404 );
        }

        $rows   = $this->get_repo()->get_keys_for_order_admin( $order_id );
        $shaped = array_map( [ $this, 'shape_customer_key_row' ], $rows );
        return new WP_REST_Response( [
            'order_id' => $order_id,
            'keys'     => $shaped,
            'total'    => count( $shaped ),
        ], 200 );
    }

    /**
     * GET /account/downloads/key/{key_id}/stream
     *
     * In-app stream button for the My Account → Downloads page. Looks up the
     * key by id (no raw-token round trip), verifies the customer owns it, runs
     * the same self-heal/revoked/expired/quota checks as the public token
     * endpoint, then streams.
     *
     * Auth model: logged-in WP user whose tc_customers.id matches
     * key.customer_id. Email-only keys (guest checkouts) refuse this path —
     * those customers must use the /downloads/{token} URL from email.
     */
    public function stream_in_app( WP_REST_Request $request ) {
        $key_id = (int) $request->get_param( 'key_id' );
        if ( $key_id <= 0 ) {
            return $this->error_response( 'tc_invalid_key', __( 'Invalid key id.', 'titancart-lite' ), 400 );
        }

        $acct_mgr = new \TitanCart\Modules\Accounts\AccountManager();
        $customer = $acct_mgr->get_current_customer();
        if ( ! $customer ) {
            return $this->error_response( 'tc_no_customer', __( 'No customer record found for the current user.', 'titancart-lite' ), 404 );
        }

        // Ownership check first — if the customer doesn't own this key we
        // 404 (not 403) to avoid disclosing that the id exists.
        $raw_key = $this->get_repo()->find_key_by_id( $key_id );
        if ( ! $raw_key ) {
            return $this->error_response( 'tc_dl_not_found', __( 'Download link not found.', 'titancart-lite' ), 404 );
        }
        $owns =
            ( $raw_key['customer_id'] !== null && (int) $raw_key['customer_id'] === (int) $customer['id'] )
            || ( ! empty( $raw_key['email'] ) && strcasecmp( (string) $raw_key['email'], (string) $customer['email'] ) === 0 );
        if ( ! $owns ) {
            return $this->error_response( 'tc_dl_not_found', __( 'Download link not found.', 'titancart-lite' ), 404 );
        }

        // Now run the full validation pipeline (revoked/expired/quota self-heal).
        $result = $this->get_manager()->validate_key_by_id( $key_id );
        if ( is_wp_error( $result ) ) {
            return $this->error_response_from_wp_error( $result );
        }

        // See download_by_token() for the propagate-not-overwrite reasoning.
        return $this->stream_validated( $result['key'], $result['file'] );
    }

    // ══════════════════════════════════════════════════════════════════════════
    // RESPONSE SHAPERS
    // ══════════════════════════════════════════════════════════════════════════

    /**
     * Normalize a tc_downloadable_files row for the API response.
     * Casts numeric DB strings to int and strips NULLs to predictable shapes.
     */
    private function shape_file_row( ?array $row ): ?array {
        if ( ! $row ) {
            return null;
        }
        return [
            'id'              => (int) $row['id'],
            'product_id'      => (int) $row['product_id'],
            'variation_id'    => $row['variation_id'] !== null ? (int) $row['variation_id'] : null,
            'filename'        => (string) $row['filename'],
            'original_name'   => (string) $row['original_name'],
            'file_path'       => (string) $row['file_path'],
            'file_size'       => (int) $row['file_size'],
            'mime_type'       => (string) $row['mime_type'],
            'preview_path'    => $row['preview_path'] ?: null,
            'readme'          => $row['readme'] ?: null,
            'position'        => (int) $row['position'],
            'status'          => (string) $row['status'],
            'created_at'      => (string) $row['created_at'],
            'updated_at'      => (string) ( $row['updated_at'] ?? '' ),
            'keys_issued'     => isset( $row['keys_issued'] ) ? (int) $row['keys_issued'] : 0,
            'keys_active'     => isset( $row['keys_active'] ) ? (int) $row['keys_active'] : 0,
            'downloads_total' => isset( $row['downloads_total'] ) ? (int) $row['downloads_total'] : 0,
        ];
    }

    private function shape_agreement_row( ?array $row ): ?array {
        if ( ! $row ) {
            return null;
        }
        return [
            'id'               => (int) $row['id'],
            'product_id'       => $row['product_id'] !== null ? (int) $row['product_id'] : null,
            'is_global'        => (int) $row['is_global'],
            'version'          => (int) $row['version'],
            'title'            => (string) $row['title'],
            'content'          => (string) $row['content'],
            'language_code'    => (string) $row['language_code'],
            'required'         => (int) $row['required'],
            'status'           => (string) $row['status'],
            'created_at'       => (string) $row['created_at'],
            'updated_at'       => (string) ( $row['updated_at'] ?? '' ),
            'acceptance_count' => isset( $row['acceptance_count'] ) ? (int) $row['acceptance_count'] : 0,
        ];
    }

    private function shape_admin_key_row( array $row ): array {
        return [
            'id'                  => (int) $row['id'],
            'order_id'            => (int) $row['order_id'],
            'order_item_id'       => (int) $row['order_item_id'],
            'product_id'          => (int) $row['product_id'],
            'product_name'        => (string) ( $row['product_name'] ?? '' ),
            'file_id'             => (int) $row['file_id'],
            'file_original_name'  => (string) ( $row['file_original_name'] ?? '' ),
            'file_size'           => isset( $row['file_size'] ) ? (int) $row['file_size'] : 0,
            'file_mime'           => (string) ( $row['file_mime'] ?? '' ),
            'file_status'         => (string) ( $row['file_status'] ?? '' ),
            'customer_id'         => $row['customer_id'] !== null ? (int) $row['customer_id'] : null,
            'email'               => (string) $row['email'],
            'status'              => (string) $row['status'],
            'expires_at'          => $row['expires_at'] ?: null,
            'download_count'      => (int) $row['download_count'],
            'max_downloads'       => $row['max_downloads'] !== null ? (int) $row['max_downloads'] : null,
            'last_ip'             => $row['last_ip'] ?: null,
            'last_downloaded_at'  => $row['last_downloaded_at'] ?: null,
            'created_at'          => (string) $row['created_at'],
        ];
    }

    private function shape_acceptance_row( array $row ): array {
        return [
            'id'                    => (int) $row['id'],
            'agreement_id'          => (int) $row['agreement_id'],
            'agreement_version'     => (int) $row['agreement_version'],
            'agreement_title'       => (string) ( $row['agreement_title'] ?? '' ),
            'is_global'             => isset( $row['is_global'] ) ? (int) $row['is_global'] : 0,
            'agreement_product_id'  => isset( $row['agreement_product_id'] ) && $row['agreement_product_id'] !== null
                                        ? (int) $row['agreement_product_id'] : null,
            'customer_id'           => $row['customer_id'] !== null ? (int) $row['customer_id'] : null,
            'email'                 => (string) $row['email'],
            'order_id'              => $row['order_id'] !== null ? (int) $row['order_id'] : null,
            'order_item_id'         => $row['order_item_id'] !== null ? (int) $row['order_item_id'] : null,
            'ip_address'            => $row['ip_address'] ?: null,
            'user_agent'            => $row['user_agent'] ?: null,
            'accepted_content_hash' => (string) $row['accepted_content_hash'],
            'accepted_at'           => (string) $row['accepted_at'],
        ];
    }

    /**
     * Customer-safe shape for a download key row. Used by both the My Account
     * downloads list and the per-order endpoint.
     *
     * Tolerates two source shapes:
     *   - get_keys_for_customer(): file fields are file_original_name /
     *     file_size / file_mime (after the repo extension), product_name from
     *     the order_items LEFT JOIN.
     *   - get_keys_for_order_admin(): file_original_name / file_size /
     *     file_mime, product_name from order_items.
     *
     * Hides last_ip (privacy — admin-only field).
     */
    private function shape_customer_key_row( array $row ): array {
        return [
            'id'                  => (int) $row['id'],
            'order_id'            => (int) $row['order_id'],
            'order_item_id'       => (int) $row['order_item_id'],
            'product_id'          => (int) $row['product_id'],
            'product_name'        => (string) ( $row['product_name'] ?? '' ),
            'file_id'             => (int) $row['file_id'],
            'file_original_name'  => (string) ( $row['file_original_name'] ?? $row['original_name'] ?? '' ),
            'file_size'           => isset( $row['file_size'] ) ? (int) $row['file_size'] : 0,
            'file_mime'           => (string) ( $row['file_mime'] ?? $row['mime_type'] ?? '' ),
            'status'              => (string) $row['status'],
            'expires_at'          => $row['expires_at'] ?: null,
            'download_count'      => (int) $row['download_count'],
            'max_downloads'       => $row['max_downloads'] !== null ? (int) $row['max_downloads'] : null,
            'last_downloaded_at'  => $row['last_downloaded_at'] ?: null,
            'created_at'          => (string) $row['created_at'],
        ];
    }

    // ══════════════════════════════════════════════════════════════════════════
    // ADMIN — RESEND DOWNLOADS EMAIL (Phase 4)
    // ══════════════════════════════════════════════════════════════════════════

    /**
     * POST /admin/orders/{id}/resend-downloads-email
     *
     * Re-issues download keys for the order and sends a fresh "Downloads Ready"
     * email to the customer. The flow:
     *
     *   1. reissue_keys_for_order() force-revokes any existing active keys for
     *      the order. After this point, links in any older email no longer work.
     *   2. Same call then issues fresh keys with new tokens, fresh expires_at,
     *      download_count=0.
     *   3. Issuance fires titancart_downloadable_keys_issued with
     *      context='admin_resend' — the Mailer listener (wired in OrdersModule)
     *      routes that to send_downloads_ready() which composes and dispatches
     *      the standalone "fresh links" email variant.
     *
     * Email-send success is fire-and-forget — failures are captured in
     * error_log but don't fail this request. The 200 response means the keys
     * were re-issued (verifiable by the new key_hash rows in
     * tc_downloadable_keys); if the customer doesn't receive the email, the
     * admin can re-click.
     *
     * Returns 422 on no-op (order has no downloadable line items) per the
     * Phase 4 design call — surface a misclick rather than silently
     * pretend something happened.
     *
     * @param  WP_REST_Request $request
     * @return WP_REST_Response
     */
    public function resend_downloads_email( WP_REST_Request $request ) {
        $order_id = (int) $request->get_param( 'id' );
        if ( $order_id <= 0 ) {
            return $this->error_response( 'tc_invalid_order', __( 'Invalid order id.', 'titancart-lite' ), 400 );
        }

        // Verify the order exists before any side effects (revoke/issue).
        $order_manager = new \TitanCart\Modules\Orders\OrderManager();
        $order         = $order_manager->get_order( $order_id );
        if ( ! $order ) {
            return $this->error_response( 'tc_order_not_found', __( 'Order not found.', 'titancart-lite' ), 404 );
        }

        // Reissue: revoke active keys + issue fresh, with context='admin_resend'.
        // The Mailer listener picks up the issuance hook and sends the email.
        $issued = $this->get_manager()->reissue_keys_for_order( $order_id, 'admin_resend_email' );
        if ( empty( $issued ) ) {
            return $this->error_response(
                'no_downloadable_items',
                __( 'This order has no downloadable line items to re-issue.', 'titancart-lite' ),
                422
            );
        }

        return new WP_REST_Response( [
            'success'        => true,
            'order_id'       => $order_id,
            'order_number'   => (string) ( $order['order_number'] ?? '' ),
            'customer_email' => (string) ( $order['email'] ?? $order['billing_email'] ?? '' ),
            'keys_issued'    => count( $issued ),
        ], 200 );
    }

    // ══════════════════════════════════════════════════════════════════════════
    // PHASE 5 — AGREEMENT RESOLUTION + ACCEPTANCE
    // ══════════════════════════════════════════════════════════════════════════

    /**
     * GET /products/{id}/agreement
     *
     * Resolve which agreement applies to a downloadable product, honoring the
     * per-product override → global default fallback chain. Returns the agreement
     * row plus a sha256 of its current content so the client can detect tampering
     * (or stale UI) when the customer eventually submits acceptance.
     *
     * Response shape:
     *   { agreement: { id, version, title, content, language_code, required,
     *                  content_hash } | null }
     *
     * Returns { agreement: null } when:
     *   - The product is not downloadable (no gate needed)
     *   - The product has no per-product agreement AND no global default exists
     *   - The applicable agreement has required=0 (informational only — no gate)
     *
     * The product check (is_downloadable=1) keeps the agreement query from
     * spuriously surfacing on non-digital PDPs even if a global default exists.
     */
    public function get_product_agreement( WP_REST_Request $request ) {
        $product_id = (int) $request->get_param( 'id' );
        if ( $product_id <= 0 ) {
            return $this->error_response( 'tc_invalid_product', __( 'Invalid product id.', 'titancart-lite' ), 400 );
        }

        $language_code = strtolower( substr( (string) ( $request->get_param( 'language_code' ) ?? 'en' ), 0, 2 ) );
        if ( $language_code === '' ) {
            $language_code = 'en';
        }

        // Bail out for non-downloadable products — no gate to render. We resolve
        // this through the products repository to keep the controller agnostic
        // of the products table structure.
        $product_repo = new \TitanCart\Modules\Products\ProductRepository();
        $product      = $product_repo->get( $product_id );
        if ( ! $product || empty( $product['is_downloadable'] ) ) {
            return new WP_REST_Response( [ 'agreement' => null ], 200 );
        }

        $agreement = $this->get_manager()->get_applicable_agreement( $product_id, $language_code );
        if ( ! $agreement ) {
            return new WP_REST_Response( [ 'agreement' => null ], 200 );
        }

        // Informational-only agreements (required=0) don't need a gate. Surface
        // them as null so the client renders nothing — admins who want a
        // visible-but-non-blocking notice can use a different field in the future.
        if ( empty( $agreement['required'] ) ) {
            return new WP_REST_Response( [ 'agreement' => null ], 200 );
        }

        return new WP_REST_Response( [
            'agreement' => [
                'id'            => (int) $agreement['id'],
                'version'       => (int) $agreement['version'],
                'title'         => (string) $agreement['title'],
                'content'       => (string) $agreement['content'],
                'language_code' => (string) $agreement['language_code'],
                'required'      => (int) $agreement['required'],
                'content_hash'  => hash( 'sha256', (string) $agreement['content'] ),
            ],
        ], 200 );
    }

    /**
     * POST /agreements/accept
     *
     * Record a customer's acceptance of a download agreement. The handler is
     * tolerant of two call patterns:
     *
     *   1. PDP click — logged-in customer clicks "I agree" before add-to-cart.
     *      Records an acceptance anchored to customer_id (+ WP user email).
     *      No order_id yet — that gets recorded again at place-order time
     *      with the order anchor for chargeback evidence.
     *
     *   2. Checkout submit — server-side path, called from CheckoutManager
     *      after order creation. Records an acceptance anchored to billing
     *      email + order_id + customer_id (if logged in).
     *
     * Anti-tampering:
     *   The client may send `content_hash` (the hash they computed at GET time).
     *   The server recomputes from the current agreement content. If they
     *   diverge, we LOG the divergence but still record the acceptance using
     *   the SERVER's current content — that's the canonical chargeback evidence.
     *   We don't reject because in practice the only divergence cause is admin
     *   editing the agreement between the customer's GET and POST, and we
     *   want the most recent terms in the evidence trail.
     *
     * Idempotency:
     *   Repeated identical POSTs (same agreement+version+customer/email) DO
     *   create duplicate rows for now. The DB has indexes for fast lookup
     *   but no UNIQUE constraint — duplicate rows are inert (still valid
     *   evidence) and we don't want a UNIQUE to silently drop a legitimate
     *   re-acceptance after an agreement edit. has_acceptance() callers should
     *   check by (agreement_id, version, customer/email) and treat any hit
     *   as proof.
     */
    public function accept_agreement( WP_REST_Request $request ) {
        $agreement_id = (int) $request->get_param( 'agreement_id' );
        if ( $agreement_id <= 0 ) {
            return $this->error_response( 'tc_invalid_agreement', __( 'Invalid agreement id.', 'titancart-lite' ), 400 );
        }

        $agreement = $this->get_repo()->get_agreement( $agreement_id );
        if ( ! $agreement ) {
            return $this->error_response( 'tc_agreement_not_found', __( 'Agreement not found.', 'titancart-lite' ), 404 );
        }
        if ( $agreement['status'] !== 'active' ) {
            return $this->error_response( 'tc_agreement_archived', __( 'This agreement is no longer active. Please refresh and try again.', 'titancart-lite' ), 410 );
        }

        // Resolve identity: prefer logged-in customer, fall back to provided email.
        $customer_id = null;
        $email       = trim( (string) ( $request->get_param( 'email' ) ?? '' ) );

        if ( is_user_logged_in() ) {
            $acct_mgr = new \TitanCart\Modules\Accounts\AccountManager();
            $customer = $acct_mgr->get_current_customer();
            if ( $customer ) {
                $customer_id = (int) $customer['id'];
                if ( $email === '' ) {
                    $email = (string) $customer['email'];
                }
            }
        }

        if ( $email === '' ) {
            // For guests we need at least an email to anchor the acceptance to.
            // The PDP path doesn't have one yet — the JS gate is enough at PDP
            // time; CheckoutManager records the binding acceptance once
            // billing.email is collected.
            return $this->error_response(
                'tc_missing_identity',
                __( 'Cannot record acceptance without a customer email. Please complete checkout to bind the acceptance.', 'titancart-lite' ),
                400
            );
        }

        // Optional tamper-detection log. The server's current content always
        // wins for the stored hash — we're recording WHAT we showed at this
        // moment, not what the client claims they saw earlier.
        $client_hash = (string) ( $request->get_param( 'content_hash' ) ?? '' );
        $server_hash = hash( 'sha256', (string) $agreement['content'] );
        if ( $client_hash !== '' && $client_hash !== $server_hash ) {
            // Most common cause: admin edited the agreement between PDP view
            // and accept click. The acceptance still binds to the CURRENT
            // text, which is what the customer would see if they refreshed.
            titancart_log( sprintf(
                'TitanCart Phase5: agreement %d hash drift on accept (client=%s server=%s)',
                $agreement_id,
                substr( $client_hash, 0, 12 ),
                substr( $server_hash, 0, 12 )
            ) );
        }

        $order_id      = (int) ( $request->get_param( 'order_id' ) ?? 0 );
        $order_item_id = (int) ( $request->get_param( 'order_item_id' ) ?? 0 );

        $ip = titancart_client_ip() ?: null; // WP.org R1 🅱2: validated capture.
        $ua = isset( $_SERVER['HTTP_USER_AGENT'] )  ? substr( sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ), 0, 255 ) : null; // WP.org R1 🅱2: sanitize before cap.

        $acceptance_id = $this->get_manager()->record_acceptance(
            (int) $agreement['id'],
            (int) $agreement['version'],
            (string) $agreement['content'],
            $customer_id,
            $email,
            $order_id > 0 ? $order_id : null,
            $order_item_id > 0 ? $order_item_id : null,
            $ip,
            $ua
        );

        if ( ! $acceptance_id ) {
            return $this->error_response( 'tc_acceptance_failed', __( 'Could not record acceptance.', 'titancart-lite' ), 500 );
        }

        return new WP_REST_Response( [
            'acceptance_id'     => $acceptance_id,
            'agreement_id'      => (int) $agreement['id'],
            'agreement_version' => (int) $agreement['version'],
        ], 201 );
    }

    // ══════════════════════════════════════════════════════════════════════════
    // PERMISSION CALLBACKS
    // ══════════════════════════════════════════════════════════════════════════

    public function admin_only() {
        if ( current_user_can( 'manage_options' ) ) {
            return true;
        }
        return new WP_Error(
            'tc_forbidden',
            __( 'You do not have permission to access this endpoint.', 'titancart-lite' ),
            [ 'status' => 403 ]
        );
    }

    public function public_with_nonce() {
        $nonce = isset( $_SERVER['HTTP_X_WP_NONCE'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_X_WP_NONCE'] ) ) : ''; // WP.org R1 🅱2: sanitize nonce input.
        if ( $nonce && wp_verify_nonce( $nonce, 'wp_rest' ) ) {
            return true;
        }
        return new WP_Error(
            'tc_forbidden',
            __( 'Invalid or missing security token.', 'titancart-lite' ),
            [ 'status' => 403 ]
        );
    }

    /**
     * Permission gate for the Phase 3 customer-side endpoints. Requires only
     * that a WP user is logged in — ownership checks against tc_customers.id
     * happen inside each handler.
     *
     * Returns 401 (not 403) so a fetch() in the My Account page can redirect
     * to login on a session timeout.
     */
    public function logged_in_only() {
        if ( is_user_logged_in() ) {
            return true;
        }
        return new WP_Error(
            'tc_login_required',
            __( 'You must be logged in to access this endpoint.', 'titancart-lite' ),
            [ 'status' => 401 ]
        );
    }

    /**
     * Permission gate for /account/downloads/order/{order_id} (WP.org R2 item
     * 3): the FULL auth model in one visible place — logged-in WP user whose
     * tc_customers row owns the requested order.
     *
     * Error semantics mirror the handler exactly so SPA behavior is unchanged:
     *   - not logged in            → tc_login_required 401 (fetch() redirects to login)
     *   - bad order id             → tc_invalid_order  400
     *   - no customer record       → tc_no_customer    404
     *   - missing OR foreign order → tc_order_not_found 404 (identical response,
     *     so authenticated users cannot enumerate which order ids exist)
     *
     * @param  WP_REST_Request $request
     * @return true|WP_Error
     */
    public function customer_owns_order( WP_REST_Request $request ) {
        if ( ! is_user_logged_in() ) {
            return new WP_Error(
                'tc_login_required',
                __( 'You must be logged in to view order downloads. Guest customers should use the download links emailed at checkout.', 'titancart-lite' ),
                [ 'status' => 401 ]
            );
        }

        $order_id = (int) $request->get_param( 'order_id' );
        if ( $order_id <= 0 ) {
            return new WP_Error( 'tc_invalid_order', __( 'Invalid order id.', 'titancart-lite' ), [ 'status' => 400 ] );
        }

        $acct_mgr = new \TitanCart\Modules\Accounts\AccountManager();
        $customer = $acct_mgr->get_current_customer();
        if ( ! $customer ) {
            return new WP_Error( 'tc_no_customer', __( 'No customer record found for the current user.', 'titancart-lite' ), [ 'status' => 404 ] );
        }

        $om    = new \TitanCart\Modules\Orders\OrderManager();
        $order = $om->get_order( $order_id );
        if ( ! $order || (int) ( $order['customer_id'] ?? 0 ) !== (int) $customer['id'] ) {
            // 404 not 403 — don't disclose that the order exists but belongs to someone else.
            return new WP_Error( 'tc_order_not_found', __( 'Order not found.', 'titancart-lite' ), [ 'status' => 404 ] );
        }

        return true;
    }

    // ══════════════════════════════════════════════════════════════════════════
    // INTERNAL HELPERS
    // ══════════════════════════════════════════════════════════════════════════

    private function get_manager(): DownloadableManager {
        if ( $this->manager === null ) {
            $this->manager = new DownloadableManager();
        }
        return $this->manager;
    }

    private function get_repo(): DownloadableRepository {
        if ( $this->repo === null ) {
            $this->repo = new DownloadableRepository();
        }
        return $this->repo;
    }

    /**
     * Sanitize a file status string from a request param. Defaults to 'active'.
     */
    private function sanitize_file_status( $value ): string {
        $value = is_string( $value ) ? strtolower( $value ) : '';
        return in_array( $value, [ 'active', 'disabled' ], true ) ? $value : 'active';
    }

    /**
     * Sanitize an agreement status string. Defaults to 'active'.
     */
    private function sanitize_agreement_status( $value ): string {
        $value = is_string( $value ) ? strtolower( $value ) : '';
        return in_array( $value, [ 'active', 'archived' ], true ) ? $value : 'active';
    }

    /**
     * Normalize a $_FILES entry — guards against array-form uploads (multiple
     * files under the same name) and missing keys.
     *
     * @param  mixed $entry  Whatever sat under $_FILES['key'].
     * @return array|null
     */
    private function normalize_files_entry( $entry ): ?array {
        if ( ! is_array( $entry ) ) {
            return null;
        }
        // WP.org R2 item 5 (#631 seam rule): sanitize the client-supplied members
        // (name/type) here so every caller inherits it; tmp_name is PHP-generated
        // and validated downstream by wp_handle_upload()'s is_uploaded_file check.
        $clean = static function ( $name, $tmp, $size, $type, $error ): array {
            return [
                'name'     => sanitize_file_name( (string) $name ),
                'tmp_name' => (string) $tmp,
                'size'     => (int) $size,
                'type'     => sanitize_mime_type( (string) $type ),
                'error'    => (int) $error,
            ];
        };
        // Single-file form: each value is a scalar.
        if ( isset( $entry['name'] ) && ! is_array( $entry['name'] ) ) {
            return $clean(
                $entry['name'],
                $entry['tmp_name'] ?? '',
                $entry['size']     ?? 0,
                $entry['type']     ?? '',
                $entry['error']    ?? UPLOAD_ERR_NO_FILE
            );
        }
        // Multi-file form (array per key) — take the first.
        if ( isset( $entry['name'] ) && is_array( $entry['name'] ) ) {
            return $clean(
                $entry['name'][0]     ?? '',
                $entry['tmp_name'][0] ?? '',
                $entry['size'][0]     ?? 0,
                $entry['type'][0]     ?? '',
                $entry['error'][0]    ?? UPLOAD_ERR_NO_FILE
            );
        }
        return null;
    }

    /**
     * Build a uniform error response.
     */
    private function error_response( string $code, string $message, int $status = 400 ): WP_REST_Response {
        return new WP_REST_Response( [ 'code' => $code, 'message' => $message ], $status );
    }

    /**
     * Convert a WP_Error into a uniform response.
     */
    private function error_response_from_wp_error( WP_Error $error ): WP_REST_Response {
        $data   = $error->get_error_data();
        $status = is_array( $data ) && isset( $data['status'] ) ? (int) $data['status'] : 400;
        return new WP_REST_Response( [
            'code'    => $error->get_error_code(),
            'message' => $error->get_error_message(),
        ], $status );
    }

    /**
     * Shared streaming path for both the public token endpoint and the in-app
     * stream button. Caller has already run validate_key()/validate_key_by_id()
     * and confirmed the key is usable + the file row is healthy.
     *
     * Order of operations (critical):
     *   1. Resolve absolute path (returns null on traversal attempt or missing file)
     *   2. record_download() — increment counter BEFORE streaming. stream_file()
     *      exits the request so anything after it is unreachable. We accept the
     *      tradeoff: a download counted even on partial network failure beats
     *      no download counted on success.
     *   3. stream_file() — sends the bytes and exits.
     *
     * If the file is missing on disk (admin deleted/moved it after a key was
     * issued), this method does NOT exit — it returns a 404 that the caller
     * propagates as a JSON response. That gives the customer a readable error
     * instead of a hung request.
     *
     * @param  array $key   Key row from the repo.
     * @param  array $file  File row from the repo.
     * @return WP_REST_Response|void  void on successful stream (request exits).
     */
    private function stream_validated( array $key, array $file ) {
        $absolute = DownloadableUploadService::get_absolute_path( (string) $file['file_path'] );
        if ( ! $absolute || ! is_readable( $absolute ) ) {
            // File row points at something that isn't on disk anymore. Don't
            // increment the counter — this is a server-side problem, not a
            // legitimate download.
            return $this->error_response(
                'tc_file_missing',
                __( 'The file is no longer available on the server. Please contact support.', 'titancart-lite' ),
                404
            );
        }

        $ip = titancart_client_ip(); // WP.org R1 🅱2: validated capture.
        $ua = isset( $_SERVER['HTTP_USER_AGENT'] ) ? substr( sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ), 0, 512 ) : ''; // WP.org R1 🅱2: sanitize before cap.

        // Increment counter BEFORE streaming — stream_file() exits the request.
        $this->get_manager()->record_download(
            (int) $key['id'],
            (int) ( $file['file_size'] ?? 0 ),
            $ip,
            $ua
        );

        DownloadableUploadService::stream_file(
            (string) $file['file_path'],
            (string) ( $file['original_name'] ?: $file['filename'] ),
            (string) $file['mime_type']
        );
        exit; // belt-and-suspenders — stream_file already exits.
    }
}
