<?php

namespace App\Services\Octoparse;

use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;

/**
 * Octoparse Open API Client.
 *
 * Handles authentication (password grant + refresh-token flow), automatic
 * token caching, throttling to Octoparse's 20 req/sec hard limit, and
 * exponential backoff on 429/5xx responses.
 */
class OctoparseApiClient
{
    private const ACCESS_TOKEN_CACHE_KEY = 'octoparse.access_token';
    private const REFRESH_TOKEN_CACHE_KEY = 'octoparse.refresh_token';
    // Refresh slightly before the API-reported expiry so that requests do not
    // race a token going stale mid-flight.
    private const TOKEN_EXPIRY_SAFETY_MARGIN_SECONDS = 60;

    /** @var array */
    private $config;

    /** @var float|null */
    private $lastRequestAt = null;

    public function __construct()
    {
        $this->config = config('octoparse');
    }

    public static function isAvailable(): bool
    {
        return !empty(config('octoparse.api.username'))
            && !empty(config('octoparse.api.password'));
    }

    /**
     * Get a valid access token. Uses cache → refresh → password fallback.
     */
    public function getAccessToken(): string
    {
        $cached = Cache::get(self::ACCESS_TOKEN_CACHE_KEY);
        if ($cached) {
            return $cached;
        }

        $refreshToken = Cache::get(self::REFRESH_TOKEN_CACHE_KEY);
        if ($refreshToken) {
            try {
                $tokens = $this->refreshAccessToken($refreshToken);
                $this->storeTokens($tokens);
                return $tokens['access_token'];
            } catch (\Throwable $e) {
                Log::warning('[Octoparse] Refresh token grant failed, falling back to password grant', [
                    'error' => $e->getMessage(),
                ]);
                Cache::forget(self::REFRESH_TOKEN_CACHE_KEY);
            }
        }

        $tokens = $this->loginWithPassword();
        $this->storeTokens($tokens);
        echo "▶ 9 — Access token obtained (grant=password)\n";
        // dd(['token_preview' => substr($tokens['access_token'], 0, 20) . '...', 'expires_in' => $tokens['expires_in']]);
        return $tokens['access_token'];
    }

    /**
     * Force token refresh on the next request.
     */
    public function invalidateToken(): void
    {
        Cache::forget(self::ACCESS_TOKEN_CACHE_KEY);
    }

    /**
     * Fetch a page of all data for a task.
     *
     * Octoparse response shape:
     *   { data: { offset, total, restTotal, dataList: [...] }, error: "...", error_Description: "..." }
     *
     * @return array{offset:int,total:int,restTotal:int,dataList:array}
     */
    public function getDataByOffset(string $taskId, int $offset, int $size): array
    {
        $response = $this->executeWithRetry(function () use ($taskId, $offset, $size) {
            return $this->authenticatedRequest('GET', '/api/alldata/GetDataOfTaskByOffset', [
                'taskId' => $taskId,
                'offset' => $offset,
                'size'   => $size,
            ]);
        }, "getDataByOffset task={$taskId} offset={$offset}");

        $data = $response->json('data') ?? [];

        return [
            'offset' => (int) ($data['offset'] ?? 0),
            'total' => (int) ($data['total'] ?? 0),
            'restTotal' => (int) ($data['restTotal'] ?? 0),
            'dataList' => $data['dataList'] ?? [],
        ];
    }

    /**
     * Fetch only rows not yet marked as exported.
     */
    public function getNotExportedData(string $taskId, int $size): array
    {
        $response = $this->executeWithRetry(function () use ($taskId, $size) {
            return $this->authenticatedRequest('GET', '/api/notexportdata/gettop', [
                'taskId' => $taskId,
                'size'   => $size,
            ]);
        }, "getNotExportedData task={$taskId}");

        $data = $response->json('data') ?? [];

        return [
            'currentTotal' => (int) ($data['currentTotal'] ?? 0),
            'total' => (int) ($data['total'] ?? 0),
            'dataList' => $data['dataList'] ?? [],
        ];
    }

