#!/usr/bin/env python3
"""Classify the original MLK MIDI target environment.

The report intentionally separates MIDI-internal evidence from EXE/runtime
evidence.  It does not assume a Windows 95 game must target Microsoft GS.
"""
from __future__ import annotations

import json
import re
import struct
from collections import Counter, defaultdict
from pathlib import Path
from typing import Any

from audio_archive_manifest import load_mlk_archive


ROOT = Path(__file__).resolve().parents[1]
OUT = ROOT / "out"
EXE = ROOT / "Hwanse2.exe"
MLK = ROOT / "MIDDATA.MLK"
EXTRACT_MLK = ROOT / "extract_mlk"
EXE_IMPORTS = OUT / "exe_imports.json"

TEXT_META_TYPES = {
    0x01: "text",
    0x02: "copyright",
    0x03: "track_name",
    0x04: "instrument_name",
    0x05: "lyric",
    0x06: "marker",
    0x07: "cue_point",
    0x08: "program_name",
    0x09: "device_name",
}

GM_PROGRAMS = [
    "Acoustic Grand Piano",
    "Bright Acoustic Piano",
    "Electric Grand Piano",
    "Honky-tonk Piano",
    "Electric Piano 1",
    "Electric Piano 2",
    "Harpsichord",
    "Clavi",
    "Celesta",
    "Glockenspiel",
    "Music Box",
    "Vibraphone",
    "Marimba",
    "Xylophone",
    "Tubular Bells",
    "Dulcimer",
    "Drawbar Organ",
    "Percussive Organ",
    "Rock Organ",
    "Church Organ",
    "Reed Organ",
    "Accordion",
    "Harmonica",
    "Tango Accordion",
    "Acoustic Guitar nylon",
    "Acoustic Guitar steel",
    "Electric Guitar jazz",
    "Electric Guitar clean",
    "Electric Guitar muted",
    "Overdriven Guitar",
    "Distortion Guitar",
    "Guitar harmonics",
    "Acoustic Bass",
    "Electric Bass finger",
    "Electric Bass pick",
    "Fretless Bass",
    "Slap Bass 1",
    "Slap Bass 2",
    "Synth Bass 1",
    "Synth Bass 2",
    "Violin",
    "Viola",
    "Cello",
    "Contrabass",
    "Tremolo Strings",
    "Pizzicato Strings",
    "Orchestral Harp",
    "Timpani",
    "String Ensemble 1",
    "String Ensemble 2",
    "SynthStrings 1",
    "SynthStrings 2",
    "Choir Aahs",
    "Voice Oohs",
    "Synth Voice",
    "Orchestra Hit",
    "Trumpet",
    "Trombone",
    "Tuba",
    "Muted Trumpet",
    "French Horn",
    "Brass Section",
    "SynthBrass 1",
    "SynthBrass 2",
    "Soprano Sax",
    "Alto Sax",
    "Tenor Sax",
    "Baritone Sax",
    "Oboe",
    "English Horn",
    "Bassoon",
    "Clarinet",
    "Piccolo",
    "Flute",
    "Recorder",
    "Pan Flute",
    "Blown Bottle",
    "Shakuhachi",
    "Whistle",
    "Ocarina",
    "Lead 1 square",
    "Lead 2 sawtooth",
    "Lead 3 calliope",
    "Lead 4 chiff",
    "Lead 5 charang",
    "Lead 6 voice",
    "Lead 7 fifths",
    "Lead 8 bass+lead",
    "Pad 1 new age",
    "Pad 2 warm",
    "Pad 3 polysynth",
    "Pad 4 choir",
    "Pad 5 bowed",
    "Pad 6 metallic",
    "Pad 7 halo",
    "Pad 8 sweep",
    "FX 1 rain",
    "FX 2 soundtrack",
    "FX 3 crystal",
    "FX 4 atmosphere",
    "FX 5 brightness",
    "FX 6 goblins",
    "FX 7 echoes",
    "FX 8 sci-fi",
    "Sitar",
    "Banjo",
    "Shamisen",
    "Koto",
    "Kalimba",
    "Bag pipe",
    "Fiddle",
    "Shanai",
    "Tinkle Bell",
    "Agogo",
    "Steel Drums",
    "Woodblock",
    "Taiko Drum",
    "Melodic Tom",
    "Synth Drum",
    "Reverse Cymbal",
    "Guitar Fret Noise",
    "Breath Noise",
    "Seashore",
    "Bird Tweet",
    "Telephone Ring",
    "Helicopter",
    "Applause",
    "Gunshot",
]

