<?php
declare(strict_types=1);

/** Test-only observer. Never writes SQL, parameters, response bodies or credentials. */
final class SqlCoverageRecorder
{
    private $path;
    private $root;
    private $scenario;
    private $count = 0;
    private $sourceHashes = [];

    public function __construct(string $path, string $root, string $scenario)
    {
        if (!preg_match('/^PILOT_[A-Z0-9_]{1,80}$/D', $scenario)
            || !is_dir(dirname($path)) || is_link($path)) {
            throw new RuntimeException('Invalid coverage destination/scenario');
        }
        $this->path = $path;
        $this->root = rtrim($root, '/');
        $this->scenario = $scenario;
    }

    // This is a structural fingerprint, not a SQL translator or a proof of equivalence.
    // Keep the normalized SQL in memory only; double-quoted identifiers stay distinct.
    public static function fingerprint(string $sql): array
    {
        $tokens = [];
        $warning = false;
        $length = strlen($sql);
        for ($i = 0; $i < $length;) {
            $c = $sql[$i];
            if (strpos(" \t\r\n\v\f", $c) !== false) { ++$i; continue; }
            if (substr($sql, $i, 2) === '--' || $c === '#') {
                $end = strpos($sql, "\n", $i);
                $i = $end === false ? $length : $end + 1;
                continue;
            }
            if (substr($sql, $i, 2) === '/*') {
                $end = strpos($sql, '*/', $i + 2);
                $i = $end === false ? $length : $end + 2;
                continue;
            }
            if ($c === "'" || $c === '`' || $c === '"') {
                $quote = $c;
                $identifier = '';
                $closed = false;
                ++$i;
                while ($i < $length) {
                    if ($sql[$i] === '\\' && $quote === "'") { $i += 2; continue; }
                    if ($sql[$i] === $quote) {
                        if (($sql[$i + 1] ?? '') === $quote) {
                            $identifier .= $quote; $i += 2; continue;
                        }
                        ++$i; $closed = true; break;
                    }
                    $identifier .= $sql[$i++];
                }
                $tokens[] = $quote === "'" ? '?' : 'identifier:' . strtolower($identifier);
                if (!$closed) $warning = true;
                continue;
            }
            $next = $sql[$i + 1] ?? '';
            if (($c >= '0' && $c <= '9') || ($c === '.' && $next !== '' && $next >= '0' && $next <= '9')) {
                if (preg_match('/\G(?:0x[0-9a-f]+|(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?)/i', $sql, $m, 0, $i) !== 1) {
                    $tokens[] = $c; ++$i; $warning = true; continue;
                }
                $tokens[] = '?'; $i += strlen($m[0]); continue;
            }
            if (($c >= 'A' && $c <= 'Z') || ($c >= 'a' && $c <= 'z') || $c === '_') {
                if (preg_match('/\G[a-z_][a-z0-9_$]*/i', $sql, $m, 0, $i) !== 1) {
                    $tokens[] = $c; ++$i; $warning = true; continue;
                }
                $tokens[] = strtolower($m[0]); $i += strlen($m[0]); continue;
            }
            if (ord($c) >= 128) $warning = true;
            $tokens[] = $c; ++$i;
        }
        $operation = in_array($tokens[0] ?? '', ['select', 'insert', 'update', 'delete',
            'replace', 'merge', 'begin', 'commit', 'rollback', 'savepoint'], true)
            ? $tokens[0] : 'other';
        return ['fingerprint' => hash('sha256', implode(' ', $tokens)), 'operation' => $operation,
            'lexical_warning' => $warning];
    }

    public function observe($sql, $runtime = null): void
    {
        $event = self::fingerprint((string)$sql);
        $sites = [];
        foreach (debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 96) as $frame) {
            $file = (string)($frame['file'] ?? '');
            if (strpos($file, $this->root . '/application/') !== 0
                && strpos($file, $this->root . '/app/') !== 0) continue;
            $site = substr($file, strlen($this->root) + 1) . ':' . (int)($frame['line'] ?? 0);
            $sites[$site] = $site;
            $relative = substr($file, strlen($this->root) + 1);
            if (!isset($this->sourceHashes[$relative])) {
                $this->sourceHashes[$relative] = hash_file('sha256', $file);
            }
        }
        ++$this->count;
        $this->append(['kind' => 'query_observed', 'scenario' => $this->scenario,
            'sequence' => $this->count, 'operation' => $event['operation'],
            'fingerprint_lexical_warning' => $event['lexical_warning'],
            'fingerprint' => $event['fingerprint'], 'callsites' => array_values($sites),
            'source_hashes' => $this->sourceHashes]);
    }

    public function finish(): void
    {
        $error = error_get_last();
        $fatal = $error !== null && in_array($error['type'], [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR], true);
        $this->append(['kind' => 'request_end', 'scenario' => $this->scenario,
            'observed_queries' => $this->count, 'http_status' => http_response_code() ?: 200,
            'fatal' => $fatal]);
    }

    private function append(array $event): void
    {
        if (file_put_contents($this->path, json_encode($event, JSON_UNESCAPED_SLASHES)
            . "\n", FILE_APPEND | LOCK_EX) === false) {
            throw new RuntimeException('Coverage write failed');
        }
        chmod($this->path, 0600);
    }
}
