#!/usr/bin/env python3
"""Scan an *implemented* screen for the localization keys the registry×code union misses.

The mapper's primary new-key recipe is a STATIC cross-reference (design-export
components × component-registry `localizationKeys` × the screen's wired call sites).
That recipe is structurally blind to three things, which this scanner recovers by
reading the screen's actual source tree:

  1. **Error / validation / alert strings**  -  copy that lives in validators, error
     mappers, alert/toast builders, or ViewModel error branches, not in the
     VC→VM→cell chain the static recipe walks.
  2. **Dynamic / interpolated keys**  -  keys built at runtime: `String(format:)`,
     interpolated/variable `.localized` receivers, `LocalizationStringKey(<expr>)`,
     Android `getString(<var>)`  -  none of which are string literals, so the literal
     greps never see them.
  3. **Keys wired in code but absent from the component registry** (and the inverse
     is the agent's job to confirm)  -  surfaced here as plain `static` call sites so
     the agent can diff them against the registry union.

Heuristic + read-only: it greps `.swift`/`.kt`/`.xml`/`.ts`/`.tsx`/`.js`/`.jsx`/`.vue`
under --screen-path and classifies each hit `static | error | dynamic` with
file:line provenance. It does
NOT resolve values or invent keys  -  it hands the agent a categorized worklist to
fold into the mapping (errors and dynamics especially), where each still gets the
normal new/legacy/CMS resolution.

Output (to --out or stdout): { screenPath, counts:{static,error,dynamic},
  keys:[ {key, category, platform, file, line, snippet} ] }.
"""
import argparse
import json
import os
import re
import sys

EXTS = (".swift", ".kt", ".kts", ".xml", ".ts", ".tsx", ".js", ".jsx", ".vue")
ERROR_CTX = re.compile(
    r"alert|error|validat|warning|toast|snackbar|failure|exception|errorMessage|showError",
    re.I)

# iOS
RE_LSK = re.compile(r"LocalizationStringKey\.([A-Za-z0-9_]+(?:\.[A-Za-z0-9_]+)+)")
RE_LSK_DYN = re.compile(r"LocalizationStringKey\s*\(")
RE_LITERAL_LOCALIZED = re.compile(r'"([^"\\]+)"\s*\.\s*localized')
RE_VAR_LOCALIZED = re.compile(r'(?<!")\b([A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z0-9_]+)*)\s*\.\s*localized')
RE_STRING_FORMAT = re.compile(r"String\s*\(\s*format\s*:")
# Android
RE_R_STRING = re.compile(r"R\.string\.([A-Za-z0-9_]+)")
RE_AT_STRING = re.compile(r"@string/([A-Za-z0-9_]+)")
RE_GETSTRING_DYN = re.compile(r"getString\s*\(\s*[A-Za-z_]")  # variable, not R.string.literal
# Web (react-i18next / next-intl `t('key')`; vue-i18n `$t('key')`); `(?<!\$)\bt\(`
# keeps a plain t(...) call from also matching inside a $t(...) call.
RE_WEB_T = re.compile(r"(?<!\$)\bt\(\s*['\"]([A-Za-z0-9_.\-]+)['\"]")
RE_WEB_VUE_T = re.compile(r"\$t\(\s*['\"]([A-Za-z0-9_.\-]+)['\"]")
RE_WEB_T_DYN = re.compile(r"(?<!\$)\bt\(\s*[`$A-Za-z_]")   # template literal or variable, not a literal
RE_WEB_VUE_T_DYN = re.compile(r"\$t\(\s*[`$A-Za-z_]")


def platform_of(path):
    if path.endswith((".kt", ".kts")):
        return "android"
    if path.endswith(".xml"):
        return "android"
    if path.endswith((".ts", ".tsx", ".js", ".jsx", ".vue")):
        return "web"
    return "ios"


def classify(line):
    return "error" if ERROR_CTX.search(line) else None


