#!/usr/bin/env python3
"""Fail-closed, unsigned-only Base Sepolia bonds-pilot rehearsal recorder.

This tool never imports a wallet, accepts a secret, signs a payload, sends a
transaction, or deploys a contract.  It can only:
  * render an unsigned, human-reviewable action plan;
  * check public addresses and a read-only RPC endpoint; and
  * validate the recorded outcome of a separately performed rehearsal.

It deliberately exits non-zero if a gate is incomplete.  A successful local
syntax check is not an authorization to deploy or activate a pilot.
"""
from __future__ import annotations

import argparse
import copy
from collections import Counter
import json
import os
import re
import sys
import urllib.error
import urllib.request
from datetime import datetime, timezone
from pathlib import Path
from typing import Any

try:
    from canonical_json import load_json_file
except ModuleNotFoundError:
    import importlib.util
    _canon_spec = importlib.util.spec_from_file_location("vaultfire_canonical_json", Path(__file__).with_name("canonical_json.py"))
    assert _canon_spec and _canon_spec.loader
    _canon = importlib.util.module_from_spec(_canon_spec); _canon_spec.loader.exec_module(_canon)
    load_json_file = _canon.load_json_file

BASE_SEPOLIA_CHAIN_ID = 84532
ADDRESS_RE = re.compile(r"^0x[a-fA-F0-9]{40}$")
TX_RE = re.compile(r"^0x[a-fA-F0-9]{64}$")
INTEGER_RE = re.compile(r"^(0|[1-9][0-9]*)$")
FORBIDDEN_KEY_RE = re.compile(r"(private|secret|mnemonic|seed|password|keystore|credential|api[_-]?key)", re.I)
FORBIDDEN_VALUE_RE = re.compile(r"^(0x)?[a-fA-F0-9]{64}$")

REQUIRED_ROLE_SETS = {"governanceSigners": 5}
SINGLE_ROLES = ("guardian", "treasury", "namedPartner", "counsel", "auditor")

SCENARIOS = [
    ("GOV-01", "governance confirm / execute", ["ProposedV3", "ConfirmedV3", "ExecutedV3"]),
    ("GOV-02", "governance threshold cancellation", ["ProposedV3", "CancellationConfirmedV3", "CancelledV3"]),
    ("PILOT-01", "guardian pause and governance unpause", ["GuardianPausedV3", "PausedV3", "UnpausedV3"]),
    ("PILOT-02", "one-partner and module allowlist", ["DesignPartnerSetV3", "ModuleSetV3", "PositionRegisteredV3"]),
    ("PART-01", "Partnership typed mutual-consent settlement and pull withdrawals", ["BondCreatedV3", "BondFundedV3", "BondActivatedV3", "MutualSettlementExecutedV3", "BondSettledV3"]),
    ("PART-02", "Partnership funded-never-activated expiry and principal refund", ["BondFundedV3", "BondExpiredV3"]),
    ("ACC-01", "Accountability typed mutual-consent slash/refund and withdrawal", ["BondCreatedV3", "BondFundedV3", "BondActivatedV3", "MutualSettlementExecutedV3", "BondSettledV3"]),
    ("DISP-01", "generic dispute input cannot change Base-pilot custody", []),
    ("DISP-02", "behavioral, quality, reputation, and surveillance hashes cannot freeze or settle", []),
    ("DISP-03", "dispute/consensus dependencies are not custody authorities", []),
    ("TIME-01", "exact proposal, funding, activation, authorization and expiry deadlines", []),
    ("WITHDRAW-01", "withdrawal or expiry refund remains available while paused", ["PausedV3", "BondExpiredV3"]),
    ("EXIT-01", "permissionless force exit after liveness deadline", ["ForceExitedV3"]),
    ("CAP-01", "per-party, per-bond, aggregate, global and per-position cap rejection", []),
    ("REPLAY-01", "terms and mutual-settlement authorization replay rejection", []),
    ("COMP-01", "compromised signer, guardian, or owner containment", ["GuardianPausedV3"]),
    ("ROT-01", "signer and guardian rotation timing, acceptance, and stale proposal invalidation", ["GuardianRotationProposedV3", "GuardianUpdatedV3", "SignersUpdatedV3"]),
    ("MON-01", "events, liabilities, caps, pause state, and monitoring", []),
    ("MIG-01", "migration notice, read-only export, rollback and no-loss withdrawal path", []),
]


