<?php
declare(strict_types=1);

require_once __DIR__ . DIRECTORY_SEPARATOR . 'spa_http.php';
require_once __DIR__ . DIRECTORY_SEPARATOR . 'spa_store_normalize.php';

/**
 * 同步前檢查：金鑰已設定、新手村狀態為「啟用」、API 實測可通過。
 *
 * @return array{ok:bool, error?:string, hint_zh?:string}
 */
function fate_spa_sync_api_key_gate(): array
{
    $key = defined('FATE_SPA_STORE_API_KEY') ? trim((string) FATE_SPA_STORE_API_KEY) : '';
    if ($key === '') {
        return [
            'ok' => false,
            'error' => 'api_key_missing',
            'hint_zh' => '未設定新手村店家公開 API 金鑰。請至「新手村 API」或「網站與模版」貼上已啟用的金鑰。',
        ];
    }

    $base = defined('FATE_SPA_API_BASE') ? rtrim((string) FATE_SPA_API_BASE, '/') : '';
    if ($base === '') {
        return ['ok' => false, 'error' => 'api_base_missing', 'hint_zh' => 'FATE_SPA_API_BASE 未設定。'];
    }

    if (!function_exists('fate_spa_store_public_client_status_fetch')) {
        require_once __DIR__ . DIRECTORY_SEPARATOR . 'spa_store_public_client_key.php';
    }

    $siteUrl = function_exists('fate_spa_member_site_url') ? fate_spa_member_site_url() : '';
    $st = fate_spa_store_public_client_status_fetch($key, $siteUrl !== '' ? $siteUrl : null);
    $d = $st['ok'] ? ($st['data'] ?? []) : [];

    if (!empty($d['catalog_auth_mismatch'])) {
        return fate_spa_sync_api_key_gate_unauthorized_hint($key, $base, 0, '', $d, $st);
    }

    $probeUrl = $base . '/api/store_public_catalog.php?limit=1&offset=0';
    $probe = fate_spa_http_get_json($probeUrl, $key, 20);
    $http = (int) ($probe['http'] ?? 0);
    $pdata = isset($probe['data']) && is_array($probe['data']) ? $probe['data'] : [];

    if ($probe['ok'] && !empty($pdata['ok'])) {
        $authType = (string) ($d['auth_type'] ?? 'client');
        $hint = $authType === 'site_master'
            ? '已通過新手村 API 驗證（全站主金鑰）。'
            : '已通過新手村 API 驗證（狀態：' . (string) ($d['status_label'] ?? '啟用') . '）。';

        return ['ok' => true, 'hint_zh' => $hint];
    }

    $apiErr = isset($pdata['error']) ? trim((string) $pdata['error']) : '';
    if ($http === 401 || $http === 403 || str_contains($apiErr, '未授權') || str_contains($apiErr, '未啟用')) {
        return fate_spa_sync_api_key_gate_unauthorized_hint($key, $base, $http, $apiErr, $d, $st);
    }

    if (!$st['ok']) {
        return [
            'ok' => false,
            'error' => 'status_check_failed',
            'hint_zh' => '無法確認金鑰狀態，且無法連線店家目錄：' . (string) ($st['error'] ?? $probe['error'] ?? '請檢查網路'),
        ];
    }

    $authType = (string) ($d['auth_type'] ?? '');
    if ($authType !== 'site_master') {
        if (empty($d['registered'])) {
            return [
                'ok' => false,
                'error' => 'not_registered',
                'hint_zh' => '此金鑰尚未在新手村通過申請。請先完成 API 金鑰申請並待站方「啟用」。',
            ];
        }
        if (empty($d['api_allowed'])) {
            $label = (string) ($d['status_label'] ?? '未啟用');

            return [
                'ok' => false,
                'error' => 'api_key_not_active',
                'hint_zh' => '金鑰狀態為「' . $label . '」，無法同步。須為「啟用」後才可呼叫 API。',
            ];
        }
    }

    $fallbackErr = $apiErr !== '' ? $apiErr : (string) ($probe['error'] ?? '連線失敗');

    return [
        'ok' => false,
        'error' => 'spa_probe_failed',
        'hint_zh' => '無法向新手村（' . $base . '）取得店家目錄：' . $fallbackErr,
    ];
}

/**
 * @param array<string, mixed> $d status API 資料
 * @param array{ok:bool, source?:string} $st fetch 結果
 */
