#!/usr/bin/env python3
"""Build the frozen V2 ABI package from pinned, explicit inputs only.

The generator accepts only (1) ABI JSON from the canonical V2 contracts package,
(2) ABI emitted by solc from an explicitly enumerated pinned Solidity source, or
(3) an ABI field in a frozen response from an explicitly enumerated verified
deployed-source API response. It never queries a network, consults a mirror,
infers an interface from a name, or changes V2 source. Names without such an
input remain evidence-backed blocks.
"""
from __future__ import annotations

import argparse
import hashlib
import json
import re
import subprocess
from pathlib import Path
from typing import Any

ROOT = Path(__file__).resolve().parents[1]
DEPLOYMENTS = ROOT / "baseline/deployments.json"
SOURCE_PLAN = ROOT / "baseline/v2_abi_sources.json"
OUTPUT = ROOT / "legacy/v2/abis"
CANONICAL_ABI_DIR = Path("repos/vaultfire-contracts-repo/abis")
IMPORT_RE = re.compile(r'import\s+(?:[^"\']*?\s+from\s+)?["\']([^"\']+)["\']\s*;')


def canonical(value: object) -> bytes:
    return (json.dumps(value, sort_keys=True, separators=(",", ":")) + "\n").encode()


def digest(payload: bytes) -> str:
    return hashlib.sha256(payload).hexdigest()


def solidity_type(item: dict[str, Any]) -> str:
    value = item["type"]
    if value.startswith("tuple"):
        suffix = value[len("tuple") :]
        value = "(" + ",".join(solidity_type(component) for component in item.get("components", [])) + ")" + suffix
    return value


def signature(entry: dict[str, Any]) -> str:
    return f"{entry.get('name', '')}({','.join(solidity_type(i) for i in entry.get('inputs', []))})"


def read_json(path: Path) -> Any:
    try:
        return json.loads(path.read_text())
    except (OSError, json.JSONDecodeError) as exc:
        raise SystemExit(f"cannot read JSON {path}: {exc}") from exc


def baseline_path(relative: str) -> Path:
    """Resolve an explicit baseline input without allowing it to escape ROOT."""
    path = (ROOT / relative).resolve()
    try:
        path.relative_to(ROOT.resolve())
    except ValueError as exc:
        raise SystemExit(f"baseline input escapes repository root: {relative}") from exc
    if not path.is_file():
        raise SystemExit(f"missing baseline input: {relative}")
    return path


def ensure_pinned_v2(v2_root: Path) -> str:
    expected = (ROOT / "baseline/V2_HEAD_COMMIT.txt").read_text().strip()
    # The V2 checkout is a real checkout in release environments. Ask git rather
    # than resolving .git internals so linked worktrees remain supported.
    result = subprocess.run(["git", "-C", str(v2_root), "rev-parse", "HEAD"], capture_output=True, text=True)
    if result.returncode:
        raise SystemExit(f"cannot resolve pinned V2 HEAD at {v2_root}: {result.stderr.strip()}")
    actual = result.stdout.strip()
    if actual != expected:
        raise SystemExit(f"V2 snapshot drift: expected {expected}, found {actual} at {v2_root}")
    return actual


def relative_v2_path(v2_root: Path, path: Path) -> str:
    try:
        return path.resolve().relative_to(v2_root.resolve()).as_posix()
    except ValueError as exc:
        raise SystemExit(f"source escapes V2 root: {path}") from exc


def source_closure(v2_root: Path, root_source: str) -> dict[str, str]:
    """Return a lexically sorted relative-source closure for solc standard JSON."""
    seen: dict[str, str] = {}

    def visit(relative: str) -> None:
        if relative in seen:
            return
        path = (v2_root / relative).resolve()
        normalized = relative_v2_path(v2_root, path)
        if normalized != relative:
            raise SystemExit(f"non-canonical source path: {relative}")
        if not path.is_file():
            raise SystemExit(f"missing pinned source: {relative}")
        content = path.read_text()
        seen[relative] = content
        for import_path in IMPORT_RE.findall(content):
            if not import_path.startswith("."):
                raise SystemExit(f"unsupported non-local import in {relative}: {import_path}")
            resolved = relative_v2_path(v2_root, path.parent / import_path)
            visit(resolved)

    visit(root_source)
    return {path: seen[path] for path in sorted(seen)}


