#!/usr/bin/env python3
"""Turn a reuse-map mapping into the documents that get shared.

Every run produces <slug>.md, the Confluence storage-format body
<slug>.confluence.xml and <slug>.preview.html. A Word file and a PDF are added
on request (--docx, --pdf, or both via --all). Output depends only on the
mapping: no network access and no decisions are made here.
"""
import argparse
import html
import json
import os
import re
import shlex
import shutil
import struct
import subprocess
import sys
import tempfile
import time
import unicodedata
import zipfile
from collections import namedtuple
from xml.sax.saxutils import escape, quoteattr

from _shared import RTL_LANGS, order_langs, parse_langs, slug, xml_text

WARN = "\u26a0\ufe0f"
DASH = "-"
VERDICTS = ("reuse", "review", "new")
PLATFORM_NAMES = ("iOS", "Android", "Web")
LEGACY_KEY_FIELDS = ("legacyKeyIOS", "legacyKeyAndroid", "legacyKeyWeb")
FINAL_COPY = "content team's final copy"


def summary_columns(element, shot, new_key, legacy, old, new, verdict, note):
    return [
        "#", element, shot, new_key,
        *(f"{legacy} {platform}" for platform in PLATFORM_NAMES),
        *(f"{age} {lang}" for age in (old, new) for lang in ("en", "tr")),
        "CMS TR", "CMS EN", verdict, note,
    ]


def detail_columns(lang, old, new):
    return [lang, f"{old} (legacy)", f"{new} (redesign)", "CMS"]


T = {
    "en": {
        "title": "Localization reuse map",
        "intro": (
            "For each UI element, the redesign key and value next to the legacy key per platform, "
            f"the legacy value and the {FINAL_COPY} from Figma. "
            "The verdict is a recommendation; the content team makes the call."
        ),
        "verdict": {v: v.upper() for v in VERDICTS},
        "legend": {
            "reuse": "same function and an equivalent value; adopt the legacy value or key, no new translation",
            "review": "same function but the wording, value or key differs; the content team chooses",
            "new": "no legacy counterpart; mint a key and a value",
        },
        "platforms": "Platforms",
        "figma": "Figma nodes",
        "screen": "Screen",
        "keymap": "Key map",
        "summary": "Summary",
        "details": "Details",
        "elements": "elements",
        "cms_rows": "rows carry CMS copy",
        "cms_drift": "differ from the new value",
        "drift_note": (
            f"A warning mark on a CMS cell means the {FINAL_COPY} differs from the new value. "
            "CMS copy is context for the decision, not an input to the verdict."
        ),
        "no_screenshot": "No screenshot attached.",
        "key": "Key",
        "columns": summary_columns("UI element", "Screenshot", "New key", "Legacy", "Old", "New", "Verdict", "Note"),
        "detail_columns": detail_columns("Lang", "Old", "New"),
    },
    "tr": {
        "title": "Lokalizasyon yeniden kullan\u0131m haritas\u0131",
        "intro": "Her aray\u00fcz \u00f6\u011fesi i\u00e7in yeni anahtar ve de\u011fer; platform baz\u0131nda eski anahtar, eski de\u011fer ve i\u00e7erik ekibinin Figma'daki son metni yan yana. Karar bir \u00f6neridir; son s\u00f6z i\u00e7erik ekibinindir.",
        "verdict": {"reuse": "YEN\u0130DEN KULLAN", "review": "\u0130NCELE", "new": "YEN\u0130"},
        "legend": {
            "reuse": "ayn\u0131 i\u015flev ve e\u015fde\u011fer de\u011fer; eski de\u011fer ya da anahtar kullan\u0131l\u0131r, yeni \u00e7eviri gerekmez",
            "review": "ayn\u0131 i\u015flev ama metin, de\u011fer ya da anahtar farkl\u0131; se\u00e7imi i\u00e7erik ekibi yapar",
            "new": "eski kar\u015f\u0131l\u0131\u011f\u0131 yok; yeni anahtar ve de\u011fer gerekir",
        },
        "platforms": "Platformlar",
        "figma": "Figma node'lar\u0131",
        "screen": "Ekran",
        "keymap": "Anahtar haritas\u0131",
        "summary": "\u00d6zet",
        "details": "Ayr\u0131nt\u0131lar",
        "elements": "\u00f6\u011fe",
        "cms_rows": "sat\u0131rda CMS metni var",
        "cms_drift": "yeni de\u011ferden farkl\u0131",
        "drift_note": "CMS h\u00fccresindeki uyar\u0131 i\u015fareti, i\u00e7erik ekibinin son metninin yeni de\u011ferden farkl\u0131 oldu\u011funu g\u00f6sterir. CMS metni karar i\u00e7in ba\u011flamd\u0131r, karar\u0131n girdisi de\u011fildir.",
        "no_screenshot": "Ekran g\u00f6r\u00fcnt\u00fcs\u00fc eklenmedi.",
        "key": "Anahtar",
        "columns": summary_columns(
            "Aray\u00fcz \u00f6\u011fesi", "Ekran g\u00f6r\u00fcnt\u00fcs\u00fc", "Yeni anahtar",
            "Eski", "Eski", "Yeni", "Karar", "Not",
        ),
        "detail_columns": detail_columns("Dil", "Eski", "Yeni"),
    },
}

