#!/usr/bin/env python3
"""
Compute coverage和缺失诊断（基于 *.func_map.jsonl）：
- Coverage by optimization level
- 反查缺失原因（伪代码/汇编/输出缺失等）
- 规范化失败的计数与原因（基于唯一函数，pseudo_normalize 为空）

Key points
----------
- Source function counting prefers tree-sitter C when available; otherwise falls back
  to the original regex used in build-func-maps.py. This improves precision when
  possible but keeps a safe fallback.
- Coverage is based on *unique* source functions (path + name) observed in the func_map
  versus the total source functions derived from benchmarks’ Makefiles.

Outputs
-------
- <output_dir>/func_map_stats.json : machine-readable coverage & gap summary.
- <output_dir>/func_map_stats.md   : human-readable coverage、缺失原因、规范化缺失表。
"""

from __future__ import annotations

import argparse
import json
import re
import sys
from collections import Counter, defaultdict
from dataclasses import dataclass
from pathlib import Path
from typing import Dict, List, Optional, Set, Tuple

# Optional tree-sitter (preferred for accurate function discovery).
try:  # pragma: no cover - optional dependency
    from tree_sitter import Parser
    from tree_sitter_languages import get_language
    print("Use tree sitter")

    TS_LANGUAGE_C = get_language("c")
    TS_PARSER = Parser()
    TS_PARSER.set_language(TS_LANGUAGE_C)
except Exception:  # pragma: no cover - optional dependency
    TS_LANGUAGE_C = None
    TS_PARSER = None

REPO_ROOT = Path(__file__).resolve().parents[1]
OPT_PATTERN = re.compile(r"\.O([0-3])\b", re.IGNORECASE)
FUNC_KEYWORDS = {"if", "for", "while", "switch", "return", "sizeof", "do", "case", "else"}
TYPEDEF_MAP = {
    "cpu_set_t": "int",
    "nl_item": "int",
    "__time_t": "int",
    "__mode_t": "unsigned short",
    "__off64_t": "long long",
    "__blksize_t": "long",
    "__ino_t": "unsigned long",
    "__blkcnt_t": "unsigned long long",
    "__syscall_slong_t": "long",
    "__ssize_t": "long int",
    "wchar_t": "unsigned short int",
    "wctype_t": "unsigned short int",
    "__int64": "long long",
    "__int32": "int",
    "__int16": "short",
    "__int8": "char",
    "_QWORD": "uint64_t",
    "_OWORD": "long double",
    "_DWORD": "uint32_t",
    "size_t": "unsigned int",
    "_BYTE": "uint8_t",
    "_TBYTE": "uint16_t",
    "_BOOL8": "uint8_t",
    "gcc_va_list": "va_list",
    "_WORD": "unsigned short",
    "_BOOL4": "int",
    "__va_list_tag": "va_list",
    "_IO_FILE": "FILE",
    "DIR": "int",
    "__fsword_t": "long",
    "__kernel_ulong_t": "int",
    "cc_t": "int",
    "speed_t": "int",
    "fd_set": "int",
    "__suseconds_t": "int",
    "_UNKNOWN": "void",
    "__sighandler_t": "void (*)(int)",
    "__compar_fn_t": "int (*)(const void *, const void *)",
}


# ----------------------------
# Helpers for source scanning
# ----------------------------
def _read_text(path: Path) -> str:
    return path.read_text(encoding="utf-8")


def _remove_comments(text: str) -> str:
    text = re.sub(r"/\*.*?\*/", "", text, flags=re.DOTALL)
    text = re.sub(r"//.*?$", "", text, flags=re.MULTILINE)
    return text


def _hex_to_dec(text: str) -> str:
    pattern = re.compile(r"\b(0x[0-9a-fA-F]+)([uUlL]{1,3})?\b")

    def convert(match: re.Match[str]) -> str:
        hex_part = match.group(1)
        suffix = match.group(2) or ""
        return str(int(hex_part, 16)) + suffix

    return pattern.sub(convert, text)