function fate_spa_sync_api_key_gate_unauthorized_hint(
    string $key,
    string $base,
    int $http,
    string $apiErr,
    array $d,
    array $st
): array {
    if (isset($d['host_matches_key']) && $d['host_matches_key'] === false) {
        $fromKey = (string) ($d['host_from_key'] ?? '?');
        $siteHost = (string) ($d['site_host'] ?? '?');

        return [
            'ok' => false,
            'error' => 'host_mismatch',
            'hint_zh' => '金鑰所屬網域（' . $fromKey . '）與申請紀錄（' . $siteHost . '）不一致，新手村會拒絕通行。請在 FATE「新手村 API」重新產生金鑰，並以正確網址重新申請、啟用後，再貼至「網站與模版」。',
        ];
    }

    if (!empty($d['catalog_auth_mismatch'])) {
        $deployHint = trim((string) ($d['deploy_hint'] ?? ''));

        return [
            'ok' => false,
            'error' => 'spa_catalog_auth_mismatch',
            'hint_zh' => ($deployHint !== '' ? $deployHint : '新手村狀態顯示已啟用，但店家目錄 API 仍拒絕金鑰。')
                . ' 請站方將新手村程式更新至最新版（store_public_api validate_key 須傳入 PDO）後，在 FATE「新手村 API」按重新查詢再同步。',
        ];
    }

    $label = (string) ($d['status_label'] ?? '');
    $source = (string) ($st['source'] ?? ($d['source'] ?? ''));
    $keyHint = (string) ($d['key_hint'] ?? '');
    $parts = [];

    if ($http > 0) {
        $parts[] = '新手村回傳 HTTP ' . $http;
    }
    if ($apiErr !== '') {
        $parts[] = $apiErr;
    }

    $verifyReason = trim((string) ($d['verify_reason'] ?? ''));
    $verifyCode = (string) ($d['verify_code'] ?? '');
    if ($verifyReason !== '' && ($d['verify_ok'] ?? null) === false) {
        $suffix = $verifyCode === 'site_origin_mismatch'
            ? '。請確認「網站與模版」的對外網址與金鑰申請站點相同，且勿將他站金鑰貼至本站。'
            : '。請至「新手村 API」確認金鑰與申請網址一致，並將同一把金鑰貼至「網站與模版」。';

        return [
            'ok' => false,
            'error' => $verifyCode !== '' ? $verifyCode : 'verify_failed',
            'hint_zh' => $verifyReason . $suffix,
        ];
    }

    if ($label === '啟用' || ($d['status'] ?? '') === 'active') {
        $msg = '實際呼叫新手村 API 仍遭拒絕'
            . ($parts !== [] ? '（' . implode('；', $parts) . '）' : '')
            . '。請檢查：①「網站與模版」金鑰是否與申請時完全相同（尾碼'
            . ($keyHint !== '' ? '：' . $keyHint : '')
            . '）；② 申請網址須與金鑰所屬網站一致（例：https://fate.novice.tw/）';
        if ($source === 'local_db') {
            $msg .= '；③ 狀態來自本機資料庫，與 ' . $base . ' 可能不是同一套資料，請在該站重新審核啟用';
        }
        $msg .= '。';

        return ['ok' => false, 'error' => 'spa_http_unauthorized', 'hint_zh' => $msg];
    }

    if (($d['status'] ?? '') === 'pending') {
        return [
            'ok' => false,
            'error' => 'api_key_not_active',
            'hint_zh' => '金鑰仍為「申請中」，須待新手村站方啟用後才能同步。',
        ];
    }

    return [
        'ok' => false,
        'error' => 'spa_http_unauthorized',
        'hint_zh' => '新手村拒絕此金鑰'
            . ($parts !== [] ? '（' . implode('；', $parts) . '）' : '')
            . '。請至「新手村 API」確認已啟用，並將同一把金鑰貼至「網站與模版」。',
    ];
}

/**
 * 同步阻擋訊息（給後台／使用者看的白話文，勿顯示 error 代碼）。
 */
function fate_spa_sync_gate_user_message(array $gate): string
{
    $hint = trim((string) ($gate['hint_zh'] ?? ''));
    if ($hint !== '') {
        return $hint;
    }

    $code = (string) ($gate['error'] ?? '');

    return match ($code) {
        'api_key_missing' => '尚未設定新手村店家公開 API 金鑰。請至「新手村 API」或「網站與模版」貼上金鑰。',
        'api_key_not_active' => '金鑰尚未啟用，無法同步。請在新手村完成申請並待站方「啟用」後，再將同一把金鑰用於本站同步。',
        'not_registered' => '此金鑰尚未在新手村提交申請。請先完成 API 金鑰申請並待站方審核。',
        'status_check_failed' => '無法向新手村確認金鑰狀態，請檢查網路與 FATE_SPA_API_BASE 設定。',
        'spa_http_unauthorized' => '新手村拒絕此金鑰。請確認「網站與模版」金鑰與新手村啟用的是同一把，且申請網址與金鑰所屬網站一致。',
        'host_mismatch' => '金鑰內嵌網域與申請網域不一致，請重新產生金鑰並重新申請。',
        'spa_api_denied' => '新手村拒絕同步請求，請確認金鑰狀態為「啟用」。',
        'spa_probe_failed' => '無法連線新手村店家目錄 API，請稍後再試或聯絡站方。',
        'api_base_missing' => '本站尚未設定新手村 API 網址（FATE_SPA_API_BASE）。',
        default => '目前無法開始同步，請至「新手村 API」確認金鑰為「啟用」狀態。',
    };
}

/**
 * 建立分批同步工作狀態（Session 或 CLI 迴圈共用）。
 *
 * @return array<string,mixed>
 */
function fate_spa_sync_job_create(bool $force): array
{
    return [
        'version' => 1,
        'force' => $force,
        'api_base' => FATE_SPA_API_BASE,
        'api_key' => FATE_SPA_STORE_API_KEY,
        'catalog_limit' => 200,
        'catalog_offset' => 0,
        'catalog_total' => null,
        'page_ids' => [],
        'page_idx' => 0,
        'stats' => [
            'fetched_pages' => 0,
            'stores_seen' => 0,
            'updated' => 0,
            'skipped_same_revision' => 0,
            'errors' => 0,
        ],
        'diagnostics' => [
            'catalog_base' => FATE_SPA_API_BASE,
            'api_key_set' => FATE_SPA_STORE_API_KEY !== '',
            'hint_zh' => '',
        ],
        'done' => false,
        'fatal' => null,
        'phase' => 'init',
    ];
}

/**
 * @param array<string,mixed> $job
 */