Look = namedtuple("Look", "status mark badge shade")
LOOKS = {
    "reuse": Look("Green", "\u2705", "#1e8e3e", "E6F4EA"),
    "review": Look("Yellow", "\U0001f536", "#e37400", "FDF3E2"),
    "new": Look("Blue", "\U0001f195", "#1a73e8", "E8F0FE"),
}
UNKNOWN_LOOK = Look("Grey", "", "#80868b", None)
EMU_PER_PX = 9525
PNG_MAGIC = bytes([0x89]) + b"PNG\r\n\x1a\n"


def look(row):
    return LOOKS.get(row.get("verdict"), UNKNOWN_LOOK)


def norm(value):
    text = unicodedata.normalize("NFC", str(value or "")).strip()
    text = re.sub(r"\s+", " ", text)
    text = re.sub(r"[.:\u2026]+$", "", text).strip()
    return text.lower()


def drifts(cms_value, new_value):
    return bool(cms_value) and bool(new_value) and norm(cms_value) != norm(new_value)


def row_drifts(row):
    cms, new = row.get("cms") or {}, row.get("new") or {}
    return any(drifts(cms.get(lang), new.get(lang)) for lang in ("en", "tr"))


def cms_cell(row, lang):
    value = (row.get("cms") or {}).get(lang) or ""
    if value and drifts(value, (row.get("new") or {}).get(lang)):
        return f"{WARN} {value}"
    return value


def lang_values(row, lang):
    legacy = (row.get("legacy") or {}).get(lang) or ""
    fresh = (row.get("new") or {}).get(lang) or ""
    return legacy, fresh, cms_cell(row, lang)


def legacy_key(value, prefix):
    value = str(value or "").strip()
    if not value:
        return DASH
    if value.startswith("("):
        return value
    return prefix + value


def load_keyshots(mapping, mapping_dir, out_dir):
    manifest_name = mapping.get("keyshots")
    if not manifest_name:
        return {}
    hits = [
        os.path.join(folder, manifest_name)
        for folder in (mapping_dir, out_dir, os.getcwd())
        if os.path.isfile(os.path.join(folder, manifest_name))
    ]
    if not hits:
        print(
            f"# warning: no keyshots manifest called {manifest_name} next to the mapping, "
            "under --out or in the working directory; the Screenshot column is left empty",
            file=sys.stderr,
        )
        return {}
    with open(hits[0], encoding="utf-8") as stream:
        manifest = json.load(stream)
    folder = os.path.dirname(os.path.abspath(hits[0]))
    shots = {}
    for shot in manifest.get("shots") or []:
        if str(shot.get("file", "")).endswith(".png"):
            shots[int(shot["row"])] = os.path.join(folder, shot["file"])
    return shots


def xml_escape(value):
    return escape(xml_text(value))


def node(name, attrs=(), *children):
    opening = name + "".join(f" {k}={quoteattr(xml_text(v))}" for k, v in attrs)
    if not children:
        return f"<{opening}/>"
    return f"<{opening}>{''.join(children)}</{name}>"


def header_cells(labels, esc):
    return "".join(f"<th>{esc(label)}</th>" for label in labels)


def storage_macro(kind, *children):
    return node("ac:structured-macro", [("ac:name", kind)], *children)


def storage_image(filename, dimension, pixels):
    target = node("ri:attachment", [("ri:filename", os.path.basename(filename))])
    return node("ac:image", [(f"ac:{dimension}", pixels)], target)


def mapping_langs(rows):
    found = []
    for row in rows:
        for side in ("new", "legacy", "cms"):
            found.extend((row.get(side) or {}).keys())
    return order_langs(found)


