<?php

declare(strict_types=1);

namespace PpPhp\Core\Events;

/**
 * Phase-1 code checker for the Events editor ("grammar check" for event code).
 *
 * Two independent layers, both surfaced as {line, severity, message, range,
 * replacement} diagnostics the editor renders in a strip under the textarea:
 *
 *   A. SYNTAX  — is this valid PHP at all? Uses token_get_all($src, TOKEN_PARSE),
 *      which runs the FULL PHP parser and throws a ParseError on bad syntax
 *      WITHOUT executing the code and WITHOUT needing shell access (php -l /
 *      exec are often disabled on shared hosting). The snippet is a handler
 *      BODY, so it is wrapped in a function to parse in the right context.
 *
 *   B. SEMANTIC — are the PPPHP helpers spelled right? Lexes the body with the
 *      tolerant token_get_all (no TOKEN_PARSE) and matches every function /
 *      static-method call against the real API catalogue (EventApiReference).
 *      An unknown call that is a small edit-distance away from a catalogued
 *      helper becomes a "did you mean …" fix. It never flags PHP built-ins,
 *      already-defined functions, or names that aren't close to one of ours, so
 *      a user's own helpers don't trip it.
 *
 * JavaScript events are linted client-side (the browser can parse JS; PHP
 * cannot), so this class only handles PHP.
 */
final class EventLinter
{
    /** Language constructs that look like calls but aren't functions. */
    private const CONSTRUCTS = [
        'isset', 'empty', 'unset', 'list', 'array', 'echo', 'print',
        'include', 'include_once', 'require', 'require_once',
        'exit', 'die', 'eval', 'match', 'throw', 'clone', 'yield',
    ];

    /** Legacy helpers to gently steer away from. name => guidance. */
    private const DEPRECATED = [
        'pp_store_upload' => 'a raw /uploads helper — for a File/Image field read the value with pp_file_url() instead.',
    ];

    /**
     * Lint a PHP event body.
     *
     * @param array<int,string> $contextVars The variables this event hands the
     *        author (e.g. ['$username','$password']) — used for a "did you mean
     *        $record?" check on a mistyped variable.
     * @return array<int, array{line:int|null,severity:string,message:string,source:string,range?:array{0:int,1:int},replacement?:string}>
     */
    public static function php(string $code, array $contextVars = []): array
    {
        $diags = [];
        if (trim($code) === '') {
            return $diags;
        }
        // Event bodies keep their leading "<?php" tag (it is in every stub).
        // Blank the opening/closing tags out with equal-length spaces so the
        // wrapper parses AND every character offset / line number stays aligned
        // with what the editor holds (so fix ranges land on the right token).
        $code = self::blankTags($code);

        $syntax = self::phpSyntax($code);
        if ($syntax !== null) {
            $diags[] = $syntax;
        }
        // Semantic runs regardless — the tolerant lexer still yields tokens even
        // when the parser rejected the body, so a typo'd helper is still caught.
        foreach (self::phpSemantic($code) as $d) {
            $diags[] = $d;
        }
        foreach (self::phpVariables($code, $contextVars) as $d) {
            $diags[] = $d;
        }
        foreach (self::phpArgCounts($code) as $d) {
            $diags[] = $d;
        }
        foreach (self::phpUnusedVars($code) as $d) {
            $diags[] = $d;
        }
        return array_slice($diags, 0, 25);
    }

    /** Flatten a body into tokens carrying their absolute start offset. */
    private static function tokens(string $code): array
    {
        $tokens = @token_get_all('<?php ' . $code);
        if (!is_array($tokens)) {
            return [[], 6];
        }
        $flat = [];
        $offset = 0;
        foreach ($tokens as $tok) {
            if (is_array($tok)) {
                $flat[] = ['id' => $tok[0], 'text' => $tok[1], 'line' => $tok[2], 'start' => $offset];
                $offset += strlen($tok[1]);
            } else {
                $flat[] = ['id' => null, 'text' => $tok, 'line' => 0, 'start' => $offset];
                $offset += strlen($tok);
            }
        }
        return [$flat, 6];   // 6 = strlen('<?php ')
    }