def _remove_keywords(text: str) -> str:
    patterns = [
        r"\b__fastcall\b",
        r"\b__cdecl\b",
        r"\b__ptr32\b",
        r"\b__noreturn\s+noreturn\b",
    ]
    combined = re.compile("|".join(patterns))
    return combined.sub("", text)


def _replace_typedefs(text: str) -> str:
    for alias, original in TYPEDEF_MAP.items():
        pattern = re.compile(rf"\b{re.escape(alias)}\b")
        text = pattern.sub(original, text)
    return text


def _strip_empty(code: str) -> str:
    return "\n".join(line for line in code.splitlines() if line.strip())


def _process_code(code_str: str) -> str:
    code_str = _remove_comments(code_str)
    code_str = _hex_to_dec(code_str)
    code_str = _remove_keywords(code_str)
    code_str = _replace_typedefs(code_str)
    return code_str


def _strip_comments_and_strings(text: str) -> str:
    result = list(text)
    i = 0
    length = len(text)
    while i < length:
        nxt = text[i : i + 2]
        ch = text[i]
        if nxt == "//":
            end = text.find("\n", i)
            if end == -1:
                end = length
            for j in range(i, end):
                result[j] = " "
            i = end
            continue
        if nxt == "/*":
            end = text.find("*/", i + 2)
            if end == -1:
                end = length - 2
            for j in range(i, end + 2):
                result[j] = " "
            i = end + 2
            continue
        if ch in {'"', "'"}:
            quote = ch
            result[i] = " "
            i += 1
            while i < length:
                c = text[i]
                result[i] = " "
                if c == "\\":
                    i += 2
                    continue
                if c == quote:
                    i += 1
                    break
                i += 1
            continue
        i += 1
    return "".join(result)


def _find_matching_brace(text: str, start_idx: int) -> int:
    depth = 0
    i = start_idx
    length = len(text)
    while i < length:
        nxt = text[i : i + 2]
        ch = text[i]
        if nxt == "//":
            i = text.find("\n", i)
            if i == -1:
                return length - 1
            continue
        if nxt == "/*":
            i = text.find("*/", i + 2)
            if i == -1:
                return length - 1
            i += 2
            continue
        if ch in {'"', "'"}:
            quote = ch
            i += 1
            while i < length:
                c = text[i]
                if c == "\\":
                    i += 2
                    continue
                if c == quote:
                    i += 1
                    break
                i += 1
            continue
        if ch == "{":
            depth += 1
        elif ch == "}":
            depth -= 1
            if depth == 0:
                return i
        i += 1
    return length - 1


def _good_func(func: str) -> bool:
    body = "{".join(func.split("{", 1)[1:]) if "{" in func else func
    total = 0
    for line in body.splitlines():
        if len(line.strip()) >= 3:
            total += 1
    return 3 < total < 300


def _good_func_reason(func: str) -> Tuple[bool, str]:
    """
    Return (is_good, reason_if_bad) based on the same heuristic as _good_func.
    """
    body = "{".join(func.split("{", 1)[1:]) if "{" in func else func
    total = 0
    for line in body.splitlines():
        if len(line.strip()) >= 3:
            total += 1
    if total <= 3:
        return False, "length_constraint_violation_short"
    if total >= 300:
        return False, "length_constraint_violation_long"
    return True, ""


def _extract_functions_treesitter(text: str) -> List[str]:
    """
    Tree-sitter based function discovery. Returns function names.
    """
    if not TS_PARSER or not TS_LANGUAGE_C:
        return []
    try:
        tree = TS_PARSER.parse(text.encode("utf-8"))
        query = TS_LANGUAGE_C.query(
            "(function_definition declarator: (function_declarator declarator: (identifier) @name))"
        )
        captures = query.captures(tree.root_node)
        names: List[str] = []
        for node, cap_name in captures:
            if cap_name != "name":
                continue
            names.append(text[node.start_byte : node.end_byte])
        return names
    except Exception:
        return []