class Doc:
    def __init__(self, mapping, t, prefix, shots, out_dir, langs=None):
        self.m = mapping
        self.t = t
        self.prefix = prefix
        self.shots = shots
        self.out = out_dir
        self.rows = mapping.get("rows") or []
        self.langs = list(langs) if langs else mapping_langs(self.rows)
        self.counts = {v: sum(1 for r in self.rows if r.get("verdict") == v) for v in VERDICTS}
        self.cms_rows = sum(1 for r in self.rows if (r.get("cms") or {}).get("tr") or (r.get("cms") or {}).get("en"))
        self.drift_rows = sum(1 for r in self.rows if row_drifts(r))

    def listed(self, field):
        return ", ".join(self.m.get(field) or []) or DASH

    def verdict(self, row):
        return self.t["verdict"].get(row.get("verdict"), str(row.get("verdict") or "?").upper())

    def count_line(self):
        tally = " / ".join(f"{self.t['verdict'][v]} {self.counts[v]}" for v in VERDICTS)
        return f"{tally} - {len(self.rows)} {self.t['elements']}"

    def cms_line(self):
        return f"CMS: {self.cms_rows} {self.t['cms_rows']}, {self.drift_rows} {self.t['cms_drift']}"

    def summary_values(self, index, row):
        old, new = row.get("legacy") or {}, row.get("new") or {}
        keys = [legacy_key(row.get(field), self.prefix) for field in LEGACY_KEY_FIELDS]
        texts = [side.get(lang) or "" for side in (old, new) for lang in ("en", "tr")]
        return [
            str(index), row.get("element") or "", None, row.get("newKey") or "",
            *keys, *texts,
            cms_cell(row, "tr"), cms_cell(row, "en"), None, row.get("note") or "",
        ]

    def detail_langs(self, row):
        return self.langs + [lang for lang in (row.get("cms") or {}) if lang not in self.langs]

    def md(self):
        t, m = self.t, self.m

        def flat(value):
            return str(value).replace("|", "\\|").replace("\r", " ").replace("\n", " ")

        def table_line(values):
            return "| " + " | ".join(values) + " |"

        lines = [f"# {t['title']}: {m.get('screen')}", "", t["intro"], ""]
        lines.append(f"**{t['platforms']}:** {self.listed('platforms')}  ")
        lines.append(f"**{t['figma']}:** {self.listed('figmaNodes')}")
        lines += ["", f"## {t['screen']}", ""]
        lines.append(f"![{t['screen']}]({m['screenshot']})" if m.get("screenshot") else t["no_screenshot"])
        if m.get("overlay"):
            lines += ["", f"## {t['keymap']}", "", f"![{t['keymap']}]({m['overlay']})"]
        lines += ["", f"## {t['summary']}", "", self.count_line(), "", self.cms_line(), ""]
        lines += [f"- {LOOKS[v].mark} **{t['verdict'][v]}**: {t['legend'][v]}" for v in VERDICTS]
        lines += ["", f"> {WARN} {t['drift_note']}", ""]
        lines.append(table_line(t["columns"]))
        lines.append("|" + "---|" * len(t["columns"]))
        for i, row in enumerate(self.rows, 1):
            values = self.summary_values(i, row)
            shot = self.shots.get(i)
            values[2] = f"![{i}]({os.path.relpath(shot, self.out)})" if shot else DASH
            values[3] = f"`{values[3]}`" if values[3] else DASH
            values[4:7] = [DASH if v == DASH else f"`{v}`" for v in values[4:7]]
            values[13] = f"{look(row).mark} {self.verdict(row)}".strip()
            lines.append(table_line([flat(v) for v in values]))
        lines += ["", f"## {t['details']}", ""]
        for i, row in enumerate(self.rows, 1):
            lines += [f"### {i}. {row.get('element') or ''} - {self.verdict(row)}", ""]
            lines += [f"{t['key']}: `{row.get('newKey') or ''}`", ""]
            lines.append(table_line(t["detail_columns"]))
            lines.append("|---|---|---|---|")
            for lang in self.detail_langs(row):
                lines.append(table_line([lang] + [flat(v) for v in lang_values(row, lang)]))
            lines.append("")
        return "\n".join(lines) + "\n"

    def status(self, row):
        return storage_macro(
            "status",
            node("ac:parameter", [("ac:name", "colour")], look(row).status),
            node("ac:parameter", [("ac:name", "title")], xml_escape(self.verdict(row))),
        )

    def xml(self):
        t, m = self.t, self.m
        e = xml_escape

        def heading(label):
            return f"<h2>{e(t[label])}</h2>"

        def labelled(label, value):
            return f"<strong>{e(t[label])}:</strong> {e(value)}"

        legend = "".join(f"<li>{self.status({'verdict': v})} {e(t['legend'][v])}</li>" for v in VERDICTS)
        facts = " | ".join([
            labelled("screen", str(m.get("screen"))),
            labelled("platforms", self.listed("platforms")),
            labelled("figma", self.listed("figmaNodes")),
        ])
        notes = [t["intro"], self.count_line(), self.cms_line(), WARN + " " + t["drift_note"]]
        info_body = f"<p>{e(notes[0])}</p><ul>{legend}</ul>" + "".join(f"<p>{e(n)}</p>" for n in notes[1:])
        blocks = [
            f"<p>{facts}</p>",
            storage_macro("info", node("ac:rich-text-body", (), info_body)),
            heading("screen"),
        ]
        if m.get("screenshot"):
            blocks.append(f"<p>{storage_image(m['screenshot'], 'height', 500)}</p>")
        else:
            blocks.append(f"<p><em>{e(t['no_screenshot'])}</em></p>")
        if m.get("overlay"):
            blocks += [heading("keymap"), f"<p>{storage_image(m['overlay'], 'width', 900)}</p>"]
        blocks.append(heading("summary"))
        summary = []
        for i, row in enumerate(self.rows, 1):
            cells = []
            for col, value in enumerate(self.summary_values(i, row)):
                if col == 2:
                    shot = self.shots.get(i)
                    inner = storage_image(shot, "width", 240) if shot else DASH
                elif col == 13:
                    inner = self.status(row)
                elif col in (3, 4, 5, 6) and value and value != DASH:
                    inner = f"<code>{e(value)}</code>"
                else:
                    inner = e(value)
                cells.append(f"<td>{inner}</td>")
            summary.append("<tr>" + "".join(cells) + "</tr>")
        blocks.append(f"<table><tbody><tr>{header_cells(t['columns'], e)}</tr>{''.join(summary)}</tbody></table>")
        blocks.append(heading("details"))
        for i, row in enumerate(self.rows, 1):
            blocks.append(f"<h3>{i}. {e(row.get('element') or '')} {self.status(row)}</h3>")
            blocks.append(f"<p>{e(t['key'])}: <code>{e(row.get('newKey') or '')}</code></p>")
            lang_rows = "".join(
                f"<tr><td>{lang}</td>" + "".join(f"<td>{e(v)}</td>" for v in lang_values(row, lang)) + "</tr>"
                for lang in self.detail_langs(row)
            )
            blocks.append(f"<table><tbody><tr>{header_cells(t['detail_columns'], e)}</tr>{lang_rows}</tbody></table>")
        return "\n".join(blocks) + "\n"

    def src(self, path):
        if not path:
            return ""
        return "file://" + path if os.path.isabs(path) else path

    def preview(self):
        t, m = self.t, self.m
        e = html.escape

        def badge(row):
            return f'<span class="badge" style="background:{look(row).badge}">{e(self.verdict(row))}</span>'

        legend = "".join(f"<li>{badge({'verdict': v})} {e(t['legend'][v])}</li>" for v in VERDICTS)
        summary = []
        for i, row in enumerate(self.rows, 1):
            shot = self.shots.get(i)
            cells = []
            for col, value in enumerate(self.summary_values(i, row)):
                if col == 2:
                    inner = f'<img class="shot" src="{e(self.src(shot))}">' if shot else DASH
                elif col == 13:
                    inner = badge(row)
                elif col in (3, 4, 5, 6):
                    inner = f"<code>{e(value or DASH)}</code>"
                else:
                    inner = e(value)
                cells.append(f"<td>{inner}</td>")
            summary.append("<tr>" + "".join(cells) + "</tr>")
        details = []
        for i, row in enumerate(self.rows, 1):
            lang_rows = "".join(
                f"<tr><td>{lang}</td>" + "".join(f"<td>{e(v)}</td>" for v in lang_values(row, lang)) + "</tr>"
                for lang in self.detail_langs(row)
            )
            details.append(
                f"<h3>{i}. {e(row.get('element') or '')} {badge(row)}</h3>"
                f"<p>{e(t['key'])}: <code>{e(row.get('newKey') or '')}</code></p>"
                f"<table><tr>{header_cells(t['detail_columns'], e)}</tr>{lang_rows}</table>"
            )
        if m.get("screenshot"):
            screen = f'<img class="screen" src="{e(self.src(m.get("screenshot")))}">'
        else:
            screen = f"<p>{e(t['no_screenshot'])}</p>"
        overlay = ""
        if m.get("overlay"):
            overlay = f'<h3>{e(t["keymap"])}</h3><img class="overlay" src="{e(self.src(m.get("overlay")))}">'
        small = "11px"
        style = (
            "body{font-family:-apple-system,Helvetica,Arial,sans-serif;margin:24px;color:#202124;}"
            "table{border-collapse:collapse;margin:8px 0 16px;font-size:12px;}"
            "th,td{border:1px solid #dadce0;padding:4px 6px;vertical-align:top;text-align:left;}"
            f"th{{background:#f5f6f8;}}code{{font-size:{small};}}"
            f".badge{{color:#fff;border-radius:4px;padding:1px 6px;font-size:{small};font-weight:600;}}"
            ".screen{max-height:500px;}.overlay{max-width:100%;}.shot{width:200px;}"
        )
        caption = f"{e(t['title'])}: {e(str(m.get('screen')))}"
        sections = [
            (t["screen"], screen + overlay),
            (t["summary"], f"<table><tr>{header_cells(t['columns'], e)}</tr>{''.join(summary)}</table>"),
            (t["details"], "".join(details)),
        ]
        numbered = "".join(f"<h2>{n}. {e(title)}</h2>{content}" for n, (title, content) in enumerate(sections, 1))
        return (
            f'<!doctype html><html><head><meta charset="utf-8"><title>{caption}</title>'
            f"<style>{style}</style></head><body>"
            f"<h1>{caption}</h1><p>{e(t['intro'])}</p>"
            f"<p><b>{e(t['platforms'])}:</b> {e(self.listed('platforms'))} | "
            f"<b>{e(t['figma'])}:</b> {e(self.listed('figmaNodes'))}</p>"
            f"<ul>{legend}</ul><p>{e(self.count_line())}<br>{e(self.cms_line())}</p><p>{e(WARN + ' ' + t['drift_note'])}</p>"
            f"{numbered}</body></html>\n"
        )


