#!/usr/bin/env python3
"""Summarize where WLK and MLK audio display titles come from."""
from __future__ import annotations

import argparse
import html
import json
import struct
from collections import Counter
from pathlib import Path
from typing import Any

from audio_archive_manifest import load_mlk_archive, load_wlk_archive


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

MLK_USAGE_LABELS = {
    "00": "title bgm",
    "01": "map bgm",
    "08": "battle bgm",
}

def read_varlen(data: bytes, offset: int) -> tuple[int, int]:
    value = 0
    for _ in range(4):
        if offset >= len(data):
            raise ValueError("unterminated MIDI variable-length value")
        byte = data[offset]
        offset += 1
        value = (value << 7) | (byte & 0x7F)
        if not byte & 0x80:
            return value, offset
    return value, offset


def decode_text(payload: bytes) -> str:
    for encoding in ("cp932", "shift_jis", "utf-8"):
        try:
            text = payload.decode(encoding)
            break
        except UnicodeDecodeError:
            continue
    else:
        text = payload.decode("cp932", errors="replace")
    return text.replace("\x00", "").strip()


def parse_midi_track_names(payload: bytes) -> dict[str, Any]:
    if payload[:4] != b"MThd":
        raise ValueError("MIDI payload does not start with MThd")
    header_size = struct.unpack_from(">I", payload, 4)[0]
    header = payload[8:8 + header_size]
    if len(header) < 6:
        raise ValueError("short MIDI header")
    fmt, track_count, division = struct.unpack_from(">HHH", header, 0)
    offset = 8 + header_size
    names: list[str] = []
    track_name_events: list[dict[str, Any]] = []
    tracks_seen = 0
    for _track_index in range(track_count):
        if payload[offset:offset + 4] != b"MTrk":
            raise ValueError(f"missing MTrk at 0x{offset:x}")
        track_size = struct.unpack_from(">I", payload, offset + 4)[0]
        offset += 8
        end = offset + track_size
        running_status: int | None = None
        tracks_seen += 1
        while offset < end:
            _delta, offset = read_varlen(payload, offset)
            if offset >= end:
                break
            status_offset = offset
            status = payload[offset]
            if status & 0x80:
                offset += 1
                if status < 0xF0:
                    running_status = status
            elif running_status is not None:
                status = running_status
            else:
                raise ValueError("MIDI running status without previous channel status")

            if status == 0xFF:
                if offset >= end:
                    break
                meta_type = payload[offset]
                offset += 1
                length, offset = read_varlen(payload, offset)
                meta_payload = payload[offset:offset + length]
                offset += length
                if meta_type == 0x03:
                    text = decode_text(meta_payload)
                    track_name_events.append(
                        {
                            "offset": status_offset,
                            "offsetHex": f"0x{status_offset:04X}",
                            "length": length,
                            "text": text,
                            "empty": not bool(text),
                        }
                    )
                    if text:
                        names.append(text)
                if meta_type == 0x2F:
                    break
            elif status in (0xF0, 0xF7):
                length, offset = read_varlen(payload, offset)
                offset += length
            else:
                event_type = status & 0xF0
                data_len = 1 if event_type in (0xC0, 0xD0) else 2
                if payload[offset - 1] & 0x80:
                    offset += data_len
                else:
                    offset += data_len - 1
        offset = end
    return {
        "format": fmt,
        "trackCount": tracks_seen,
        "division": division,
        "trackNames": names,
        "trackNameEvents": track_name_events,
    }


def extract_web_game_block(root: Path, name: str) -> str:
    path = root / "web" / "game.html"
    if not path.exists():
        return ""
    text = path.read_text(encoding="utf-8")
    marker = f"const {name} = {{"
    start = text.find(marker)
    if start < 0:
        return ""
    start = text.find("{", start)
    if start < 0:
        return ""
    depth = 0
    for index in range(start, len(text)):
        char = text[index]
        if char == "{":
            depth += 1
        elif char == "}":
            depth -= 1
            if depth == 0:
                return text[start + 1:index]
    return ""


