<?php

declare(strict_types=1);

namespace Numinix\SeekmodoWordPress\Mode;

/**
 * Structured (JSON-lines) shadow-mode divergence logger.
 *
 * Lines look like:
 *
 *   {"ts":1717286412,"q":"spruce","n":[12,8,4],"g":[12,4,9],"l_ms":91,
 *    "jaccard":0.5,"top1_match":true,"top3_match":false}
 *
 * Stored under `wp-content/uploads/seekmodo-shadow.log` (rotated
 * daily, the operator can tail it during the 7d soak). On the AKS
 * connector this is StorageLogger::path() ← we follow the same
 * convention to keep operator muscle memory consistent.
 *
 * Rotation policy: simple daily rotation by suffix:
 *   seekmodo-shadow.log              ← today's file
 *   seekmodo-shadow.log.2026-06-06   ← yesterday's file
 *   seekmodo-shadow.log.2026-06-05   ← two days ago
 *   ...
 * Files older than 14 days are deleted opportunistically on each
 * rotation hit (cheap, no separate cron).
 *
 * PII posture: only the search keyword is logged. We never log the
 * visitor's IP / user-agent / session id — the webserver's access log
 * already has the keyword via the `?s=` query string, so this is no
 * net-new exposure.
 */
final class ShadowDiffLogger
{
    /**
     * Capture a single shadow-mode divergence.
     *
     * @param array<int,int> $native_ids  Result IDs from WP's native LIKE.
     * @param array<int,int> $gateway_ids Result IDs the gateway proposed.
     * @param int            $latency_ms  Gateway round-trip latency.
     */
    public static function log(string $keyword, array $native_ids, array $gateway_ids, int $latency_ms): void
    {
        if ($keyword === '') {
            return;
        }

        $native  = array_values(array_map('intval', $native_ids));
        $gateway = array_values(array_map('intval', $gateway_ids));

        $jaccard    = self::jaccard($native, $gateway);
        $top1_match = !empty($native) && !empty($gateway) && $native[0] === $gateway[0];
        $top3_match = self::topNMatches($native, $gateway, 3);

        $entry = [
            'ts'         => time(),
            'q'          => $keyword,
            'n'          => array_slice($native, 0, 20),
            'g'          => array_slice($gateway, 0, 20),
            'l_ms'       => $latency_ms,
            'jaccard'    => round($jaccard, 3),
            'top1_match' => $top1_match,
            'top3_match' => $top3_match,
        ];

        $line = wp_json_encode($entry);
        if (!is_string($line)) {
            return;
        }

        self::appendRotated($line . "\n");
    }

    public static function logPath(): string
    {
        $uploads = function_exists('wp_upload_dir') ? wp_upload_dir() : null;
        $base    = is_array($uploads) && !empty($uploads['basedir'])
            ? (string) $uploads['basedir']
            : sys_get_temp_dir();

        return rtrim((string) $base, '/\\') . DIRECTORY_SEPARATOR . 'seekmodo-shadow.log';
    }

    /**
     * Aggregate the last <window_sec> seconds of log lines into a
     * compact summary. Called by `wp seekmodo shadow-report`.
     *
     * @return array<string,float|int>
     */
    public static function summarize(int $window_sec): array
    {
        $path = self::logPath();
        if (!is_file($path)) {
            return ['lines' => 0, 'jaccard_mean' => 0.0, 'top1_match_rate' => 0.0, 'top3_match_rate' => 0.0, 'p95_ms' => 0];
        }
        $cutoff   = time() - $window_sec;
        $lines    = [];
        $fh       = @fopen($path, 'r'); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- local shadow log
        if (!$fh) {
            return ['lines' => 0, 'jaccard_mean' => 0.0, 'top1_match_rate' => 0.0, 'top3_match_rate' => 0.0, 'p95_ms' => 0];
        }
        while (($line = fgets($fh)) !== false) {
            $row = json_decode((string) $line, true);
            if (!is_array($row) || (int) ($row['ts'] ?? 0) < $cutoff) {
                continue;
            }
            $lines[] = $row;
        }
        fclose($fh); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose

        $n = count($lines);
        if ($n === 0) {
            return ['lines' => 0, 'jaccard_mean' => 0.0, 'top1_match_rate' => 0.0, 'top3_match_rate' => 0.0, 'p95_ms' => 0];
        }
        $j_sum  = 0.0;
        $t1     = 0;
        $t3     = 0;
        $lats   = [];
        foreach ($lines as $row) {
            $j_sum  += (float) ($row['jaccard'] ?? 0.0);
            $t1     += !empty($row['top1_match']) ? 1 : 0;
            $t3     += !empty($row['top3_match']) ? 1 : 0;
            $lats[] = (int) ($row['l_ms'] ?? 0);
        }
        sort($lats);
        $p95 = $lats[(int) min($n - 1, floor($n * 0.95))] ?? 0;

        return [
            'lines'           => $n,
            'jaccard_mean'    => round($j_sum / $n, 3),
            'top1_match_rate' => round($t1 / $n, 3),
            'top3_match_rate' => round($t3 / $n, 3),
            'p95_ms'          => $p95,
        ];
    }

    // -------------------------------------------------------------------
    // Helpers
    // -------------------------------------------------------------------

    /**
     * @param array<int,int> $a
     * @param array<int,int> $b
     */
    private static function jaccard(array $a, array $b): float
    {
        $a = array_unique($a);
        $b = array_unique($b);
        if (empty($a) && empty($b)) {
            return 1.0;
        }
        $inter = count(array_intersect($a, $b));
        $union = count(array_unique(array_merge($a, $b)));

        return $union === 0 ? 0.0 : $inter / $union;
    }

    /**
     * @param array<int,int> $a
     * @param array<int,int> $b
     */
    private static function topNMatches(array $a, array $b, int $n): bool
    {
        if (count($a) < $n || count($b) < $n) {
            return count(array_intersect(array_slice($a, 0, $n), array_slice($b, 0, $n))) === min($n, count($a), count($b));
        }
        $a_top = array_slice($a, 0, $n);
        $b_top = array_slice($b, 0, $n);
        sort($a_top);
        sort($b_top);

        return $a_top === $b_top;
    }

    private static function appendRotated(string $line): void
    {
        $path = self::logPath();
        $dir  = dirname($path);
        if (!is_dir($dir)) {
            @mkdir($dir, 0775, true); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_mkdir
        }

        // Daily rotation. If the current file's last-modified date is
        // not today's date, rotate it.
        if (is_file($path)) {
            $today    = gmdate('Y-m-d');
            $modified = gmdate('Y-m-d', (int) filemtime($path));
            if ($modified !== $today) {
                @rename($path, $path . '.' . $modified); // phpcs:ignore WordPress.WP.AlternativeFunctions.rename_rename
                self::cleanupOldRotations($path);
            }
        }

        @file_put_contents($path, $line, FILE_APPEND | LOCK_EX);
    }

    private static function cleanupOldRotations(string $path): void
    {
        $dir       = dirname($path);
        $glob      = $path . '.*';
        $rotations = glob($glob) ?: [];
        $cutoff    = time() - 14 * 86400;
        foreach ($rotations as $f) {
            if (is_file($f) && filemtime($f) < $cutoff) {
                @unlink($f); // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink
            }
        }
    }
}
