#!/usr/bin/env python3
"""errors_check.py: heuristic scan of JS/TS files for error-handling hazards.

Usage: python3 errors_check.py [--strict] FILE_OR_DIR [...]
Reads files, writes nothing, makes no network calls. Standard library only.
Exit 0 = no errors (and no warnings with --strict). Exit 1 = findings. Exit 2 = bad input.
It reads text, not a syntax tree: a hit inside a string or comment is a false positive.
Output lines: path:line: ID LEVEL message. It never prints source text.
"""
import os
import re
import sys

EXT = (".js", ".jsx", ".mjs", ".cjs", ".ts", ".tsx", ".mts", ".cts", ".vue", ".svelte")
SKIP = {"node_modules", ".git", "dist", "build", "coverage", ".next", ".nuxt"}
FIX = {
    "E1": "empty catch: handle, rethrow or report",
    "E2": "catch only logs: the caller never learns it failed",
    "E3": "rethrow without { cause }: original stack lost",
    "E4": "throws a non-Error: no stack, no instanceof",
    "E5": "fetch without a response.ok or status check: HTTP 4xx/5xx resolves",
    "E6": "uncaughtException handler does not exit: process state is undefined",
    "E7": "unhandledRejection handler neither exits nor reports",
    "E8": "JSON.stringify on an Error gives {}: pick message, stack, cause",
    "E9": "caught error never used",
    "E10": "catch only rethrows the same error: remove it",
    "E11": "preventDefault in unhandledrejection hides the console error: confirm it is reported",
    "P1": "no global error or unhandledrejection handler anywhere",
    "P2": "React code but no error boundary anywhere",
}
LEVEL = {"E1": "error", "E4": "error", "E6": "error", "E10": "warn", "P1": "info", "P2": "info"}


def strip(line):
    line = re.sub(r"//.*$", "", line)
    return re.sub(r"/\*.*?\*/", "", line)


def block_after(code, i, col):
    """Return (text, end_line) of the {...} block whose '{' is first found at or after code[i][col:]."""
    depth = 0
    started = False
    buf = []
    for j in range(i, min(len(code), i + 80)):
        seg = code[j][col:] if j == i else code[j]
        for ch in seg:
            if ch == "{":
                depth += 1
                started = True
            if started:
                buf.append(ch)
            if ch == "}":
                depth -= 1
                if started and depth == 0:
                    return "".join(buf), j
        buf.append("\n")
    return "".join(buf), min(len(code) - 1, i + 80)


