#!/usr/bin/env python3
"""Locate candidate GDR/HLDS firmware LBA-window math sites from disassembly.

This is an analyzer-adjacent helper, not a runtime emulator test.  It scans an
MN103S disassembly for READ CDB opcode dispatch, CDB LBA byte assembly, nearby
arithmetic, and candidate SRAM/MMIO writes.  It emits an analyzer proof-pack
skeleton that the offline proof runner can later bind to a session and mark as
BLOCKED until CPU/MMIO trace support can prove the candidate dynamically.

The key design point: do not bake media-sidecar layout into the firmware proof.
The current bridge model may say host LBA 32 maps to ISO LBA 306112, but this
tool is looking for firmware code that parses/adjusts/stores the LBA.
"""
from __future__ import annotations

import argparse
import csv
import json
import re
from pathlib import Path
from typing import Any

HEX_PC_RE = re.compile(r"^\s*([0-9A-Fa-f]{8}):\s*(.*?)\s{2,}|^\s*([0-9A-Fa-f]{8}):\s*(.*)$")
ADDR_RE = re.compile(r"0x([0-9A-Fa-f]+)")

DEFAULT_CONSTANTS = {
    "host_game_start_lba": 0x20,
    "assumed_game_output_start_lba": 0x4ABC0,
    "assumed_unlocked_window_delta": 0x4ABA0,
    "full_physical_sector_count": 0x345B60,
    "full_physical_max_lba": 0x345B5F,
    "locked_visible_sector_count": 0x1B50,
    "locked_visible_max_lba": 0x1B4F,
    "raw_layer_break_lba": 0x1FEF60,
}

# SRAM fields observed in the 8050L path so far. These are candidates only.
CANDIDATE_ADDRS = {
    0xDFA0: "packet_cdb_opcode_or_shadow",
    0xDFA1: "packet_cdb_byte1_or_shadow",
    0xDFA2: "packet_cdb_lba_b0_or_shadow",
    0xDFA3: "packet_cdb_lba_b1_or_shadow",
    0xDFA4: "packet_cdb_lba_b2_or_shadow",
    0xDFA5: "packet_cdb_lba_b3_or_shadow",
    0xDFA6: "packet_cdb_transfer_len_b0_or_shadow",
    0xDFA7: "packet_cdb_transfer_len_b1_or_shadow",
    0xDFA8: "packet_cdb_transfer_len_b2_or_shadow",
    0xDFA9: "packet_cdb_transfer_len_b3_or_shadow",
    0x05EC: "assembled_lba_candidate",
    0x05F0: "transfer_or_packet_state_candidate",
    0x05D0: "packet_opcode_shadow",
    0x06F1: "packet_phase_or_dma_state_candidate",
    0x0760: "current_or_cached_lba_candidate",
    0x0764: "lba_or_rotational_state_candidate",
    0x0765: "lba_or_rotational_state_candidate",
    0x0767: "transfer_count_or_sector_window_candidate",
}


def parse_int(text: str | None, default: int = 0) -> int:
    if text is None:
        return default
    s = str(text).strip()
    if not s:
        return default
    return int(s, 0)



def _candidate_roots(workspace: str | None = None) -> list[Path]:
    roots: list[Path] = []
    if workspace:
        roots.append(Path(workspace))
    cwd = Path.cwd()
    roots.append(cwd)
    # The emulator usually runs from scripts/toolchain/mn103s_emu_trace_v17,
    # so repo root is commonly three parents up.
    cur = cwd
    for _ in range(5):
        roots.append(cur)
        cur = cur.parent
    # Preserve order while removing duplicates.
    out: list[Path] = []
    seen: set[str] = set()
    for r in roots:
        try:
            key = str(r.resolve())
        except Exception:
            key = str(r)
        if key not in seen:
            seen.add(key)
            out.append(r)
    return out


def _find_first_existing(patterns: list[str], workspace: str | None = None) -> Path | None:
    for root in _candidate_roots(workspace):
        for pat in patterns:
            # Try both literal relative path and recursive glob pattern.
            literal = root / pat
            if literal.exists():
                return literal
            try:
                matches = sorted(root.glob(pat))
            except Exception:
                matches = []
            for m in matches:
                if m.exists() and m.is_file():
                    return m
    return None


