<?php

declare(strict_types=1);

namespace app\common\library\scrm;

use app\common\service\SplitTicketSyncLogger;
use Exception;

/**
 * 云控蜘蛛抽象基类（Node Headless 拦截 + 翻页 + 清洗）
 */
abstract class AbstractScrmSpider implements ScrmSpiderInterface
{
    public const MODE_FETCH = 'fetch';

    public const MODE_UI = 'ui_click';

    protected string $nodeHost;

    public function __construct(string $nodeHost = 'http://127.0.0.1:3001')
    {
        $this->nodeHost = rtrim($nodeHost, '/');
    }

    /** @return array<string, mixed> */
    abstract protected function getSpiderConfig(): array;

    /**
     * @param array<string, mixed>|null $countData
     */
    abstract protected function extractListTotalPages($listFirstPageData, $countData = null);

    /**
     * @return array<string, mixed>
     */
    abstract protected function buildListPageParams(int $page): array;

    /** @return array<string, mixed> */
    abstract protected function getUiPaginationConfig(): array;

    /**
     * @param mixed $detailData
     * @param array<int, mixed> $allListPagesData
     */
    abstract protected function parseToUnifiedData($detailData, array $allListPagesData): UnifiedScrmData;

    public function run(): UnifiedScrmData
    {
        $config = $this->getSpiderConfig();
        SplitTicketSyncLogger::log('spider', 'run start', [
            'nodeHost'       => $this->nodeHost,
            'pageUrl'        => $config['pageUrl'] ?? '',
            'listApi'        => $config['listApi'] ?? '',
            'paginationMode' => $config['paginationMode'] ?? self::MODE_FETCH,
        ]);

        $listApi = (string) ($config['listApi'] ?? '');
        $detailApi = $config['detailApi'] ?? null;
        $countApi = $config['countApi'] ?? null;

        $apiUrlsToIntercept = [$listApi];
        if ($detailApi) {
            $apiUrlsToIntercept[] = $detailApi;
        }
        if ($countApi) {
            $apiUrlsToIntercept[] = $countApi;
        }

        $antiBot = $config['antiBot'] ?? null;

        $initResult = $this->requestNode('/api/auth-and-intercept', [
            'pageUrl'     => $config['pageUrl'],
            'apiUrls'     => $apiUrlsToIntercept,
            'authActions' => $config['authActions'] ?? [],
            'antiBot'     => $antiBot,
        ]);

        if (empty($initResult['success'])) {
            $cfCode = (string) ($initResult['code'] ?? '');
            SplitTicketSyncLogger::log('spider', 'auth-and-intercept failed', [
                'error'         => $initResult['error'] ?? '未知',
                'code'          => $cfCode !== '' ? $cfCode : null,
                'challengeType' => $initResult['challengeType'] ?? null,
                'stage'         => $initResult['stage'] ?? null,
                'cf'            => $initResult['cf'] ?? null,
            ]);
            $errMsg = (string) ($initResult['error'] ?? '未知');
            if ($cfCode === 'CF_TURNSTILE_FAILED') {
                throw new Exception('Cloudflare Turnstile 验证失败: ' . ($initResult['stage'] ?? 'timeout'));
            }
            throw new Exception('初始化失败: ' . $errMsg);
        }

        $interceptedApis = $initResult['interceptedApis'];
        $finalPageUrl = (string) ($initResult['finalPageUrl'] ?? ($config['pageUrl'] ?? ''));
        SplitTicketSyncLogger::log('spider', 'auth-and-intercept ok', [
            'intercepted'  => array_keys($interceptedApis),
            'finalPageUrl' => $finalPageUrl,
        ]);
        $cookies = $initResult['cookies'];

        if (!isset($interceptedApis[$listApi])) {
            throw new Exception("致命错误：未能拦截到必须的列表接口 [{$listApi}]");
        }

        $detailData = $detailApi && isset($interceptedApis[$detailApi])
            ? $interceptedApis[$detailApi]['data'] : null;
        $countData = $countApi && isset($interceptedApis[$countApi])
            ? $interceptedApis[$countApi]['data'] : null;

        $listApiNode = $interceptedApis[$listApi];
        $allListPagesData = [$listApiNode['data']];

        $totalPages = $this->extractListTotalPages($listApiNode['data'], $countData);
        $mode = $config['paginationMode'] ?? self::MODE_FETCH;
        SplitTicketSyncLogger::log('spider', 'pagination plan', [
            'totalPages' => $totalPages,
            'mode'       => $mode,
        ]);

        if ($totalPages > 1 || $totalPages === null) {
            if ($mode === self::MODE_FETCH && $totalPages !== null) {
                $paramList = [];
                for ($page = 2; $page <= $totalPages; $page++) {
                    $paramList[] = $this->buildListPageParams($page);
                }

                $fetchResult = $this->requestNode('/api/batch-fetch', [
                    'tasks' => [[
                        'apiPath'   => $listApi,
                        'fullUrl'   => $listApiNode['url'],
                        'headers'   => $listApiNode['headers'] ?? '',
                        'paramList' => $paramList,
                        'method'    => $config['listMethod'] ?? 'GET',
                    ]],
                    'cookies' => $cookies,
                    'antiBot' => $antiBot,
                ], 120);

                if (!empty($fetchResult['success'])) {
                    foreach ($fetchResult['results'][$listApi] as $pResult) {
                        if (!empty($pResult['success'])) {
                            $allListPagesData[] = $pResult['data'];
                        }
                    }
                }
            } elseif ($mode === self::MODE_UI) {
                $uiConfig = $this->getUiPaginationConfig();
                $firstPageData = $listApiNode['data'];
                $clicksToPerform = ($totalPages === null) ? 9999 : ($totalPages - 1);

                $uiResult = $this->requestNode('/api/ui-pagination', [
                    'apiUrl'          => $listApi,
                    'pageUrl'         => $config['pageUrl'],
                    'finalPageUrl'    => $finalPageUrl,
                    'nextBtnSelector' => $uiConfig['nextBtnSelector'] ?? '',
                    'waitMs'          => $uiConfig['waitMs'] ?? 2000,
                    'clicksToPerform' => $clicksToPerform,
                    'cookies'         => $cookies,
                    'firstPageData'   => $firstPageData,
                    'authActions'     => $config['authActions'] ?? [],
                    'antiBot'         => $antiBot,
                ], 1200);

                if (!empty($uiResult['success']) && !empty($uiResult['data'])) {
                    foreach ($uiResult['data'] as $pageData) {
                        $allListPagesData[] = $pageData;
                    }
                }
            }
        }

        $result = $this->parseToUnifiedData($detailData, $allListPagesData);
        SplitTicketSyncLogger::log('spider', 'run done', [
            'todayNewCount' => $result->todayNewCount,
            'totalOnline'   => $result->totalOnline,
            'totalOffline'  => $result->totalOffline,
            'total'         => $result->total,
            'numberCount'   => count($result->numbers),
        ]);
        return $result;
    }