function fate_spa_sync_catalog_failure(array &$job, array $cat, string $url, int $offset, callable $logErr): void
{
    $job['stats']['errors']++;
    $transportErr = (string) ($cat['error'] ?? 'unknown');
    $http = (int) ($cat['http'] ?? 0);
    $job['diagnostics']['catalog_step'] = 'http_transport';
    $job['diagnostics']['catalog_url'] = $url;
    $job['diagnostics']['catalog_http_status'] = $http;
    $job['diagnostics']['catalog_error'] = $transportErr;
    if ($http === 401 || str_contains($transportErr, '401')) {
        $job['diagnostics']['hint_zh'] = 'HTTP 401：請確認 fate 環境變數 FATE_SPA_STORE_API_KEY 與新手村 STORE_PUBLIC_API_KEY 一致，且請求會帶上標頭 X-Store-API-Key。';
    } elseif ($http === 404) {
        $job['diagnostics']['hint_zh'] = 'HTTP 404：請確認 FATE_SPA_API_BASE 指向已部署 store_public_catalog.php 的站台（網址結尾勿重複斜線）。';
    } elseif ($http === 0 || str_contains($transportErr, 'resolve') || str_contains($transportErr, 'SSL') || str_contains($transportErr, '連線')) {
        $job['diagnostics']['hint_zh'] = '無法連線：請檢查 NAS 能否連到外網、DNS、防火牆，以及 PHP 是否啟用 curl／allow_url_fopen。';
    } elseif (str_contains($transportErr, 'JSON') || str_contains($transportErr, 'json')) {
        $job['diagnostics']['hint_zh'] = '回應不是 JSON：遠端可能回傳錯誤頁（PHP 錯誤、WAF、登入頁）。請用瀏覽器或 curl 直接請求上述 catalog_url 確認內容。';
    } else {
        $job['diagnostics']['hint_zh'] = '請對照 catalog_error／HTTP 狀態排查網路與 API 基底網址。';
    }
    $job['fatal'] = ['reason' => 'catalog_transport'];
    $logErr('catalog 失敗 offset=' . $offset . ': ' . $transportErr . ' http=' . $http);
}

/**
 * @param array<string,mixed> $job
 */
function fate_spa_sync_catalog_api_body_failure(array &$job, array $cat, string $url, array $data): void
{
    $job['stats']['errors']++;
    $apiErr = isset($data['error']) ? (string) $data['error'] : 'API 回傳 ok=false';
    $job['diagnostics']['catalog_step'] = 'api_body';
    $job['diagnostics']['catalog_url'] = $url;
    $job['diagnostics']['catalog_http_status'] = (int) ($cat['http'] ?? 0);
    $job['diagnostics']['catalog_api_error'] = $apiErr;
    if (str_contains($apiErr, '未授權') || str_contains($apiErr, '金鑰') || str_contains($apiErr, '未啟用')) {
        $job['diagnostics']['hint_zh'] = 'API 回覆未授權：金鑰須在新手村為「啟用」狀態，且與 FATE「網站與模版」設定的金鑰一致。申請中／已停用／禁止無法同步。';
    } elseif (str_contains($apiErr, '尚未啟用')) {
        $job['diagnostics']['hint_zh'] = '新手村 production 尚未設定 STORE_PUBLIC_API_KEY；請在 spa/config 設定後再同步。';
    } elseif (str_contains($apiErr, '資料庫')) {
        $detail = isset($data['detail']) && is_string($data['detail']) && $data['detail'] !== ''
            ? $data['detail']
            : '';
        $job['diagnostics']['catalog_api_detail'] = $detail;
        $job['diagnostics']['hint_zh'] = 'FATE 僅透過 HTTP 呼叫新手村 API（非直連 spa 資料庫）。此錯誤為 API 伺服器回傳：表示 spa.novice.tw 在執行 store_public_catalog 時，其主機上的 db_connect 失敗。請在該站修 MySQL／db_connect，並直接開啟 catalog_url 確認應回 {"ok":true,...}。'
            . ($detail !== '' ? ' API detail：' . $detail : '');
    } elseif ($apiErr === '讀取目錄失敗') {
        $job['diagnostics']['hint_zh'] = '新手村在目錄查詢時發生例外（常見：PDO LIMIT 綁定或 spa_stores 缺少 last_updated／place_last_updated）。請將新版 api/store_public_catalog.php 部署至 spa，或見 newbies/sql/spa_stores_catalog_columns_compat.sql；並檢視伺服器 PHP error_log。';
    } else {
        $job['diagnostics']['hint_zh'] = '請依 catalog_api_error 修正新手村端或認證設定。';
    }
    $job['fatal'] = ['reason' => 'catalog_api'];
}

/**
 * 載入下一頁目錄至 job.page_ids；失敗時設定 fatal 並回傳 false。
 *
 * @param array<string,mixed> $job
 */
