<?php
/**
 * Google Search Console reader (Phase 1: vendor property only).
 *
 * Auth is a service account added as a user on the GSC property - no OAuth,
 * no refresh tokens, no consent screens. The JSON key lives OUTSIDE the
 * webroot (instinctor_gsc_key_path, default /var/www/instinctor.com/gsc-key.json)
 * and never ships in any build. When the key file is absent this whole
 * service reports unavailable and the dashboard panel stays hidden, so the
 * same code is safe on every install.
 *
 * Results are cached in Config for six hours: GSC data lags ~2 days and
 * changes at most daily, so live fetches per pageload would be pure waste.
 *
 * Phase 2 (Pro sites via platform relay) reuses query() with a different
 * property string; nothing here assumes the vendor property except summary().
 */

namespace Instinctor\Services;

use Instinctor\Core\Config;

if (!defined('ABSPATH')) {
    exit;
}

class GscService
{
    private const SCOPE     = 'https://www.googleapis.com/auth/webmasters.readonly';
    private const CACHE_KEY = 'instinctor_gsc_cache';
    private const CACHE_TTL = 21600; // 6 hours

    private static ?string $token = null;

    public static function keyPath(): string
    {
        return (string) Config::get('instinctor_gsc_key_path', '/var/www/instinctor.com/gsc-key.json');
    }

    public static function available(): bool
    {
        // The key file is shared across every site on the VPS (same php-fpm
        // user), so readability alone is NOT enough: without this gate every
        // fleet standalone showed instinctor.com's Search data in its SEO tab
        // (2026-07-24). A site only gets the panel when ITS OWN database
        // explicitly names its property - no default, no inheritance.
        return is_readable(self::keyPath())
            && (string) Config::get('instinctor_gsc_property', '') !== '';
    }

    public static function property(): string
    {
        return (string) Config::get('instinctor_gsc_property', 'sc-domain:instinctor.com');
    }

    /**
     * Dashboard payload: top queries, top pages, and the near-miss list
     * (positions 8-20 with impressions - the pages worth thickening).
     * Served from cache; at most one live GSC round-trip per 6 hours.
     */
    /** Days in each selectable window. Keys are what the panel sends. */
    public const RANGES = ['7d' => 7, '28d' => 28, '3mo' => 90];

    /**
     * Search Console data lags. Asking for "up to today" returns a window whose
     * last days are simply missing, so a 7-day pick would really be 4-5 days of
     * data and the totals would read low for no visible reason. Every window ends
     * LAG days back instead, which makes "last 7 days" mean seven days that
     * actually have data.
     */
    private const LAG_DAYS = 2;

    public static function summary(string $range = '28d'): array
    {
        if (!self::available()) {
            return ['available' => false];
        }
        if (!isset(self::RANGES[$range])) {
            $range = '28d';
        }

        $cacheKey = self::CACHE_KEY . '_' . $range;
        $cached   = json_decode((string) Config::get($cacheKey, ''), true);
        if (is_array($cached) && (time() - (int) ($cached['fetched_at'] ?? 0)) < self::CACHE_TTL) {
            return $cached;
        }

        $to   = gmdate('Y-m-d', strtotime('-' . self::LAG_DAYS . ' days'));
        $from = gmdate('Y-m-d', strtotime('-' . (self::LAG_DAYS + self::RANGES[$range] - 1) . ' days'));

        $queries = self::query(['query'], 250, $from, $to);
        $pages   = self::query(['page'], 100, $from, $to);
        if ($queries === null || $pages === null) {
            // Fetch failed: keep serving the stale cache if one exists.
            return is_array($cached) ? $cached : ['available' => false];
        }

        $fmtQ = [];
        foreach ($queries as $r) {
            $fmtQ[] = [
                'query'       => (string) $r['keys'][0],
                'clicks'      => (int) $r['clicks'],
                'impressions' => (int) $r['impressions'],
                'ctr'         => round($r['ctr'] * 100, 1),
                'position'    => round($r['position'], 1),
            ];
        }

        $fmtP = [];
        foreach ($pages as $r) {
            $path = (string) parse_url((string) $r['keys'][0], PHP_URL_PATH);
            $fmtP[] = [
                'path'        => $path !== '' ? $path : '/',
                'clicks'      => (int) $r['clicks'],
                'impressions' => (int) $r['impressions'],
                'ctr'         => round($r['ctr'] * 100, 1),
                'position'    => round($r['position'], 1),
            ];
        }

        // Near-miss: ranking on page 1 bottom to page 2, with real demand.
        $near = array_values(array_filter($fmtQ, function ($q) {
            return $q['position'] >= 8 && $q['position'] <= 20 && $q['impressions'] >= 5;
        }));
        usort($near, function ($a, $b) { return $b['impressions'] - $a['impressions']; });

        $out = [
            'available'  => true,
            'range'      => ['from' => $from, 'to' => $to, 'key' => $range, 'lag_days' => self::LAG_DAYS],
            'queries'    => array_slice($fmtQ, 0, 25),
            'pages'      => array_slice($fmtP, 0, 15),
            'nearMiss'   => array_slice($near, 0, 15),
            'totals'     => [
                'clicks'      => array_sum(array_column($fmtQ, 'clicks')),
                'impressions' => array_sum(array_column($fmtQ, 'impressions')),
            ],
            'fetched_at' => time(),
        ];
        Config::set($cacheKey, (string) wp_json_encode($out));
        return $out;
    }