def png_size(path):
    try:
        with open(path, "rb") as stream:
            head = stream.read(24)
    except OSError:
        return None
    if head.startswith(PNG_MAGIC):
        return struct.unpack(">II", head[16:24])
    return None


def fit(size, max_w, max_h):
    w, h = size[0] * EMU_PER_PX, size[1] * EMU_PER_PX
    ratio = min(max_w / w, max_h / h, 1.0)
    return int(w * ratio), int(h * ratio)


OOXML = "http://schemas.openxmlformats.org/"
NS_DRAWING = OOXML + "drawingml/2006/"
NS_DOC_REL = OOXML + "officeDocument/2006/relationships"
XML_DECL = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
MIME_OOXML = "application/vnd.openxmlformats-"


def prefixed(ns):
    def build(tag, *children, **attrs):
        return node(f"{ns}:{tag}", [(f"{ns}:{k}", v) for k, v in attrs.items()], *children)
    return build


w = prefixed("w")


def plain(ns):
    def build(tag, *children, **attrs):
        return node(f"{ns}:{tag}", list(attrs.items()), *children)
    return build


a, pic, wp = plain("a"), plain("pic"), plain("wp")


class Docx:
    def __init__(self):
        self.media = []

    def image(self, path, rid, max_w, max_h, number):
        size = png_size(path)
        if not size:
            return ""
        cx, cy = fit(size, max_w, max_h)
        media_name = f"image{number}.png"
        self.media.append((rid, path, media_name))
        extent = {"cx": cx, "cy": cy}
        origin = {"x": 0, "y": 0}
        shape = pic(
            "spPr",
            a("xfrm", a("off", **origin), a("ext", **extent)),
            a("prstGeom", a("avLst"), prst="rect"),
        )
        fill = pic("blipFill", node("a:blip", [("r:embed", rid)]), a("stretch", a("fillRect")))
        identity = pic("nvPicPr", pic("cNvPr", id=number, name=media_name), pic("cNvPicPr"))
        picture = node("pic:pic", [("xmlns:pic", NS_DRAWING + "picture")], identity, fill, shape)
        graphic = node(
            "a:graphic", [("xmlns:a", NS_DRAWING + "main")],
            a("graphicData", picture, uri=NS_DRAWING + "picture"),
        )
        inline = wp("inline", wp("extent", **extent), wp("docPr", id=number, name=f"Picture {number}"), graphic)
        return w("r", w("drawing", inline))