def solc_version(solc: str) -> str:
    result = subprocess.run([solc, "--version"], capture_output=True, text=True)
    if result.returncode:
        raise SystemExit(f"cannot run {solc} --version: {result.stderr.strip()}")
    return result.stdout.strip()


def compile_abi(
    v2_root: Path, source: str, contract_name: str, solc: str, compiler_input: dict[str, Any] | None = None
) -> tuple[list[Any], dict[str, Any]]:
    sources = source_closure(v2_root, source)
    compiler_input = compiler_input or {}
    source_root = compiler_input.get("sourceRoot")
    if source_root is None:
        source_units = sources
        source_unit = source
        settings = {"outputSelection": {source: {contract_name: ["abi"]}}}
    else:
        if not isinstance(source_root, str) or not source_root:
            raise SystemExit(f"invalid compiler source root for {source}:{contract_name}")
        prefix = source_root.rstrip("/") + "/"
        if not source.startswith(prefix) or any(not path.startswith(prefix) for path in sources):
            raise SystemExit(f"compiler source root does not cover source closure: {source}:{contract_name}")
        source_units = {path.removeprefix(prefix): content for path, content in sources.items()}
        source_unit = source.removeprefix(prefix)
        settings = compiler_input.get("settings")
        if not isinstance(settings, dict) or not settings:
            raise SystemExit(f"missing compiler settings for {source}:{contract_name}")
    standard_input = {
        "language": "Solidity",
        "sources": {name: {"content": content} for name, content in source_units.items()},
        "settings": settings,
    }
    result = subprocess.run([solc, "--standard-json"], input=json.dumps(standard_input), capture_output=True, text=True)
    if result.returncode:
        raise SystemExit(f"solc failed for {source}:{contract_name}: {result.stderr.strip()}")
    stdout = result.stdout
    # Some solc builds prepend an informational line before standard JSON.
    if not stdout.lstrip().startswith("{"):
        stdout = stdout[stdout.find("{") :] if "{" in stdout else stdout
    try:
        output = json.loads(stdout)
    except json.JSONDecodeError as exc:
        raise SystemExit(f"solc emitted invalid JSON for {source}:{contract_name}: {exc}") from exc
    errors = [issue for issue in output.get("errors", []) if issue.get("severity") == "error"]
    if errors:
        messages = "\n".join(issue.get("formattedMessage", str(issue)) for issue in errors)
        raise SystemExit(f"solc compilation errors for {source}:{contract_name}:\n{messages}")
    try:
        artifact = output["contracts"][source_unit][contract_name]
        abi = artifact["abi"]
    except KeyError as exc:
        raise SystemExit(f"solc did not emit ABI for {source}:{contract_name}") from exc
    if not isinstance(abi, list):
        raise SystemExit(f"solc ABI for {source}:{contract_name} is not an array")
    source_records = [{"path": path, "sha256": digest(content.encode())} for path, content in sources.items()]
    actual_solc_version = solc_version(solc)
    expected_solc_version = compiler_input.get("solcVersion")
    if expected_solc_version and expected_solc_version not in actual_solc_version:
        raise SystemExit(
            f"unexpected solc version for {source}:{contract_name}; "
            f"expected token {expected_solc_version}, found {actual_solc_version}"
        )
    expected_input_hash = compiler_input.get("standardInputSha256")
    standard_input_hash = digest(canonical(standard_input))
    if expected_input_hash and expected_input_hash != standard_input_hash:
        raise SystemExit(f"compiler input hash mismatch for {source}:{contract_name}")
    runtime_binding = compiler_input.get("runtimeBytecodeBinding")
    if runtime_binding:
        try:
            runtime_hex = artifact["evm"]["deployedBytecode"]["object"]
            runtime = bytes.fromhex(runtime_hex)
            expected_runtime = runtime_binding["compiledRuntime"]
        except (KeyError, TypeError, ValueError) as exc:
            raise SystemExit(f"invalid runtime-bytecode binding for {source}:{contract_name}") from exc
        if (
            len(runtime) != expected_runtime.get("bytes")
            or digest(runtime) != expected_runtime.get("sha256")
        ):
            raise SystemExit(f"compiled runtime mismatch for {source}:{contract_name}")
    provenance = {
        "kind": "pinned-solidity-source",
        "source": source,
        "sourceContract": contract_name,
        "sourceFiles": source_records,
        "solcVersion": actual_solc_version,
        "standardInputSha256": standard_input_hash,
    }
    if compiler_input:
        provenance["compilerInput"] = compiler_input
    return abi, provenance


