#!/usr/bin/env python3
"""Render a screen localization-reuse mapping into shareable artifacts.

Input: a mapping JSON (schema in reference/format-and-output.md). Output, into --out:
  <slug>.confluence.xml  -  Confluence *storage format* (what the REST API wants)
  <slug>.md  -  Markdown mirror (PR / quick read)
  <slug>.preview.html  -  standalone HTML to eyeball in a browser before upload
  <slug>.docx  -  Word doc for the content team (--docx; pure-stdlib, offline)
  <slug>.pdf  -  read-only share copy (--pdf; via an installed renderer)

The three text artifacts above are always written. `.docx` / `.pdf` are opt-in
(`--docx` / `--pdf`, or `--all` for everything). The .docx is generated with the
stdlib only (a .docx is a ZIP of OOXML)  -  no third-party dependency, no network.
The .pdf is produced by whatever renderer the machine has (LibreOffice `soffice`,
then Chrome/Chromium headless, then `wkhtmltopdf`); if none is installed the PDF is
skipped with a note pointing at the .docx / .html (still no pip, no network).

Document language defaults to **English**; pass `--ui-lang tr` for Turkish chrome.
Author it in whatever language the content team actually reads. Only the template
chrome is localised  -  the key/value data is rendered verbatim.

Two-level layout: a Summary table (Old en/tr · New en/tr · CMS tr/en at a glance +
keys + verdict) and Details (one block per element, all 8 languages, Old vs New vs
CMS). The CMS columns carry the content team's actual final copy from Figma Dev Mode
annotations (mapping `cms`, from scripts/fetch-annotations.py); a ⚠ marks a CMS value
that drifts from the new design value. If the mapping has a top-level `overlay`
(a key↔UI image from scripts/render-overlay.py), it is embedded as a "key map" section.

Deterministic rendering only  -  no judgement, no network. Live Confluence upload is a
separate step (scripts/publish-confluence.py  -  Server/DC Bearer, idempotent
update-in-place); `--print-upload` prints the ready publish command.
"""
import argparse
import html
import json
import os
import sys

VCOLOUR = {"reuse": "Green", "review": "Yellow", "new": "Blue"}
VEMOJI = {"reuse": "✅", "review": "🔶", "new": "🆕"}
PAGE_TITLE_PREFIX = os.environ.get("LOCALIZATION_PAGE_PREFIX", "Localization")
SUMMARY_LANGS = ["en", "tr"]
DETAIL_LANGS = ["en", "tr", "ar", "de", "es", "fr", "it", "ru"]

# UI chrome strings  -  English default, Turkish alternate (--ui-lang).
T = {
    "tr": {
        "title": "Lokalizasyon Yeniden Kullanım Haritası",
        "intro": "İçerik yönetimi için  -  hangi yeni tasarım metinleri mevcut bir çeviriyi yeniden kullanabilir. "
                 "Karar bir öneridir; içerik ekibi onaylar.",
        "platforms": "Platformlar", "screen": "Ekran", "summary": "Özet",
        "details": "Detaylar  -  tüm diller", "from_export": "design-export'tan (tek depo)",
        "verdict": {"reuse": "YENİDEN KULLAN", "review": "İNCELE", "new": "YENİ"},
        "legend": {"reuse": "mevcut değeri/anahtarı yeniden kullan",
                   "review": "aynı işlev, farklılık var  -  içerik ekibi karar verir",
                   "new": "eski karşılığı yok"},
        "counts": ["yeniden kullan", "incele", "yeni", "öğe"],
        "cms_counts": ["içerik onaylı", "içerik farkı"],
        "keymap": "Ekran  -  anahtar eşlemesi",
        "drift_note": "⚠ = içerik ekibi (CMS) metni yeni tasarım değerinden farklı",
        "cols": ["#", "UI öğesi", "Ekran Görüntüsü", "Yeni anahtar", "Eski iOS", "Eski Android", "Eski Web",
                 "Eski en", "Eski tr", "Yeni en", "Yeni tr", "İçerik TR", "İçerik EN", "Karar", "Not"],
        "dcols": ["Dil", "Eski (mevcut)", "Yeni (tasarım)", "İçerik (CMS)"],
    },
    "en": {
        "title": "Localization Reuse Map",
        "intro": "For content management  -  which redesign strings can reuse an existing translation. "
                 "Verdict is a recommendation; content team confirms.",
        "platforms": "Platforms", "screen": "Screen", "summary": "Summary",
        "details": "Details  -  all languages", "from_export": "from design-export (single-repo)",
        "verdict": {"reuse": "REUSE", "review": "REVIEW", "new": "NEW"},
        "legend": {"reuse": "reuse the legacy value/key",
                   "review": "same function, drift  -  content team decides",
                   "new": "no legacy counterpart"},
        "counts": ["reuse", "review", "new", "elements"],
        "cms_counts": ["with CMS copy", "CMS differs"],
        "keymap": "Screen  -  key map",
        "drift_note": "⚠ = CMS copy differs from the new design value",
        "cols": ["#", "UI element", "Screenshot", "New key", "Legacy iOS", "Legacy Android", "Legacy Web",
                 "Old en", "Old tr", "New en", "New tr", "CMS TR", "CMS EN", "Verdict", "Note"],
        "dcols": ["Lang", "Old (legacy)", "New (redesign)", "CMS"],
    },
}


def e(s):
    return html.escape("" if s in (None, "") else str(s), quote=True)


def dash(s):
    return s if s not in (None, "") else "-"


def md(s):
    return ("" if s in (None, "") else str(s)).replace("|", "\\|").replace("\n", " ")


def vtext(t, vd):
    return f'{VEMOJI.get(vd, "")} {t["verdict"].get(vd, vd)}'


LEGACY_KEY_PREFIX = ""


def legdisp(k):
    """Legacy keys are stored bare in the mapping. When the legacy backend namespaces
    them under a prefix the app strips, the content team still wants the full stored
    key  -  set the mapping's `legacyKeyPrefix` and it is re-added for display only.
    Parenthesised markers and empties pass through."""
    if k in (None, ""):
        return "-"
    return k if k.startswith("(") else LEGACY_KEY_PREFIX + k


