<?php

declare(strict_types=1);

const API_BASE = 'https://luugpm.cloud/public';
const POLL_INTERVAL_MS = 2000;
const POLL_TIMEOUT_MS = 120000;
const SESSION_TTL = 7000;
const CURL_CONNECT_TIMEOUT = 5;
const CURL_TIMEOUT = 15;

// API 代理，避免浏览器跨域限制
if (isset($_GET['action'])) {
    set_time_limit(30);
    header('Content-Type: application/json; charset=utf-8');

    $action = $_GET['action'];

    try {
        ensureCacheDir();

        if ($action === 'session_status') {
            $session = loadSessionCache();
            if ($session === null || isSessionExpired($session)) {
                $cookie = ensureApiSession();
                $session = loadSessionCache() ?? [
                    'cookie' => $cookie,
                    'refreshed_at' => time(),
                    'expires_at' => time() + SESSION_TTL,
                ];
            }

            jsonOk([
                'expires_at' => $session['expires_at'],
                'expires_in' => max(0, $session['expires_at'] - time()),
                'refreshed_at' => $session['refreshed_at'],
            ]);
        }

        if ($action === 'refresh_session') {
            $session = refreshApiSession();
            jsonOk([
                'expires_at' => $session['expires_at'],
                'expires_in' => max(0, $session['expires_at'] - time()),
                'refreshed_at' => $session['refreshed_at'],
            ]);
        }

        if ($action === 'request_phone') {
            $number = normalizePhoneNumber(trim((string) ($_GET['number'] ?? '')));
            if ($number === '') {
                jsonError('请输入手机号', 400);
            }

            $data = requestPhoneFromApi($number);

            if (($data[0] ?? '') === '') {
                jsonError('未获取到 otpId，号码可能不可用或 API 繁忙', 422);
            }

            jsonOk([
                'otpId' => $data[0],
                'phone' => $data[1] ?? '',
                'raw' => $data,
            ]);
        }

        if ($action === 'request_code') {
            $otpId = trim((string) ($_GET['otpId'] ?? ''));
            if ($otpId === '') {
                jsonError('缺少 otpId', 400);
            }

            $url = buildRequestCodeUrl($otpId);
            [$response, ] = fetchApiRaw($url, ensureApiSession());
            $code = trim($response, " \t\n\r\0\x0B\"");

            jsonOk(['code' => $code]);
        }

        if ($action === 'diagnose') {
            jsonOk(runDiagnostics());
        }

        jsonError('未知操作', 400);
    } catch (Throwable $e) {
        jsonError($e->getMessage(), 500);
    }

    exit;
}

/**
 * 输出成功 JSON 并结束
 *
 * @param array<string, mixed> $payload
 */
function jsonOk(array $payload): void
{
    echo json_encode(['ok' => true] + $payload, JSON_UNESCAPED_UNICODE);
    exit;
}

/**
 * 输出错误 JSON 并结束
 */
function jsonError(string $message, int $status = 422): void
{
    http_response_code($status);
    echo json_encode(['ok' => false, 'error' => $message], JSON_UNESCAPED_UNICODE);
    exit;
}

/**
 * 确保缓存目录存在且 Web 用户可写
 */
function ensureCacheDir(): void
{
    $dir = getCacheDir();

    if (!is_dir($dir) && !mkdir($dir, 0775, true) && !is_dir($dir)) {
        throw new RuntimeException('无法创建缓存目录: ' . $dir);
    }

    if (!is_writable($dir)) {
        throw new RuntimeException(
            '缓存目录不可写: ' . $dir . '，请执行 chown -R www:www ' . $dir . ' && chmod 775 ' . $dir
        );
    }
}

/** @var string|null */
$resolvedCacheDir = null;

/**
 * 缓存目录：优先脚本目录，不可写时回退到系统临时目录
 */
function getCacheDir(): string
{
    global $resolvedCacheDir;

    if ($resolvedCacheDir !== null) {
        return $resolvedCacheDir;
    }

    $candidates = [
        __DIR__ . '/.otp_cache',
        sys_get_temp_dir() . '/dujiaoka_otp_cache',
    ];

    foreach ($candidates as $dir) {
        if (is_dir($dir) || @mkdir($dir, 0775, true)) {
            if (is_writable($dir)) {
                $resolvedCacheDir = $dir;
                return $dir;
            }
        }
    }

    $resolvedCacheDir = $candidates[0];

    return $resolvedCacheDir;
}