def extract_web_game_wlk_cues(root: Path) -> list[dict[str, Any]]:
    block = extract_web_game_block(root, "SOUND_ASSETS")
    rows: list[dict[str, Any]] = []
    for line in block.splitlines():
        stripped = line.strip().rstrip(",")
        if not stripped or ":" not in stripped:
            continue
        key, value = stripped.split(":", 1)
        key = key.strip()
        value = value.strip().strip('"')
        if not value.startswith("../extract_wlk/") or not value.endswith(".wav"):
            continue
        exe_id = Path(value).stem
        if not exe_id.isdigit():
            continue
        rows.append(
            {
                "key": key,
                "exeId": exe_id,
                "file": f"extract_wlk/{exe_id}.wav",
                "source": "web/game.html SOUND_ASSETS browser cue key; EXE id",
                "isArchiveTitle": False,
            }
        )
    return rows


def extract_web_game_mlk_cues(root: Path) -> list[dict[str, Any]]:
    block = extract_web_game_block(root, "MUSIC_CUES")
    rows: list[dict[str, Any]] = []
    for line in block.splitlines():
        stripped = line.strip().rstrip(",")
        if not stripped or ":" not in stripped or "../extract_mlk/" not in stripped:
            continue
        cue_name = stripped.split(":", 1)[0].strip()
        key = cue_name
        key_marker = 'key: "'
        src_marker = 'src: "../extract_mlk/'
        index_marker = "index: "
        source_marker = 'source: "'
        if key_marker in stripped:
            key = stripped.split(key_marker, 1)[1].split('"', 1)[0]
        exe_id = stripped.split(src_marker, 1)[1].split(".mid", 1)[0]
        source = "MIDDATA.MLK"
        if source_marker in stripped:
            source = stripped.split(source_marker, 1)[1].split('"', 1)[0]
        index = int(exe_id)
        if index_marker in stripped:
            index = int(stripped.split(index_marker, 1)[1].split(",", 1)[0].strip())
        rows.append(
            {
                "key": key,
                "exeId": exe_id,
                "file": f"extract_mlk/{exe_id}.mid",
                "source": f"web/game.html MUSIC_CUES browser cue key; archive={source}; EXE id",
                "isArchiveTitle": False,
            }
        )
    return rows


def summarize_wlk(path: Path) -> dict[str, Any]:
    manifest = load_wlk_archive(path)
    count = manifest["count"]
    entries: list[dict[str, Any]] = []
    flag_counts: Counter[str] = Counter()
    bit_depth_counts: Counter[str] = Counter()
    loop_count = 0
    for source in manifest["entries"]:
        entry_id = source["exeId"]
        bit_depth = source["bitDepth"]
        loop = source["loop"]
        label = f"WLK id {entry_id}"
        label_source = "EXE id fallback"
        flag_counts[source["flagsHex"].lower().replace("x", "x")] += 1
        bit_depth_counts[f"{bit_depth}-bit"] += 1
        if loop:
            loop_count += 1
        entries.append(
            {
                "id": entry_id,
                "exeIndex": source["exeIndex"],
                "exeId": source["exeId"],
                "archive": "PCMDATA.WLK",
                "file": f"extract_wlk/{entry_id}.wav",
                "statusHex": source["statusHex"].lower(),
                "flagsHex": source["flagsHex"].lower(),
                "offset": source["offset"],
                "offsetHex": source["offsetHex"],
                "payloadBytes": source["payloadBytes"],
                "sampleRate": source["sampleRate"],
                "bitDepth": bit_depth,
                "channels": source["channels"],
                "gain": source["gain"],
                "gainRaw": source["gainRaw"],
                "loop": loop,
                "durationSeconds": source["durationSeconds"],
                "embeddedTitle": None,
                "titleSourceLabel": "no embedded WLK name",
                "displayLabel": label,
                "displayLabelSource": label_source,
            }
        )
    embedded_count = sum(1 for entry in entries if entry["embeddedTitle"])
    browser_cue_label_count = 0
    fallback_label_count = len(entries)
    return {
        "archive": "PCMDATA.WLK",
        "path": str(path.relative_to(ROOT)),
        "magic": manifest["magic"],
        "count": count,
        "archiveFlagsHex": manifest["archiveFlagsHex"].lower(),
        "entrySizeBytes": manifest["entrySizeBytes"],
        "titleStorage": "absent",
        "embeddedTitleCount": embedded_count,
        "browserCueLabelCount": browser_cue_label_count,
        "exeIdLabelCount": fallback_label_count,
        "fallbackLabelCount": fallback_label_count,
        "flagCounts": dict(sorted(flag_counts.items())),
        "bitDepthCounts": dict(sorted(bit_depth_counts.items())),
        "loopCount": loop_count,
        "entries": entries,
    }


