#!/usr/bin/env python3
"""Build and validate the verified WLK/MLK audio archive manifest.

Audio ids in reports and web pages are normalized to the same zero-based ids
the EXE uses.  Files are therefore addressed as `extract_wlk/00.wav` and
`extract_mlk/00.mid` without a second display numbering scheme.
"""
from __future__ import annotations

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


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

WLK_VERIFIED_ROWS: tuple[tuple[int, int, int, int | None], ...] = (
    (0x0004B0, 11025, 8, -500),
    (0x000B92, 22050, 8, -700),
    (0x001A47, 22050, 8, -1200),
    (0x0028FC, 22050, 8, -200),
    (0x003403, 22050, 8, -200),
    (0x004C22, 22050, 8, None),
    (0x006743, 22050, 8, None),
    (0x00FFAA, 22050, 8, -500),
    (0x011D08, 22050, 8, -500),
    (0x016FDB, 22050, 8, None),
    (0x01E0B5, 22050, 8, -500),
    (0x0235E4, 11025, 8, -500),
    (0x023FE2, 22050, 8, -500),
    (0x02563F, 22050, 8, -500),
    (0x027E99, 22050, 8, -500),
    (0x02C3C3, 22050, 8, -500),
    (0x030784, 22050, 8, -500),
    (0x03156E, 22050, 16, -500),
    (0x03EE84, 22050, 8, -500),
    (0x03FBF8, 44100, 8, -500),
    (0x04050E, 11025, 8, -500),
    (0x0411FF, 11025, 8, -500),
    (0x0494BA, 22050, 8, -500),
    (0x0550E4, 22050, 8, -500),
    (0x056447, 22050, 8, -500),
    (0x05A971, 11025, 16, -500),
    (0x05E499, 22050, 8, None),
    (0x05E953, 22050, 8, -500),
    (0x0690DE, 22050, 8, -500),
    (0x06A7D6, 44100, 16, -500),
    (0x079A62, 22050, 8, -500),
    (0x07EB12, 22050, 8, -500),
    (0x0800A6, 22050, 8, -500),
    (0x08605A, 22050, 8, None),
    (0x08D4DF, 22050, 8, -500),
    (0x0914B5, 22050, 8, -800),
    (0x09EC62, 11025, 8, -500),
    (0x0A155D, 22050, 8, -500),
    (0x0A99F8, 22050, 8, -500),
    (0x0B3C4A, 11025, 8, -500),
    (0x0BF786, 11025, 8, -500),
    (0x0C0CB4, 22050, 8, -500),
    (0x0C1D9B, 22050, 8, -500),
    (0x0C530D, 11025, 8, -500),
    (0x0C61A8, 11025, 8, -500),
    (0x0C7F23, 22050, 8, -1800),
    (0x0CA9FE, 22050, 8, -500),
    (0x0CB270, 22050, 8, -500),
    (0x0CE478, 22050, 16, -500),
    (0x0E1AEE, 22050, 8, -500),
    (0x0E5804, 22050, 8, None),
    (0x0ED891, 22050, 16, -500),
    (0x0FB2FF, 44100, 16, -500),
    (0x111813, 22050, 16, -500),
)

MLK_VERIFIED_OFFSETS: tuple[int, ...] = (
    0x0000B5,
    0x0025B0,
    0x004E02,
    0x005E65,
    0x007D13,
    0x008CE9,
    0x00B118,
    0x011276,
    0x016B9B,
    0x0178A6,
    0x018C47,
    0x01B998,
    0x01C7F9,
    0x01C8E9,
    0x01F5C4,
    0x025472,
    0x0256F0,
    0x025A2F,
    0x025B9F,
    0x026074,
)


def _hex(value: int, width: int = 6) -> str:
    return f"0x{value:0{width}X}"


def _check(condition: bool, message: str) -> None:
    if not condition:
        raise ValueError(message)


def parse_smf_header(payload: bytes) -> dict[str, int]:
    _check(payload[:4] == b"MThd", "MIDI payload does not start with MThd")
    header_size = struct.unpack_from(">I", payload, 4)[0]
    _check(header_size >= 6, f"short MIDI header: {header_size}")
    fmt, track_count, division = struct.unpack_from(">HHH", payload, 8)
    return {
        "format": fmt,
        "trackCount": track_count,
        "division": division,
        "headerSize": header_size,
    }


