#!/usr/bin/env python3
"""Export the consumer-visible Nexamas.UI API from compiled CLR metadata.

This is an audit utility. It does not execute the target assembly and therefore can
inspect a .NET Framework assembly on non-Windows hosts. Install its only dependency
with: python -m pip install dnfile
"""
from __future__ import annotations

import argparse
import csv
import hashlib
import json
import re
import struct
import sys
from collections import Counter, defaultdict
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Iterable
import xml.etree.ElementTree as ET

try:
    import dnfile
except ImportError as exc:  # pragma: no cover
    raise SystemExit("dnfile is required: python -m pip install dnfile") from exc

ELEMENT = {
    0x01: "System.Void", 0x02: "System.Boolean", 0x03: "System.Char",
    0x04: "System.SByte", 0x05: "System.Byte", 0x06: "System.Int16",
    0x07: "System.UInt16", 0x08: "System.Int32", 0x09: "System.UInt32",
    0x0A: "System.Int64", 0x0B: "System.UInt64", 0x0C: "System.Single",
    0x0D: "System.Double", 0x0E: "System.String", 0x16: "System.TypedReference",
    0x18: "System.IntPtr", 0x19: "System.UIntPtr", 0x1C: "System.Object",
}

ACCESS_RANK = {
    "Private": 0,
    "PrivateProtected": 1,
    "Internal": 2,
    "Protected": 3,
    "ProtectedInternal": 4,
    "Public": 5,
}


def read_json(path: Path) -> dict[str, Any]:
    return json.loads(path.read_text(encoding="utf-8-sig"))


def sha256_file(path: Path) -> str:
    h = hashlib.sha256()
    with path.open("rb") as fh:
        for chunk in iter(lambda: fh.read(1024 * 1024), b""):
            h.update(chunk)
    return h.hexdigest().upper()


def compressed_uint(data: bytes, pos: int) -> tuple[int, int]:
    first = data[pos]
    if first & 0x80 == 0:
        return first, pos + 1
    if first & 0xC0 == 0x80:
        return ((first & 0x3F) << 8) | data[pos + 1], pos + 2
    if first & 0xE0 == 0xC0:
        return ((first & 0x1F) << 24) | (data[pos + 1] << 16) | (data[pos + 2] << 8) | data[pos + 3], pos + 4
    raise ValueError(f"invalid compressed integer at {pos}")


def strip_arity(name: str) -> str:
    return name.split("`", 1)[0]


@dataclass
class SigContext:
    type_params: list[str]
    method_params: list[str]


