#!/usr/bin/env python3
"""Build docs/parity-gallery.html pairing iOS + Android goldens per screen.

The output is a single self-contained HTML file that gitea's raw-file view
can render directly. Relative <img> paths resolve within the repo so the
images load without any webserver.

Variant matrix (driven by the canonical `GalleryScreens` manifest in
`composeApp/src/commonMain/.../testing/GalleryManifest.kt`):

  DataCarrying surfaces — 4 PNGs per platform:
      <screen>_empty_light.png      <screen>_empty_dark.png
      <screen>_populated_light.png  <screen>_populated_dark.png

  DataFree surfaces — 2 PNGs per platform:
      <screen>_light.png            <screen>_dark.png

Category is detected per-screen from which filename pattern exists on
either platform. Missing captures render as explicit
`[missing — android]` / `[missing — ios]` placeholder boxes so the gap
is visible and actionable, rather than silently omitted.

iOS snapshots live under
    iosApp/HoneyDueTests/__Snapshots__/SnapshotGalleryTests/
with swift-snapshot-testing's `test_<func>.<named>.png` format which we
strip to align with Android's plain `<name>.png`.

Usage: python3 scripts/build_parity_gallery.py
"""
from __future__ import annotations
import glob
import html
import os
import re

REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
ANDROID_DIR = "composeApp/src/androidUnitTest/roborazzi"
IOS_DIR = "iosApp/HoneyDueTests/__Snapshots__/SnapshotGalleryTests"
OUT_HTML = "docs/parity-gallery.html"
OUT_MD = "docs/parity-gallery-grid.md"
MANIFEST_KT = "composeApp/src/commonMain/kotlin/com/tt/honeyDue/testing/GalleryManifest.kt"

# swift-snapshot-testing names files "test_<func>.<name>.png".
IOS_NAME_RE = re.compile(r"^test_[^.]+\.(.+)\.png$")

# Stale golden patterns from prior test configurations — filter out so
# they don't clutter the gallery.
STALE_SUFFIX_RE = re.compile(
    r"_(default|midnight|ocean)_(light|dark)$|_(actual|compare)$"
)

# Kotlin manifest row: `GalleryScreen("name", GalleryCategory.X, platformsSet),`
MANIFEST_ROW_RE = re.compile(
    r'GalleryScreen\(\s*"(?P<name>\w+)"\s*,\s*'
    r'GalleryCategory\.(?P<category>\w+)\s*,\s*'
    r'(?P<platforms>\w+)\s*\)',
)


def canonical_name(platform: str, path: str) -> str | None:
    """Return the canonical key or None if unparseable / stale."""
    base = os.path.basename(path)
    if platform == "ios":
        m = IOS_NAME_RE.match(base)
        if not m:
            return None
        key = m.group(1)
    else:
        if not base.endswith(".png"):
            return None
        key = base[:-4]
    # Drop stale theme-named and compare-artifact files.
    if STALE_SUFFIX_RE.search(key):
        return None
    return key


def load(platform: str, directory: str) -> dict[str, str]:
    out: dict[str, str] = {}
    full = os.path.join(REPO_ROOT, directory)
    if not os.path.isdir(full):
        return out
    for p in glob.glob(f"{full}/**/*.png", recursive=True):
        key = canonical_name(platform, p)
        if key is None:
            continue
        out[key] = os.path.relpath(p, os.path.join(REPO_ROOT, "docs"))
    return out