def load_json(path: Path) -> dict[str, Any]:
    value = load_json_file(path, str(path))
    if not isinstance(value, dict):
        raise ValueError(f"{path} must contain a JSON object")
    return value


def dump_json(value: dict[str, Any], path: Path | None) -> None:
    serialized = json.dumps(value, indent=2, sort_keys=True) + "\n"
    if path:
        path.parent.mkdir(parents=True, exist_ok=True)
        path.write_text(serialized, encoding="utf-8")
    else:
        print(serialized, end="")


def public_only(value: Any, path: str = "$", allow_transaction_hash: bool = False) -> list[str]:
    """Reject fields and values that look like credentials or private keys."""
    errors: list[str] = []
    if isinstance(value, dict):
        for key, child in value.items():
            child_path = f"{path}.{key}"
            if FORBIDDEN_KEY_RE.search(key):
                errors.append(f"{child_path}: forbidden secret-bearing field")
            errors.extend(public_only(child, child_path, key == "transactionHashes"))
    elif isinstance(value, list):
        for index, child in enumerate(value):
            errors.extend(public_only(child, f"{path}[{index}]", allow_transaction_hash))
    elif (isinstance(value, str) and FORBIDDEN_VALUE_RE.fullmatch(value) and not ADDRESS_RE.fullmatch(value)
          and not (allow_transaction_hash and TX_RE.fullmatch(value))):
        errors.append(f"{path}: value resembles a private key or transaction hash")
    return errors


def address(value: Any) -> bool:
    return isinstance(value, str) and bool(ADDRESS_RE.fullmatch(value))


def valid_uint(value: Any) -> bool:
    return isinstance(value, str) and bool(INTEGER_RE.fullmatch(value))


def collect_addresses(intake: dict[str, Any]) -> list[str]:
    values: list[str] = []
    for role in REQUIRED_ROLE_SETS:
        for item in intake.get(role, []):
            if isinstance(item, dict) and address(item.get("publicAddress")):
                values.append(item["publicAddress"].lower())
    for role in SINGLE_ROLES:
        item = intake.get(role, {})
        if isinstance(item, dict) and address(item.get("publicAddress")):
            values.append(item["publicAddress"].lower())
    return values