    /**
     * Flag a catalogued call made with too few / too many arguments.
     *
     * @return array<int, array{line:int,severity:string,message:string,source:string,range:array{0:int,1:int}}>
     */
    private static function phpArgCounts(string $code): array
    {
        $sigs = EventApiReference::signatures()['php'] ?? [];
        if ($sigs === []) {
            return [];
        }
        [$flat, $prefixLen] = self::tokens($code);
        $n = count($flat);
        if ($n === 0) {
            return [];
        }
        $sig = static function (int $from, int $dir) use ($flat, $n): int {
            for ($i = $from + $dir; $i >= 0 && $i < $n; $i += $dir) {
                $id = $flat[$i]['id'];
                if ($id !== T_WHITESPACE && $id !== T_COMMENT && $id !== T_DOC_COMMENT) {
                    return $i;
                }
            }
            return -1;
        };
        // Count arguments inside the call whose "(" is at index $open.
        $count = static function (int $open) use ($flat, $n): array {
            $depth = 0;
            $commas = 0;
            $sawArg = false;
            $spread = false;
            for ($i = $open; $i < $n; $i++) {
                $t = $flat[$i]['text'];
                $id = $flat[$i]['id'];
                if ($t === '(' || $t === '[' || $t === '{') {
                    $depth++;
                    // A nested group (array literal, (expr), closure) IS an argument.
                    if ($depth >= 2) { $sawArg = true; }
                    continue;
                }
                if ($t === ')' || $t === ']' || $t === '}') {
                    $depth--;
                    if ($depth === 0) break;
                    continue;
                }
                if ($depth === 1 && $id !== T_WHITESPACE && $id !== T_COMMENT && $id !== T_DOC_COMMENT) {
                    if ($t === ',') { $commas++; continue; }
                    if ($id === T_ELLIPSIS) { $spread = true; }
                    $sawArg = true;
                }
            }
            return [$sawArg ? $commas + 1 : 0, $spread];
        };

        $out = [];
        for ($i = 0; $i < $n; $i++) {
            if ($flat[$i]['id'] !== T_STRING) {
                continue;
            }
            $nextI = $sig($i, +1);
            $prevI = $sig($i, -1);
            $next = $nextI >= 0 ? $flat[$nextI] : null;
            $prev = $prevI >= 0 ? $flat[$prevI] : null;

            $label = null;
            $openI = -1;
            $nameTok = $i;

            if ($next && $next['id'] === T_DOUBLE_COLON) {                 // Class::method(
                $mI = $sig($nextI, +1);
                $cI = $mI >= 0 ? $sig($mI, +1) : -1;
                if ($mI >= 0 && $flat[$mI]['id'] === T_STRING && $cI >= 0 && $flat[$cI]['text'] === '(') {
                    $label = $flat[$i]['text'] . '::' . $flat[$mI]['text'];
                    $openI = $cI;
                    $nameTok = $mI;
                }
            } elseif ($next && $next['text'] === '(') {                    // name(
                if ($prev && in_array($prev['id'], [T_OBJECT_OPERATOR, T_DOUBLE_COLON, T_FUNCTION, T_NEW, T_NS_SEPARATOR], true)) {
                    continue;
                }
                $label = $flat[$i]['text'];
                $openI = $nextI;
            }
            if ($label === null || !isset($sigs[$label])) {
                continue;
            }

            [$argc, $spread] = $count($openI);
            if ($spread) {
                continue;   // a spread makes the count unknowable
            }
            [$min, $max] = $sigs[$label];
            $start = $flat[$nameTok]['start'] - $prefixLen;
            $range = [$start, $start + strlen($flat[$nameTok]['text'])];
            $disp = str_contains($label, '::') ? $label : $label;
            if ($argc < $min) {
                $out[] = [
                    'line' => $flat[$nameTok]['line'],
                    'severity' => 'warning',
                    'message' => "{$disp}() needs " . self::plural($min, 'argument') . ", got {$argc}.",
                    'source' => 'php',
                    'range' => $range,
                ];
            } elseif ($max >= 0 && $argc > $max) {
                $out[] = [
                    'line' => $flat[$nameTok]['line'],
                    'severity' => 'warning',
                    'message' => "{$disp}() takes at most " . self::plural($max, 'argument') . ", got {$argc}.",
                    'source' => 'php',
                    'range' => $range,
                ];
            }
        }
        return $out;
    }