def evidence_records(v2_root: Path, evidence: list[str]) -> list[dict[str, str]]:
    records = []
    for relative in evidence:
        path = (v2_root / relative).resolve()
        if relative_v2_path(v2_root, path) != relative or not path.is_file():
            raise SystemExit(f"missing pinned deployment evidence: {relative}")
        records.append({"path": relative, "sha256": digest(path.read_bytes())})
    return sorted(records, key=lambda item: item["path"])


def blocker_evidence_records(v2_root: Path, evidence: list[str]) -> list[dict[str, str]]:
    records = []
    for reference in evidence:
        if reference.startswith("v2:"):
            relative = reference.removeprefix("v2:")
            path = (v2_root / relative).resolve()
            if relative_v2_path(v2_root, path) != relative or not path.is_file():
                raise SystemExit(f"missing pinned blocker evidence: {reference}")
        else:
            path = ROOT / reference
            if not path.is_file():
                raise SystemExit(f"missing baseline blocker evidence: {reference}")
        records.append({"path": reference, "sha256": digest(path.read_bytes())})
    return sorted(records, key=lambda item: item["path"])


def verified_deployed_abi(
    entry: dict[str, Any], deployments: list[dict[str, Any]]
) -> tuple[list[Any], dict[str, Any]]:
    """Read one frozen, uncached Blockscout API response and validate its chain binding."""
    contract = entry["contract"]
    response_path = entry["response"]
    provenance_path = entry["provenance"]
    response_bytes = baseline_path(response_path).read_bytes()
    metadata_path = baseline_path(provenance_path)
    metadata_bytes = metadata_path.read_bytes()
    metadata = read_json(metadata_path)
    if metadata.get("schemaVersion") != "1.0.0":
        raise SystemExit(f"unsupported verified-source metadata schema: {contract}")
    raw = metadata.get("rawResponse", {})
    retrieval = metadata.get("retrieval", {})
    verified = metadata.get("verifiedContract", {})
    if (
        raw.get("path") != response_path
        or raw.get("sha256") != digest(response_bytes)
        or raw.get("bytes") != len(response_bytes)
    ):
        raise SystemExit(f"verified source response hash mismatch: {contract}")
    envelope_path = retrieval.get("contentFetchEnvelope")
    if not isinstance(envelope_path, str):
        raise SystemExit(f"verified source fetch envelope missing: {contract}")
    envelope_bytes = baseline_path(envelope_path).read_bytes()
    if retrieval.get("contentFetchEnvelopeSha256") != digest(envelope_bytes):
        raise SystemExit(f"verified source fetch envelope hash mismatch: {contract}")
    envelope = read_json(baseline_path(envelope_path))
    try:
        fetched = envelope["results"][0]
    except (KeyError, IndexError, TypeError) as exc:
        raise SystemExit(f"invalid frozen fetch envelope: {contract}") from exc
    if (
        fetched.get("error") is not None
        or fetched.get("is_cached") is not False
        or fetched.get("raw_html", "").encode() != response_bytes
        or fetched.get("url") != retrieval.get("url")
        or entry.get("url") != retrieval.get("url")
        or entry.get("provider") != "Base Blockscout v1 API"
        or retrieval.get("method") != "pplx_sdk.content.fetch"
        or retrieval.get("cacheEnabled") is not False
        or retrieval.get("returnHtml") is not True
        or retrieval.get("returnedField") != "raw_html"
        or retrieval.get("isCached") is not False
    ):
        raise SystemExit(f"verified source fetch provenance is incomplete or mismatched: {contract}")
    response = read_json(baseline_path(response_path))
    try:
        source = response["result"][0]
    except (KeyError, IndexError, TypeError) as exc:
        raise SystemExit(f"invalid verified source response: {contract}") from exc
    if response.get("status") != "1" or response.get("message") != "OK" or len(response.get("result", [])) != 1:
        raise SystemExit(f"verified source response is not a successful singleton: {contract}")
    matching_deployments = [
        deployment for deployment in deployments
        if deployment["name"] == contract
        and deployment["chainId"] == entry["chainId"]
        and deployment["address"].lower() == entry["address"].lower()
    ]
    if len(matching_deployments) != 1:
        raise SystemExit(f"verified source does not match an exact deployed address: {contract}")
    if (
        source.get("Address", "").lower() != entry["address"].lower()
        or source.get("ContractName") != contract
        or source.get("CompilerVersion") != entry.get("compilerVersion")
        or source.get("CompilerVersion") != verified.get("compilerVersion")
        or verified.get("chain") != "base"
        or verified.get("chainId") != entry["chainId"]
        or verified.get("address", "").lower() != entry["address"].lower()
        or verified.get("contractName") != contract
        or digest(source.get("SourceCode", "").encode()) != verified.get("sourceCodeSha256")
    ):
        raise SystemExit(f"verified source identity mismatch: {contract}")
    try:
        abi_field = source["ABI"]
        abi = json.loads(abi_field)
    except (KeyError, TypeError, json.JSONDecodeError) as exc:
        raise SystemExit(f"verified source has no valid ABI field: {contract}") from exc
    if not isinstance(abi, list):
        raise SystemExit(f"verified source ABI is not an array: {contract}")
    abi_payload = canonical(abi)
    if (
        digest(abi_field.encode()) != verified.get("abiFieldSha256")
        or digest(abi_payload) != verified.get("canonicalAbiSha256")
        or len(abi) != verified.get("abiEntries")
    ):
        raise SystemExit(f"verified source ABI hash mismatch: {contract}")
    provenance = {
        "kind": "verified-deployed-source",
        "provider": entry["provider"],
        "url": retrieval["url"],
        "retrievedAt": metadata["retrievedAt"],
        "chainId": entry["chainId"],
        "address": entry["address"].lower(),
        "response": {"path": response_path, "sha256": digest(response_bytes)},
        "fetchEnvelope": {"path": envelope_path, "sha256": digest(envelope_bytes)},
        "metadata": {"path": provenance_path, "sha256": digest(metadata_bytes)},
        "compilerVersion": source["CompilerVersion"],
        "sourceCodeSha256": verified["sourceCodeSha256"],
        "abiFieldSha256": verified["abiFieldSha256"],
    }
    return abi, provenance