class MetadataExporter:
    def __init__(self, assembly_path: Path):
        self.assembly_path = assembly_path
        self.pe = dnfile.dnPE(str(assembly_path))
        self.tables = self.pe.net.mdtables
        self.typedefs = self.tables.TypeDef.rows
        self.typerefs = self.tables.TypeRef.rows
        self.typespecs = self.tables.TypeSpec.rows
        self.nested_parent: dict[int, Any] = {
            id(row.NestedClass.row): row.EnclosingClass.row
            for row in self.tables.NestedClass.rows
        }
        self.generic_params: dict[int, list[tuple[int, str, Any]]] = defaultdict(list)
        for gp in self.tables.GenericParam.rows:
            self.generic_params[id(gp.Owner.row)].append((int(gp.Number), str(gp.Name), gp))
        for values in self.generic_params.values():
            values.sort(key=lambda x: x[0])

        self.constant_by_parent: dict[int, Any] = {}
        for c in self.tables.Constant.rows:
            if c.Parent.row is not None:
                self.constant_by_parent[id(c.Parent.row)] = c

        self.property_by_type: dict[int, list[Any]] = defaultdict(list)
        for pm in self.tables.PropertyMap.rows:
            self.property_by_type[id(pm.Parent.row)] = [x.row for x in pm.PropertyList]
        self.event_by_type: dict[int, list[Any]] = defaultdict(list)
        for em in self.tables.EventMap.rows:
            self.event_by_type[id(em.Parent.row)] = [x.row for x in em.EventList]

        self.semantics_by_assoc: dict[int, list[Any]] = defaultdict(list)
        for sem in self.tables.MethodSemantics.rows:
            if sem.Association.row is not None:
                self.semantics_by_assoc[id(sem.Association.row)].append(sem)

    def type_ref_name(self, row: Any) -> str:
        if row is None:
            return "?"
        table_name = row.__class__.__name__
        if table_name == "TypeDefRow":
            return self.typedef_full_name(row)
        if table_name == "TypeRefRow":
            name = str(row.TypeName)
            namespace = str(row.TypeNamespace)
            scope = getattr(row, "ResolutionScope", None)
            parent = getattr(scope, "row", None)
            if parent is not None and parent.__class__.__name__ == "TypeRefRow":
                return self.type_ref_name(parent) + "+" + name
            return (namespace + "." if namespace else "") + name
        if table_name == "TypeSpecRow":
            return self.parse_type(bytes(row.Signature.value), 0, SigContext([], []))[0]
        return str(row)

    def typedef_full_name(self, row: Any) -> str:
        names = [str(row.TypeName)]
        current = row
        while id(current) in self.nested_parent:
            current = self.nested_parent[id(current)]
            names.append(str(current.TypeName))
        namespace = str(current.TypeNamespace)
        return (namespace + "." if namespace else "") + "+".join(reversed(names))

    def root_typedef(self, row: Any) -> Any:
        current = row
        while id(current) in self.nested_parent:
            current = self.nested_parent[id(current)]
        return current

    def externally_visible_type(self, row: Any) -> bool:
        root = self.root_typedef(row)
        if not str(root.TypeNamespace).startswith("Nexamas.UI"):
            return False
        current = row
        if id(current) not in self.nested_parent:
            return bool(current.Flags.tdPublic)
        while id(current) in self.nested_parent:
            if not current.Flags.tdNestedPublic:
                return False
            current = self.nested_parent[id(current)]
        return bool(current.Flags.tdPublic)

    def type_kind(self, row: Any) -> str:
        if row.Flags.tdInterface:
            return "Interface"
        extends = self.type_ref_name(row.Extends.row) if row.Extends.row else ""
        if extends == "System.Enum":
            return "Enum"
        if extends == "System.ValueType":
            return "Structure"
        if extends in ("System.MulticastDelegate", "System.Delegate"):
            return "Delegate"
        return "Class"

    def type_access(self, row: Any) -> str:
        if row.Flags.tdPublic or row.Flags.tdNestedPublic:
            return "Public"
        if row.Flags.tdNestedFamily:
            return "Protected"
        if row.Flags.tdNestedFamORAssem:
            return "ProtectedInternal"
        if row.Flags.tdNestedFamANDAssem:
            return "PrivateProtected"
        if row.Flags.tdNotPublic or row.Flags.tdNestedAssembly:
            return "Internal"
        return "Private"

    def generic_names(self, owner: Any) -> list[str]:
        return [name or f"T{num}" for num, name, _ in self.generic_params.get(id(owner), [])]

    def resolve_coded_type(self, encoded: int, ctx: SigContext) -> str:
        tag = encoded & 0x3
        index = encoded >> 2
        if index <= 0:
            return "?"
        if tag == 0:
            return self.type_ref_name(self.typedefs[index - 1])
        if tag == 1:
            return self.type_ref_name(self.typerefs[index - 1])
        if tag == 2:
            return self.type_ref_name(self.typespecs[index - 1])
        return "?"

    def parse_type(self, data: bytes, pos: int, ctx: SigContext) -> tuple[str, int]:
        modifiers: list[str] = []
        while pos < len(data) and data[pos] in (0x1F, 0x20):
            required = data[pos] == 0x1F
            token, pos = compressed_uint(data, pos + 1)
            modifiers.append(("modreq" if required else "modopt") + "(" + self.resolve_coded_type(token, ctx) + ")")
        et = data[pos]
        pos += 1
        if et in ELEMENT:
            base = ELEMENT[et]
        elif et == 0x0F:  # PTR
            inner, pos = self.parse_type(data, pos, ctx)
            base = inner + "*"
        elif et == 0x10:  # BYREF
            inner, pos = self.parse_type(data, pos, ctx)
            base = inner + "&"
        elif et in (0x11, 0x12):  # VALUETYPE / CLASS
            token, pos = compressed_uint(data, pos)
            base = self.resolve_coded_type(token, ctx)
        elif et == 0x13:  # VAR
            number, pos = compressed_uint(data, pos)
            base = ctx.type_params[number] if number < len(ctx.type_params) else f"!{number}"
        elif et == 0x14:  # ARRAY
            inner, pos = self.parse_type(data, pos, ctx)
            rank, pos = compressed_uint(data, pos)
            num_sizes, pos = compressed_uint(data, pos)
            sizes = []
            for _ in range(num_sizes):
                value, pos = compressed_uint(data, pos)
                sizes.append(value)
            num_lobounds, pos = compressed_uint(data, pos)
            for _ in range(num_lobounds):
                _, pos = compressed_uint(data, pos)
            base = inner + "[" + ("," * max(rank - 1, 0)) + "]"
        elif et == 0x15:  # GENERICINST
            class_or_value = data[pos]
            pos += 1
            if class_or_value not in (0x11, 0x12):
                raise ValueError(f"bad genericinst kind {class_or_value:#x}")
            token, pos = compressed_uint(data, pos)
            generic_name = strip_arity(self.resolve_coded_type(token, ctx))
            count, pos = compressed_uint(data, pos)
            args = []
            for _ in range(count):
                arg, pos = self.parse_type(data, pos, ctx)
                args.append(arg)
            base = generic_name + "<" + ", ".join(args) + ">"
        elif et == 0x1B:  # FNPTR
            sig, pos = self.parse_method_signature(data, pos, ctx)
            base = "fnptr(" + sig["display"] + ")"
        elif et == 0x1D:  # SZARRAY
            inner, pos = self.parse_type(data, pos, ctx)
            base = inner + "[]"
        elif et == 0x1E:  # MVAR
            number, pos = compressed_uint(data, pos)
            base = ctx.method_params[number] if number < len(ctx.method_params) else f"!!{number}"
        elif et == 0x41:  # SENTINEL
            inner, pos = self.parse_type(data, pos, ctx)
            base = "... " + inner
        elif et == 0x45:  # PINNED
            inner, pos = self.parse_type(data, pos, ctx)
            base = inner + " pinned"
        else:
            raise ValueError(f"unsupported element type {et:#x} at {pos - 1}: {data.hex()}")
        if modifiers:
            base += " " + " ".join(modifiers)
        return base, pos

    def parse_method_signature(self, data: bytes, pos: int, ctx: SigContext) -> tuple[dict[str, Any], int]:
        callconv = data[pos]
        pos += 1
        generic_count = 0
        if callconv & 0x10:
            generic_count, pos = compressed_uint(data, pos)
        param_count, pos = compressed_uint(data, pos)
        return_type, pos = self.parse_type(data, pos, ctx)
        params = []
        for _ in range(param_count):
            if data[pos] == 0x41:
                pos += 1
                params.append("...")
            ptype, pos = self.parse_type(data, pos, ctx)
            params.append(ptype)
        display = return_type + " (" + ", ".join(params) + ")"
        return {
            "callingConvention": callconv & 0x0F,
            "hasThis": bool(callconv & 0x20),
            "explicitThis": bool(callconv & 0x40),
            "genericParameterCount": generic_count,
            "returnType": return_type,
            "parameterTypes": params,
            "display": display,
        }, pos

    def parse_property_signature(self, data: bytes, ctx: SigContext) -> dict[str, Any]:
        pos = 0
        callconv = data[pos]
        pos += 1
        param_count, pos = compressed_uint(data, pos)
        ptype, pos = self.parse_type(data, pos, ctx)
        params = []
        for _ in range(param_count):
            value, pos = self.parse_type(data, pos, ctx)
            params.append(value)
        return {"hasThis": bool(callconv & 0x20), "propertyType": ptype, "parameterTypes": params}

    def parse_field_signature(self, data: bytes, ctx: SigContext) -> str:
        pos = 0
        if data[pos] == 0x06:
            pos += 1
        value, _ = self.parse_type(data, pos, ctx)
        return value

    def method_access(self, row: Any) -> str:
        f = row.Flags
        if f.mdPublic: return "Public"
        if f.mdFamORAssem: return "ProtectedInternal"
        if f.mdFamily: return "Protected"
        if f.mdFamANDAssem: return "PrivateProtected"
        if f.mdAssem: return "Internal"
        return "Private"

    def field_access(self, row: Any) -> str:
        f = row.Flags
        if f.fdPublic: return "Public"
        if f.fdFamORAssem: return "ProtectedInternal"
        if f.fdFamily: return "Protected"
        if f.fdFamANDAssem: return "PrivateProtected"
        if f.fdAssembly: return "Internal"
        return "Private"

    def constant_value(self, parent: Any) -> Any:
        c = self.constant_by_parent.get(id(parent))
        if c is None:
            return None
        raw = bytes(c.Value.value)
        t = int(c.Type)
        try:
            if t == 0x02: return bool(raw[0])
            if t == 0x03: return chr(struct.unpack("<H", raw[:2])[0])
            if t == 0x04: return struct.unpack("<b", raw[:1])[0]
            if t == 0x05: return raw[0]
            if t == 0x06: return struct.unpack("<h", raw[:2])[0]
            if t == 0x07: return struct.unpack("<H", raw[:2])[0]
            if t == 0x08: return struct.unpack("<i", raw[:4])[0]
            if t == 0x09: return struct.unpack("<I", raw[:4])[0]
            if t == 0x0A: return struct.unpack("<q", raw[:8])[0]
            if t == 0x0B: return struct.unpack("<Q", raw[:8])[0]
            if t == 0x0C: return struct.unpack("<f", raw[:4])[0]
            if t == 0x0D: return struct.unpack("<d", raw[:8])[0]
            if t == 0x0E: return raw.decode("utf-16le")
            if t == 0x12: return None
        except Exception:
            return "0x" + raw.hex()
        return "0x" + raw.hex()

    def accessor_info(self, association: Any) -> list[dict[str, Any]]:
        result = []
        for sem in self.semantics_by_assoc.get(id(association), []):
            method = sem.Method.row
            if sem.Semantics.msGetter: role = "get"
            elif sem.Semantics.msSetter: role = "set"
            elif sem.Semantics.msAddOn: role = "add"
            elif sem.Semantics.msRemoveOn: role = "remove"
            elif sem.Semantics.msFire: role = "raise"
            else: role = "other"
            result.append({"role": role, "method": method, "access": self.method_access(method)})
        return result

    def type_record(self, row: Any) -> dict[str, Any]:
        full_name = self.typedef_full_name(row)
        root = self.root_typedef(row)
        namespace = str(root.TypeNamespace)
        type_params = self.generic_names(row)
        ctx = SigContext(type_params, [])
        kind = self.type_kind(row)
        members: list[dict[str, Any]] = []
        property_accessor_methods: set[int] = set()
        event_accessor_methods: set[int] = set()

        for prop in self.property_by_type.get(id(row), []):
            accessors = self.accessor_info(prop)
            for acc in accessors: property_accessor_methods.add(id(acc["method"]))
            visible = [a for a in accessors if ACCESS_RANK[a["access"]] >= ACCESS_RANK["Protected"]]
            if not visible:
                continue
            sig = self.parse_property_signature(bytes(prop.Type.value), ctx)
            access = max((a["access"] for a in visible), key=lambda x: ACCESS_RANK[x])
            members.append({
                "kind": "Property", "name": str(prop.Name), "access": access,
                "type": sig["propertyType"], "parameterTypes": sig["parameterTypes"],
                "accessors": [{"role": a["role"], "access": a["access"]} for a in accessors],
                "isStatic": all(not a["method"].Flags.mdStatic for a in []) if False else all(a["method"].Flags.mdStatic for a in accessors),
            })

        for event in self.event_by_type.get(id(row), []):
            accessors = self.accessor_info(event)
            for acc in accessors: event_accessor_methods.add(id(acc["method"]))
            visible = [a for a in accessors if ACCESS_RANK[a["access"]] >= ACCESS_RANK["Protected"]]
            if not visible:
                continue
            access = max((a["access"] for a in visible), key=lambda x: ACCESS_RANK[x])
            event_type = self.type_ref_name(event.EventType.row)
            members.append({
                "kind": "Event", "name": str(event.Name), "access": access,
                "type": event_type,
                "accessors": [{"role": a["role"], "access": a["access"]} for a in accessors],
                "isStatic": all(a["method"].Flags.mdStatic for a in accessors),
            })

        for fidx in row.FieldList:
            field = fidx.row
            access = self.field_access(field)
            if ACCESS_RANK[access] < ACCESS_RANK["Protected"]:
                continue
            members.append({
                "kind": "Field", "name": str(field.Name), "access": access,
                "type": self.parse_field_signature(bytes(field.Signature.value), ctx),
                "isStatic": bool(field.Flags.fdStatic),
                "isLiteral": bool(field.Flags.fdLiteral),
                "isReadOnly": bool(field.Flags.fdInitOnly),
                "defaultValue": self.constant_value(field),
            })

        for midx in row.MethodList:
            method = midx.row
            if id(method) in property_accessor_methods or id(method) in event_accessor_methods:
                continue
            access = self.method_access(method)
            if ACCESS_RANK[access] < ACCESS_RANK["Protected"]:
                continue
            method_params = self.generic_names(method)
            mctx = SigContext(type_params, method_params)
            sig, _ = self.parse_method_signature(bytes(method.Signature.value), 0, mctx)
            param_rows = sorted((x.row for x in method.ParamList if int(x.row.Sequence) > 0), key=lambda p: int(p.Sequence))
            params = []
            for index, ptype in enumerate(sig["parameterTypes"]):
                prow = param_rows[index] if index < len(param_rows) else None
                params.append({
                    "name": str(prow.Name) if prow is not None and str(prow.Name) else f"arg{index}",
                    "type": ptype,
                    "optional": bool(prow.Flags.pdOptional) if prow is not None else False,
                    "hasDefault": bool(prow.Flags.pdHasDefault) if prow is not None else False,
                    "defaultValue": self.constant_value(prow) if prow is not None else None,
                    "in": bool(prow.Flags.pdIn) if prow is not None else False,
                    "out": bool(prow.Flags.pdOut) if prow is not None else False,
                })
            name = str(method.Name)
            members.append({
                "kind": "Constructor" if name in (".ctor", ".cctor") else "Method",
                "name": name,
                "access": access,
                "returnType": sig["returnType"],
                "parameters": params,
                "genericParameters": method_params,
                "isStatic": bool(method.Flags.mdStatic),
                "isAbstract": bool(method.Flags.mdAbstract),
                "isVirtual": bool(method.Flags.mdVirtual),
                "isFinal": bool(method.Flags.mdFinal),
            })

        def member_canonical(m: dict[str, Any]) -> str:
            if m["kind"] in ("Method", "Constructor"):
                pars = ",".join(p["type"] for p in m["parameters"])
                return f"{m['kind']}|{m['access']}|{m['name']}|{m.get('returnType','')}|{pars}|static={m['isStatic']}"
            return f"{m['kind']}|{m['access']}|{m['name']}|{m.get('type','')}|static={m.get('isStatic',False)}"
        members.sort(key=member_canonical)
        canonical_members = [member_canonical(m) for m in members]
        member_hash = hashlib.sha256(("\n".join(canonical_members) + "\n").encode()).hexdigest().upper()
        return {
            "fullName": full_name,
            "namespace": namespace,
            "name": str(row.TypeName),
            "kind": kind,
            "access": self.type_access(row),
            "isNested": id(row) in self.nested_parent,
            "declaringType": self.typedef_full_name(self.nested_parent[id(row)]) if id(row) in self.nested_parent else None,
            "genericParameters": type_params,
            "baseType": self.type_ref_name(row.Extends.row) if row.Extends.row else None,
            "isAbstract": bool(row.Flags.tdAbstract),
            "isSealed": bool(row.Flags.tdSealed),
            "memberCount": len(members),
            "memberHash": member_hash,
            "members": members,
        }

    def export(self) -> dict[str, Any]:
        asm = self.tables.Assembly.rows[0]
        module = self.tables.Module.rows[0]
        records = [self.type_record(t) for t in self.typedefs if self.externally_visible_type(t)]
        records.sort(key=lambda x: x["fullName"].lower())
        canonical = []
        for rec in records:
            canonical.append(f"TYPE|{rec['fullName']}|{rec['kind']}|{rec['access']}|{rec['memberHash']}")
        api_hash = hashlib.sha256(("\n".join(canonical) + "\n").encode()).hexdigest().upper()
        return {
            "schema": "Nexamas.UI.AssemblyPublicApi/1.0",
            "assemblyPath": str(self.assembly_path),
            "assemblySha256": sha256_file(self.assembly_path),
            "assemblyName": str(asm.Name),
            "assemblyVersion": f"{asm.MajorVersion}.{asm.MinorVersion}.{asm.BuildNumber}.{asm.RevisionNumber}",
            "moduleMvid": str(module.Mvid),
            "publicApiHash": api_hash,
            "externallyVisibleTypeCount": len(records),
            "topLevelPublicTypeCount": sum(not r["isNested"] for r in records),
            "nestedPublicTypeCount": sum(r["isNested"] for r in records),
            "consumerVisibleDeclaredMemberCount": sum(r["memberCount"] for r in records),
            "directPublicDeclaredMemberCount": sum(1 for r in records for m in r["members"] if m["access"] == "Public"),
            "protectedDeclaredMemberCount": sum(1 for r in records for m in r["members"] if m["access"] == "Protected"),
            "protectedInternalDeclaredMemberCount": sum(1 for r in records for m in r["members"] if m["access"] == "ProtectedInternal"),
            "types": records,
        }