def para(text, bold=False, size=None, rtl=False):
    styling = []
    if bold:
        styling.append(w("b"))
    if size:
        styling.append(w("sz", val=size))
    if rtl:
        styling.append(w("rtl"))
    run = [w("rPr", *styling)] if styling else []
    run.append(node("w:t", [("xml:space", "preserve")], xml_escape(text)))
    lead = [w("pPr", w("bidi"))] if rtl else []
    return w("p", *lead, w("r", *run))


def tcell(content, fill=None, raw=False, rtl=False):
    inner = content if raw else para(content, rtl=rtl)
    shading = [w("tcPr", w("shd", val="clear", color="auto", fill=fill))] if fill else []
    return w("tc", *shading, inner or w("p"))


EDGES = ("top", "left", "bottom", "right") + tuple("inside" + axis for axis in "HV")


def table(rows_xml):
    lines = "".join(w(edge, val="single", sz=4, color="DADCE0") for edge in EDGES)
    props = w("tblPr", w("tblW", w=0, type="auto"), w("tblBorders", lines))
    return w("tbl", props, *rows_xml)


def local_file(doc, name):
    if not name:
        return None
    if os.path.isabs(name):
        return name if os.path.isfile(name) else None
    for base in (doc.base, doc.out):
        candidate = os.path.join(base, name)
        if os.path.isfile(candidate):
            return candidate
    return None