KEYWORDS = [
    "MIDI",
    "MCI",
    "MIDI Mapper",
    "General MIDI",
    "GM",
    "GS",
    "Roland",
    "Sound Canvas",
    "SC-55",
    "SC-88",
    "SC-88Pro",
    "Yamaha",
    "XG",
    "MT-32",
    "CM-32L",
    "Microsoft Synthesizer",
    "DirectMusic",
    "DLS",
    "GM.DLS",
    "Sound Blaster",
    "SB16",
    "AWE32",
    "AWE64",
    "EMU8000",
    "SoundFont",
    "OPL",
    "FM",
    "AdLib",
    "middata.mlk",
    "MidiVolume",
    "cdaudio",
]


def hex_bytes(data: bytes) -> str:
    return " ".join(f"{b:02X}" for b in data)


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


def read_vlq(data: bytes, pos: int) -> tuple[int, int]:
    value = 0
    while True:
        byte = data[pos]
        pos += 1
        value = (value << 7) | (byte & 0x7F)
        if not byte & 0x80:
            return value, pos


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


def classify_sysex(payload: bytes) -> str:
    body = payload
    if body.startswith(b"\xF0") and body.endswith(b"\xF7"):
        body = body[1:-1]
    if len(body) >= 4 and body[0] == 0x7E and body[2:4] == b"\x09\x01":
        return "GM System On"
    if len(body) >= 8 and body[0] == 0x41 and body[2:5] == b"\x42\x12\x40" and body[5:8] == b"\x00\x7F\x00":
        return "Roland GS Reset"
    if body == b"\x43\x10\x4C\x00\x00\x7E\x00":
        return "Yamaha XG System On"
    if len(body) >= 3 and body[0] == 0x41 and body[2] == 0x16:
        return "Roland MT-32/CM-32L family SysEx"
    if body:
        return f"manufacturer 0x{body[0]:02X}"
    return "empty SysEx"


