<?php

declare(strict_types=1);

namespace SeoScope\Integrations;

use DateTimeImmutable;
use DateTimeInterface;
use DateTimeZone;
use RuntimeException;

/**
 * GSC Search Analytics (webmasters v3) — payloads aligned with Search Console UI (web, auto, dataState).
 */
final class GoogleSearchConsoleClient
{
    private const BASE = 'https://www.googleapis.com/webmasters/v3';
    private const MAX_RETRIES = 4;

    /** Google keeps “fresh” / partial data for roughly the last few days; match UI-style `all` vs `final`. */
    public const DATA_STATE_FRESH_DAYS = 3;

    /**
     * @param 'web'|'image'|'video'|'news'|string $searchType
     * @return 'all'|'final'
     */
    public static function dataStateForEndDate(string $endDate, ?DateTimeInterface $now = null): string
    {
        if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $endDate)) {
            return 'final';
        }
        $tz = new DateTimeZone(date_default_timezone_get() ?: 'UTC');
        $now = $now ?? new DateTimeImmutable('now', $tz);
        $end = DateTimeImmutable::createFromFormat('!Y-m-d', $endDate, $tz);
        if ($end === false) {
            return 'final';
        }
        $today = $now->setTime(0, 0, 0);
        $threshold = $today->modify('-' . self::DATA_STATE_FRESH_DAYS . ' days');
        return $end >= $threshold ? 'all' : 'final';
    }

    /**
     * Default parity options: Search Console “Web” search type, `auto` aggregation, `dataState` from end date.
     *
     * @return array{searchType:string,aggregationType:string,dataState:string}
     */
    public static function defaultSearchAnalyticsOptions(string $endDate): array
    {
        return [
            'searchType' => 'web',
            'aggregationType' => 'auto',
            'dataState' => self::dataStateForEndDate($endDate),
        ];
    }

    /**
     * Merge base payload with parity fields; `$extras` overwrite (e.g. dimensionFilterGroups).
     *
     * @param list<string> $dimensions
     * @param array<string, mixed> $extras
     * @return array<string, mixed>
     */
    public static function buildSearchAnalyticsBody(
        string $startDate,
        string $endDate,
        array $dimensions,
        int $rowLimit,
        int $startRow,
        array $extras = []
    ): array {
        return array_merge(
            [
                'startDate' => $startDate,
                'endDate' => $endDate,
                'dimensions' => $dimensions,
                'rowLimit' => $rowLimit,
                'startRow' => $startRow,
                'searchType' => 'web',
                'aggregationType' => 'auto',
                'dataState' => self::dataStateForEndDate($endDate),
            ],
            $extras
        );
    }

    /**
     * @return list<array{siteUrl:string, permissionLevel:string, siteType:string}>
     */
    public static function listSites(string $accessToken): array
    {
        $decoded = self::requestJson('GET', self::BASE . '/sites', $accessToken, null);

        $entries = $decoded['siteEntry'] ?? [];
        if (!is_array($entries)) {
            return [];
        }

        $out = [];
        foreach ($entries as $e) {
            if (!is_array($e)) {
                continue;
            }
            $siteUrl = trim((string) ($e['siteUrl'] ?? ''));
            $perm = trim((string) ($e['permissionLevel'] ?? ''));
            if ($siteUrl === '' || $perm === '') {
                continue;
            }
            $siteType = str_starts_with($siteUrl, 'sc-domain:') ? 'domain' : 'url-prefix';
            $out[] = [
                'siteUrl' => $siteUrl,
                'permissionLevel' => $perm,
                'siteType' => $siteType,
            ];
        }
        return $out;
    }

    /**
     * Search Analytics query for a single site.
     *
     * @param list<string> $dimensions e.g. query, page, device, country, date
     * @param array<string, mixed> $payloadExtras Merged into the JSON body (e.g. dimensionFilterGroups); can override dataState, searchType.
     * @return list<array{keys:list<string>,clicks:float|int,impressions:float|int,ctr:float|int,position:float|int}>
     */
    public static function searchAnalyticsAllRows(
        string $siteUrl,
        string $accessToken,
        string $startDate,
        string $endDate,
        array $dimensions,
        int $rowLimit,
        array $payloadExtras = []
    ): array {
        $siteUrl = trim($siteUrl);
        if ($siteUrl === '') {
            throw new RuntimeException('Missing siteUrl.');
        }
        if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $startDate) || !preg_match('/^\d{4}-\d{2}-\d{2}$/', $endDate)) {
            throw new RuntimeException('Invalid date format.');
        }
        $rowLimit = max(1, min(25000, $rowLimit));

        $encodedSite = rawurlencode($siteUrl);
        $url = self::BASE . '/sites/' . $encodedSite . '/searchAnalytics/query';

        $startRow = 0;
        $out = [];
        while (true) {
            $payload = self::buildSearchAnalyticsBody($startDate, $endDate, $dimensions, $rowLimit, $startRow, $payloadExtras);
            $decoded = self::requestJson('POST', $url, $accessToken, $payload);
            $rows = $decoded['rows'] ?? [];
            if (!is_array($rows) || $rows === []) {
                break;
            }
            foreach ($rows as $r) {
                if (!is_array($r)) {
                    continue;
                }
                $keys = $r['keys'] ?? [];
                if (!is_array($keys)) {
                    $keys = [];
                }
                $out[] = [
                    'keys' => array_values(array_map('strval', $keys)),
                    'clicks' => $r['clicks'] ?? 0,
                    'impressions' => $r['impressions'] ?? 0,
                    'ctr' => $r['ctr'] ?? 0,
                    'position' => $r['position'] ?? 0,
                ];
            }
            if (count($rows) < $rowLimit) {
                break;
            }
            $startRow += $rowLimit;
            if ($startRow > 200000) {
                // Safety guard for shared hosting (prevent runaway loops).
                break;
            }
        }
        return $out;
    }

    /**
     * Property-level totals for a date range (no dimensions), optionally with dimensionFilterGroups.
     *
     * @param array<string, mixed> $payloadExtras e.g. dimensionFilterGroups
     * @return array{keys:list<string>,clicks:float|int,impressions:float|int,ctr:float|int,position:float|int}|null
     */
    public static function searchAnalyticsPropertyTotalRow(
        string $siteUrl,
        string $accessToken,
        string $startDate,
        string $endDate,
        array $payloadExtras = []
    ): ?array {
        $rows = self::searchAnalyticsAllRows(
            $siteUrl,
            $accessToken,
            $startDate,
            $endDate,
            [],
            1,
            $payloadExtras
        );
        if ($rows === []) {
            return null;
        }
        return $rows[0] ?? null;
    }

    /**
     * GSC device value for the Search Analytics filter API (UPPERCASE).
     */
    public static function gscDeviceExpressionFromSlug(string $deviceSlug): string
    {
        $s = strtolower(trim($deviceSlug));
        return match ($s) {
            'mobile' => 'MOBILE',
            'tablet' => 'TABLET',
            'desktop' => 'DESKTOP',
            default => $s !== '' ? strtoupper(str_replace(' ', '', trim($deviceSlug))) : 'DESKTOP',
        };
    }

    /**
     * @return array{groupType:string,filters:list<array{dimension:string,operator:string,expression:string}>}
     */
    public static function dimensionFilterGroupAnd(
        string $deviceSlug,
        string $countryCodeLower
    ): array {
        $filters = [];
        if ($deviceSlug !== '' && $deviceSlug !== 'all') {
            $filters[] = [
                'dimension' => 'device',
                'operator' => 'equals',
                'expression' => self::gscDeviceExpressionFromSlug($deviceSlug),
            ];
        }
        $c = strtolower(trim($countryCodeLower));
        if ($c !== '' && $c !== 'all') {
            $filters[] = [
                'dimension' => 'country',
                'operator' => 'equals',
                'expression' => $c,
            ];
        }
        return [
            'groupType' => 'and',
            'filters' => $filters,
        ];
    }

    /**
     * @param array<string,mixed>|null $jsonBody
     * @return array<string,mixed>
     */
    private static function requestJson(string $method, string $url, string $accessToken, ?array $jsonBody): array
    {
        if (!extension_loaded('curl')) {
            throw new RuntimeException('cURL extension is required for Google API requests.');
        }
        if ($accessToken === '') {
            throw new RuntimeException('Missing access token.');
        }

        $method = strtoupper($method);
        $attempt = 0;
        $lastError = '';

        while (true) {
            $attempt++;
            $ch = curl_init($url);
            if ($ch === false) {
                throw new RuntimeException('Failed to initialize cURL.');
            }
            $headers = [
                'Authorization: Bearer ' . $accessToken,
                'Accept: application/json',
            ];
            if ($method === 'POST') {
                $headers[] = 'Content-Type: application/json';
                $body = json_encode($jsonBody ?? [], JSON_UNESCAPED_SLASHES);
                curl_setopt($ch, CURLOPT_POST, true);
                curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
            } else {
                curl_setopt($ch, CURLOPT_HTTPGET, true);
            }
            curl_setopt_array($ch, [
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_HTTPHEADER => $headers,
                CURLOPT_TIMEOUT => 25,
            ]);

            $bodyRaw = curl_exec($ch);
            $err = curl_error($ch);
            $status = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
            curl_close($ch);

            if ($bodyRaw === false) {
                $lastError = $err !== '' ? $err : 'unknown error';
                if ($attempt <= self::MAX_RETRIES) {
                    self::backoffSleep($attempt);
                    continue;
                }
                throw new RuntimeException('Google API request failed: ' . $lastError);
            }

            $decoded = json_decode((string) $bodyRaw, true);
            if (!is_array($decoded)) {
                throw new RuntimeException('Google API response was not valid JSON (HTTP ' . $status . ').');
            }

            if ($status === 429 || $status >= 500) {
                $lastError = self::extractErrorMessage($decoded, $status);
                if ($attempt <= self::MAX_RETRIES) {
                    self::backoffSleep($attempt);
                    continue;
                }
                throw new RuntimeException('Google API error: ' . $lastError);
            }

            if ($status >= 400) {
                $msg = self::extractErrorMessage($decoded, $status);
                throw new RuntimeException('Google API error: ' . $msg);
            }

            return $decoded;
        }
    }

    /**
     * @param array<string,mixed> $decoded
     */
    private static function extractErrorMessage(array $decoded, int $status): string
    {
        $msg = 'HTTP ' . $status;
        if (isset($decoded['error']) && is_array($decoded['error'])) {
            $msg = (string) ($decoded['error']['message'] ?? $msg);
        } elseif (isset($decoded['error_description'])) {
            $msg = (string) $decoded['error_description'];
        } elseif (isset($decoded['error'])) {
            $msg = is_string($decoded['error']) ? $decoded['error'] : $msg;
        }
        return $msg;
    }

    private static function backoffSleep(int $attempt): void
    {
        $ms = min(8000, (int) (250 * (2 ** max(0, $attempt - 1))));
        $ms += random_int(0, 150);
        usleep($ms * 1000);
    }
}