    /**
     * Note a variable that is assigned once and never used again — a plain
     * "$x = …;" whose name appears nowhere else in the body. Kept deliberately
     * strict (single occurrence, simple assignment) so it never misfires on
     * interpolation, closures, by-ref, or compound assignment.
     *
     * @return array<int, array{line:int,severity:string,message:string,source:string,range:array{0:int,1:int}}>
     */
    private static function phpUnusedVars(string $code): array
    {
        [$flat, $prefixLen] = self::tokens($code);
        $n = count($flat);
        if ($n === 0) {
            return [];
        }
        $sig = static function (int $from, int $dir) use ($flat, $n): int {
            for ($i = $from + $dir; $i >= 0 && $i < $n; $i += $dir) {
                $id = $flat[$i]['id'];
                if ($id !== T_WHITESPACE && $id !== T_COMMENT && $id !== T_DOC_COMMENT) {
                    return $i;
                }
            }
            return -1;
        };
        $occ = [];
        foreach ($flat as $idx => $t) {
            if ($t['id'] === T_VARIABLE) {
                $occ[strtolower($t['text'])][] = $idx;
            }
        }
        $superLower = array_map('strtolower', self::SUPERGLOBALS);
        $out = [];
        foreach ($occ as $lower => $indices) {
            if (count($indices) !== 1 || in_array($lower, $superLower, true)) {
                continue;
            }
            $idx = $indices[0];
            $nextI = $sig($idx, +1);
            $prevI = $sig($idx, -1);
            if ($nextI < 0 || $flat[$nextI]['text'] !== '=') {
                continue;   // only a simple "$x =" target
            }
            // Exclude "$x =& …" (reference bind) and any compound handled by id already.
            $afterEq = $sig($nextI, +1);
            if ($afterEq >= 0 && $flat[$afterEq]['text'] === '&') {
                continue;
            }
            // Exclude a static/global declaration target (those are intentional).
            if ($prevI >= 0 && in_array($flat[$prevI]['id'], [T_GLOBAL, T_STATIC], true)) {
                continue;
            }
            $name = $flat[$idx]['text'];
            $start = $flat[$idx]['start'] - $prefixLen;
            $out[] = [
                'line' => $flat[$idx]['line'],
                'severity' => 'info',
                'message' => "{$name} is assigned but never used.",
                'source' => 'php',
                'range' => [$start, $start + strlen($name)],
            ];
        }
        return $out;
    }

    private static function plural(int $count, string $noun): string
    {
        return $count . ' ' . $noun . ($count === 1 ? '' : 's');
    }