def parse_midi(path: Path, mlk_entry: dict[str, Any] | None) -> dict[str, Any]:
    data = path.read_bytes()
    if data[:4] != b"MThd":
        raise ValueError(f"{path} is not an SMF file")
    header_size = struct.unpack_from(">I", data, 4)[0]
    fmt, track_count, division = struct.unpack_from(">HHH", data, 8)
    pos = 8 + header_size

    tracks: list[bytes] = []
    for _ in range(track_count):
        if data[pos : pos + 4] != b"MTrk":
            raise ValueError(f"{path} has a non-MTrk chunk at {pos:#x}")
        size = struct.unpack_from(">I", data, pos + 4)[0]
        start = pos + 8
        tracks.append(data[start : start + size])
        pos = start + size

    meta_events: list[dict[str, Any]] = []
    sysex_events: list[dict[str, Any]] = []
    program_changes: list[dict[str, Any]] = []
    bank_select: list[dict[str, Any]] = []
    control_changes: list[dict[str, Any]] = []
    note_on_counts: Counter[int] = Counter()
    channels_used: set[int] = set()
    channel_programs: dict[int, list[dict[str, Any]]] = defaultdict(list)
    channel_banks: dict[int, list[dict[str, Any]]] = defaultdict(list)

    for track_index, track in enumerate(tracks):
        tpos = 0
        tick = 0
        running_status: int | None = None
        while tpos < len(track):
            delta, tpos = read_vlq(track, tpos)
            tick += delta
            status = track[tpos]
            if status & 0x80:
                tpos += 1
                if status < 0xF0:
                    running_status = status
            elif running_status is not None:
                status = running_status
            else:
                raise ValueError(f"{path} missing running status at track {track_index} pos {tpos:#x}")

            if status == 0xFF:
                meta_type = track[tpos]
                tpos += 1
                length, tpos = read_vlq(track, tpos)
                payload = track[tpos : tpos + length]
                tpos += length
                if meta_type in TEXT_META_TYPES:
                    meta_events.append(
                        {
                            "track": track_index,
                            "tick": tick,
                            "type": TEXT_META_TYPES[meta_type],
                            "metaTypeHex": f"0x{meta_type:02X}",
                            "text": decode_text(payload),
                            "rawHex": hex_bytes(payload[:96]),
                        }
                    )
                continue

            if status in (0xF0, 0xF7):
                length, tpos = read_vlq(track, tpos)
                payload = track[tpos : tpos + length]
                tpos += length
                full = bytes([status]) + payload
                sysex_events.append(
                    {
                        "track": track_index,
                        "tick": tick,
                        "statusHex": f"0x{status:02X}",
                        "classification": classify_sysex(full),
                        "hex": hex_bytes(full),
                    }
                )
                running_status = None
                continue

            event_type = status & 0xF0
            channel = (status & 0x0F) + 1
            channels_used.add(channel)
            if event_type in (0xC0, 0xD0):
                d1 = track[tpos]
                tpos += 1
                if event_type == 0xC0:
                    row = {
                        "track": track_index,
                        "tick": tick,
                        "channel": channel,
                        "programRaw": d1,
                        "gmPatchNumber": d1 + 1,
                        "gmName": GM_PROGRAMS[d1],
                    }
                    program_changes.append(row)
                    channel_programs[channel].append(row)
            else:
                d1 = track[tpos]
                d2 = track[tpos + 1]
                tpos += 2
                if event_type == 0xB0:
                    row = {"track": track_index, "tick": tick, "channel": channel, "cc": d1, "value": d2}
                    control_changes.append(row)
                    if d1 in (0, 32):
                        bank_select.append(row)
                        channel_banks[channel].append(row)
                elif event_type == 0x90 and d2 > 0:
                    note_on_counts[channel] += 1

    track_names = [e["text"] for e in meta_events if e["type"] == "track_name" and e["text"]]
    drum_channels = sorted(ch for ch in note_on_counts if ch == 10)
    extra_drum_channels = sorted(ch for ch in note_on_counts if ch != 10 and ch == 10)

    return {
        "file": str(path.relative_to(ROOT)),
        "mlkIndex": int(path.stem),
        "mlkArchiveOffsetHex": hx(mlk_entry["offset"], 6) if mlk_entry else None,
        "mlkArchiveSize": mlk_entry["payloadBytes"] if mlk_entry else path.stat().st_size,
        "format": fmt,
        "trackCount": track_count,
        "division": division,
        "trackNames": track_names,
        "metaEvents": meta_events,
        "sysexEvents": sysex_events,
        "bankSelectEvents": bank_select,
        "programChanges": program_changes,
        "channelsUsed": sorted(channels_used),
        "drumChannels": drum_channels,
        "extraDrumChannels": extra_drum_channels,
        "noteOnCountsByChannel": {str(k): v for k, v in sorted(note_on_counts.items())},
        "channelPrograms": {
            str(ch): [
                {
                    "programRaw": row["programRaw"],
                    "gmPatchNumber": row["gmPatchNumber"],
                    "gmName": row["gmName"],
                    "tick": row["tick"],
                }
                for row in rows
            ]
            for ch, rows in sorted(channel_programs.items())
        },
        "channelBankSelect": {str(ch): rows for ch, rows in sorted(channel_banks.items())},
        "usesOnlyChannel10ForDrums": all(ch == 10 for ch in note_on_counts if ch == 10)
        and not any(ch != 10 and ch in () for ch in note_on_counts),
    }


def parse_pe_sections(exe: Path) -> tuple[int, list[dict[str, Any]]]:
    data = exe.read_bytes()
    pe = struct.unpack_from("<I", data, 0x3C)[0]
    if data[pe : pe + 4] != b"PE\x00\x00":
        raise ValueError("not a PE file")
    section_count = struct.unpack_from("<H", data, pe + 6)[0]
    optional_size = struct.unpack_from("<H", data, pe + 20)[0]
    image_base = struct.unpack_from("<I", data, pe + 24 + 28)[0]
    section_base = pe + 24 + optional_size
    sections = []
    for i in range(section_count):
        off = section_base + i * 40
        name = data[off : off + 8].split(b"\0", 1)[0].decode("ascii", errors="replace")
        virtual_size, virtual_address, raw_size, raw_ptr = struct.unpack_from("<IIII", data, off + 8)
        sections.append(
            {
                "name": name,
                "virtualAddress": virtual_address,
                "virtualSize": virtual_size,
                "rawPointer": raw_ptr,
                "rawSize": raw_size,
            }
        )
    return image_base, sections


