<?php
/**
 * DownloadableManager — service layer for the Downloadables module.
 *
 * Holds all the "what should happen when" logic for downloadable products.
 * The DownloadableRepository handles raw DB access; this class handles state
 * transitions, key generation, integrity checks, and hook firing.
 *
 * Phase 1 surface:
 *   issue_keys_for_order()       Called when an order transitions to a paid state.
 *   reissue_keys_for_order()     S133 Phase 4 — admin resend: revoke active + issue fresh.
 *   validate_key()               Resolves a raw token to a (key, file) pair if usable.
 *   validate_key_by_id()         Same checks as validate_key() but resolves by key_id (S131 Phase 3).
 *   record_download()            Increments counter + writes a log row.
 *   revoke_keys_for_order()      Called on refund/cancel when the setting allows.
 *   revoke_keys_for_order_item() Partial-refund variant.
 *   expire_keys()                Cron — flips active keys past expires_at to expired.
 *   get_applicable_agreement()   Per-product override → global default fallback.
 *   record_acceptance()          Writes a chargeback-evidence row.
 *   has_accepted()               Has user/email already accepted a given agreement+version?
 *   get_acceptances_for_order()  Lookup helper for the admin order detail page.
 *
 * Most of these methods are unused by the UI in Phase 1 — the hooks fire,
 * keys get written, but nothing renders them yet. Phases 2-5 wire the UI.
 * As of S133, Phase 4 (email integration) is wired through Mailer.
 *
 * @package TitanCart\Modules\Downloadables
 */

namespace TitanCart\Modules\Downloadables;

use TitanCart\Services\SettingsService;

defined( 'ABSPATH' ) || exit;

class DownloadableManager {

    private DownloadableRepository $repo;

    /** Length of the raw token (in hex chars). 64 chars = 256 bits of entropy. */
    private const TOKEN_LENGTH = 64;

    /** How many days of log rows to keep before pruning. */
    private const LOG_RETENTION_DAYS = 365;

    public function __construct( ?DownloadableRepository $repo = null ) {
        $this->repo = $repo ?: new DownloadableRepository();
    }

    // ══════════════════════════════════════════════════════════════════════════
    // KEY ISSUANCE
    // ══════════════════════════════════════════════════════════════════════════