/**
 * Session 缓存文件路径
 */
function getSessionCacheFile(): string
{
    return getCacheDir() . '/session.json';
}

/**
 * Cookie Jar 文件路径
 */
function getCookieJarFile(): string
{
    return getCacheDir() . '/cookies.txt';
}

/**
 * Session 锁文件路径
 */
function getSessionLockFile(): string
{
    return getCacheDir() . '/session.lock';
}

/**
 * 规范化手机号：纯数字时自动补 + 前缀
 */
function normalizePhoneNumber(string $number): string
{
    $number = trim($number);

    if ($number === '') {
        return '';
    }

    if (str_starts_with($number, '+')) {
        return $number;
    }

    if (preg_match('/^\d+$/', $number) === 1) {
        return '+' . $number;
    }

    return $number;
}

/**
 * 构建 request_phone URL（与 otp.html 的 fetch 一致，+ 不编码为 %2B）
 */
function buildRequestPhoneUrl(string $number): string
{
    if (!preg_match('/^\+?\d{6,20}$/', $number)) {
        throw new RuntimeException('手机号格式不正确');
    }

    return API_BASE . '/request_phone?number=' . $number;
}

/**
 * 构建 request_code URL
 */
function buildRequestCodeUrl(string $otpId): string
{
    if (!preg_match('/^[a-f0-9]+$/', $otpId)) {
        throw new RuntimeException('otpId 格式不正确');
    }

    return API_BASE . '/request_code?otpId=' . $otpId;
}

/**
 * 从 API 获取号码 session（模拟 otp.html 浏览器行为）
 *
 * @return array{0: string, 1: string}
 */
function requestPhoneFromApi(string $number): array
{
    $url = buildRequestPhoneUrl($number);
    $attempts = [
        static fn (): ?string => null,
        static function (): ?string {
            $session = loadSessionCache();

            return $session['cookie'] ?? null;
        },
        static fn (): string => refreshApiSession()['cookie'],
    ];

    foreach ($attempts as $cookieResolver) {
        $cookie = $cookieResolver();
        [$body, $responseCookies] = fetchApiRaw($url, $cookie);
        persistResponseCookies($responseCookies, $cookie);

        $data = decodeJsonArray($body);
        $otpId = (string) ($data[0] ?? '');
        if ($otpId !== '') {
            return [(string) $data[0], (string) ($data[1] ?? '')];
        }

        // 拿到新 Cookie 后立即用新 Cookie 再试一次
        if ($responseCookies !== []) {
            $mergedCookie = buildCookieHeader(mergeCookieMaps(parseCookieHeader($cookie), $responseCookies));
            [$body2, ] = fetchApiRaw($url, $mergedCookie);
            persistResponseCookies($responseCookies, $mergedCookie);
            $data2 = decodeJsonArray($body2);
            if (($data2[0] ?? '') !== '') {
                return [(string) $data2[0], (string) ($data2[1] ?? '')];
            }
        }
    }

    return ['', ''];
}

/**
 * 解析 Cookie 请求头为 map
 *
 * @return array<string, string>
 */
function parseCookieHeader(?string $cookieHeader): array
{
    if ($cookieHeader === null || trim($cookieHeader) === '') {
        return [];
    }

    $cookies = [];
    foreach (explode(';', $cookieHeader) as $part) {
        $part = trim($part);
        $separator = strpos($part, '=');
        if ($separator === false) {
            continue;
        }
        $cookies[trim(substr($part, 0, $separator))] = trim(substr($part, $separator + 1));
    }

    return $cookies;
}

/**
 * 合并 Cookie map
 *
 * @param array<string, string> $base
 * @param array<string, string> $extra
 * @return array<string, string>
 */
function mergeCookieMaps(array $base, array $extra): array
{
    return $extra === [] ? $base : array_merge($base, $extra);
}

/**
 * 保存响应中的 Cookie 到 Session 缓存
 *
 * @param array<string, string> $responseCookies
 */