def summarize_mlk(path: Path) -> dict[str, Any]:
    manifest = load_mlk_archive(path)
    data = path.read_bytes()
    count = manifest["count"]
    table_end = manifest["bodyOffset"]
    entries: list[dict[str, Any]] = []
    flag_counts: Counter[str] = Counter()
    for source in manifest["entries"]:
        index = source["exeIndex"]
        offset = source["offset"]
        size = source["payloadBytes"]
        payload = data[offset:offset + size]
        midi = parse_midi_track_names(payload)
        entry_id = f"{index:02d}"
        embedded_title = midi["trackNames"][0] if midi["trackNames"] else None
        usage_label = MLK_USAGE_LABELS.get(entry_id)
        label = embedded_title or usage_label or f"MLK id {entry_id}"
        title_source = "embedded MIDI title" if embedded_title else "no embedded MIDI title"
        label_source = "embedded MIDI title" if embedded_title else ("browser cue usage label" if usage_label else "EXE id fallback")
        flag_counts[source["entryFlagHex"].lower()] += 1
        entries.append(
            {
                "id": entry_id,
                "exeIndex": source["exeIndex"],
                "exeId": source["exeId"],
                "archive": "MIDDATA.MLK",
                "file": f"extract_mlk/{entry_id}.mid",
                "offset": offset,
                "offsetHex": source["offsetHex"],
                "payloadBytes": size,
                "entryFlagHex": source["entryFlagHex"].lower(),
                "format": midi["format"],
                "trackCount": midi["trackCount"],
                "division": midi["division"],
                "embeddedTrackNames": midi["trackNames"],
                "embeddedTrackNameEvents": midi["trackNameEvents"],
                "embeddedTrackNameOffsetHex": midi["trackNameEvents"][0]["offsetHex"] if midi["trackNameEvents"] else None,
                "embeddedTitle": embedded_title,
                "usageLabel": usage_label,
                "titleSourceLabel": title_source,
                "displayLabel": label,
                "displayLabelSource": label_source,
            }
        )
    embedded_count = sum(1 for entry in entries if entry["embeddedTitle"])
    usage_count = sum(1 for entry in entries if entry["usageLabel"])
    fallback_count = sum(1 for entry in entries if entry["displayLabelSource"] == "EXE id fallback")
    return {
        "archive": "MIDDATA.MLK",
        "path": str(path.relative_to(ROOT)),
        "count": count,
        "tableOffset": manifest["tableOffset"],
        "bodyOffset": table_end,
        "entrySizeBytes": manifest["entrySizeBytes"],
        "titleStorage": "standard MIDI track-name meta events",
        "embeddedTitleCount": embedded_count,
        "noEmbeddedTitleCount": count - embedded_count,
        "browserCueUsageLabelCount": usage_count,
        "fallbackLabelCount": fallback_count,
        "noEmbeddedTitleIds": [entry["id"] for entry in entries if not entry["embeddedTitle"]],
        "entryFlagCounts": dict(sorted(flag_counts.items())),
        "entries": entries,
    }


def build_summary(root: Path = ROOT) -> dict[str, Any]:
    wlk = summarize_wlk(root / "PCMDATA.WLK")
    mlk = summarize_mlk(root / "MIDDATA.MLK")
    wlk_runtime_cues = extract_web_game_wlk_cues(root)
    mlk_runtime_cues = extract_web_game_mlk_cues(root)
    wlk["browserRuntimeCueCount"] = len(wlk_runtime_cues)
    wlk["browserRuntimeCues"] = wlk_runtime_cues
    mlk["browserRuntimeCueCount"] = len(mlk_runtime_cues)
    mlk["browserRuntimeCues"] = mlk_runtime_cues
    return {
        "source": "audio title provenance",
        "conclusion": (
            "PCMDATA.WLK has no embedded effect-name storage, so WLK viewer labels "
            "stay as EXE id entries instead of inferred effect names; "
            "WLK browser cue names from web/game.html are provenance only, not titles. "
            "MIDDATA.MLK stores titles only where the contained MIDI has a track-name meta event."
        ),
        "wlk": wlk,
        "mlk": mlk,
        "displayLabelContract": {
            "wlkFirstLine": "WLK id NN plus NN.wav",
            "wlkMetadataLine": "PCMDATA.WLK · no embedded WLK name · flag/bit-depth metadata",
            "mlkFirstLine": "embedded MIDI title, usage label, or MLK id NN plus NN.mid",
            "mlkMetadataLine": "usage label when present · embedded/no embedded MIDI title",
        },
        "completionImpact": "resource-viewer provenance only; does not promote route, battle, or event proof",
    }