    /**
     * Generate keys for every downloadable file in the order.
     *
     * Idempotent — if keys already exist for a given (order_item × file) pair,
     * we don't issue a duplicate. This means a status transition like
     * pending → processing → completed will issue keys exactly once.
     *
     * Returns the array of NEW raw tokens keyed by key_id, keyed for use by the
     * email layer (Phase 4). The raw tokens are returned ONCE and never stored.
     *
     * The optional $context argument disambiguates the call site for downstream
     * listeners (chiefly the Mailer). Two values are recognised today:
     *
     *   'order_paid'   (default) — fired from on_order_status_changed when the
     *                              order transitions to a PAID status. Mailer
     *                              uses this signal to decide between inline
     *                              embed (sync gateway, place_order request)
     *                              and standalone send (async gateway, capture
     *                              request).
     *   'admin_resend'           — fired from reissue_keys_for_order during the
     *                              admin "Resend downloads email" flow. Mailer
     *                              always sends standalone in this case.
     *
     * @param  int    $order_id
     * @param  string $context  Disambiguator for the keys-issued action.
     * @return array<int, array{token: string, key_id: int, file_id: int, order_item_id: int}>
     */
    public function issue_keys_for_order( int $order_id, string $context = 'order_paid' ): array {
        $db = titancart_db();

        // Fetch order + items + product flags. The interesting field for our
        // purposes is `is_downloadable` on each line item's product — it stays
        // the canonical "this product is digital" flag in Phase 1. Mixed-content
        // logic comes in Phase 5.
        //
        // Note: orders.customer_id references tc_customers.id (NOT wp_users.ID);
        // it's nullable for guest checkouts. The downloadable_keys.customer_id
        // column follows the same convention — TitanCart's customer convention
        // throughout (orders, wishlists, etc.).
        $items = $db->getArray(
            "SELECT oi.id           AS order_item_id,
                    oi.order_id     AS order_id,
                    oi.product_id   AS product_id,
                    oi.variation_id AS variation_id,
                    o.customer_id   AS customer_id,
                    o.billing_email AS email,
                    p.is_downloadable AS is_downloadable
             FROM {$db->table('order_items')} oi
             LEFT JOIN {$db->table('orders')}   o ON o.id = oi.order_id
             LEFT JOIN {$db->table('products')} p ON p.id = oi.product_id
             WHERE oi.order_id = ?i",
            $order_id
        );

        $issued = [];

        foreach ( $items as $item ) {
            if ( empty( $item['is_downloadable'] ) ) {
                continue;
            }

            $product_id   = (int) $item['product_id'];
            $variation_id = $item['variation_id'] ? (int) $item['variation_id'] : null;
            $files        = $this->repo->get_files_for_product( $product_id, $variation_id );

            if ( ! $files ) {
                continue;
            }

            // Idempotency check: only ACTIVE existing keys block re-issuance.
            // Revoked / expired keys are tombstones — they should not prevent
            // a fresh key from being issued for the same (order_item × file)
            // pair. This matters for two flows:
            //   1. Admin resend (Phase 4) — calls revoke + issue, expects fresh.
            //   2. Order un-refund — admin reverses a refund; revoked keys
            //      shouldn't block the re-flip-to-paid handler.
            // Phase 1's broader "skip if any row exists" was over-eager.
            $existing_keys = $this->repo->get_keys_for_order_item( (int) $item['order_item_id'] );
            $active_file_ids = array_values( array_unique( array_map(
                fn( $k ) => (int) $k['file_id'],
                array_filter( $existing_keys, fn( $k ) => ( $k['status'] ?? '' ) === 'active' )
            ) ) );

            foreach ( $files as $file ) {
                $file_id = (int) $file['id'];

                // Idempotency: skip if an ACTIVE key already exists for this pair.
                if ( in_array( $file_id, $active_file_ids, true ) ) {
                    continue;
                }

                $token = $this->generate_token();
                $hash  = $this->hash_token( $token );

                $expires_at = $this->calculate_expiry( $product_id );
                $max_dl     = $this->resolve_max_downloads( $product_id );

                $key_id = $this->repo->create_key( [
                    'key_hash'      => $hash,
                    'order_id'      => $order_id,
                    'order_item_id' => (int) $item['order_item_id'],
                    'product_id'    => $product_id,
                    'file_id'       => $file_id,
                    'customer_id'   => $item['customer_id'] ? (int) $item['customer_id'] : null,
                    'email'         => (string) ( $item['email'] ?? '' ),
                    'status'        => 'active',
                    'expires_at'    => $expires_at,
                    'max_downloads' => $max_dl,
                ] );

                if ( $key_id ) {
                    $this->repo->log_event( [
                        'key_id' => $key_id,
                        'event'  => 'issued',
                        'meta'   => [ 'order_id' => $order_id, 'file_id' => $file_id ],
                    ] );

                    $issued[ $key_id ] = [
                        'token'         => $token,
                        'key_id'        => $key_id,
                        'file_id'       => $file_id,
                        'order_item_id' => (int) $item['order_item_id'],
                    ];
                }
            }
        }

        if ( $issued ) {
            /**
             * Fires after one or more download keys have been issued for an order.
             * Phase 4 hooks this to (a) stash the hydrated payload for the
             * order-confirmation email to embed inline (sync gateway path), and
             * (b) send a standalone "Downloads Ready" email when the order
             * confirmation has already gone out (async gateway / resend paths).
             *
             * The $context arg disambiguates the call site:
             *   'order_paid'   — issued because order status flipped to paid.
             *                    Inline-vs-standalone decided by whether the
             *                    confirmation email already went out (transient).
             *   'admin_resend' — explicit admin "Resend downloads email" action.
             *                    Always send standalone.
             *
             * @param int    $order_id
             * @param array  $issued    Map of key_id → { token, key_id, file_id, order_item_id }
             * @param string $context   'order_paid' | 'admin_resend'
             */
            do_action( 'titancart_downloadable_keys_issued', $order_id, $issued, $context );
        }

        return $issued;
    }