def resolve_input_path(label: str, value: str | None, workspace: str | None, patterns: list[str], required: bool) -> Path | None:
    if value:
        p = Path(value)
        if p.exists() and p.is_file():
            return p
        if required:
            print(f"[ERROR] required {label} not found: {value}")
            print("[ERROR] No backup/convenience fallback is allowed for proof runs.")
            if label == "asm":
                print(r"[HINT] Expected analyzer disassembly under: disassembly\0099_GDR-8050L_0L23_firmware_90000000_GDR-8050L_0L23_5af3ae9b_firmware_4c61eb12_plaintext_7cab0f5c.asm")
            if label == "firmware":
                print(r"[HINT] Expected analyzer firmware under: decrypted_payloads\GDR-8050L_0L23_5af3ae9b_firmware_4c61eb12_plaintext_7cab0f5c.bin")
            raise FileNotFoundError(value)
        return None
    found = _find_first_existing(patterns, workspace)
    if found:
        print(f"[INFO] auto-detected {label}: {found}")
        return found
    if required:
        raise FileNotFoundError(f"Could not auto-detect required {label}; pass --{label} explicitly; no backup/convenience fallback is allowed")
    return None




def _as_path_variants(rel: str | None, workspace: str | None = None) -> list[Path]:
    """Return likely concrete paths for a manifest relative path.

    Stage manifests store Windows-style relpaths such as
    ``disassembly\\0099_...asm``.  The locator may run from the repo root,
    from scripts/toolchain/mn103s_emu_trace_v17, or from a copied package, so
    keep resolution broad but deterministic.
    """
    out: list[Path] = []
    if not rel:
        return out
    rel_s = str(rel).strip()
    if not rel_s:
        return out
    # Preserve a raw Path first for native Windows use, then add a POSIX-normalized
    # version for tests/containers.
    raw = Path(rel_s)
    norm = Path(rel_s.replace("\\", "/"))
    if raw.is_absolute():
        out.append(raw)
    if norm.is_absolute() and norm != raw:
        out.append(norm)
    roots: list[Path] = []
    if workspace:
        roots.append(Path(workspace))
    roots.extend(_candidate_roots(workspace))
    for root in roots:
        out.append(root / raw)
        out.append(root / norm)
    # v177: do not search by basename; manifest relpaths must resolve exactly
    # under the workspace/candidate roots. This prevents silently using backup
    # firmware/assembly copies.
    # De-duplicate in order.
    seen: set[str] = set()
    uniq: list[Path] = []
    for x in out:
        key = str(x)
        if key not in seen:
            seen.add(key)
            uniq.append(x)
    return uniq


def _record_matches_model_version(rec: dict[str, Any], model: str, revision: str) -> bool:
    text_model = (model or "").lower()
    text_rev = (revision or "").lower()
    hay = json.dumps(rec, sort_keys=True).lower()
    return (not text_model or text_model in hay) and (not text_rev or text_rev in hay)


def _load_stage5_manifest(args: argparse.Namespace) -> dict[str, Any] | None:
    """Load stage5 disassembly manifest from an explicit JSON file or workspace.

    Important: this tool must not depend on a temporary handoff archive like
    data.zip.  The analyzer JSON files are the contract, but they should be
    present as normal workspace artifacts, or passed explicitly with
    --stage5-manifest.
    """
    candidates: list[Path] = []
    if getattr(args, "stage5_manifest", None):
        candidates.append(Path(args.stage5_manifest))
    else:
        for root in _candidate_roots(getattr(args, "workspace", None)):
            candidates.append(root / "stage5_disassembly_manifest.json")
            candidates.append(root / "stage5" / "stage5_disassembly_manifest.json")
            candidates.append(root / "manifests" / "stage5_disassembly_manifest.json")

    seen: set[str] = set()
    for p in candidates:
        key = str(p)
        if key in seen:
            continue
        seen.add(key)
        if p.exists() and p.is_file():
            print(f"[INFO] using stage5 manifest: {p}")
            return json.loads(p.read_text(encoding="utf-8", errors="ignore"))

    if getattr(args, "stage5_manifest", None):
        raise FileNotFoundError(f"stage5 manifest not found: {args.stage5_manifest}")
    return None