    /**
     * Raw Search Analytics query over an explicit window.
     * Returns rows array, or null on any auth/transport failure.
     */
    private static function query(array $dimensions, int $rowLimit, string $from = '', string $to = ''): ?array
    {
        $token = self::accessToken();
        if ($token === null) {
            return null;
        }
        $url = 'https://searchconsole.googleapis.com/webmasters/v3/sites/'
             . rawurlencode(self::property()) . '/searchAnalytics/query';
        $body = [
            'startDate'  => $from !== '' ? $from : gmdate('Y-m-d', strtotime('-28 days')),
            'endDate'    => $to   !== '' ? $to   : gmdate('Y-m-d'),
            'dimensions' => $dimensions,
            'rowLimit'   => $rowLimit,
        ];
        $res = self::post($url, wp_json_encode($body), [
            'Authorization: Bearer ' . $token,
            'Content-Type: application/json',
        ]);
        if (!is_array($res) || isset($res['error'])) {
            return null;
        }
        return $res['rows'] ?? [];
    }

    /** Mint (and per-request cache) an access token via signed JWT. */
    private static function accessToken(): ?string
    {
        if (self::$token !== null) {
            return self::$token;
        }
        $key = json_decode((string) file_get_contents(self::keyPath()), true);
        if (empty($key['client_email']) || empty($key['private_key'])) {
            return null;
        }
        $b64 = function ($data) { return rtrim(strtr(base64_encode($data), '+/', '-_'), '='); };
        $now = time();
        $hdr = $b64((string) wp_json_encode(['alg' => 'RS256', 'typ' => 'JWT']));
        $clm = $b64((string) wp_json_encode([
            'iss'   => $key['client_email'],
            'scope' => self::SCOPE,
            'aud'   => 'https://oauth2.googleapis.com/token',
            'iat'   => $now,
            'exp'   => $now + 3600,
        ]));
        $ok = openssl_sign("$hdr.$clm", $sig, $key['private_key'], 'sha256WithRSAEncryption');
        if (!$ok) {
            return null;
        }
        $jwt = "$hdr.$clm." . $b64($sig);
        $res = self::post('https://oauth2.googleapis.com/token', http_build_query([
            'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer',
            'assertion'  => $jwt,
        ]), ['Content-Type: application/x-www-form-urlencoded']);
        if (empty($res['access_token'])) {
            return null;
        }
        self::$token = (string) $res['access_token'];
        return self::$token;
    }

    /** Small POST helper; returns decoded JSON array or null. */
    private static function post(string $url, string $body, array $headers): ?array
    {
        $ch = curl_init($url);
        curl_setopt_array($ch, [
            CURLOPT_POST           => true,
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_TIMEOUT        => 15,
            CURLOPT_HTTPHEADER     => $headers,
            CURLOPT_POSTFIELDS     => $body,
        ]);
        $raw = curl_exec($ch);
        curl_close($ch);
        if (!is_string($raw) || $raw === '') {
            return null;
        }
        $dec = json_decode($raw, true);
        return is_array($dec) ? $dec : null;
    }
}