def load_manifest() -> list[tuple[str, str, set[str]]]:
    """Parse `GalleryManifest.kt` → list of (name, category, platforms).

    Source of truth for which screens exist, their category, and which
    platforms are expected to capture them. The Kotlin manifest is
    itself CI-gated against both platforms' tests, so any drift surfaces
    there before reaching the gallery builder.
    """
    path = os.path.join(REPO_ROOT, MANIFEST_KT)
    if not os.path.isfile(path):
        raise SystemExit(
            f"Canonical manifest not found at {MANIFEST_KT}. "
            "Run this script from the MyCribKMM repo root."
        )
    with open(path, encoding="utf-8") as f:
        text = f.read()
    rows: list[tuple[str, str, set[str]]] = []
    for m in MANIFEST_ROW_RE.finditer(text):
        name = m.group("name")
        category = m.group("category")
        platforms_ident = m.group("platforms")
        # Resolve the platforms-set identifier to platform names. The
        # manifest declares `both`, `androidOnly`, `iosOnly` as locals
        # inside `GalleryScreens`; we mirror that mapping here.
        platforms = {
            "both": {"android", "ios"},
            "androidOnly": {"android"},
            "iosOnly": {"ios"},
        }.get(platforms_ident)
        if platforms is None:
            raise SystemExit(
                f"Unknown platforms identifier '{platforms_ident}' in manifest"
            )
        rows.append((name, category, platforms))
    if not rows:
        raise SystemExit("No screens parsed from manifest — regex drift?")
    return rows


def rows_for(category: str) -> list[tuple[str, str]]:
    """Return the list of (state_label, filename_suffix) for a category."""
    if category == "DataCarrying":
        return [
            ("empty / light", "empty_light"),
            ("empty / dark", "empty_dark"),
            ("populated / light", "populated_light"),
            ("populated / dark", "populated_dark"),
        ]
    return [
        ("light", "light"),
        ("dark", "dark"),
    ]


def placeholder(platform: str, screen: str, suffix: str, expected: bool) -> str:
    """HTML for a visible placeholder box.

    `expected=True` → screen is in the manifest for this platform but the
      PNG is missing (red border, action needed).
    `expected=False` → screen is explicitly not-on-this-platform per the
      manifest (muted border, no action needed).
    """
    if expected:
        cls = "missing missing-needed"
        label = f"[missing — {platform}]"
    else:
        cls = "missing missing-platform"
        label = f"not on {platform}"
    return (
        f"<div class='{cls}'>{label}<br>"
        f"<span class='hint'>{html.escape(screen)}_{html.escape(suffix)}.png</span></div>"
    )


def write_html(
    android: dict[str, str],
    ios: dict[str, str],
    manifest: list[tuple[str, str, set[str]]],
) -> None:
    out_path = os.path.join(REPO_ROOT, OUT_HTML)
    os.makedirs(os.path.dirname(out_path), exist_ok=True)

    # Preserve manifest order so the HTML reads product-flow top-to-bottom.
    screen_names = [name for name, _, _ in manifest]
    category_by_name = {name: cat for name, cat, _ in manifest}
    platforms_by_name = {name: plats for name, _, plats in manifest}

    total_pngs_android = len(android)
    total_pngs_ios = len(ios)
    data_carrying = sum(1 for _, c, _ in manifest if c == "DataCarrying")
    data_free = sum(1 for _, c, _ in manifest if c == "DataFree")

    with open(out_path, "w", encoding="utf-8") as f:
        f.write(PAGE_HEAD)
        f.write(
            f"<div class='meta'>"
            f"{len(screen_names)} screens · "
            f"{data_carrying} DataCarrying · {data_free} DataFree · "
            f"{total_pngs_android} Android PNGs · {total_pngs_ios} iOS PNGs"
            f"</div>\n"
        )
        f.write("<div class='nav'>")
        f.write(
            " ".join(
                f"<a href='#{html.escape(s)}'>{html.escape(s)}</a>"
                for s in screen_names
            )
        )
        f.write("</div>\n")
        f.write(
            "<div class='grid-header'>"
            "<div class='label'></div>"
            "<div>Android</div>"
            "<div>iOS</div>"
            "</div>\n"
        )

        for screen in screen_names:
            category = category_by_name[screen]
            plats = platforms_by_name[screen]
            f.write(f"<div class='screen' id='{html.escape(screen)}'>\n")
            f.write(
                f"  <h2>{html.escape(screen)} "
                f"<span class='badge badge-{category.lower()}'>{category}</span>"
            )
            if plats != {"android", "ios"}:
                only = "Android" if "android" in plats else "iOS"
                f.write(f" <span class='badge badge-only'>{only}-only</span>")
            f.write("</h2>\n")
            for state_label, suffix in rows_for(category):
                key = f"{screen}_{suffix}"
                a_rel = android.get(key)
                i_rel = ios.get(key)
                a_expected = "android" in plats
                i_expected = "ios" in plats
                a_cell = (
                    f"<img loading='lazy' src='{html.escape(a_rel)}' alt='{key} Android'/>"
                    if a_rel
                    else placeholder("android", screen, suffix, a_expected)
                )
                i_cell = (
                    f"<img loading='lazy' src='{html.escape(i_rel)}' alt='{key} iOS'/>"
                    if i_rel
                    else placeholder("ios", screen, suffix, i_expected)
                )
                f.write(
                    f"  <div class='row'>"
                    f"<div class='label'>{html.escape(state_label)}</div>"
                    f"{a_cell}{i_cell}"
                    f"</div>\n"
                )
            f.write("</div>\n")
        f.write(PAGE_FOOT)

    print(
        f"wrote {OUT_HTML}: "
        f"{len(screen_names)} screens ({data_carrying} DC + {data_free} DF) · "
        f"{total_pngs_android} Android · {total_pngs_ios} iOS"
    )