def compile_includes(project: Path) -> list[str]:
    root = ET.parse(project).getroot()
    ns = {"m": "http://schemas.microsoft.com/developer/msbuild/2003"}
    values = []
    for node in root.findall(".//m:Compile", ns):
        value = node.get("Include")
        if value:
            values.append(value.replace("\\", "/"))
    return values


def source_freshness(root: Path, assembly: Path, includes: list[str]) -> list[dict[str, Any]]:
    cutoff = assembly.stat().st_mtime
    result = []
    for rel in includes:
        path = root / rel
        if path.exists() and path.stat().st_mtime > cutoff:
            result.append({
                "path": rel,
                "modifiedUtc": datetime.fromtimestamp(path.stat().st_mtime, timezone.utc).isoformat(),
                "publicDeclarationLines": [
                    {"line": i, "text": line.strip()}
                    for i, line in enumerate(path.read_text(encoding="utf-8-sig", errors="replace").splitlines(), 1)
                    if re.match(r"^\s*(?:<[^>]+>\s*)*(?:Partial\s+|NotInheritable\s+|MustInherit\s+|Shared\s+|Overrides\s+|Overloads\s+|Default\s+|ReadOnly\s+|WriteOnly\s+)*Public\b", line, re.I)
                ],
            })
    return result


