<?php
namespace App;

/**
 * کلاینت سرویس استعلام زحل (zohal.io) — جایگزین زیبال.
 *
 * احراز هویت: هدر  Authorization: Bearer <ZOHAL_TOKEN>
 * پایه:       https://service.zohal.io/api/v0
 *
 * ⚠️ زحل فقط به درخواست‌هایی پاسخ می‌دهد که از IPهای اعلام‌شده در پنل ارسال
 *    شوند. اگر IP سرور ثبت نشده باشد همهٔ استعلام‌ها خطا می‌خورند.
 *
 * شکل پاسخ تبدیل کارت به شبا:
 *   { "result": 1,
 *     "response_body": { "data": {"IBAN":"…","bank_name":"…","name":"…"},
 *                        "error_code": null, "message": "موفق" } }
 *
 * کدهای result طبق مستندات: 1=موفق ، 4=درخواست نامعتبر ،
 * 5=خطای داخلی سرور زحل ، 6=ورودی نادرست.
 * error_code = "invalid" یعنی موجودی کیف پول کافی نیست.
 */
class Zohal {

    private static function baseUrl(): string {
        return defined('ZOHAL_BASE_URL') ? rtrim(ZOHAL_BASE_URL, '/') : 'https://service.zohal.io/api/v0';
    }

    private static function token(): string {
        return defined('ZOHAL_TOKEN') ? (string)ZOHAL_TOKEN : '';
    }

    private static function headers(): array {
        return [
            'Content-Type: application/json',
            'Accept: application/json',
            'Authorization: Bearer ' . self::token(),
        ];
    }

    private static function concurrency(): int {
        if (defined('ZOHAL_BATCH_CONCURRENCY')) return max(1, (int)ZOHAL_BATCH_CONCURRENCY);
        return 8;
    }

    // ---------------------------------------------------------------- cache

