#!/usr/bin/env python3
"""Render a "which key ↔ which UI element" overlay image for one screen.

Figma's own image render does NOT include the Dev Mode annotation overlay, so this
RECONSTRUCTS it deterministically: the screen image (REST render or an MCP-supplied
data URL) on the right, one card per keyable text element on the left, each card
tying the **localization key** to the element and showing the content team's
**CMS TR/EN** copy where annotated. No AI image generation  -  pure geometry + HTML +
headless Chrome (with an `.html` fallback when no Chrome is present).

Two input modes (mirrors fetch-annotations.py's REST/MCP split):
  * REST (default)  -  needs a Figma token (--token / env / keychain FIGMA_ACCESS_TOKEN):
      geometry  `GET /v1/files/<key>/nodes?ids=<id>`  → absoluteBoundingBox
      image     `GET /v1/images/<key>?ids=<id>&format=png&scale=<n>` → PNG url → data URL
  * --spec <file> (MCP / no network): { fileKey, pages:[{nodeId,name,image:<dataUrl>,
      frame:{w,h}, nodes:[{characters,tr,en,x,y,w,h}] }] }  -  the orchestrator builds
      this from get_screenshot + use_figma so no REST calls happen.

Cards are keyed off the run's **mapping JSON** (`--mapping`): each text node is matched to
a mapping row (by `cmsNodeId`, else by normalized value) to label the card with its
`newKey`. A node carrying a Final UX Writing annotation → pink "İçerik (CMS)" card with
TR/EN; a keyable node without one → gray "Öneri bekleniyor" card with the on-screen text.
Non-keyable text (no key, no annotation) is dropped.

**No annotations needed.** When value-matching is unreliable (design wording ≠ resolved
values, or the frame mixes in background content), anchor explicitly: set each row's
`cmsNodeId` to a synthetic id and feed a `--spec` whose nodes carry the same `id` + the
box read off the clean screenshot  -  `cmsNodeId` matching is exact, so the overlay is
faithful with zero annotations. (`design-export/.../tree.json` is component-instance level
- use its root `nodeId` for the REST frame, not for per-label boxes.)

Output: `<out>/<slug>.overlay.png` (or `<slug>.overlay.html` fallback) + a sibling
`<slug>.overlay.manifest.json` (`page → file → {fileKey,nodeId}`) so the publish step
can find the image to attach. Read-only against every source.
"""
import argparse
import base64
import json
import os
import re
import ssl
import subprocess
import sys
import tempfile
import unicodedata
import urllib.error
import urllib.parse
import urllib.request

FIGMA_API = "https://api.figma.com/v1"
OUT_SUFFIX = ".overlay.png"

# --- card layout constants (ported from web render-annotations.mjs) --------
CARD_W = 320
COL_X = 24
CHANNEL = 190
DESIGN_X = COL_X + CARD_W + CHANNEL
RIGHT_MARGIN = 40
TOP = 60
CARD_H = 116
CARD_GAP = 14
PINK = "#EC1E91"
GRAY = "#9aa0a6"

# 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):
    if not raw:
        return {"tr": None, "en": None}
    lines = [ln.strip() for ln in re.split(r"\r?\n", raw) if ln.strip()]
    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}
    if len(bare) >= 2:
        return {"tr": bare[0], "en": bare[1]}
    if len(bare) == 1:
        return {"tr": bare[0], "en": None}
    return {"tr": None, "en": None}


def canon_id(s):
    return re.sub(r"^(\d+)-(\d+)$", r"\1:\2", (s or "").strip())


def norm(s):
    """NFC → trim → collapse whitespace → strip trailing .:... → lower (web `norm`)."""
    if not s:
        return ""
    s = unicodedata.normalize("NFC", s).strip()
    s = re.sub(r"\s+", " ", s)
    s = re.sub(r"[.:...]+$", "", s).strip()
    return s.lower()


def slug(s):
    s = (s or "screen").strip()
    s = re.sub(r'[/\\:*?"<>|]+', "-", s)
    s = re.sub(r"\s+", "-", s)
    s = re.sub(r"-{2,}", "-", s)
    return s.strip("-").lower() or "screen"


def esc(s):
    return (str(s) if s is not None else "").replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")