    /**
     * Portable build-time check for a JavaScript event body. The browser does
     * the full job (Acorn syntax + AST arg-count/unused); here — with no JS
     * parser in PHP — we run the high-value, parser-free part: the `pp.*`
     * "did you mean" spell-check against the catalogue. Mirrors the client
     * jsSemantic so the build report agrees with the editor.
     *
     * @return array<int, array{line:int,severity:string,message:string,source:string,range:array{0:int,1:int},replacement:string}>
     */
    public static function js(string $code): array
    {
        if (trim($code) === '') {
            return [];
        }
        [$known, $namespaces, $byParent] = self::jsIndex();
        if ($known === []) {
            return [];
        }
        $out = [];
        if (!preg_match_all('/\bpp\.[A-Za-z0-9_$.]+/', $code, $m, PREG_OFFSET_CAPTURE)) {
            return [];
        }
        foreach ($m[0] as $hit) {
            $path = rtrim($hit[0], '.');
            $offset = $hit[1];
            if (isset($known[$path]) || isset($namespaces[$path])) {
                continue;
            }
            $dot = strrpos($path, '.');
            if ($dot === false) {
                continue;
            }
            $parent = substr($path, 0, $dot);
            $leaf = substr($path, $dot + 1);
            if (!isset($namespaces[$parent])) {
                continue;   // only judge members of a KNOWN namespace
            }
            $siblings = $byParent[$parent] ?? [];
            $best = null;
            $bestDist = PHP_INT_MAX;
            foreach ($siblings as $s) {
                $d = self::damerau(strtolower($leaf), strtolower($s));
                if ($d > 0 && $d < $bestDist) { $bestDist = $d; $best = $s; }
            }
            $threshold = strlen($leaf) <= 4 ? 1 : 2;
            if ($best !== null && $bestDist <= $threshold) {
                $to = $parent . '.' . $best;
                $out[] = [
                    'line' => substr_count($code, "\n", 0, $offset) + 1,
                    'severity' => 'warning',
                    'message' => "Unknown pp API {$path} — did you mean {$to}?",
                    'source' => 'js',
                    'range' => [$offset, $offset + strlen($path)],
                    'replacement' => $to,
                ];
            }
        }
        return $out;
    }

    /** @return array{0:array<string,bool>,1:array<string,bool>,2:array<string,array<int,string>>} */
    private static function jsIndex(): array
    {
        static $cache = null;
        if ($cache !== null) {
            return $cache;
        }
        $labels = EventApiReference::symbols()['js'] ?? [];
        $known = [];
        $namespaces = [];
        $byParent = [];
        foreach ($labels as $l) {
            $known[$l] = true;
            $parts = explode('.', $l);
            for ($k = 1; $k < count($parts); $k++) {
                $namespaces[implode('.', array_slice($parts, 0, $k))] = true;
            }
            if (count($parts) > 1) {
                $parent = implode('.', array_slice($parts, 0, -1));
                $byParent[$parent][] = $parts[count($parts) - 1];
            }
        }
        return $cache = [$known, $namespaces, $byParent];
    }

    /** Replace a leading open tag / trailing close tag with same-length spaces. */
    private static function blankTags(string $code): string
    {
        $code = preg_replace_callback(
            '/^(\s*)(<\?php|<\?=|<\?)/i',
            static fn($m) => $m[1] . str_repeat(' ', strlen($m[2])),
            $code,
            1
        ) ?? $code;
        $code = preg_replace_callback(
            '/\?>(\s*)$/',
            static fn($m) => '  ' . $m[1],
            $code,
            1
        ) ?? $code;
        return $code;
    }

    /** @return array{line:int|null,severity:string,message:string,source:string}|null */
    private static function phpSyntax(string $code): ?array
    {
        // Prefix has NO newline, so a body line N stays source line N. The
        // closing brace is on its own line so a trailing // comment in the body
        // cannot swallow it.
        $wrapped = '<?php function __ppchk(){' . $code . "\n}";
        try {
            token_get_all($wrapped, TOKEN_PARSE);
            return null;
        } catch (\Throwable $e) {
            return [
                'line' => max(1, $e->getLine()),
                'severity' => 'error',
                'message' => 'Syntax error: ' . self::tidy($e->getMessage()),
                'source' => 'php',
            ];
        }
    }