def midi_track_event_label(entry: dict[str, Any]) -> str:
    event = (entry.get("embeddedTrackNameEvents") or [{}])[0]
    if not event:
        return "-"
    title_status = "empty" if event.get("empty") else "title"
    return f"{event.get('offsetHex', '-')} len {event.get('length', '-')} {title_status}"


def html_page(summary: dict[str, Any]) -> str:
    mlk_cues_by_id = {row["exeId"]: row for row in summary["mlk"].get("browserRuntimeCues", [])}
    wlk_cue_rows = "\n".join(
        "<tr>"
        f"<td>{html.escape(cue['key'])}</td>"
        f"<td>{html.escape(cue['exeId'])}</td>"
        f"<td><code>{html.escape(cue['file'])}</code></td>"
        f"<td>{html.escape(cue['source'])}</td>"
        "</tr>"
        for cue in summary["wlk"].get("browserRuntimeCues", [])
    )
    wlk_rows = "\n".join(
        "<tr>"
        f"<td>{html.escape(entry['id'])}</td>"
        f"<td>{html.escape(entry['displayLabel'])}</td>"
        f"<td>{html.escape(entry['displayLabelSource'])}</td>"
        f"<td>{html.escape(entry['titleSourceLabel'])}</td>"
        f"<td>{html.escape(entry['flagsHex'])}</td>"
        f"<td>{entry['bitDepth']}-bit {entry['sampleRate']} Hz</td>"
        f"<td>{entry['durationSeconds']:.3f}s</td>"
        "</tr>"
        for entry in summary["wlk"]["entries"]
    )
    mlk_rows = "\n".join(
        (
            "<tr>"
            f"<td>{html.escape(entry['id'])}</td>"
            f"<td><code>{html.escape(entry['file'])}</code></td>"
            f"<td>{html.escape(entry['displayLabel'])}</td>"
            f"<td>{html.escape(entry['embeddedTitle'] or '-')}</td>"
            f"<td>{html.escape(midi_track_event_label(entry))}</td>"
            f"<td>{html.escape(mlk_cues_by_id.get(entry['id'], {}).get('key', '-'))}</td>"
            f"<td>{html.escape(entry['displayLabelSource'])}</td>"
            f"<td>{html.escape(entry['titleSourceLabel'])}</td>"
            "</tr>"
        )
        for entry in summary["mlk"]["entries"]
    )
    mlk_cue_rows = "\n".join(
        "<tr>"
        f"<td>{html.escape(cue['key'])}</td>"
        f"<td>{html.escape(cue['exeId'])}</td>"
        f"<td><code>{html.escape(cue['file'])}</code></td>"
        f"<td>{html.escape(cue['source'])}</td>"
        "</tr>"
        for cue in summary["mlk"].get("browserRuntimeCues", [])
    )
    return f"""<!doctype html>
<html lang="ko">
<head>
  <meta charset="utf-8">
  <title>Audio Title Provenance</title>
  <style>
    body {{ font-family: system-ui, sans-serif; margin: 24px; line-height: 1.5; color: #202124; }}
    table {{ border-collapse: collapse; width: 100%; margin: 16px 0 28px; }}
    th, td {{ border: 1px solid #d7dce2; padding: 6px 8px; text-align: left; }}
    th {{ background: #f4f6f8; }}
    code {{ background: #f4f6f8; padding: 1px 4px; }}
  </style>
</head>
<body>
  <h1>Audio Title Provenance</h1>
  <p>{html.escape(summary['conclusion'])}</p>
  <h2>Summary</h2>
  <table>
  <thead><tr><th>archive</th><th>entries</th><th>embedded titles</th><th>browser cue provenance</th><th>EXE id fallback entries</th><th>title storage</th></tr></thead>
    <tbody>
      <tr><td>PCMDATA.WLK</td><td>{summary['wlk']['count']}</td><td>{summary['wlk']['embeddedTitleCount']}</td><td>{summary['wlk'].get('browserRuntimeCueCount', 0)}</td><td>{summary['wlk']['fallbackLabelCount']}</td><td>{html.escape(summary['wlk']['titleStorage'])}</td></tr>
      <tr><td>MIDDATA.MLK</td><td>{summary['mlk']['count']}</td><td>{summary['mlk']['embeddedTitleCount']}</td><td>{summary['mlk'].get('browserRuntimeCueCount', 0)}</td><td>{summary['mlk']['fallbackLabelCount']}</td><td>{html.escape(summary['mlk']['titleStorage'])}</td></tr>
    </tbody>
  </table>
  <h2>Source Boundary</h2>
  <ul>
    <li>WLK effect-name strings are not embedded in <code>PCMDATA.WLK</code>; browser cue keys in <code>web/game.html</code> are runtime provenance only.</li>
    <li>MLK music titles below are extracted from <code>0xFF 0x03</code> MIDI track-name meta events inside each restored <code>.mid</code> payload.</li>
    <li>No <code>.exe</code> file is present in the current workspace, so this report uses the WLK/MLK archives, restored files, and browser runtime code as its evidence.</li>
    <li>Browser cue keys are useful for runtime/debugging, but they are not archive titles.</li>
  </ul>
  <h2>WLK Browser Runtime Cue Provenance</h2>
  <p>These are cue keys from <code>web/game.html</code>, not <code>PCMDATA.WLK</code> title storage.</p>
  <table>
    <thead><tr><th>cue key</th><th>WLK id</th><th>file</th><th>source</th></tr></thead>
    <tbody>{wlk_cue_rows}</tbody>
  </table>
  <h2>WLK Numbered Entries</h2>
  <table>
    <thead><tr><th>id</th><th>viewer label</th><th>label source</th><th>title source</th><th>flags</th><th>format</th><th>duration</th></tr></thead>
    <tbody>{wlk_rows}</tbody>
  </table>
  <h2>MLK Extracted MIDI Track Names</h2>
  <table>
    <thead><tr><th>id</th><th>file</th><th>viewer label</th><th>MIDI track-name title</th><th>track-name event</th><th>browser cue</th><th>label source</th><th>title source</th></tr></thead>
    <tbody>{mlk_rows}</tbody>
  </table>
  <h2>MLK Browser Runtime Cue Provenance</h2>
  <p>These are browser music cue keys from <code>web/game.html</code>, not additional archive titles.</p>
  <table>
    <thead><tr><th>cue key</th><th>MLK id</th><th>file</th><th>source</th></tr></thead>
    <tbody>{mlk_cue_rows}</tbody>
  </table>
  <h2>WLK Cue Provenance Caveat</h2>
  <p>The WLK cue keys above are kept only as runtime provenance. They should not be treated as confirmed effect identities. Manual audition results belong in <code>docs/WLK_SOUND_TITLE_REVIEW.md</code>.</p>
  <h2>Completion Impact</h2>
  <p>{html.escape(summary['completionImpact'])}</p>
</body>
</html>
"""


def write_outputs(summary: dict[str, Any], out_dir: Path = OUT) -> None:
    out_dir.mkdir(parents=True, exist_ok=True)
    (out_dir / "audio_title_provenance.json").write_text(
        json.dumps(summary, ensure_ascii=False, indent=2) + "\n",
        encoding="utf-8",
    )
    (out_dir / "audio_title_provenance.html").write_text(html_page(summary), encoding="utf-8")


def main() -> None:
    parser = argparse.ArgumentParser()
    parser.add_argument("--root", type=Path, default=ROOT)
    parser.add_argument("--out-dir", type=Path, default=OUT)
    args = parser.parse_args()
    summary = build_summary(args.root)
    write_outputs(summary, args.out_dir)
    print(f"wrote audio title provenance -> {args.out_dir / 'audio_title_provenance.html'}")


if __name__ == "__main__":
    main()