def counts_line(t, rows):
    c = {k: sum(1 for r in rows if r.get("verdict") == k) for k in VCOLOUR}
    cn = t["counts"]
    return f'{c["reuse"]} {cn[0]} · {c["review"]} {cn[1]} · {c["new"]} {cn[2]} · {len(rows)} {cn[3]}'


# ---- CMS (content team) annotation copy ----
# `cms` is the content team's actual final copy, fetched from Figma Dev Mode
# annotations (see scripts/fetch-annotations.py). 8-lang-capable; the Summary shows
# tr/en, the Details table shows every language that has a CMS value.

def cms_of(r):
    return r.get("cms") or {}


def _cnorm(s):
    """Normalize for drift comparison: NFC, collapse whitespace, strip trailing .:..., lowercase.
    So casing/spacing/trailing-punctuation differences don't read as a real CMS drift."""
    import re
    import unicodedata
    if s in (None, ""):
        return ""
    s = unicodedata.normalize("NFC", str(s)).strip()
    s = re.sub(r"\s+", " ", s)
    return re.sub(r"[.:...]+$", "", s).strip().lower()


def _drifts(cms_val, new_val):
    return (cms_val not in (None, "") and new_val not in (None, "")
            and _cnorm(cms_val) != _cnorm(new_val))


def row_drift(r):
    """True if any summary-lang CMS value differs (meaningfully) from the new value."""
    nw, cms = r.get("new") or {}, cms_of(r)
    return any(_drifts(cms.get(l), nw.get(l)) for l in SUMMARY_LANGS)


def cms_disp(r, lang):
    """CMS value for a lang, with a ⚠ marker when it drifts from the new value; '  -  ' if absent."""
    cms, nw = cms_of(r), r.get("new") or {}
    v = cms.get(lang)
    if v in (None, ""):
        return "-"
    return ("⚠ " if _drifts(v, nw.get(lang)) else "") + str(v)


def cms_line(t, rows):
    have = sum(1 for r in rows if cms_of(r).get("tr") or cms_of(r).get("en"))
    drift = sum(1 for r in rows if row_drift(r))
    cc = t["cms_counts"]
    return f'{have} {cc[0]} · {drift} {cc[1]}'


# ---- per-key keyshots (annotated screenpieces) ----
# The mapping's top-level `keyshots` names the manifest render-key-shots.py wrote
# (`<slug>.keyshots.manifest.json`); each shot is a cropped screenshot with a red box
# around that row's element. The Summary table's "Ekran Görüntüsü" cell embeds it  -  
# rows absent from the manifest render "-" (never a guessed crop, same rule as values).

def keyshots_of(m):
    """{row number: relative file} for the embeddable (.png) shots, else {}."""
    return m.get("_keyshots") or {}


def load_keyshots(m, bases):
    """Resolve + parse the manifest named by mapping `keyshots`; keep .png shots only
    (an .html Chrome-fallback can't be embedded). Stores {row: relpath} on the mapping
    and returns the manifest's directory (for resolving shot files), or None."""
    name = m.get("keyshots")
    if not name:
        return None
    for b in bases:
        cand = name if os.path.isabs(name) else os.path.join(b, name)
        if os.path.isfile(cand):
            with open(cand, encoding="utf-8") as fh:
                man = json.load(fh)
            m["_keyshots"] = {s["row"]: s["file"] for s in man.get("shots", [])
                              if s.get("file", "").endswith(".png")}
            return os.path.dirname(os.path.abspath(cand))
    print(f"warning: keyshots manifest not found: {name}  -  table cells stay blank",
          file=sys.stderr)
    return None


# ---- Confluence storage format ----

def status_macro(t, vd):
    return (f'<ac:structured-macro ac:name="status"><ac:parameter ac:name="colour">{VCOLOUR.get(vd,"Grey")}</ac:parameter>'
            f'<ac:parameter ac:name="title">{e(t["verdict"].get(vd, vd))}</ac:parameter></ac:structured-macro>')


