#!/usr/bin/env python3
"""Fetch the content team's Final UX Writing annotations off the screen's Figma nodes.

The CMS / content team writes the **final bilingual copy** as Figma Dev Mode
annotations (category "Final UX Writing") directly on the text nodes, in the form
`TR: <türkçe>\nEN: <english>`. The design text itself is usually a *placeholder*
("Giriniz", "Lorem ipsum", "Ahmet Abdullah"), so the annotation  -  not the design
text  -  is the content team's *actual* value. This script harvests them so the map
can show an `İçerik (CMS)` column beside the new (resources) and legacy values.

Three sources, in priority order (Figma changes constantly → live is primary):
  1. REST  -  `GET https://api.figma.com/v1/files/<key>/nodes?ids=...`, header
            `X-Figma-Token` (from --token, env FIGMA_ACCESS_TOKEN/FIGMA_TOKEN, or
            the macOS keychain item `FIGMA_ACCESS_TOKEN`). Default.
  2. MCP  -  `--from-mcp <raw.json>`: an already-captured Dev Mode dump (the host's
            Figma plugin/`use_figma` output). No network, no rate limit  -  use it
            for big runs or when REST is 429-throttled.
  3. local  -  `--local <design-export-screen-dir>`: best-effort read of the in-repo
            snapshot's `tree.json` (only if it carries `annotations`). The safety
            net when neither API nor MCP is reachable; usually yields nothing, in
            which case the CMS column is simply left blank (blank beats a guess).

Why a script: parsing `TR:/EN:` labels out of a node tree across many nodes is
pure IO + string work  -  slow and error-prone by hand. Read-only; touches no repo.

Emits the `_annotations.json` contract (to --out or stdout):
  { generatedAt, source, files[], auditedNodes[], count, withTr, withEn,
    annotations: [ {nodeId, name, type, designText, raw, tr, en, mode} ] }

The label→{tr,en} parser (`split_lang`) is a faithful port of the web skill's
`lib/split-lang.mjs`, so iOS/Android and web stay byte-compatible on the rules.
"""
import argparse
import datetime
import json
import os
import re
import ssl
import subprocess
import sys
import urllib.error
import urllib.parse
import urllib.request

FIGMA_API = "https://api.figma.com/v1"
CHUNK = 4  # Figma /nodes batches; mirrors the web extractor

# --- label → {tr, en, mode}  -  verbatim port of lib/split-lang.mjs ----------
# Annotation authors separate label from value with a colon, a hyphen, or the
# en-dash their editor auto-inserts. Built from the codepoint so this file
# carries no literal dash variant of its own.
_DASHES = ':\\-' + chr(0x2013)
RE_TR = re.compile(rf"^(?:tr|tr[kc]e|t[üu]rk[çc]e)\s*[{_DASHES}]\s*(.+)$", re.I)
RE_EN = re.compile(rf"^(?:en|eng|ing|[iİ]ngilizce)\s*[{_DASHES}]\s*(.+)$", re.I)


def split_lang(raw):
    """Parse a Dev Mode annotation label into {tr, en, mode}.

    Prefixed `TR:`/`EN:` lines win over position; otherwise line1=TR, line2=EN.
    """
    if not raw:
        return {"tr": None, "en": None, "mode": "empty"}
    lines = [ln.strip() for ln in re.split(r"\r?\n", raw)]
    lines = [ln for ln in lines if ln]
    tr = en = None
    bare = []
    for ln in lines:
        m = RE_TR.match(ln)
        if m and tr is None:
            tr = m.group(1).strip()
            continue
        m = RE_EN.match(ln)
        if m and en is None:
            en = m.group(1).strip()
            continue
        bare.append(ln)
    if tr is not None or en is not None:
        bi = 0
        if tr is None and bi < len(bare):
            tr = bare[bi]
            bi += 1
        if en is None and bi < len(bare):
            en = bare[bi]
            bi += 1
        return {"tr": tr, "en": en, "mode": "prefixed"}
    if len(bare) >= 2:
        return {"tr": bare[0], "en": bare[1], "mode": "two-line"}
    if len(bare) == 1:
        return {"tr": bare[0], "en": None, "mode": "single"}
    return {"tr": None, "en": None, "mode": "empty"}


def canon_id(s):
    """`1234-56` (URL form) → `1234:56` (node-id form); trims."""
    return re.sub(r"^(\d+)-(\d+)$", r"\1:\2", (s or "").strip())