def write_markdown(
    android: dict[str, str],
    ios: dict[str, str],
    manifest: list[tuple[str, str, set[str]]],
) -> None:
    """Gitea-renderable grid as markdown tables.

    Images are emitted as raw `<img>` tags with explicit `width` and
    `height` attributes rather than markdown `![]()` syntax. Gitea's
    markdown renderer (goldmark + bluemonday) strips inline `style`
    attributes, but keeps the `width`/`height` HTML attributes. Forcing
    both dimensions guarantees identical cell sizes regardless of the
    underlying PNG resolution (Android is 360×800 @1x, iOS is 780×1688
    @2x; without this, row heights would shift a few percent per
    platform and break side-by-side comparisons).
    """
    # Fixed display size for every image cell. Kept at a ~9:19.5 aspect
    # ratio (modern phone proportions). Width chosen to fit two tall
    # portrait screens side-by-side in a typical Gitea markdown pane.
    img_w, img_h = 260, 560

    out = os.path.join(REPO_ROOT, OUT_MD)
    os.makedirs(os.path.dirname(out), exist_ok=True)

    def img_tag(src: str, alt: str) -> str:
        return (
            f'<img src="{html.escape(src)}" alt="{html.escape(alt)}" '
            f'width="{img_w}" height="{img_h}">'
        )

    with open(out, "w", encoding="utf-8") as f:
        f.write("# honeyDue parity gallery\n\n")
        f.write(
            f"*{len(manifest)} screens · {len(android)} Android · {len(ios)} iOS*\n\n"
        )
        f.write(
            "Auto-generated by `scripts/build_parity_gallery.py` — do not hand-edit.\n\n"
        )
        f.write("See [parity-gallery.md](parity-gallery.md) for the workflow guide.\n\n")
        f.write("## Screens\n\n")
        for name, category, plats in manifest:
            tag = ""
            if plats != {"android", "ios"}:
                only = "Android" if "android" in plats else "iOS"
                tag = f" — *{only}-only*"
            f.write(f"- [{name}](#{name.replace('_', '-')}) *({category})*{tag}\n")
        f.write("\n---\n\n")
        for name, category, plats in manifest:
            anchor = name.replace("_", "-")
            f.write(f"## {name} *({category})*<a id='{anchor}'></a>\n\n")
            f.write("| State / Mode | Android | iOS |\n")
            f.write("|---|---|---|\n")
            for state_label, suffix in rows_for(category):
                key = f"{name}_{suffix}"
                a = android.get(key)
                i = ios.get(key)
                a_cell = img_tag(a, f"{key} Android") if a else (
                    "_\\[missing — android\\]_" if "android" in plats else "_(not on android)_"
                )
                i_cell = img_tag(i, f"{key} iOS") if i else (
                    "_\\[missing — ios\\]_" if "ios" in plats else "_(not on ios)_"
                )
                f.write(f"| **{state_label}** | {a_cell} | {i_cell} |\n")
            f.write("\n[top](#honeydue-parity-gallery)\n\n---\n\n")
    print(f"wrote {OUT_MD}")


