#!/usr/bin/env python3
"""Render a PER-KEY "screenpiece" for every mapping row: a cropped band of the screen
with a RED BOX around exactly that element, so the table's "Ekran Görüntüsü" cell shows
the content team where each key lives on screen (ported from the web skill's
render-key-shots.mjs). No AI image generation  -  pure geometry + HTML + headless Chrome.

Input modes (same split as render-overlay.py):
  * REST (default)  -  Figma token (--token / env / keychain FIGMA_ACCESS_TOKEN):
      geometry  GET /v1/files/<key>/nodes?ids=<frame>  → per-text absoluteBoundingBox,
                with ANCESTOR VISIBILITY (`visible!=false`) + cumulative opacity tracked,
                so hidden component variants with stale bboxes are never boxed;
      image     GET /v1/images/<key>?ids=<frame>&format=png&scale=<n> → one render,
                cropped per key.
  * --spec <file> (repeatable; MCP / offline)  -  the SAME spec shape render-overlay.py
      takes: { fileKey?, pages:[{nodeId, image, frame:{w,h}, nodes:[{id, characters,
      x,y,w,h}]}] }. `image` may be a data URL **or a file path** (resolved relative to
      the spec file)  -  a committed example spec can reference screenshot.png instead of
      inlining base64. Several --spec files = several screen states; each row is boxed
      on the FIRST spec/page where its node matches.

Rows are matched to nodes exactly like the overlay: by `cmsNodeId` first, else by
normalized value (new/cms/legacy tr+en, element label). Every matched row gets a shot;
unmatched rows are listed in the manifest's `missing` (blank cell in the table  -  never
a guessed crop).

Output, into --out:
  keyshots/keyshot__<NN>__<key>.png   one per matched row; NN = the row's 1-based
                                      position in mapping.rows  -  the SAME number the
                                      table and the overlay cards use.
  <slug>.keyshots.manifest.json       {shots:[{row,key,element,file,...}], missing:[...]}
                                      build-artifact.py reads this (mapping top-level
                                      `keyshots`) to fill the table cells;
                                      publish-confluence.py --keyshots attaches the PNGs.
No headless Chrome → per-shot `.html` files are written instead (open/print manually);
the manifest marks them `"fallback": "html"`. Read-only against every source.
"""
import argparse
import base64
import importlib.util
import json
import os
import re
import sys
import tempfile

sys.dont_write_bytecode = True
_HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, _HERE)

# Shared with the overlay renderer on purpose: SAME matching = the shot, the
# overlay card and the table row can never disagree about which node a key is.
# The filename is not a valid identifier, so it loads by path rather than by
# `import`: spec_from_file_location yields a real module object, and no source
# text is evaluated at runtime.
_RO_PATH = os.path.join(_HERE, "render-overlay.py")
_spec = importlib.util.spec_from_file_location("render_overlay", _RO_PATH)
if _spec is None or _spec.loader is None:
    sys.exit(f"render-key-shots: cannot load {_RO_PATH}")
_ro = importlib.util.module_from_spec(_spec)
sys.modules["render_overlay"] = _ro
_spec.loader.exec_module(_ro)

canon_id = _ro.canon_id
slug = _ro.slug
build_index = _ro.build_index
match_row = _ro.match_row
figma_token = _ro.figma_token
http_get_json = _ro.http_get_json
http_get_bytes = _ro.http_get_bytes
find_chrome = _ro.find_chrome
FIGMA_API = _ro.FIGMA_API

RED = "#E81932"


def key_file(num, key):
    # Drop a trailing "(owned by ...)" marker first: it is provenance, not identity, so
    # letting it into the filename churns every PNG (and orphans the old ones) whenever
    # a component is renamed or the tag is added. Matches build-spreadsheet's clean_key.
    bare = re.sub(r"\s*\(.*\)\s*$", "", key or "").strip()
    safe = re.sub(r'[/\\:*?"<>|\s]+', "-", bare or "key").strip("-")
    return f"keyshot__{num:02d}__{safe}.png"