def _extract_source_functions(path: Path) -> Dict[str, str]:
    """
    Return a map of function_name -> function_body for a single C source file.
    Prefers tree-sitter name discovery; falls back to regex if needed.
    """
    text = _read_text(path)
    ts_names = set(_extract_functions_treesitter(text))

    sanitized = _strip_comments_and_strings(text)
    pattern = re.compile(
        r"(?P<prefix>^|[;\n}])(?P<signature>[^{;}]*?)\b(?P<name>[A-Za-z_][\w]*)\s*\([^;{}]*\)\s*\{",
        re.MULTILINE,
    )
    funcs: Dict[str, str] = {}
    for match in pattern.finditer(sanitized):
        name = match.group("name")
        if name in FUNC_KEYWORDS:
            continue
        brace_idx = sanitized.find("{", match.start("signature"))
        if brace_idx == -1:
            continue
        end_idx = _find_matching_brace(text, brace_idx)
        if end_idx <= brace_idx:
            continue
        start_idx = match.start("signature")
        content = text[start_idx : end_idx + 1].strip("\n") + "\n"
        funcs.setdefault(name, content)

    # Include any extra names tree-sitter found (without bodies if regex missed).
    for name in ts_names:
        funcs.setdefault(name, "")
    return funcs


def _parse_makefile(makefile: Path) -> List[Path]:
    text = _read_text(makefile)
    prog_match = re.search(r"^PROG\s*=\s*(\S+)", text, flags=re.MULTILINE)
    if not prog_match:
        return []
    prog = prog_match.group(1).strip()
    objs_match = re.search(r"^LOCAL_OBJS\s*=\s*(.*)$", text, flags=re.MULTILINE)
    obj_tokens: List[str] = []
    if objs_match:
        obj_tokens = [token for token in objs_match.group(1).split() if token]
    if not obj_tokens:
        obj_tokens = [f"{prog}.o"]
    src_paths: List[Path] = []
    for token in obj_tokens:
        if not token.endswith(".o"):
            continue
        candidate = makefile.parent / token.replace(".o", ".c")
        if candidate.exists():
            src_paths.append(candidate)
    if not src_paths:
        fallback = makefile.parent / f"{prog}.c"
        if fallback.exists():
            src_paths.append(fallback)
    return src_paths


def collect_source_functions(bench_dir: Path) -> Dict[Tuple[str, str], str]:
    """
    Return {(relative_source_path, function_name): body} for a benchmark dir.
    """
    makefile = bench_dir / "Makefile"
    if not makefile.exists():
        return {}
    funcs: Dict[Tuple[str, str], str] = {}
    for src in _parse_makefile(makefile):
        rel = str(src.relative_to(REPO_ROOT))
        for fname, body in _extract_source_functions(src).items():
            funcs[(rel, fname)] = body
    return funcs


# ----------------------------
# Helpers for pseudo / asm (mock build-func-maps.py)
# ----------------------------
def _parse_pseudo(pseudo_path: Path) -> Dict[str, Dict[str, str]]:
    text = _read_text(pseudo_path)
    lines = text.splitlines()
    pattern = re.compile(r"^/\*\s*(?P<name>[^@]+?)\s*@\s*(?P<addr>0x[0-9a-fA-F]+)\s*\*/$")
    current: Optional[str] = None
    current_addr: Optional[str] = None
    buffer: List[str] = []
    out: Dict[str, Dict[str, str]] = {}
    for raw_line in lines:
        line = raw_line.strip()
        match = pattern.match(line)
        if match:
            if current and buffer:
                content = "\n".join(buffer).strip("\n") + "\n"
                out.setdefault(
                    current,
                    {
                        "path": str(pseudo_path.relative_to(REPO_ROOT)),
                        "function_name": current,
                        "address": current_addr,
                        "label": current,
                        "content": content,
                    },
                )
            current = match.group("name").strip()
            current_addr = match.group("addr")
            buffer = []
        else:
            if current is not None:
                buffer.append(raw_line)
    if current and buffer:
        content = "\n".join(buffer).strip("\n") + "\n"
        out.setdefault(
            current,
            {
                "path": str(pseudo_path.relative_to(REPO_ROOT)),
                "function_name": current,
                "address": current_addr,
                "label": current,
                "content": content,
            },
        )
    return out


