#!/usr/bin/env python3
"""Fidelity check  -  cross-verify a mapping against the REAL sources before publishing.

The map can drift from reality (stale keys parsed from an old doc, hallucinated keys,
a wired key that never made it into a row, an empty value). This script reconciles the
mapping JSON against the live repos and reports what doesn't hold up  -  so the page you
publish reflects the code, not a guess. Read-only.

Checks (each reported, never auto-fixed):
  1. newKey ∈ screen code  -  every row's `newKey` should appear as a wired
     `LocalizationStringKey.<...>` call site (or `"Key".localized`, or a web
     `t('Key')` / `$t('Key')` i18n call) in --screen-path.
     Component-owned keys (`(owned by ...)` / `(DS bileşen...)`) are exempt (they live in the
     component, not the screen file) and listed separately.
  2. code keys ∉ map  -  `LocalizationStringKey.<...>` wired in the screen but with no
     row → missing coverage (add a row or confirm it's out of scope).
  3. value gaps  -  `new.tr` empty (verdict≠new), or legacy entirely empty where
     the verdict claims reuse/review.
  4. untranslated  -  a row whose `new.tr` == `new.en` (flag, as the renderer does).
  5. resources (optional)  -  with --resources-root, the new key's Suggested/<Key>.json exists.

Exit code 2 when any hard fidelity issue is found (unverified non-component key, code key
missing from the map, or a reuse/review row with no legacy value), so it can gate a run.
"""
import argparse
import json
import os
import re
import sys

EXTS = (".swift", ".kt", ".kts", ".xml", ".ts", ".tsx", ".js", ".jsx", ".vue")
RE_LSK = re.compile(r"LocalizationStringKey\.([A-Za-z0-9_]+(?:\.[A-Za-z0-9_]+)+)")
RE_LITERAL = re.compile(r'"([A-Za-z0-9_.]+)"\s*\.\s*localized')
# Web (react-i18next / next-intl `t('key')`; vue-i18n `$t('key')`)
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_LSK is greedy across dots, so `...StringKey.Ns.leaf.localized` captures the accessor
# too. Strip it, or no call site ever matches its mapping row and every wired key is
# reported unmapped.
RE_ACCESSOR = re.compile(r"\.localized\w*$")
MARKER = re.compile(r"\(.*(owned by|bileşen|component).*\)", re.I)


def clean_key(k):
    """Strip a trailing '(owned by ...)' / '(DS bileşene ait)' marker → bare dotted key."""
    return re.sub(r"\s*\(.*\)\s*$", "", (k or "")).strip()


def is_component(k):
    return bool(MARKER.search(k or ""))


def norm_key(k):
    return clean_key(k).lower()


def scan_code(screen_path):
    """Return (set of wired dotted keys lowercased, full lowercased corpus text)."""
    keys, corpus = set(), []
    files = []
    if os.path.isfile(screen_path):
        files = [screen_path]
    else:
        for dp, _d, names in os.walk(screen_path):
            if "/.git" in dp:
                continue
            files += [os.path.join(dp, n) for n in names if n.endswith(EXTS)]
    for f in files:
        try:
            txt = open(f, encoding="utf-8", errors="replace").read()
        except OSError:
            continue
        corpus.append(txt.lower())
        for m in RE_LSK.finditer(txt):
            keys.add(RE_ACCESSOR.sub("", m.group(1)).lower())
        for m in RE_LITERAL.finditer(txt):
            keys.add(m.group(1).lower())
        for m in RE_WEB_T.finditer(txt):
            keys.add(m.group(1).lower())
        for m in RE_WEB_VUE_T.finditer(txt):
            keys.add(m.group(1).lower())
    return keys, "\n".join(corpus), len(files)


def leaf_in_corpus(key, corpus):
    """Weak check: the key's last segment appears as a word in the code corpus."""
    leaf = clean_key(key).split(".")[-1]
    return bool(leaf) and re.search(r"\b" + re.escape(leaf.lower()) + r"\b", corpus) is not None


