<?php
/**
 * PageSpeed Insights reader.
 *
 * Google's PSI API is a plain GET with no auth: unlike GscService there is no
 * service account, no key file, no consent screen, so this works on every
 * install out of the box. A key is nominally optional but required in practice:
 * keyless callers share ONE anonymous Google project whose daily quota is always
 * spent, so keyless requests 429 immediately (verified 2026-08-02). Paste
 * a key in SEO settings to use one (SEO -> Settings -> PageSpeed API key).
 *
 * Every run is a real Lighthouse render on Google's servers and takes 10-30s,
 * so results are cached per URL+strategy for 24 hours and the panel only
 * fetches when the user asks. Nothing here runs on a pageload or a cron.
 *
 * Two kinds of number come back and they are NOT interchangeable:
 *   - LAB (lighthouseResult): a single synthetic render. Always present.
 *   - FIELD (loadingExperience): real Chrome users over the last 28 days.
 *     Only exists once a URL has enough traffic, so most pages have none.
 * The panel labels which one it is showing rather than blending them.
 */

namespace Instinctor\Services;

use Instinctor\Core\Config;

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

class PageSpeedService
{
    private const ENDPOINT   = 'https://www.googleapis.com/pagespeedonline/v5/runPagespeed';
    private const CACHE_KEY  = 'instinctor_psi_cache';
    private const CACHE_TTL  = 86400; // 24h - a Lighthouse run is expensive and the score barely moves
    private const TIMEOUT    = 60;    // PSI itself takes 10-30s; below ~45 it times out on slow pages
    private const MAX_CACHED = 60;    // keep the config row bounded on big sites

    /** Metrics we surface, in display order. */
    private const LAB_AUDITS = [
        'largest-contentful-paint' => 'LCP',
        'cumulative-layout-shift'  => 'CLS',
        'total-blocking-time'      => 'TBT',
        'speed-index'              => 'Speed Index',
        'first-contentful-paint'   => 'FCP',
    ];

    /**
     * Cached result for a URL, or null when it has never been measured.
     *
     * @return array<string,mixed>|null
     */
    public static function cached(string $url, string $strategy = 'mobile'): ?array
    {
        $all = self::readCache();
        $hit = $all[self::slot($url, $strategy)] ?? null;
        return is_array($hit) ? $hit : null;
    }

    /**
     * Measure a URL. Returns the cached run when one is fresh enough unless
     * $force is set, so a double-click costs nothing.
     *
     * @return array<string,mixed> Always has `ok`; on failure also `error`.
     */
    public static function check(string $url, string $strategy = 'mobile', bool $force = false): array
    {
        $url      = trim($url);
        $strategy = ($strategy === 'desktop') ? 'desktop' : 'mobile';

        if ($url === '' || !filter_var($url, FILTER_VALIDATE_URL)) {
            return ['ok' => false, 'error' => 'That does not look like a public URL.'];
        }
        // PSI can only reach a public host. Saying so beats a 30s wait for
        // Google to report a lighthouse error we would have to translate.
        $host = (string) wp_parse_url($url, PHP_URL_HOST);
        if ($host === '' || $host === 'localhost' || strpos($host, '.') === false
            || filter_var($host, FILTER_VALIDATE_IP) !== false) {
            return ['ok' => false, 'error' => 'PageSpeed can only measure a public address, not a local one.'];
        }

        if (!$force) {
            $hit = self::cached($url, $strategy);
            if ($hit !== null && (time() - (int) ($hit['fetched_at'] ?? 0)) < self::CACHE_TTL) {
                $hit['cached'] = true;
                return $hit;
            }
        }

        $args = ['url' => $url, 'strategy' => $strategy, 'category' => 'performance'];
        // Saved through the SEO settings panel, which namespaces every key with
        // instinctor_seo_. Read the same place the panel writes.
        $key = (string) Config::get('instinctor_seo_pagespeed_api_key', '');
        if ($key !== '') {
            $args['key'] = $key;
        }

        $res = wp_remote_get(self::ENDPOINT . '?' . http_build_query($args), ['timeout' => self::TIMEOUT]);
        if (is_wp_error($res)) {
            return ['ok' => false, 'error' => 'Could not reach PageSpeed: ' . $res->get_error_message()];
        }
        $code = (int) wp_remote_retrieve_response_code($res);
        $body = json_decode((string) wp_remote_retrieve_body($res), true);

        if ($code !== 200 || !is_array($body)) {
            // Google's own message is the useful one (quota, unreachable URL,
            // render failure). Fall back to the status only when it is absent.
            $msg = is_array($body) ? (string) ($body['error']['message'] ?? '') : '';
            if ($msg === '') {
                $msg = 'PageSpeed returned HTTP ' . $code . '.';
            }
            if ($code === 429) {
                // Keyless 429s are not a temporary rate limit, they are the shared
                // anonymous project's exhausted daily quota, so "try again later"
                // would be a lie: it needs a key.
                $msg = $key === ''
                    ? 'Google needs an API key for PageSpeed. Add a free one in SEO settings.'
                    : 'PageSpeed daily quota reached for this key. It resets tomorrow.';
            }
            return ['ok' => false, 'error' => $msg];
        }

        $out = self::shape($body, $url, $strategy);
        self::store($url, $strategy, $out);
        return $out;
    }