def build_shot_html(img_src, fw, fh, box, pad):
    """Full-frame-width band: `pad` px of context above/below the element, red box on it."""
    cy0 = max(0, box["y"] - pad)
    cy1 = min(fh, box["y"] + box["h"] + pad)
    crop_w, crop_h = fw, cy1 - cy0
    bx = max(0, box["x"] - 6)
    by = box["y"] - cy0 - 6
    bw, bh = box["w"] + 12, box["h"] + 12
    html = f"""<!doctype html><html><head><meta charset="utf-8"><style>
*{{margin:0;padding:0;box-sizing:border-box}}
body{{width:{crop_w}px;height:{crop_h}px;overflow:hidden;position:relative;background:#fff}}
img{{position:absolute;left:0;top:{-cy0}px;width:{fw}px;height:{fh}px}}
.box{{position:absolute;left:{bx}px;top:{by}px;width:{bw}px;height:{bh}px;
 border:3px solid {RED};border-radius:6px;box-shadow:0 0 0 2px rgba(232,25,50,.15)}}
</style></head><body><img src="{img_src.replace('"', '&quot;')}"/><div class="box"></div></body></html>"""
    return html, crop_w, crop_h


def render_shot_png(chrome, html, w, h, out_path, scale):
    """One headless-Chrome screenshot. Returns None on success, else an error string."""
    import subprocess
    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={w},{int(h)}",
             f"--screenshot={out_path}", tmp],
            capture_output=True, text=True, timeout=120)
        if not os.path.isfile(out_path):
            return (r.stderr or "no output")[:200]
        return None
    except Exception as exc:  # timeout / spawn failure  -  fall back per shot, not per run
        return str(exc)[:200]
    finally:
        try:
            os.unlink(tmp)
        except OSError:
            pass


def spec_image_src(page, spec_path):
    """`image` as a browser src: pass a data URL through; inline a file path (relative
    to the spec file) as base64 so the crop HTML stays self-contained for Chrome."""
    img = page.get("image") or ""
    if img.startswith("data:"):
        return img
    cand = img if os.path.isabs(img) else os.path.join(os.path.dirname(os.path.abspath(spec_path)), img)
    if not os.path.isfile(cand):
        sys.exit(f"spec image not found: {cand}")
    with open(cand, "rb") as fh:
        return "data:image/png;base64," + base64.b64encode(fh.read()).decode("ascii")