def scan_line(path, lineno, line, out, seen):
    plat = platform_of(path)
    err = classify(line)

    def add(key, category, dyn=False):
        cat = "dynamic" if dyn else (category or "static")
        # error context upgrades a static/dynamic hit's category to error only when
        # it isn't already dynamic (dynamic is the more actionable label)
        if err and cat == "static":
            cat = "error"
        sig = (key, cat, path, lineno)
        if sig in seen:
            return
        seen.add(sig)
        out.append({
            "key": key, "category": cat, "platform": plat,
            "file": path, "line": lineno, "snippet": line.strip()[:200],
        })

    if path.endswith(".xml"):
        for m in RE_AT_STRING.finditer(line):
            add(m.group(1), err)
        return

    if plat == "web":
        for m in RE_WEB_T.finditer(line):
            add(m.group(1), err)
        for m in RE_WEB_VUE_T.finditer(line):
            add(m.group(1), err)
        if RE_WEB_T_DYN.search(line):
            add("(dynamic: t(...))", err, dyn=True)
        if RE_WEB_VUE_T_DYN.search(line):
            add("(dynamic: $t(...))", err, dyn=True)
        return

    # iOS / Kotlin source
    for m in RE_LSK.finditer(line):
        add(m.group(1), err)
    for m in RE_LITERAL_LOCALIZED.finditer(line):
        add(m.group(1), err)
    for m in RE_R_STRING.finditer(line):
        add(m.group(1), err)

    # dynamic constructions  -  record the expression, not a resolvable key
    if RE_LSK_DYN.search(line):
        add("(dynamic: LocalizationStringKey(...))", err, dyn=True)
    if RE_STRING_FORMAT.search(line) and (".localized" in line or "LocalizationStringKey" in line):
        add("(dynamic: String(format:...))", err, dyn=True)
    if RE_GETSTRING_DYN.search(line) and "R.string." not in line:
        add("(dynamic: getString(var))", err, dyn=True)
    # variable .localized receiver (not a string literal, not the iOS key enum)
    for m in RE_VAR_LOCALIZED.finditer(line):
        recv = m.group(1)
        if recv.startswith("LocalizationStringKey"):
            continue
        add(f"(dynamic: {recv}.localized)", err, dyn=True)


def main():
    ap = argparse.ArgumentParser(description=__doc__,
                                 formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument("--screen-path", required=True,
                    help="dir (or file) of the implemented screen to scan")
    ap.add_argument("--out", help="write JSON here (default: stdout)")
    ap.add_argument("--category", choices=["all", "error", "dynamic", "static"], default="all",
                    help="filter the emitted keys (default all)")
    args = ap.parse_args()

    root = args.screen_path
    if not os.path.exists(root):
        ap.error(f"--screen-path not found: {root}")

    files = []
    if os.path.isfile(root):
        files = [root]
    else:
        for dirpath, _dirs, names in os.walk(root):
            if "/.git" in dirpath or "/Generated/" in dirpath:
                continue
            for n in names:
                if n.endswith(EXTS):
                    files.append(os.path.join(dirpath, n))

    out, seen = [], set()
    for path in sorted(files):
        try:
            with open(path, encoding="utf-8", errors="replace") as fh:
                for i, line in enumerate(fh, 1):
                    scan_line(path, i, line, out, seen)
        except OSError:
            continue

    if args.category != "all":
        out = [k for k in out if k["category"] == args.category]
    out.sort(key=lambda k: (k["category"], k["file"], k["line"]))

    counts = {c: sum(1 for k in out if k["category"] == c) for c in ("static", "error", "dynamic")}
    result = {"screenPath": root, "filesScanned": len(files), "counts": counts, "keys": out}

    text = json.dumps(result, ensure_ascii=False, indent=2)
    if args.out:
        with open(args.out, "w", encoding="utf-8") as fh:
            fh.write(text + "\n")
    else:
        sys.stdout.write(text + "\n")
    sys.stderr.write(f"# scanned {len(files)} file(s): "
                     f"{counts['static']} static · {counts['error']} error · "
                     f"{counts['dynamic']} dynamic\n")


if __name__ == "__main__":
    main()