    /**
     * Reissue keys for an order — revokes any existing active keys, then issues
     * fresh ones for every downloadable file in the order.
     *
     * Used by the admin "Resend downloads email" flow (Phase 4). The semantic
     * is "send the customer a fresh set of download links," which means:
     *   1. The OLD links in the original email become invalid (revoked).
     *   2. The NEW links are issued with download_count=0 and a fresh expires_at.
     *   3. The titancart_downloadable_keys_issued hook fires, which the Mailer
     *      listener picks up to send the standalone Downloads Ready email.
     *
     * Revocation runs with $force=true so it bypasses the
     * `catalog_revoke_on_refund` setting — admin's intent here is explicit
     * regardless of refund-revocation config.
     *
     * Returns the same shape as issue_keys_for_order(): a key_id → { token, ... }
     * map of newly-issued keys. Empty array if the order has no downloadable
     * line items.
     *
     * @param  int    $order_id
     * @param  string $reason  Logged on the revocation events.
     * @return array<int, array{token: string, key_id: int, file_id: int, order_item_id: int}>
     */
    public function reissue_keys_for_order( int $order_id, string $reason = 'admin_resend_email' ): array {
        // Step 1 — revoke active keys. Force-revoke bypasses the
        // catalog_revoke_on_refund setting; the admin's intent is explicit.
        $this->revoke_keys_for_order( $order_id, $reason, true );

        // Step 2 — issue fresh, tagging the action with 'admin_resend' so the
        // Mailer listener routes to standalone send (regardless of whether the
        // original confirmation email transient is still alive).
        return $this->issue_keys_for_order( $order_id, 'admin_resend' );
    }

    // ══════════════════════════════════════════════════════════════════════════
    // KEY VALIDATION + DOWNLOAD
    // ══════════════════════════════════════════════════════════════════════════

    /**
     * Resolve a raw token to (key, file) if it's usable, or return a WP_Error
     * with a status-code-friendly slug if not.
     *
     * Does NOT increment counters — that's record_download()'s job, called
     * after the file has been streamed successfully.
     *
     * @param  string $raw_token
     * @return array{key: array, file: array}|\WP_Error
     */
    public function validate_key( string $raw_token ) {
        $hash = $this->hash_token( $raw_token );
        $key  = $this->repo->find_key_by_hash( $hash );
        return $this->validate_key_row( $key );
    }

    /**
     * Sibling to validate_key() that resolves by key_id rather than raw token.
     * Used by the My Account in-app download button (Phase 3) where the
     * customer is already authenticated and we already have the key_id from
     * their list view — no raw token round-trip needed.
     *
     * Returns the same shape as validate_key(), runs the same self-heal +
     * revoked/expired/quota checks. Auth (does this customer own this key?)
     * is the caller's responsibility; this method only checks usability.
     *
     * @param  int $key_id
     * @return array{key: array, file: array}|\WP_Error
     */
    public function validate_key_by_id( int $key_id ) {
        $key = $this->repo->find_key_by_id( $key_id );
        return $this->validate_key_row( $key );
    }