    /**
     * Mark the data as exported. After this call /api/notexportdata/gettop
     * will only return rows scraped since this point.
     */
    public function markDataExported(string $taskId): void
    {
        $this->executeWithRetry(function () use ($taskId) {
            return $this->authenticatedRequest('POST', '/api/notexportdata/update', [
                'taskId' => $taskId,
            ]);
        }, "markDataExported task={$taskId}");
    }

    // ------------------------------------------------------------------
    // Authentication
    // ------------------------------------------------------------------

    private function loginWithPassword(): array
    {
        $payload = [
            'username'   => $this->config['api']['username'],
            'password'   => $this->config['api']['password'],
            'grant_type' => 'password',
        ];

        return $this->postTokenRequest($payload, 'password');
    }

    private function refreshAccessToken(string $refreshToken): array
    {
        return $this->postTokenRequest([
            'refresh_token' => $refreshToken,
            'grant_type'    => 'refresh_token',
        ], 'refresh_token');
    }

    /**
     * Octoparse expects token requests as application/x-www-form-urlencoded.
     */
    private function postTokenRequest(array $payload, string $grantType): array
    {
        $url = rtrim($this->config['api']['base_url'], '/') . '/token';

        echo "▶ T1 — Token request URL={$url} grant={$grantType}\n";
        // dd(['url' => $url, 'payload' => $payload]);

        $response = Http::asJson()
            ->acceptJson()
            ->timeout($this->config['api']['request_timeout'])
            ->post($url, $payload);

        echo "▶ T2 — Token response status={$response->status()}\n";
        // dd([
        //     'status' => $response->status(),
        //     'headers' => $response->headers(),
        //     'body' => $response->body(),
        // ]);

        if (!$response->successful()) {
            throw new \RuntimeException(
                "Octoparse {$grantType} grant failed (HTTP {$response->status()}): " . $response->body()
            );
        }

        $body = $response->json();
        $data = $body['data'] ?? $body; // Octoparse wraps token in {"data": {...}}

        if (empty($data['access_token'])) {
            throw new \RuntimeException(
                "Octoparse {$grantType} grant returned no access_token: " . $response->body()
            );
        }

        return [
            'access_token'  => $data['access_token'],
            'refresh_token' => $data['refresh_token'] ?? null,
            'expires_in'    => (int) ($data['expires_in'] ?? 3600),
        ];
    }

    private function storeTokens(array $tokens): void
    {
        $accessTtl = max(
            60,
            min(
                (int) $this->config['api']['access_token_ttl'],
                $tokens['expires_in'] - self::TOKEN_EXPIRY_SAFETY_MARGIN_SECONDS
            )
        );

        Cache::put(self::ACCESS_TOKEN_CACHE_KEY, $tokens['access_token'], $accessTtl);

        if (!empty($tokens['refresh_token'])) {
            Cache::put(
                self::REFRESH_TOKEN_CACHE_KEY,
                $tokens['refresh_token'],
                (int) $this->config['api']['refresh_token_ttl']
            );
        }
    }

    // ------------------------------------------------------------------
    // HTTP execution: throttle + retry + auto-refresh
    // ------------------------------------------------------------------

    private function authenticatedRequest(string $method, string $path, array $params = []): Response
    {
        $this->throttle();

        $url = rtrim($this->config['api']['base_url'], '/') . $path;

        $request = Http::withToken($this->getAccessToken())
            ->timeout($this->config['api']['request_timeout'])
            ->acceptJson();

        $method = strtoupper($method);

        echo "▶ 10 — HTTP {$method} {$url}\n";
        // dd(['method' => $method, 'url' => $url, 'params' => $params]);

        if ($method === 'GET') {
            return $request->get($url, $params);
        }

        // Octoparse uses query-string parameters for POST endpoints too
        // (e.g. /api/notexportdata/update?taskId=X).
        return $request->send($method, $url, ['query' => $params]);
    }

    /**
     * Enforce the local per-second cap to stay below Octoparse's 20 req/sec.
     */
    private function throttle(): void
    {
        $delayMs = (int) $this->config['import']['rate_limit_delay_ms'];

        if ($delayMs <= 0) {
            return;
        }

        $now = microtime(true);
        if ($this->lastRequestAt !== null) {
            $elapsedMs = ($now - $this->lastRequestAt) * 1000;
            $waitMs = $delayMs - $elapsedMs;
            if ($waitMs > 0) {
                usleep((int) ($waitMs * 1000));
            }
        }
        $this->lastRequestAt = microtime(true);
    }

