#!/usr/bin/env python3
"""import_check.py: heuristic scan of JS/TS files for dynamic import() and lazy-loading hazards.

Usage: python3 import_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"}
MSG = {
    "D1": ("error", "JSON import() without { with: { type: \"json\" } }"),
    "D2": ("warn", "floating import(): not awaited, returned or caught, so a failed load is an unhandled rejection"),
    "D11": ("info", "await import() outside try: confirm a caller handles the rejection"),
    "D3": ("warn", "cache-busting import() URL: every call makes a new module that is never freed"),
    "D4": ("info", "non-literal specifier: bundler may not split it; check build output"),
    "D5": ("warn", "lazy() created inside a function: a new component type on every render"),
    "D6": ("warn", "lazy() in a file with no Suspense: confirm a parent provides one"),
    "D7": ("error", "module exports a name 'then': import() will call it as a thenable"),
    "D8": ("info", "top-level await import(): blocks module evaluation, defeats lazy loading"),
    "D9": ("info", "relative import() without a file extension: Node ESM requires one"),
    "D10": ("warn", "same module imported statically and dynamically in this file: no split"),
    "P1": ("warn", "import() used but no preloadError or ChunkLoadError handler in scanned files"),
    "P2": ("warn", "lazy() used but no error boundary in scanned files"),
}


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


def try_ranges(code):
    ranges = []
    for i, l in enumerate(code):
        m = re.search(r"\btry\s*\{", l)
        if not m:
            continue
        depth, started = 0, False
        for j in range(i, min(len(code), i + 120)):
            seg = code[j][m.start():] if j == i else code[j]
            for ch in seg:
                depth += ch == "{"
                if ch == "{":
                    started = True
                depth -= ch == "}"
            if started and depth <= 0:
                ranges.append((i, j))
                break
    return ranges


def scan(path, project):
    out = []
    try:
        raw_lines = open(path, encoding="utf-8", errors="replace").read().splitlines()
    except OSError as e:
        print(f"{path}: cannot read ({e})", file=sys.stderr)
        sys.exit(2)
    code = [strip(l) for l in raw_lines]
    whole = "\n".join(code)
    raw_whole = "\n".join(raw_lines)
    if re.search(r"vite:preloadError|ChunkLoadError|dynamically imported module|preloadError", raw_whole):
        project["handler"] = True
    if re.search(r"ErrorBoundary|componentDidCatch|getDerivedStateFromError|errorElement|onErrorCaptured|errorComponent", whole):
        project["boundary"] = True
    ranges = try_ranges(code)
    static = set(re.findall(r"(?:^|\n)\s*import\s[^;\n]*?from\s*[\"']([^\"']+)[\"']", whole))
    ext = os.path.splitext(path)[1]
    depth = 0
    for i, l in enumerate(code):
        n = i + 1
        for m in re.finditer(r"(?<![\w$.])import\s*\(", l):
            project["imports"] += 1
            arg = " ".join(code[i:i + 6])[m.end():]
            d = 1
            for k, ch in enumerate(arg):
                d += (ch == "(") - (ch == ")")
                if d == 0:
                    arg = arg[:k]
                    break
            lit = re.match(r"\s*([\"'])([^\"']+)\1", arg)
            if lit:
                spec = lit.group(2)
                if spec.endswith(".json") and not re.search(r"\bwith\s*:|\bassert\s*:", arg[:200]):
                    out.append((n, "D1"))
                if spec in static:
                    out.append((n, "D10"))
                if spec.startswith(".") and ext in (".mjs", ".cjs") and not re.search(r"\.\w+$", spec):
                    out.append((n, "D9"))
            else:
                if "@vite-ignore" not in raw_lines[i] and "webpackIgnore" not in raw_lines[i]:
                    out.append((n, "D4"))
            if re.search(r"Date\.now\(\)|Math\.random\(\)|new Date\(", arg[:160]) or (lit and re.search(r"[?&]t=", lit.group(2))):
                out.append((n, "D3"))
            wrapped = re.search(r"\b(lazy|defineAsyncComponent|loadable|lazyRoute)\s*\(|^\s*(component|lazy|Component|element)\s*:", l) or (
                i > 0 and re.search(r"\b(lazy|defineAsyncComponent|loadable)\s*\(\s*$|^\s*(component|lazy)\s*:\s*$", code[i - 1]))
            in_try = any(a <= i <= b for a, b in ranges) and "await" in l
            caught = re.search(r"\.catch\s*\(|,\s*\(?\s*\w*\s*\)?\s*=>", " ".join(code[i:i + 8]).split("import", 1)[-1])
            is_await = re.search(r"\bawait\s+import\s*\(", l)
            passed_on = re.search(r"\breturn\s+import\s*\(|=>\s*import\s*\(", l)
            if wrapped or passed_on:
                pass
            elif is_await:
                if not in_try:
                    out.append((n, "D11"))
            elif not caught:
                out.append((n, "D2"))
            if depth == 0 and re.search(r"\bawait\s+import\s*\(", l):
                out.append((n, "D8"))
        if re.search(r"\bexport\s+(const|let|var|function|async\s+function|class)\s+then\b|\bexport\s*\{[^}]*\b(then|\w+\s+as\s+then)\b[^}]*\}", l):
            out.append((n, "D7"))
        if re.search(r"^\s+.*\b(const|let|var)\s+\w+\s*=\s*(React\.)?lazy\s*\(", l):
            out.append((n, "D5"))
        if re.search(r"(?<![\w.])(React\.)?lazy\s*\(", l) and not re.match(r"\s*import\b", l):
            project["lazy"] += 1
            if "Suspense" not in whole:
                out.append((n, "D6"))
        depth += l.count("{") - l.count("}")
        depth = max(depth, 0)
    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 = {"handler": False, "boundary": False, "imports": 0, "lazy": 0}
    errors = warns = count = 0
    for f in files(args):
        count += 1
        for n, rid in sorted(set(scan(f, project))):
            lvl, text = MSG[rid]
            errors += lvl == "error"
            warns += lvl == "warn"
            print(f"{f}:{n}: {rid} {lvl} {text}")
    if project["imports"] and not project["handler"]:
        warns += 1
        print(f"(project): P1 warn {MSG['P1'][1]}")
    if project["lazy"] and not project["boundary"]:
        warns += 1
        print(f"(project): P2 warn {MSG['P2'][1]}")
    print(f"scanned {count} files: {project['imports']} import() calls, {errors} errors, {warns} warnings")
    sys.exit(1 if errors or (strict and warns) else 0)


if __name__ == "__main__":
    main()