def enrich_with_baselines(root: Path, data: dict[str, Any]) -> dict[str, Any]:
    type_baseline_path = root / "eng/tests/Nexamas.UI.PublicSurfaceTypeBaseline.json"
    classification_path = root / "eng/tests/Nexamas.UI.PublicApiClassificationBaseline.json"
    approved_path = root / "eng/tests/Nexamas.UI.ApprovedV1PublicSurface.json"
    type_baseline = read_json(type_baseline_path)
    classification = read_json(classification_path)
    approved = read_json(approved_path)

    baseline_by_simple = defaultdict(list)
    for item in type_baseline["publicTypes"]:
        baseline_by_simple[(item["namespace"], item["name"])].append(item)
    class_by_simple = {(x["namespace"], x["name"]): x for x in classification["classifications"]}
    approved_by_simple = {(x["namespace"], x["name"]): x for x in approved["approvedPublicTypes"]}

    for rec in data["types"]:
        key = (rec["namespace"], rec["name"])
        entries = baseline_by_simple.get(key, [])
        rec["sourceBaselineIds"] = [x["id"] for x in entries]
        rec["sourceEvidence"] = [e for x in entries for e in x.get("evidence", [])]
        cls = class_by_simple.get(key)
        rec["classification"] = cls.get("category") if cls else "UnclassifiedGenerated"
        rec["classificationRationale"] = cls.get("rationale") if cls else "Compiler-generated public nested delegate emitted for a parameterized VB Event; absent from the source-line type baseline."
        rec["v1Approval"] = approved_by_simple.get(key, {}).get("approval")

    assembly_keys = {(r["namespace"], r["name"]) for r in data["types"] if not (r["kind"] == "Delegate" and r["isNested"])}
    baseline_keys = {(x["namespace"], x["name"]) for x in type_baseline["publicTypes"]}
    data["baselineComparison"] = {
        "sourceBaselineDeclaredTypeCount": type_baseline["totalPublicTypeCount"],
        "assemblyExternallyVisibleTypeCount": data["externallyVisibleTypeCount"],
        "assemblyExplicitTypeCountExcludingCompilerGeneratedEventDelegates": len(assembly_keys),
        "baselineOnly": sorted([{"namespace": ns, "name": name} for ns, name in baseline_keys - assembly_keys], key=lambda x: (x["namespace"], x["name"])),
        "assemblyOnlyExplicit": sorted([{"namespace": ns, "name": name} for ns, name in assembly_keys - baseline_keys], key=lambda x: (x["namespace"], x["name"])),
        "compilerGeneratedPublicEventDelegates": [r["fullName"] for r in data["types"] if r["kind"] == "Delegate" and r["isNested"]],
        "nestedIdentityMismatches": [
            {
                "assemblyFullName": r["fullName"],
                "baselineId": r["sourceBaselineIds"][0] if r["sourceBaselineIds"] else None,
            }
            for r in data["types"]
            if r["isNested"] and r["kind"] != "Delegate" and r["sourceBaselineIds"]
        ],
        "finding": "The source baseline flattens three explicit nested public types into namespace-level identities and omits six compiler-generated public delegate types emitted by parameterized VB events.",
    }
    return data