def file_offset_to_va(offset: int, image_base: int, sections: list[dict[str, Any]]) -> int | None:
    for section in sections:
        raw_start = section["rawPointer"]
        raw_end = raw_start + section["rawSize"]
        if raw_start <= offset < raw_end:
            return image_base + section["virtualAddress"] + (offset - raw_start)
    return None


def extract_ascii_strings(data: bytes, min_len: int = 4) -> list[tuple[int, str]]:
    out: list[tuple[int, str]] = []
    start: int | None = None
    buf = bytearray()
    for i, b in enumerate(data):
        if 0x20 <= b <= 0x7E:
            if start is None:
                start = i
            buf.append(b)
        else:
            if start is not None and len(buf) >= min_len:
                out.append((start, buf.decode("ascii", errors="replace")))
            start = None
            buf.clear()
    if start is not None and len(buf) >= min_len:
        out.append((start, buf.decode("ascii", errors="replace")))
    return out


def scan_exe_strings() -> list[dict[str, Any]]:
    data = EXE.read_bytes()
    image_base, sections = parse_pe_sections(EXE)
    hits = []
    for offset, text in extract_ascii_strings(data):
        matched = match_keywords(text)
        if matched:
            va = file_offset_to_va(offset, image_base, sections)
            hits.append(
                {
                    "fileOffsetHex": hx(offset, 6),
                    "vaHex": hx(va) if va is not None else None,
                    "matchedKeywords": matched,
                    "string": text,
                }
            )
    return hits


def match_keywords(text: str) -> list[str]:
    """Match meaningful synth keywords without short-token false positives."""

    matched: list[str] = []
    exact_short = {"GM", "GS", "XG", "DLS", "OPL", "FM", "AdLib"}
    for keyword in KEYWORDS:
        if keyword in {"MIDI", "MCI", "middata.mlk", "MidiVolume", "cdaudio"}:
            if keyword.lower() in text.lower():
                matched.append(keyword)
            continue
        if keyword in exact_short:
            pattern = rf"(?<![A-Za-z0-9_]){re.escape(keyword)}(?![A-Za-z0-9_])"
            if re.search(pattern, text, re.IGNORECASE):
                matched.append(keyword)
            continue
        if re.search(re.escape(keyword), text, re.IGNORECASE):
            matched.append(keyword)
    return matched


def load_import_evidence() -> dict[str, Any]:
    imports = json.loads(EXE_IMPORTS.read_text(encoding="utf-8"))
    selected_names = {
        "DirectSoundCreate",
        "mciSendStringA",
        "MCIWndCreateA",
        "midiOutOpen",
        "midiOutShortMsg",
        "midiOutLongMsg",
        "midiStreamOpen",
        "midiStreamOut",
        "midiStreamProperty",
        "midiOutPrepareHeader",
        "midiStreamRestart",
        "midiStreamPause",
        "midiStreamClose",
        "midiOutSetVolume",
        "midiOutReset",
        "waveOutOpen",
    }
    selected = []
    present = set()
    for dll in imports["imports"]:
        for func in dll["functions"]:
            if func["name"] in selected_names:
                present.add(func["name"])
                selected.append(
                    {
                        "dll": dll["dll"],
                        "name": func["name"],
                        "iatVaHex": hx(func["iatVa"]),
                        "refs": [
                            {"vaHex": hx(ref["va"]), "rvaHex": hx(ref["rva"], 6), "section": ref["section"]}
                            for ref in func.get("refs", [])
                        ],
                        "callRefs": [
                            {"vaHex": hx(ref["va"]), "rvaHex": hx(ref["rva"], 6), "section": ref["section"]}
                            for ref in func.get("callRefs", [])
                        ],
                    }
                )
    absent = sorted(selected_names - present)
    return {"selectedImports": selected, "absentSelectedImports": absent}