def validate_intake(intake: dict[str, Any]) -> list[str]:
    errors = public_only(intake)
    if intake.get("schema") != "vaultfire.base-sepolia-public-intake.v1":
        errors.append("$.schema: must be vaultfire.base-sepolia-public-intake.v1")
    if intake.get("network", {}).get("chainId") != BASE_SEPOLIA_CHAIN_ID:
        errors.append(f"$.network.chainId: must be {BASE_SEPOLIA_CHAIN_ID}")
    for role, required in REQUIRED_ROLE_SETS.items():
        entries = intake.get(role)
        if not isinstance(entries, list) or len(entries) != required:
            errors.append(f"$.{role}: exactly {required} public-address entries are required")
            continue
        for index, entry in enumerate(entries):
            if not isinstance(entry, dict) or not address(entry.get("publicAddress")):
                errors.append(f"$.{role}[{index}].publicAddress: valid public EVM address required")
            if not isinstance(entry, dict) or not entry.get("publicContact"):
                errors.append(f"$.{role}[{index}].publicContact: public contact route required")
    for role in SINGLE_ROLES:
        entry = intake.get(role)
        if not isinstance(entry, dict) or not address(entry.get("publicAddress")):
            errors.append(f"$.{role}.publicAddress: valid public EVM address required")
        if not isinstance(entry, dict) or not entry.get("publicContact"):
            errors.append(f"$.{role}.publicContact: public contact route required")
    if not intake.get("namedPartner", {}).get("legalOrTradingName"):
        errors.append("$.namedPartner.legalOrTradingName: named partner required")
    if not intake.get("namedPartner", {}).get("writtenAcceptanceReference"):
        errors.append("$.namedPartner.writtenAcceptanceReference: written partner acceptance reference required")
    signers = intake.get("governanceSigners", [])
    for index, signer in enumerate(signers):
        if not isinstance(signer, dict):
            continue
        if signer.get("authorizedToSign") is not True or not signer.get("authorizationReference"):
            errors.append(
                f"$.governanceSigners[{index}]: public authorization attestation and reference required; "
                "do not provide a signature or secret"
            )
    if len(set(collect_addresses(intake))) != len(collect_addresses(intake)):
        errors.append("$.roles: public addresses must be distinct across pilot roles")
    guardian_value = intake.get("guardian", {}).get("publicAddress")
    guardian = guardian_value.lower() if address(guardian_value) else ""
    signer_addresses = [
        item["publicAddress"].lower()
        for item in signers
        if isinstance(item, dict) and address(item.get("publicAddress"))
    ]
    if guardian and guardian in signer_addresses:
        errors.append("$.guardian: guardian must not also be a governance signer")
    if not intake.get("custodyAndIndependence", {}).get("accepted"):
        errors.append("$.custodyAndIndependence.accepted: explicit acceptance required")
    for field in (
        "publicCustodyStatementReference",
        "publicIndependenceStatementReference",
        "confirmGuardianIsNotSigner",
        "confirmPartnerHasNoGovernanceRole",
        "confirmAttestersAreOperationallyIndependent",
    ):
        if not intake.get("custodyAndIndependence", {}).get(field):
            errors.append(f"$.custodyAndIndependence.{field}: required")
    if not intake.get("riskAcceptance", {}).get("accepted"):
        errors.append("$.riskAcceptance.accepted: explicit acceptance required")
    for field in (
        "acceptanceReference",
        "acceptsTestnetOnlyNoDeploymentAuthorization",
        "acceptsPublicChainDataWarning",
        "acceptsPauseAndRollbackProcedure",
    ):
        if not intake.get("riskAcceptance", {}).get(field):
            errors.append(f"$.riskAcceptance.{field}: required")
    if not intake.get("incidentContacts", {}).get("accepted"):
        errors.append("$.incidentContacts.accepted: public incident contact acceptance required")
    for field in ("publicPrimaryRoute", "publicBackupRoute", "availabilityReference"):
        if not intake.get("incidentContacts", {}).get(field):
            errors.append(f"$.incidentContacts.{field}: required")
    if not intake.get("counsel", {}).get("launchReviewReference"):
        errors.append("$.counsel.launchReviewReference: required")
    if not intake.get("auditor", {}).get("scopeReference"):
        errors.append("$.auditor.scopeReference: required")
    return errors


def rpc_check(rpc_url: str | None) -> dict[str, Any]:
    result: dict[str, Any] = {"configured": bool(rpc_url), "reachable": False, "chainId": None, "errors": []}
    if not rpc_url:
        result["errors"].append("No read-only Base Sepolia RPC URL was supplied.")
        return result
    payload = json.dumps({"jsonrpc": "2.0", "id": 1, "method": "eth_chainId", "params": []}).encode()
    request = urllib.request.Request(
        rpc_url,
        data=payload,
        headers={
            "Content-Type": "application/json",
            "User-Agent": "Vaultfire-Base-Sepolia-Rehearsal/1.0",
        },
        method="POST",
    )
    try:
        with urllib.request.urlopen(request, timeout=10) as response:
            body = json.loads(response.read().decode())
        if "error" in body:
            result["errors"].append(f"RPC error: {body['error']}")
            return result
        chain_id = int(body.get("result", "0x0"), 16)
        result["reachable"] = True
        result["chainId"] = chain_id
        if chain_id != BASE_SEPOLIA_CHAIN_ID:
            result["errors"].append(f"RPC chainId {chain_id} is not Base Sepolia ({BASE_SEPOLIA_CHAIN_ID}).")
    except (urllib.error.URLError, TimeoutError, ValueError, json.JSONDecodeError) as exc:
        result["errors"].append(f"Read-only RPC check failed: {exc}")
    return result