    /**
     * Shared validation logic for both validate_key() and validate_key_by_id().
     *
     * @param  array|null $key  The key row, or null when the lookup failed.
     * @return array{key: array, file: array}|\WP_Error
     */
    private function validate_key_row( ?array $key ) {
        if ( ! $key ) {
            return new \WP_Error( 'tc_dl_not_found', __( 'Download link not found.', 'titancart-lite' ), [ 'status' => 404 ] );
        }

        if ( $key['status'] === 'revoked' ) {
            $this->repo->log_event( [ 'key_id' => (int) $key['id'], 'event' => 'failed_auth', 'meta' => [ 'reason' => 'revoked' ] ] );
            return new \WP_Error( 'tc_dl_revoked', __( 'This download link has been revoked.', 'titancart-lite' ), [ 'status' => 410 ] );
        }

        if ( $key['status'] === 'expired' || ( ! empty( $key['expires_at'] ) && strtotime( $key['expires_at'] ) < time() ) ) {
            // Self-heal: if the cron hasn't run yet, mark as expired now.
            if ( $key['status'] === 'active' ) {
                $this->repo->update_key( (int) $key['id'], [ 'status' => 'expired' ] );
                $this->repo->log_event( [ 'key_id' => (int) $key['id'], 'event' => 'expired' ] );
            }
            return new \WP_Error( 'tc_dl_expired', __( 'This download link has expired.', 'titancart-lite' ), [ 'status' => 410 ] );
        }

        if ( $key['max_downloads'] !== null && (int) $key['download_count'] >= (int) $key['max_downloads'] ) {
            $this->repo->log_event( [ 'key_id' => (int) $key['id'], 'event' => 'failed_quota' ] );
            return new \WP_Error( 'tc_dl_quota', __( 'You have reached the download limit for this file.', 'titancart-lite' ), [ 'status' => 429 ] );
        }

        $file = $this->repo->get_file( (int) $key['file_id'] );
        if ( ! $file || $file['status'] !== 'active' ) {
            $this->repo->log_event( [ 'key_id' => (int) $key['id'], 'event' => 'failed_auth', 'meta' => [ 'reason' => 'file_missing_or_disabled' ] ] );
            return new \WP_Error( 'tc_dl_file_unavailable', __( 'This file is no longer available.', 'titancart-lite' ), [ 'status' => 404 ] );
        }

        return [ 'key' => $key, 'file' => $file ];
    }

    /**
     * Called by the streaming endpoint (Phase 3) after the file has been served.
     * Increments the download counter, stamps last_ip / last_downloaded_at,
     * and writes a log row.
     *
     * @param  int    $key_id
     * @param  int    $bytes_served
     * @param  string $ip
     * @param  string $user_agent
     * @return void
     */
    public function record_download( int $key_id, int $bytes_served, string $ip = '', string $user_agent = '' ): void {
        $db = titancart_db();
        // Atomic-ish increment via direct query — avoids a read/modify/write race.
        $db->query(
            "UPDATE {$db->table('downloadable_keys')}
             SET download_count = download_count + 1,
                 last_ip = ?s,
                 last_downloaded_at = ?s
             WHERE id = ?i",
            $ip,
            titancart_now_utc(), // KI-24: UTC, not MySQL-tz NOW()
            $key_id
        );

        $this->repo->log_event( [
            'key_id'       => $key_id,
            'event'        => 'downloaded',
            'ip_address'   => $ip ?: null,
            'user_agent'   => $user_agent ?: null,
            'bytes_served' => $bytes_served,
        ] );
    }

    // ══════════════════════════════════════════════════════════════════════════
    // REVOCATION
    // ══════════════════════════════════════════════════════════════════════════

    /**
     * Revoke every active key tied to an order.
     *
     * Gated by the `catalog_revoke_on_refund` setting — when OFF, this method
     * returns 0 and does nothing. Setting defaults to ON.
     *
     * @param  int    $order_id
     * @param  string $reason  Free-form note logged on each revocation.
     * @param  bool   $force   When true, bypasses the setting check (admin manual-revoke).
     * @return int    Keys revoked.
     */
    public function revoke_keys_for_order( int $order_id, string $reason = 'order_refunded', bool $force = false ): int {
        if ( ! $force && ! SettingsService::bool( 'general', 'catalog_revoke_on_refund', true ) ) {
            return 0;
        }

        $keys = $this->repo->get_keys_for_order( $order_id );
        return $this->revoke_key_set( $keys, $reason );
    }

    /**
     * Partial-refund variant — revoke keys for one order item only.
     *
     * @param  int    $order_item_id
     * @param  string $reason
     * @param  bool   $force
     * @return int
     */
    public function revoke_keys_for_order_item( int $order_item_id, string $reason = 'item_refunded', bool $force = false ): int {
        if ( ! $force && ! SettingsService::bool( 'general', 'catalog_revoke_on_refund', true ) ) {
            return 0;
        }

        $keys = $this->repo->get_keys_for_order_item( $order_item_id );
        return $this->revoke_key_set( $keys, $reason );
    }