def table_row(cells):
    return "<w:tr>" + "".join(cells) + "</w:tr>"


def heading(text):
    return para(text, bold=True, size=28)


def summary_table(doc, dx, t):
    lines = [table_row(tcell(c, fill="F5F6F8") for c in t["columns"])]
    for i, row in enumerate(doc.rows, 1):
        cells = []
        for col, value in enumerate(doc.summary_values(i, row)):
            if col == 2:
                shot_file = doc.shots.get(i)
                drawing = dx.image(shot_file, f"rId{200 + i}", 1_900_000, 2_600_000, 200 + i) if shot_file else ""
                cells.append(tcell(f"<w:p>{drawing}</w:p>" if drawing else para(DASH), raw=True))
            elif col == 13:
                cells.append(tcell(doc.verdict(row), fill=look(row).shade))
            elif col == 11:
                tr_drift = drifts((row.get("cms") or {}).get("tr"), (row.get("new") or {}).get("tr"))
                cells.append(tcell(value, fill="FCE4F1" if tr_drift else None))
            else:
                cells.append(tcell(value))
        lines.append(table_row(cells))
    return table(lines)


def detail_blocks(doc, t):
    blocks = []
    for i, row in enumerate(doc.rows, 1):
        blocks.append(para(f"{i}. {row.get('element') or ''} - {doc.verdict(row)}", bold=True))
        blocks.append(para(f"{t['key']}: {row.get('newKey') or ''}"))
        lines = [table_row(tcell(c, fill="F5F6F8") for c in t["detail_columns"])]
        for lang in doc.detail_langs(row):
            lines.append(table_row(tcell(v, rtl=lang in RTL_LANGS) for v in (lang, *lang_values(row, lang))))
        blocks.append(table(lines))
    return blocks


def page_setup():
    margins = dict.fromkeys(("top", "right", "bottom", "left"), 720)
    return w(
        "sectPr",
        w("pgSz", w=16838, h=11906, orient="landscape"),
        w("pgMar", **margins, header=360, footer=360, gutter=0),
    )


def docx_parts(document, media):
    image_rels = "".join(
        node("Relationship", [("Id", rid), ("Type", NS_DOC_REL + "/image"), ("Target", f"media/{name}")])
        for rid, _, name in media
    )
    defaults = [("rels", MIME_OOXML + "package.relationships+xml"), ("xml", "application/xml"), ("png", "image/png")]
    types = "".join(node("Default", [("Extension", ext), ("ContentType", kind)]) for ext, kind in defaults)
    types += node("Override", [
        ("PartName", "/word/document.xml"),
        ("ContentType", MIME_OOXML + "officedocument.wordprocessingml.document.main+xml"),
    ])
    root_rel = node("Relationship", [
        ("Id", "rId1"), ("Type", NS_DOC_REL + "/officeDocument"), ("Target", "word/document.xml"),
    ])
    rels_ns = f'<Relationships xmlns="{OOXML}package/2006/relationships">'
    return [
        ("[Content_Types].xml", f'{XML_DECL}<Types xmlns="{OOXML}package/2006/content-types">{types}</Types>'),
        ("_rels/.rels", f"{XML_DECL}{rels_ns}{root_rel}</Relationships>"),
        ("word/_rels/document.xml.rels", f"{XML_DECL}{rels_ns}{image_rels}</Relationships>"),
        ("word/document.xml", document),
    ]


def build_docx(doc, path):
    t, m = doc.t, doc.m
    dx = Docx()
    body = [
        para(f"{t['title']}: {m.get('screen')}", bold=True, size=32),
        para(t["intro"]),
        para(f"{t['platforms']}: {doc.listed('platforms')} | {t['figma']}: {doc.listed('figmaNodes')}"),
    ]
    body += [para(f"{t['verdict'][v]}: {t['legend'][v]}") for v in VERDICTS]
    body += [para(doc.count_line()), para(doc.cms_line()), para(f"{WARN} {t['drift_note']}")]
    body.append(heading(t["screen"]))
    screenshot = local_file(doc, m.get("screenshot"))
    if screenshot:
        body.append(w("p", dx.image(screenshot, "rId100", 5_000_000, 5_500_000, 100)))
    else:
        body.append(para(t["no_screenshot"]))
    overlay = local_file(doc, m.get("overlay"))
    if overlay:
        body += [heading(t["keymap"]), w("p", dx.image(overlay, "rId101", 9_000_000, 5_500_000, 101))]
    body += [heading(t["summary"]), summary_table(doc, dx, t), heading(t["details"])]
    body += detail_blocks(doc, t)
    namespaces = [
        ("xmlns:w", OOXML + "wordprocessingml/2006/main"),
        ("xmlns:r", NS_DOC_REL),
        ("xmlns:wp", NS_DRAWING + "wordprocessingDrawing"),
    ]
    document = XML_DECL + node("w:document", namespaces, w("body", *body, page_setup()))
    with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as archive:
        for member, text in docx_parts(document, dx.media):
            archive.writestr(member, text)
        for _, source, media_name in dx.media:
            archive.write(source, f"word/media/{media_name}")