def _clean_instruction(raw: str) -> Optional[str]:
    stripped = raw.strip()
    if not stripped:
        return None
    parts = raw.split("\t")
    if len(parts) >= 3:
        relevant = parts[2:]
    elif len(parts) == 2:
        relevant = parts[1:]
    else:
        relevant = [stripped]
    instr = "\t".join(relevant)
    instr = instr.split("#")[0].strip()
    if not instr:
        return None
    if all(c in "0123456789abcdefABCDEF" for c in instr.replace(" ", "")):
        return None
    return instr


def _clean_asm_block(name: str, lines: List[str]) -> str:
    cleaned = [f"<{name}>:"]
    for raw in lines[1:]:
        instr = _clean_instruction(raw)
        if instr:
            cleaned.append(instr)
    return "\n".join(cleaned) + "\n"


def _parse_assembly(asm_path: Path) -> Dict[str, str]:
    lines = _read_text(asm_path).splitlines()
    header = re.compile(r"^\s*([0-9a-fA-F]+)\s+<([^>]+)>:\s*$")
    current: Optional[str] = None
    buffer: List[str] = []
    result: Dict[str, str] = {}
    for line in lines:
        match = header.match(line)
        if match:
            if current and buffer:
                result.setdefault(current, _clean_asm_block(current, buffer))
            current = match.group(2)
            buffer = [line]
        else:
            if current is not None:
                buffer.append(line)
    if current and buffer:
        result.setdefault(current, _clean_asm_block(current, buffer))
    return result


def _load_pseudo_names(pseudo_path: Path) -> Tuple[Optional[Set[str]], str]:
    if not pseudo_path.exists():
        return None, "pseudo_file_missing"
    try:
        return set(_parse_pseudo(pseudo_path).keys()), "ok"
    except Exception:
        return None, "pseudo_parse_error"


def _load_asm_names(asm_path: Path) -> Tuple[Optional[Set[str]], str]:
    if not asm_path.exists():
        return None, "asm_file_missing"
    try:
        return set(_parse_assembly(asm_path).keys()), "ok"
    except Exception:
        return None, "asm_parse_error"


def _diagnose_normalize_reason(pseudo_content: str) -> str:
    if not pseudo_content or not pseudo_content.strip():
        return "pseudo_missing_or_empty"
    try:
        processed = _process_code(pseudo_content)
        if not processed.strip():
            return "processed_empty"
        cleaned = _strip_empty(processed)
        ok, reason = _good_func_reason(cleaned)
        if not ok:
            return reason
        return "format_failed_or_unknown"
    except Exception:
        return "diagnostic_error"


def _bucket_normalize_reason(reason: str) -> str:
    if reason in {
        "too_short",
        "too_long",
        "filtered_by_size",
        "length_constraint_violation",
        "length_constraint_violation_short",
        "length_constraint_violation_long",
    }:
        return "length_constraint_violation_short" if "short" in reason else "length_constraint_violation_long"
    if reason == "format_failed_or_unknown":
        return "normalization_pipeline_failure"
    if reason in {"pseudo_missing_or_empty", "processed_empty"}:
        return "pseudocode_unavailable_or_elided"
    return "diagnostic_error"


def _label_normalize_reason(reason: str) -> str:
    return {
        "length_constraint_violation_short": "Length constraint violation (short)",
        "length_constraint_violation_long": "Length constraint violation (long)",
        "normalization_pipeline_failure": "Formatting/normalization pipeline failure",
        "pseudocode_unavailable_or_elided": "Pseudocode unavailable or removed during preprocessing",
        "diagnostic_error": "Diagnostic error",
    }.get(reason, reason)


def _label_missing_reason(reason: str) -> str:
    return {
        "pseudocode_unavailable": "Pseudocode unavailable (incl. missing ASM)",
    }.get(reason, reason)


# ----------------------------
# Core analysis
# ----------------------------
@dataclass
class OptStats:
    source_funcs_total: int = 0        # unique funcs available in source for this opt
    mapped_unique: int = 0             # unique mapped source funcs