def render_storage(m, t):
    rows = m["rows"]
    shot = m.get("screenshot")
    shot_xml = (f'<ac:image ac:height="500"><ri:attachment ri:filename="{e(os.path.basename(shot))}"/></ac:image>'
                if shot else "<p><em>[screenshot]</em></p>")
    over = m.get("overlay")
    over_xml = (f'<h2>{e(t["keymap"])}</h2>'
                f'<ac:image ac:width="900"><ri:attachment ri:filename="{e(os.path.basename(over))}"/></ac:image>'
                if over else "")
    legend = " &nbsp; ".join(f'{status_macro(t, k)} {e(t["legend"][k])}' for k in VCOLOUR)
    head = (f'<p><strong>{e(t["screen"])}:</strong> {e(m["screen"])} &nbsp; '
            f'<strong>{e(t["platforms"])}:</strong> {e(", ".join(m.get("platforms", [])))} &nbsp; '
            f'<strong>Figma:</strong> {e(", ".join(m.get("figmaNodes", [])))}</p>'
            f'<ac:structured-macro ac:name="info"><ac:rich-text-body><p>{legend} &nbsp;|&nbsp; '
            f'{e(counts_line(t, rows))} &nbsp;|&nbsp; {e(cms_line(t, rows))}</p>'
            f'<p><em>{e(t["drift_note"])}</em></p></ac:rich-text-body></ac:structured-macro>')
    head_row = "".join(f"<th>{e(c)}</th>" for c in t["cols"])
    body = []
    shots = keyshots_of(m)
    for i, r in enumerate(rows, 1):
        nw, old = r.get("new", {}) or {}, r.get("legacy", {}) or {}
        ks = (f'<ac:image ac:width="240"><ri:attachment ri:filename="{e(os.path.basename(shots[i]))}"/></ac:image>'
              if i in shots else "-")
        tds = [str(i), e(r.get("element")), ks, f'<code>{e(r.get("newKey"))}</code>',
               f'<code>{e(legdisp(r.get("legacyKeyIOS")))}</code>', f'<code>{e(legdisp(r.get("legacyKeyAndroid")))}</code>',
               f'<code>{e(legdisp(r.get("legacyKeyWeb")))}</code>']
        tds += [e(dash(old.get(l))) for l in SUMMARY_LANGS] + [e(dash(nw.get(l))) for l in SUMMARY_LANGS]
        tds += [e(cms_disp(r, "tr")), e(cms_disp(r, "en"))]
        tds += [status_macro(t, r.get("verdict", "")), e(r.get("note"))]
        body.append("<tr>" + "".join(f"<td>{c}</td>" for c in tds) + "</tr>")
    summary = f"<h2>{e(t['summary'])}</h2><table><tbody><tr>{head_row}</tr>{''.join(body)}</tbody></table>"
    det = [f"<h2>{e(t['details'])}</h2>"]
    for i, r in enumerate(rows, 1):
        nw, old, cms = r.get("new", {}) or {}, r.get("legacy", {}) or {}, cms_of(r)
        lr = "".join(f"<tr><td>{l}</td><td>{e(dash(old.get(l)))}</td><td>{e(dash(nw.get(l)))}</td>"
                     f"<td>{e(dash(cms.get(l)))}</td></tr>" for l in DETAIL_LANGS)
        dh = "".join(f"<th>{e(c)}</th>" for c in t["dcols"])
        det.append(f"<h3>{i}. {e(r.get('element'))} {status_macro(t, r.get('verdict',''))}</h3>"
                   f"<p>{e(t['cols'][3])} <code>{e(r.get('newKey'))}</code> · {e(t['cols'][4])} "
                   f"<code>{e(legdisp(r.get('legacyKeyIOS')))}</code> · {e(t['cols'][5])} "
                   f"<code>{e(legdisp(r.get('legacyKeyAndroid')))}</code> · {e(t['cols'][6])} "
                   f"<code>{e(legdisp(r.get('legacyKeyWeb')))}</code></p>"
                   f"<table><tbody><tr>{dh}</tr>{lr}</tbody></table>")
    return head + f"<h2>{e(t['screen'])}</h2>{shot_xml}" + over_xml + summary + "".join(det)


# ---- Markdown mirror ----

def render_markdown(m, t):
    rows = m["rows"]
    over = m.get("overlay")
    over_md = ([f"## {t['keymap']}", "", f"![keymap]({os.path.basename(over)})", ""] if over else [])
    out = [f"# {t['title']}  -  {m['screen']}", "", f"_{t['intro']}_", "",
           f"**{t['platforms']}:** {', '.join(m.get('platforms', []))} · **Figma:** {', '.join(m.get('figmaNodes', []))}", "",
           f"## {t['screen']}", "", f"![{t['screen'].lower()}]({os.path.basename(m.get('screenshot','screenshot.png'))})", "",
           *over_md,
           f"## {t['summary']}  -  {counts_line(t, rows)} · {cms_line(t, rows)}", "",
           f"_{t['drift_note']}_", "",
           "| " + " | ".join(t["cols"]) + " |", "|" + "|".join("---" for _ in t["cols"]) + "|"]
    shots = keyshots_of(m)
    for i, r in enumerate(rows, 1):
        nw, old = r.get("new", {}) or {}, r.get("legacy", {}) or {}
        c = [str(i), md(r.get("element")), f"![{i}]({shots[i]})" if i in shots else "-",
             f'`{r.get("newKey")}`',
             f'`{legdisp(r.get("legacyKeyIOS"))}`', f'`{legdisp(r.get("legacyKeyAndroid"))}`',
             f'`{legdisp(r.get("legacyKeyWeb"))}`']
        c += [md(dash(old.get(l))) for l in SUMMARY_LANGS] + [md(dash(nw.get(l))) for l in SUMMARY_LANGS]
        c += [md(cms_disp(r, "tr")), md(cms_disp(r, "en"))]
        c += [vtext(t, r.get("verdict", "")), md(r.get("note"))]
        out.append("| " + " | ".join(c) + " |")
    out += ["", f"## {t['details']}", ""]
    for i, r in enumerate(rows, 1):
        nw, old, cms = r.get("new", {}) or {}, r.get("legacy", {}) or {}, cms_of(r)
        out += [f"### {i}. {r.get('element')}  -  {vtext(t, r.get('verdict',''))}",
                f"{t['cols'][3]} `{r.get('newKey')}` · {t['cols'][4]} `{legdisp(r.get('legacyKeyIOS'))}` · "
                f"{t['cols'][5]} `{legdisp(r.get('legacyKeyAndroid'))}` · {t['cols'][6]} `{legdisp(r.get('legacyKeyWeb'))}`",
                "", "| " + " | ".join(t["dcols"]) + " |", "|" + "|".join("---" for _ in t["dcols"]) + "|"]
        for l in DETAIL_LANGS:
            out.append(f"| {l} | {md(dash(old.get(l)))} | {md(dash(nw.get(l)))} | {md(dash(cms.get(l)))} |")
        out.append("")
    return "\n".join(out) + "\n"


# ---- Browser preview ----