function persistResponseCookies(array $responseCookies, ?string $requestCookie = null): void
{
    if ($responseCookies === [] && ($requestCookie === null || $requestCookie === '')) {
        return;
    }

    $merged = mergeCookieMaps(parseCookieHeader($requestCookie), $responseCookies);
    if ($merged !== []) {
        saveSessionCache(buildCookieHeader($merged));
    }
}

/**
 * 发起 API 请求并返回响应体与 Set-Cookie
 *
 * @return array{0: string, 1: array<string, string>}
 */
function fetchApiRaw(string $url, ?string $cookie = null): array
{
    if (!function_exists('curl_init')) {
        throw new RuntimeException('服务器未启用 cURL 扩展');
    }

    $responseCookies = [];
    $headerCallback = static function ($curl, string $headerLine) use (&$responseCookies): int {
        parseSetCookieHeader($headerLine, $responseCookies);

        return strlen($headerLine);
    };

    $ch = curl_init($url);
    if ($ch === false) {
        throw new RuntimeException('cURL 初始化失败');
    }

    $headers = getBrowserHeaders();
    if ($cookie !== null && $cookie !== '') {
        $headers[] = 'Cookie: ' . $cookie;
    }

    curl_setopt_array($ch, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_FOLLOWLOCATION => true,
        CURLOPT_CONNECTTIMEOUT => CURL_CONNECT_TIMEOUT,
        CURLOPT_TIMEOUT => CURL_TIMEOUT,
        CURLOPT_HTTPHEADER => $headers,
        CURLOPT_ENCODING => '',
        CURLOPT_HEADERFUNCTION => $headerCallback,
        CURLOPT_SSL_VERIFYPEER => false,
        CURLOPT_SSL_VERIFYHOST => 0,
    ]);

    $result = curl_exec($ch);
    $httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
    $error = curl_error($ch);
    curl_close($ch);

    if ($result === false) {
        throw new RuntimeException('远程请求失败: ' . ($error !== '' ? $error : '网络错误'));
    }

    if ($httpCode >= 400) {
        throw new RuntimeException('远程 API 返回 HTTP ' . $httpCode);
    }

    if (isHtmlResponse($result)) {
        throw new RuntimeException('远程 API 返回异常页面 (HTTP ' . $httpCode . ')');
    }

    return [$result, $responseCookies];
}

/**
 * 请求号码并解析 JSON 数组
 *
 * @return array<int, string>
 */
function requestPhoneData(string $url): array
{
    [$response, ] = fetchApiRaw($url, ensureApiSession());
    $data = decodeJsonArray($response);

    return [
        (string) ($data[0] ?? ''),
        (string) ($data[1] ?? ''),
    ];
}

/**
 * 解析上游 JSON 数组响应
 *
 * @return array<int, mixed>
 */
function decodeJsonArray(string $response): array
{
    if (isHtmlResponse($response)) {
        throw new RuntimeException('上游 API 返回异常页面，请稍后重试');
    }

    $data = json_decode($response, true);
    if (!is_array($data)) {
        throw new RuntimeException('上游 API 返回格式错误');
    }

    return $data;
}

/**
 * 判断响应是否为 HTML 错误页
 */
function isHtmlResponse(string $response): bool
{
    $snippet = ltrim($response);

    return str_starts_with($snippet, '<!DOCTYPE')
        || str_starts_with($snippet, '<html')
        || str_contains($snippet, 'Bad gateway');
}

/**
 * 运行环境诊断，便于排查非 cURL 类问题
 *
 * @return array<string, mixed>
 */