def _find_stage5_record(manifest: dict[str, Any] | None, model: str, revision: str) -> dict[str, Any] | None:
    if not manifest:
        return None
    for rec in manifest.get("records") or []:
        if _record_matches_model_version(rec, model, revision):
            return rec
    return None


def _derive_manifest_paths(args: argparse.Namespace) -> tuple[Path | None, Path | None, str | None, dict[str, Any] | None]:
    """Resolve asm/firmware paths from analyzer JSON if available.

    Returns (asm_path, firmware_path, manifest_workspace, record).  The returned
    paths may be None when a manifest is absent or when the manifest points to
    artifacts that are not present on this machine.
    """
    manifest = _load_stage5_manifest(args)
    rec = _find_stage5_record(manifest, args.model, args.revision) if manifest else None
    if not rec:
        return None, None, None, None
    manifest_workspace = manifest.get("workspace") or None
    asm_rel = rec.get("asm_rel") or rec.get("disassembly_rel")
    fw_rel = rec.get("disassembly_input_rel") or rec.get("input_rel") or rec.get("plaintext_rel")
    # Prefer caller workspace, then manifest workspace.
    workspace = args.workspace or manifest_workspace
    asm_path = None
    fw_path = None
    for cand in _as_path_variants(asm_rel, workspace):
        if cand.exists() and cand.is_file():
            asm_path = cand
            break
    for cand in _as_path_variants(fw_rel, workspace):
        if cand.exists() and cand.is_file():
            fw_path = cand
            break
    print(f"[INFO] stage5 manifest record: {rec.get('record_id', '<unknown>')} model={args.model} revision={args.revision}")
    print(f"[INFO] manifest asm_rel: {asm_rel}")
    print(f"[INFO] manifest firmware_rel: {fw_rel}")
    if manifest_workspace:
        print(f"[INFO] manifest workspace: {manifest_workspace}")
    if asm_path:
        print(f"[INFO] resolved asm from manifest: {asm_path}")
    else:
        raise FileNotFoundError(f"stage5 manifest asm_rel did not resolve to an existing file: {asm_rel}; no backup/convenience assembly fallback is allowed")
    if fw_path:
        print(f"[INFO] resolved firmware from manifest: {fw_path}")
    else:
        raise FileNotFoundError(f"stage5 manifest firmware_rel did not resolve to an existing file: {fw_rel}; no backup/convenience firmware fallback is allowed")
    return asm_path, fw_path, manifest_workspace, rec

def parse_asm(path: Path) -> list[dict[str, Any]]:
    rows: list[dict[str, Any]] = []
    for lineno, line in enumerate(path.read_text(encoding="utf-8", errors="ignore").splitlines(), 1):
        m = re.match(r"^\s*([0-9A-Fa-f]{8}):\s*(.*)$", line)
        if not m:
            continue
        pc = int(m.group(1), 16)
        rest = m.group(2).rstrip()
        # Split bytes and mnemonic loosely. GNU objdump text is not consistent
        # because some MN103 unknowns wrap byte text onto following columns.
        parts = re.split(r"\s{2,}", rest, maxsplit=1)
        insn = parts[1].strip() if len(parts) > 1 else rest.strip()
        rows.append({"lineno": lineno, "pc": pc, "text": line.rstrip(), "insn": insn})
    return rows


def context(rows: list[dict[str, Any]], idx: int, span: int) -> list[str]:
    lo = max(0, idx - span)
    hi = min(len(rows), idx + span + 1)
    return [r["text"] for r in rows[lo:hi]]


