<?php
/**
 * Abstract base class for TitanCart payment gateways.
 *
 * Provides shared utilities so concrete gateways stay lean:
 *   - Transaction logging (write to tc_payment_transactions)
 *   - Order status updates via OrderManager
 *   - Settings retrieval / decryption
 *   - HTTP client wrapper (wp_remote_post/get with error handling)
 *
 * @package TitanCart\Modules\Payments
 */

namespace TitanCart\Modules\Payments;

use TitanCart\DB\Database;
use TitanCart\Modules\Orders\OrderManager;
use TitanCart\Core\LogService;

defined( 'ABSPATH' ) || exit;

abstract class AbstractGateway implements PaymentGatewayInterface {

    protected Database     $db;
    protected OrderManager $order_manager;

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

    // ══════════════════════════════════════════════════════════════════════════
    // TRANSACTION LOGGING
    // ══════════════════════════════════════════════════════════════════════════

    /**
     * Write a transaction row to wp_tc_payment_transactions.
     *
     * @param  int    $order_id
     * @param  int    $payment_method_id
     * @param  string $gateway            Gateway slug.
     * @param  string $transaction_id     External transaction ID.
     * @param  float  $amount
     * @param  string $currency
     * @param  string $type               'charge' | 'refund' | 'void' | 'authorize' | 'capture'
     * @param  string $status             'pending' | 'completed' | 'failed' | 'refunded' | 'voided'
     * @param  array  $gateway_response   Raw gateway response to store as JSON.
     * @return int    Inserted transaction ID, 0 on failure.
     */
    protected function log_transaction(
        int    $order_id,
        int    $payment_method_id,
        string $gateway,
        string $transaction_id,
        float  $amount,
        string $currency,
        string $type             = 'charge',
        string $status           = 'pending',
        array  $gateway_response = []
    ): int {
        return $this->db->insert( 'payment_transactions', [
            'order_id'          => $order_id,
            'payment_method_id' => $payment_method_id ?: null,
            'gateway'           => $gateway,
            'transaction_id'    => $transaction_id,
            'amount'            => $amount,
            'currency'          => strtoupper( $currency ),
            'type'              => $type,
            'status'            => $status,
            'gateway_response'  => wp_json_encode( $gateway_response ),
        ] );
    }

    /**
     * Update the status column on an existing transaction row, optionally
     * refreshing the `gateway_response` JSON payload at the same time.
     *
     * Pre-S213 this method only wrote `status` — gateways that needed to
     * refresh the response payload had to issue a separate UPDATE (or
     * silently leave the row's `gateway_response` frozen at PI-creation-
     * time state, which is the Bug #2 class identified at S212 close).
     * The signature now accepts an optional second mutation; passing
     * `null` (the default) preserves pre-S213 behavior. Backwards
     * compatible — Pattern #168 (new optional props leave all existing
     * call sites unaffected).
     *
     * @param int        $transaction_row_id  The tc_payment_transactions.id.
     * @param string     $status              New status value.
     * @param array|null $gateway_response    Optional. Refreshed gateway-response
     *                                        array. When non-null, written to the
     *                                        `gateway_response` JSON column.
     */
    protected function update_transaction_status( int $transaction_row_id, string $status, ?array $gateway_response = null ): void {
        $data = [ 'status' => $status ];

        if ( $gateway_response !== null ) {
            $data['gateway_response'] = wp_json_encode( $gateway_response );
        }

        $this->db->update(
            'payment_transactions',
            $data,
            [ 'id' => $transaction_row_id ]
        );
    }

    /**
     * Retrieve the most recent transaction row for an order.
     *
     * @param  int    $order_id
     * @param  string $type     Optional: filter by type (e.g. 'charge').
     * @return array|null
     */
    protected function get_latest_transaction( int $order_id, string $type = '' ): ?array {
        $where = "order_id = ?i";
        $args  = [ $order_id ];

        if ( $type ) {
            $where  .= " AND type = ?s";
            $args[]  = $type;
        }

        return $this->db->getRow(
            "SELECT * FROM {$this->db->table('payment_transactions')}
             WHERE {$where}
             ORDER BY id DESC LIMIT 1",
            ...$args
        );
    }