def unsigned_plan(intake: dict[str, Any], intake_errors: list[str], rpc: dict[str, Any]) -> dict[str, Any]:
    public_addresses = collect_addresses(intake)
    gates = {
        "publicAddressIntakeComplete": not intake_errors,
        "readOnlyRpcReachableOnBaseSepolia": bool(rpc["reachable"] and rpc["chainId"] == BASE_SEPOLIA_CHAIN_ID),
        "authorizedSigningPubliclyAttested": not any("authorization" in error for error in intake_errors),
        "deploymentApprovalRecorded": False,
        "broadcastEnabled": False,
    }
    return {
        "schema": "vaultfire.base-sepolia-unsigned-rehearsal-plan.v1",
        "createdAt": datetime.now(timezone.utc).isoformat(),
        "network": {"name": "Base Sepolia", "chainId": BASE_SEPOLIA_CHAIN_ID},
        "mode": "UNSIGNED_PLAN_ONLY",
        "broadcastProhibited": True,
        "signingProhibited": True,
        "secretsAccepted": False,
        "publicAddressesCollected": public_addresses,
        "gates": gates,
        "failClosed": not all(gates.values()),
        "blockers": intake_errors + rpc["errors"] + [
            "No deployment approval can be created by this tool.",
            "This plan contains no calldata, signature, private key, seed phrase, or broadcast action.",
        ],
        "steps": [
            {
                "id": scenario_id,
                "description": description,
                "requiredEvents": events,
                "requiresHumanAction": True,
                "unsigned": True,
            }
            for scenario_id, description, events in SCENARIOS
        ],
    }