def discover_func_maps(paths: List[str]) -> List[Path]:
    if paths:
        out = []
        for p in paths:
            path = Path(p)
            if not path.is_absolute():
                path = REPO_ROOT / path
            if path.is_file():
                out.append(path)
        return out
    return sorted(REPO_ROOT.rglob("*.func_map.jsonl"))


def extract_opt_level(binary_str: str) -> Optional[str]:
    match = OPT_PATTERN.search(binary_str)
    if not match:
        return None
    return f"O{match.group(1)}"


def analyze(func_map_paths: List[Path]):
    opt_stats: Dict[str, OptStats] = defaultdict(OptStats)
    bench_source_cache: Dict[Path, Dict[Tuple[str, str], str]] = {}
    per_opt_benches: Dict[str, set[Path]] = defaultdict(set)
    per_opt_mapped_unique: Dict[str, set[Tuple[str, str]]] = defaultdict(set)
    per_opt_pseudo_norm_available: Dict[str, set[Tuple[str, str]]] = defaultdict(set)
    per_binary_mapped: Dict[Path, Set[Tuple[str, str]]] = defaultdict(set)
    binary_opt: Dict[Path, str] = {}
    per_opt_normalize_empty_unique: Dict[str, Set[Tuple[str, str]]] = defaultdict(set)
    per_opt_normalize_reasons: Dict[str, Dict[str, Set[Tuple[str, str]]]] = defaultdict(
        lambda: defaultdict(set)
    )

    for path in func_map_paths:
        with path.open("r", encoding="utf-8") as handle:
            for raw in handle:
                raw = raw.strip()
                if not raw:
                    continue
                entry = json.loads(raw)
                binary_str = entry.get("binary") or ""
                opt = extract_opt_level(binary_str)
                if not opt:
                    continue

                # Normalize binary path (relative to repo root).
                binary_path = Path(binary_str)
                if not binary_path.is_absolute():
                    binary_path = REPO_ROOT / binary_path
                binary_opt[binary_path] = opt

                src = entry.get("source") or {}

                # Benchmark directory is parent of binary path (relative to repo).
                bench_dir = binary_path.parent
                per_opt_benches[opt].add(bench_dir)

                # Cache all source functions for the bench, keyed by (source_path, func_name).
                if bench_dir not in bench_source_cache:
                    bench_source_cache[bench_dir] = collect_source_functions(bench_dir)

                function_name = src.get("function_name", "<unknown>")
                source_path_rel = src.get("path", "")
                mapped_key = (source_path_rel, function_name)

                funcs_for_bench = bench_source_cache.get(bench_dir, {})
                if mapped_key in funcs_for_bench:
                    per_opt_mapped_unique[opt].add(mapped_key)

                per_binary_mapped[binary_path].add(mapped_key)

                pseudo_norm = entry.get("pseudo_normalize")
                if pseudo_norm is None or not str(pseudo_norm).strip():
                    per_opt_normalize_empty_unique[opt].add(mapped_key)
                    pseudo = entry.get("pseudo") or {}
                    reason = _diagnose_normalize_reason(pseudo.get("content", ""))
                    per_opt_normalize_reasons[opt][reason].add(mapped_key)
                else:
                    # pseudo_normalize 可用视为可用于反编译
                    per_opt_pseudo_norm_available[opt].add(mapped_key)

    # Fold source counts per opt (unique functions per bench summed).
    for opt, benches in per_opt_benches.items():
        total_sources = 0
        for bench in benches:
            funcs = bench_source_cache.get(bench)
            if funcs:
                total_sources += len(funcs)
        opt_stats[opt].source_funcs_total = total_sources
        opt_stats[opt].mapped_unique = len(per_opt_mapped_unique.get(opt, ()))

    # Gap diagnostics: why source funcs are missing from func_map.
    missing_reasons_per_opt: Dict[str, Counter] = defaultdict(Counter)
    missing_totals_per_opt: Dict[str, int] = defaultdict(int)
    for binary_path, mapped_keys in per_binary_mapped.items():
        opt = binary_opt.get(binary_path)
        if not opt:
            continue
        bench_dir = binary_path.parent
        funcs = bench_source_cache.get(bench_dir, {})
        pseudo_names, pseudo_status = _load_pseudo_names(Path(str(binary_path) + ".pseudo"))
        asm_names, asm_status = _load_asm_names(Path(str(binary_path) + ".s"))

        for src_key in funcs:
            src_path_rel, func_name = src_key
            if src_key in mapped_keys:
                continue

            in_pseudo = pseudo_names is not None and func_name in pseudo_names
            in_asm = asm_names is not None and func_name in asm_names

            if pseudo_status != "ok" and asm_status != "ok":
                reason = "missing_pseudo_and_asm_files" if (
                    pseudo_status == "pseudo_file_missing" and asm_status == "asm_file_missing"
                ) else "artifact_error"
            elif pseudo_status != "ok":
                reason = pseudo_status
            elif asm_status != "ok":
                reason = asm_status
            elif not in_pseudo and not in_asm:
                reason = "missing_in_pseudo_and_asm"
            elif not in_pseudo:
                reason = "missing_in_pseudo_only"
            elif not in_asm:
                reason = "missing_in_asm_only"
            else:
                reason = "dropped_during_mapping"

            # 只统计 missing_in_pseudo_only，其它原因忽略
            if reason in {"missing_in_pseudo_only", "missing_in_pseudo_and_asm"}:
                missing_reasons_per_opt[opt]["pseudocode_unavailable"] += 1
                missing_totals_per_opt[opt] += 1

    summary = {
        opt: {
            "source_funcs": stat.source_funcs_total,
            "mapped_unique": stat.mapped_unique,
            "coverage": (
                round(stat.mapped_unique / stat.source_funcs_total, 4)
                if stat.source_funcs_total
                else 0.0
            ),
        }
        for opt, stat in sorted(opt_stats.items())
    }

    gaps = {
        opt: {
            "source_funcs": opt_stats[opt].source_funcs_total,
            "mapped_unique": opt_stats[opt].mapped_unique,
            "missing": missing_totals_per_opt.get(opt, 0),
            "missing_pct": (
                round(missing_totals_per_opt.get(opt, 0) / opt_stats[opt].source_funcs_total, 4)
                if opt_stats[opt].source_funcs_total
                else 0.0
            ),
            "reasons": dict(sorted(missing_reasons_per_opt.get(opt, {}).items())),
        }
        for opt in sorted(opt_stats.keys())
    }

    meta = {
        "total_func_map_files": len(func_map_paths),
    }

    normalize_empty = {
        opt: len(vals) for opt, vals in sorted(per_opt_normalize_empty_unique.items())
    }
    normalize_reasons: Dict[str, Dict[str, int]] = defaultdict(dict)
    for opt, reasons in per_opt_normalize_reasons.items():
        bucketed: Dict[str, int] = defaultdict(int)
        for reason, funcs in reasons.items():
            bucket = _bucket_normalize_reason(reason)
            bucketed[bucket] += len(funcs)
        normalize_reasons[opt] = dict(sorted(bucketed.items()))

    pseudocode_norm_available = {
        opt: len(per_opt_pseudo_norm_available.get(opt, ())) for opt in opt_stats.keys()
    }

    return summary, meta, gaps, normalize_empty, normalize_reasons, pseudocode_norm_available