# --- annotation parsing ----------------------------------------------------
def labels_of(node):
    """The annotation label strings on a node (each `label` ?? `labelMarkdown`)."""
    anns = node.get("annotations")
    if not isinstance(anns, list):
        return []
    out = []
    for a in anns:
        if not isinstance(a, dict):
            continue
        lbl = a.get("label") or a.get("labelMarkdown") or ""
        if lbl:
            out.append(lbl)
    return out


def design_text(node):
    if node.get("type") == "TEXT" and isinstance(node.get("characters"), str):
        return node["characters"]
    return node.get("name")


def parse_item(node_id, name, ntype, dtext, labels):
    """Run split_lang over each label; keep the first that yields tr or en."""
    best = {"tr": None, "en": None, "mode": "empty"}
    raw = ""
    for lbl in (labels or []):
        parsed = split_lang(lbl)
        if parsed["tr"] is not None or parsed["en"] is not None:
            best = parsed
            raw = lbl
            break
        if not raw:
            raw = lbl
    return {
        "nodeId": node_id,
        "name": name,
        "type": ntype,
        "designText": dtext,
        "raw": raw,
        "tr": best["tr"],
        "en": best["en"],
        "mode": best["mode"],
    }


def walk_collect(node, out):
    """Recurse a Figma node tree, collecting every node that carries annotations."""
    labels = labels_of(node)
    if labels:
        out.append(parse_item(node.get("id"), node.get("name"),
                              node.get("type"), design_text(node), labels))
    for child in node.get("children", []) or []:
        walk_collect(child, out)


# --- sources ---------------------------------------------------------------
def figma_token(args):
    if args.token:
        return args.token
    for env in ("FIGMA_ACCESS_TOKEN", "FIGMA_TOKEN"):
        v = os.environ.get(env)
        if v:
            return v.strip()
    try:
        r = subprocess.run(
            ["security", "find-generic-password", "-s", "FIGMA_ACCESS_TOKEN", "-w"],
            capture_output=True, text=True)
        if r.returncode == 0 and r.stdout.strip():
            return r.stdout.strip()
    except Exception:
        pass
    return None


def http_get_json(url, headers, ca_file):
    ctx = ssl.create_default_context(cafile=ca_file) if ca_file else ssl.create_default_context()
    req = urllib.request.Request(url, headers=headers)
    try:
        with urllib.request.urlopen(req, context=ctx, timeout=60) as r:
            return json.load(r)
    except urllib.error.HTTPError as e:
        if e.code == 429:
            sys.exit("Figma 429 (rate-limited)  -  re-run with the MCP dump instead: "
                     "--from-mcp <raw.json>.")
        body = e.read()[:200]
        sys.exit(f"Figma HTTP {e.code} for {url}: {body!r}")
    except urllib.error.URLError as e:
        msg = str(getattr(e, "reason", e))
        if re.search(r"CERTIFICATE|ISSUER|SELF.SIGNED|UNABLE_TO|CERT", msg, re.I):
            sys.exit(
                "TLS verification failed. Behind a corporate proxy, export the system "
                "roots and pass --ca-file:\n"
                "  security find-certificate -a -p /System/Library/Keychains/"
                "SystemRootCertificates.keychain > /tmp/ca.pem\n"
                "  security find-certificate -a -p /Library/Keychains/System.keychain "
                ">> /tmp/ca.pem\n"
                "  ... --ca-file /tmp/ca.pem")
        sys.exit(f"Figma request failed: {msg}")


def from_rest(file_key, node_ids, token, ca_file):
    if not token:
        sys.exit("No Figma token  -  pass --token, set FIGMA_ACCESS_TOKEN, or add the "
                 "keychain item FIGMA_ACCESS_TOKEN (or use --from-mcp / --local).")
    headers = {"X-Figma-Token": token}
    collected = []
    ids = [canon_id(n) for n in node_ids]
    for i in range(0, len(ids), CHUNK):
        batch = ids[i:i + CHUNK]
        q = ",".join(urllib.parse.quote(n, safe="") for n in batch)
        data = http_get_json(f"{FIGMA_API}/files/{file_key}/nodes?ids={q}", headers, ca_file)
        for nid in batch:
            entry = (data.get("nodes") or {}).get(nid)
            doc = entry.get("document") if isinstance(entry, dict) else None
            if doc:
                walk_collect(doc, collected)
    return collected, "rest"


def from_mcp(path):
    with open(path, encoding="utf-8") as fh:
        dump = json.load(fh)
    items = dump if isinstance(dump, list) else (dump.get("annotations") or dump.get("items") or [])
    collected = []
    files = []
    for it in items:
        fk = it.get("fileKey")
        if fk and fk not in files:
            files.append(fk)
        labels = it.get("labels")
        if labels is None and it.get("raw") is not None:
            labels = [it["raw"]]
        collected.append(parse_item(it.get("nodeId"), it.get("name"), it.get("type"),
                                    it.get("designText"), labels or []))
    return collected, "mcp", files