def collect_rest_pages(file_key, node_ids, token, ca_file, scale):
    """Fetch each frame's VISIBLE text nodes (+ image render)  -  visibility is ancestor
    `visible` AND cumulative opacity, exactly the web port's rule, because a node tree
    carries text that is not rendered in this state and its stale bbox would land the
    red box on the wrong content."""
    headers = {"X-Figma-Token": token}
    import urllib.parse
    pages = []
    for nid in node_ids:
        data = http_get_json(f"{FIGMA_API}/files/{file_key}/nodes?ids={urllib.parse.quote(nid, safe='')}",
                             headers, ca_file)
        doc = ((data.get("nodes") or {}).get(nid) or {}).get("document")
        if not doc:
            sys.stderr.write(f"# warning: node {nid} not found  -  skipped\n")
            continue
        fb = doc.get("absoluteBoundingBox") or {"x": 0, "y": 0, "width": 0, "height": 0}
        nodes = []

        def walk(n, anc_visible, opacity):
            vis = anc_visible and n.get("visible") is not False
            op = opacity * (n.get("opacity") if isinstance(n.get("opacity"), (int, float)) else 1)
            b = n.get("absoluteBoundingBox")
            is_text = n.get("type") == "TEXT" and isinstance(n.get("characters"), str) and n["characters"].strip()
            if is_text and b and vis and op > 0:
                nodes.append({"id": n.get("id"), "characters": n["characters"],
                              "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, vis, op)

        walk(doc, True, 1)
        img = http_get_json(
            f"{FIGMA_API}/images/{file_key}?ids={urllib.parse.quote(nid, safe='')}&format=png&scale={scale}",
            headers, ca_file)
        url = (img.get("images") or {}).get(nid)
        if not url:
            sys.stderr.write(f"# warning: no image render for {nid}  -  skipped\n")
            continue
        raw = http_get_bytes(url, ca_file)
        pages.append({"nodeId": nid,
                      "image": "data:image/png;base64," + base64.b64encode(raw).decode("ascii"),
                      "frame": {"w": round(fb.get("width", 0)), "h": round(fb.get("height", 0))},
                      "nodes": nodes, "_src": "rest"})
    return pages


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", action="append", default=[],
                    help="spec JSON (repeatable  -  one per screen state; overlay-compatible shape; "
                         "image may be a data URL or a file path relative to the spec)")
    ap.add_argument("--file", help="Figma fileKey (REST; else mapping figmaFileKey)")
    ap.add_argument("--nodes", help="comma-separated frame node-ids (REST; else mapping figmaNodes)")
    ap.add_argument("--pad", type=int, default=150, help="context band above/below the element (px)")
    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 (shots go under <out>/keyshots/)")
    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)
    shots_dir = os.path.join(args.out, "keyshots")
    os.makedirs(shots_dir, exist_ok=True)

    # ---- geometry pages, in precedence order (first match wins per row) ----
    if args.spec:
        pages = []
        for sp in args.spec:
            with open(sp, encoding="utf-8") as fh:
                spec = json.load(fh)
            for page in spec.get("pages") or []:
                page["_img_src"] = spec_image_src(page, sp)
                pages.append(page)
        if not pages:
            sys.exit("--spec files carry no pages")
    else:
        file_key = args.file or m.get("figmaFileKey")
        node_ids = [canon_id(n) for n in (args.nodes or "").split(",") if n.strip()] or \
            [canon_id(str(n)) for n in (m.get("figmaNodes") or [])]
        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 offline 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).")
        pages = collect_rest_pages(file_key, node_ids, token, args.ca_file, args.scale)
        for page in pages:
            page["_img_src"] = page["image"]
        if not pages:
            sys.exit("no renderable frames")

    # ---- choose one node per row: first page (in order) whose nodes match it ----
    by = [build_index([r]) for r in rows]  # per-row index → exact same matcher as overlay
    chosen = {}   # row idx (0-based) → {page, node}
    for page in pages:
        for n in sorted(page["nodes"], key=lambda x: x.get("y", 0)):
            for i, r in enumerate(rows):
                if i in chosen or not r.get("newKey"):
                    continue
                if match_row(n, *by[i]) is r:
                    chosen[i] = {"page": page, "node": n}
    missing = [{"row": i + 1, "key": r.get("newKey"),
                "reason": "no visible node matched (cmsNodeId/value)"}
               for i, r in enumerate(rows) if r.get("newKey") and i not in chosen]

    chrome = find_chrome()
    if not chrome:
        sys.stderr.write("# no headless Chrome  -  writing per-shot .html fallbacks\n")

    manifest = {"screen": screen, "slug": sl, "pad": args.pad, "scale": args.scale,
                "dir": "keyshots", "shots": [], "missing": missing}
    ok = fail = 0
    for i in sorted(chosen):
        r, c = rows[i], chosen[i]
        page, n = c["page"], c["node"]
        num = i + 1
        html, w, h = build_shot_html(page["_img_src"], page["frame"]["w"], page["frame"]["h"],
                                     {"x": n["x"], "y": n["y"], "w": n["w"], "h": n["h"]}, args.pad)
        fname = key_file(num, r["newKey"])
        out_png = os.path.join(shots_dir, fname)
        entry = {"row": num, "key": r["newKey"], "element": r.get("element"),
                 "file": f"keyshots/{fname}", "pageNode": page.get("nodeId"),
                 "cropW": w, "cropH": h}
        if chrome:
            err = render_shot_png(chrome, html, w, h, out_png, args.scale)
            if err:
                fail += 1
                sys.stderr.write(f"# ✗ row {num} {r['newKey']}: {err}\n")
                continue
        else:
            alt = out_png[:-4] + ".html"
            with open(alt, "w", encoding="utf-8") as fh:
                fh.write(html)
            entry["file"] = f"keyshots/{os.path.basename(alt)}"
            entry["fallback"] = "html"
        ok += 1
        manifest["shots"].append(entry)

    mpath = os.path.join(args.out, sl + ".keyshots.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"# keyshots: {ok} written, {len(missing)} unmatched, {fail} failed → "
                     f"{shots_dir}/ · manifest: {mpath}\n")
    sys.stderr.write(f'# set the mapping\'s top-level "keyshots": "{os.path.basename(mpath)}" '
                     "so build-artifact.py fills the table cells\n")


if __name__ == "__main__":
    main()