# ----------------------------
# Reporting
# ----------------------------
def write_json(
    summary: dict,
    meta: dict,
    gaps: dict,
    normalize_empty: dict,
    normalize_reasons: dict,
    pseudocode_norm_available: dict,
    output_dir: Path,
) -> Path:
    output_dir.mkdir(parents=True, exist_ok=True)
    out = output_dir / "func_map_stats.json"
    out.write_text(
        json.dumps(
            {
                "summary": summary,
                "meta": meta,
                "gaps": gaps,
                "normalize_empty": normalize_empty,
                "normalize_reasons": normalize_reasons,
                "pseudocode_norm_available": pseudocode_norm_available,
            },
            indent=2,
        ),
        encoding="utf-8",
    )
    return out


def write_markdown(
    summary: dict,
    meta: dict,
    gaps: dict,
    normalize_empty: dict,
    normalize_reasons: dict,
    pseudocode_norm_available: dict,
    output_dir: Path,
) -> Path:
    output_dir.mkdir(parents=True, exist_ok=True)
    out = output_dir / "func_map_stats.md"

    lines: List[str] = []
    lines.append("# Func Map Coverage Report")
    lines.append("")
    lines.append(f"- Func-map files analyzed: {meta['total_func_map_files']}")
    lines.append("")

    # Coverage table
    lines.append("## Coverage by optimization level")
    lines.append("| Opt | Source funcs | Mapped unique | Coverage |")
    lines.append("|:---:|-------------:|--------------:|---------:|")
    for opt, data in summary.items():
        lines.append(
            f"| {opt} | {data['source_funcs']} | "
            f"{data['mapped_unique']} | {data['coverage']*100:.2f}% |"
        )
    lines.append("")

    # Pseudocode availability (unique functions with normalized pseudocode present)
    lines.append("## Pseudocode availability (normalized, unique functions)")
    lines.append("| Opt | Source funcs | With normalized pseudocode | Availability |")
    lines.append("|:---:|-------------:|--------------------------:|-------------:|")
    for opt, data in summary.items():
        norm_available = pseudocode_norm_available.get(opt, 0)
        lines.append(
            f"| {opt} | {data['source_funcs']} | {norm_available} | "
            f"{(norm_available / data['source_funcs'] * 100) if data['source_funcs'] else 0:.2f}% |"
        )
    lines.append("")

    # Normalization failure counts removed; we report only causes.
    if normalize_reasons:
        lines.append("## Normalization failure causes (unique functions)")
        lines.append("| Opt | Cause | Count |")
        lines.append("|:---:|:------|------:|")
        for opt, reasons in normalize_reasons.items():
            for reason, count in sorted(reasons.items(), key=lambda x: (-x[1], x[0])):
                lines.append(f"| {opt} | {_label_normalize_reason(reason)} | {count} |")
        lines.append("")

    if gaps:
        lines.append("## Pseudocode coverage gaps (per optimization level)")
        lines.append("| Opt | Cause | Count | Of sources |")
        lines.append("|:---:|:------|------:|-----------:|")
        for opt, data in gaps.items():
            reasons = data.get("reasons", {})
            total = data.get("source_funcs", 0) or 1
            for reason, count in sorted(reasons.items(), key=lambda x: (-x[1], x[0])):
                lines.append(
                    f"| {opt} | {_label_missing_reason(reason)} | {count} | {count/total*100:.2f}% |"
                )
        lines.append("")

    out.write_text("\n".join(lines), encoding="utf-8")
    return out