def classify(row: dict[str, Any]) -> list[str]:
    text = row["text"].lower()
    insn = row["insn"].lower()
    out: list[str] = []
    # READ(10)/READ(12)/READ CD opcode compares. Disassembler prints decimal.
    if "cmp\t40" in text or "cmp\t40," in text or re.search(r"\bcmp\s+40\s*,", text):
        out.append("read10_opcode_compare_0x28")
    if "cmp\t168" in text or re.search(r"\bcmp\s+168\s*,", text):
        out.append("read12_opcode_compare_0xA8")
    if "cmp\t190" in text or re.search(r"\bcmp\s+190\s*,", text):
        out.append("read_cd_or_related_opcode_compare_0xBE")

    # Candidate CDB shadow reads/writes / assembled LBA fields.
    for addr, name in CANDIDATE_ADDRS.items():
        needle = f"0x{addr:x}"
        if needle in text:
            if "movbu" in insn and "(" in insn:
                out.append(f"candidate_addr_{addr:04x}_{name}")
            elif "mov" in insn:
                out.append(f"candidate_addr_{addr:04x}_{name}")

    # Arithmetic near packet/LBA paths.
    if re.search(r"\b(add|sub|mulu|lsr|lsl|and|or)\b", insn):
        nums = [int(x) for x in re.findall(r"(?<!0x)\b\d{1,7}\b", insn)]
        if any(n in (32, 75, 87, 96, 150, 2048, 4096, 4500) for n in nums):
            out.append("candidate_lba_or_sector_arithmetic")
        if "add\t150" in text or re.search(r"\badd\s+150\s*,", text):
            out.append("candidate_read_lba_plus_150")
        if "mulu\t4500" in text or "mulu\t75" in text:
            out.append("candidate_msf_or_timecode_math")

    return out


def scan_asm(rows: list[dict[str, Any]], span: int) -> list[dict[str, Any]]:
    hits: list[dict[str, Any]] = []
    for i, row in enumerate(rows):
        kinds = classify(row)
        if not kinds:
            continue
        hits.append({
            "pc": f"0x{row['pc']:08X}",
            "lineno": row["lineno"],
            "kinds": sorted(set(kinds)),
            "text": row["text"],
            "context": context(rows, i, span),
        })
    # Promote context clusters around opcode compare + CDB byte assembly.
    return hits


def scan_binary_constants(path: Path, rom_base: int, constants: dict[str, int]) -> dict[str, Any]:
    if not path or not path.exists():
        return {}
    data = path.read_bytes()
    out: dict[str, Any] = {}
    for name, value in constants.items():
        rec: dict[str, Any] = {"value": value, "value_hex": f"0x{value:X}", "occurrences": []}
        for width in (1, 2, 3, 4):
            if value >= (1 << (width * 8)):
                continue
            needle = value.to_bytes(width, "little")
            start = 0
            while True:
                pos = data.find(needle, start)
                if pos < 0:
                    break
                # Avoid reporting tons of one-byte 0x20 hits.
                if width > 1 or value not in (0x20,):
                    rec["occurrences"].append({"width": width, "offset": pos, "pc": f"0x{rom_base + pos:08X}"})
                start = pos + 1
                if len(rec["occurrences"]) >= 256:
                    break
        out[name] = rec
    return out


def summarize_candidates(hits: list[dict[str, Any]]) -> dict[str, Any]:
    by_kind: dict[str, int] = {}
    pcs_by_kind: dict[str, list[str]] = {}
    for hit in hits:
        for k in hit["kinds"]:
            by_kind[k] = by_kind.get(k, 0) + 1
            pcs_by_kind.setdefault(k, []).append(hit["pc"])
    # Compact notable PCs for the report/proof pack.
    notable_kinds = [
        "read10_opcode_compare_0x28",
        "read12_opcode_compare_0xA8",
        "read_cd_or_related_opcode_compare_0xBE",
        "candidate_addr_dfa2_packet_cdb_lba_b0_or_shadow",
        "candidate_addr_dfa3_packet_cdb_lba_b1_or_shadow",
        "candidate_addr_dfa4_packet_cdb_lba_b2_or_shadow",
        "candidate_addr_dfa5_packet_cdb_lba_b3_or_shadow",
        "candidate_addr_05ec_assembled_lba_candidate",
        "candidate_read_lba_plus_150",
        "candidate_msf_or_timecode_math",
    ]
    notable: dict[str, list[str]] = {k: pcs_by_kind.get(k, [])[:32] for k in notable_kinds if pcs_by_kind.get(k)}
    return {"hit_count": len(hits), "by_kind": by_kind, "notable_pcs": notable}


