#!/usr/bin/env python3
"""Build a review page for map animation source-offset hypotheses.

This report intentionally does not replace the palette redraw conclusion in
map_animation_tile_review.  It is a visual/audit aid for the alternate idea
that some layer1 0x40 regions may select source tiles by shifting the layer0
source footprint.
"""
from __future__ import annotations

import html
import json
import sys
from collections import Counter
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parent))

from decode_cns import decompress_cns, parse_image  # noqa: E402


ROOT = Path(__file__).resolve().parents[1]
OUT = ROOT / "out"
EXTRACT_FLD = ROOT / "extract_fld"
TILE_SIZE = 16


def esc(value: object) -> str:
    return html.escape(str(value))


def load_tileset_indices(stem: str) -> tuple[int, int, list[list[int]]]:
    decoded = decompress_cns((EXTRACT_FLD / f"{stem}.cns").read_bytes())
    width, height, _palette, pixels, bpp = parse_image(decoded)
    stride = ((width * bpp + 31) // 32) * 4
    rows: list[list[int]] = []
    for y in range(height):
        row = pixels[(height - 1 - y) * stride : (height - y) * stride]
        if bpp == 8:
            rows.append(list(row[:width]))
        elif bpp == 4:
            values = []
            for byte in row[: (width + 1) // 2]:
                values.append(byte >> 4)
                if len(values) < width:
                    values.append(byte & 0x0F)
            rows.append(values)
        else:
            raise ValueError(f"unsupported tileset bpp: {bpp}")
    return width, height, rows


def block_stats(index_rows: list[list[int]], x: int, y: int, w: int, h: int) -> dict:
    counts: Counter[int] = Counter()
    for py in range(y * TILE_SIZE, (y + h) * TILE_SIZE):
        for px in range(x * TILE_SIZE, (x + w) * TILE_SIZE):
            counts[index_rows[py][px]] += 1
    nonzero = Counter({key: value for key, value in counts.items() if key != 0})
    return {
        "nonzeroPixels": sum(nonzero.values()),
        "paletteIndexCount": len(nonzero),
        "topPaletteIndices": [
            {"index": index, "pixels": count}
            for index, count in nonzero.most_common(8)
        ],
    }


def same_row_block_candidates(component: dict, columns: int, rows: int, index_rows: list[list[int]]) -> list[dict]:
    block = component.get("sourceBlock")
    if not block:
        return []
    width = block["width"]
    height = block["height"]
    source_y = block["sourceY"]
    base_x = block["sourceX"]
    if width <= 0 or height <= 0 or source_y + height > rows:
        return []
    step = width if columns % width == 0 else 1
    candidates = []
    for source_x in range(0, columns - width + 1, step):
        stats = block_stats(index_rows, source_x, source_y, width, height)
        if stats["nonzeroPixels"] <= 0 and source_x != base_x:
            continue
        candidates.append(
            {
                "sourceX": source_x,
                "sourceY": source_y,
                "width": width,
                "height": height,
                "baseTile": source_y * columns + source_x,
                "deltaTiles": source_x - base_x,
                "deltaPixels": (source_x - base_x) * TILE_SIZE,
                "isCurrent": source_x == base_x,
                **stats,
            }
        )
    return candidates


def unique_row_patterns(component: dict) -> list[dict]:
    counter: Counter[tuple[int | None, ...]] = Counter()
    first_y: dict[tuple[int | None, ...], int] = {}
    for row in component.get("rows", []):
        pattern = tuple(row["tiles"])
        counter[pattern] += 1
        first_y.setdefault(pattern, row["y"])

    patterns = []
    for pattern, count in counter.most_common():
        tiles = [tile for tile in pattern if tile is not None]
        contiguous = bool(tiles) and all(tiles[index] == tiles[0] + index for index in range(len(tiles)))
        columns = sorted({tile % 40 for tile in tiles}) if tiles else []
        rows = sorted({tile // 40 for tile in tiles}) if tiles else []
        patterns.append(
            {
                "firstMapY": first_y[pattern],
                "repeatCount": count,
                "width": len(pattern),
                "tiles": list(pattern),
                "contiguous": contiguous,
                "sourceX": tiles[0] % 40 if contiguous and tiles else None,
                "sourceY": tiles[0] // 40 if contiguous and tiles else None,
                "sourceColumns": columns,
                "sourceRows": rows,
            }
        )
    return patterns


def row_shift_candidates(pattern: dict, columns: int, rows: int, index_rows: list[list[int]]) -> list[dict]:
    if not pattern.get("contiguous"):
        return []
    width = pattern["width"]
    source_y = pattern["sourceY"]
    base_x = pattern["sourceX"]
    if source_y is None or base_x is None or width <= 0:
        return []
    step = width if columns % width == 0 else 1
    candidates = []
    for source_x in range(0, columns - width + 1, step):
        stats = block_stats(index_rows, source_x, source_y, width, 1)
        if stats["nonzeroPixels"] <= 0 and source_x != base_x:
            continue
        candidates.append(
            {
                "sourceX": source_x,
                "sourceY": source_y,
                "width": width,
                "height": 1,
                "baseTile": source_y * columns + source_x,
                "deltaTiles": source_x - base_x,
                "deltaPixels": (source_x - base_x) * TILE_SIZE,
                "isCurrent": source_x == base_x,
                **stats,
            }
        )
    return candidates


def classify_component(component: dict, row_patterns: list[dict], block_candidates: list[dict]) -> dict:
    if component.get("sourceBlock") and len(block_candidates) >= 2:
        return {
            "class": "rectangular-offset-plausible",
            "confidence": "visual-review-needed",
            "reason": "The flagged placement is one rectangular source block, and same-row shifted blocks of equal size exist.",
        }
    contiguous_patterns = [pattern for pattern in row_patterns if pattern.get("contiguous")]
    if contiguous_patterns and len(contiguous_patterns) == len(row_patterns):
        return {
            "class": "row-offset-or-row-cycle-plausible",
            "confidence": "visual-review-needed",
            "reason": "The region is built from repeated contiguous source rows; shifted row candidates can be inspected, but this is not one full rect.",
        }
    if contiguous_patterns:
        return {
            "class": "mixed-row-offset-candidate",
            "confidence": "weak",
            "reason": "Some rows are contiguous source rows, while others are repeated/filler patterns. A single shift rule is unlikely.",
        }
    return {
        "class": "offset-shift-unproven",
        "confidence": "weak",
        "reason": "The flagged region does not map to a rectangular source block or clean contiguous source-row patterns.",
    }


def build_review() -> dict:
    source = json.loads((OUT / "map_animation_tile_review.json").read_text(encoding="utf-8"))
    tileset_cache: dict[str, tuple[int, int, list[list[int]]]] = {}
    maps = []
    for row in source["maps"]:
        tileset = row["sourceTileset"]
        if tileset not in tileset_cache:
            tileset_cache[tileset] = load_tileset_indices(tileset)
        width_px, height_px, index_rows = tileset_cache[tileset]
        columns = width_px // TILE_SIZE
        source_rows = height_px // TILE_SIZE
        components = []
        for number, component in enumerate(row["placementComponents"], start=1):
            block_candidates = same_row_block_candidates(component, columns, source_rows, index_rows)
            patterns = unique_row_patterns(component)
            for pattern in patterns:
                pattern["shiftCandidates"] = row_shift_candidates(pattern, columns, source_rows, index_rows)
            classification = classify_component(component, patterns, block_candidates)
            components.append(
                {
                    "componentIndex": number,
                    "mapBounds": {
                        "x0": component["x0"],
                        "y0": component["y0"],
                        "x1": component["x1"],
                        "y1": component["y1"],
                        "width": component["width"],
                        "height": component["height"],
                        "cellCount": component["cellCount"],
                    },
                    "sourceBlock": component.get("sourceBlock"),
                    "blockShiftCandidates": block_candidates,
                    "rowPatterns": patterns,
                    **classification,
                }
            )
        maps.append(
            {
                "map": row["map"],
                "sourceTileset": tileset,
                "tilesetColumns": columns,
                "tilesetRows": source_rows,
                "animatedCellCount": row["animatedCellCount"],
                "components": components,
            }
        )
    counts = Counter(component["class"] for row in maps for component in row["components"])
    return {
        "version": 1,
        "kind": "hwanse-map-animation-offset-shift-hypothesis-review",
        "source": "out/map_animation_tile_review.json",
        "summary": {
            "mapCount": len(maps),
            "componentCount": sum(len(row["components"]) for row in maps),
            "classes": dict(sorted(counts.items())),
        },
        "notes": [
            "This page tests an alternate offset-shift hypothesis for layer1 0x40 map animation.",
            "It does not prove runtime tile replacement. The EXE-grounded consumer still shows 0x40 as dirty redraw and palette paths are still real.",
            "A candidate here means the current source footprint has same-size or same-row visual neighbors that could be selected by an offset.",
            "Use this with map_review tile selection to manually compare the map cell and source tileset positions.",
        ],
        "maps": maps,
    }


def render_candidate_canvas(row: dict, candidate: dict, class_name: str = "") -> str:
    current = " current" if candidate.get("isCurrent") else ""
    return (
        f"<span class=\"candidate{current}\">"
        f"<canvas class=\"source-block-canvas {esc(class_name)}\" "
        f"data-tileset=\"{esc(row['sourceTileset'])}\" "
        f"data-block-x=\"{candidate['sourceX']}\" "
        f"data-block-y=\"{candidate['sourceY']}\" "
        f"data-block-w=\"{candidate['width']}\" "
        f"data-block-h=\"{candidate['height']}\"></canvas>"
        f"<code>#{candidate['baseTile']}</code>"
        f"<span class=\"muted\">dx {candidate['deltaTiles']} · nz {candidate['nonzeroPixels']}</span>"
        "</span>"
    )


def render_block_candidates(row: dict, component: dict) -> str:
    candidates = component.get("blockShiftCandidates") or []
    if not candidates:
        return "<p class=\"muted\">No rectangular same-row block-shift candidates.</p>"
    rendered = "".join(render_candidate_canvas(row, candidate, "block") for candidate in candidates)
    return f"<div class=\"candidate-row\">{rendered}</div>"


def render_pattern(row: dict, pattern: dict) -> str:
    tiles = " ".join("-" if tile is None else f"#{tile}" for tile in pattern["tiles"][:24])
    if len(pattern["tiles"]) > 24:
        tiles += " ..."
    candidates = pattern.get("shiftCandidates") or []
    candidate_html = (
        f"<div class=\"candidate-row\">{''.join(render_candidate_canvas(row, candidate, 'row') for candidate in candidates)}</div>"
        if candidates
        else "<p class=\"muted\">No clean row-shift candidates.</p>"
    )
    return (
        "<details class=\"pattern-detail\">"
        f"<summary>row pattern y={pattern['firstMapY']} · repeat {pattern['repeatCount']} · "
        f"{'contiguous' if pattern['contiguous'] else 'non-contiguous'}</summary>"
        f"<p><code>{esc(tiles)}</code></p>"
        f"{candidate_html}"
        "</details>"
    )


def render_component(row: dict, component: dict) -> str:
    bounds = component["mapBounds"]
    block = component.get("sourceBlock")
    block_text = (
        f"<code>({block['sourceX']},{block['sourceY']}) {block['width']}x{block['height']}</code>"
        if block
        else "<span class=\"muted\">not rectangular</span>"
    )
    patterns = "".join(render_pattern(row, pattern) for pattern in component["rowPatterns"][:12])
    if len(component["rowPatterns"]) > 12:
        patterns += f"<p class=\"muted\">+{len(component['rowPatterns']) - 12} more row patterns omitted.</p>"
    return (
        "<section class=\"component-card\">"
        f"<h3>region {component['componentIndex']} · "
        f"<code>({bounds['x0']},{bounds['y0']})-({bounds['x1']},{bounds['y1']})</code> "
        f"{bounds['width']}x{bounds['height']} · {bounds['cellCount']} cells</h3>"
        f"<p><span class=\"tag\">{esc(component['class'])}</span> "
        f"<span class=\"muted\">{esc(component['confidence'])}</span></p>"
        f"<p>{esc(component['reason'])}</p>"
        f"<p>current source block: {block_text}</p>"
        "<details open><summary>same-row block shift candidates</summary>"
        f"{render_block_candidates(row, component)}"
        "</details>"
        "<details><summary>row pattern shift candidates</summary>"
        f"{patterns}"
        "</details>"
        "</section>"
    )


def write_html(review: dict) -> None:
    rows = []
    for row in review["maps"]:
        components = "".join(render_component(row, component) for component in row["components"])
        rows.append(
            "<section class=\"map-card\">"
            f"<h2><a href=\"../web/map_review.html?map={esc(row['map'])}&amp;visualFilter=animation\"><code>{esc(row['map'])}</code></a> "
            f"<span class=\"muted\">{esc(row['sourceTileset'])}</span></h2>"
            f"<p>{row['animatedCellCount']} animated cells · tileset {row['tilesetColumns']}x{row['tilesetRows']} tiles</p>"
            f"{components}"
            "</section>"
        )
    classes = ", ".join(f"{key}: {value}" for key, value in review["summary"]["classes"].items())
    notes = "".join(f"<li>{esc(note)}</li>" for note in review["notes"])
    doc = f"""<!doctype html>
<html lang="ko">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <link rel="icon" href="../favicon.ico">
  <title>Map animation offset-shift hypothesis</title>
  <style>
    body {{ margin: 0; padding: 18px; font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: #f6f7f9; color: #17202a; }}
    h1 {{ margin: 0 0 6px; font-size: 24px; }}
    h2 {{ margin: 0 0 8px; font-size: 18px; }}
    h3 {{ margin: 10px 0 6px; font-size: 14px; }}
    a {{ color: #185abc; text-decoration: none; }}
    a:hover {{ text-decoration: underline; }}
    code {{ background: #f1f5f9; border-radius: 4px; padding: 1px 4px; }}
    .muted {{ color: #607080; font-size: 12px; }}
    .panel, .map-card, .component-card {{ background: #fff; border: 1px solid #d8dee6; border-radius: 8px; padding: 14px; margin: 14px 0; }}
    .component-card {{ background: #fbfdff; }}
    .tag {{ display: inline-block; border: 1px solid #d8dee6; border-radius: 999px; padding: 1px 7px; font-size: 12px; color: #344050; background: #f8fafc; }}
    details {{ margin: 10px 0; }}
    summary {{ cursor: pointer; color: #185abc; font-weight: 700; }}
    .candidate-row {{ display: flex; flex-wrap: wrap; gap: 8px; align-items: flex-start; margin: 8px 0; }}
    .candidate {{ display: inline-grid; justify-items: center; gap: 3px; border: 1px solid #d8dee6; border-radius: 6px; padding: 6px; background: #fff; }}
    .candidate.current {{ border-color: #0f766e; box-shadow: 0 0 0 2px rgba(15, 118, 110, 0.14); }}
    .source-block-canvas {{ image-rendering: pixelated; background: repeating-conic-gradient(#ddd 0 25%, #f8fafc 0 50%) 0 / 12px 12px; border: 1px solid #cbd5e1; max-width: 240px; }}
    .pattern-detail {{ border-top: 1px solid #e2e8f0; padding-top: 8px; }}
  </style>
</head>
<body>
  <h1>Map animation offset-shift hypothesis</h1>
  <p class="muted">Classes: {esc(classes or '-')}</p>
  <section class="panel"><ul>{notes}</ul></section>
  {''.join(rows)}
  <script src="../web/engine/cns/renderer.js"></script>
  <script>
    async function drawSourceBlockPreview(canvas) {{
      const source = await window.HWANSE_CNS_RENDERER.loadImageCanvas(canvas.dataset.tileset);
      const tileSize = 16;
      const scale = Number(canvas.dataset.blockH) > 8 ? 1 : 2;
      const blockX = Number(canvas.dataset.blockX);
      const blockY = Number(canvas.dataset.blockY);
      const blockW = Number(canvas.dataset.blockW);
      const blockH = Number(canvas.dataset.blockH);
      canvas.width = blockW * tileSize * scale;
      canvas.height = blockH * tileSize * scale;
      const ctx = canvas.getContext("2d");
      ctx.imageSmoothingEnabled = false;
      ctx.clearRect(0, 0, canvas.width, canvas.height);
      ctx.drawImage(source, blockX * tileSize, blockY * tileSize, blockW * tileSize, blockH * tileSize, 0, 0, canvas.width, canvas.height);
    }}
    for (const canvas of document.querySelectorAll(".source-block-canvas")) {{
      drawSourceBlockPreview(canvas).catch((error) => {{
        const ctx = canvas.getContext("2d");
        ctx.fillStyle = "#fee2e2";
        ctx.fillRect(0, 0, canvas.width || 80, canvas.height || 32);
        console.error(error);
      }});
    }}
  </script>
</body>
</html>
"""
    (OUT / "map_animation_shift_hypothesis_review.html").write_text(doc, encoding="utf-8")


def main() -> None:
    OUT.mkdir(exist_ok=True)
    review = build_review()
    (OUT / "map_animation_shift_hypothesis_review.json").write_text(
        json.dumps(review, ensure_ascii=False, indent=2),
        encoding="utf-8",
    )
    write_html(review)


if __name__ == "__main__":
    main()