    // ══════════════════════════════════════════════════════════════════════════
    // ORDER STATUS
    // ══════════════════════════════════════════════════════════════════════════

    /**
     * Transition the order to a new status and log the change.
     *
     * @param int    $order_id
     * @param string $status_handle   e.g. 'processing', 'failed'
     * @param string $comment         Staff-visible note.
     * @param bool   $notify_customer Send status email?
     */
    protected function set_order_status(
        int    $order_id,
        string $status_handle,
        string $comment          = '',
        bool   $notify_customer  = false
    ): void {
        $this->order_manager->update_status( $order_id, $status_handle, $comment, $notify_customer );
    }

    // ══════════════════════════════════════════════════════════════════════════
    // HTTP CLIENT
    // ══════════════════════════════════════════════════════════════════════════

    /**
     * POST to an external API with JSON body. Returns decoded response body.
     *
     * @param  string $url
     * @param  array  $body           Data to JSON-encode as request body.
     * @param  array  $headers        Additional request headers.
     * @param  int    $timeout        Request timeout in seconds.
     * @return array{
     *   ok:      bool,
     *   status:  int,
     *   body:    array,
     *   raw:     string
     * }
     */
    protected function http_post(
        string $url,
        array  $body,
        array  $headers = [],
        int    $timeout = 15
    ): array {
        // Serialize the body. Empty PHP arrays serialize to "[]" by default,
        // but some APIs (notably PayPal's capture endpoint) reject that as
        // "not well-formed, syntactically incorrect, or violates schema"
        // because they expect an object when Content-Type is application/json.
        // For an empty body we emit "{}" instead, which is universally accepted.
        $json_body = empty( $body ) ? '{}' : wp_json_encode( $body );

        $response = wp_remote_post( $url, [
            'headers' => array_merge( [ 'Content-Type' => 'application/json' ], $headers ),
            'body'    => $json_body,
            'timeout' => $timeout,
        ] );

        return $this->parse_response( $response );
    }

    /**
     * GET from an external API. Returns decoded response body.
     *
     * @param  string $url
     * @param  array  $headers
     * @param  int    $timeout
     * @return array{ok: bool, status: int, body: array, raw: string}
     */
    protected function http_get( string $url, array $headers = [], int $timeout = 15 ): array {
        $response = wp_remote_get( $url, [
            'headers' => $headers,
            'timeout' => $timeout,
        ] );

        return $this->parse_response( $response );
    }

    /**
     * Parse a WP_Error or HTTP response array into a consistent shape.
     *
     * @param  \WP_Error|array $response
     * @return array{ok: bool, status: int, body: array, raw: string}
     */
    private function parse_response( \WP_Error|array $response ): array {
        if ( is_wp_error( $response ) ) {
            LogService::error( 'requests', 'http', 'Payment HTTP error: ' . $response->get_error_message(), [
                'gateway'    => static::class,
                'wp_error'   => $response->get_error_message(),
            ] );
            return [ 'ok' => false, 'status' => 0, 'body' => [], 'raw' => '' ];
        }

        $status = (int) wp_remote_retrieve_response_code( $response );
        $raw    = wp_remote_retrieve_body( $response );
        $body   = json_decode( $raw, true ) ?: [];

        return [
            'ok'     => $status >= 200 && $status < 300,
            'status' => $status,
            'body'   => $body,
            'raw'    => $raw,
        ];
    }

    // ══════════════════════════════════════════════════════════════════════════
    // SETTINGS HELPERS
    // ══════════════════════════════════════════════════════════════════════════

    /**
     * Retrieve a typed setting value with fallback to default.
     *
     * @param  array  $settings  Decoded gateway settings array.
     * @param  string $key       Setting key.
     * @param  mixed  $default   Default value if key absent or empty.
     * @return mixed
     */
    protected function setting( array $settings, string $key, mixed $default = '' ): mixed {
        $value = $settings[ $key ] ?? $default;
        return ( $value === '' || $value === null ) ? $default : $value;
    }