def render_html(m, t):
    rows = m["rows"]
    bgc = {"reuse": "#e6f4ea", "review": "#fdf3e2", "new": "#e8f0fe"}
    fgc = {"reuse": "#1f8a4c", "review": "#b06a00", "new": "#1666c8"}

    def badge(vd):
        return (f'<span style="background:{bgc.get(vd,"#eee")};color:{fgc.get(vd,"#555")};font-weight:600;'
                f'padding:2px 8px;border-radius:10px;font-size:12px;white-space:nowrap">{e(vtext(t, vd))}</span>')

    summ = ["<table><thead><tr>" + "".join(f"<th>{e(c)}</th>" for c in t["cols"]) + "</tr></thead><tbody>"]
    shots = keyshots_of(m)
    for i, r in enumerate(rows, 1):
        nw, old = r.get("new", {}) or {}, r.get("legacy", {}) or {}
        ks = (f'<img src="{e(shots[i])}" style="max-width:240px;border:1px solid #ddd;border-radius:6px">'
              if i in shots else "-")
        cells = ([str(i), e(r.get("element")), ks, f"<code>{e(r.get('newKey'))}</code>",
                  f"<code>{e(legdisp(r.get('legacyKeyIOS')))}</code>", f"<code>{e(legdisp(r.get('legacyKeyAndroid')))}</code>",
                  f"<code>{e(legdisp(r.get('legacyKeyWeb')))}</code>"]
                 + [e(dash(old.get(l))) for l in SUMMARY_LANGS] + [e(dash(nw.get(l))) for l in SUMMARY_LANGS]
                 + [e(cms_disp(r, "tr")), e(cms_disp(r, "en"))]
                 + [badge(r.get("verdict", "")), f"<span style='color:#555'>{e(r.get('note'))}</span>"])
        summ.append("<tr>" + "".join(f"<td>{c}</td>" for c in cells) + "</tr>")
    summ.append("</tbody></table>")
    det = []
    for i, r in enumerate(rows, 1):
        nw, old, cms = r.get("new", {}) or {}, r.get("legacy", {}) or {}, cms_of(r)
        lr = "".join(f"<tr><td>{l}</td><td>{e(dash(old.get(l)))}</td><td>{e(dash(nw.get(l)))}</td>"
                     f"<td>{e(dash(cms.get(l)))}</td></tr>" for l in DETAIL_LANGS)
        dh = "".join(f"<th>{e(c)}</th>" for c in t["dcols"])
        det.append(f"<h3>{i}. {e(r.get('element'))} {badge(r.get('verdict',''))}</h3>"
                   f"<p>{e(t['cols'][3])} <code>{e(r.get('newKey'))}</code> · {e(t['cols'][4])} "
                   f"<code>{e(legdisp(r.get('legacyKeyIOS')))}</code> · {e(t['cols'][5])} "
                   f"<code>{e(legdisp(r.get('legacyKeyAndroid')))}</code> · {e(t['cols'][6])} "
                   f"<code>{e(legdisp(r.get('legacyKeyWeb')))}</code><br><span style='color:#777'>{e(r.get('note'))}</span></p>"
                   f"<table style='max-width:780px'><thead><tr>{dh}</tr></thead><tbody>{lr}</tbody></table>")
    def img_src(p):
        # absolute path → file:// URL; bare filename → relative (preview.html sits in
        # the same out dir, and Chrome's html→pdf resolves it too)
        return f"file://{e(p)}" if os.path.isabs(p or "") else e(p)

    shot = m.get("screenshot")
    shot_html = (f'<img src="{img_src(shot)}" style="max-height:520px;border:1px solid #ddd;border-radius:8px">'
                 if shot else '<div style="color:#999">[screenshot]</div>')
    over = m.get("overlay")
    over_html = (f'<h2>{e(t["keymap"])}</h2><img src="{img_src(over)}" '
                 'style="max-width:100%;border:1px solid #ddd;border-radius:8px">' if over else "")
    return f"""<!doctype html><meta charset=utf-8><title>{e(t['title'])}  -  {e(m['screen'])}</title>
<style>
 body{{font:13.5px -apple-system,Segoe UI,Roboto,sans-serif;margin:26px;color:#1a1a1a;max-width:1280px}}
 h1{{font-size:22px}} h2{{font-size:17px;margin-top:30px;border-bottom:2px solid #eee;padding-bottom:4px}}
 h3{{font-size:15px;margin-top:20px}} code{{font:12px Menlo,monospace;color:#a3306e}}
 table{{border-collapse:collapse;width:100%;margin-top:8px}}
 th,td{{border:1px solid #e3e3e3;padding:6px 9px;text-align:left;vertical-align:top}}
 th{{background:#f5f6f8}} tr:nth-child(even) td{{background:#fafbfc}}
</style>
<h1>{e(t['title'])}  -  {e(m['screen'])}</h1>
<p style="color:#555">{e(t['intro'])}</p>
<h2>1 · {e(t['screen'])}</h2>{shot_html}
<p style="color:#777">Figma {e(', '.join(m.get('figmaNodes',[])))}  -  {e(t['from_export'])}.</p>
{over_html}
<h2>2 · {e(t['summary'])} <span style="font-weight:400;color:#666">  -  {e(counts_line(t, rows))} · {e(cms_line(t, rows))}</span></h2>
<p style="color:#777">{e(t['drift_note'])}</p>
{''.join(summ)}
<h2>3 · {e(t['details'])}</h2>{''.join(det)}
"""


# ---- DOCX (pure stdlib  -  a .docx is a ZIP of OOXML) ----
# No third-party dependency: we emit the minimal Office Open XML package by hand
# (zipfile + XML strings), so the skill stays offline and dependency-free. Word
# resolves the per-language fonts itself; we set bidi/rtl on Arabic cells.

EMU_PER_PX = 9525          # at 96 dpi (914400 EMU / inch)
DOCX_IMG_MAX_W_EMU = 5_000_000   # ~5.5 in wide
DOCX_IMG_MAX_H_EMU = 5_500_000   # ~6 in tall  -  keep portrait phone shots on one landscape page
DV_FILL = {"reuse": "E6F4EA", "review": "FDF3E2", "new": "E8F0FE"}   # mirror the HTML badges
HEAD_FILL = "F5F6F8"


def _png_size(path):
    """(w, h) in px from a PNG header, or None. Stdlib only."""
    import struct
    try:
        with open(path, "rb") as fh:
            head = fh.read(24)
    except OSError:
        return None
    if len(head) < 24 or head[:8] != b"\x89PNG\r\n\x1a\n" or head[12:16] != b"IHDR":
        return None
    return struct.unpack(">II", head[16:24])


def _dxe(s):
    """Escape text for WordprocessingML (same rules as XML)."""
    return html.escape("" if s in (None, "") else str(s), quote=True)