def make_record(contract: str, abi: list[Any], provenance: dict[str, Any]) -> tuple[dict[str, Any], bytes]:
    payload = canonical(abi)
    return ({
        "contract": contract,
        "file": f"abis/{contract}.json",
        "sha256": digest(payload),
        "entries": len(abi),
        "functions": sorted(signature(item) for item in abi if item.get("type") == "function"),
        "events": sorted(signature(item) for item in abi if item.get("type") == "event"),
        "errors": sorted(signature(item) for item in abi if item.get("type") == "error"),
        "provenance": provenance,
    }, payload)


def compiler_for_entry(entry: dict[str, Any], default_solc: str, overrides: dict[str, str]) -> str:
    compiler_input = entry.get("compilerInput")
    expected_version = compiler_input.get("solcVersion") if isinstance(compiler_input, dict) else None
    if not expected_version:
        return default_solc
    compiler = overrides.get(expected_version)
    if not compiler:
        raise SystemExit(
            f"{entry['contract']} requires solc {expected_version}; "
            f"pass --compiler {expected_version}=/path/to/solc"
        )
    return compiler


def build(v2_root: Path, solc: str = "solc", compiler_overrides: dict[str, str] | None = None) -> dict[str, Any]:
    v2_root = v2_root.resolve()
    compiler_overrides = compiler_overrides or {}
    v2_head = ensure_pinned_v2(v2_root)
    deployments = read_json(DEPLOYMENTS)["contracts"]
    deployment_names = sorted({entry["name"] for entry in deployments})
    plan = read_json(SOURCE_PLAN)
    source_entries = plan["sources"]
    verified_entries = plan.get("verifiedDeployedSources", [])
    blocked = plan["blocked"]
    artifact_search = plan.get("compiledArtifactSearch")
    if not isinstance(artifact_search, dict) or not artifact_search.get("paths") or not artifact_search.get("result"):
        raise SystemExit("source plan must record compiled artifact search evidence")
    source_names = [entry["contract"] for entry in source_entries]
    verified_names = [entry["contract"] for entry in verified_entries]
    blocked_names = [entry["contract"] for entry in blocked]
    all_planned_names = source_names + verified_names + blocked_names
    if len(set(all_planned_names)) != len(all_planned_names):
        raise SystemExit("source plan contains duplicate contract names")

    records: list[dict[str, Any]] = []
    outputs: dict[str, bytes] = {}
    canonical_dir = v2_root / CANONICAL_ABI_DIR
    for path in sorted(canonical_dir.glob("*.json")):
        abi = read_json(path)
        if not isinstance(abi, list):
            raise SystemExit(f"{path}: expected top-level ABI array")
        name = path.stem
        record, payload = make_record(name, abi, {
            "kind": "canonical-abi-json",
            "source": relative_v2_path(v2_root, path),
            "sourceSha256": digest(path.read_bytes()),
        })
        records.append(record)
        outputs[record["file"]] = payload

    for entry in sorted(source_entries, key=lambda item: item["contract"]):
        name = entry["contract"]
        compiler_input = entry.get("compilerInput")
        abi, provenance = compile_abi(
            v2_root,
            entry["source"],
            entry["sourceContract"],
            compiler_for_entry(entry, solc, compiler_overrides),
            compiler_input,
        )
        if entry.get("deploymentEvidence"):
            provenance["deploymentEvidence"] = evidence_records(v2_root, entry["deploymentEvidence"])
        if "note" in entry:
            provenance["note"] = entry["note"]
        record, payload = make_record(name, abi, provenance)
        records.append(record)
        outputs[record["file"]] = payload

    for entry in sorted(verified_entries, key=lambda item: item["contract"]):
        abi, provenance = verified_deployed_abi(entry, deployments)
        record, payload = make_record(entry["contract"], abi, provenance)
        records.append(record)
        outputs[record["file"]] = payload

    records.sort(key=lambda record: record["contract"])
    record_names = [record["contract"] for record in records]
    if len(set(record_names)) != len(record_names):
        raise SystemExit("canonical ABI inputs and pinned source plan overlap")
    unexpected = sorted(set(record_names + blocked_names) - set(deployment_names))
    missing = sorted(set(deployment_names) - set(record_names) - set(blocked_names))
    if unexpected or missing:
        raise SystemExit(f"source plan/deployments mismatch; unexpected={unexpected}, missing={missing}")

    blocked_records = []
    for entry in blocked:
        record = dict(entry)
        record["evidenceSha256"] = blocker_evidence_records(v2_root, entry["evidence"])
        blocked_records.append(record)

    manifest = {
        "schemaVersion": "2.2.0",
        "v2Head": v2_head,
        "canonicalAbiSource": CANONICAL_ABI_DIR.as_posix(),
        "sourcePlan": "baseline/v2_abi_sources.json",
        "compiledArtifactSearch": artifact_search,
        "canonicalAbiCount": len(records),
        "deployedUniqueContractCount": len(deployment_names),
        "records": records,
        "blockedDeployedContracts": sorted(blocked_records, key=lambda item: item["contract"]),
        "notes": [
            "ABIs are copied only from pinned canonical ABI JSON, emitted from an explicit pinned Solidity source closure, or read from frozen verified deployed-source API evidence.",
            "The package never downloads sources, uses mirrors, or infers interfaces from contract names or bytecode.",
            "VaultfireTrustAttestation is retained as a public deployment alias and is bound to the exact TrustPortabilityExtension source by compiler settings plus all four frozen deployed runtime observations.",
            "V2 source and deployed bytecode are immutable and untouched.",
        ],
    }
    OUTPUT.mkdir(parents=True, exist_ok=True)
    for relative, payload in sorted(outputs.items()):
        (OUTPUT.parent / relative).write_bytes(payload)
    (OUTPUT.parent / "manifest.json").write_bytes(canonical(manifest))
    return manifest


def main() -> None:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--v2-root", type=Path, default=ROOT / "v2-repo", help="pinned V2 checkout (default: repository v2-repo)")
    parser.add_argument("--solc", default="solc", help="solc executable used for explicit Solidity sources")
    parser.add_argument(
        "--compiler",
        action="append",
        default=[],
        metavar="VERSION=EXECUTABLE",
        help="override the compiler executable required by an exact source plan; may be repeated",
    )
    args = parser.parse_args()
    compiler_overrides: dict[str, str] = {}
    for value in args.compiler:
        version, separator, executable = value.partition("=")
        if not separator or not version or not executable or version in compiler_overrides:
            parser.error("--compiler entries must be unique VERSION=EXECUTABLE pairs")
        compiler_overrides[version] = executable
    manifest = build(args.v2_root, args.solc, compiler_overrides)
    print(f"froze {manifest['canonicalAbiCount']} traceable V2 ABIs; {len(manifest['blockedDeployedContracts'])} deployed names remain explicitly blocked")


if __name__ == "__main__":
    main()
