#!/usr/bin/env python3
"""Read-only scanner for main-thread yielding mistakes in JS, TS, JSX, TSX, Vue and Svelte.

Usage: python3 long_task_check.py PATH [PATH ...] [--strict]
Prints one summary line, then one line per finding: file:line level code message.
Exit 1 if any error, or any warning with --strict. Standard library only.
It reads text. It cannot time anything: confirm every finding with the probe in
references/appendix.md before claiming a long task.
"""
import os, re, sys

EXT = (".js", ".jsx", ".mjs", ".cjs", ".ts", ".tsx", ".vue", ".svelte")
SKIP = {"node_modules", "dist", "build", ".git", ".next", "coverage"}
LOOP = re.compile(r"^\s*(for\s*\(|for\s+await|while\s*\()")
YIELD = re.compile(r"scheduler\??\.yield\??\.?\(|\byieldToMain\s*\(|\byieldNow\s*\(")
GUARD = re.compile(r"performance\.now|deadline|% *\d+ *===? *0|\bbatch|\bchunk|elapsed|isInputPending")
HANDLER = re.compile(
    r"addEventListener\(\s*['\"](click|input|keydown|keyup|change|pointerup|pointerdown|submit|touchend)['\"]"
    r"|\bon(Click|Input|KeyDown|KeyUp|Change|PointerUp|Submit)\s*=\s*\{?")
HEAVY = re.compile(r"JSON\.(parse|stringify)\(|\.sort\(|structuredClone\(|\.flatMap\(|\.reduce\(.*\.\.\.")


def files(paths):
    for p in paths:
        if os.path.isfile(p):
            yield p
            continue
        for root, dirs, names in os.walk(p):
            dirs[:] = [d for d in dirs if d not in SKIP]
            for n in names:
                if n.endswith(EXT) and not n.endswith((".min.js", ".d.ts")):
                    yield os.path.join(root, n)


def block_after(lines, i, cap=40):
    """Text from line i (1-based) to the brace that closes the first '{' on it or after it."""
    depth, started, buf = 0, False, []
    for ln in lines[i - 1:i - 1 + cap]:
        buf.append(ln)
        depth += ln.count("{") - ln.count("}")
        started = started or "{" in ln
        if started and depth <= 0:
            break
    return "\n".join(buf)


def scan(path):
    out = []
    try:
        text = open(path, encoding="utf-8", errors="replace").read()
    except OSError as e:
        return [(path, 0, "error", "unreadable", str(e))]
    lines = text.splitlines()
    uses_yield = bool(re.search(r"scheduler\??\.yield", text))
    detects = bool(re.search(r"scheduler\?\.yield|['\"]yield['\"]\s+in\s+(globalThis\.)?scheduler|typeof\s+scheduler|globalThis\.scheduler\?\.", text))
    first_yield = 0
    for i, ln in enumerate(lines, 1):
        code = ln.split("//")[0]
        if re.search(r"scheduler\.yield\(\)", code) and not re.search(r"\b(await|return)\b[^;]*scheduler\.yield|\.then\(|=>|\?\s*scheduler\.yield", code):
            out.append((path, i, "warn", "yield-no-await", "scheduler.yield() not awaited: it only schedules, nothing pauses"))
        if "isInputPending" in code:
            out.append((path, i, "warn", "input-pending", "isInputPending is not recommended: it can return false after a user input"))
        if re.search(r"\.forEach\(", code):
            block = "\n".join(lines[i - 1:i + 7])
            if re.search(r"await\s+[\w.]*(yield|scheduler)", block) or re.search(r"scheduler\??\.yield", block):
                out.append((path, i, "error", "yield-in-foreach", "forEach does not wait for async callbacks: the yield never pauses the loop"))
        m = re.search(r"setTimeout\(\s*(\w+)\s*(,\s*0\s*)?\)", code)
        if m:
            name = m.group(1)
            window = "\n".join(lines[max(0, i - 12):i])
            if re.search(r"(function\s+%s\b|const\s+%s\s*=)" % (name, name), window):
                out.append((path, i, "info", "timeout-recursion", "setTimeout chunking: 5 ms minimum after 5 nested calls and queued behind other tasks; prefer scheduler.yield with a fallback"))
        if re.search(r"requestIdleCallback\(", code) and not re.search(r"timeout\s*:", "\n".join(lines[i - 1:i + 3])):
            out.append((path, i, "info", "idle-no-timeout", "requestIdleCallback with no timeout may never run on a busy page; not for user-triggered work"))
        if re.search(r"innerHTML\s*\+=", code):
            out.append((path, i, "warn", "innerhtml-append", "innerHTML += re-parses the whole subtree each time; inside a loop it is a long-task candidate"))
        if LOOP.match(ln) and (YIELD.search("\n".join(lines[i:i + 14]))):
            body = "\n".join(lines[i - 1:i + 14])
            if not GUARD.search(body):
                out.append((path, i, "info", "yield-every-iteration", "loop yields on every pass with no batch or time check: overhead; yield about every 50 ms"))
        if HANDLER.search(code):
            body = block_after(lines, i)
            nested = re.search(r"for\s*\([^)]*\)\s*\{[^}]*for\s*\(", body.replace("\n", " "))
            if (HEAVY.search(body) or nested) and not YIELD.search(body):
                out.append((path, i, "info", "heavy-in-handler", "event handler runs sort/JSON/nested loop work with no yield: measure it as a long-task candidate"))
        if uses_yield and not first_yield and re.search(r"scheduler\.yield", code):
            first_yield = i
    if uses_yield and not detects:
        out.append((path, first_yield, "warn", "yield-no-fallback", "scheduler.yield used with no feature check: Safari lacks it (verify current support)"))
    return out


def main(argv):
    strict = "--strict" in argv
    paths = [a for a in argv if not a.startswith("--")]
    if not paths:
        print(__doc__.strip().splitlines()[2]); return 2
    res, n = [], 0
    for f in files(paths):
        n += 1
        res.extend(scan(f))
    res.sort(key=lambda r: (r[0], r[1]))
    e = sum(r[2] == "error" for r in res); w = sum(r[2] == "warn" for r in res); i = sum(r[2] == "info" for r in res)
    print(f"{e} errors, {w} warnings, {i} info, {n} files")
    for f, ln, lvl, code, msg in res:
        print(f"{f}:{ln} {lvl:<5} {code:<22} {msg}")
    return 1 if e or (strict and w) else 0


if __name__ == "__main__":
    sys.exit(main(sys.argv[1:]))