def scan(path, project):
    out = []
    try:
        raw = open(path, encoding="utf-8", errors="replace").read()
    except OSError as e:
        print(f"{path}: cannot read ({e})", file=sys.stderr)
        sys.exit(2)
    code = [strip(l) for l in raw.splitlines()]
    whole = "\n".join(code)
    if re.search(r"addEventListener\(\s*[\"'](error|unhandledrejection)[\"']|onerror\s*=|onunhandledrejection\s*=|process\.on\(\s*[\"'](uncaughtException|unhandledRejection)", whole):
        project["handlers"] = True
    if re.search(r"componentDidCatch|getDerivedStateFromError|ErrorBoundary|errorElement|onCaughtError", whole):
        project["boundary"] = True
    if re.search(r"from\s+[\"']react[\"']|require\(\s*[\"']react[\"']\)", whole):
        project["react"] = True
    has_ok = bool(re.search(r"\.ok\b|\.status\b|response\.statusText|raiseForStatus|ky\(|axios", whole))
    for i, l in enumerate(code):
        n = i + 1
        m = re.search(r"\bcatch\s*(\(\s*([A-Za-z_$][\w$]*)[^)]*\))?\s*\{", l)
        if m:
            body, end = block_after(code, i, m.start())
            inner = body.strip()[1:-1].strip()
            var = m.group(2)
            if not inner:
                out.append((n, "E1"))
            else:
                stmts = [s for s in re.split(r";|\n", inner) if s.strip()]
                if all(re.match(r"\s*console\.(log|warn|error|info|debug)\(", s) for s in stmts):
                    out.append((n, "E2"))
                if var and not re.search(r"\b" + re.escape(var) + r"\b", inner):
                    out.append((n, "E9"))
                if var and re.fullmatch(r"throw\s+" + re.escape(var), inner.rstrip(";").strip()):
                    out.append((n, "E10"))
                for k, bl in enumerate(body.split("\n")):
                    tm = re.search(r"\bthrow\s+new\s+[A-Za-z_$][\w$.]*\s*\(", bl)
                    if tm:
                        if "cause" not in body[body.find(bl):body.find(bl) + 400].split(";")[0]:
                            out.append((i + k + 1, "E3"))
        if re.search(r"\.catch\(\s*(\(\s*[A-Za-z_$]*\s*\)|[A-Za-z_$]+|function\s*\([^)]*\))\s*(=>)?\s*\{\s*\}\s*\)", l):
            out.append((n, "E1"))
        if re.search(r"\.catch\(\s*(\(\s*\)\s*=>\s*(null|undefined|\{\})|_?\s*=>\s*(null|undefined))\s*\)", l):
            out.append((n, "E1"))
        if re.search(r"\bthrow\s+([\"'`]|\{|\d)", l):
            out.append((n, "E4"))
        fm = re.search(r"(?<![\w.$])fetch\s*\(", l)
        if fm and not has_ok:
            out.append((n, "E5"))
        pm = re.search(r"process\.on\(\s*[\"'](uncaughtException|unhandledRejection)[\"']", l)
        if pm:
            body, _ = block_after(code, i, pm.end())
            exits = re.search(r"process\.exit|exitCode|\bthrow\b|server\.close|shutdown", body)
            if pm.group(1) == "uncaughtException" and not exits:
                out.append((n, "E6"))
            if pm.group(1) == "unhandledRejection" and not exits and not re.search(r"report|captureException|logger|log\.", body):
                out.append((n, "E7"))
        if re.search(r"JSON\.stringify\(\s*(err|error|e|ex|exception|reason)\s*[,)]", l):
            out.append((n, "E8"))
        um = re.search(r"unhandledrejection[\"']\s*,", l) or re.search(r"onunhandledrejection\s*=", l)
        if um:
            body, _ = block_after(code, i, um.end())
            if "preventDefault" in body:
                out.append((n, "E11"))
    return out


def files(args):
    for a in args:
        if os.path.isfile(a):
            yield a
        elif os.path.isdir(a):
            for root, dirs, names in os.walk(a):
                dirs[:] = sorted(d for d in dirs if d not in SKIP)
                for nme in sorted(names):
                    if nme.endswith(EXT):
                        yield os.path.join(root, nme)
        else:
            print(f"{a}: not found", file=sys.stderr)
            sys.exit(2)


def main():
    args = [a for a in sys.argv[1:] if a != "--strict"]
    strict = "--strict" in sys.argv[1:]
    if not args:
        print(__doc__.split("\n")[2], file=sys.stderr)
        sys.exit(2)
    project = {"handlers": False, "boundary": False, "react": False}
    errors = warns = count = 0
    for f in files(args):
        count += 1
        for n, rid in sorted(set(scan(f, project))):
            lvl = LEVEL.get(rid, "warn")
            errors += lvl == "error"
            warns += lvl == "warn"
            print(f"{f}:{n}: {rid} {lvl} {FIX[rid]}")
    if count and not project["handlers"]:
        print(f"(project): P1 info {FIX['P1']}")
    if project["react"] and not project["boundary"]:
        print(f"(project): P2 info {FIX['P2']}")
    print(f"scanned {count} files: {errors} errors, {warns} warnings")
    sys.exit(1 if errors or (strict and warns) else 0)


if __name__ == "__main__":
    main()