    /**
     * Build a standardised failure result array.
     *
     * @param string $message  Human-readable error.
     * @return array
     */
    protected function fail_result( string $message ): array {
        return [
            'success'        => false,
            'transaction_id' => '',
            'message'        => $message,
            'payment_data'   => [],
        ];
    }

    /**
     * Build a standardised success result array.
     *
     * @param string $transaction_id  Gateway transaction ID.
     * @param string $message
     * @param array  $payment_data    Extra data for JS frontend.
     * @return array
     */
    protected function success_result( string $transaction_id, string $message = '', array $payment_data = [] ): array {
        return [
            'success'        => true,
            'transaction_id' => $transaction_id,
            'message'        => $message ?: __( 'Payment processed.', 'titancart-lite' ),
            'payment_data'   => $payment_data,
        ];
    }

    // ══════════════════════════════════════════════════════════════════════════
    // POST-CHECKOUT INSTRUCTIONS RENDERING
    // ══════════════════════════════════════════════════════════════════════════

    /**
     * Return HTML to render on the order-received page and on the account
     * order-detail page, allowing offline gateways (bank transfer, check) to
     * display payment instructions to the customer after checkout.
     *
     * Default: empty string (no extra content). Online gateways (Stripe,
     * PayPal, AN, Square) and COD have nothing to add here.
     *
     * Offline gateways override this to return their payment instructions
     * (bank account details, check mailing address). The PaymentsModule
     * dispatches via the `titancart_order_received_payment_section` and
     * `titancart_order_detail_payment_section` actions; both call this method.
     *
     * S210 — closes the bug found in Task 4b smoke where Bank Transfer orders
     * showed only the generic "Your order has been received and is being
     * processed" message, leaving customers without account details to
     * actually pay.
     *
     * @param  array $order     The full order row from tc_orders.
     * @param  array $settings  Decoded gateway settings from tc_payment_methods.
     * @return string           HTML snippet, or empty string for "no content".
     */
    public function render_post_checkout_instructions( array $order, array $settings ): string {
        return '';
    }

    // ══════════════════════════════════════════════════════════════════════════
    // ASYNC PAYMENT VERIFICATION
    // ══════════════════════════════════════════════════════════════════════════

    /**
     * Verify that an asynchronous payment has actually settled at the gateway.
     *
     * Synchronous gateways (those whose `process_payment()` returns the real
     * charge result inline — Authorize.net, Square, PayPal redirect, offline
     * gateways) keep the default `false`. They never enter the finalize path
     * because the cart-commit gate in `CheckoutManager::place_order()` already
     * committed at place-order time.
     *
     * Asynchronous gateways — those that return `payment_data['requires_action']
     * = true` from `process_payment()` to defer the actual charge to a JS-driven
     * second step (Stripe Card Elements / PaymentIntents) — MUST override this
     * method. The override should query the gateway's own API and return true
     * ONLY if the gateway server-side confirms the payment has settled.
     *
     * This is the security gate for the `/checkout/finalize/{order_id}` endpoint:
     * stock decrement and cart destruction will not run unless this returns true.
     * A malicious caller cannot fake a "succeeded" response — only the gateway can.
     *
     * @param  string $transaction_id  The gateway transaction ID from `process_payment()`.
     * @param  array  $settings        Decoded gateway settings (from payment_methods.settings).
     * @return bool   True if the payment is confirmed settled. False otherwise.
     */
    public function verify_async_payment( string $transaction_id, array $settings ): bool {
        return false;
    }

    /**
     * Verify a vault-only SetupIntent has settled at the gateway (S279, B2).
     *
     * SetupIntent sibling of verify_async_payment(). A "vault-only" order is a
     * $0 order whose payment step saved a payment method for future off-session
     * charging WITHOUT taking any money now — used by the preorder /
     * delayed-start subscription flow (the first charge fires on a future floor
     * date). Because no charge happens, verify_async_payment() (which checks a
     * PaymentIntent's `succeeded`/`processing` status) does not apply; the
     * deferred-commit endpoint instead asks this method to confirm the funding
     * source was actually saved server-side.
     *
     * Unlike verify_async_payment() (which returns only a bool), this returns
     * the verified intent object too, because the post-commit handler needs the
     * resolved payment_method + customer off the SetupIntent to vault a renewal
     * token. Synchronous / non-vaulting gateways keep the default (not verified)
     * and never enter this branch.
     *
     * @param  string $setup_intent_id  Gateway setup/vault intent ID.
     * @param  array  $settings         Decoded gateway settings.
     * @return array{verified: bool, intent: array}
     */
    public function verify_setup_intent( string $setup_intent_id, array $settings ): array {
        return [ 'verified' => false, 'intent' => [] ];
    }