    /**
     * @return array<int, array{line:int|null,severity:string,message:string,source:string,range:array{0:int,1:int},replacement?:string}>
     */
    private static function phpSemantic(string $code): array
    {
        [$functions, $classMethods] = self::catalogue();
        $out = [];
        $seen = [];

        $prefix = '<?php ';
        $tokens = @token_get_all($prefix . $code);
        if (!is_array($tokens)) {
            return $out;
        }

        // Materialise tokens with their absolute start offset in the source.
        $flat = [];
        $offset = 0;
        foreach ($tokens as $tok) {
            if (is_array($tok)) {
                $flat[] = ['id' => $tok[0], 'text' => $tok[1], 'line' => $tok[2], 'start' => $offset];
                $offset += strlen($tok[1]);
            } else {
                $flat[] = ['id' => null, 'text' => $tok, 'line' => 0, 'start' => $offset];
                $offset += strlen($tok);
            }
        }
        $n = count($flat);
        $prefixLen = strlen($prefix);

        // Index of the next / previous SIGNIFICANT token (skip whitespace + comments).
        $significant = static function (int $from, int $dir) use ($flat, $n): int {
            $i = $from + $dir;
            while ($i >= 0 && $i < $n) {
                $id = $flat[$i]['id'];
                if ($id !== T_WHITESPACE && $id !== T_COMMENT && $id !== T_DOC_COMMENT) {
                    return $i;
                }
                $i += $dir;
            }
            return -1;
        };

        for ($i = 0; $i < $n; $i++) {
            if ($flat[$i]['id'] !== T_STRING) {
                continue;
            }
            $name = $flat[$i]['text'];
            $nextI = $significant($i, +1);
            $prevI = $significant($i, -1);
            $next = $nextI >= 0 ? $flat[$nextI] : null;
            $prev = $prevI >= 0 ? $flat[$prevI] : null;

            // ── static call  Class::method(  ────────────────────────────────
            if ($next && $next['id'] === T_DOUBLE_COLON) {
                $mI = $significant($nextI, +1);
                $callI = $mI >= 0 ? $significant($mI, +1) : -1;
                if ($mI >= 0 && $flat[$mI]['id'] === T_STRING && $callI >= 0 && $flat[$callI]['text'] === '(') {
                    $class = $name;
                    $method = $flat[$mI]['text'];
                    if (isset($classMethods[$class])) {
                        $known = $classMethods[$class];
                        if (!in_array(strtolower($method), array_map('strtolower', $known), true)) {
                            $hit = self::nearest($method, $known);
                            if ($hit !== null) {
                                $key = $flat[$mI]['start'] . ':' . $method;
                                if (!isset($seen[$key])) {
                                    $seen[$key] = true;
                                    $start = $flat[$mI]['start'] - $prefixLen;
                                    $out[] = [
                                        'line' => $flat[$mI]['line'],
                                        'severity' => 'warning',
                                        'message' => "Unknown method {$class}::{$method}() — did you mean {$class}::{$hit}()?",
                                        'source' => 'php',
                                        'range' => [$start, $start + strlen($method)],
                                        'replacement' => $hit,
                                    ];
                                }
                            }
                        }
                    }
                }
                continue; // handled as a static reference
            }

            // ── plain function call  name(  ─────────────────────────────────
            if (!$next || $next['text'] !== '(') {
                continue;
            }
            // Not a method / property / declaration / instantiation.
            if ($prev && in_array($prev['id'], [T_OBJECT_OPERATOR, T_DOUBLE_COLON, T_FUNCTION, T_NEW, T_NS_SEPARATOR], true)) {
                continue;
            }
            if (defined('T_NULLSAFE_OBJECT_OPERATOR') && $prev && $prev['id'] === T_NULLSAFE_OBJECT_OPERATOR) {
                continue;
            }

            $lower = strtolower($name);
            if (in_array($lower, self::CONSTRUCTS, true)) {
                continue;
            }
            // Deprecated but valid → gentle info.
            if (isset(self::DEPRECATED[$name])) {
                $out[] = [
                    'line' => $flat[$i]['line'],
                    'severity' => 'info',
                    'message' => "{$name}() is " . self::DEPRECATED[$name],
                    'source' => 'php',
                    'range' => [$flat[$i]['start'] - $prefixLen, $flat[$i]['start'] - $prefixLen + strlen($name)],
                ];
                continue;
            }
            if (isset($functions[$lower])) {
                continue; // a catalogued helper
            }
            if (\function_exists($name)) {
                continue; // a real PHP built-in or already-defined function
            }
            if (strlen($name) < 4) {
                continue;
            }
            $hit = self::nearest($name, array_values($functions));
            if ($hit !== null) {
                $key = $flat[$i]['start'] . ':' . $name;
                if (!isset($seen[$key])) {
                    $seen[$key] = true;
                    $start = $flat[$i]['start'] - $prefixLen;
                    $out[] = [
                        'line' => $flat[$i]['line'],
                        'severity' => 'warning',
                        'message' => "Unknown function {$name}() — did you mean {$hit}()?",
                        'source' => 'php',
                        'range' => [$start, $start + strlen($name)],
                        'replacement' => $hit,
                    ];
                }
            }
        }

        return $out;
    }