def _run(text, *, bold=False, color=None, rtl=False):
    rpr = []
    if bold:
        rpr.append("<w:b/>")
    if rtl:
        rpr.append("<w:rtl/>")
    if color:
        rpr.append(f'<w:color w:val="{color}"/>')
    rpr = f"<w:rPr>{''.join(rpr)}</w:rPr>" if rpr else ""
    return f'<w:r>{rpr}<w:t xml:space="preserve">{_dxe(text)}</w:t></w:r>'


def _para(runs, *, rtl=False, spacing_after=40):
    if isinstance(runs, str):
        runs = [_run(runs)]
    ppr = [f'<w:spacing w:after="{spacing_after}"/>']
    if rtl:
        ppr.append("<w:bidi/>")
    return f"<w:p><w:pPr>{''.join(ppr)}</w:pPr>{''.join(runs)}</w:p>"


def _heading(text, level=1):
    sz = {1: "32", 2: "26", 3: "22"}.get(level, "24")
    return (f'<w:p><w:pPr><w:spacing w:before="200" w:after="80"/></w:pPr>'
            f'<w:r><w:rPr><w:b/><w:sz w:val="{sz}"/></w:rPr>'
            f'<w:t xml:space="preserve">{_dxe(text)}</w:t></w:r></w:p>')


def _cell(content_para, *, fill=None, width=None):
    tcpr = []
    if width:
        tcpr.append(f'<w:tcW w:w="{width}" w:type="dxa"/>')
    if fill:
        tcpr.append(f'<w:shd w:val="clear" w:color="auto" w:fill="{fill}"/>')
    tcpr = f"<w:tcPr>{''.join(tcpr)}</w:tcPr>" if tcpr else ""
    return f"<w:tc>{tcpr}{content_para}</w:tc>"


def _table(grid_widths, header_cells, body_rows):
    borders = ("<w:tblBorders>"
               + "".join(f'<w:{s} w:val="single" w:sz="4" w:space="0" w:color="D9D9D9"/>'
                         for s in ("top", "left", "bottom", "right", "insideH", "insideV"))
               + "</w:tblBorders>")
    grid = "<w:tblGrid>" + "".join(f'<w:gridCol w:w="{w}"/>' for w in grid_widths) + "</w:tblGrid>"
    tblpr = ('<w:tblPr><w:tblStyle w:val="TableGrid"/><w:tblW w:w="5000" w:type="pct"/>'
             '<w:tblLayout w:type="autofit"/>' + borders + "</w:tblPr>")
    hdr = "<w:tr>" + "".join(header_cells) + "</w:tr>"
    body = "".join("<w:tr>" + "".join(r) + "</w:tr>" for r in body_rows)
    return f"<w:tbl>{tblpr}{grid}{hdr}{body}</w:tbl>"


def _image_para(rid, cx, cy, *, name="screenshot.png", uid=1):
    return (
        '<w:p><w:pPr><w:spacing w:after="120"/></w:pPr><w:r><w:drawing>'
        f'<wp:inline distT="0" distB="0" distL="0" distR="0">'
        f'<wp:extent cx="{cx}" cy="{cy}"/><wp:docPr id="{uid}" name="{_dxe(name)}"/>'
        '<a:graphic xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main">'
        '<a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/picture">'
        '<pic:pic xmlns:pic="http://schemas.openxmlformats.org/drawingml/2006/picture">'
        f'<pic:nvPicPr><pic:cNvPr id="{uid}" name="{_dxe(name)}"/><pic:cNvPicPr/></pic:nvPicPr>'
        f'<pic:blipFill><a:blip r:embed="{rid}"/><a:stretch><a:fillRect/></a:stretch></pic:blipFill>'
        f'<pic:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="{cx}" cy="{cy}"/></a:xfrm>'
        '<a:prstGeom prst="rect"><a:avLst/></a:prstGeom></pic:spPr>'
        '</pic:pic></a:graphicData></a:graphic></wp:inline></w:drawing></w:r></w:p>'
    )