# ----------------------------
# CLI
# ----------------------------
def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(description="Analyze func_map coverage per O level.")
    parser.add_argument(
        "--func-map",
        action="append",
        default=[],
        help="Func map JSONL file(s) to include. If omitted, all *.func_map.jsonl are scanned.",
    )
    parser.add_argument(
        "--output-dir",
        default="reports/func_map_stats",
        help="Output directory for JSON/Markdown reports (relative to repo root).",
    )
    return parser.parse_args()


def main() -> int:
    args = parse_args()
    func_map_paths = discover_func_maps(args.func_map)
    if not func_map_paths:
        print("No func_map files found.", file=sys.stderr)
        return 1

    output_dir = Path(args.output_dir)
    if not output_dir.is_absolute():
        output_dir = REPO_ROOT / output_dir

    (
        summary,
        meta,
        gaps,
        normalize_empty,
        normalize_reasons,
        pseudocode_norm_available,
    ) = analyze(func_map_paths)
    json_path = write_json(
        summary, meta, gaps, normalize_empty, normalize_reasons, pseudocode_norm_available, output_dir
    )
    md_path = write_markdown(
        summary, meta, gaps, normalize_empty, normalize_reasons, pseudocode_norm_available, output_dir
    )
    print(f"Wrote:\n - {json_path}\n - {md_path}")
    return 0


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