<?php

declare(strict_types=1);

namespace App\Controllers;

use App\Core\App;
use App\Core\Availability;
use App\Core\Database as DB;
use App\Core\RateLimiter;
use App\Core\Request;
use App\Core\Response;
use App\Core\Schema;
use App\Core\Stay;

/**
 * Online booking deposits. Two gateways, both optional and configured
 * entirely from CMS settings (Admin » Settings):
 *
 *  - PayPal — Orders API v2; the frontend renders PayPal's JS SDK buttons,
 *    this controller creates and captures the order server-side.
 *  - WiPay  — Caribbean hosted checkout; this controller requests a hosted
 *    payment page URL, the browser is redirected there, and WiPay calls
 *    back /api/payments/wipay/response with an md5-signed result.
 *
 * Invariant: the charge amount always comes from server-side settings and
 * the stored booking row — never from anything the client sends.
 */
final class PaymentController
{
    // --- Bookings ---------------------------------------------------------

    public function book(Request $request): never
    {
        $this->throttle($request, 'book');

        $s = $this->settings();
        $amount = round((float) ($s['deposit_usd'] ?? 0), 2);
        if (($s['payments_enabled'] ?? '') !== '1' || $amount < 1) {
            Response::error('Online booking deposits are not available right now.', 503);
        }

        $name = $request->str('name');
        $email = $request->str('email');
        $itemType = $request->str('item_type');
        $itemName = $request->str('item_name');

        if (mb_strlen($name) < 2 || mb_strlen($name) > 120) {
            Response::error('Please provide your name.');
        }
        if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
            Response::error('Please provide a valid email address.');
        }
        if (!in_array($itemType, ['treatment', 'package', 'stay'], true)) {
            Response::error('Unknown booking type.');
        }
        if ($itemName === '' || mb_strlen($itemName) > 190) {
            Response::error('Please choose a treatment, package or stay.');
        }

        $this->ensureTable();
        Schema::heal(); // room/slot + stay columns exist on pre-existing installs

        // Managed calendar: when the guest picked a room + slot (the /book flow),
        // re-validate it server-side. This is the authoritative no-double-booking
        // guard — a slot taken between the browser fetching availability and
        // submitting is rejected here.
        $roomId = (int) $request->str('room_id');
        $slotDate = $request->str('slot_date');
        $slotTime = $request->str('slot_time');
        $slotted = $roomId > 0 || $slotDate !== '' || $slotTime !== '';
        if ($slotted) {
            if ($roomId < 1 || $slotDate === '' || $slotTime === '') {
                Response::error('Please choose a room, date and time for your booking.');
            }
            if (!Availability::isOpen($roomId, $slotDate, $slotTime)) {
                Response::error('That time slot is no longer available — please pick another.', 409);
            }
        }

        // Guest-house stay: re-validate the nightly range server-side (the
        // authoritative no-double-booking guard, mirroring the slot check above).
        $isStay = $itemType === 'stay';
        $checkIn = $request->str('check_in');
        $checkOut = $request->str('check_out');
        $guests = (int) $request->str('guests');
        if ($isStay) {
            $gh = DB::selectOne('SELECT min_nights, max_guests FROM guesthouse WHERE active = 1 ORDER BY id LIMIT 1');
            $minNights = max(1, (int) ($gh['min_nights'] ?? 1));
            $maxGuests = max(1, (int) ($gh['max_guests'] ?? 1));
            if ($guests < 1 || $guests > $maxGuests) {
                Response::error('Please choose a valid number of guests.');
            }
            if (!Stay::isRangeOpen($checkIn, $checkOut, $minNights)) {
                Response::error('Those dates are no longer available — please pick another range.', 409);
            }
        }

        // Unguessable public reference. Doubles as WiPay's order_id, which
        // must be alphanumeric-bounded and at most 16 characters.
        $reference = 'TT' . strtoupper(bin2hex(random_bytes(5)));