def _docx_document_xml(m, t, *, has_image, img_cx, img_cy, has_overlay=False, ov_cx=0, ov_cy=0,
                       keyshots=None):
    keyshots = keyshots or {}   # {row number: (rid, cx, cy)}  -  per-key screenpieces
    rows = m["rows"]
    body = [_heading(f"{t['title']}  -  {m['screen']}", 1),
            _para([_run(t["intro"], color="555555")]),
            _para([_run(f"{t['platforms']}: ", bold=True), _run(", ".join(m.get("platforms", []))),
                   _run("   ·   Figma: ", bold=True), _run(", ".join(m.get("figmaNodes", [])))])]
    # legend + counts
    leg = []
    for k in VCOLOUR:
        leg.append(_run(f"■ ", color=VCOLOUR.get(k, "808080")))
        leg.append(_run(f"{t['verdict'][k]}  -  {t['legend'][k]}    "))
    body.append(_para(leg))
    body.append(_para([_run(counts_line(t, rows), bold=True)]))

    # section Screen
    body.append(_heading(t["screen"], 2))
    if has_image:
        body.append(_image_para("rId100", img_cx, img_cy))
    else:
        body.append(_para([_run("[screenshot]", color="999999")]))
    if has_overlay:
        body.append(_heading(t["keymap"], 2))
        body.append(_image_para("rId101", ov_cx, ov_cy, name="overlay.png", uid=2))

    # section Summary table
    body.append(_heading(t["summary"], 2))
    cols = t["cols"]
    grid = [600, 2400, 2300, 2800, 1500, 1500, 1500, 1500, 1500, 1500, 1500, 1500, 1500, 1400, 2400]
    head_cells = [_cell(_para([_run(c, bold=True)]), fill=HEAD_FILL) for c in cols]
    body_rows = []
    for i, r in enumerate(rows, 1):
        nw, old = r.get("new", {}) or {}, r.get("legacy", {}) or {}
        vd = r.get("verdict", "")
        if i in keyshots:
            rid, kcx, kcy = keyshots[i]
            ks_cell = _cell(_image_para(rid, kcx, kcy, name=f"keyshot{i}.png", uid=100 + i))
        else:
            ks_cell = _cell(_para("-"))
        cells = [_cell(_para(str(i))),
                 _cell(_para(dash(r.get("element")))),
                 ks_cell,
                 _cell(_para([_run(r.get("newKey"))])),
                 _cell(_para([_run(legdisp(r.get("legacyKeyIOS")))])),
                 _cell(_para([_run(legdisp(r.get("legacyKeyAndroid")))])),
                 _cell(_para([_run(legdisp(r.get("legacyKeyWeb")))]))]
        cells += [_cell(_para(dash(old.get(l)))) for l in SUMMARY_LANGS]
        cells += [_cell(_para(dash(nw.get(l)))) for l in SUMMARY_LANGS]
        cells.append(_cell(_para([_run(cms_disp(r, "tr"))]), fill="FCE4F1" if row_drift(r) else None))
        cells.append(_cell(_para([_run(cms_disp(r, "en"))])))
        cells.append(_cell(_para([_run(vtext(t, vd), bold=True)]), fill=DV_FILL.get(vd)))
        cells.append(_cell(_para(dash(r.get("note")))))
        body_rows.append(cells)
    body.append(_table(grid, head_cells, body_rows))

    # section Details  -  per element, all 8 languages
    body.append(_heading(t["details"], 2))
    for i, r in enumerate(rows, 1):
        nw, old, cms = r.get("new", {}) or {}, r.get("legacy", {}) or {}, cms_of(r)
        vd = r.get("verdict", "")
        body.append(_heading(f"{i}. {r.get('element')}  -  {vtext(t, vd)}", 3))
        body.append(_para([_run(f"{cols[3]} "), _run(r.get("newKey")),
                           _run(f"  ·  {cols[4]} "), _run(legdisp(r.get("legacyKeyIOS"))),
                           _run(f"  ·  {cols[5]} "), _run(legdisp(r.get("legacyKeyAndroid"))),
                           _run(f"  ·  {cols[6]} "), _run(legdisp(r.get("legacyKeyWeb")))]))
        if r.get("note"):
            body.append(_para([_run(r.get("note"), color="777777")]))
        dh = [_cell(_para([_run(c, bold=True)]), fill=HEAD_FILL) for c in t["dcols"]]
        drows = []
        for l in DETAIL_LANGS:
            rtl = l == "ar"
            drows.append([_cell(_para([_run(l, bold=True)])),
                          _cell(_para([_run(dash(old.get(l)), rtl=rtl)], rtl=rtl)),
                          _cell(_para([_run(dash(nw.get(l)), rtl=rtl)], rtl=rtl)),
                          _cell(_para([_run(dash(cms.get(l)), rtl=rtl)], rtl=rtl))])
        body.append(_table([800, 4200, 4200, 4200], dh, drows))

    sect = ('<w:sectPr><w:pgSz w:w="16838" w:h="11906" w:orient="landscape"/>'
            '<w:pgMar w:top="720" w:right="720" w:bottom="720" w:left="720" '
            'w:header="0" w:footer="0" w:gutter="0"/></w:sectPr>')
    return (
        '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
        '<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" '
        'xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" '
        'xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing">'
        f'<w:body>{"".join(body)}{sect}</w:body></w:document>'
    )


def _emu_fit(path, max_w, max_h):
    """Scaled (cx, cy) EMU for a PNG to fit a box, or a safe default if dims unknown."""
    size = _png_size(path)
    if size:
        w, h = size
        nat_w, nat_h = w * EMU_PER_PX, h * EMU_PER_PX
        scale = min(max_w / nat_w, max_h / nat_h, 1.0)
        return int(nat_w * scale), int(nat_h * scale)
    return max_w, max_w


def write_docx(m, t, out_path, screenshot_path=None, overlay_path=None, keyshot_paths=None):
    """Write a self-contained .docx. Paths are absolute file paths or None;
    keyshot_paths is {row number: absolute PNG path} (per-key screenpieces)."""
    import zipfile

    img_bytes = None
    img_cx = img_cy = 0
    if screenshot_path and os.path.exists(screenshot_path):
        with open(screenshot_path, "rb") as fh:
            img_bytes = fh.read()
        img_cx, img_cy = _emu_fit(screenshot_path, DOCX_IMG_MAX_W_EMU, DOCX_IMG_MAX_H_EMU)
    has_image = img_bytes is not None

    # the overlay (key-map) is wide → allow the full landscape width
    ov_bytes = None
    ov_cx = ov_cy = 0
    if overlay_path and os.path.exists(overlay_path):
        with open(overlay_path, "rb") as fh:
            ov_bytes = fh.read()
        ov_cx, ov_cy = _emu_fit(overlay_path, 9_000_000, 5_500_000)
    has_overlay = ov_bytes is not None

    # per-key screenpieces → one image part + rel each (rId200+row); thumbnail-fit
    ks_meta, ks_bytes = {}, {}
    for row, path in (keyshot_paths or {}).items():
        if not (path and os.path.exists(path)):
            continue
        with open(path, "rb") as fh:
            ks_bytes[row] = fh.read()
        cx, cy = _emu_fit(path, 1_900_000, 2_600_000)
        ks_meta[row] = (f"rId{200 + row}", cx, cy)

    content_types = ('<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
                     '<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">'
                     '<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>'
                     '<Default Extension="xml" ContentType="application/xml"/>'
                     '<Default Extension="png" ContentType="image/png"/>'
                     '<Override PartName="/word/document.xml" '
                     'ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>'
                     '</Types>')
    root_rels = ('<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
                 '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
                 '<Relationship Id="rId1" '
                 'Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" '
                 'Target="word/document.xml"/></Relationships>')
    doc_rels_items = ""
    if has_image:
        doc_rels_items += ('<Relationship Id="rId100" '
                           'Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" '
                           'Target="media/screenshot.png"/>')
    if has_overlay:
        doc_rels_items += ('<Relationship Id="rId101" '
                           'Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" '
                           'Target="media/overlay.png"/>')
    for row, (rid, _, _) in sorted(ks_meta.items()):
        doc_rels_items += (f'<Relationship Id="{rid}" '
                           'Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" '
                           f'Target="media/keyshot{row}.png"/>')
    doc_rels = ('<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
                '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
                f'{doc_rels_items}</Relationships>')
    document = _docx_document_xml(m, t, has_image=has_image, img_cx=img_cx, img_cy=img_cy,
                                 has_overlay=has_overlay, ov_cx=ov_cx, ov_cy=ov_cy,
                                 keyshots=ks_meta)

    with zipfile.ZipFile(out_path, "w", zipfile.ZIP_DEFLATED) as z:
        z.writestr("[Content_Types].xml", content_types)
        z.writestr("_rels/.rels", root_rels)
        z.writestr("word/document.xml", document)
        z.writestr("word/_rels/document.xml.rels", doc_rels)
        if has_image:
            z.writestr("word/media/screenshot.png", img_bytes)
        if has_overlay:
            z.writestr("word/media/overlay.png", ov_bytes)
        for row, data in sorted(ks_bytes.items()):
            z.writestr(f"word/media/keyshot{row}.png", data)