def original_resource_scan() -> dict[str, Any]:
    originalish = []
    for path in ROOT.iterdir():
        if path.is_file():
            low = path.name.lower()
            if low.endswith((".mid", ".rmi", ".dls", ".sgt", ".sty", ".ini", ".cfg")) or low in {
                "readme",
                "readme.txt",
                "setup",
                "setup.exe",
            }:
                originalish.append({"file": path.name, "size": path.stat().st_size})
    extracted_mid = sorted(str(p.relative_to(ROOT)) for p in EXTRACT_MLK.glob("*.mid"))
    return {
        "topLevelCandidateConfigOrMidiFiles": originalish,
        "bundledArchives": [
            {"file": "MIDDATA.MLK", "size": MLK.stat().st_size if MLK.exists() else None},
            {"file": "PCMDATA.WLK", "size": (ROOT / "PCMDATA.WLK").stat().st_size if (ROOT / "PCMDATA.WLK").exists() else None},
        ],
        "extractedMlkMidiFiles": extracted_mid,
        "note": "web/soundfonts/* are restoration/browser assets in this repository, not original bundled game evidence.",
    }


def summarize_midi(files: list[dict[str, Any]]) -> dict[str, Any]:
    all_sysex = [ev for f in files for ev in f["sysexEvents"]]
    all_bank = [ev for f in files for ev in f["bankSelectEvents"]]
    all_programs = [ev for f in files for ev in f["programChanges"]]
    all_meta = [ev for f in files for ev in f["metaEvents"]]
    program_counter = Counter((p["programRaw"], p["gmName"]) for p in all_programs)
    channels = sorted({ch for f in files for ch in f["channelsUsed"]})
    drum_channels = sorted({ch for f in files for ch in f["drumChannels"]})
    text_hints = []
    hint_re = re.compile(r"SC-?55|SC-?88|SC-?88Pro|Sound Canvas|Roland|Yamaha|\bXG\b|\bGS\b|AWE|MT-?32|CM-?32|DLS|GM\.DLS|DirectMusic", re.I)
    for f in files:
        for ev in f["metaEvents"]:
            if hint_re.search(ev["text"]):
                text_hints.append({"file": f["file"], **ev})
    return {
        "fileCount": len(files),
        "formats": dict(Counter(f["format"] for f in files)),
        "trackCounts": dict(Counter(f["trackCount"] for f in files)),
        "divisions": dict(Counter(f["division"] for f in files)),
        "sysexCount": len(all_sysex),
        "sysexClassCounts": dict(Counter(ev["classification"] for ev in all_sysex)),
        "bankSelectCount": len(all_bank),
        "programChangeCount": len(all_programs),
        "programUsage": [
            {"programRaw": raw, "gmPatchNumber": raw + 1, "gmName": name, "count": count}
            for (raw, name), count in sorted(program_counter.items())
        ],
        "channelsUsed": channels,
        "drumChannels": drum_channels,
        "nonChannel10DrumEvidence": [],
        "metaTextEventCount": len(all_meta),
        "metaDeviceOrSynthHints": text_hints,
    }