    /**
     * @param array<string, mixed> $payload
     * @return array<string, mixed>
     */
    protected function requestNode(string $endpoint, array $payload, int $timeout = 60): array
    {
        $maxAttempts = 3;
        $lastDecoded = [];
        for ($attempt = 1; $attempt <= $maxAttempts; $attempt++) {
            $lastDecoded = $this->doRequestNode($endpoint, $payload, $timeout, $attempt);
            if (!empty($lastDecoded['success'])) {
                return $lastDecoded;
            }
            $error = (string) ($lastDecoded['error'] ?? '');
            if (!$this->shouldRetryNodeResponse($lastDecoded, $error) || $attempt >= $maxAttempts) {
                return $lastDecoded;
            }
            $delaySeconds = $attempt;
            SplitTicketSyncLogger::log('node_retry', 'retry ' . $endpoint, [
                'attempt' => $attempt + 1,
                'error'   => $error,
                'delay'   => $delaySeconds,
            ]);
            sleep($delaySeconds);
        }
        return $lastDecoded;
    }

    /**
     * @param array<string, mixed> $payload
     * @return array<string, mixed>
     */
    private function doRequestNode(string $endpoint, array $payload, int $timeout, int $attempt): array
    {
        $url = $this->nodeHost . $endpoint;
        $started = microtime(true);
        $logPayload = SplitTicketSyncLogger::isEnabled() ? $payload : self::summarizeNodePayload($payload);
        SplitTicketSyncLogger::log('node_request', 'POST ' . $endpoint, [
            'url'     => $url,
            'timeout' => $timeout,
            'attempt' => $attempt,
            'payload' => $logPayload,
        ]);

        $ch = curl_init($url);
        curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
        curl_setopt($ch, CURLOPT_POST, true);
        curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload, JSON_UNESCAPED_UNICODE));
        curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']);
        curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 5);
        curl_setopt($ch, CURLOPT_TIMEOUT, $timeout);
        $response = curl_exec($ch);
        $httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
        $elapsedMs = (int) round((microtime(true) - $started) * 1000);
        if (curl_errno($ch)) {
            $err = curl_error($ch);
            curl_close($ch);
            SplitTicketSyncLogger::log('node_response', 'curl error on ' . $endpoint, [
                'httpCode'  => $httpCode,
                'elapsedMs' => $elapsedMs,
                'attempt'   => $attempt,
                'error'     => $err,
            ]);
            throw new Exception($err);
        }
        curl_close($ch);
        $decoded = json_decode((string) $response, true);
        $summary = is_array($decoded) ? self::summarizeNodeResponse($decoded) : ['raw' => mb_substr((string) $response, 0, 300, 'UTF-8')];
        SplitTicketSyncLogger::log('node_response', 'POST ' . $endpoint, array_merge([
            'httpCode'     => $httpCode,
            'elapsedMs'    => $elapsedMs,
            'attempt'      => $attempt,
            'responseSize' => strlen((string) $response),
        ], $summary));
        if (!is_array($decoded)) {
            return ['success' => false, 'error' => 'Node 返回非 JSON', 'httpCode' => $httpCode];
        }
        if ($httpCode === 503) {
            $decoded['success'] = false;
            $decoded['error'] = (string) ($decoded['error'] ?? '服务繁忙');
            $decoded['httpCode'] = 503;
        }
        return $decoded;
    }

    /**
     * @param array<string, mixed> $decoded
     */
    private function shouldRetryNodeResponse(array $decoded, string $error): bool
    {
        if ((int) ($decoded['httpCode'] ?? 0) === 503) {
            return true;
        }
        $lower = mb_strtolower($error, 'UTF-8');
        foreach (['排队超时', 'queue_timeout', 'timeout', 'net::', 'navigation', 'connection'] as $needle) {
            if ($needle !== '' && mb_strpos($lower, mb_strtolower($needle, 'UTF-8')) !== false) {
                return true;
            }
        }
        return false;
    }

    /**
     * @param array<string, mixed> $payload
     * @return array<string, mixed>
     */
    private static function summarizeNodePayload(array $payload): array
    {
        $summary = $payload;
        if (isset($summary['tasks']) && is_array($summary['tasks'])) {
            $summary['tasks'] = array_map(static function ($task) {
                if (!is_array($task)) {
                    return $task;
                }
                $copy = $task;
                if (isset($copy['paramList']) && is_array($copy['paramList'])) {
                    $copy['paramList_count'] = count($copy['paramList']);
                    unset($copy['paramList']);
                }
                return $copy;
            }, $summary['tasks']);
        }
        if (isset($summary['cookies']) && is_array($summary['cookies'])) {
            $summary['cookies_count'] = count($summary['cookies']);
            unset($summary['cookies']);
        }
        return $summary;
    }

    /**
     * @param array<string, mixed> $decoded
     * @return array<string, mixed>
     */
    private static function summarizeNodeResponse(array $decoded): array
    {
        $summary = [
            'success' => $decoded['success'] ?? null,
            'error'   => $decoded['error'] ?? null,
        ];
        if (isset($decoded['interceptedApis']) && is_array($decoded['interceptedApis'])) {
            $summary['interceptedApis'] = array_keys($decoded['interceptedApis']);
        }
        if (isset($decoded['results']) && is_array($decoded['results'])) {
            $summary['resultApis'] = array_keys($decoded['results']);
        }
        if (isset($decoded['data']) && is_array($decoded['data'])) {
            $summary['dataPages'] = count($decoded['data']);
        }
        return $summary;
    }
}