function runDiagnostics(): array
{
    try {
        ensureCacheDir();
    } catch (Throwable $e) {
        return [
            'php_version' => PHP_VERSION,
            'curl_enabled' => function_exists('curl_init'),
            'cache_dir' => getCacheDir(),
            'cache_writable' => false,
            'note' => $e->getMessage(),
        ];
    }

    $cacheDir = getCacheDir();
    $cacheWritable = is_dir($cacheDir) && is_writable($cacheDir);
    $result = [
        'php_version' => PHP_VERSION,
        'curl_enabled' => function_exists('curl_init'),
        'cache_dir' => $cacheDir,
        'cache_writable' => $cacheWritable,
        'session_cache' => is_file(getSessionCacheFile()) ? 'exists' : 'missing',
        'upstream_reachable' => false,
        'session_refresh' => 'unknown',
        'sample_request_phone' => null,
        'note' => '',
    ];

    if (!$result['curl_enabled']) {
        $result['note'] = 'PHP cURL 扩展未启用（极少见）';
        return $result;
    }

    if (!$cacheWritable) {
        $result['note'] = '缓存目录不可写: ' . $cacheDir . '，请执行 chown -R www:www ' . dirname($cacheDir) . '/.otp_cache && chmod 775 ' . $cacheDir;
        return $result;
    }

    try {
        $session = refreshApiSession();
        $result['session_refresh'] = 'ok';
        $result['session_expires_in'] = max(0, $session['expires_at'] - time());
    } catch (Throwable $e) {
        $result['session_refresh'] = 'fail';
        $result['note'] = 'Session 获取失败: ' . $e->getMessage();
        return $result;
    }

    try {
        $url = buildRequestPhoneUrl('+15679986037');
        [$raw, ] = fetchApiRaw($url, null);
        $result['upstream_reachable'] = true;
        $result['sample_request_phone'] = $raw;
        $data = json_decode($raw, true);
        if (is_array($data) && ($data[0] ?? '') === '') {
            $result['note'] = 'cURL 和 Session 均正常；上游 API 对该号码返回空 otpId（号码不可用或 API 端无库存）';
        } else {
            $result['note'] = '一切正常，可以正常使用';
        }
    } catch (Throwable $e) {
        $result['note'] = '上游 API 请求失败: ' . $e->getMessage();
    }

    return $result;
}

/**
 * 浏览器请求头
 *
 * @return list<string>
 */
function getBrowserHeaders(): array
{
    return [
        'User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36 Edg/149.0.0.0',
        'Accept: */*',
        'Accept-Language: zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6,ru;q=0.5',
        'Referer: https://luugpm.cloud/otp.html',
        'Origin: https://luugpm.cloud',
        'DNT: 1',
    ];
}

/**
 * 读取 Session 缓存
 *
 * @return array{cookie: string, refreshed_at: int, expires_at: int}|null
 */
function loadSessionCache(): ?array
{
    $file = getSessionCacheFile();
    if (!is_file($file)) {
        return null;
    }

    $data = json_decode((string) file_get_contents($file), true);
    if (!is_array($data) || empty($data['cookie'])) {
        return null;
    }

    return [
        'cookie' => (string) $data['cookie'],
        'refreshed_at' => (int) ($data['refreshed_at'] ?? 0),
        'expires_at' => (int) ($data['expires_at'] ?? 0),
    ];
}

/**
 * 判断 Session 是否过期
 *
 * @param array{cookie: string, refreshed_at: int, expires_at: int} $session
 */
function isSessionExpired(array $session): bool
{
    return time() >= $session['expires_at'];
}

/**
 * 保存 Session 缓存
 *
 * @return array{cookie: string, refreshed_at: int, expires_at: int}
 */
function saveSessionCache(string $cookie): array
{
    $session = [
        'cookie' => $cookie,
        'refreshed_at' => time(),
        'expires_at' => time() + SESSION_TTL,
    ];

    file_put_contents(getSessionCacheFile(), json_encode($session, JSON_UNESCAPED_UNICODE));

    return $session;
}

/**
 * 从 Netscape Cookie 文件或 Set-Cookie 响应头解析 Cookie 字符串
 */