    /**
     * @param  callable():Response  $callback
     */
    private function executeWithRetry(callable $callback, string $operation): Response
    {
        $maxRetries = (int) $this->config['import']['max_retries'];
        $baseDelayMs = (int) $this->config['import']['backoff_base_ms'];

        $attempt = 0;
        $lastException = null;

        while ($attempt <= $maxRetries) {
            try {
                /** @var Response $response */
                $response = $callback();

                // Token expired mid-flight: clear cache and retry once.
                if ($response->status() === 401 && $attempt < $maxRetries) {
                    echo "▶ 17 — Got 401 on {$operation} — invalidating token and retrying (attempt {$attempt})\n";
                    // dd(['operation' => $operation, 'attempt' => $attempt, 'status' => $response->status(), 'body' => $response->body()]);
                    Log::warning("[Octoparse] 401 on {$operation}, refreshing token and retrying");
                    $this->invalidateToken();
                    $attempt++;
                    continue;
                }

                // Rate limited.
                if ($response->status() === 429 && $attempt < $maxRetries) {
                    $retryAfter = $this->resolveRetryAfter($response, $baseDelayMs, $attempt);
                    echo "▶ 18a — 429 on {$operation} sleeping {$retryAfter}ms (attempt {$attempt})\n";
                    // dd(['retryAfter_ms' => $retryAfter, 'attempt' => $attempt, 'headers' => $response->headers()]);
                    Log::warning("[Octoparse] 429 on {$operation}, sleeping {$retryAfter}ms (attempt {$attempt})");
                    usleep($retryAfter * 1000);
                    $attempt++;
                    continue;
                }

                // Retry transient 5xx.
                if ($response->serverError() && $attempt < $maxRetries) {
                    $delay = (int) ($baseDelayMs * pow(2, $attempt));
                    echo "▶ 18b — {$response->status()} on {$operation} sleeping {$delay}ms (attempt {$attempt})\n";
                    // dd(['status' => $response->status(), 'delay_ms' => $delay, 'attempt' => $attempt, 'body' => $response->body()]);
                    Log::warning("[Octoparse] {$response->status()} on {$operation}, sleeping {$delay}ms (attempt {$attempt})");
                    usleep($delay * 1000);
                    $attempt++;
                    continue;
                }

                if (!$response->successful()) {
                    throw new \RuntimeException(
                        "Octoparse API error on {$operation} (HTTP {$response->status()}): " . $response->body()
                    );
                }

                return $response;
            } catch (\Throwable $e) {
                $lastException = $e;
                $attempt++;

                if ($attempt > $maxRetries) {
                    break;
                }

                $delay = (int) ($baseDelayMs * pow(2, $attempt - 1));
                Log::warning("[Octoparse] Transport error on {$operation}, sleeping {$delay}ms (attempt {$attempt}): " . $e->getMessage());
                usleep($delay * 1000);
            }
        }

        throw $lastException ?? new \RuntimeException("Octoparse API failed after {$maxRetries} retries: {$operation}");
    }

    private function resolveRetryAfter(Response $response, int $baseDelayMs, int $attempt): int
    {
        $headerValue = $response->header('Retry-After');
        if ($headerValue !== null && $headerValue !== '') {
            // Retry-After may be an integer (seconds) or an HTTP date.
            if (is_numeric($headerValue)) {
                return max(1, (int) $headerValue * 1000);
            }
            $timestamp = strtotime((string) $headerValue);
            if ($timestamp !== false) {
                $diff = $timestamp - time();
                if ($diff > 0) {
                    return $diff * 1000;
                }
            }
        }

        // Exponential backoff with jitter (±20%).
        $base = $baseDelayMs * pow(2, $attempt);
        $jitter = $base * (mt_rand(-20, 20) / 100);
        return max(1, (int) ($base + $jitter));
    }
}