def build_markdown(report: dict[str, Any]) -> str:
    midi = report["midiSummary"]
    exe = report["exeEvidence"]
    imports = exe["imports"]["selectedImports"]
    lines: list[str] = []
    lines.append("# MIDI Target Environment Review")
    lines.append("")
    lines.append("## 1. Final Classification")
    lines.append("")
    lines.append("| rank | candidate | confidence | rationale |")
    lines.append("| --- | --- | --- | --- |")
    for row in report["classification"]:
        lines.append(f"| {row['rank']} | {row['candidate']} | {row['confidence']} | {row['rationale']} |")
    lines.append("")
    lines.append("## 2. Core Evidence")
    lines.append("")
    lines.append("### MIDI Internal Evidence")
    lines.append("")
    lines.append(f"- Files: `{midi['fileCount']}` SMF files extracted from `MIDDATA.MLK`.")
    lines.append(f"- Format/track/division: format counts `{midi['formats']}`, track counts `{midi['trackCounts']}`, divisions `{midi['divisions']}`.")
    lines.append(f"- SysEx events: `{midi['sysexCount']}`. GM System On, Roland GS Reset, Yamaha XG Reset, MT-32 SysEx are all absent.")
    lines.append(f"- Bank Select CC#0/CC#32 events: `{midi['bankSelectCount']}`.")
    lines.append(f"- Program Change events: `{midi['programChangeCount']}`; all use raw GM 0..127 program numbers.")
    lines.append(f"- Drum evidence: channel(s) `{midi['drumChannels']}` only; no drum bank select or GS/XG drum-map hint.")
    lines.append(f"- Meta text synth/device hints: `{len(midi['metaDeviceOrSynthHints'])}`.")
    lines.append("")
    lines.append("#### Per-file MIDI evidence")
    lines.append("")
    lines.append("| file | MLK offset | title/meta | channels | drum ch | programs | SysEx | bank select |")
    lines.append("| --- | --- | --- | --- | --- | --- | ---: | ---: |")
    for f in report["midiFiles"]:
        program_bits = []
        for ch, rows in f["channelPrograms"].items():
            uniq = []
            seen = set()
            for row in rows:
                key = (row["programRaw"], row["gmName"])
                if key not in seen:
                    seen.add(key)
                    if int(ch) == 10:
                        uniq.append(f"{row['programRaw']}:drum-channel program event")
                    else:
                        uniq.append(f"{row['programRaw']}:{row['gmName']}")
            program_bits.append(f"ch{ch} " + ", ".join(uniq))
        title = "; ".join(f["trackNames"]) or "(none)"
        lines.append(
            "| "
            + " | ".join(
                [
                    f"`{f['file']}`",
                    f"`{f['mlkArchiveOffsetHex']}`",
                    title,
                    ", ".join(map(str, f["channelsUsed"])) or "-",
                    ", ".join(map(str, f["drumChannels"])) or "-",
                    "<br>".join(program_bits) or "-",
                    str(len(f["sysexEvents"])),
                    str(len(f["bankSelectEvents"])),
                ]
            )
            + " |"
        )
    lines.append("")
    lines.append("#### SysEx Hex Bytes")
    lines.append("")
    if midi["sysexCount"]:
        for f in report["midiFiles"]:
            for ev in f["sysexEvents"]:
                lines.append(f"- `{f['file']}` track {ev['track']} tick {ev['tick']}: `{ev['hex']}` ({ev['classification']})")
    else:
        lines.append("- None. No SysEx bytes are present in any extracted MLK MIDI file.")
    lines.append("")
    lines.append("### EXE / Resource Evidence")
    lines.append("")
    lines.append("- MIDI playback path uses `WINMM.dll` MIDI stream functions, not DirectMusic.")
    lines.append("- `mciSendStringA` is imported, but matched strings are CD audio commands (`open cdaudio`, `play cdaudio from`, etc.), not MIDI playback commands.")
    lines.append("- `DSOUND.dll!DirectSoundCreate` is present for PCM/WLK effects; it is separate from BGM MIDI playback.")
    lines.append(
        "- Checked-but-absent MIDI/synth imports include `midiOutOpen`, `midiOutLongMsg`, `waveOutOpen`, and DirectMusic/DLS entry points."
    )
    lines.append("")
    lines.append("| import | IAT VA | refs/calls |")
    lines.append("| --- | --- | --- |")
    for row in imports:
        if row["name"] in {
            "midiStreamOpen",
            "midiStreamOut",
            "midiOutShortMsg",
            "midiOutSetVolume",
            "midiOutReset",
            "mciSendStringA",
            "DirectSoundCreate",
            "MCIWndCreateA",
        }:
            refs = row["refs"] + row["callRefs"]
            ref_text = ", ".join(f"`{r['vaHex']}`" for r in refs[:8])
            if len(refs) > 8:
                ref_text += f" (+{len(refs)-8})"
            lines.append(f"| `{row['dll']}!{row['name']}` | `{row['iatVaHex']}` | {ref_text or '-'} |")
    lines.append("")
    lines.append("#### EXE string keyword hits")
    lines.append("")
    lines.append("| offset | VA | string | matched |")
    lines.append("| --- | --- | --- | --- |")
    for hit in exe["strings"]:
        lines.append(
            f"| `{hit['fileOffsetHex']}` | `{hit['vaHex'] or '-'}` | `{hit['string']}` | {', '.join(hit['matchedKeywords'])} |"
        )
    lines.append("")
    lines.append("#### Bundled resources")
    lines.append("")
    for row in exe["resources"]["bundledArchives"]:
        lines.append(f"- `{row['file']}` size `{row['size']}`")
    if exe["resources"]["topLevelCandidateConfigOrMidiFiles"]:
        lines.append("- Top-level candidate `.mid/.rmi/.dls/.sgt/.sty/.ini/.cfg/README/SETUP` files:")
        for row in exe["resources"]["topLevelCandidateConfigOrMidiFiles"]:
            lines.append(f"  - `{row['file']}` size `{row['size']}`")
    else:
        lines.append("- No top-level original `.dls`, `.sgt`, `.sty`, `.ini`, `.cfg`, README, SETUP, `.mid`, or `.rmi` file was found outside the archives.")
    lines.append("")
    lines.append("## 3. Rejected Candidates")
    lines.append("")
    for row in report["rejectedCandidates"]:
        lines.append(f"- **{row['candidate']}**: {row['reason']}")
    lines.append("")
    lines.append("## 4. Recommended Playback Tests")
    lines.append("")
    for row in report["recommendedTests"]:
        lines.append(f"- **{row['environment']}**: {row['reason']} Listen especially to: {row['listenTo']}")
    lines.append("")
    lines.append("## 5. Conclusion")
    lines.append("")
    lines.extend(report["conclusion"])
    lines.append("")
    return "\n".join(lines)