def main():
    ap = argparse.ArgumentParser(description=__doc__,
                                 formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument("--mapping", required=True)
    ap.add_argument("--screen-path", required=True, help="implemented screen dir/file")
    ap.add_argument("--resources-root", help="resources / snapshot root (checks Suggested/<Key>.json)")
    ap.add_argument("--out", help="write the full report JSON here (default: stdout summary only)")
    args = ap.parse_args()

    m = json.load(open(args.mapping, encoding="utf-8"))
    rows = m.get("rows", [])
    code_keys, corpus, nfiles = scan_code(args.screen_path)

    verified, weak, unverified, component = [], [], [], []
    value_gaps, untranslated = [], []
    map_keys = set()

    for r in rows:
        k = r.get("newKey", "")
        ck = clean_key(k)
        map_keys.add(norm_key(k))
        # 1. newKey in code
        if is_component(k) or k.startswith("(dynamic") or "dinamik" in (r.get("element", "").lower()):
            component.append(k)
        elif norm_key(k) in code_keys:
            verified.append(k)
        elif leaf_in_corpus(k, corpus):
            weak.append(k)
        else:
            unverified.append({"key": k, "element": r.get("element")})
        # 3. value gaps
        nw, lg, vd = r.get("new") or {}, r.get("legacy") or {}, r.get("verdict")
        if vd != "new" and not (nw.get("tr") or "").strip() and not k.startswith("(dynamic"):
            value_gaps.append({"key": k, "issue": "new.tr empty"})
        if vd in ("reuse", "review") and not any((lg.get(l) or "").strip() for l in ("en", "tr")):
            value_gaps.append({"key": k, "issue": f"verdict={vd} but no legacy value"})
        # 4. untranslated
        if (nw.get("tr") or "").strip() and nw.get("tr") == nw.get("en"):
            untranslated.append(k)
        # 5. resources
        if args.resources_root and not is_component(k) and not k.startswith("(dynamic"):
            seg = ".".join(s[:1].upper() + s[1:] if s else s for s in ck.split("."))
            p1 = os.path.join(args.resources_root, "Localization", "Suggested", seg + ".json")
            p2 = os.path.join(args.resources_root, "Resources", "Localization",
                              "LocalizationStrings", "Sources", "Suggested", seg + ".json")
            if not (os.path.isfile(p1) or os.path.isfile(p2)):
                value_gaps.append({"key": k, "issue": "no Suggested/<Key>.json"})

    # 2. code keys not in map
    code_not_mapped = sorted(k for k in code_keys if k not in map_keys)

    report = {
        "screen": m.get("screen"), "rows": len(rows), "filesScanned": nfiles,
        "newKeyInCode": {"verified": len(verified), "weak": weak,
                         "unverified": unverified, "component_or_dynamic": len(component)},
        "codeKeysNotInMap": code_not_mapped,
        "valueGaps": value_gaps, "untranslated": untranslated,
    }
    hard = len(unverified) + len(code_not_mapped) + sum(1 for g in value_gaps if "no legacy" in g["issue"])

    if args.out:
        with open(args.out, "w", encoding="utf-8") as fh:
            json.dump(report, fh, ensure_ascii=False, indent=2)
            fh.write("\n")
    # summary to stderr (always)
    sys.stderr.write(
        f"# fidelity: {len(rows)} rows · {nfiles} files\n"
        f"#   newKey in code: {len(verified)} verified · {len(weak)} weak(leaf) · "
        f"{len(unverified)} UNVERIFIED · {len(component)} component/dynamic (exempt)\n"
        f"#   code keys not in map: {len(code_not_mapped)}\n"
        f"#   value gaps: {len(value_gaps)} · untranslated(tr==en): {len(untranslated)}\n")
    if unverified:
        sys.stderr.write("#   UNVERIFIED keys: " + ", ".join(u["key"] for u in unverified[:12]) + "\n")
    if code_not_mapped:
        sys.stderr.write("#   wired-but-unmapped: " + ", ".join(code_not_mapped[:12]) + "\n")
    if not args.out:
        json.dump(report, sys.stdout, ensure_ascii=False, indent=2)
        sys.stdout.write("\n")
    sys.exit(2 if hard else 0)


if __name__ == "__main__":
    main()