def write_markdown(path: Path, data: dict[str, Any], project_info: dict[str, Any], comparison: dict[str, Any] | None) -> None:
    type_counts = Counter(r["kind"] for r in data["types"])
    namespace_counts = Counter(r["namespace"] for r in data["types"])
    class_counts = Counter(r["classification"] for r in data["types"])
    newer = project_info["compiledSourcesNewerThanAssembly"]
    lines = [
        "# Assembly-derived public API inventory",
        "",
        "> Generated from CLR metadata in the packaged external-consumer assembly. This is audit evidence, not beginner documentation.",
        "",
        "## Assembly identity",
        "",
        f"- Assembly: `{data['assemblyName']}`",
        f"- Version: `{data['assemblyVersion']}`",
        f"- SHA-256: `{data['assemblySha256']}`",
        f"- MVID: `{data['moduleMvid']}`",
        f"- Canonical public API hash: `{data['publicApiHash']}`",
        "",
        "## Verified counts",
        "",
        f"- Consumer-visible types: **{data['externallyVisibleTypeCount']}**",
        f"- Top-level public types: **{data['topLevelPublicTypeCount']}**",
        f"- Nested public types: **{data['nestedPublicTypeCount']}**",
        f"- Declared consumer-visible members (Public/Protected): **{data['consumerVisibleDeclaredMemberCount']}**",
        f"- Direct Public members: **{data['directPublicDeclaredMemberCount']}**",
        f"- Protected members: **{data['protectedDeclaredMemberCount']}**",
        f"- Protected Friend / Protected Internal members: **{data['protectedInternalDeclaredMemberCount']}**",
        f"- Project Compile includes: **{project_info['compileIncludeCount']}**",
        f"- Missing Compile includes: **{len(project_info['missingCompileIncludes'])}**",
        f"- Uncompiled VB files under MASSystem/Component: **{len(project_info['uncompiledSourceFiles'])}**",
        "",
        "### Type kinds",
        "",
        "| Kind | Count |",
        "|---|---:|",
    ]
    lines += [f"| {k} | {v} |" for k, v in sorted(type_counts.items())]
    lines += ["", "### Public namespaces", "", "| Namespace | Types |", "|---|---:|"]
    lines += [f"| `{k}` | {v} |" for k, v in sorted(namespace_counts.items())]
    lines += ["", "### Existing classification mapping", "", "| Classification | Assembly types |", "|---|---:|"]
    lines += [f"| `{k}` | {v} |" for k, v in sorted(class_counts.items())]

    bc = data["baselineComparison"]
    lines += [
        "",
        "## Source-baseline discrepancy",
        "",
        f"The existing source-line baseline reports **{bc['sourceBaselineDeclaredTypeCount']}** records. The compiled assembly exposes **{bc['assemblyExternallyVisibleTypeCount']}** consumer-visible types.",
        "",
        "The difference is structural rather than six intentional new APIs:",
        "",
        "- `MASTileBox.MASTileItem`, `MASTileBox.MASTileClickedEventArgs`, and `MASTypography.MASTextStyle` are public **nested** types. The existing source scanner records them as namespace-level types.",
        "- Six parameterized VB events on `MASApplicationWindowInput` cause the compiler to emit public nested delegate types. The source scanner does not see generated CLR types.",
        "",
        "Generated delegates:",
        "",
    ]
    lines += [f"- `{x}`" for x in bc["compilerGeneratedPublicEventDelegates"]]

    lines += ["", "## Assembly freshness boundary", ""]
    if newer:
        lines += [
            f"The packaged assembly predates **{len(newer)}** compiled source file(s). Therefore type/member metadata is strong evidence for the packaged build, but the current source must remain authoritative for those files until a clean Windows rebuild is produced:",
            "",
        ]
        for item in newer:
            lines.append(f"- `{item['path']}` — {len(item['publicDeclarationLines'])} line(s) beginning with `Public` after the assembly build")
    else:
        lines.append("No compiled source file is newer than the inspected assembly.")

    if comparison:
        lines += [
            "",
            "## Duplicate packaged assembly comparison",
            "",
            f"- Second assembly SHA-256: `{comparison['assemblySha256']}`",
            f"- Second assembly MVID: `{comparison['moduleMvid']}`",
            f"- Public API hash: `{comparison['publicApiHash']}`",
            f"- Public API hashes match: **{'yes' if comparison['publicApiHash'] == data['publicApiHash'] else 'no'}**",
        ]

    lines += [
        "",
        "## Type catalog",
        "",
        "| Type | Kind | Classification | Members | Nested | Member hash |",
        "|---|---|---|---:|---|---|",
    ]
    for rec in data["types"]:
        lines.append(f"| `{rec['fullName']}` | {rec['kind']} | `{rec['classification']}` | {rec['memberCount']} | {'yes' if rec['isNested'] else 'no'} | `{rec['memberHash'][:12]}` |")
    path.write_text("\n".join(lines) + "\n", encoding="utf-8")