function fate_spa_sync_job_refill_page(array &$job, callable $logErr): bool
{
    $base = (string) $job['api_base'];
    $key = (string) $job['api_key'];
    $limit = (int) $job['catalog_limit'];
    $offset = (int) $job['catalog_offset'];
    $url = $base . '/api/store_public_catalog.php?limit=' . $limit . '&offset=' . $offset;
    $job['phase'] = 'catalog';

    $cat = fate_spa_http_get_json($url, $key, null);
    $job['stats']['fetched_pages']++;
    $http = (int) ($cat['http'] ?? 0);

    if ($http === 401 || $http === 403) {
        $cat['error'] = 'HTTP ' . $http;
        fate_spa_sync_catalog_failure($job, $cat, $url, $offset, $logErr);
        $job['diagnostics']['hint_zh'] = '新手村拒絕金鑰（HTTP ' . $http . '）。請至「新手村 API」確認狀態為啟用。';

        return false;
    }

    if (!$cat['ok'] || !isset($cat['data']) || !is_array($cat['data'])) {
        fate_spa_sync_catalog_failure($job, $cat, $url, $offset, $logErr);
        return false;
    }
    $data = $cat['data'];
    if (empty($data['ok'])) {
        fate_spa_sync_catalog_api_body_failure($job, $cat, $url, $data);
        return false;
    }

    if ($job['catalog_total'] === null) {
        $job['catalog_total'] = (int) ($data['total'] ?? 0);
    }

    $items = isset($data['items']) && is_array($data['items']) ? $data['items'] : [];
    $job['catalog_offset'] = $offset + $limit;

    $ids = [];
    foreach ($items as $row) {
        if (!is_array($row)) {
            continue;
        }
        $sid = (int) ($row['id'] ?? 0);
        if ($sid > 0) {
            $ids[] = $sid;
        }
    }

    $job['page_ids'] = $ids;
    $job['page_idx'] = 0;

    if ($ids === []) {
        $job['done'] = true;
        $job['phase'] = 'done';
    }

    return true;
}

/**
 * @param array<string,mixed> $job
 */