    /** PHP superglobals + predefined that are always in scope. */
    private const SUPERGLOBALS = [
        '$_GET', '$_POST', '$_REQUEST', '$_SESSION', '$_SERVER', '$_FILES',
        '$_COOKIE', '$_ENV', '$GLOBALS', '$this', '$http_response_header',
        '$argv', '$argc', '$php_errormsg',
    ];

    /**
     * Flag a variable that is read but never assigned and is a small edit away
     * from one of the event's context variables — the classic "$recrod" typo.
     * Deliberately conservative: it only speaks up when the name is CLOSE to a
     * real context variable, so a user's own locals are left alone. Assignment
     * tracking is intentionally over-eager (marking a name assigned only
     * SUPPRESSES a warning, which is the safe direction).
     *
     * @param array<int,string> $contextVars
     * @return array<int, array{line:int,severity:string,message:string,source:string,range:array{0:int,1:int},replacement:string}>
     */
    private static function phpVariables(string $code, array $contextVars): array
    {
        $context = array_values(array_filter(array_map('strval', $contextVars), static fn($v) => $v !== ''));
        if ($context === []) {
            return [];
        }
        $prefix = '<?php ';
        $tokens = @token_get_all($prefix . $code);
        if (!is_array($tokens)) {
            return [];
        }
        $prefixLen = strlen($prefix);

        $flat = [];
        $offset = 0;
        foreach ($tokens as $tok) {
            if (is_array($tok)) {
                $flat[] = ['id' => $tok[0], 'text' => $tok[1], 'line' => $tok[2], 'start' => $offset];
                $offset += strlen($tok[1]);
            } else {
                $flat[] = ['id' => null, 'text' => $tok, 'line' => 0, 'start' => $offset];
                $offset += strlen($tok);
            }
        }
        $n = count($flat);
        $sig = static function (int $from, int $dir) use ($flat, $n): int {
            for ($i = $from + $dir; $i >= 0 && $i < $n; $i += $dir) {
                $id = $flat[$i]['id'];
                if ($id !== T_WHITESPACE && $id !== T_COMMENT && $id !== T_DOC_COMMENT) {
                    return $i;
                }
            }
            return -1;
        };
        // True when token index $from (a "(" or "," before a variable) sits inside
        // a function/fn/closure PARAMETER list — so the variable binds a name.
        $inParamList = static function (int $from) use ($flat, $sig): bool {
            $depth = 0;
            for ($i = $from, $steps = 0; $i >= 0 && $steps < 60; $i--, $steps++) {
                $t = $flat[$i]['text'];
                if ($t === ')') { $depth++; continue; }
                if ($t === '(') {
                    if ($depth > 0) { $depth--; continue; }
                    $before = $sig($i, -1);              // token owning this "("
                    if ($before < 0) return false;
                    if (in_array($flat[$before]['id'], [T_FUNCTION, T_FN], true)) return true;
                    // named function: `function name(`
                    if ($flat[$before]['id'] === T_STRING) {
                        $bb = $sig($before, -1);
                        return $bb >= 0 && $flat[$bb]['id'] === T_FUNCTION;
                    }
                    return false;
                }
                if ($t === ';' || $t === '{' || $t === '}') return false;
            }
            return false;
        };

        // Pass 1 — every variable that is (loosely) an assignment target.
        $assigned = [];
        $assignPrev = [T_AS, T_DOUBLE_ARROW, T_GLOBAL, T_STATIC];
        for ($i = 0; $i < $n; $i++) {
            if ($flat[$i]['id'] !== T_VARIABLE) {
                continue;
            }
            $name = $flat[$i]['text'];
            $nextI = $sig($i, +1);
            $prevI = $sig($i, -1);
            $next = $nextI >= 0 ? $flat[$nextI] : null;
            $prev = $prevI >= 0 ? $flat[$prevI] : null;
            $isAssign = false;
            if ($next) {
                if ($next['text'] === '=') {
                    $isAssign = true;                       // $x = …
                } elseif (in_array($next['id'], [
                    T_PLUS_EQUAL, T_MINUS_EQUAL, T_MUL_EQUAL, T_DIV_EQUAL, T_CONCAT_EQUAL,
                    T_MOD_EQUAL, T_AND_EQUAL, T_OR_EQUAL, T_XOR_EQUAL, T_SL_EQUAL, T_SR_EQUAL,
                    T_POW_EQUAL, T_COALESCE_EQUAL,
                ], true)) {
                    $isAssign = true;                       // $x += …
                }
            }
            if (!$isAssign && $prev && in_array($prev['id'], $assignPrev, true)) {
                $isAssign = true;                           // foreach as $x / $k => $x / global / static
            }
            // A by-reference / variadic parameter binds the name.
            if (!$isAssign && $prev && ($prev['text'] === '&' || ($prev['id'] === T_ELLIPSIS))) {
                $isAssign = true;
            }
            // Closure/function parameters: the variable sits inside a "(…)" that
            // directly follows `function`/`fn`. (A plain `if (`/call `(` must NOT
            // count — that would hide the common `if ($typo)` mistake.)
            if (!$isAssign && $prev && ($prev['text'] === '(' || $prev['text'] === ',')) {
                if ($inParamList($prevI)) {
                    $isAssign = true;
                }
            }
            if ($isAssign) {
                $assigned[strtolower($name)] = true;
            }
        }

        // Pass 2 — reads that look like a typo of a context variable.
        $ctxLower = [];
        foreach ($context as $c) {
            $ctxLower[strtolower($c)] = $c;
        }
        $superLower = array_map('strtolower', self::SUPERGLOBALS);
        $out = [];
        $seen = [];
        for ($i = 0; $i < $n; $i++) {
            if ($flat[$i]['id'] !== T_VARIABLE) {
                continue;
            }
            $name = $flat[$i]['text'];
            $lower = strtolower($name);
            if (isset($assigned[$lower]) || isset($ctxLower[$lower]) || in_array($lower, $superLower, true)) {
                continue;
            }
            // Property/method access ($obj->foo) starts from a variable that may
            // be user-defined; only judge the leading variable itself.
            $prevI = $sig($i, -1);
            if ($prevI >= 0 && $flat[$prevI]['id'] === T_OBJECT_OPERATOR) {
                continue;
            }
            $bare = ltrim($name, '$');
            if (strlen($bare) < 3) {
                continue;
            }
            $hit = self::nearestVar($bare, $context);
            if ($hit !== null) {
                $key = $flat[$i]['start'] . ':' . $name;
                if (!isset($seen[$key])) {
                    $seen[$key] = true;
                    $start = $flat[$i]['start'] - $prefixLen;
                    $out[] = [
                        'line' => $flat[$i]['line'],
                        'severity' => 'warning',
                        'message' => "Undefined variable {$name} — did you mean {$hit}?",
                        'source' => 'php',
                        'range' => [$start, $start + strlen($name)],
                        'replacement' => $hit,
                    ];
                }
            }
        }
        return $out;
    }