    /**
     * Revoke a single key by id. Admin-driven manual revoke from the order
     * detail panel — bypasses the catalog_revoke_on_refund setting by default
     * since the admin has explicitly chosen to kill this specific link.
     *
     * @param  int    $key_id
     * @param  string $reason  Free-form note logged on the revocation event.
     * @param  bool   $force   When true (default), bypasses the setting check.
     * @return bool   True if the key was active and has been revoked.
     */
    public function revoke_key( int $key_id, string $reason = 'admin_manual_revoke', bool $force = true ): bool {
        if ( ! $force && ! SettingsService::bool( 'general', 'catalog_revoke_on_refund', true ) ) {
            return false;
        }

        $key = $this->repo->find_key_by_id( $key_id );
        if ( ! $key || $key['status'] !== 'active' ) {
            return false;
        }

        $count = $this->revoke_key_set( [ $key ], $reason );
        return $count > 0;
    }

    /**
     * Internal: revoke an arbitrary set of key rows (only those still active).
     *
     * @param  array<int, array> $keys
     * @param  string             $reason
     * @return int
     */
    private function revoke_key_set( array $keys, string $reason ): int {
        $count = 0;
        foreach ( $keys as $key ) {
            if ( $key['status'] !== 'active' ) {
                continue;
            }
            if ( $this->repo->update_key( (int) $key['id'], [ 'status' => 'revoked' ] ) ) {
                $this->repo->log_event( [
                    'key_id' => (int) $key['id'],
                    'event'  => 'revoked',
                    'meta'   => [ 'reason' => $reason ],
                ] );
                $count++;
            }
        }
        return $count;
    }

    // ══════════════════════════════════════════════════════════════════════════
    // EXPIRY (CRON)
    // ══════════════════════════════════════════════════════════════════════════

    /**
     * Cron handler — flips active keys past their expires_at to expired.
     * Also prunes log rows older than LOG_RETENTION_DAYS.
     *
     * @return array{expired: int, pruned: int}
     */
    public function expire_keys(): array {
        $expired = 0;
        $candidates = $this->repo->find_keys_due_to_expire( 1000 );
        foreach ( $candidates as $row ) {
            if ( $this->repo->update_key( (int) $row['id'], [ 'status' => 'expired' ] ) ) {
                $this->repo->log_event( [
                    'key_id' => (int) $row['id'],
                    'event'  => 'expired',
                ] );
                $expired++;
            }
        }

        $pruned = $this->repo->prune_log( self::LOG_RETENTION_DAYS );

        return [ 'expired' => $expired, 'pruned' => $pruned ];
    }

    // ══════════════════════════════════════════════════════════════════════════
    // AGREEMENTS
    // ══════════════════════════════════════════════════════════════════════════

    /**
     * Resolve which agreement applies to a given product, honoring the
     * per-product override → global default fallback chain.
     *
     * Returns null when no agreement applies (most common case in v1, since
     * the global agreement defaults to disabled).
     *
     * @param  int    $product_id
     * @param  string $language_code
     * @return array|null
     */
    public function get_applicable_agreement( int $product_id, string $language_code = 'en' ): ?array {
        $product_specific = $this->repo->get_product_agreement( $product_id, $language_code );
        if ( $product_specific ) {
            return $product_specific;
        }
        return $this->repo->get_global_agreement( $language_code );
    }