function fate_spa_sync_job_process_one_id(
    array &$job,
    PDO $pdo,
    PDOStatement $upsert,
    PDOStatement $selectLocal,
    int $sid,
    callable $logErr
): void {
    $base = (string) $job['api_base'];
    $key = (string) $job['api_key'];
    $force = (bool) $job['force'];

    $job['stats']['stores_seen']++;
    $job['phase'] = 'detail';

    $detailUrl = $base . '/api/store_public.php?id=' . $sid . '&omit_google_reviews=1';
    $res = fate_spa_http_get_json($detailUrl, $key, null);
    if (!$res['ok'] || !isset($res['data']) || !is_array($res['data'])) {
        $job['stats']['errors']++;
        return;
    }
    $payload = $res['data'];
    if (empty($payload['ok'])) {
        $job['stats']['errors']++;
        return;
    }
    $meta = isset($payload['meta']) && is_array($payload['meta']) ? $payload['meta'] : [];
    $store = isset($payload['store']) && is_array($payload['store']) ? $payload['store'] : [];
    $remoteUnix = (int) ($meta['data_as_of_unix'] ?? 0);

    if (!$force) {
        $selectLocal->execute([$sid]);
        $localUnix = (int) $selectLocal->fetchColumn();
        if ($localUnix > 0 && $remoteUnix > 0 && $localUnix >= $remoteUnix) {
            $job['stats']['skipped_same_revision']++;
            return;
        }
    }

    $syncedAt = date('Y-m-d H:i:s');
    $payloadArchive = null;
    if (defined('FATE_SYNC_ARCHIVE_JSON_PAYLOAD') && FATE_SYNC_ARCHIVE_JSON_PAYLOAD) {
        try {
            $payloadArchive = json_encode(['meta' => $meta, 'store' => $store], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
        } catch (Throwable $e) {
            $payloadArchive = null;
        }
    }

    $row = fate_spa_normalize_store_row($meta, $store, $syncedAt, $payloadArchive);
    $row['spa_store_id'] = $sid;

    try {
        $upsert->execute($row);
        $job['stats']['updated']++;
    } catch (Throwable $e) {
        $job['stats']['errors']++;
        $logErr('upsert id=' . $sid . ': ' . $e->getMessage());
    }
}

/**
 * 同步單一店家至 spa_store_snapshots（新增店後立即出現於 FATE 前台）。
 *
 * @return array{ok:bool,error?:string,updated?:bool}
 */
function fate_spa_sync_snapshot_for_store(PDO $pdo, int $storeId): array
{
    if ($storeId <= 0) {
        return ['ok' => false, 'error' => '無效的店家 ID'];
    }
    if (!defined('FATE_SPA_API_BASE')) {
        require_once dirname(__DIR__) . DIRECTORY_SEPARATOR . 'config' . DIRECTORY_SEPARATOR . 'app.php';
    }

    $job = fate_spa_sync_job_create(true);
    $upsert = $pdo->prepare(fate_spa_normalized_upsert_sql());
    $selectLocal = $pdo->prepare('SELECT source_data_as_of_unix FROM spa_store_snapshots WHERE spa_store_id = ? LIMIT 1');
    $log = static function (string $msg): void {
        error_log('fate_spa_sync_snapshot_for_store: ' . $msg);
    };

    fate_spa_sync_job_process_one_id($job, $pdo, $upsert, $selectLocal, $storeId, $log);

    if ((int) ($job['stats']['updated'] ?? 0) > 0) {
        return ['ok' => true, 'updated' => true];
    }
    if ((int) ($job['stats']['errors'] ?? 0) > 0) {
        return ['ok' => false, 'error' => '無法從新手村 API 同步此店（請確認 FATE_SPA_API_BASE 與金鑰）'];
    }

    return ['ok' => true, 'updated' => false];
}

/**
 * 執行至多 $maxDetailCalls 筆店家拉詳細並入庫；用於後台分批請求避免 504。
 *
 * @param array<string,mixed> $job
 * @return array<string,mixed> 給前端的快照（含 progress_percent）
 */
function fate_spa_sync_job_step(PDO $pdo, array &$job, int $maxDetailCalls, bool $verboseStderr = false): array
{
    $logErr = static function (string $msg) use ($verboseStderr): void {
        if ($verboseStderr && PHP_SAPI === 'cli') {
            fwrite(STDERR, $msg . "\n");
        }
    };

    if (!empty($job['done']) || $job['fatal'] !== null) {
        return fate_spa_sync_job_snapshot($job);
    }

    $remainingGuard = 520;
    $detailCalls = 0;

    $upsertSql = fate_spa_normalized_upsert_sql();
    $upsert = $pdo->prepare($upsertSql);
    $selectLocal = $pdo->prepare('SELECT source_data_as_of_unix FROM spa_store_snapshots WHERE spa_store_id = ? LIMIT 1');

    while ($detailCalls < $maxDetailCalls && $job['fatal'] === null && !$job['done'] && $remainingGuard-- > 0) {
        if ($job['page_idx'] >= count($job['page_ids'])) {
            if (!fate_spa_sync_job_refill_page($job, $logErr)) {
                break;
            }
            if ($job['done'] || $job['fatal'] !== null) {
                break;
            }
        }

        if ($job['page_idx'] >= count($job['page_ids'])) {
            break;
        }

        $sid = (int) $job['page_ids'][$job['page_idx']];
        $job['page_idx']++;
        fate_spa_sync_job_process_one_id($job, $pdo, $upsert, $selectLocal, $sid, $logErr);
        $detailCalls++;
    }

    if ($job['stats']['fetched_pages'] > 500) {
        $job['fatal'] = ['reason' => 'page_guard'];
        $job['diagnostics']['hint_zh'] = '目錄分頁超過安全上限，已中止。';
        $job['stats']['errors']++;
    }

    return fate_spa_sync_job_snapshot($job, $detailCalls);
}

/**
 * @param array<string,mixed> $job
 * @return array<string,mixed>
 */
function fate_spa_sync_job_snapshot(array $job, int $processed_this_round = 0): array
{
    $total = $job['catalog_total'];
    $seen = (int) ($job['stats']['stores_seen'] ?? 0);
    $pct = null;
    if ($total !== null && $total > 0) {
        $pct = min(100.0, round(($seen / $total) * 100, 1));
    }

    $naturalDone = !empty($job['done']);
    $fatal = $job['fatal'] !== null;
    $finished = $naturalDone || $fatal;

    return [
        'finished' => $finished,
        'fatal' => $fatal,
        'phase' => (string) ($job['phase'] ?? ''),
        'progress_percent' => $pct,
        'catalog_total' => $job['catalog_total'],
        'stats' => $job['stats'],
        'processed_this_round' => $processed_this_round,
        'diagnostics' => $job['diagnostics'],
        'hint_zh' => (string) ($job['diagnostics']['hint_zh'] ?? ''),
        'sync_ok' => $finished && !$fatal && (int) ($job['stats']['errors'] ?? 0) === 0,
    ];
}

/**
 * @param array<string,mixed> $job
 * @return array{ok:bool,stats:array<string,int>,catalog_total:?int,diagnostics:array<string,mixed>}
 */
function fate_spa_sync_job_to_legacy_result(array $job): array
{
    $fatal = $job['fatal'] !== null;
    $errors = (int) ($job['stats']['errors'] ?? 0);
    return [
        'ok' => !$fatal && $errors === 0,
        'stats' => $job['stats'],
        'catalog_total' => $job['catalog_total'],
        'diagnostics' => $job['diagnostics'],
    ];
}

/**
 * 自新手村公開 API 同步店家至 fate.spa_store_snapshots（不含 Google 評論陣列、不含技師）。
 * CLI／一次性請求：內部改為多輪 step，與後台分批邏輯一致。
 *
 * @return array{ok:bool,stats:array<string,int>,catalog_total:?int,diagnostics:array<string,mixed>}
 */
function fate_run_spa_store_sync(PDO $pdo, bool $force, bool $verboseStderr = false): array
{
    @set_time_limit(0);
    $gate = fate_spa_sync_api_key_gate();
    if (!$gate['ok']) {
        return [
            'ok' => false,
            'catalog_total' => 0,
            'stats' => [
                'fetched_pages' => 0,
                'stores_seen' => 0,
                'updated' => 0,
                'skipped_same_revision' => 0,
                'errors' => 1,
            ],
            'fatal' => ['reason' => (string) ($gate['error'] ?? 'api_key_blocked')],
            'hint_zh' => (string) ($gate['hint_zh'] ?? ''),
        ];
    }
    $job = fate_spa_sync_job_create($force);
    $job['diagnostics']['api_key_gate'] = (string) ($gate['hint_zh'] ?? '');
    $chunk = defined('FATE_SYNC_CLI_CHUNK_DETAIL') ? FATE_SYNC_CLI_CHUNK_DETAIL : 250;

    while (!$job['done'] && $job['fatal'] === null) {
        fate_spa_sync_job_step($pdo, $job, $chunk, $verboseStderr);
    }

    return fate_spa_sync_job_to_legacy_result($job);
}

/**
 * 自快照表彙整店家封面／相簿 path（去重後的下載清單）。
 *
 * @return list<string>
 */
function fate_spa_image_sync_collect_paths(PDO $pdo): array
{
    return fate_spa_image_sync_build_inventory($pdo)['paths'];
}

/**
 * @return array{
 *   paths:list<string>,
 *   photo_refs:int,
 *   unique_paths:int,
 *   unique_basenames:int,
 *   stores_with_photos:int
 * }
 */
function fate_spa_image_sync_build_inventory(PDO $pdo): array
{
    $stmt = $pdo->query(
        'SELECT `cover_photo_path`, `photos_json`, `payload_json`
         FROM `spa_store_snapshots`
         WHERE `source_type` IN (\'api\', \'manual\')'
    );
    if ($stmt === false) {
        return [
            'paths' => [],
            'photo_refs' => 0,
            'unique_paths' => 0,
            'unique_basenames' => 0,
            'stores_with_photos' => 0,
        ];
    }

    $paths = [];
    $photoRefs = 0;
    $storesWithPhotos = 0;

    while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
        $before = count($paths);
        $cover = isset($row['cover_photo_path']) ? trim((string) $row['cover_photo_path']) : '';
        if ($cover !== '') {
            $photoRefs++;
            fate_spa_image_sync_register_path($paths, $cover);
        }
        fate_spa_image_sync_register_photos_json($paths, isset($row['photos_json']) ? (string) $row['photos_json'] : '', $photoRefs);

        $payloadJson = isset($row['payload_json']) ? (string) $row['payload_json'] : '';
        $photosJsonRaw = isset($row['photos_json']) ? trim((string) $row['photos_json']) : '';
        if ($payloadJson !== '' && $photosJsonRaw === '') {
            try {
                $payload = json_decode($payloadJson, true, 8, JSON_THROW_ON_ERROR);
            } catch (Throwable) {
                $payload = null;
            }
            if (is_array($payload)) {
                $store = isset($payload['store']) && is_array($payload['store']) ? $payload['store'] : [];
                $payloadCover = isset($store['cover_photo_path']) ? trim((string) $store['cover_photo_path']) : '';
                if ($payloadCover !== '' && $cover === '') {
                    $photoRefs++;
                    fate_spa_image_sync_register_path($paths, $payloadCover);
                }
                if (isset($store['photos']) && is_array($store['photos'])) {
                    foreach ($store['photos'] as $item) {
                        $photoRefs++;
                        fate_spa_image_sync_register_photo_item($paths, $item);
                    }
                }
            }
        }

        if ($cover !== '' || trim((string) ($row['photos_json'] ?? '')) !== '') {
            $storesWithPhotos++;
        } elseif (count($paths) > $before) {
            $storesWithPhotos++;
        }
    }

    $basenames = [];
    foreach ($paths as $path) {
        $basenames[strtolower(basename($path))] = true;
    }

    $uniquePaths = array_values($paths);

    return [
        'paths' => $uniquePaths,
        'photo_refs' => $photoRefs,
        'unique_paths' => count($uniquePaths),
        'unique_basenames' => count($basenames),
        'stores_with_photos' => $storesWithPhotos,
    ];
}

/** @param array<string, string> $paths */
function fate_spa_image_sync_register_photos_json(array &$paths, string $json, int &$photoRefs): void
{
    if ($json === '') {
        return;
    }
    try {
        $arr = json_decode($json, true, 64, JSON_THROW_ON_ERROR);
    } catch (Throwable) {
        return;
    }
    if (!is_array($arr)) {
        return;
    }
    foreach ($arr as $item) {
        $photoRefs++;
        fate_spa_image_sync_register_photo_item($paths, $item);
    }
}

/** @param array<string, string> $paths */
function fate_spa_image_sync_register_photo_item(array &$paths, mixed $item): void
{
    if (is_string($item)) {
        fate_spa_image_sync_register_path($paths, $item);

        return;
    }
    if (!is_array($item)) {
        return;
    }
    if (!empty($item['path']) && is_string($item['path'])) {
        fate_spa_image_sync_register_path($paths, $item['path']);

        return;
    }
    $compat = dirname(__DIR__, 2) . DIRECTORY_SEPARATOR . 'newbies' . DIRECTORY_SEPARATOR . 'includes' . DIRECTORY_SEPARATOR . 'store_photo_paths_compat.php';
    if (!function_exists('fate_path_is_file_safe')) {
        require_once __DIR__ . DIRECTORY_SEPARATOR . 'fate_path_safe.php';
    }
    if (fate_path_is_file_safe($compat)) {
        require_once $compat;
    }
    if (function_exists('store_photo_paths_item_resolve_path')) {
        $resolved = store_photo_paths_item_resolve_path($item);
        if ($resolved !== '') {
            fate_spa_image_sync_register_path($paths, $resolved);
        }
    }
}

/** @param array<string, string> $paths */
function fate_spa_image_sync_register_path(array &$paths, string $path): void
{
    $p = fate_spa_image_sync_normalize_path($path);
    if ($p === null) {
        return;
    }
    $paths[$p] = $p;
}

function fate_spa_image_sync_normalize_path(string $path): ?string
{
    $p = trim(str_replace('\\', '/', $path));
    $p = ltrim($p, '/');
    if ($p === '' || preg_match('#^https?://#i', $p) === 1) {
        return null;
    }
    if (str_contains($p, '..')) {
        return null;
    }

    return $p;
}

/**
 * @return array{count:int, bytes:int, dir:string, web_path:string}
 */
function fate_spa_stores_local_upload_stats(): array
{
    if (!function_exists('fate_spa_stores_local_upload_dir')) {
        require_once __DIR__ . DIRECTORY_SEPARATOR . 'fate_public_urls.php';
    }
    $dir = fate_spa_stores_local_upload_dir();
    $count = 0;
    $bytes = 0;
    if (is_dir($dir)) {
        $it = new DirectoryIterator($dir);
        foreach ($it as $file) {
            if ($file->isFile()) {
                $count++;
                $bytes += (int) $file->getSize();
            }
        }
    }

    return [
        'count' => $count,
        'bytes' => $bytes,
        'dir' => $dir,
        'web_path' => fate_spa_stores_local_upload_rel(),
    ];
}

/** 會員手動上傳至 uploads/stores 的檔名慣例（member_cover_… 等）。 */
function fate_spa_is_member_store_upload_filename(string $filename): bool
{
    $name = basename($filename);

    return $name !== '' && str_starts_with($name, 'member_');
}

/**
 * manual 快照中引用的 uploads/stores 檔名（小寫 basename => true）。
 *
 * @return array<string, true>
 */
function fate_spa_collect_protected_store_upload_basenames(PDO $pdo): array
{
    $protected = [];
    $stmt = $pdo->query(
        'SELECT `cover_photo_path`, `photos_json`
         FROM `spa_store_snapshots`
         WHERE `source_type` = \'manual\''
    );
    if ($stmt === false) {
        return $protected;
    }

    while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
        $cover = isset($row['cover_photo_path']) ? trim((string) $row['cover_photo_path']) : '';
        if ($cover !== '') {
            $protected[strtolower(basename($cover))] = true;
        }

        $json = isset($row['photos_json']) ? (string) $row['photos_json'] : '';
        if ($json === '') {
            continue;
        }
        try {
            $photos = json_decode($json, true, 8, JSON_THROW_ON_ERROR);
        } catch (Throwable) {
            continue;
        }
        if (!is_array($photos)) {
            continue;
        }
        foreach ($photos as $item) {
            if (is_string($item)) {
                $path = trim($item);
            } elseif (is_array($item)) {
                $path = isset($item['path']) ? trim((string) $item['path']) : '';
            } else {
                continue;
            }
            if ($path !== '') {
                $protected[strtolower(basename($path))] = true;
            }
        }
    }

    return $protected;
}