def validate_record(record: dict[str, Any]) -> list[str]:
    errors = public_only(record)
    if record.get("schema") != "vaultfire.base-sepolia-rehearsal-record.v1":
        errors.append("$.schema: invalid rehearsal record schema")
    if record.get("network", {}).get("chainId") != BASE_SEPOLIA_CHAIN_ID:
        errors.append("$.network.chainId: record must be Base Sepolia")
    expected_ids = [scenario[0] for scenario in SCENARIOS]
    expected_counts = Counter(expected_ids)
    scenarios = record.get("scenarios")
    scenario_counts = Counter(
        item.get("id") for item in scenarios if isinstance(item, dict) and isinstance(item.get("id"), str)
    ) if isinstance(scenarios, list) else Counter()
    if (
        not isinstance(scenarios, list)
        or len(scenarios) != len(expected_ids)
        or scenario_counts != expected_counts
    ):
        errors.append("$.scenarios: exactly one entry is required for every checklist scenario")
    else:
        required_events = {scenario_id: events for scenario_id, _, events in SCENARIOS}
        for scenario in scenarios:
            scenario_id = scenario.get("id")
            status = scenario.get("status")
            if status not in {"planned", "observed", "failed"}:
                errors.append(f"$.scenarios[{scenario_id}].status: planned, observed, or failed required")
            if not isinstance(scenario.get("expectedState"), dict):
                errors.append(f"$.scenarios[{scenario_id}].expectedState: object required")
            if status in {"observed", "failed"} and not isinstance(scenario.get("actualState"), dict):
                errors.append(f"$.scenarios[{scenario_id}].actualState: object required for outcome")
            if status in {"observed", "failed"} and not scenario.get("actualState"):
                errors.append(f"$.scenarios[{scenario_id}].actualState: non-empty exact outcome required")
            hashes = scenario.get("transactionHashes", [])
            if not isinstance(hashes, list) or any(not isinstance(item, str) or not TX_RE.fullmatch(item) for item in hashes):
                errors.append(f"$.scenarios[{scenario_id}].transactionHashes: only public transaction hashes are allowed")
            if status == "observed":
                if not hashes:
                    errors.append(f"$.scenarios[{scenario_id}].transactionHashes: observed action requires a receipt hash")
                actual_events = scenario.get("events", [])
                names = {event.get("name") for event in actual_events if isinstance(event, dict)}
                missing = set(required_events[scenario_id]) - names
                if missing:
                    errors.append(f"$.scenarios[{scenario_id}].events: missing required events {sorted(missing)}")
                for event_index, event in enumerate(actual_events):
                    prefix = f"$.scenarios[{scenario_id}].events[{event_index}]"
                    if not isinstance(event, dict) or not event.get("name"):
                        errors.append(f"{prefix}.name: event name required")
                    elif not address(event.get("emitter")):
                        errors.append(f"{prefix}.emitter: public contract address required")
                    elif not valid_uint(event.get("logIndex")):
                        errors.append(f"{prefix}.logIndex: exact log index string required")
                    elif not valid_uint(event.get("blockNumber")):
                        errors.append(f"{prefix}.blockNumber: exact block number string required")
    contracts = record.get("contracts", {})
    if not isinstance(contracts, dict):
        errors.append("$.contracts: object required")
    else:
        for label, value in contracts.items():
            if value is not None and not address(value):
                errors.append(f"$.contracts.{label}: contract address must be public EVM address or null before deployment")
        if any(isinstance(item, dict) and item.get("status") == "observed" for item in record.get("scenarios", [])):
            for label in ("governance", "controller", "consensus", "disputeRegistry",
                          "partnershipBond", "accountabilityBond", "bondFactory"):
                if not address(contracts.get(label)):
                    errors.append(f"$.contracts.{label}: observed rehearsal requires recorded public contract address")
    reconciliations = record.get("reconciliations", [])
    if not isinstance(reconciliations, list) or not reconciliations:
        errors.append("$.reconciliations: at least one balance/liability reconciliation is required")
    else:
        for index, entry in enumerate(reconciliations):
            prefix = f"$.reconciliations[{index}]"
            for field in ("observedBalanceAtomic", "principalLiabilityAtomic", "pendingCreditLiabilityAtomic"):
                if not valid_uint(entry.get(field)):
                    errors.append(f"{prefix}.{field}: unsigned atomic integer string required")
            for field in ("blockNumber", "balanceEvidence", "liabilityEvidence"):
                if not entry.get(field):
                    errors.append(f"{prefix}.{field}: required")
            if not valid_uint(entry.get("blockNumber")):
                errors.append(f"{prefix}.blockNumber: exact block number string required")
            if all(valid_uint(entry.get(field)) for field in
                   ("observedBalanceAtomic", "principalLiabilityAtomic", "pendingCreditLiabilityAtomic")):
                balance = int(entry["observedBalanceAtomic"])
                liabilities = int(entry["principalLiabilityAtomic"]) + int(entry["pendingCreditLiabilityAtomic"])
                if balance < liabilities:
                    errors.append(f"{prefix}: assets {balance} are less than liabilities {liabilities}")
    return errors