# --- Figma token + HTTP (self-contained, mirrors fetch-annotations.py) ------
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 _ctx(ca_file):
    return ssl.create_default_context(cafile=ca_file) if ca_file else ssl.create_default_context()


def http_get_json(url, headers, ca_file):
    req = urllib.request.Request(url, headers=headers)
    try:
        with urllib.request.urlopen(req, context=_ctx(ca_file), 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 --spec (MCP path) instead.")
        sys.exit(f"Figma HTTP {e.code} for {url}: {e.read()[:200]!r}")
    except urllib.error.URLError as e:
        sys.exit(f"Figma request failed: {getattr(e, 'reason', e)}")


def http_get_bytes(url, ca_file):
    try:
        with urllib.request.urlopen(url, context=_ctx(ca_file), timeout=120) as r:
            return r.read()
    except Exception as e:
        sys.exit(f"image fetch failed: {e}")


# --- node collection (REST) ------------------------------------------------
def node_anno(node):
    anns = node.get("annotations")
    if not isinstance(anns, list):
        return {"tr": None, "en": None}
    for a in anns:
        if not isinstance(a, dict):
            continue
        lbl = a.get("label") or a.get("labelMarkdown") or ""
        if lbl:
            p = split_lang(lbl)
            if p["tr"] is not None or p["en"] is not None:
                return p
    return {"tr": None, "en": None}


def collect_rest_nodes(file_key, node_id, token, ca_file, scale):
    headers = {"X-Figma-Token": token}
    data = http_get_json(f"{FIGMA_API}/files/{file_key}/nodes?ids={urllib.parse.quote(node_id, safe='')}",
                         headers, ca_file)
    entry = (data.get("nodes") or {}).get(node_id) or {}
    doc = entry.get("document")
    if not doc:
        sys.exit(f"node {node_id} not found in file {file_key}")
    fb = doc.get("absoluteBoundingBox") or {"x": 0, "y": 0, "width": 0, "height": 0}
    frame = {"w": round(fb.get("width", 0)), "h": round(fb.get("height", 0))}
    nodes = []

    def walk(n):
        b = n.get("absoluteBoundingBox")
        is_text = n.get("type") == "TEXT" and isinstance(n.get("characters"), str) and n["characters"].strip()
        ann = node_anno(n)
        if b and (is_text or ann["tr"] is not None or ann["en"] is not None):
            nodes.append({
                "id": n.get("id"),
                "characters": n.get("characters") if is_text else "",
                "tr": ann["tr"], "en": ann["en"],
                "x": round(b["x"] - fb["x"]), "y": round(b["y"] - fb["y"]),
                "w": round(b["width"]), "h": round(b["height"]),
            })
        for c in n.get("children", []) or []:
            walk(c)

    walk(doc)

    # image render → data URL
    img = http_get_json(
        f"{FIGMA_API}/images/{file_key}?ids={urllib.parse.quote(node_id, safe='')}&format=png&scale={scale}",
        headers, ca_file)
    url = (img.get("images") or {}).get(node_id)
    if not url:
        sys.exit(f"no image render returned for {node_id}")
    raw = http_get_bytes(url, ca_file)
    data_url = "data:image/png;base64," + base64.b64encode(raw).decode("ascii")
    return {"nodeId": node_id, "image": data_url, "frame": frame, "nodes": nodes}


# --- card model ------------------------------------------------------------
def build_index(rows):
    by_node, by_val = {}, {}
    for r in rows:
        key = r.get("newKey")
        if not key:
            continue
        nid = r.get("cmsNodeId")
        if nid:
            by_node[canon_id(nid)] = r
        # index every known value (tr + en across new / cms / legacy) so a node
        # matches its key regardless of the frame's language or wording source
        for src in (r.get("new") or {}, r.get("cms") or {}, r.get("legacy") or {}):
            if isinstance(src, dict):
                for lang in ("tr", "en"):
                    v = src.get(lang)
                    if v:
                        by_val.setdefault(norm(v), r)
        if r.get("element"):
            by_val.setdefault(norm(r["element"]), r)
    return by_node, by_val


def match_row(node, by_node, by_val):
    nid = canon_id(node.get("id") or "")
    if nid in by_node:
        return by_node[nid]
    for v in (node.get("tr"), node.get("characters")):
        if v and norm(v) in by_val:
            return by_val[norm(v)]
    return None


def build_cards(page, rows):
    by_node, by_val = build_index(rows)
    # 1-based index of every row in the mapping → the SAME number build-artifact prints
    # in the summary/detail tables (both enumerate rows in order). The overlay card badge
    # uses it so a mark on the screen and its table row always carry the same number.
    row_num = {id(r): i for i, r in enumerate(rows, 1)}
    cards = []
    for n in page["nodes"]:
        row = match_row(n, by_node, by_val)
        key = row.get("newKey") if row else None
        # CMS values: prefer the node's own annotation, else the matched row's cms.*
        tr = n.get("tr")
        en = n.get("en")
        if tr is None and en is None and row and isinstance(row.get("cms"), dict):
            tr, en = row["cms"].get("tr"), row["cms"].get("en")
        if not key and tr is None and en is None:
            continue  # non-keyable
        cards.append({
            "key": key or "(eşleşmedi)",
            "tr": tr, "en": en,
            "designText": n.get("characters") or "",
            "x": n["x"], "y": n["y"], "w": n["w"], "h": n["h"],
            "annotated": tr is not None or en is not None,
            "side": (n.get("side") or "left").lower(),
            "num": row_num.get(id(row)) if row else None,   # table row number (or None → sequential)
        })
    cards.sort(key=lambda c: c["y"])
    return cards


# --- HTML composition ------------------------------------------------------
def build_html(page, cards, title):
    """Lay out cards on EITHER side of the frame (per-card `side`: left|right). Cards
    that don't read well on the left (e.g. right-column UI like the amenity grid's
    right entries) are placed on the right so their connector stays short and doesn't
    cross the whole frame. Each column is ordered by target y and greedily stacked;
    each link gets its own lane and a numbered dot matching its card. Returns
    (html, canvas_w, canvas_h)."""
    frame_w = page["frame"]["w"] or 800
    frame_h = page["frame"]["h"] or 600
    left_card_right = COL_X + CARD_W
    design_right = DESIGN_X + frame_w
    right_col_x = design_right + CHANNEL

    items = []
    for c in cards:
        tx = DESIGN_X + c["x"] + c["w"] / 2
        ty = TOP + c["y"] + c["h"] / 2
        items.append({"tx": tx, "ty": ty, "c": c, "side": c.get("side", "left")})

    left = sorted([it for it in items if it["side"] != "right"], key=lambda it: it["ty"])
    right = sorted([it for it in items if it["side"] == "right"], key=lambda it: it["ty"])
    has_right = bool(right)

    def place(col):
        cursor = TOP
        for it in col:
            it["y"] = max(cursor, it["ty"] - CARD_H / 2, TOP)
            cursor = it["y"] + CARD_H + CARD_GAP
        return cursor
    cur_l, cur_r = place(left), place(right)

    canvas_w = (right_col_x + CARD_W + RIGHT_MARGIN) if has_right else (design_right + RIGHT_MARGIN)
    canvas_h = max(TOP + frame_h + TOP, cur_l + TOP, cur_r + TOP)

    # numbering: use the matched row's table number so the mark on the screen and its
    # table row agree; fall back to sequential only for a card with no matched row.
    seq = 1
    for it in left + right:
        n = it["c"].get("num")
        if n is None:
            n = seq
        it["num"] = n
        seq += 1

    nL, nR = len(left), len(right)
    lane_step_l = (CHANNEL - 30) / max(1, nL - 1) if nL > 1 else 0
    lane_step_r = (CHANNEL - 30) / max(1, nR - 1) if nR > 1 else 0

    connectors, card_html = [], []

    def emit(it, idx, side):
        c, tx, ty, y, num = it["c"], it["tx"], it["ty"], it["y"], it["num"]
        card_mid = y + CARD_H / 2
        colour = PINK if c["annotated"] else GRAY
        if side == "right":
            lane = design_right + 16 + idx * lane_step_r
            path = f'M {right_col_x} {card_mid:.0f} H {lane:.0f} V {ty:.0f} H {tx:.0f}'
            left_px = right_col_x
        else:
            lane = left_card_right + 16 + idx * lane_step_l
            path = f'M {left_card_right} {card_mid:.0f} H {lane:.0f} V {ty:.0f} H {tx:.0f}'
            left_px = COL_X
        connectors.append(
            f'<path d="{path}" '
            f'stroke="{colour}" stroke-width="1.4" stroke-dasharray="5 4" fill="none" opacity="0.85"/>'
            f'<circle cx="{tx:.0f}" cy="{ty:.0f}" r="9" fill="{colour}"/>'
            f'<text x="{tx:.0f}" y="{ty + 3.5:.0f}" text-anchor="middle" font-size="11" '
            f'font-weight="700" fill="#fff">{num}</text>')
        badge = f'<span class="num" style="background:{colour}">{num}</span>'
        if c["annotated"]:
            pill = f'<span class="pill" style="background:{PINK}">İçerik (CMS)</span>'
            rows = f'<div class="row"><span class="lang">TR:</span> {esc(c["tr"]) if c["tr"] else "-"}</div>'
            if c["en"] is not None:
                rows += f'<div class="row"><span class="lang">EN:</span> {esc(c["en"])}</div>'
            klass = ""
        else:
            pill = f'<span class="pill" style="background:{GRAY}">Öneri bekleniyor</span>'
            rows = f'<div class="row muted">metin: {esc(c["designText"]) or "-"}</div>'
            klass = " keyonly"
        card_html.append(
            f'<div class="card{klass}" style="left:{left_px:.0f}px;top:{y:.0f}px">'
            f'<div class="hd">{badge}{pill}</div>'
            f'<div class="key">{esc(c["key"])}</div>{rows}</div>')

    for i, it in enumerate(left):
        emit(it, i, "left")
    for i, it in enumerate(right):
        emit(it, i, "right")

    html = f"""<!doctype html><html><head><meta charset="utf-8"><style>
  * {{ box-sizing:border-box; }}
  body {{ margin:0; background:#1e1e1e; font-family:-apple-system,'Segoe UI',Roboto,Arial,sans-serif; }}
  .canvas {{ position:relative; width:{canvas_w}px; height:{canvas_h:.0f}px; }}
  .design {{ position:absolute; left:{DESIGN_X}px; top:{TOP}px; width:{frame_w}px; height:{frame_h}px;
            box-shadow:0 0 0 1px #333; }}
  svg.links {{ position:absolute; left:0; top:0; width:{canvas_w}px; height:{canvas_h:.0f}px; pointer-events:none; }}
  .card {{ position:absolute; width:{CARD_W}px; min-height:{CARD_H}px; background:#2b2b2b; color:#e8e8e8;
          border-radius:10px; padding:10px 14px; box-shadow:0 1px 4px rgba(0,0,0,.4); }}
  .card.keyonly {{ background:#f4f5f6; color:#202124; }}
  .hd {{ display:flex; align-items:center; gap:7px; margin-bottom:6px; }}
  .num {{ display:inline-flex; align-items:center; justify-content:center; width:18px; height:18px;
         border-radius:50%; color:#fff; font-size:11px; font-weight:700; flex:none; }}
  .pill {{ display:inline-block; color:#fff; font-size:11px; font-weight:600; padding:2px 8px;
          border-radius:10px; }}
  .key {{ font-family:'SF Mono',Menlo,Consolas,monospace; font-size:12px; color:#9ad; margin-bottom:6px;
         word-break:break-all; }}
  .card.keyonly .key {{ color:#1666c8; }}
  .row {{ font-size:13px; line-height:1.35; }}
  .row.muted {{ color:#5f6368; }}
  .lang {{ color:{PINK}; font-weight:600; }}
  .title {{ position:absolute; left:{COL_X}px; top:18px; color:#bbb; font-size:13px; }}
</style></head><body><div class="canvas">
  <div class="title">{esc(title)}</div>
  <img class="design" src="{page['image']}">
  <svg class="links">{''.join(connectors)}</svg>
  {''.join(card_html)}
</div></body></html>"""
    return html, canvas_w, canvas_h


# --- rasterize -------------------------------------------------------------
def find_chrome():
    for p in (
        "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
        "/Applications/Chromium.app/Contents/MacOS/Chromium",
        "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
    ):
        if os.path.isfile(p) and os.access(p, os.X_OK):
            return p
    from shutil import which
    for c in ("google-chrome", "google-chrome-stable", "chromium", "chromium-browser", "chrome"):
        w = which(c)
        if w:
            return w
    return None


def render_png(html, out_path, canvas_w, canvas_h, scale):
    chrome = find_chrome()
    if not chrome:
        alt = out_path[:-4] + ".html" if out_path.endswith(".png") else out_path + ".html"
        with open(alt, "w", encoding="utf-8") as fh:
            fh.write(html)
        sys.stderr.write(f"# no headless Chrome found  -  wrote {alt} (open/print to PDF)\n")
        return alt
    with tempfile.NamedTemporaryFile("w", suffix=".html", delete=False, encoding="utf-8") as tf:
        tf.write(html)
        tmp = tf.name
    try:
        r = subprocess.run(
            [chrome, "--headless", "--disable-gpu", "--hide-scrollbars",
             f"--force-device-scale-factor={scale}",
             f"--window-size={canvas_w},{int(canvas_h)}",
             f"--screenshot={out_path}", tmp],
            capture_output=True, text=True, timeout=120)
        if not os.path.isfile(out_path):
            sys.exit(f"Chrome screenshot failed: {r.stderr[:300]}")
    finally:
        try:
            os.unlink(tmp)
        except OSError:
            pass
    return out_path


def main():
    ap = argparse.ArgumentParser(description=__doc__,
                                 formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument("--mapping", required=True, help="mapping JSON (rows + figmaFileKey/figmaNodes + screen)")
    ap.add_argument("--spec", help="MCP spec JSON (no network)")
    ap.add_argument("--file", help="Figma fileKey (REST; else from --mapping figmaFileKey)")
    ap.add_argument("--nodes", help="node-ids (REST; else from --mapping figmaNodes)")
    ap.add_argument("--scale", type=int, default=2)
    ap.add_argument("--token")
    ap.add_argument("--ca-file")
    ap.add_argument("--out", default=".", help="output dir")
    ap.add_argument("--slug", help="override slug (default from screen name)")
    args = ap.parse_args()

    with open(args.mapping, encoding="utf-8") as fh:
        m = json.load(fh)
    rows = m.get("rows") or []
    screen = m.get("screen") or "screen"
    sl = args.slug or slug(screen)
    os.makedirs(args.out, exist_ok=True)

    file_key = args.file or m.get("figmaFileKey")
    node_ids = [n.strip() for n in (args.nodes or "").split(",") if n.strip()] or \
        [str(n) for n in (m.get("figmaNodes") or [])]

    # one overlay for the screen: use the FIRST node as the framing node (the screen frame)
    if args.spec:
        with open(args.spec, encoding="utf-8") as fh:
            spec = json.load(fh)
        pages = spec.get("pages") or []
        if not pages:
            sys.exit("--spec has no pages")
        page = pages[0]
        file_key = file_key or spec.get("fileKey")
    else:
        if not (file_key and node_ids):
            ap.error("REST needs a fileKey + node-ids (via --file/--nodes or the mapping). "
                     "Or pass --spec for the MCP path.")
        token = figma_token(args)
        if not token:
            sys.exit("No Figma token  -  pass --token, set FIGMA_ACCESS_TOKEN, or add the "
                     "keychain item FIGMA_ACCESS_TOKEN (or use --spec).")
        page = collect_rest_nodes(file_key, canon_id(node_ids[0]), token, args.ca_file, args.scale)
        page["name"] = screen

    cards = build_cards(page, rows)
    if not cards:
        sys.stderr.write("# warning: no keyable cards built (no node matched a row / no annotations)\n")
    html, canvas_w, canvas_h = build_html(page, cards, screen)
    out_png = os.path.join(args.out, sl + OUT_SUFFIX)
    written = render_png(html, out_png, canvas_w, canvas_h, args.scale)

    manifest = {
        "screen": screen,
        "file": os.path.basename(written),
        "fileKey": file_key,
        "nodeId": canon_id(node_ids[0]) if node_ids else page.get("nodeId"),
        "cardCount": len(cards),
        "annotatedCount": sum(1 for c in cards if c["annotated"]),
        "width": canvas_w, "height": int(canvas_h), "scale": args.scale,
    }
    mpath = os.path.join(args.out, sl + ".overlay.manifest.json")
    with open(mpath, "w", encoding="utf-8") as fh:
        json.dump(manifest, fh, ensure_ascii=False, indent=2)
        fh.write("\n")
    sys.stderr.write(f"# overlay: {len(cards)} card(s), {manifest['annotatedCount']} annotated → "
                     f"{written}\n")


if __name__ == "__main__":
    main()