def load_wlk_archive(path: Path) -> dict[str, Any]:
    data = path.read_bytes()
    _check(data[:8] == b"WLKF0200", f"bad WLK magic: {data[:8]!r}")
    count, archive_flags = struct.unpack_from("<HH", data, 8)
    _check(count == len(WLK_VERIFIED_ROWS), f"WLK count {count} != verified count {len(WLK_VERIFIED_ROWS)}")

    entries: list[dict[str, Any]] = []
    flag_counts: Counter[str] = Counter()
    bit_depth_counts: Counter[str] = Counter()
    sample_rate_counts: Counter[str] = Counter()
    gain_counts: Counter[str] = Counter()
    loop_count = 0
    for index in range(count):
        status, flags, offset, size, sample_rate, gain_raw, reserved = struct.unpack_from(
            "<BBIIIiI",
            data,
            12 + index * 22,
        )
        expected_offset, expected_sample_rate, expected_bit_depth, expected_gain = WLK_VERIFIED_ROWS[index]
        bit_depth = 16 if flags & 0x80 else 8
        channels = 2 if flags & 0x40 else 1
        gain = gain_raw if gain_raw != 0 else None
        _check(offset == expected_offset, f"WLK id {index:02d} offset mismatch: {_hex(offset)} != {_hex(expected_offset)}")
        _check(sample_rate == expected_sample_rate, f"WLK id {index:02d} sample rate mismatch: {sample_rate} != {expected_sample_rate}")
        _check(bit_depth == expected_bit_depth, f"WLK id {index:02d} bit depth mismatch: {bit_depth} != {expected_bit_depth}")
        _check(gain == expected_gain, f"WLK id {index:02d} gain mismatch: {gain} != {expected_gain}")
        _check(channels == 1, f"WLK id {index:02d} unexpectedly has {channels} channels")
        _check(offset + size <= len(data), f"WLK id {index:02d} payload exceeds archive size")
        _check(reserved == 0, f"WLK id {index:02d} reserved field is not zero")

        loop = bool(flags & 0x20)
        duration = size / max(1, channels * (bit_depth // 8) * sample_rate)
        flag_hex = f"0x{flags:02X}"
        flag_counts[flag_hex] += 1
        bit_depth_counts[f"{bit_depth}-bit"] += 1
        sample_rate_counts[str(sample_rate)] += 1
        gain_counts[str(gain_raw)] += 1
        if loop:
            loop_count += 1
        entries.append(
            {
                "exeIndex": index,
                "exeId": f"{index:02d}",
                "file": f"extract_wlk/{index:02d}.wav",
                "archive": "PCMDATA.WLK",
                "statusHex": f"0x{status:02X}",
                "flagsHex": flag_hex,
                "offset": offset,
                "offsetHex": _hex(offset),
                "payloadBytes": size,
                "sampleRate": sample_rate,
                "bitDepth": bit_depth,
                "channels": channels,
                "gain": gain,
                "gainRaw": gain_raw,
                "loop": loop,
                "durationSeconds": round(duration, 6),
                "verified": True,
            }
        )

    return {
        "archive": "PCMDATA.WLK",
        "path": str(path.relative_to(ROOT)) if path.is_relative_to(ROOT) else str(path),
        "magic": "WLKF0200",
        "count": count,
        "archiveFlagsHex": f"0x{archive_flags:04X}",
        "entrySizeBytes": 22,
        "verifiedCount": len(entries),
        "verifiedSource": "user supplied WLK extraction table, normalized to EXE ids and validated against PCMDATA.WLK entry table",
        "fileNaming": "EXE ids map directly to extract_wlk/00.wav..53.wav",
        "titleStorage": "absent",
        "flagCounts": dict(sorted(flag_counts.items())),
        "bitDepthCounts": dict(sorted(bit_depth_counts.items())),
        "sampleRateCounts": dict(sorted(sample_rate_counts.items(), key=lambda item: int(item[0]))),
        "gainCounts": dict(sorted(gain_counts.items(), key=lambda item: int(item[0]))),
        "loopCount": loop_count,
        "entries": entries,
    }


def load_mlk_archive(path: Path) -> dict[str, Any]:
    data = path.read_bytes()
    count = data[0]
    _check(count == len(MLK_VERIFIED_OFFSETS), f"MLK count {count} != verified count {len(MLK_VERIFIED_OFFSETS)}")
    table_end = 1 + count * 9
    entries: list[dict[str, Any]] = []
    flag_counts: Counter[str] = Counter()
    format_counts: Counter[str] = Counter()
    for index in range(count):
        base = 1 + index * 9
        flag = data[base]
        offset, size = struct.unpack_from("<II", data, base + 1)
        expected_offset = MLK_VERIFIED_OFFSETS[index]
        _check(offset == expected_offset, f"MLK id {index:02d} offset mismatch: {_hex(offset)} != {_hex(expected_offset)}")
        _check(offset >= table_end, f"MLK id {index:02d} starts inside entry table")
        _check(offset + size <= len(data), f"MLK id {index:02d} payload exceeds archive size")
        payload = data[offset:offset + size]
        header = parse_smf_header(payload)
        _check(header["format"] == 0, f"MLK id {index:02d} is MIDI format {header['format']}, expected 0")
        _check(header["trackCount"] == 1, f"MLK id {index:02d} has {header['trackCount']} tracks, expected 1")
        flag_hex = f"0x{flag:02X}"
        flag_counts[flag_hex] += 1
        format_counts[str(header["format"])] += 1
        entries.append(
            {
                "exeIndex": index,
                "exeId": f"{index:02d}",
                "file": f"extract_mlk/{index:02d}.mid",
                "archive": "MIDDATA.MLK",
                "entryFlagHex": flag_hex,
                "offset": offset,
                "offsetHex": _hex(offset),
                "payloadBytes": size,
                "format": header["format"],
                "trackCount": header["trackCount"],
                "division": header["division"],
                "verified": True,
            }
        )

    return {
        "archive": "MIDDATA.MLK",
        "path": str(path.relative_to(ROOT)) if path.is_relative_to(ROOT) else str(path),
        "count": count,
        "tableOffset": 1,
        "bodyOffset": table_end,
        "entrySizeBytes": 9,
        "verifiedCount": len(entries),
        "verifiedSource": "user supplied MLK extraction table, normalized to EXE ids and validated against MIDDATA.MLK entry table",
        "fileNaming": "EXE ids map directly to extract_mlk/00.mid..19.mid",
        "titleStorage": "standard MIDI track-name meta events when present",
        "entryFlagCounts": dict(sorted(flag_counts.items())),
        "formatCounts": dict(sorted(format_counts.items())),
        "entries": entries,
    }


def build_summary(root: Path = ROOT) -> dict[str, Any]:
    wlk = load_wlk_archive(root / "PCMDATA.WLK")
    mlk = load_mlk_archive(root / "MIDDATA.MLK")
    return {
        "source": "audio archive manifest",
        "status": "verified",
        "conclusion": (
            "PCMDATA.WLK is finalized as 54 verified mono PCM entries and MIDDATA.MLK "
            "as 20 verified SMF format-0 MIDI tracks.  No WLK id 25/45/53 special probe "
            "or audition rewrite is part of the restoration contract."
        ),
        "numberingNote": "All active audio references use EXE internal zero-based ids: WLK id 00..53 and MLK id 00..19.",
        "wlk": wlk,
        "mlk": mlk,
        "midiPlaybackRecommendation": {
            "parser": "SMF parser with running status, tempo map, note matching",
            "synth": "web/engine/audio/midi_bgm_player.js WebAudio GM Lite baseline with optional SF2/SF3/Web MIDI modes",
            "scheduler": "Web Audio lookahead scheduling, not per-note setTimeout",
            "drums": "GM Lite channel 9 percussion synthesis or selected soundfont percussion",
            "fallback": "GM Lite WebAudio synth if optional soundfont loading fails",
        },
        "restorationContract": {
            "wlkEntries": wlk["count"],
            "mlkEntries": mlk["count"],
            "wlkVerified": wlk["verifiedCount"] == 54,
            "mlkVerified": mlk["verifiedCount"] == 20,
            "rawWavFilesPreserved": True,
            "rawMidiFilesPreserved": True,
            "probeRequired": False,
            "wlk45SpecialCaseRemoved": True,
        },
    }


def html_page(summary: dict[str, Any]) -> str:
    def wlk_row(entry: dict[str, Any]) -> str:
        gain = entry["gain"] if entry["gain"] is not None else 0
        return (
            "<tr>"
            f"<td>{html.escape(entry['exeId'])}</td>"
            f"<td><code>{html.escape(entry['file'])}</code></td>"
            f"<td>{html.escape(entry['offsetHex'])}</td>"
            f"<td>{entry['payloadBytes']}</td>"
            f"<td>{entry['sampleRate']} Hz · {entry['bitDepth']}-bit · mono</td>"
            f"<td>{gain}</td>"
            f"<td>{html.escape(entry['flagsHex'])}</td>"
            "</tr>"
        )

    def mlk_row(entry: dict[str, Any]) -> str:
        return (
            "<tr>"
            f"<td>{html.escape(entry['exeId'])}</td>"
            f"<td><code>{html.escape(entry['file'])}</code></td>"
            f"<td>{html.escape(entry['offsetHex'])}</td>"
            f"<td>{entry['payloadBytes']}</td>"
            f"<td>{entry['format']}</td>"
            f"<td>{entry['trackCount']}</td>"
            f"<td>{entry['division']}</td>"
            "</tr>"
        )

    wlk_rows = "\n".join(wlk_row(entry) for entry in summary["wlk"]["entries"])
    mlk_rows = "\n".join(mlk_row(entry) for entry in summary["mlk"]["entries"])
    return f"""<!doctype html>
<html lang="ko">
<head>
  <meta charset="utf-8">
  <title>Audio Archive Manifest</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 Archive Manifest</h1>
  <p>{html.escape(summary['conclusion'])}</p>
  <p>{html.escape(summary['numberingNote'])}</p>
  <h2>WLK Entries</h2>
  <table>
    <thead><tr><th>WLK id</th><th>file</th><th>offset</th><th>bytes</th><th>format</th><th>gain</th><th>flags</th></tr></thead>
    <tbody>{wlk_rows}</tbody>
  </table>
  <h2>MLK Entries</h2>
  <table>
    <thead><tr><th>MLK id</th><th>file</th><th>offset</th><th>bytes</th><th>format</th><th>tracks</th><th>division</th></tr></thead>
    <tbody>{mlk_rows}</tbody>
  </table>
</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_archive_manifest.json").write_text(
        json.dumps(summary, ensure_ascii=False, indent=2) + "\n",
        encoding="utf-8",
    )
    (out_dir / "audio_archive_manifest.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 archive manifest -> {args.out_dir / 'audio_archive_manifest.html'}")


if __name__ == "__main__":
    main()