/**
 * 刪除圖片同步產生的本站快取；保留會員手動上傳。
 *
 * @return array{
 *   deleted:int,
 *   skipped_protected:int,
 *   bytes:int,
 *   errors:list<string>
 * }
 */
function fate_spa_purge_synced_store_images(PDO $pdo): array
{
    if (!function_exists('fate_spa_stores_local_upload_dir')) {
        require_once __DIR__ . DIRECTORY_SEPARATOR . 'fate_public_urls.php';
    }

    $dir = fate_spa_stores_local_upload_dir();
    $protected = fate_spa_collect_protected_store_upload_basenames($pdo);
    $deleted = 0;
    $skipped = 0;
    $bytes = 0;
    $errors = [];

    if (!is_dir($dir)) {
        return [
            'deleted' => 0,
            'skipped_protected' => 0,
            'bytes' => 0,
            'errors' => [],
        ];
    }

    $it = new DirectoryIterator($dir);
    foreach ($it as $file) {
        if (!$file->isFile()) {
            continue;
        }
        $name = $file->getFilename();
        $lower = strtolower($name);
        if (fate_spa_is_member_store_upload_filename($name) || isset($protected[$lower])) {
            $skipped++;
            continue;
        }

        $size = (int) $file->getSize();
        if (@unlink($file->getPathname())) {
            $deleted++;
            $bytes += $size;
        } else {
            $errors[] = $name;
        }
    }

    return [
        'deleted' => $deleted,
        'skipped_protected' => $skipped,
        'bytes' => $bytes,
        'errors' => $errors,
    ];
}

