<?php
/**
 * Currency / FX — the ONE centralized source of truth for account currency
 * and currency conversion (2026-08-11). Read the "Currency / FX Domain"
 * section of CLAUDE.md before touching this file or adding a new caller.
 *
 * This exists for exactly the reason the app's other Single Source of
 * Truth rules exist (see CLAUDE.md's "Single Source of Truth Rule (Core
 * Account Metrics)"): so Calculator/Dashboard/Journal/Reports/Analytics/
 * Colex never each grow their own copy of "how do I convert currency X to
 * currency Y." They must all call this class. No page may hardcode an FX
 * rate, call an FX API directly, or implement its own conversion formula.
 *
 * Deliberately NOT the same problem as calculator-source.html's
 * fetchAllRates()/LIVE_RATES/getResolvedRate() — that system answers "what
 * currency does this instrument quote in" (an instrument-market concern,
 * client-side, scoped to ~7 majors vs USD, feeding CalculatorEngine's
 * pipValue math). This class answers "what is this ACCOUNT's currency, and
 * how do I convert an amount into/out of it" (an account concern,
 * server-side, arbitrary pair, arbitrary currency list). The two compose
 * (an instrument's USD-anchored pip value may eventually need converting
 * into a non-USD account's currency) but must never be conflated — see
 * CLAUDE.md for the current, deliberately-scoped boundary between them.
 *
 * USD remains the default everywhere, unchanged from today — every
 * existing default (trading_accounts.currency, user_profiles.base_currency)
 * is already 'USD' and stays that way. This class only makes selecting a
 * different currency actually mean something; it changes no default.
 */
class CurrencyService
{
    /**
     * The one canonical supported-currency list — replaces the two
     * independently hand-typed lists that used to live in
     * public/add-live-account.php and public/settings.php. Every currency
     * here needs a real ISO 4217 code; `symbol` is what formatMoney()
     * prints — a real glyph for the handful of currencies with one
     * unambiguous, universally-recognized symbol, and the ISO code itself
     * for everything else (safer than guessing at a symbol that might
     * collide with another currency, e.g. "kr" is used by NOK/SEK/DKK).
     */
    public const SUPPORTED_CURRENCIES = [
        'USD' => ['name' => 'US Dollar',          'symbol' => '$'],
        'EUR' => ['name' => 'Euro',                'symbol' => '€'],
        'GBP' => ['name' => 'British Pound',       'symbol' => '£'],
        'JPY' => ['name' => 'Japanese Yen',        'symbol' => '¥'],
        'KES' => ['name' => 'Kenyan Shilling',     'symbol' => 'KES'],
        'ZAR' => ['name' => 'South African Rand',  'symbol' => 'ZAR'],
        'AUD' => ['name' => 'Australian Dollar',   'symbol' => 'A$'],
        'CAD' => ['name' => 'Canadian Dollar',     'symbol' => 'C$'],
        'CHF' => ['name' => 'Swiss Franc',         'symbol' => 'CHF'],
        'NZD' => ['name' => 'New Zealand Dollar',  'symbol' => 'NZ$'],
        'SGD' => ['name' => 'Singapore Dollar',    'symbol' => 'S$'],
        'HKD' => ['name' => 'Hong Kong Dollar',    'symbol' => 'HK$'],
        'NOK' => ['name' => 'Norwegian Krone',     'symbol' => 'NOK'],
        'SEK' => ['name' => 'Swedish Krona',       'symbol' => 'SEK'],
        'DKK' => ['name' => 'Danish Krone',        'symbol' => 'DKK'],
        'PLN' => ['name' => 'Polish Zloty',        'symbol' => 'PLN'],
        'AED' => ['name' => 'UAE Dirham',          'symbol' => 'AED'],
        'SAR' => ['name' => 'Saudi Riyal',         'symbol' => 'SAR'],
    ];

    /** How long a cached rate is trusted before a fresh fetch is attempted. */
    private const CACHE_TTL_SECONDS = 3600;

    /** Per-request memoization — avoids repeat cache/HTTP round trips within one page render. */
    private static array $requestCache = [];

    public static function isSupported(string $code): bool
    {
        return isset(self::SUPPORTED_CURRENCIES[strtoupper($code)]);
    }