# ---- PDF (convert via whatever renderer the machine has  -  no pip, no network) ----
# Multilingual (Arabic + Cyrillic + Latin) PDF needs real font shaping, so we don't
# hand-roll it: we drive an installed converter. Order = best table fidelity first.

def _chrome_candidates():
    env = os.environ.get("CHROME_BIN")
    cands = [env] if env else []
    cands += ["google-chrome", "google-chrome-stable", "chromium", "chromium-browser",
              "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
              "/Applications/Chromium.app/Contents/MacOS/Chromium"]
    return cands


def _which(cmd):
    import shutil
    if os.path.isabs(cmd):
        return cmd if os.path.exists(cmd) else None
    return shutil.which(cmd)


def _run_tool(cmd, timeout):
    """Run a converter, swallowing TimeoutExpired/OSError. Some headless browsers write
    the PDF and then hang on shutdown, so we never trust the return code alone  -  the
    caller checks the output file. Returns the CompletedProcess, or None on error."""
    import subprocess
    try:
        return subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
    except subprocess.TimeoutExpired:
        return None      # process already killed by run(); output may still exist
    except OSError:
        return None


def _nonempty(path):
    return bool(path) and os.path.exists(path) and os.path.getsize(path) > 0


def _run_until_file(cmd, out_path, timeout):
    """Launch cmd and return as soon as out_path is written and its size stops growing  -  
    then kill the process tree. For headless browsers that print the PDF in ~1s but hang
    on shutdown, this avoids blocking on the full timeout. Returns True if out_path exists."""
    import signal
    import subprocess
    import time
    if os.path.exists(out_path):
        os.remove(out_path)
    try:
        proc = subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
                                start_new_session=True)
    except OSError:
        return False
    deadline = time.time() + timeout
    last_size, stable = -1, 0
    try:
        while time.time() < deadline:
            if proc.poll() is not None:           # exited on its own
                break
            if os.path.exists(out_path):
                size = os.path.getsize(out_path)
                stable = stable + 1 if size == last_size and size > 0 else 0
                last_size = size
                if stable >= 3:                   # ~0.45s unchanged → fully written
                    break
            time.sleep(0.15)
    finally:
        if proc.poll() is None:
            try:
                os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
            except (ProcessLookupError, PermissionError):
                proc.terminate()
            try:
                proc.wait(timeout=5)
            except subprocess.TimeoutExpired:
                try:
                    os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
                except (ProcessLookupError, PermissionError):
                    proc.kill()
    return _nonempty(out_path)


def to_pdf(out_pdf, *, docx_path=None, html_path=None):
    """Convert to PDF using the first available renderer. Returns the tool name used,
    or None if no converter is installed (caller prints a skip note)."""
    import tempfile

    # 1) LibreOffice  -  best fidelity for the table-heavy docx.
    soffice = next((_which(c) for c in
                    ("soffice", "libreoffice",
                     "/Applications/LibreOffice.app/Contents/MacOS/soffice") if _which(c)), None)
    if soffice and _nonempty(docx_path):
        with tempfile.TemporaryDirectory() as td:
            _run_tool([soffice, "--headless", "--convert-to", "pdf", "--outdir", td, docx_path], 180)
            produced = os.path.join(td, os.path.splitext(os.path.basename(docx_path))[0] + ".pdf")
            if _nonempty(produced):
                with open(produced, "rb") as src, open(out_pdf, "wb") as dst:
                    dst.write(src.read())
                return "libreoffice"

    # 2) Chrome/Chromium headless  -  html → pdf. Chrome often writes the PDF and then
    #    hangs on exit; _run_tool absorbs the timeout and we judge by the file.
    chrome = next((_which(c) for c in _chrome_candidates() if _which(c)), None)
    if chrome and html_path:
        url = f"file://{os.path.abspath(html_path)}"
        for headless in ("--headless=new", "--headless"):
            with tempfile.TemporaryDirectory() as td:
                ok = _run_until_file([chrome, headless, "--disable-gpu", "--no-sandbox",
                                      f"--user-data-dir={td}", "--no-pdf-header-footer",
                                      "--allow-file-access-from-files",
                                      f"--print-to-pdf={out_pdf}", url], out_pdf, 90)
                if ok:
                    return "chrome"

    # 3) wkhtmltopdf  -  html → pdf.
    wk = _which("wkhtmltopdf")
    if wk and html_path:
        _run_tool([wk, "--enable-local-file-access", "--quiet", html_path, out_pdf], 120)
        if _nonempty(out_pdf):
            return "wkhtmltopdf"

    return None


# ---- UPLOAD HOOK ----
# Live upload now lives in scripts/publish-confluence.py (Server/DC Bearer, idempotent
# update-in-place). --print-upload prints the ready command + the equivalent raw curl.