def build_proof_pack(args: argparse.Namespace, hits: list[dict[str, Any]], constants: dict[str, Any]) -> dict[str, Any]:
    summary = summarize_candidates(hits)
    media_layout_delta = None
    try:
        media = json.loads(Path(args.media_profile).read_text(encoding="utf-8")) if args.media_profile else {}
        layout = (media.get("image") or {}).get("layout") or media.get("layout") or {}
        game_output = int(layout.get("game_output_start_lba") or 0)
        game_src = int(layout.get("game_unlocked_source_start_lba") or 0)
        if game_output or game_src:
            media_layout_delta = game_output - game_src
    except Exception:
        media_layout_delta = None

    probe_reads = [
        {"host_lba": 0},
        {"host_lba": 32},
        {"host_lba": 33},
        {"host_lba": 306111},
        {"host_lba": 306112},
        {"host_lba": 1913889},
    ]
    if media_layout_delta is not None:
        for row in probe_reads:
            row["expected_current_bridge_iso_lba"] = int(row["host_lba"]) + media_layout_delta

    pack = {
        "format": "hlds-analyzer-proof-pack",
        "version": 1,
        "proof_pack_id": args.proof_pack_id or f"{args.profile_id}.lba_math_candidates",
        "device_profile_id": args.profile_id,
        "firmware": {
            "model": args.model,
            "revision": args.revision,
            "rom_base": f"0x{parse_int(args.rom_base, 0x90000000):08X}",
            "disassembly": str(args.asm) if args.asm else None,
            "firmware_image": str(args.firmware) if args.firmware else None,
        },
        "findings": [
            {
                "finding_id": f"{args.profile_id}.read_cdb_lba_parse_and_window_math.candidates",
                "type": "firmware_lba_transform",
                "confidence": "candidate",
                "static_evidence": {
                    "summary": summary,
                    "candidate_constants": DEFAULT_CONSTANTS,
                    "constant_occurrences": constants,
                    "candidate_hits": hits[: args.max_hits_in_proof_pack],
                    "notes": [
                        "This finding is intentionally candidate-level. It locates READ CDB/LBA/arithmetic sites but does not prove firmware-owned translation yet.",
                        "Disk-specific layout constants may not appear directly in firmware; the transform may be derived from media/control data or hardware state.",
                        "Dynamic PROVEN status requires CPU/MMIO trace evidence that the firmware parses host LBA and writes the translated/current source LBA to the packet/DMA hardware surface.",
                    ],
                },
                "dynamic_expectation": {
                    "run_mode": "offline_cpu_trace",
                    "requires_auth_state": "unlocked",
                    "proof_goal": "identify_firmware_or_hardware_source_of_lba_translation",
                    "probe_reads": probe_reads,
                    "expected_dynamic_evidence": [
                        "hit candidate READ opcode compare PC",
                        "observe CDB bytes 2..5 assembled into a 32-bit host LBA",
                        "observe firmware/window/state write carrying source/current LBA",
                        "observe packet/DMA submission using that firmware-derived source LBA",
                    ],
                    "bridge_model_is_not_sufficient": True,
                },
            }
        ],
    }
    return pack


def write_json(path: Path, obj: Any) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text(json.dumps(obj, indent=2, sort_keys=True) + "\n", encoding="utf-8")


def write_csv(path: Path, hits: list[dict[str, Any]]) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    with path.open("w", newline="", encoding="utf-8") as f:
        w = csv.DictWriter(f, fieldnames=["pc", "lineno", "kinds", "text"])
        w.writeheader()
        for h in hits:
            w.writerow({"pc": h["pc"], "lineno": h["lineno"], "kinds": ";".join(h["kinds"]), "text": h["text"]})


def write_markdown(path: Path, args: argparse.Namespace, hits: list[dict[str, Any]], constants: dict[str, Any]) -> None:
    summary = summarize_candidates(hits)
    lines: list[str] = []
    lines.append("# HLDS GDR LBA math locator report")
    lines.append("")
    lines.append(f"profile_id={args.profile_id}")
    lines.append(f"asm={args.asm or ''}")
    lines.append(f"firmware={args.firmware or ''}")
    lines.append("")
    lines.append(f"Candidate hits: {summary['hit_count']}")
    lines.append("")
    lines.append("## Hit counts by kind")
    lines.append("")
    for k, v in sorted(summary["by_kind"].items(), key=lambda kv: (-kv[1], kv[0])):
        lines.append(f"- `{k}`: {v}")
    lines.append("")
    lines.append("## Notable PCs")
    lines.append("")
    for k, pcs in summary["notable_pcs"].items():
        lines.append(f"- `{k}`: {', '.join(pcs[:16])}")
    lines.append("")
    lines.append("## Disk/layout constants in firmware image")
    lines.append("")
    if not constants:
        lines.append("No firmware binary constant scan was requested.")
    else:
        for name, rec in constants.items():
            occ = rec.get("occurrences") or []
            lines.append(f"- `{name}` `{rec.get('value_hex')}`: {len(occ)} occurrence(s)")
    lines.append("")
    lines.append("## Interpretation")
    lines.append("")
    lines.append("This report is a candidate locator. It is expected that disc-specific offsets such as the Red Faction II game-output start may not appear as hardcoded firmware constants. A true proof requires CPU/MMIO tracing of the READ path and packet/DMA submission.")
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text("\n".join(lines) + "\n", encoding="utf-8")