    public static function symbol(?string $code): string
    {
        $code = strtoupper((string)$code);
        return self::SUPPORTED_CURRENCIES[$code]['symbol'] ?? ($code ?: '$');
    }

    /**
     * The one place a monetary amount becomes display text. Mirrors
     * src/RenderHelpers.php's pnlFormat() sign convention ($forceSign) so
     * existing "+$1,234.56 / -$1,234.56" P&L call sites can adopt this with
     * the same visual behavior, just currency-aware instead of hardcoded '$'.
     */
    public static function format(float $amount, ?string $code, bool $forceSign = false): string
    {
        $sym = self::symbol($code);
        // Sign goes BEFORE the symbol ("-$1,234.56", never "$-1,234.56") —
        // number_format() would otherwise bury its own minus sign after the
        // symbol. Computed from abs() so there's never a double sign.
        $sign = $amount < 0 ? '-' : ($forceSign ? '+' : '');
        // Real single-glyph symbols ($/€/£/¥ etc.) sit flush against the
        // number, matching every existing "$1,234.56"-style call site in
        // this app; multi-letter ISO-code "symbols" (KES/ZAR/AED/...) get a
        // space so they read as a currency code, not a jammed prefix.
        $separator = mb_strlen($sym) > 2 ? ' ' : '';
        return $sign . $sym . $separator . number_format(abs($amount), 2);
    }

    /**
     * Real-time (cached) FX rate from $from to $to — i.e. multiply an
     * amount in $from by this to get the equivalent in $to. Returns
     * ['rate' => float, 'source' => string, 'fetched_at' => string,
     * 'stale' => bool] or null if no rate could be obtained from any
     * provider AND no cached rate (even a stale one) exists to fall back to.
     */
    public static function getRate(string $from, string $to): ?array
    {
        $from = strtoupper($from);
        $to = strtoupper($to);
        if ($from === $to) {
            return ['rate' => 1.0, 'source' => 'identity', 'fetched_at' => date('Y-m-d H:i:s'), 'stale' => false];
        }

        $memoKey = "{$from}:{$to}";
        if (isset(self::$requestCache[$memoKey])) return self::$requestCache[$memoKey];

        $pdo = self::pdo();
        $cached = self::readCache($pdo, $from, $to);
        if ($cached && (time() - strtotime($cached['fetched_at'])) < self::CACHE_TTL_SECONDS) {
            return self::$requestCache[$memoKey] = [
                'rate' => (float)$cached['rate'], 'source' => $cached['source'],
                'fetched_at' => $cached['fetched_at'], 'stale' => false,
            ];
        }

        $fresh = self::fetchFromProviders($from, $to);
        if ($fresh !== null) {
            self::writeCache($pdo, $from, $to, $fresh['rate'], $fresh['source']);
            return self::$requestCache[$memoKey] = [
                'rate' => $fresh['rate'], 'source' => $fresh['source'],
                'fetched_at' => date('Y-m-d H:i:s'), 'stale' => false,
            ];
        }

        // Every live provider failed this request — fall back to whatever
        // is cached, even if stale, rather than surfacing a hard error to
        // the trader. A real, disclosed degraded state (stale=true), never
        // a silent wrong number.
        if ($cached) {
            return self::$requestCache[$memoKey] = [
                'rate' => (float)$cached['rate'], 'source' => $cached['source'] . ' (stale)',
                'fetched_at' => $cached['fetched_at'], 'stale' => true,
            ];
        }

        return self::$requestCache[$memoKey] = null;
    }

    /** Converts $amount from $from into $to using the current rate. Returns null (never a wrong number) if no rate is available. */
    public static function convert(float $amount, string $from, string $to): ?float
    {
        $r = self::getRate($from, $to);
        return $r === null ? null : $amount * $r['rate'];
    }