def upload_curl(slug, m):
    screen = m["screen"]
    over = " --overlay {}.overlay.png".format(slug) if m.get("overlay") else ""
    ks = " --keyshots {}.keyshots.manifest.json".format(slug) if m.get("keyshots") else ""
    return (
        "# LIVE publish (idempotent update-in-place under the localizations parent):\n"
        f"python3 publish-confluence.py --xml {slug}.confluence.xml --screen {json.dumps(screen)} "
        f"--screenshot screenshot.png{over}{ks} --attach {slug}.localization.xlsx\n"
        "#\n"
        "# Equivalent by hand (curl, Server/DC Bearer PAT  -  NOT Cloud user:token).\n"
        "# $CONFLUENCE_BASE_URL / $CONFLUENCE_SPACE / $CONFLUENCE_PARENT are your wiki's target:\n"
        '#   TOKEN=$(security find-generic-password -s CONFLUENCE_API_TOKEN -w)\n'
        '#   curl -sS -H "Authorization: Bearer $TOKEN" -X POST '
        '"$CONFLUENCE_BASE_URL/rest/api/content" \\\n'
        '#     -H "Content-Type: application/json" -d @- <<JSON\n'
        f'#   {{"type":"page","title":{json.dumps(PAGE_TITLE_PREFIX + "-" + screen)},'
        '"space":{"key":"$CONFLUENCE_SPACE"},'
        '"ancestors":[{"id":"$CONFLUENCE_PARENT"}],'
        f'"body":{{"storage":{{"value":"<{slug}.confluence.xml>","representation":"storage"}}}}}}\n#   JSON\n'
        "#   (then attach screenshot.png + the overlay by filename).")


def main():
    ap = argparse.ArgumentParser(description=__doc__)
    ap.add_argument("mapping", help="path to the mapping JSON (- for stdin)")
    ap.add_argument("--out", default=".")
    ap.add_argument("--slug")
    ap.add_argument("--ui-lang", choices=["tr", "en"], default="en", help="document chrome language (default en)")
    ap.add_argument("--docx", action="store_true", help="also write <slug>.docx (Word; stdlib, offline)")
    ap.add_argument("--pdf", action="store_true", help="also write <slug>.pdf (via an installed renderer)")
    ap.add_argument("--all", action="store_true", help="write every format (implies --docx --pdf)")
    ap.add_argument("--print-upload", action="store_true")
    args = ap.parse_args()
    want_docx = args.docx or args.all
    want_pdf = args.pdf or args.all

    src = sys.stdin if args.mapping == "-" else open(args.mapping, encoding="utf-8")
    m = json.load(src)
    if "rows" not in m or "screen" not in m:
        ap.error("mapping JSON needs at least 'screen' and 'rows' (see reference/format-and-output.md)")
    global LEGACY_KEY_PREFIX
    LEGACY_KEY_PREFIX = m.get("legacyKeyPrefix", "") or ""
    t = T[args.ui_lang]
    slug = args.slug or "".join(ch if ch.isalnum() else "-" for ch in m["screen"].lower()).strip("-")
    while "--" in slug:
        slug = slug.replace("--", "-")
    os.makedirs(args.out, exist_ok=True)
    ks_bases = ([os.path.dirname(os.path.abspath(args.mapping))] if args.mapping != "-" else [])
    ks_bases += [os.path.abspath(args.out), os.getcwd()]
    ks_dir = load_keyshots(m, ks_bases)   # fills m["_keyshots"] for the renderers
    files = {f"{slug}.confluence.xml": render_storage(m, t),
             f"{slug}.md": render_markdown(m, t),
             f"{slug}.preview.html": render_html(m, t)}
    for name, content in files.items():
        with open(os.path.join(args.out, name), "w", encoding="utf-8") as fh:
            fh.write(content)
        print("wrote", os.path.join(args.out, name))

    # Resolve a bare filename (screenshot / overlay) to an absolute path for embedding
    # (docx) / loading (pdf): look beside the mapping, then in --out, then cwd.
    def resolve_asset(name):
        if not name:
            return None
        bases = [os.path.dirname(os.path.abspath(args.mapping))] if args.mapping != "-" else []
        bases += [os.path.abspath(args.out), os.getcwd()]
        for b in bases:
            cand = name if os.path.isabs(name) else os.path.join(b, name)
            if os.path.exists(cand):
                return os.path.abspath(cand)
        return None

    shot_abs = resolve_asset(m.get("screenshot"))
    overlay_abs = resolve_asset(m.get("overlay"))

    docx_path = os.path.join(args.out, f"{slug}.docx")
    html_path = os.path.join(args.out, f"{slug}.preview.html")
    ks_abs = ({row: os.path.join(ks_dir, rel) for row, rel in keyshots_of(m).items()}
              if ks_dir else None)
    if want_docx or want_pdf:
        # PDF via LibreOffice needs the docx; build it whenever either format is requested.
        write_docx(m, t, docx_path, screenshot_path=shot_abs, overlay_path=overlay_abs,
                   keyshot_paths=ks_abs)
        if want_docx:
            print("wrote", docx_path)
    if want_pdf:
        pdf_path = os.path.join(args.out, f"{slug}.pdf")
        tool = to_pdf(pdf_path, docx_path=docx_path, html_path=html_path)
        if tool:
            print(f"wrote {pdf_path} (via {tool})")
            # docx was only built as a means to the pdf and wasn't requested → clean it up
            if not want_docx and os.path.exists(docx_path):
                os.remove(docx_path)
        else:
            # leave the docx in place as the fallback we point the user at
            keep = f"{slug}.docx" if (want_docx or os.path.exists(docx_path)) else f"{slug}.preview.html"
            print(f"skipped {slug}.pdf  -  no PDF renderer found "
                  f"(install LibreOffice/Chrome/wkhtmltopdf); share {keep} or {slug}.preview.html instead",
                  file=sys.stderr)

    if args.print_upload:
        print("\n" + upload_curl(slug, m))


if __name__ == "__main__":
    main()