function fate_spa_format_bytes(int $bytes): string
{
    if ($bytes < 1024) {
        return $bytes . ' B';
    }
    if ($bytes < 1048576) {
        return round($bytes / 1024, 1) . ' KB';
    }
    if ($bytes < 1073741824) {
        return round($bytes / 1048576, 1) . ' MB';
    }

    return round($bytes / 1073741824, 2) . ' GB';
}

function fate_spa_stores_upload_free_bytes(): ?int
{
    if (!function_exists('fate_spa_stores_local_upload_dir')) {
        require_once __DIR__ . DIRECTORY_SEPARATOR . 'fate_public_urls.php';
    }
    $dir = fate_spa_stores_local_upload_dir();
    $check = is_dir($dir) ? $dir : dirname($dir);
    $free = @disk_free_space($check);

    return is_numeric($free) ? (int) $free : null;
}

/**
 * @return array<string, mixed>
 */
function fate_spa_image_sync_job_create(bool $force): array
{
    if (!function_exists('fate_spa_stores_local_upload_dir')) {
        require_once __DIR__ . DIRECTORY_SEPARATOR . 'fate_public_urls.php';
    }

    return [
        'version' => 1,
        'force' => $force,
        'image_paths' => [],
        'idx' => 0,
        'total' => 0,
        'stats' => [
            'downloaded' => 0,
            'skipped_exists' => 0,
            'skipped_invalid' => 0,
            'errors' => 0,
            'bytes' => 0,
        ],
        'diagnostics' => [
            'upload_dir' => fate_spa_stores_local_upload_dir(),
            'web_path' => fate_spa_stores_local_upload_rel(),
            'hint_zh' => '',
        ],
        'done' => false,
        'fatal' => null,
        'phase' => 'init',
    ];
}

/**
 * @param array<string, mixed> $job
 * @return array<string, mixed>
 */