def mac_app(bundle, binary=None):
    return f"/Applications/{bundle}.app/Contents/MacOS/{binary or bundle}"


BROWSER_APPS = [mac_app("Google Chrome"), mac_app("Chromium"), mac_app("Microsoft Edge")]
BROWSER_COMMANDS = (
    [f"google-chrome{tail}" for tail in ("", "-stable")]
    + [f"chromium{tail}" for tail in ("", "-browser")]
    + ["chrome"]
)


def chrome_candidates():
    listed = [os.environ.get("CHROME_BIN")] + BROWSER_APPS
    found = [path for path in listed if path and os.path.isfile(path)]
    found += [hit for hit in map(shutil.which, BROWSER_COMMANDS) if hit]
    return found


def pdf_libreoffice(docx_path, out_dir):
    binaries = [shutil.which("soffice"), shutil.which("libreoffice"), mac_app("LibreOffice", "soffice")]
    name = os.path.splitext(os.path.basename(docx_path))[0] + ".pdf"
    for binary in binaries:
        if not (binary and os.path.isfile(binary)):
            continue
        with tempfile.TemporaryDirectory() as scratch:
            try:
                subprocess.run([binary, "--headless", "--convert-to", "pdf", "--outdir", scratch, docx_path],
                               capture_output=True, timeout=180)
            except (OSError, subprocess.SubprocessError):
                continue
            rendered = os.path.join(scratch, name)
            if os.path.isfile(rendered) and os.path.getsize(rendered) > 0:
                target = os.path.join(out_dir, name)
                shutil.copyfile(rendered, target)
                return target
    return None


def settle(proc, path, limit):
    deadline = time.time() + limit
    previous = -1
    while time.time() < deadline and proc.poll() is None:
        current = os.path.getsize(path) if os.path.exists(path) else -1
        if current > 0 and current == previous:
            break
        previous = current
        time.sleep(1)
    if proc.poll() is None:
        proc.kill()
        proc.wait()


def pdf_chrome(preview_path, pdf_path):
    page = "file://" + os.path.abspath(preview_path)
    for chrome in chrome_candidates():
        for headless in ("--headless=new", "--headless"):
            with tempfile.TemporaryDirectory() as profile:
                if os.path.exists(pdf_path):
                    os.remove(pdf_path)
                argv = [chrome, headless, "--no-sandbox", "--disable-gpu", f"--user-data-dir={profile}", "--no-pdf-header-footer",
                        f"--print-to-pdf={pdf_path}", page]
                try:
                    proc = subprocess.Popen(argv, stderr=subprocess.DEVNULL, stdout=subprocess.DEVNULL)
                except OSError:
                    break
                settle(proc, pdf_path, 90)
                if os.path.isfile(pdf_path) and os.path.getsize(pdf_path) > 0:
                    return pdf_path
    return None


def pdf_wkhtml(preview_path, pdf_path):
    binary = shutil.which("wkhtmltopdf")
    if not binary:
        return None
    try:
        subprocess.run([binary, "--enable-local-file-access", preview_path, pdf_path], capture_output=True, timeout=120)
    except (OSError, subprocess.SubprocessError):
        return None
    return pdf_path if os.path.isfile(pdf_path) and os.path.getsize(pdf_path) > 0 else None


def upload_path(name, mapping_dir, out_dir):
    if os.path.isabs(name):
        return name
    for folder in (mapping_dir, out_dir):
        if os.path.exists(os.path.join(folder, name)):
            return os.path.join(folder, name)
    return os.path.join(out_dir, name)