    /**
     * Handle a confirmed vault-only setup (S279, B2).
     *
     * Called once the deferred-commit endpoint (or the gateway's own webhook)
     * confirms a SetupIntent settled. Gateways that support $0 vault-only orders
     * override this to mark their transaction row completed, advance the order,
     * and fire their gateway-specific "setup succeeded" action so the
     * subscription extension can capture the now-known payment method as a
     * renewal token. No-op by default.
     *
     * @param array      $intent  The verified setup/vault intent object.
     * @param array|null $txn     Optional matching tc_payment_transactions row;
     *                            resolved from the intent ID when null (webhook path).
     */
    public function handle_setup_intent_succeeded( array $intent, ?array $txn = null ): void {
        // Default: no-op. Vault-capable gateways override.
    }

    // ══════════════════════════════════════════════════════════════════════════
    // SUBSCRIPTION CAPABILITY (KI-79, S394)
    // ══════════════════════════════════════════════════════════════════════════

    /**
     * Can this gateway support a subscription line — i.e. can it vault a
     * reusable payment token that the renewal cron can charge off-session?
     *
     * ★ THE PREDICATE IS TOKENLESSNESS, NOT "OFFLINE". KI-79 was filed as an
     * "offline gateway" bug (COD / cheque / bank transfer), but "offline" is a
     * *proxy* for the real requirement and the proxy is exactly what a future
     * gateway slips through. The real requirement is: does this gateway vault a
     * token that `charge_off_session()` can later charge without the customer
     * present? If not, `RenewalService::process_single_renewal()` gets
     * `requires_manual` back, `create_renewal_order()` never runs, the
     * `subscription_renewal` order-item meta is never written, and
     * `PP_Licensing::extend_license_for_renewal()` becomes UNREACHABLE — the
     * subscription rolls forward and reads healthy forever while the licence
     * expires on its original date and is never extended. The two records
     * disagree, silently, in the direction nobody looks at. That is KI-79.
     *
     * ⚠️ DEFAULT IS `false` — THIS FAILS CLOSED BY CONSTRUCTION, AND THAT IS
     * THE WHOLE POINT. The rejected alternative (a hardcoded `cod|check|
     * bank_transfer` slug blocklist) fails OPEN: it cannot know about a gateway
     * that does not exist yet, so any future tokenless gateway — including one
     * registered by a third party through `titancart_register_gateways` —
     * would default to *allowed* and silently re-open this defect. Here, a
     * gateway that says nothing is assumed incapable, which is the safe
     * direction. A gateway author who vaults tokens opts IN, deliberately.
     *
     * A gateway may override this to `true` ONLY if it genuinely vaults a
     * reusable token. As of S394 the honest test is a THREE-part trace, and
     * every part must hold — do not infer capability from the gateway's name:
     *   1. the gateway implements `charge_off_session()`;
     *   2. its initial-checkout path actually stores a reusable token into
     *      `tc_subscription_payment_tokens`;
     *   3. a `tcs_charge_renewal` claimant (Pattern #181) is registered that
     *      routes that token's slug to that `charge_off_session()`.
     * Built-ins passing all three at S394: Stripe, PayPal, Authorize.net,
     * Square. Failing all three: COD, Cheque, Bank Transfer.
     *
     * Mirrors the idiom of `verify_async_payment()` above (default `false`,
     * capable gateways override). ⚠️ KI-79's ticket cites this idiom as
     * `is_async()` — no such method exists; the real sibling is
     * `verify_async_payment()`.
     *
     * @since S394 (KI-79)
     * @return bool True only if this gateway vaults an off-session-chargeable token.
     */
    public function supports_subscriptions(): bool {
        return false;
    }
}