def main() -> int:
    ap = argparse.ArgumentParser(description="Locate candidate HLDS/GDR firmware LBA math sites and emit analyzer proof-pack skeleton")
    ap.add_argument("--asm", default=None, help="MN103S disassembly text; auto-detected from workspace if omitted/missing")
    ap.add_argument("--workspace", default=None, help="repository/workspace root for resolving manifest relpaths and auto-detecting files")
    ap.add_argument("--stage5-manifest", default=None, help="explicit analyzer stage5_disassembly_manifest.json path; reads normal JSON files, never data.zip")
    ap.add_argument("--firmware", default=None, help="optional firmware binary for constant occurrence scan")
    ap.add_argument("--rom-base", default="0x90000000")
    ap.add_argument("--profile-id", default="hlds.gdr8050l.0l23")
    ap.add_argument("--model", default="GDR-8050L")
    ap.add_argument("--revision", default="0L23")
    ap.add_argument("--media-profile", default=None, help="optional media profile/sidecar for current bridge expected LBA examples only")
    ap.add_argument("--proof-pack-id", default=None)
    ap.add_argument("--out-proof-pack", default="profiles/proofs/hlds.gdr8050l.0l23.lba_math_candidates.proof_pack.json")
    ap.add_argument("--out-csv", default="emutests/lba_math_locator_candidates.csv")
    ap.add_argument("--out-md", default="emutests/lba_math_locator_report.md")
    ap.add_argument("--context-lines", type=int, default=8)
    ap.add_argument("--max-hits-in-proof-pack", type=int, default=80)
    args = ap.parse_args()

    manifest_asm, manifest_fw, manifest_workspace, manifest_record = _derive_manifest_paths(args)
    effective_workspace = args.workspace or manifest_workspace

    asm = manifest_asm or resolve_input_path(
        "asm",
        args.asm,
        effective_workspace,
        [
            r"disassembly/0099_GDR-8050L_0L23_firmware_90000000_GDR-8050L_0L23_5af3ae9b_firmware_4c61eb12_plaintext_7cab0f5c.asm",
            r"disassembly/*GDR-8050L*0L23*plaintext*.asm",
            r"**/disassembly/*GDR-8050L*0L23*plaintext*.asm",
        ],
        required=True,
    )
    fw = manifest_fw or resolve_input_path(
        "firmware",
        args.firmware,
        effective_workspace,
        [
            r"decrypted_payloads/GDR-8050L_0L23_5af3ae9b_firmware_4c61eb12_plaintext_7cab0f5c.bin",
            r"decrypted_payloads/*GDR-8050L*0L23*plaintext*.bin",
            r"**/decrypted_payloads/*GDR-8050L*0L23*plaintext*.bin",
        ],
        required=True,
    )
    args.asm = str(asm)
    args.firmware = str(fw) if fw else None
    rows = parse_asm(asm)
    hits = scan_asm(rows, args.context_lines)
    constants = scan_binary_constants(fw, parse_int(args.rom_base, 0x90000000), DEFAULT_CONSTANTS) if fw else {}
    proof = build_proof_pack(args, hits, constants)
    write_json(Path(args.out_proof_pack), proof)
    write_csv(Path(args.out_csv), hits)
    write_markdown(Path(args.out_md), args, hits, constants)
    print(f"[OK] parsed asm rows: {len(rows)}")
    print(f"[OK] candidate hits: {len(hits)}")
    print(f"[OK] wrote proof pack: {args.out_proof_pack}")
    print(f"[OK] wrote CSV: {args.out_csv}")
    print(f"[OK] wrote report: {args.out_md}")
    return 0


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