    /**
     * Record a customer's acceptance of an agreement.
     *
     * The accepted_content_hash is computed from the EXACT content the user
     * saw — pass the rendered text in $shown_content. This is the field that
     * holds up in a chargeback ("here's exactly what they agreed to and a
     * fingerprint proving it hasn't changed since").
     *
     * @param  int         $agreement_id
     * @param  int         $version
     * @param  string      $shown_content    Exact text the customer was shown.
     * @param  int|null    $customer_id      tc_customers.id (NOT wp_users.ID)
     * @param  string      $email
     * @param  int|null    $order_id
     * @param  int|null    $order_item_id
     * @param  string|null $ip_address
     * @param  string|null $user_agent
     * @return int  acceptance_id, or 0 on failure.
     */
    public function record_acceptance(
        int $agreement_id,
        int $version,
        string $shown_content,
        ?int $customer_id,
        string $email,
        ?int $order_id = null,
        ?int $order_item_id = null,
        ?string $ip_address = null,
        ?string $user_agent = null
    ): int {
        return $this->repo->create_acceptance( [
            'agreement_id'          => $agreement_id,
            'agreement_version'     => $version,
            'customer_id'           => $customer_id,
            'email'                 => $email,
            'order_id'              => $order_id,
            'order_item_id'         => $order_item_id,
            'ip_address'            => $ip_address,
            'user_agent'            => $user_agent,
            'accepted_content_hash' => hash( 'sha256', $shown_content ),
        ] );
    }

    /**
     * Has this customer/email accepted this specific agreement+version?
     * Used by the PDP and checkout gates (Phase 5).
     *
     * @param  int         $agreement_id
     * @param  int         $version
     * @param  int|null    $customer_id   tc_customers.id
     * @param  string|null $email
     * @return bool
     */
    public function has_accepted( int $agreement_id, int $version, ?int $customer_id, ?string $email ): bool {
        return $this->repo->has_acceptance( $agreement_id, $version, $customer_id, $email );
    }

    /**
     * Fetch all acceptance rows for an order — used by the admin
     * chargeback-evidence panel (Phase 2) and the order email (Phase 4).
     *
     * @param  int $order_id
     * @return array<int, array>
     */
    public function get_acceptances_for_order( int $order_id ): array {
        return $this->repo->get_acceptances_for_order( $order_id );
    }

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

    /**
     * Generate a cryptographically random hex token.
     *
     * @return string
     */
    private function generate_token(): string {
        try {
            return bin2hex( random_bytes( self::TOKEN_LENGTH / 2 ) );
        } catch ( \Throwable $e ) {
            // Vanishingly unlikely — random_bytes only fails if no PRNG is
            // available, which would be a much bigger problem. Fall back to
            // wp_generate_password as a last resort.
            return wp_generate_password( self::TOKEN_LENGTH, false, false );
        }
    }

    /**
     * Hash a raw token. We use sha256 because the token has 256 bits of entropy
     * — bcrypt-style work factors are unnecessary and would slow down validation.
     *
     * @param  string $raw_token
     * @return string  64-char hex digest.
     */
    public function hash_token( string $raw_token ): string {
        return hash( 'sha256', $raw_token );
    }

    /**
     * Compute the expires_at timestamp for a new key.
     *
     * Per-product `download_ttl_hours` overrides the global setting.
     * A value of 0 or a negative value means "never expires" (NULL stored).
     *
     * @param  int $product_id
     * @return string|null  MySQL DATETIME, or null for unlimited.
     */
    private function calculate_expiry( int $product_id ): ?string {
        $db   = titancart_db();
        $ttl  = $db->getField(
            "SELECT download_ttl_hours FROM {$db->table('products')} WHERE id = ?i LIMIT 1",
            $product_id
        );

        if ( $ttl === null || $ttl === '' ) {
            $ttl = (int) SettingsService::int( 'general', 'catalog_download_key_ttl', 24 );
        } else {
            $ttl = (int) $ttl;
        }

        if ( $ttl <= 0 ) {
            return null;
        }

        return gmdate( 'Y-m-d H:i:s', time() + ( $ttl * HOUR_IN_SECONDS ) );
    }

    /**
     * Resolve max_downloads for a new key. NULL means unlimited.
     *
     * @param  int $product_id
     * @return int|null
     */
    private function resolve_max_downloads( int $product_id ): ?int {
        $db  = titancart_db();
        $max = $db->getField(
            "SELECT download_max_count FROM {$db->table('products')} WHERE id = ?i LIMIT 1",
            $product_id
        );

        if ( $max === null || $max === '' ) {
            return null;
        }
        $max = (int) $max;
        return $max > 0 ? $max : null;
    }
}