function parseCookieJar(string $cookieFile): string
{
    if (!is_file($cookieFile)) {
        throw new RuntimeException('Cookie 文件不存在');
    }

    $parts = [];
    $lines = file($cookieFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [];

    foreach ($lines as $line) {
        if ($line === '') {
            continue;
        }

        if (str_starts_with($line, '#HttpOnly_')) {
            $line = substr($line, 10);
        } elseif (str_starts_with($line, '#')) {
            continue;
        }

        $columns = explode("\t", $line);
        if (count($columns) >= 7) {
            $parts[] = $columns[5] . '=' . $columns[6];
        }
    }

    if ($parts === []) {
        throw new RuntimeException('未能解析 API Cookie');
    }

    return implode('; ', $parts);
}

/**
 * 从 Set-Cookie 响应头提取 Cookie
 *
 * @param array<string, string> $cookies
 */
function parseSetCookieHeader(string $headerLine, array &$cookies): void
{
    if (! str_starts_with(strtolower($headerLine), 'set-cookie:')) {
        return;
    }

    $cookiePart = trim(substr($headerLine, 11));
    $nameValue = explode(';', $cookiePart)[0] ?? '';
    $separator = strpos($nameValue, '=');
    if ($separator === false) {
        return;
    }

    $name = trim(substr($nameValue, 0, $separator));
    $value = trim(substr($nameValue, $separator + 1));
    if ($name !== '') {
        $cookies[$name] = $value;
    }
}

/**
 * 将 Cookie 数组转为请求头字符串
 *
 * @param array<string, string> $cookies
 */
function buildCookieHeader(array $cookies): string
{
    $parts = [];
    foreach ($cookies as $name => $value) {
        $parts[] = $name . '=' . $value;
    }

    if ($parts === []) {
        throw new RuntimeException('Cookie 为空');
    }

    return implode('; ', $parts);
}

/**
 * 调用 sanctum/csrf-cookie 获取新 Session
 *
 * @return array{cookie: string, refreshed_at: int, expires_at: int}
 */
function refreshApiSession(): array
{
    if (!function_exists('curl_init')) {
        throw new RuntimeException('服务器未启用 cURL 扩展');
    }

    $lockFile = fopen(getSessionLockFile(), 'c');
    if ($lockFile === false) {
        throw new RuntimeException('无法创建 Session 锁');
    }

    try {
        if (!acquireLock($lockFile)) {
            throw new RuntimeException('Session 正忙，请稍后重试');
        }

        $cookies = [];
        $headerCallback = static function ($curl, string $headerLine) use (&$cookies): int {
            parseSetCookieHeader($headerLine, $cookies);

            return strlen($headerLine);
        };

        $ch = curl_init(API_BASE . '/sanctum/csrf-cookie');
        if ($ch === false) {
            throw new RuntimeException('cURL 初始化失败');
        }

        curl_setopt_array($ch, [
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_FOLLOWLOCATION => true,
            CURLOPT_CONNECTTIMEOUT => CURL_CONNECT_TIMEOUT,
            CURLOPT_TIMEOUT => CURL_TIMEOUT,
            CURLOPT_HTTPHEADER => getBrowserHeaders(),
            CURLOPT_ENCODING => '',
            CURLOPT_HEADERFUNCTION => $headerCallback,
            CURLOPT_SSL_VERIFYPEER => false,
            CURLOPT_SSL_VERIFYHOST => 0,
        ]);

        curl_exec($ch);
        $httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
        $error = curl_error($ch);
        curl_close($ch);

        if ($httpCode !== 204 && $httpCode !== 200) {
            throw new RuntimeException('获取 Session 失败: HTTP ' . $httpCode . ($error !== '' ? ' - ' . $error : ''));
        }

        if ($cookies === []) {
            throw new RuntimeException('未能从响应头获取 Cookie');
        }

        return saveSessionCache(buildCookieHeader($cookies));
    } finally {
        flock($lockFile, LOCK_UN);
        fclose($lockFile);
    }
}

/**
 * 获取可用 Session，过期则自动刷新
 */
function ensureApiSession(): string
{
    $session = loadSessionCache();
    if ($session !== null && !isSessionExpired($session)) {
        return $session['cookie'];
    }

    return refreshApiSession()['cookie'];
}

/**
 * 模拟浏览器请求远程 API
 */
function fetchUrl(string $url, ?string $cookie = null): string
{
    [$body, $responseCookies] = fetchApiRaw($url, $cookie ?? ensureApiSession());
    persistResponseCookies($responseCookies, $cookie);

    return $body;
}

/**
 * 带超时的文件锁
 */
function acquireLock($handle): bool
{
    for ($i = 0; $i < 20; $i++) {
        if (flock($handle, LOCK_EX | LOCK_NB)) {
            return true;
        }

        usleep(100000);
    }

    return false;
}

?>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>手机号 OTP 获取</title>
    <style>
        :root {
            --bg: #0f1419;
            --card: #1a2332;
            --border: #2d3a4f;
            --text: #e7ecf3;
            --muted: #8b9cb3;
            --primary: #3b82f6;
            --success: #22c55e;
            --warning: #f59e0b;
            --danger: #ef4444;
        }

        * { box-sizing: border-box; }

        body {
            margin: 0;
            min-height: 100vh;
            font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "PingFang SC", "Microsoft YaHei", sans-serif;
            background: linear-gradient(160deg, #0f1419 0%, #162032 100%);
            color: var(--text);
            padding: 24px 16px 48px;
        }

        .container {
            max-width: 960px;
            margin: 0 auto;
        }

        h1 {
            margin: 0 0 8px;
            font-size: 1.5rem;
            font-weight: 600;
        }

        .subtitle {
            color: var(--muted);
            margin-bottom: 24px;
            font-size: 0.9rem;
        }

        .panel {
            background: var(--card);
            border: 1px solid var(--border);
            border-radius: 12px;
            padding: 20px;
            margin-bottom: 20px;
        }

        .form-row {
            display: flex;
            gap: 12px;
            flex-wrap: wrap;
        }

        input[type="text"] {
            flex: 1;
            min-width: 220px;
            padding: 12px 14px;
            border-radius: 8px;
            border: 1px solid var(--border);
            background: #0f1419;
            color: var(--text);
            font-size: 1rem;
        }

        input[type="text"]:focus {
            outline: none;
            border-color: var(--primary);
            box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.2);
        }

        button {
            padding: 12px 20px;
            border: none;
            border-radius: 8px;
            font-size: 0.95rem;
            cursor: pointer;
            transition: opacity 0.15s;
        }

        button:hover { opacity: 0.9; }
        button:disabled { opacity: 0.5; cursor: not-allowed; }

        .btn-primary {
            background: var(--primary);
            color: #fff;
        }

        .btn-danger {
            background: transparent;
            color: var(--danger);
            border: 1px solid var(--danger);
            padding: 6px 12px;
            font-size: 0.85rem;
        }

        .hint {
            margin-top: 12px;
            color: var(--muted);
            font-size: 0.85rem;
            line-height: 1.6;
        }

        .task-list {
            display: flex;
            flex-direction: column;
            gap: 12px;
        }

        .task {
            background: #121a26;
            border: 1px solid var(--border);
            border-radius: 10px;
            padding: 16px;
        }

        .task-header {
            display: flex;
            justify-content: space-between;
            align-items: center;
            gap: 12px;
            margin-bottom: 10px;
        }

        .phone-number {
            font-weight: 600;
            font-size: 1.05rem;
        }

        .status {
            display: inline-flex;
            align-items: center;
            gap: 6px;
            padding: 4px 10px;
            border-radius: 999px;
            font-size: 0.8rem;
            font-weight: 500;
        }

        .status-pending { background: rgba(59, 130, 246, 0.15); color: #93c5fd; }
        .status-polling { background: rgba(245, 158, 11, 0.15); color: #fcd34d; }
        .status-success { background: rgba(34, 197, 94, 0.15); color: #86efac; }
        .status-failed { background: rgba(239, 68, 68, 0.15); color: #fca5a5; }

        .task-meta {
            color: var(--muted);
            font-size: 0.85rem;
            margin-bottom: 8px;
        }

        .task-result {
            background: #0f1419;
            border-radius: 8px;
            padding: 12px;
            font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
            font-size: 0.9rem;
            word-break: break-all;
            min-height: 40px;
        }

        .progress-bar {
            height: 4px;
            background: var(--border);
            border-radius: 2px;
            margin-top: 10px;
            overflow: hidden;
        }

        .progress-fill {
            height: 100%;
            background: var(--primary);
            width: 0%;
            transition: width 0.3s linear;
        }

        .empty {
            text-align: center;
            color: var(--muted);
            padding: 32px 16px;
        }

        .session-bar {
            display: flex;
            justify-content: space-between;
            align-items: center;
            gap: 12px;
            margin-top: 14px;
            padding: 10px 14px;
            border-radius: 8px;
            background: #121a26;
            border: 1px solid var(--border);
            font-size: 0.85rem;
        }

        .session-ok { color: #86efac; }
        .session-warn { color: #fcd34d; }
        .session-error { color: #fca5a5; }

        .btn-secondary {
            background: transparent;
            color: var(--primary);
            border: 1px solid var(--primary);
            padding: 6px 12px;
            font-size: 0.85rem;
        }

        .mode-tag {
            display: inline-block;
            padding: 2px 8px;
            border-radius: 999px;
            font-size: 0.75rem;
            background: rgba(34, 197, 94, 0.15);
            color: #86efac;
            margin-left: 8px;
        }
    </style>
</head>
<body>
<div class="container">
    <h1>手机号 OTP 获取</h1>

    <div class="panel">
        <div class="form-row">
            <input type="text" id="phoneInput" placeholder="输入手机号，如 16096920150 或 +16096920150" autocomplete="off">
            <button class="btn-primary" id="submitBtn" type="button">开始请求</button>
        </div>
    </div>

    <div class="panel">
        <div id="taskList" class="task-list">
            <div class="empty" id="emptyHint">暂无任务，请在上方输入手机号开始</div>
        </div>
    </div>
</div>

<script>
(function () {
    const POLL_INTERVAL = <?= POLL_INTERVAL_MS ?>;
    const POLL_TIMEOUT = <?= POLL_TIMEOUT_MS ?>;
    const PROXY = '?action=';

    const phoneInput = document.getElementById('phoneInput');
    const submitBtn = document.getElementById('submitBtn');
    const taskList = document.getElementById('taskList');
    const emptyHint = document.getElementById('emptyHint');

    /** @type {Map<string, object>} */
    const tasks = new Map();
    let taskIdCounter = 0;

    submitBtn.addEventListener('click', startTask);
    phoneInput.addEventListener('keydown', (e) => {
        if (e.key === 'Enter') startTask();
    });
    phoneInput.addEventListener('blur', () => {
        const normalized = normalizePhoneNumber(phoneInput.value);
        if (normalized !== phoneInput.value.trim()) {
            phoneInput.value = normalized;
        }
    });

    /**
     * 规范化手机号：纯数字时自动补 + 前缀
     */
    function normalizePhoneNumber(raw) {
        const number = raw.trim();
        if (!number) {
            return '';
        }
        if (number.startsWith('+')) {
            return number;
        }
        if (/^\d+$/.test(number)) {
            return '+' + number;
        }
        return number;
    }

    /**
     * 通过同源 PHP 代理请求，避免 CORS 跨域问题
     */
    async function apiFetch(action) {
        const res = await fetch(PROXY + action);
        const text = await res.text();

        if (text.trim().startsWith('<!DOCTYPE') || text.trim().startsWith('<html')) {
            throw new Error('服务器返回异常，请稍后重试');
        }

        let data;
        try {
            data = JSON.parse(text);
        } catch {
            throw new Error('服务器返回异常: ' + text.slice(0, 120));
        }

        if (!res.ok || data.ok === false) {
            throw new Error(data.error || ('请求失败 HTTP ' + res.status));
        }

        return data;
    }

    function startTask() {
        const number = normalizePhoneNumber(phoneInput.value);
        if (!number) {
            alert('请输入手机号');
            return;
        }

        if (emptyHint) emptyHint.remove();

        const id = 'task-' + (++taskIdCounter);
        const task = {
            id,
            number,
            otpId: null,
            status: 'pending',
            result: '',
            startTime: Date.now(),
            pollStartTime: null,
            timer: null,
            progressTimer: null,
            el: null,
        };

        tasks.set(id, task);
        renderTask(task);
        phoneInput.value = '';
        requestPhone(task);
    }

    async function requestPhone(task) {
        updateStatus(task, 'pending', '正在请求号码...');

        try {
            const data = await apiFetch('request_phone&number=' + encodeURIComponent(task.number));

            if (!data.otpId) {
                throw new Error('未获取到 otpId');
            }

            task.otpId = data.otpId;
            task.pollStartTime = Date.now();
            updateStatus(task, 'polling', '获取验证码中...');
            updateMeta(task);
            updateWaitCountdown(task);
            startPolling(task);
        } catch (err) {
            failTask(task, err.message || '请求失败');
        }
    }

    function startPolling(task) {
        pollOnce(task);

        task.timer = setInterval(() => {
            if (Date.now() - task.pollStartTime >= POLL_TIMEOUT) {
                stopPolling(task);
                failTask(task, '120 秒内未获取到验证码');
                return;
            }
            pollOnce(task);
        }, POLL_INTERVAL);

        task.progressTimer = setInterval(() => {
            updateProgress(task);
            updateWaitCountdown(task);
        }, 1000);
    }

    function updateWaitCountdown(task) {
        if (task.status !== 'polling' || !task.pollStartTime) {
            return;
        }

        const maxSec = Math.floor(POLL_TIMEOUT / 1000);
        updateResult(task, '等待中... (' + elapsedSec(task) + 's / ' + maxSec + 's)');
    }

    async function pollOnce(task) {
        if (task.status !== 'polling') return;

        try {
            const data = await apiFetch('request_code&otpId=' + encodeURIComponent(task.otpId));
            const code = (data.code || '').trim();

            if (!code || code === 'null' || code === '[]') {
                return;
            }

            successTask(task, code);
        } catch (err) {
            updateResult(task, '轮询出错: ' + (err.message || '未知错误'));
        }
    }

    function elapsedSec(task) {
        return Math.floor((Date.now() - task.pollStartTime) / 1000);
    }

    function stopPolling(task) {
        if (task.timer) {
            clearInterval(task.timer);
            task.timer = null;
        }
        if (task.progressTimer) {
            clearInterval(task.progressTimer);
            task.progressTimer = null;
        }
    }

    function successTask(task, content) {
        stopPolling(task);
        task.status = 'success';
        task.result = content;
        updateStatus(task, 'success', '获取成功');
        updateResult(task, content);
        updateProgress(task, 100);
    }

    function failTask(task, message) {
        stopPolling(task);
        task.status = 'failed';
        task.result = message;
        updateStatus(task, 'failed', '失败');
        updateResult(task, message);
        updateProgress(task, 100);
    }

    function removeTask(id) {
        const task = tasks.get(id);
        if (!task) return;
        stopPolling(task);
        tasks.delete(id);
        task.el?.remove();
        if (tasks.size === 0) {
            taskList.innerHTML = '<div class="empty" id="emptyHint">暂无任务，请在上方输入手机号开始</div>';
        }
    }

    function renderTask(task) {
        const el = document.createElement('div');
        el.className = 'task';
        el.id = task.id;
        el.innerHTML = `
            <div class="task-header">
                <span class="phone-number">${escapeHtml(task.number)}</span>
                <div>
                    <span class="status status-pending">等待中</span>
                    <button class="btn-danger" type="button">移除</button>
                </div>
            </div>
            <div class="task-meta">otpId: -</div>
            <div class="task-result">准备中...</div>
            <div class="progress-bar"><div class="progress-fill"></div></div>
        `;

        el.querySelector('.btn-danger').addEventListener('click', () => removeTask(task.id));
        taskList.prepend(el);
        task.el = el;
    }

    function updateStatus(task, status, label) {
        task.status = status;
        const badge = task.el?.querySelector('.status');
        if (!badge) return;
        badge.className = 'status status-' + status;
        badge.textContent = label;
    }

    function updateMeta(task) {
        const meta = task.el?.querySelector('.task-meta');
        if (meta) meta.textContent = 'otpId: ' + (task.otpId || '-');
    }

    function updateResult(task, text) {
        task.result = text;
        const box = task.el?.querySelector('.task-result');
        if (box) box.textContent = text;
    }

    function updateProgress(task, forcePercent) {
        const fill = task.el?.querySelector('.progress-fill');
        if (!fill) return;

        if (forcePercent !== undefined) {
            fill.style.width = forcePercent + '%';
            return;
        }

        if (!task.pollStartTime) {
            fill.style.width = '0%';
            return;
        }

        const pct = Math.min(100, ((Date.now() - task.pollStartTime) / POLL_TIMEOUT) * 100);
        fill.style.width = pct + '%';
    }

    function escapeHtml(str) {
        return str
            .replace(/&/g, '&amp;')
            .replace(/</g, '&lt;')
            .replace(/>/g, '&gt;')
            .replace(/"/g, '&quot;');
    }
})();
</script>
</body>
</html>