    /** Flatten Google's payload into the handful of numbers the panel shows. */
    private static function shape(array $body, string $url, string $strategy): array
    {
        $lh     = $body['lighthouseResult'] ?? [];
        $audits = is_array($lh['audits'] ?? null) ? $lh['audits'] : [];

        $rawScore = $lh['categories']['performance']['score'] ?? null;
        $score    = is_numeric($rawScore) ? (int) round(((float) $rawScore) * 100) : null;

        $metrics = [];
        foreach (self::LAB_AUDITS as $id => $label) {
            if (!isset($audits[$id])) {
                continue;
            }
            $metrics[] = [
                'label'   => $label,
                'display' => (string) ($audits[$id]['displayValue'] ?? ''),
                // 0..1, Lighthouse's own pass/fail weighting for that metric.
                'score'   => is_numeric($audits[$id]['score'] ?? null) ? (float) $audits[$id]['score'] : null,
            ];
        }

        // Field data (CrUX). Absent for most pages; the panel hides its block.
        $field   = [];
        $fieldOv = null;
        $le      = $body['loadingExperience'] ?? [];
        if (!empty($le['metrics']) && is_array($le['metrics'])) {
            $fieldOv = (string) ($le['overall_category'] ?? '');
            $names   = [
                'LARGEST_CONTENTFUL_PAINT_MS'   => ['LCP', 'ms'],
                'INTERACTION_TO_NEXT_PAINT'     => ['INP', 'ms'],
                'CUMULATIVE_LAYOUT_SHIFT_SCORE' => ['CLS', ''],
            ];
            foreach ($names as $k => [$label, $unit]) {
                if (!isset($le['metrics'][$k]['percentile'])) {
                    continue;
                }
                $p = (int) $le['metrics'][$k]['percentile'];
                $field[] = [
                    'label'    => $label,
                    // CLS arrives multiplied by 100 so it survives as an int.
                    'display'  => $unit === 'ms' ? ($p . ' ms') : (string) round($p / 100, 2),
                    'category' => (string) ($le['metrics'][$k]['category'] ?? ''),
                ];
            }
        }

        // Heaviest opportunities, by the time Lighthouse says they cost.
        $opps = [];
        foreach ($audits as $a) {
            $ms = $a['details']['overallSavingsMs'] ?? null;
            if (!is_numeric($ms) || (float) $ms < 100) {
                continue;
            }
            $opps[] = ['title' => (string) ($a['title'] ?? ''), 'savings_ms' => (int) round((float) $ms)];
        }
        usort($opps, function ($x, $y) { return $y['savings_ms'] - $x['savings_ms']; });

        return [
            'ok'          => true,
            'url'         => $url,
            'strategy'    => $strategy,
            'score'       => $score,
            'metrics'     => $metrics,
            'field'       => $field,
            'field_class' => $fieldOv,
            'opportunities' => array_slice($opps, 0, 5),
            'fetched_at'  => time(),
        ];
    }

    /** @return array<string,mixed> */
    private static function readCache(): array
    {
        $raw = json_decode((string) Config::get(self::CACHE_KEY, ''), true);
        return is_array($raw) ? $raw : [];
    }

    private static function store(string $url, string $strategy, array $result): void
    {
        $all = self::readCache();
        $all[self::slot($url, $strategy)] = $result;
        if (count($all) > self::MAX_CACHED) {
            // Drop the oldest runs, not arbitrary keys.
            uasort($all, function ($a, $b) {
                return (int) ($b['fetched_at'] ?? 0) <=> (int) ($a['fetched_at'] ?? 0);
            });
            $all = array_slice($all, 0, self::MAX_CACHED, true);
        }
        Config::set(self::CACHE_KEY, (string) wp_json_encode($all));
    }

    private static function slot(string $url, string $strategy): string
    {
        return md5($url) . ':' . $strategy;
    }
}