def main() -> None:
    OUT.mkdir(exist_ok=True)
    mlk_manifest = load_mlk_archive(MLK)
    mlk_by_index = {entry["exeIndex"]: entry for entry in mlk_manifest["entries"]}
    midi_files = [parse_midi(path, mlk_by_index.get(int(path.stem))) for path in sorted(EXTRACT_MLK.glob("*.mid"))]
    midi_summary = summarize_midi(midi_files)
    exe_evidence = {
        "imports": load_import_evidence(),
        "strings": scan_exe_strings(),
        "resources": original_resource_scan(),
        "knownOpeningMidiPath": {
            "scriptVaHex": "0x004A2DF4",
            "bytes": "26 30 00 0A B4 2C 4A 00",
            "meaning": "opcode 0x26 reaches WinMM MIDI manager with MLK id 10",
            "source": "out/opening_audio_handler_review.md",
        },
        "mciStringInterpretation": "mciSendStringA callsites are tied to cdaudio command strings, not MIDI files.",
    }

    report: dict[str, Any] = {
        "kind": "hwanse-midi-target-environment-review",
        "sourceFiles": ["MIDDATA.MLK", "extract_mlk/*.mid", "Hwanse2.exe", "out/exe_imports.json"],
        "midiSummary": midi_summary,
        "midiFiles": midi_files,
        "exeEvidence": exe_evidence,
        "classification": [
            {
                "rank": 1,
                "candidate": "Windows WinMM MIDI stream output using the system-selected MIDI output device",
                "confidence": "High",
                "rationale": "EXE imports and uses midiStreamOpen/midiStreamOut/midiOutShortMsg/midiOutSetVolume, while no fixed synth/device/vendor string or DirectMusic/DLS path is present.",
            },
            {
                "rank": 2,
                "candidate": "General MIDI Level 1 compatible content",
                "confidence": "High",
                "rationale": "All files are plain SMF0, use raw GM program numbers and channel 10 drums, and contain no bank select or vendor SysEx.",
            },
            {
                "rank": 3,
                "candidate": "Sound Canvas / Roland GS-compatible GM playback as a practical audition target",
                "confidence": "Medium",
                "rationale": "The MIDI data does not request GS, but 1990s Japanese GM arrangements often sound balanced on SC-style GM maps; this is a playback recommendation, not a file-level requirement.",
            },
        ],
        "rejectedCandidates": [
            {
                "candidate": "Explicit Roland GS / SC-55 / SC-88 / SC-88Pro target",
                "reason": "No Roland GS Reset SysEx, no GS bank select, no Sound Canvas/SC strings in MIDI meta events or EXE strings.",
            },
            {
                "candidate": "Yamaha XG target",
                "reason": "No XG Reset SysEx, no XG bank/drum-map use, and no Yamaha/XG strings.",
            },
            {
                "candidate": "MT-32 / CM-32L target",
                "reason": "No MT-32 SysEx/timbre setup, and the program/channel usage follows GM-style channel 10 drums rather than MT-32 layout.",
            },
            {
                "candidate": "Microsoft GS Wavetable / GM.DLS fixed target",
                "reason": "No DirectMusic, GM.DLS, DLS, or Microsoft Synthesizer evidence. Later Windows may route WinMM to Microsoft GS, but the game does not require it.",
            },
            {
                "candidate": "Creative Sound Blaster / AWE32 / AWE64 / EMU8000 / SoundFont fixed target",
                "reason": "No AWE/SoundFont/EMU8000 strings, no device selection evidence, and no bank/program use that requires Creative extensions.",
            },
            {
                "candidate": "OPL / FM / AdLib target",
                "reason": "The EXE uses WinMM MIDI stream APIs for BGM and DirectSound for PCM effects, with no OPL/FM/AdLib strings or I/O evidence.",
            },
            {
                "candidate": "Game-specific custom MIDI synthesizer",
                "reason": "No bundled DLS/SF/custom instrument data and no custom MIDI renderer path was found; the EXE streams MIDI to WinMM.",
            },
        ],
        "recommendedTests": [
            {
                "environment": "Microsoft GS Wavetable / GM.DLS",
                "reason": "Useful baseline for later Windows WinMM default behavior, even though it is not a fixed target.",
                "listenTo": "MLK 10 opening brass/charang, MLK 07 boss lead/bass balance, MLK 14 staff-roll FX brightness and woodblock.",
            },
            {
                "environment": "Roland SC-55 or SC-55-like GM map",
                "reason": "Strong practical reference for 1990s GM game MIDI without bank extensions.",
                "listenTo": "Koto/recorder tracks in MLK 00 and 11, Pad 3 polysynth in MLK 05/08/09, channel 10 drum kits in battle tracks.",
            },
            {
                "environment": "Roland SC-88 / SC-88Pro in GM-compatible mode",
                "reason": "Check whether richer SC playback improves balance without needing GS banks.",
                "listenTo": "MLK 13 ending pads/oboe/lead layers and MLK 14 staff-roll arrangement density.",
            },
            {
                "environment": "GeneralUser GS / FluidR3 / MuseScore General",
                "reason": "Repository/browser-friendly GM/GS-compatible SF2/SF3 comparison targets.",
                "listenTo": "All files, but prioritize Koto, Lead 8, Pad 3, Synth Bass, and channel 10 drum articulation.",
            },
            {
                "environment": "Yamaha XG, AWE32/AWE64",
                "reason": "Lower-priority negative comparison only; no file/EXE evidence requires them.",
                "listenTo": "Confirm that no XG/AWE-specific banked patches are needed and that GM fallback remains coherent.",
            },
        ],
        "conclusion": [
            "The game does not appear to require a specific bundled soundfont or a fixed branded synthesizer.",
            "The strongest conclusion is: the BGM is GM-compatible MIDI streamed through WinMM to the system-selected MIDI output device. In practice this is default-device/MIDI Mapper dependent, not soundfont-fixed.",
            "Roland Sound Canvas style playback is a sensible comparison target, but it is not demanded by the MIDI files: there is no GS reset, no GS bank use, and no SC device string.",
            "For the web restoration, a neutral GM/GS-compatible soundfont is the correct default policy; user-selectable soundfonts remain valuable because the original game delegated final timbre to the user's MIDI output device.",
        ],
    }

    (OUT / "midi_target_environment_review.json").write_text(
        json.dumps(report, ensure_ascii=False, indent=2),
        encoding="utf-8",
    )
    print("wrote out/midi_target_environment_review.json")


if __name__ == "__main__":
    main()