def template_record() -> dict[str, Any]:
    return {
        "schema": "vaultfire.base-sepolia-rehearsal-record.v1",
        "network": {"name": "Base Sepolia", "chainId": BASE_SEPOLIA_CHAIN_ID},
        "contracts": {
            "governance": None, "controller": None, "consensus": None, "disputeRegistry": None,
            "partnershipBond": None, "accountabilityBond": None, "bondFactory": None,
        },
        "scenarios": [
            {
                "id": scenario_id,
                "title": description,
                "status": "planned",
                "transactionHashes": [],
                "events": [],
                "expectedState": {
                    "assertions": [
                        "Record exact deadline timestamps and the block timestamp used for each boundary check.",
                        "Record expected state, permissions, balances, liabilities, and revert/success result before action.",
                    ]
                },
                "actualState": {},
                "evidenceReferences": [],
                "operator": "public-address-only; no secret or signature is recorded",
            }
            for scenario_id, description, _ in SCENARIOS
        ],
        "reconciliations": [{
            "asset": "native ETH",
            "contractLabel": "partnershipBond",
            "observedBalanceAtomic": "0",
            "principalLiabilityAtomic": "0",
            "pendingCreditLiabilityAtomic": "0",
            "blockNumber": "0",
            "balanceEvidence": "eth_getBalance block-tag and a public explorer URL",
            "liabilityEvidence": "public view-call output / event-derived ledger / exact block number",
        }],
        "recordingRules": [
            "Use only public contract addresses, transaction hashes, event logs, block numbers and public URLs.",
            "Never record private keys, seed phrases, signatures, passwords, API credentials, or keystore material.",
            "A planned entry is not a passed rehearsal; mark observed only after expected versus actual evidence is attached.",
            "Any failed assertion, RPC ambiguity, missing event, or asset/liability mismatch is a stop condition.",
        ],
    }


def main() -> int:
    parser = argparse.ArgumentParser(description=__doc__)
    subparsers = parser.add_subparsers(dest="command", required=True)
    plan_parser = subparsers.add_parser("plan", help="render an unsigned-only plan")
    plan_parser.add_argument("--intake", type=Path, required=True)
    plan_parser.add_argument("--rpc-url", default=os.getenv("BASE_SEPOLIA_RPC_URL"))
    plan_parser.add_argument("--out", type=Path)
    gate_parser = subparsers.add_parser("gate", help="perform fail-closed public preflight")
    gate_parser.add_argument("--intake", type=Path, required=True)
    gate_parser.add_argument("--rpc-url", default=os.getenv("BASE_SEPOLIA_RPC_URL"))
    gate_parser.add_argument("--out", type=Path)
    template_parser = subparsers.add_parser("record-template", help="write a public-only outcome record template")
    template_parser.add_argument("--out", type=Path, required=True)
    record_parser = subparsers.add_parser("validate-record", help="validate recorded outcomes and reconciliation")
    record_parser.add_argument("--record", type=Path, required=True)
    record_parser.add_argument("--out", type=Path)
    args = parser.parse_args()

    if args.command == "record-template":
        dump_json(template_record(), args.out)
        return 0
    if args.command == "validate-record":
        errors = validate_record(load_json(args.record))
        record = load_json(args.record)
        scenarios = record.get("scenarios", [])
        scenarios_by_id = {
            item.get("id"): item for item in scenarios
            if isinstance(item, dict) and isinstance(item.get("id"), str)
        }
        incomplete = [
            scenario_id for scenario_id, _, _ in SCENARIOS
            if scenarios_by_id.get(scenario_id, {}).get("status") != "observed"
        ]
        result = {
            "recordStructurallyValid": not errors,
            "rehearsalComplete": not incomplete,
            "rehearsalPassed": not errors and not incomplete,
            "failClosed": bool(errors or incomplete),
            "errors": errors,
            "incompleteScenarios": incomplete,
        }
        dump_json(result, args.out)
        return 0 if result["rehearsalPassed"] else 2

    intake = load_json(args.intake)
    intake_errors = validate_intake(intake)
    rpc = rpc_check(args.rpc_url)
    plan = unsigned_plan(intake, intake_errors, rpc)
    if args.command == "plan":
        dump_json(plan, args.out)
        return 0
    result = copy.deepcopy(plan)
    result["publicPreflightPassed"] = not result["failClosed"]
    dump_json(result, args.out)
    return 0 if result["publicPreflightPassed"] else 2


if __name__ == "__main__":
    sys.exit(main())