    /**
     * Nearest context variable to a bare (no-$) name, or null.
     * @param array<int,string> $context
     */
    private static function nearestVar(string $bare, array $context): ?string
    {
        $threshold = strlen($bare) <= 6 ? 1 : 2;
        $best = null;
        $bestDist = PHP_INT_MAX;
        foreach ($context as $c) {
            $d = self::damerau(strtolower($bare), strtolower(ltrim($c, '$')));
            if ($d > 0 && $d < $bestDist) {
                $bestDist = $d;
                $best = $c;
            }
        }
        return ($best !== null && $bestDist <= $threshold) ? $best : null;
    }

    /**
     * The known symbol sets harvested from the API catalogue.
     *
     * @return array{0: array<string,string>, 1: array<string, array<int,string>>}
     *         [ functionsLowerToOriginal, class => [methods] ]
     */
    private static function catalogue(): array
    {
        static $cache = null;
        if ($cache !== null) {
            return $cache;
        }
        $functions = [];
        $classMethods = [];
        foreach (EventApiReference::php() as $row) {
            $label = (string) ($row['label'] ?? '');
            if ($label === '') {
                continue;
            }
            if (str_contains($label, '::')) {
                [$class, $method] = explode('::', $label, 2);
                $classMethods[$class][] = $method;
            } elseif (preg_match('/^[A-Za-z_][A-Za-z0-9_]*$/', $label)) {
                $functions[strtolower($label)] = $label;
            }
        }
        return $cache = [$functions, $classMethods];
    }