    // ── Providers — same fallback philosophy as calculator-source.html's
    // fetchAllRates() (ordered chain, first success wins), called server-
    // side via cURL instead of browser fetch(), and parameterized by
    // arbitrary from/to instead of hardcoded USD-only. A provider that
    // doesn't carry the requested currency (e.g. Frankfurter has no KES)
    // simply fails to produce it, which falls through to the next
    // provider — no special-casing needed. ──────────────────────────────
    private static function fetchFromProviders(string $from, string $to): ?array
    {
        $providers = [
            'Frankfurter'      => fn() => self::fetchFrankfurter($from, $to),
            'ExchangeRate-API' => fn() => self::fetchExchangeRateApi($from, $to),
            'CDN currency-api' => fn() => self::fetchCdnCurrencyApi($from, $to),
        ];
        foreach ($providers as $name => $fn) {
            try {
                $rate = $fn();
                if ($rate !== null && $rate > 0) return ['rate' => $rate, 'source' => $name];
            } catch (\Throwable $e) {
                // Try the next provider — a single provider outage must
                // never surface as an app error.
                continue;
            }
        }
        return null;
    }

    private static function httpGetJson(string $url): ?array
    {
        $ch = curl_init($url);
        curl_setopt_array($ch, [
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_TIMEOUT => 4,
            CURLOPT_CONNECTTIMEOUT => 3,
            CURLOPT_HTTPHEADER => ['Accept: application/json'],
            CURLOPT_SSL_VERIFYPEER => true,
        ]);
        $body = curl_exec($ch);
        $code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
        curl_close($ch);
        if ($body === false || $code < 200 || $code >= 300) return null;
        $data = json_decode($body, true);
        return is_array($data) ? $data : null;
    }

    private static function fetchFrankfurter(string $from, string $to): ?float
    {
        $data = self::httpGetJson('https://api.frankfurter.app/latest?from=' . urlencode($from) . '&to=' . urlencode($to));
        return $data['rates'][$to] ?? null;
    }

    private static function fetchExchangeRateApi(string $from, string $to): ?float
    {
        $data = self::httpGetJson('https://api.exchangerate-api.com/v4/latest/' . urlencode($from));
        return $data['rates'][$to] ?? null;
    }

    private static function fetchCdnCurrencyApi(string $from, string $to): ?float
    {
        $fromLower = strtolower($from);
        $toLower = strtolower($to);
        $data = self::httpGetJson("https://cdn.jsdelivr.net/npm/@fawazahmed0/currency-api@latest/v1/currencies/{$fromLower}.json");
        return $data[$fromLower][$toLower] ?? null;
    }

    // ── Cache ────────────────────────────────────────────────────────────
    private static ?PDO $pdoInstance = null;

    private static function pdo(): PDO
    {
        if (self::$pdoInstance === null) {
            $dsn = "mysql:host=" . DB_HOST . ";port=" . DB_PORT . ";dbname=" . DB_NAME . ";charset=utf8mb4";
            self::$pdoInstance = new PDO($dsn, DB_USER, DB_PASS, [
                PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
                PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
            ]);
        }
        return self::$pdoInstance;
    }

    private static function readCache(PDO $pdo, string $from, string $to): ?array
    {
        $stmt = $pdo->prepare('SELECT rate, source, fetched_at FROM fx_rates_cache WHERE base_currency = :f AND quote_currency = :t');
        $stmt->execute([':f' => $from, ':t' => $to]);
        $row = $stmt->fetch();
        return $row ?: null;
    }

    private static function writeCache(PDO $pdo, string $from, string $to, float $rate, string $source): void
    {
        $stmt = $pdo->prepare('
            INSERT INTO fx_rates_cache (id, base_currency, quote_currency, rate, source, fetched_at)
            VALUES (:id, :f, :t, :rate, :src, NOW())
            ON DUPLICATE KEY UPDATE rate = :rate2, source = :src2, fetched_at = NOW()
        ');
        $stmt->execute([
            ':id' => self::uuid(), ':f' => $from, ':t' => $to,
            ':rate' => $rate, ':src' => $source, ':rate2' => $rate, ':src2' => $source,
        ]);
    }

    /** Self-contained (no ScopedDb dependency) — same v4 UUID shape used throughout this codebase. */
    private static function uuid(): string
    {
        $data = random_bytes(16);
        $data[6] = chr(ord($data[6]) & 0x0f | 0x40);
        $data[8] = chr(ord($data[8]) & 0x3f | 0x80);
        return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($data), 4));
    }
}