def write_csv(path: Path, data: dict[str, Any]) -> None:
    with path.open("w", encoding="utf-8-sig", newline="") as fh:
        writer = csv.DictWriter(fh, fieldnames=["fullName", "namespace", "name", "kind", "isNested", "declaringType", "classification", "v1Approval", "memberCount", "memberHash"])
        writer.writeheader()
        for rec in data["types"]:
            writer.writerow({k: rec.get(k) for k in writer.fieldnames})


def write_member_csv(path: Path, data: dict[str, Any]) -> None:
    fields = ["typeFullName", "typeClassification", "memberKind", "memberName", "access", "isStatic", "returnOrValueType", "parameterTypes", "parameterNames", "optionalDefaults"]
    with path.open("w", encoding="utf-8-sig", newline="") as fh:
        writer = csv.DictWriter(fh, fieldnames=fields)
        writer.writeheader()
        for rec in data["types"]:
            for member in rec["members"]:
                params = member.get("parameters", [])
                writer.writerow({
                    "typeFullName": rec["fullName"],
                    "typeClassification": rec["classification"],
                    "memberKind": member["kind"],
                    "memberName": member["name"],
                    "access": member["access"],
                    "isStatic": member.get("isStatic", False),
                    "returnOrValueType": member.get("returnType", member.get("type", "")),
                    "parameterTypes": " | ".join(p["type"] for p in params) if params else " | ".join(member.get("parameterTypes", [])),
                    "parameterNames": " | ".join(p["name"] for p in params),
                    "optionalDefaults": " | ".join(
                        (p["name"] + "=" + repr(p["defaultValue"]))
                        for p in params if p.get("optional") or p.get("hasDefault")
                    ),
                })