def print_upload(name, mapping, screen, mapping_dir, out_dir):
    prefix = os.environ.get("LOCALIZATION_PAGE_PREFIX", "Localization")
    quote = shlex.quote
    flags = [f"--xml {quote(os.path.join(out_dir, name + '.confluence.xml'))}", f"--screen {quote(str(screen))}"]
    flags += [
        f"--{field} {quote(upload_path(str(mapping[field]), mapping_dir, out_dir))}"
        for field in ("screenshot", "overlay", "keyshots") if mapping.get(field)
    ]
    flags.append(f"--attach {quote(os.path.join(out_dir, name + '.localization.xlsx'))}")
    print(" \\\n  ".join(["python3 scripts/publish-confluence.py"] + flags))
    print()
    title = json.dumps(f"{prefix} - {screen}", ensure_ascii=False).replace("'", "'\\''")
    print("# The same call by hand, for Server/DC with a Bearer PAT read from the keychain (argv never sees the token):")
    print("#   (umask 077 && rm -f auth.curlrc && printf 'header = \"Authorization: Bearer %s\"\\n' \"$(security find-generic-password -s CONFLUENCE_API_TOKEN -w)\" > auth.curlrc)")
    print("#   curl -sS -K auth.curlrc -X POST -H 'Content-Type: application/json' \"$CONFLUENCE_BASE_URL/rest/api/content\" \\")
    print(f"#     --data '{{\"type\":\"page\",\"title\":{title},\"space\":{{\"key\":\"'$CONFLUENCE_SPACE'\"}},"
          "\"ancestors\":[{\"id\":\"'$CONFLUENCE_PARENT'\"}],\"body\":{\"storage\":{\"value\":\"...\",\"representation\":\"storage\"}}}'")
    print("#   rm auth.curlrc")


def read_mapping(source):
    if source == "-":
        return json.load(sys.stdin), os.getcwd()
    with open(source, encoding="utf-8") as stream:
        return json.load(stream), os.path.dirname(os.path.abspath(source))


def make_pdf(docx_path, out_dir, stem, keep_docx):
    preview = os.path.join(out_dir, f"{stem}.preview.html")
    target = os.path.join(out_dir, f"{stem}.pdf")
    made = pdf_libreoffice(docx_path, out_dir) or pdf_chrome(preview, target) or pdf_wkhtml(preview, target)
    if not made:
        fallback = docx_path if os.path.exists(docx_path) else preview
        print(f"# no PDF: LibreOffice, Chrome and wkhtmltopdf are all missing; hand out {fallback} instead", file=sys.stderr)
        return
    print(f"wrote {made}")
    if not keep_docx and os.path.exists(docx_path):
        os.remove(docx_path)


def main():
    cli = argparse.ArgumentParser(description=__doc__.splitlines()[0])
    cli.add_argument("mapping", help="reuse-map JSON file; pass - to read it from stdin")
    cli.add_argument("--out", dest="target_dir", default=".", help="folder that receives the generated files")
    cli.add_argument("--slug", help="stem for the file names; derived from the screen name when omitted")
    cli.add_argument("--ui-lang", choices=["en", "tr"], default="en", help="language used for headings, the legend and labels")
    cli.add_argument("--docx", action="store_true", help="add a Word document to the outputs")
    cli.add_argument("--pdf", action="store_true", help="add a PDF to the outputs")
    cli.add_argument("--all", action="store_true", help="shorthand for --docx plus --pdf")
    cli.add_argument("--print-upload", action="store_true", help="show the publish command and a hand-rolled curl version of it")
    cli.add_argument(
        "--langs", default="all",
        help="languages of the details tables, comma list in display order; 'all' (default) takes every language "
             "in the mapping, en first then the rest sorted",
    )
    opts = cli.parse_args()

    mapping, mapping_dir = read_mapping(opts.mapping)
    if not isinstance(mapping, dict) or "screen" not in mapping or "rows" not in mapping:
        cli.error("mapping must be a JSON object with screen and rows")

    stem = opts.slug or slug(mapping.get("screen"))
    out_dir = os.path.abspath(opts.target_dir)
    os.makedirs(out_dir, exist_ok=True)
    shots = load_keyshots(mapping, mapping_dir, out_dir)
    langs = None if opts.langs.strip() == "all" else parse_langs(opts.langs)
    doc = Doc(mapping, T[opts.ui_lang], mapping.get("legacyKeyPrefix") or "", shots, out_dir, langs)
    doc.base = mapping_dir

    renderers = [("md", doc.md), ("confluence.xml", doc.xml), ("preview.html", doc.preview)]
    for suffix, render in renderers:
        target = os.path.join(out_dir, f"{stem}.{suffix}")
        with open(target, "w", encoding="utf-8") as sink:
            sink.write(render())
        print(f"wrote {target}")

    keep_docx = opts.docx or opts.all
    need_pdf = opts.pdf or opts.all
    docx_path = os.path.join(out_dir, f"{stem}.docx")
    if keep_docx or need_pdf:
        build_docx(doc, docx_path)
        if keep_docx:
            print(f"wrote {docx_path}")
    if need_pdf:
        make_pdf(docx_path, out_dir, stem, keep_docx)

    if opts.print_upload:
        print_upload(stem, mapping, mapping.get("screen"), mapping_dir, out_dir)


if __name__ == "__main__":
    main()