        DB::insert('bookings', [
            'reference'      => $reference,
            'name'           => $name,
            'email'          => $email,
            'phone'          => mb_substr($request->str('phone'), 0, 40),
            'item_type'      => $itemType,
            'item_name'      => $itemName,
            'room_id'        => $slotted ? $roomId : null,
            'slot_date'      => $slotted ? $slotDate : '',
            'slot_time'      => $slotted ? $slotTime : '',
            'slot_minutes'   => $slotted ? Availability::slotMinutes() : 0,
            'check_in'       => $isStay ? $checkIn : '',
            'check_out'      => $isStay ? $checkOut : '',
            'guests'         => $isStay ? $guests : 0,
            'preferred_date' => mb_substr($request->str('preferred_date'), 0, 40),
            'notes'          => mb_substr($request->str('notes'), 0, 1000),
            'amount'         => $amount,
            'currency'       => 'USD',
            'status'         => 'pending',
            'ip'             => $request->ip(),
            'created_at'     => date('Y-m-d H:i:s'),
        ]);

        Response::json(['reference' => $reference, 'amount' => $amount, 'currency' => 'USD'], 201);
    }

    /** Booking status by public reference — powers the payment result page. */
    public function status(Request $request, array $params): never
    {
        $b = $this->booking((string) ($params['reference'] ?? ''));
        Response::json([
            'reference' => $b['reference'],
            'item_name' => $b['item_name'],
            'amount'    => (float) $b['amount'],
            'currency'  => $b['currency'],
            'status'    => $b['status'],
        ]);
    }

    // --- PayPal (Orders API v2) --------------------------------------------

    public function paypalOrder(Request $request): never
    {
        $this->throttle($request, 'pay');
        [$base, $clientId, $secret] = $this->paypalConfig();
        $b = $this->payableBooking($request->str('reference'));

        $token = $this->paypalToken($base, $clientId, $secret);
        $value = number_format((float) $b['amount'], 2, '.', '');
        $res = $this->http('POST', $base . '/v2/checkout/orders', [
            'Content-Type: application/json',
            'Authorization: Bearer ' . $token,
        ], json_encode([
            'intent'         => 'CAPTURE',
            'purchase_units' => [[
                'reference_id' => $b['reference'],
                'custom_id'    => $b['reference'],
                'description'  => mb_substr('Booking deposit — ' . $b['item_name'], 0, 127),
                'amount'       => ['currency_code' => 'USD', 'value' => $value],
            ]],
        ]));

        $orderId = $res['body']['id'] ?? '';
        if ($res['status'] < 200 || $res['status'] >= 300 || !is_string($orderId) || $orderId === '') {
            error_log('PayPal create order failed: ' . $res['status'] . ' ' . json_encode($res['body']));
            Response::error('PayPal is temporarily unavailable — please try again.', 502);
        }

        DB::update('bookings', (int) $b['id'], [
            'provider'       => 'paypal',
            'provider_ref'   => $orderId,
            'provider_total' => (float) $b['amount'],
        ]);
        Response::json(['id' => $orderId]);
    }

    public function paypalCapture(Request $request): never
    {
        $this->throttle($request, 'pay');
        [$base, $clientId, $secret] = $this->paypalConfig();
        $b = $this->payableBooking($request->str('reference'));

        $orderId = $request->str('order_id');
        // The order must be the one this booking created — prevents replaying
        // another booking's approval onto this reference.
        if ($orderId === '' || $b['provider'] !== 'paypal' || $b['provider_ref'] !== $orderId) {
            Response::error('This payment does not match the booking.');
        }

        $token = $this->paypalToken($base, $clientId, $secret);
        $res = $this->http('POST', $base . '/v2/checkout/orders/' . rawurlencode($orderId) . '/capture', [
            'Content-Type: application/json',
            'Authorization: Bearer ' . $token,
        ], '{}');

        $capture = $res['body']['purchase_units'][0]['payments']['captures'][0] ?? [];
        $amountOk = ($capture['amount']['currency_code'] ?? '') === 'USD'
            && (float) ($capture['amount']['value'] ?? 0) === (float) $b['amount'];

        if (
            $res['status'] < 200 || $res['status'] >= 300
            || ($res['body']['status'] ?? '') !== 'COMPLETED'
            || ($capture['status'] ?? '') !== 'COMPLETED'
            || !$amountOk
        ) {
            error_log('PayPal capture failed: ' . $res['status'] . ' ' . json_encode($res['body']));
            $issue = $res['body']['details'][0]['issue'] ?? '';
            if ($issue === 'INSTRUMENT_DECLINED') {
                Response::error('The payment method was declined — please try another.', 402);
            }
            Response::error('The payment could not be completed.', 502);
        }

        $this->markPaid($b, 'paypal', (string) ($capture['id'] ?? $orderId), 'PayPal capture completed');
        Response::json(['status' => 'paid']);
    }

    // --- WiPay (hosted checkout) --------------------------------------------

    public function wipayCheckout(Request $request): never
    {
        $this->throttle($request, 'pay');
        $s = $this->settings();
        [$account, $apiKey, $sandbox] = $this->wipayCredentials($s);
        $b = $this->payableBooking($request->str('reference'));

        $country = strtoupper(trim($s['wipay_country_code'] ?? '')) ?: 'TT';
        $currency = strtoupper(trim($s['wipay_currency'] ?? '')) ?: 'USD';
        // Deposits are stored in USD; a non-USD WiPay account charges the
        // pegged/configured equivalent (e.g. 2.70 for XCD).
        $rate = (float) ($s['wipay_exchange_rate'] ?? 1) ?: 1.0;
        $total = number_format(round((float) $b['amount'] * ($currency === 'USD' ? 1.0 : $rate), 2), 2, '.', '');
        $feeStructure = trim($s['wipay_fee_structure'] ?? '') ?: 'merchant_absorb';

        $res = $this->http(
            'POST',
            'https://' . strtolower($country) . '.wipayfinancial.com/plugins/payments/request',
            ['Accept: application/json', 'Content-Type: application/x-www-form-urlencoded'],
            http_build_query([
                'account_number' => $account,
                'avs'            => '0',
                'country_code'   => $country,
                'currency'       => $currency,
                'environment'    => $sandbox ? 'sandbox' : 'live',
                'fee_structure'  => $feeStructure,
                'method'         => 'credit_card',
                'order_id'       => $b['reference'],
                'origin'         => 'TouchTherapies-website',
                'response_url'   => $this->publicUrl('/api/payments/wipay/response'),
                'total'          => $total,
            ]),
        );

        $url = $res['body']['url'] ?? '';
        if ($res['status'] < 200 || $res['status'] >= 400 || !is_string($url) || !str_starts_with($url, 'https://')) {
            error_log('WiPay checkout failed: ' . $res['status'] . ' ' . json_encode($res['body']));
            Response::error('Card payments are temporarily unavailable — please try again.', 502);
        }

        DB::update('bookings', (int) $b['id'], [
            'provider'       => 'wipay',
            'provider_total' => (float) $total,
        ]);
        Response::json(['url' => $url]);
    }

    /** WiPay redirects the customer's browser here after the hosted page. */
    public function wipayResponse(Request $request): never
    {
        $ref = (string) ($_GET['order_id'] ?? '');
        $b = DB::selectOne('SELECT * FROM bookings WHERE reference = ?', [$ref]);
        if ($b === null) {
            Response::redirect('/pay/result?status=error');
        }

        if ($b['status'] !== 'paid') { // never downgrade a booking already marked paid
            $status = (string) ($_GET['status'] ?? '');
            $txn = (string) ($_GET['transaction_id'] ?? '');
            $hash = (string) ($_GET['hash'] ?? '');
            $message = mb_substr((string) ($_GET['message'] ?? ''), 0, 255);

            // Resolve the API key directly (no availability guard — this is a
            // browser redirect, so it must always end in a redirect, not JSON).
            $s = $this->settings();
            $apiKey = ($s['wipay_sandbox'] ?? '') === '1' ? '123' : trim($s['wipay_api_key'] ?? '');
            // Signature: md5(transaction_id . original request total . API key).
            $expected = md5($txn . number_format((float) $b['provider_total'], 2, '.', '') . $apiKey);

            if ($status === 'success' && $hash !== '' && hash_equals($expected, $hash)) {
                $this->markPaid($b, 'wipay', $txn, $message);
                $b['status'] = 'paid';
            } else {
                DB::update('bookings', (int) $b['id'], [
                    'status'          => 'failed',
                    'gateway_message' => $status === 'success'
                        ? 'Response signature verification failed'
                        : ($message ?: 'Payment ' . ($status ?: 'error')),
                ]);
                if ($status === 'success') {
                    error_log("WiPay response hash mismatch for booking {$ref} (txn {$txn})");
                }
            }
        }

        Response::redirect('/pay/result?ref=' . rawurlencode($ref)
            . '&status=' . ($b['status'] === 'paid' ? 'success' : 'failed'));
    }

    // --- Shared helpers -----------------------------------------------------

    /**
     * Self-heals databases deployed before the payments feature existed
     * (mirrors how new settings self-heal on the admin Settings screen).
     */
    private function ensureTable(): void
    {
        $mysql = (App::config('db')['driver'] ?? 'sqlite') === 'mysql';
        DB::execute($mysql
            ? <<<'SQL'
              CREATE TABLE IF NOT EXISTS bookings (
                id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
                reference VARCHAR(24) NOT NULL UNIQUE,
                name VARCHAR(120) NOT NULL,
                email VARCHAR(190) NOT NULL,
                phone VARCHAR(40) DEFAULT '',
                item_type VARCHAR(16) NOT NULL DEFAULT 'treatment',
                item_name VARCHAR(190) NOT NULL,
                room_id INT UNSIGNED DEFAULT NULL,
                slot_date VARCHAR(10) DEFAULT '',
                slot_time VARCHAR(5) DEFAULT '',
                slot_minutes INT NOT NULL DEFAULT 0,
                check_in VARCHAR(10) DEFAULT '',
                check_out VARCHAR(10) DEFAULT '',
                guests INT NOT NULL DEFAULT 0,
                preferred_date VARCHAR(40) DEFAULT '',
                notes TEXT,
                amount DECIMAL(10,2) NOT NULL,
                currency CHAR(3) NOT NULL DEFAULT 'USD',
                provider VARCHAR(16) DEFAULT '',
                provider_ref VARCHAR(120) DEFAULT '',
                provider_total DECIMAL(10,2) DEFAULT NULL,
                status VARCHAR(16) NOT NULL DEFAULT 'pending',
                gateway_message VARCHAR(255) DEFAULT '',
                ip VARCHAR(45) DEFAULT '',
                created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
                paid_at DATETIME DEFAULT NULL
              ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
              SQL
            : <<<'SQL'
              CREATE TABLE IF NOT EXISTS bookings (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                reference TEXT NOT NULL UNIQUE,
                name TEXT NOT NULL,
                email TEXT NOT NULL,
                phone TEXT DEFAULT '',
                item_type TEXT NOT NULL DEFAULT 'treatment',
                item_name TEXT NOT NULL,
                room_id INTEGER,
                slot_date TEXT DEFAULT '',
                slot_time TEXT DEFAULT '',
                slot_minutes INTEGER DEFAULT 0,
                check_in TEXT DEFAULT '',
                check_out TEXT DEFAULT '',
                guests INTEGER DEFAULT 0,
                preferred_date TEXT DEFAULT '',
                notes TEXT DEFAULT '',
                amount REAL NOT NULL,
                currency TEXT NOT NULL DEFAULT 'USD',
                provider TEXT DEFAULT '',
                provider_ref TEXT DEFAULT '',
                provider_total REAL,
                status TEXT NOT NULL DEFAULT 'pending',
                gateway_message TEXT DEFAULT '',
                ip TEXT DEFAULT '',
                created_at TEXT NOT NULL DEFAULT (datetime('now')),
                paid_at TEXT
              )
              SQL);
    }

    private function throttle(Request $request, string $bucket): void
    {
        if (!RateLimiter::check($bucket . ':' . $request->ip(), 12, 600)) {
            Response::error('Too many requests — please try again later.', 429);
        }
    }

    /** @return array<string, string> raw settings name => value */
    private function settings(): array
    {
        $out = [];
        foreach (DB::select('SELECT name, value FROM settings') as $row) {
            $out[$row['name']] = (string) $row['value'];
        }
        return $out;
    }

    /** @return array<string, mixed> */
    private function booking(string $reference): array
    {
        $b = $reference === '' ? null
            : DB::selectOne('SELECT * FROM bookings WHERE reference = ?', [$reference]);
        if ($b === null) {
            Response::error('Booking not found.', 404);
        }
        return $b;
    }

    /** @return array<string, mixed> */
    private function payableBooking(string $reference): array
    {
        $b = $this->booking($reference);
        if ($b['status'] === 'paid') {
            Response::error('This booking deposit has already been paid.');
        }
        return $b;
    }

    /** @param array<string, mixed> $b */
    private function markPaid(array $b, string $provider, string $providerRef, string $message): void
    {
        DB::update('bookings', (int) $b['id'], [
            'provider'        => $provider,
            'provider_ref'    => mb_substr($providerRef, 0, 120),
            'status'          => 'paid',
            'gateway_message' => mb_substr($message, 0, 255),
            'paid_at'         => date('Y-m-d H:i:s'),
        ]);

        $mail = App::config('mail');
        if (!empty($mail['host'])) {
            // Fire-and-forget notification; failures never block the response.
            @mail(
                $mail['to'],
                "Deposit paid — booking {$b['reference']}",
                "{$b['name']} <{$b['email']}> paid the US\${$b['amount']} deposit"
                    . " for “{$b['item_name']}” via {$provider}.\n"
                    . "Preferred date: {$b['preferred_date']}\nPhone: {$b['phone']}\n"
                    . "Notes: {$b['notes']}\nReference: {$b['reference']}",
                'From: ' . $mail['from'],
            );
        }
    }

    /** @return array{0: string, 1: string, 2: string} [api base URL, client id, secret] */
    private function paypalConfig(): array
    {
        $s = $this->settings();
        $clientId = trim($s['paypal_client_id'] ?? '');
        $secret = trim($s['paypal_secret'] ?? '');
        if (($s['payments_enabled'] ?? '') !== '1' || ($s['paypal_enabled'] ?? '') !== '1'
            || $clientId === '' || $secret === '') {
            Response::error('PayPal payments are not available right now.', 503);
        }
        $base = ($s['paypal_sandbox'] ?? '') === '1'
            ? 'https://api-m.sandbox.paypal.com'
            : 'https://api-m.paypal.com';
        return [$base, $clientId, $secret];
    }

    /**
     * @param array<string, string> $s
     * @return array{0: string, 1: string, 2: bool} [account number, api key, sandbox]
     */
    private function wipayCredentials(array $s): array
    {
        if (($s['payments_enabled'] ?? '') !== '1' || ($s['wipay_enabled'] ?? '') !== '1') {
            Response::error('Card payments are not available right now.', 503);
        }
        $sandbox = ($s['wipay_sandbox'] ?? '') === '1';
        // WiPay's public sandbox credentials, from their API documentation.
        $account = $sandbox ? '1234567890' : trim($s['wipay_account_number'] ?? '');
        $apiKey = $sandbox ? '123' : trim($s['wipay_api_key'] ?? '');
        if ($account === '' || $apiKey === '') {
            Response::error('Card payments are not fully configured yet.', 503);
        }
        return [$account, $apiKey, $sandbox];
    }

    private function paypalToken(string $base, string $clientId, string $secret): string
    {
        $res = $this->http('POST', $base . '/v1/oauth2/token', [
            'Content-Type: application/x-www-form-urlencoded',
            'Authorization: Basic ' . base64_encode($clientId . ':' . $secret),
        ], 'grant_type=client_credentials');

        $token = $res['body']['access_token'] ?? '';
        if (!is_string($token) || $token === '') {
            error_log('PayPal token failed: ' . $res['status'] . ' ' . json_encode($res['body']));
            Response::error('PayPal is temporarily unavailable — please try again.', 502);
        }
        return $token;
    }

    /** Absolute public URL for a site-root path (the frontend + /api live at the domain root). */
    private function publicUrl(string $path): string
    {
        $https = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off')
            || ($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https';
        $host = $_SERVER['HTTP_HOST'] ?? 'localhost';
        return ($https ? 'https' : 'http') . '://' . $host . $path;
    }

    /**
     * Minimal HTTP client (no Composer on this stack): curl when the extension
     * exists (production), stream context otherwise, and — like the SqliteCli
     * dev fallback — the curl binary when PHP has no HTTPS support at all.
     *
     * @param list<string> $headers
     * @return array{status: int, body: array<string, mixed>}
     */
    private function http(string $method, string $url, array $headers, ?string $body): array
    {
        if (!function_exists('curl_init') && !in_array('https', stream_get_wrappers(), true)) {
            return $this->httpViaCurlBinary($method, $url, $headers, $body);
        }
        if (function_exists('curl_init')) {
            $ch = curl_init($url);
            curl_setopt_array($ch, [
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_CUSTOMREQUEST  => $method,
                CURLOPT_POSTFIELDS     => $body ?? '',
                CURLOPT_HTTPHEADER     => $headers,
                CURLOPT_TIMEOUT        => 45,
            ]);
            $raw = curl_exec($ch);
            $status = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
            $err = curl_error($ch);
            curl_close($ch);
        } else {
            $ctx = stream_context_create(['http' => [
                'method'        => $method,
                'header'        => implode("\r\n", $headers),
                'content'       => $body ?? '',
                'timeout'       => 45,
                'ignore_errors' => true,
            ]]);
            $raw = @file_get_contents($url, false, $ctx);
            $status = 0;
            foreach ($http_response_header ?? [] as $h) {
                if (preg_match('#^HTTP/\S+\s+(\d{3})#', $h, $m)) {
                    $status = (int) $m[1];
                }
            }
            $err = $raw === false ? 'request failed' : '';
        }

        if ($raw === false) {
            error_log("Payment HTTP error for $url: $err");
            Response::error('The payment provider is unreachable — please try again.', 502);
        }
        $decoded = json_decode((string) $raw, true);
        return ['status' => $status, 'body' => is_array($decoded) ? $decoded : []];
    }

    /**
     * Dev-only transport for machines whose PHP lacks both the curl extension
     * and the openssl stream wrapper (production cPanel always has curl).
     *
     * @param list<string> $headers
     * @return array{status: int, body: array<string, mixed>}
     */
    private function httpViaCurlBinary(string $method, string $url, array $headers, ?string $body): array
    {
        $cmd = 'curl -s -w \'\n%{http_code}\' --max-time 45 -X ' . escapeshellarg($method);
        foreach ($headers as $h) {
            $cmd .= ' -H ' . escapeshellarg($h);
        }
        if ($body !== null && $body !== '') {
            $cmd .= ' --data ' . escapeshellarg($body);
        }
        $out = shell_exec($cmd . ' ' . escapeshellarg($url) . ' 2>/dev/null');
        $pos = is_string($out) ? strrpos($out, "\n") : false;
        if ($out === null || $pos === false) {
            error_log("Payment HTTP error for $url: curl binary failed");
            Response::error('The payment provider is unreachable — please try again.', 502);
        }
        $decoded = json_decode(substr($out, 0, $pos), true);
        return [
            'status' => (int) trim(substr($out, $pos + 1)),
            'body'   => is_array($decoded) ? $decoded : [],
        ];
    }
}