def main() -> int:
    manifest = load_manifest()
    android = load("android", ANDROID_DIR)
    ios = load("ios", IOS_DIR)
    write_html(android, ios, manifest)
    write_markdown(android, ios, manifest)
    return 0


PAGE_HEAD = """<!DOCTYPE html>
<html lang='en'><head><meta charset='utf-8'>
<title>honeyDue parity gallery</title>
<style>
  :root { color-scheme: dark; }
  body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
         background: #0d1117; color: #e6edf3; margin: 0; padding: 20px; }
  h1 { margin: 0 0 4px; font-size: 20px; }
  .meta { color: #8b949e; font-size: 13px; margin-bottom: 16px; }
  .nav { position: sticky; top: 0; background: #0d1117; padding: 8px 0; margin-bottom: 16px;
         border-bottom: 1px solid #30363d; font-size: 12px; z-index: 10; }
  .nav a { color: #79c0ff; margin-right: 10px; text-decoration: none; white-space: nowrap; }
  .nav a:hover { text-decoration: underline; }
  .grid-header { display: grid; grid-template-columns: 140px 1fr 1fr; gap: 12px;
                 padding: 0 12px 8px; color: #8b949e; font-size: 12px; font-weight: 600;
                 position: sticky; top: 38px; background: #0d1117; z-index: 9;
                 border-bottom: 1px solid #30363d; }
  .screen { background: #161b22; border-radius: 8px; padding: 12px; margin-bottom: 20px; }
  .screen h2 { margin: 0 0 8px; font-size: 16px; color: #e6edf3; display: flex; align-items: center; gap: 8px; }
  .badge { font-size: 11px; font-weight: 600; padding: 2px 8px; border-radius: 10px; }
  .badge-datacarrying { background: #0d5d56; color: #7ee2d1; }
  .badge-datafree { background: #30363d; color: #8b949e; }
  .row { display: grid; grid-template-columns: 140px 1fr 1fr; gap: 12px;
         margin-bottom: 8px; align-items: start; }
  .label { font-size: 12px; color: #c9d1d9; padding-top: 4px; }
  /* Force every screenshot — Android and iOS — into the same display box.
     Native capture sizes differ (Android 360×800 @1x, iOS 390×844 @2x) so
     without a forced aspect-ratio + object-fit the row heights shift by a
     few percent per platform, making side-by-side comparisons noisy. */
  .row img { width: 100%; aspect-ratio: 9 / 19.5; object-fit: contain;
             background: #0d1117; border: 1px solid #30363d; border-radius: 4px;
             display: block; }
  .missing { display: flex; flex-direction: column; align-items: center; justify-content: center;
             aspect-ratio: 9 / 19.5; width: 100%;
             background: #21262d; border-radius: 4px;
             font-size: 13px; font-weight: 600; padding: 8px; box-sizing: border-box; }
  .missing.missing-needed { border: 2px dashed #f85149; color: #f85149; }
  .missing.missing-platform { border: 1px solid #30363d; color: #8b949e; }
  .missing .hint { color: #6e7681; font-size: 10px; font-weight: 400;
                   margin-top: 6px; font-family: ui-monospace, monospace; }
  .badge-only { background: #484f58; color: #c9d1d9; }
</style></head><body>
<h1>honeyDue parity gallery</h1>
"""

PAGE_FOOT = """<script>
// Ctrl/Cmd-F friendly: expose screen names in the document title on anchor change.
window.addEventListener('hashchange', () => {
  const s = location.hash.slice(1);
  document.title = s ? `${s} · parity` : 'honeyDue parity gallery';
});
</script>
</body></html>
"""


if __name__ == "__main__":
    raise SystemExit(main())