    /**
     * The nearest catalogued name to a (mis)typed one, or null when nothing is
     * close enough to be a confident "did you mean". Short names demand an
     * almost-exact match so a user's own short helper isn't second-guessed.
     *
     * @param array<int,string> $candidates
     */
    private static function nearest(string $name, array $candidates): ?string
    {
        $threshold = strlen($name) <= 6 ? 1 : 2;
        $best = null;
        $bestDist = PHP_INT_MAX;
        $ln = strtolower($name);
        foreach ($candidates as $cand) {
            $d = self::damerau($ln, strtolower($cand));
            if ($d > 0 && $d < $bestDist) {
                $bestDist = $d;
                $best = $cand;
            }
        }
        return ($best !== null && $bestDist <= $threshold) ? $best : null;
    }

    /**
     * Damerau (optimal string alignment) distance — like Levenshtein but a swap
     * of two adjacent characters costs 1, not 2. That matters because a
     * transposition (recrod→record, tost→tots) is the single most common typo.
     */
    private static function damerau(string $a, string $b): int
    {
        $la = strlen($a);
        $lb = strlen($b);
        if ($la === 0) return $lb;
        if ($lb === 0) return $la;
        $d = [];
        for ($i = 0; $i <= $la; $i++) $d[$i][0] = $i;
        for ($j = 0; $j <= $lb; $j++) $d[0][$j] = $j;
        for ($i = 1; $i <= $la; $i++) {
            for ($j = 1; $j <= $lb; $j++) {
                $cost = $a[$i - 1] === $b[$j - 1] ? 0 : 1;
                $d[$i][$j] = min($d[$i - 1][$j] + 1, $d[$i][$j - 1] + 1, $d[$i - 1][$j - 1] + $cost);
                if ($i > 1 && $j > 1 && $a[$i - 1] === $b[$j - 2] && $a[$i - 2] === $b[$j - 1]) {
                    $d[$i][$j] = min($d[$i][$j], $d[$i - 2][$j - 2] + 1);
                }
            }
        }
        return $d[$la][$lb];
    }

    private static function tidy(string $msg): string
    {
        // token_get_all messages are already clean; trim any trailing location.
        $msg = preg_replace('/\s+in .*$/', '', $msg) ?? $msg;
        return trim($msg);
    }
}