    /**
     * مسیر کش کارت→شبا. اگر فایل جدید نبود ولی کش قدیمی زیبال موجود بود،
     * یک‌بار منتقل می‌شود: شبای یک کارت مستقل از سرویس استعلام است، پس آن
     * رکوردها معتبرند و نباید دوباره هزینه شوند.
     */
    private static function cacheFile(): string {
        $new = defined('CARD_IBAN_CACHE_FILE')
            ? CARD_IBAN_CACHE_FILE
            : CACHE_DIR . '/card_to_iban.json';
        if (!is_file($new)) {
            $legacy = defined('ZIBAL_CACHE_FILE') ? ZIBAL_CACHE_FILE : CACHE_DIR . '/zibal_card_to_iban.json';
            if (is_file($legacy)) {
                if (!is_dir(CACHE_DIR)) @mkdir(CACHE_DIR, 0775, true);
                $old = json_decode((string)@file_get_contents($legacy), true);
                if (is_array($old)) {
                    // رکوردهای قدیمی 'cached_at' ندارند و بدون مهرِ زمان بلافاصله
                    // منقضی حساب می‌شدند — یعنی دوباره (این‌بار با هزینهٔ زحل)
                    // استعلام می‌شدند. زمان آخرین تغییرِ فایل به‌عنوان سن آن‌ها
                    // ثبت می‌شود تا واقعاً استفاده شوند و TTL هم بی‌معنا نشود.
                    $stamp = @filemtime($legacy) ?: time();
                    foreach ($old as $card => $e) {
                        if (is_array($e) && !empty($e['success']) && empty($e['cached_at'])) {
                            $old[$card] = $e + ['cached_at' => $stamp, 'provider' => 'zibal-legacy'];
                        }
                    }
                    @file_put_contents($new, json_encode($old, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
                } else {
                    @copy($legacy, $new);
                }
            }
        }
        return $new;
    }

    private static function loadCache(): array {
        if (!is_dir(CACHE_DIR)) @mkdir(CACHE_DIR, 0775, true);
        $f = self::cacheFile();
        if (!is_file($f)) return [];
        $raw = @file_get_contents($f);
        $data = $raw ? json_decode($raw, true) : null;
        return is_array($data) ? $data : [];
    }

    private static function saveCache(array $cache): void {
        if (!is_dir(CACHE_DIR)) @mkdir(CACHE_DIR, 0775, true);
        $f = self::cacheFile();
        $tmp = $f . '.tmp';
        @file_put_contents($tmp, json_encode($cache, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
        @rename($tmp, $f);
    }

    private static function ttl(): int {
        if (defined('CARD_IBAN_CACHE_TTL_SECONDS')) return (int)CARD_IBAN_CACHE_TTL_SECONDS;
        if (defined('ZIBAL_CACHE_TTL_SECONDS'))     return (int)ZIBAL_CACHE_TTL_SECONDS; // سازگاری با config قدیمی
        return 7776000;
    }

    /**
     * رکورد کش معتبر است؟ رکوردهای بدون 'cached_at' (کش قدیمی زیبال) یک‌بار
     * منقضی حساب می‌شوند تا تازه شوند.
     */
    private static function isCacheEntryFresh($entry): bool {
        if (!is_array($entry) || empty($entry['success'])) return false;
        $ttl = self::ttl();
        if ($ttl <= 0) return true;
        $at = (int)($entry['cached_at'] ?? 0);
        return $at > 0 && (time() - $at) < $ttl;
    }

    /**
     * وضعیت کش و پیکربندی — برای دستور /diag در تلگرام.
     * روی هاست اشتراکی بدون SSH تنها راه دیدن این اطلاعات است.
     */
    public static function diagnostics(): array {
        $file   = self::cacheFile();          // انتقال کش قدیمی همین‌جا هم اتفاق می‌افتد
        $legacy = defined('ZIBAL_CACHE_FILE') ? ZIBAL_CACHE_FILE : CACHE_DIR . '/zibal_card_to_iban.json';
        $cache  = self::loadCache();

        $fresh = 0; $stale = 0; $legacyEntries = 0;
        foreach ($cache as $e) {
            if (self::isCacheEntryFresh($e)) $fresh++; else $stale++;
            if (is_array($e) && ($e['provider'] ?? '') === 'zibal-legacy') $legacyEntries++;
        }

        return [
            'token_set'      => self::token() !== '',
            'base_url'       => self::baseUrl(),
            'concurrency'    => self::concurrency(),
            'ttl_days'       => (int)round(self::ttl() / 86400),
            'cache_file'     => $file,
            'cache_exists'   => is_file($file),
            'cache_bytes'    => is_file($file) ? (int)filesize($file) : 0,
            'cache_total'    => count($cache),
            'cache_fresh'    => $fresh,
            'cache_stale'    => $stale,
            'cache_legacy'   => $legacyEntries,
            'cache_writable' => is_dir(CACHE_DIR) && is_writable(CACHE_DIR),
            'legacy_file'    => $legacy,
            'legacy_exists'  => is_file($legacy),
            'legacy_total'   => is_file($legacy)
                ? count((array)json_decode((string)@file_get_contents($legacy), true))
                : 0,
        ];
    }

    // ------------------------------------------------------------ interpret

    /**
     * تفسیر پاسخ زحل به نتیجهٔ استاندارد پروژه.
     *
     * کلیدها:
     *   success   موفق بودن استعلام
     *   iban/name/bankName  دادهٔ کارت
     *   error     متن قابل‌نمایش خطا (در گزارش «کدهای حذف‌شده» می‌آید)
     *   transient خطای گذرا بوده و تلاش مجدد معنی دارد
     *   wallet    خطا به‌خاطر ناکافی بودن موجودی کیف پول است
     */
    private static function interpret(int $httpCode, string $curlErr, ?string $raw): array {
        if ($curlErr !== '') {
            return ['success'=>false, 'iban'=>null, 'error'=>'خطای شبکه: ' . $curlErr, 'transient'=>true];
        }

        $resp = ($raw !== null && $raw !== '') ? json_decode($raw, true) : null;

        $result = null; $errCode = null; $msg = '';
        if (is_array($resp)) {
            $result = isset($resp['result']) ? (int)$resp['result'] : null;
            $body   = $resp['response_body'] ?? [];
            if (is_array($body)) {
                $errCode = $body['error_code'] ?? null;
                $msg     = trim((string)($body['message'] ?? ''));
            }
            if ($msg === '') $msg = trim((string)($resp['message'] ?? ''));
        }

        // موجودی کیف پول کافی نیست — مشکل از کارت نیست، تلاش مجدد هم فایده ندارد.
        if (is_string($errCode) && strtolower(trim($errCode)) === 'invalid') {
            return ['success'=>false, 'iban'=>null, 'wallet'=>true, 'transient'=>false,
                    'error'=>'موجودی کیف پول زحل کافی نیست' . ($msg !== '' ? ' — ' . $msg : '')];
        }

        $tail = '';
        if ($msg !== '')            $tail .= ' — ' . $msg;
        if ($result !== null)       $tail .= " (result={$result})";
        if (is_string($errCode) && $errCode !== '') $tail .= " [error_code={$errCode}]";

        // خطاهای گذرا: شبکه/محدودیت نرخ/خطای داخلی زحل/سرویس موقتاً در دسترس نیست
        if ($httpCode === 0 || $httpCode === 408 || $httpCode === 429 || $httpCode >= 500 || $result === 5) {
            return ['success'=>false, 'iban'=>null, 'transient'=>true,
                    'error'=>"خطای موقت زحل (HTTP {$httpCode}){$tail}"];
        }
        if ($httpCode < 200 || $httpCode >= 300) {
            return ['success'=>false, 'iban'=>null, 'transient'=>false,
                    'error'=>"پاسخ HTTP {$httpCode} از زحل{$tail}"];
        }
        if (!is_array($resp)) {
            return ['success'=>false, 'iban'=>null, 'transient'=>true,
                    'error'=>'پاسخ نامعتبر (JSON خراب) از زحل'];
        }

        if ($result === 1) {
            $data = (is_array($resp['response_body'] ?? null) ? ($resp['response_body']['data'] ?? []) : []);
            $iban = is_array($data) ? trim((string)($data['IBAN'] ?? '')) : '';
            if ($iban === '') {
                return ['success'=>false, 'iban'=>null, 'transient'=>false,
                        'error'=>'زحل شبا برنگرداند' . $tail];
            }
            return [
                'success'  => true,
                'iban'     => strtoupper($iban),
                'name'     => trim((string)($data['name'] ?? '')) ?: null,
                'bankName' => trim((string)($data['bank_name'] ?? '')) ?: null,
            ];
        }

        return ['success'=>false, 'iban'=>null, 'transient'=>false,
                'error'=>'زحل: ' . ($msg !== '' ? $msg : 'خطای نامشخص') . $tail];
    }

    // ---------------------------------------------------------------- HTTP

    private static function newHandle(string $url, ?array $json): \CurlHandle {
        $ch = curl_init($url);
        curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
        curl_setopt($ch, CURLOPT_HTTPHEADER, self::headers());
        curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 8);
        curl_setopt($ch, CURLOPT_TIMEOUT, 25);
        if ($json !== null) {
            curl_setopt($ch, CURLOPT_POST, true);
            curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($json, JSON_UNESCAPED_UNICODE));
        }
        return $ch;
    }

    /** یک دور استعلام همزمان، بسته‌بسته تا سقف concurrency. */
    private static function batchOnce(array $cards): array {
        $out = [];
        $url = self::baseUrl() . '/services/inquiry/card_to_iban';

        foreach (array_chunk($cards, self::concurrency()) as $chunk) {
            $mh = curl_multi_init();
            $handles = [];
            foreach ($chunk as $card) {
                $ch = self::newHandle($url, ['card_number' => (string)$card]);
                curl_multi_add_handle($mh, $ch);
                $handles[(string)$card] = $ch;
            }
            do {
                $status = curl_multi_exec($mh, $active);
                if ($active) curl_multi_select($mh, 1.0);
            } while ($active && $status == CURLM_OK);

            foreach ($handles as $card => $ch) {
                $raw     = curl_multi_getcontent($ch);
                $http    = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
                $curlErr = (string)curl_error($ch);
                curl_multi_remove_handle($mh, $ch);
                curl_close($ch);
                $out[$card] = self::interpret($http, $curlErr, is_string($raw) ? $raw : null);
            }
            curl_multi_close($mh);
        }
        return $out;
    }

    /**
     * تبدیل چند کارت به شبا. خطاهای گذرا چند بار دیگر تلاش می‌شوند تا کارت
     * سالم به‌خاطر یک اختلال لحظه‌ای کنار گذاشته نشود.
     */
    public static function cardToIBANBatch(array $cards): array {
        $cards = array_values(array_unique(array_filter($cards)));
        if (!$cards) return [];
        if (self::token() === '') {
            $out = [];
            foreach ($cards as $c) {
                $out[(string)$c] = ['success'=>false, 'iban'=>null, 'transient'=>false,
                                    'error'=>'توکن زحل (ZOHAL_TOKEN) در config تنظیم نشده'];
            }
            return $out;
        }

        $attempts = defined('ZOHAL_RETRY_ATTEMPTS') ? max(1, (int)ZOHAL_RETRY_ATTEMPTS) : 3;
        $delayMs  = defined('ZOHAL_RETRY_DELAY_MS') ? max(0, (int)ZOHAL_RETRY_DELAY_MS) : 500;

        $out = []; $pending = $cards;
        for ($i = 1; $i <= $attempts && $pending; $i++) {
            if ($i > 1 && $delayMs > 0) usleep($delayMs * 1000 * ($i - 1)); // backoff خطی
            $res = self::batchOnce($pending);
            $retry = [];
            foreach ($res as $card => $r) {
                $out[$card] = $r;
                if (empty($r['success']) && !empty($r['transient'])) $retry[] = (string)$card;
            }
            $pending = $retry;
        }
        foreach ($out as $card => $r) {
            if (empty($r['success']) && !empty($r['transient'])) {
                $out[$card]['error'] = ($r['error'] ?? 'خطای گذرا') . " — پس از {$attempts} تلاش";
            }
        }
        return $out;
    }

    /** نسخهٔ کش‌دار. فقط نتایج موفق کش می‌شوند. */
    public static function cardToIBANBatchCached(array $cards): array {
        $cards = array_values(array_unique(array_filter($cards)));
        if (!$cards) return [];
        $cache = self::loadCache();

        $missing = []; $out = [];
        foreach ($cards as $card) {
            $card = preg_replace('/\D+/', '', (string)$card);
            if (strlen($card) !== 16) {
                $out[$card] = ['success'=>false, 'iban'=>null, 'error'=>'شماره کارت نامعتبر', 'transient'=>false];
                continue;
            }
            if (self::isCacheEntryFresh($cache[$card] ?? null)) $out[$card] = $cache[$card];
            else $missing[] = $card;
        }

        if ($missing) {
            $fresh = self::cardToIBANBatch($missing);
            $dirty = false;
            foreach ($fresh as $card => $res) {
                if (!empty($res['success'])) {
                    $cache[$card] = $res + ['cached_at' => time(), 'provider' => 'zohal'];
                    $dirty = true;
                }
                $out[$card] = $res;
            }
            if ($dirty) self::saveCache($cache);
        }
        return $out;
    }

    /** تبدیل یک کارت (با همان کش و همان تفسیر خطا). */
    public static function cardToIBAN(string $card): array {
        $card = preg_replace('/\D+/', '', $card);
        if (strlen($card) !== 16) {
            return ['success'=>false, 'iban'=>null, 'error'=>'شماره کارت نامعتبر', 'transient'=>false];
        }
        $res = self::cardToIBANBatchCached([$card]);
        return $res[$card] ?? ['success'=>false, 'iban'=>null, 'error'=>'پاسخی دریافت نشد', 'transient'=>true];
    }

    // -------------------------------------------------------------- wallet

    /**
     * موجودی کیف پول زحل (به ریال).
     *
     * GET /financial/wallet/balance/
     *
     * شکل دقیق پاسخ در مستندات OpenAPI نیامده، پس چند کلید محتمل بررسی
     * می‌شود و اگر عددی پیدا نشد، پاسخ خام برگردانده می‌شود تا قابل دیدن باشد.
     *
     * @return array ['success'=>bool, 'rial'=>?int, 'error'=>?string, 'raw'=>?string]
     */
    public static function walletBalance(): array {
        if (self::token() === '') {
            return ['success'=>false, 'rial'=>null, 'error'=>'توکن زحل (ZOHAL_TOKEN) در config تنظیم نشده'];
        }
        $ch = self::newHandle(self::baseUrl() . '/financial/wallet/balance/', null);
        $raw     = curl_exec($ch);
        $http    = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
        $curlErr = (string)curl_error($ch);
        curl_close($ch);

        if ($raw === false || $curlErr !== '') {
            return ['success'=>false, 'rial'=>null, 'error'=>'خطای شبکه: ' . ($curlErr ?: 'نامشخص')];
        }
        $resp = json_decode((string)$raw, true);
        if (!is_array($resp)) {
            return ['success'=>false, 'rial'=>null,
                    'error'=>"پاسخ نامعتبر از زحل (HTTP {$http})", 'raw'=>mb_substr((string)$raw, 0, 300)];
        }
        if ($http < 200 || $http >= 300) {
            $m = trim((string)($resp['response_body']['message'] ?? $resp['message'] ?? ''));
            return ['success'=>false, 'rial'=>null,
                    'error'=>"پاسخ HTTP {$http} از زحل" . ($m !== '' ? ' — ' . $m : ''),
                    'raw'=>mb_substr((string)$raw, 0, 300)];
        }

        $rial = self::findBalance($resp);
        if ($rial === null) {
            return ['success'=>false, 'rial'=>null,
                    'error'=>'مقدار موجودی در پاسخ پیدا نشد', 'raw'=>mb_substr((string)$raw, 0, 300)];
        }
        return ['success'=>true, 'rial'=>$rial, 'raw'=>mb_substr((string)$raw, 0, 300)];
    }

    /** جست‌وجوی بازگشتی دنبال کلیدی که موجودی را نگه داشته. */
    private static function findBalance($node, int $depth = 0): ?int {
        if ($depth > 5 || !is_array($node)) return null;
        foreach (['balance', 'wallet_balance', 'amount', 'credit', 'inventory'] as $k) {
            if (!array_key_exists($k, $node)) continue;
            $v = $node[$k];
            if (is_int($v))   return $v;
            if (is_float($v)) return (int)round($v);
            if (is_string($v)) {
                $t = Utils::toEnglishDigits(trim($v));
                if ($t !== '' && preg_match('/^-?[\d,\s٬]+(?:\.\d+)?$/u', $t)) {
                    $neg    = str_starts_with($t, '-');
                    $digits = preg_replace('/[^\d.]/', '', $t);
                    if ($digits === '' || $digits === '.') continue;
                    return (int)round((float)$digits) * ($neg ? -1 : 1);
                }
            }
        }
        foreach ($node as $v) {
            if (is_array($v)) {
                $found = self::findBalance($v, $depth + 1);
                if ($found !== null) return $found;
            }
        }
        return null;
    }
}