function fate_spa_image_sync_job_snapshot(array $job, int $processed_this_round = 0): array
{
    $total = (int) ($job['total'] ?? 0);
    $idx = (int) ($job['idx'] ?? 0);
    $pct = null;
    if ($total > 0) {
        $pct = min(100.0, round(($idx / $total) * 100, 1));
    }

    $fatal = $job['fatal'] !== null;
    $finished = !empty($job['done']) || $fatal;

    return [
        'finished' => $finished,
        'fatal' => $fatal,
        'phase' => (string) ($job['phase'] ?? ''),
        'progress_percent' => $pct,
        'image_total' => $total,
        'stats' => $job['stats'],
        'processed_this_round' => $processed_this_round,
        'diagnostics' => $job['diagnostics'],
        'hint_zh' => (string) ($job['diagnostics']['hint_zh'] ?? ''),
        'sync_ok' => $finished && !$fatal && (int) ($job['stats']['errors'] ?? 0) === 0,
    ];
}

/**
 * @param array<string, mixed> $job
 * @return array<string, mixed>
 */
function fate_spa_image_sync_job_step(array &$job, int $maxDownloads): array
{
    if (!empty($job['done']) || $job['fatal'] !== null) {
        return fate_spa_image_sync_job_snapshot($job);
    }

    $paths = isset($job['image_paths']) && is_array($job['image_paths']) ? $job['image_paths'] : [];
    $force = !empty($job['force']);
    $processed = 0;
    $job['phase'] = 'download';

    while ($processed < $maxDownloads && $job['idx'] < count($paths)) {
        $path = (string) $paths[$job['idx']];
        $job['idx']++;
        $result = fate_spa_download_image_to_local($path, $force);
        $action = (string) ($result['action'] ?? 'error');
        if ($action === 'downloaded') {
            $job['stats']['downloaded']++;
            $job['stats']['bytes'] += (int) ($result['bytes'] ?? 0);
        } elseif ($action === 'skipped_exists') {
            $job['stats']['skipped_exists']++;
        } elseif ($action === 'skipped_invalid') {
            $job['stats']['skipped_invalid']++;
        } else {
            $job['stats']['errors']++;
        }
        $processed++;
    }

    if ($job['idx'] >= count($paths)) {
        $job['done'] = true;
        $job['phase'] = 'done';
    }

    return fate_spa_image_sync_job_snapshot($job, $processed);
}

/**
 * 將遠端圖片下載至本站 uploads/stores/。
 *
 * @return array{action:string, bytes?:int, error?:string}
 */
function fate_spa_download_image_to_local(?string $relativePath, bool $force = false): array
{
    if ($relativePath === null) {
        return ['action' => 'skipped_invalid'];
    }
    $p = fate_spa_image_sync_normalize_path($relativePath);
    if ($p === null) {
        return ['action' => 'skipped_invalid'];
    }

    if (!function_exists('fate_spa_stores_local_upload_dir')) {
        require_once __DIR__ . DIRECTORY_SEPARATOR . 'fate_public_urls.php';
    }

    $filename = basename($p);
    if ($filename === '' || $filename === '.' || $filename === '..') {
        return ['action' => 'skipped_invalid'];
    }

    $dir = fate_spa_stores_local_upload_dir();
    if (!is_dir($dir)) {
        if (!@mkdir($dir, 0775, true) && !is_dir($dir)) {
            return ['action' => 'error', 'error' => '無法建立目錄'];
        }
    }
    $dest = $dir . DIRECTORY_SEPARATOR . $filename;

    if (!$force && is_file($dest)) {
        return ['action' => 'skipped_exists'];
    }

    if (!function_exists('fate_spa_asset_remote_url')) {
        require_once __DIR__ . DIRECTORY_SEPARATOR . 'fate_public_urls.php';
    }
    $remoteUrl = fate_spa_asset_remote_url($p);
    if ($remoteUrl === null) {
        return ['action' => 'skipped_invalid'];
    }

    $apiKey = defined('FATE_SPA_STORE_API_KEY') ? trim((string) FATE_SPA_STORE_API_KEY) : '';
    if (!function_exists('fate_spa_http_request_headers')) {
        require_once __DIR__ . DIRECTORY_SEPARATOR . 'spa_http.php';
    }
    $headers = fate_spa_http_request_headers($apiKey);

    $ok = false;
    if (function_exists('curl_init')) {
        $ch = @curl_init($remoteUrl);
        if ($ch !== false) {
            $tmpDest = $dest . '.part';
            $fp = @fopen($tmpDest, 'wb');
            if ($fp !== false) {
                curl_setopt_array($ch, [
                    CURLOPT_HTTPHEADER => $headers,
                    CURLOPT_FILE => $fp,
                    CURLOPT_FOLLOWLOCATION => true,
                    CURLOPT_TIMEOUT => 45,
                    CURLOPT_CONNECTTIMEOUT => 8,
                ]);
                $execOk = @curl_exec($ch);
                $http = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
                @curl_close($ch);
                @fclose($fp);
                if ($execOk !== false && $http >= 200 && $http < 300 && is_file($tmpDest) && filesize($tmpDest) > 0) {
                    if (@rename($tmpDest, $dest) || @copy($tmpDest, $dest)) {
                        @unlink($tmpDest);
                        $ok = true;
                    }
                } else {
                    @unlink($tmpDest);
                }
            }
        }
    } else {
        $ctx = [
            'http' => [
                'timeout' => 45,
                'header' => implode("\r\n", $headers),
            ],
        ];
        $data = @file_get_contents($remoteUrl, false, stream_context_create($ctx));
        if ($data !== false && $data !== '') {
            $ok = @file_put_contents($dest, $data) !== false;
        }
    }

    if (!$ok || !is_file($dest)) {
        return ['action' => 'error', 'error' => 'download_failed'];
    }

    return ['action' => 'downloaded', 'bytes' => (int) filesize($dest)];
}