def main() -> int:
    ap = argparse.ArgumentParser()
    ap.add_argument("--root", type=Path, required=True)
    ap.add_argument("--assembly", type=Path, required=True)
    ap.add_argument("--compare-assembly", type=Path)
    ap.add_argument("--output-dir", type=Path, required=True)
    args = ap.parse_args()
    root = args.root.resolve()
    assembly = (root / args.assembly).resolve() if not args.assembly.is_absolute() else args.assembly
    out = (root / args.output_dir).resolve() if not args.output_dir.is_absolute() else args.output_dir
    out.mkdir(parents=True, exist_ok=True)

    data = enrich_with_baselines(root, MetadataExporter(assembly).export())
    includes = compile_includes(root / "Nexamas.UI.vbproj")
    include_set = set(includes)
    actual_vb = sorted(str(p.relative_to(root)).replace("\\", "/") for base in (root / "MASSystem", root / "Component") for p in base.rglob("*.vb"))
    project_info = {
        "compileIncludeCount": len(includes),
        "missingCompileIncludes": [x for x in includes if not (root / x).exists()],
        "uncompiledSourceFiles": [x for x in actual_vb if x not in include_set],
        "compiledSourcesNewerThanAssembly": source_freshness(root, assembly, includes),
    }
    data["projectAlignment"] = project_info

    comparison = None
    if args.compare_assembly:
        other = (root / args.compare_assembly).resolve() if not args.compare_assembly.is_absolute() else args.compare_assembly
        comparison = MetadataExporter(other).export()
        data["comparisonAssembly"] = {
            "assemblyPath": str(other),
            "assemblySha256": comparison["assemblySha256"],
            "moduleMvid": comparison["moduleMvid"],
            "publicApiHash": comparison["publicApiHash"],
            "publicApiMatches": comparison["publicApiHash"] == data["publicApiHash"],
            "typeCountMatches": comparison["externallyVisibleTypeCount"] == data["externallyVisibleTypeCount"],
        }

    (out / "public-api-assembly.json").write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
    write_markdown(out / "public-api-assembly.md", data, project_info, comparison)
    write_csv(out / "public-api-assembly.csv", data)
    write_member_csv(out / "public-api-members.csv", data)
    print(json.dumps({
        "types": data["externallyVisibleTypeCount"],
        "members": data["consumerVisibleDeclaredMemberCount"],
        "apiHash": data["publicApiHash"],
        "newerSources": len(project_info["compiledSourcesNewerThanAssembly"]),
        "output": str(out),
    }, indent=2))
    return 0


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