def from_local(screen_dir):
    """Best-effort: read annotations from a design-export tree.json if it has any."""
    tree = os.path.join(screen_dir, "tree.json")
    if not os.path.isfile(tree):
        return [], "local"
    with open(tree, encoding="utf-8") as fh:
        data = json.load(fh)
    roots = data if isinstance(data, list) else [data]
    collected = []
    for root in roots:
        if isinstance(root, dict):
            walk_collect(root, collected)
    return collected, "local"


def main():
    ap = argparse.ArgumentParser(description=__doc__,
                                 formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument("--file", help="Figma fileKey (REST)")
    ap.add_argument("--nodes", help="comma-separated node-ids (REST), e.g. '10065:56313,10065:56329'")
    ap.add_argument("--mapping", help="a mapping JSON to read figmaFileKey + figmaNodes from")
    ap.add_argument("--from-mcp", dest="from_mcp", help="captured Dev Mode dump (MCP path)")
    ap.add_argument("--local", help="design-export screen dir (fallback; reads tree.json)")
    ap.add_argument("--source", choices=["auto", "rest", "mcp", "local"], default="auto",
                    help="force a source; 'auto' tries REST then --local (default)")
    ap.add_argument("--token", help="Figma token (else env / keychain)")
    ap.add_argument("--ca-file", help="CA bundle for TLS behind a corporate proxy")
    ap.add_argument("--out", help="write _annotations.json here (default: stdout)")
    args = ap.parse_args()

    file_key = args.file
    node_ids = [n.strip() for n in (args.nodes or "").split(",") if n.strip()]
    if args.mapping:
        with open(args.mapping, encoding="utf-8") as fh:
            m = json.load(fh)
        file_key = file_key or m.get("figmaFileKey")
        if not node_ids:
            node_ids = [str(n) for n in (m.get("figmaNodes") or [])]

    files = [file_key] if file_key else []
    source = args.source

    if args.from_mcp or source == "mcp":
        if not args.from_mcp:
            ap.error("--source mcp needs --from-mcp <raw.json>")
        collected, source, mcp_files = from_mcp(args.from_mcp)
        files = mcp_files or files
    elif source == "local":
        if not args.local:
            ap.error("--source local needs --local <dir>")
        collected, source = from_local(args.local)
    elif source == "rest":
        if not (file_key and node_ids):
            ap.error("REST needs --file and --nodes (or --mapping)")
        collected, source = from_rest(file_key, node_ids, figma_token(args), args.ca_file)
    else:  # auto: REST first, fall back to --local
        if file_key and node_ids and figma_token(args):
            try:
                collected, source = from_rest(file_key, node_ids, figma_token(args), args.ca_file)
            except SystemExit:
                if not args.local:
                    raise
                sys.stderr.write("# REST failed  -  falling back to --local\n")
                collected, source = from_local(args.local)
        elif args.local:
            collected, source = from_local(args.local)
        else:
            ap.error("nothing to do  -  supply --file/--nodes (or --mapping) for REST, "
                     "--from-mcp for MCP, or --local for the snapshot fallback")

    # de-dup by nodeId, keep first (REST returns each requested node once anyway)
    seen = set()
    deduped = []
    for a in collected:
        nid = a.get("nodeId")
        if nid in seen:
            continue
        seen.add(nid)
        deduped.append(a)

    out = {
        "generatedAt": datetime.datetime.now(datetime.timezone.utc).isoformat(),
        "source": source,
        "files": files,
        "auditedNodes": [canon_id(n) for n in node_ids],
        "count": len(deduped),
        "withTr": sum(1 for a in deduped if a.get("tr") is not None),
        "withEn": sum(1 for a in deduped if a.get("en") is not None),
        "annotations": deduped,
    }

    text = json.dumps(out, ensure_ascii=False, indent=2)
    if args.out:
        with open(args.out, "w", encoding="utf-8") as fh:
            fh.write(text + "\n")
        sys.stderr.write(f"# {out['count']} annotated node(s) "
                         f"(tr={out['withTr']} en={out['withEn']}, source={source}) → {args.out}\n")
    else:
        sys.stdout.write(text + "\n")

    only_tr = [a["nodeId"] for a in deduped if a.get("tr") and not a.get("en")]
    if only_tr:
        sys.stderr.write(f"# {len(only_tr)} annotation(s) have TR but no EN "
                         "(flag back to the content team): " + ", ".join(only_tr) + "\n")


if __name__ == "__main__":
    main()
