#!/usr/bin/env python3
"""Build an unsigned, offline-only MultisigGovernanceV3 proposal package.

This program only serializes a JSON file (or prints a dry run).  It has no RPC,
HTTP, wallet, signing, transaction-service, subprocess, or broadcast code.

The Base pilot package contains proposals for the following governed calls:

1. ``BasePilotAccessControllerV3.setModule(coordinator, true)``
2. ``BasePilotAccessControllerV3.setDesignPartner(partner, true)``
3. ``BasePilotAccessControllerV3.setCaps(exactManifestGlobal, exactManifestPerPosition)``
4. ``BasePilotAccessControllerV3.setDeployEnabled(true)``
5. ``SafeBondFactoryV3.configureFactories(partnershipFactory, accountabilityFactory)``
6. ``SafeBondFactoryV3.setDeployAllowed(true)``
7. ``SafeBondFactoryV3.deployPartnership(designPartner, address(0))``
8. ``SafeBondFactoryV3.deployAccountability(designPartner)``

Each outer payload calls ``MultisigGovernanceV3.propose(target, value,
calldata)``. The governed factory call is nested calldata, never the direct
recipient of an operator, Safe, or EOA transaction. Confirmation and execution
must occur on chain after the emitted proposal ID is known, with three distinct
current signers and the fixed 24-hour timelock.

A Safe may be supplied only as an optional signer transport. It is not the
factory owner, is never represented as the owner by this program, and may call
only the governance contract's ``propose``, ``confirm``, or ``execute`` entry
points if it is one of the named governance signers.

Example (all values below are deliberately non-production dummy addresses):

    python3 pr8/migration/safe_payload_generator.py \
      --deployment-manifest /secure/finalization-candidate.json \
    --governance 0x1111111111111111111111111111111111111111 \
      --safe-signer 0x2222222222222222222222222222222222222222 \
      --factory-coordinator 0x2222222222222222222222222222222222222222 \
      --controller 0x7777777777777777777777777777777777777777 \
      --design-partner 0x3333333333333333333333333333333333333333 \
      --partnership-factory 0x4444444444444444444444444444444444444444 \
      --accountability-factory 0x5555555555555555555555555555555555555555 \
      --created-at 0 --dry-run
"""
from __future__ import annotations

import argparse
import hashlib
import json
import re
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Sequence

from Crypto.Hash import keccak

sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "pilot"))
from deployment_manifest import ManifestValidationError, validate_manifest


ROOT = Path(__file__).resolve().parents[2]
BASE_CHAIN_ID = 8453
CHAIN_ALLOWLIST = {"base": BASE_CHAIN_ID}
GENERATOR_VERSION = "3.1.0"
ZERO_ADDRESS = "0x0000000000000000000000000000000000000000"
ADDRESS_RE = re.compile(r"^0x[0-9a-fA-F]{40}$")
UINT_RE = re.compile(r"^uint(?:([0-9]{1,3}))?$")
BYTES_RE = re.compile(r"^0x(?:[0-9a-fA-F]{2})*$")
GOVERNANCE_THRESHOLD = 3
GOVERNANCE_SIGNER_COUNT = 5
GOVERNANCE_TIMELOCK_FLOOR_SECONDS = 86_400

COORDINATOR_SOURCE = "v3-src/contracts/bonds/SafeBondFactoryV3.sol"
GOVERNANCE_SOURCE = "v3-src/contracts/governance/MultisigGovernanceV3.sol"
SPECIALIZED_FACTORY_SOURCES = {
    "partnershipFactory": "v3-src/contracts/bonds/PartnershipBondFactoryV3.sol",
    "accountabilityFactory": "v3-src/contracts/bonds/AccountabilityBondFactoryV3.sol",
}


class ValidationError(ValueError):
    """Raised when a batch input is unsafe, incomplete, or not ABI encodable."""


@dataclass(frozen=True)
class BuildConfig:
    """All concrete inputs needed to deterministically construct a batch."""

    chain: str
    governance: str
    safe_signer: str | None
    factory_coordinator: str
    controller: str
    design_partner: str
    partnership_factory: str
    accountability_factory: str
    deployment_manifest: dict[str, Any]
    created_at: int = 0


def keccak256(value: bytes) -> bytes:
    """Return Ethereum Keccak-256, not NIST SHA3-256."""
    digest = keccak.new(digest_bits=256)
    digest.update(value)
    return digest.digest()


def to_checksum_address(address: str) -> str:
    """Return the EIP-55 checksum form of an already syntactically valid address."""
    if not isinstance(address, str) or not ADDRESS_RE.fullmatch(address):
        raise ValidationError("address must be a 0x-prefixed 20-byte hexadecimal string")
    lower = address[2:].lower()
    address_hash = keccak256(lower.encode("ascii")).hex()
    checksummed = "".join(
        character.upper() if character in "abcdef" and int(address_hash[index], 16) >= 8 else character
        for index, character in enumerate(lower)
    )
    return "0x" + checksummed


def validate_address(address: str, label: str) -> str:
    """Validate a nonzero EVM address and normalize it to EIP-55 checksum form.

    All-lowercase and all-uppercase forms are accepted as valid non-checksummed
    EVM addresses.  A mixed-case input must pass EIP-55 verification; accepting
    an incorrectly mixed-case address would defeat typo detection.
    """
    if not isinstance(address, str) or not ADDRESS_RE.fullmatch(address):
        raise ValidationError(f"{label} must be a 0x-prefixed 20-byte hexadecimal address")
    if address.lower() == ZERO_ADDRESS:
        raise ValidationError(f"{label} must not be the zero address")
    body = address[2:]
    checksummed = to_checksum_address(address)
    if not (body.islower() or body.isupper()) and address != checksummed:
        raise ValidationError(f"{label} has an invalid EIP-55 checksum")
    return checksummed


def validate_uint(value: int, label: str, *, bits: int = 256, positive: bool = False) -> int:
    """Validate an unsigned integer accepted by the ABI encoder."""
    if isinstance(value, bool) or not isinstance(value, int):
        raise ValidationError(f"{label} must be an integer")
    if value < 0 or value >= 1 << bits:
        raise ValidationError(f"{label} must fit in uint{bits}")
    if positive and value == 0:
        raise ValidationError(f"{label} must be greater than zero")
    return value


def _manifest_caps(manifest: dict[str, Any]) -> dict[str, int]:
    """Validate and extract the exact controller and immutable bond caps."""
    try:
        validate_manifest(manifest)
    except ManifestValidationError as error:
        raise ValidationError(f"deployment manifest failed validation: {error}") from error
    if manifest.get("status") != "finalization-candidate":
        raise ValidationError("proposal generation requires a finalization-candidate deployment manifest")
    caps = manifest["caps"]
    return {
        "globalWei": int(caps["globalWei"]),
        "perPositionWei": int(caps["perPositionWei"]),
        "partnershipPartyWei": int(caps["partnershipPartyWei"]),
        "partnershipBondWei": int(caps["partnershipBondWei"]),
        "partnershipAggregateWei": int(caps["partnershipAggregateWei"]),
        "accountabilityBondWei": int(caps["accountabilityBondWei"]),
        "accountabilityAggregateWei": int(caps["accountabilityAggregateWei"]),
    }


def function_selector(signature: str) -> str:
    """Return a canonical 0x-prefixed four-byte selector."""
    if not isinstance(signature, str) or "(" not in signature or not signature.endswith(")"):
        raise ValidationError("function signature must be canonical, e.g. setDeployAllowed(bool)")
    return "0x" + keccak256(signature.encode("ascii"))[:4].hex()


def _encode_word(abi_type: str, value: Any) -> bytes:
    """Encode the static ABI types used by the V3 enable batch."""
    if abi_type == "address":
        if not isinstance(value, str) or ADDRESS_RE.fullmatch(value) is None:
            raise ValidationError("ABI address must be a 20-byte hexadecimal address")
        return bytes.fromhex(value[2:]).rjust(32, b"\x00")
    if abi_type == "bool":
        if not isinstance(value, bool):
            raise ValidationError("ABI bool must be True or False")
        return (b"\x00" * 31) + (b"\x01" if value else b"\x00")
    if abi_type == "bytes32":
        if not isinstance(value, str) or not re.fullmatch(r"0x[0-9a-fA-F]{64}", value):
            raise ValidationError("ABI bytes32 must be a 0x-prefixed 32-byte hexadecimal string")
        return bytes.fromhex(value[2:])

    match = UINT_RE.fullmatch(abi_type)
    if match:
        bits = int(match.group(1) or "256")
        if bits == 0 or bits > 256 or bits % 8:
            raise ValidationError(f"unsupported ABI integer type {abi_type}")
        return validate_uint(value, f"ABI {abi_type}", bits=bits).to_bytes(32, "big")
    raise ValidationError(f"unsupported ABI type {abi_type}")


def _encode_bytes(value: Any) -> bytes:
    """Encode ABI ``bytes`` input supplied as a canonical 0x-prefixed hex string."""
    if not isinstance(value, str) or BYTES_RE.fullmatch(value) is None:
        raise ValidationError("ABI bytes must be an even-length 0x-prefixed hexadecimal string")
    raw = bytes.fromhex(value[2:])
    padded_length = ((len(raw) + 31) // 32) * 32
    return len(raw).to_bytes(32, "big") + raw.ljust(padded_length, b"\x00")


def abi_encode(types: Sequence[str], values: Sequence[Any]) -> bytes:
    """Encode the static and bytes-only dynamic tuples used by this package."""
    if len(types) != len(values):
        raise ValidationError("ABI type and value counts differ")
    head_size = 32 * len(types)
    head: list[bytes] = []
    tail = b""
    for abi_type, value in zip(types, values):
        if abi_type == "bytes":
            head.append((head_size + len(tail)).to_bytes(32, "big"))
            tail += _encode_bytes(value)
        else:
            head.append(_encode_word(abi_type, value))
    return b"".join(head) + tail


def encode_function_call(signature: str, types: Sequence[str], values: Sequence[Any]) -> str:
    """Return exact ABI calldata (selector plus ABI words) as lower-case hex."""
    selector = bytes.fromhex(function_selector(signature)[2:])
    return "0x" + (selector + abi_encode(types, values)).hex()


def _source_digest(relative_path: str) -> str:
    """Bind the manifest to the exact local source reviewed by the operator."""
    source = ROOT / relative_path
    if not source.is_file():
        raise ValidationError(f"required V3 source is missing: {relative_path}")
    return hashlib.sha256(source.read_bytes()).hexdigest()


def _require_known_specialized_factories() -> None:
    """Fail closed if V3 adds a specialized factory not represented in the manifest.

    Specialized factories are intentionally not Safe transaction targets: their
    ``onlyCoordinator`` modifier means that only ``SafeBondFactoryV3`` can call
    them.  Still, every one must be an explicit, reviewed manifest input.  This
    check prevents a newly added ``*FactoryV3.sol`` deployment path from being
    silently omitted when the source tree evolves.
    """
    bonds_directory = ROOT / "v3-src/contracts/bonds"
    observed = {source.name for source in bonds_directory.glob("*FactoryV3.sol")}
    expected = {
        Path(COORDINATOR_SOURCE).name,
        *(Path(path).name for path in SPECIALIZED_FACTORY_SOURCES.values()),
    }
    unknown = sorted(observed - expected)
    missing = sorted(expected - observed)
    if unknown or missing:
        details = []
        if unknown:
            details.append("new factory source(s): " + ", ".join(unknown))
        if missing:
            details.append("missing expected factory source(s): " + ", ".join(missing))
        raise ValidationError(
            "specialized-factory manifest must be updated before generating a batch ("
            + "; ".join(details)
            + ")"
        )


def _governed_call(
    *,
    target: str,
    signature: str,
    inputs: list[dict[str, str]],
    values: Sequence[Any],
    input_values: dict[str, str],
    description: str,
    expected_earliest_execution: int,
) -> dict[str, Any]:
    """Build one deterministic governed call and its outer proposal calldata."""
    data = encode_function_call(signature, [item["type"] for item in inputs], values)
    description_hash = "0x" + keccak256(description.encode("utf-8")).hex()
    propose_calldata = encode_function_call(
        "propose(address,uint256,bytes)", ["address", "uint256", "bytes"], [target, 0, data]
    )
    return {
        "target": target,
        "value": "0",
        "calldata": data,
        "description": description,
        "descriptionHash": description_hash,
        "descriptionHashAlgorithm": "keccak256(UTF-8 description)",
        "expectedEarliestExecution": expected_earliest_execution,
        "governedContractMethod": {
            "inputs": inputs,
            "name": signature.split("(", 1)[0],
            "payable": False,
        },
        "governedContractInputsValues": input_values,
        "proposalTransaction": {
            "to": None,
            "value": "0",
            "calldata": propose_calldata,
            "contractMethod": "propose(address,uint256,bytes)",
            "arguments": {"target": target, "value": "0", "calldata": data},
        },
        "signerConfirmationInstructions": {
            "after": "Wait for ProposedV3 and record its proposalId.",
            "requiredDistinctCurrentSigners": GOVERNANCE_THRESHOLD,
            "currentSignerSetSize": GOVERNANCE_SIGNER_COUNT,
            "function": "confirm(uint256)",
            "calldataTemplate": "confirm(<proposalId from ProposedV3>)",
            "rule": "Each signer calls MultisigGovernanceV3.confirm; do not call the owned target.",
        },
        "executionInstructions": {
            "after": "At or after expectedEarliestExecution and after three confirmations.",
            "function": "execute(uint256)",
            "calldataTemplate": "execute(<proposalId from ProposedV3>)",
            "rule": "A current signer calls MultisigGovernanceV3.execute; the governance contract performs the owned call.",
        },
    }


def _validated_config(config: BuildConfig) -> BuildConfig:
    """Normalize and validate every build input before emitting any transaction."""
    _require_known_specialized_factories()
    if config.chain not in CHAIN_ALLOWLIST:
        allowed = ", ".join(CHAIN_ALLOWLIST)
        raise ValidationError(f"unsupported chain {config.chain!r}; Base pilot allowlist: {allowed}")
    governance = validate_address(config.governance, "MultisigGovernanceV3 address")
    safe_signer = validate_address(config.safe_signer, "optional Safe signer address") if config.safe_signer else None
    if safe_signer == governance:
        raise ValidationError("optional Safe signer address must not equal the on-chain governance owner")
    caps = _manifest_caps(config.deployment_manifest)
    if caps["globalWei"] != 400_000_000_000_000_000 or caps["perPositionWei"] != 200_000_000_000_000_000:
        raise ValidationError("deployment manifest controller caps exceed or diverge from the reviewed Base pilot ceiling")
    return BuildConfig(
        chain=config.chain,
        governance=governance,
        safe_signer=safe_signer,
        factory_coordinator=validate_address(config.factory_coordinator, "factory/coordinator address"),
        controller=validate_address(config.controller, "BasePilotAccessControllerV3 address"),
        design_partner=validate_address(config.design_partner, "design-partner address"),
        partnership_factory=validate_address(config.partnership_factory, "partnership specialized-factory address"),
        accountability_factory=validate_address(
            config.accountability_factory, "accountability specialized-factory address"
        ),
        deployment_manifest=config.deployment_manifest,
        created_at=validate_uint(config.created_at, "createdAt", bits=64),
    )


def build(config: BuildConfig) -> dict[str, Any]:
    """Construct a deterministic Base-only MultisigGovernanceV3 proposal package.

    This function is deliberately pure with respect to blockchain state: the
    result depends only on the supplied config and local V3 source bytes used
    for manifest digests.  It never verifies deployed bytecode or broadcasts.
    """
    config = _validated_config(config)
    caps = _manifest_caps(config.deployment_manifest)
    expected_earliest_execution = config.created_at + GOVERNANCE_TIMELOCK_FLOOR_SECONDS
    proposals = [
        _governed_call(
            target=config.controller,
            signature="setModule(address,bool)",
            inputs=[{"name": "module", "type": "address"}, {"name": "allowed", "type": "bool"}],
            values=[config.factory_coordinator, True],
            input_values={"module": config.factory_coordinator, "allowed": "true"},
            description="Allowlist the reviewed SafeBondFactoryV3 coordinator as a controller module.",
            expected_earliest_execution=expected_earliest_execution,
        ),
        _governed_call(
            target=config.controller,
            signature="setDesignPartner(address,bool)",
            inputs=[{"name": "partner", "type": "address"}, {"name": "allowed", "type": "bool"}],
            values=[config.design_partner, True],
            input_values={"partner": config.design_partner, "allowed": "true"},
            description="Allowlist the one reviewed Base pilot design partner.",
            expected_earliest_execution=expected_earliest_execution,
        ),
        _governed_call(
            target=config.controller,
            signature="setCaps(uint256,uint256)",
            inputs=[{"name": "newGlobalCap", "type": "uint256"}, {"name": "newPerPositionCap", "type": "uint256"}],
            values=[caps["globalWei"], caps["perPositionWei"]],
            input_values={"newGlobalCap": str(caps["globalWei"]), "newPerPositionCap": str(caps["perPositionWei"])},
            description="Set the exact finalization-manifest Base controller caps; no standalone cap declaration is accepted.",
            expected_earliest_execution=expected_earliest_execution,
        ),
        _governed_call(
            target=config.factory_coordinator,
            signature="configureFactories(address,address)",
            inputs=[
                {"name": "partnership", "type": "address"},
                {"name": "accountability", "type": "address"},
            ],
            values=[config.partnership_factory, config.accountability_factory],
            input_values={
                "partnership": config.partnership_factory,
                "accountability": config.accountability_factory,
            },
            description="Bind the two reviewed specialized factories to SafeBondFactoryV3 exactly once.",
            expected_earliest_execution=expected_earliest_execution,
        ),
        _governed_call(
            target=config.controller,
            signature="setDeployEnabled(bool)",
            inputs=[{"name": "enabled", "type": "bool"}],
            values=[True],
            input_values={"enabled": "true"},
            description=(
                "Enable controller registration only after module, partner, exact manifest caps, "
                "and the one-time specialized-factory binding are set."
            ),
            expected_earliest_execution=expected_earliest_execution,
        ),
        _governed_call(
            target=config.factory_coordinator,
            signature="setDeployAllowed(bool)",
            inputs=[{"name": "v", "type": "bool"}],
            values=[True],
            input_values={"v": "true"},
            description="Enable SafeBondFactoryV3 deployment gate for the capped Base V3 pilot.",
            expected_earliest_execution=expected_earliest_execution,
        ),
        _governed_call(
            target=config.factory_coordinator,
            signature="deployPartnership(address,address)",
            inputs=[
                {"name": "designPartner", "type": "address"},
                {"name": "rewardProgram", "type": "address"},
            ],
            values=[config.design_partner, ZERO_ADDRESS],
            input_values={"designPartner": config.design_partner, "rewardProgram": ZERO_ADDRESS},
            description="Deploy one governance-owned, principal-only PartnershipBondV3Safe for the design partner.",
            expected_earliest_execution=expected_earliest_execution,
        ),
        _governed_call(
            target=config.factory_coordinator,
            signature="deployAccountability(address)",
            inputs=[{"name": "designPartner", "type": "address"}],
            values=[config.design_partner],
            input_values={"designPartner": config.design_partner},
            description="Deploy one governance-owned AccountabilityBondV3Safe for the design partner.",
            expected_earliest_execution=expected_earliest_execution,
        ),
    ]
    for proposal in proposals:
        proposal["proposalTransaction"]["to"] = config.governance
    input_manifest = {
        "chain": config.chain,
        "chainId": CHAIN_ALLOWLIST[config.chain],
        "governance": config.governance,
        "safeSigner": config.safe_signer,
        "factoryCoordinator": config.factory_coordinator,
        "controller": config.controller,
        "designPartner": config.design_partner,
        "specializedFactories": {
            "partnershipFactory": config.partnership_factory,
            "accountabilityFactory": config.accountability_factory,
        },
        "pilotCaps": caps,
        "deploymentManifestPayloadSha256": config.deployment_manifest["integrity"]["manifestPayloadSha256"],
        "createdAt": config.created_at,
    }
    input_fingerprint = hashlib.sha256(
        json.dumps(input_manifest, sort_keys=True, separators=(",", ":")).encode("utf-8")
    ).hexdigest()
    return {
        "version": "1.0",
        "chainId": CHAIN_ALLOWLIST[config.chain],
        "createdAt": config.created_at,
        "meta": {
            "name": "Vaultfire V3 — Base pilot governance proposal package",
            "description": (
                "Offline-only unsigned MultisigGovernanceV3 proposal package for the capped V3 Base pilot. "
                "It contains no direct owned-factory transaction."
            ),
            "transport": {
                "onChainOwner": config.governance,
                "ownerContract": "MultisigGovernanceV3",
                "directOwnedTargetCallsPermitted": False,
                "optionalSafeSigner": config.safe_signer,
                "optionalSafeSignerRole": (
                    "one governance signer only; never the factory/module owner"
                    if config.safe_signer
                    else "not configured"
                ),
                "threshold": GOVERNANCE_THRESHOLD,
                "signerCount": GOVERNANCE_SIGNER_COUNT,
                "timelockFloorSeconds": GOVERNANCE_TIMELOCK_FLOOR_SECONDS,
            },
            "manifest": {
                "schema": "vaultfire.governance-proposal-package.v1",
                "generator": "safe_payload_generator.py",
                "generatorVersion": GENERATOR_VERSION,
                "mode": "offline-only; no signing, RPC, transaction-service, or broadcast capability",
                "createdAtPolicy": "explicit Unix timestamp; defaults to deterministic 0",
                "ownerModel": "MultisigGovernanceV3 is the on-chain owner of administered targets",
                "input": input_manifest,
                "inputFingerprintSha256": input_fingerprint,
                "sourceSha256": {
                    GOVERNANCE_SOURCE: _source_digest(GOVERNANCE_SOURCE),
                    COORDINATOR_SOURCE: _source_digest(COORDINATOR_SOURCE),
                    **{path: _source_digest(path) for path in SPECIALIZED_FACTORY_SOURCES.values()},
                },
                "operatorChecks": [
                    "Verify the coordinator owner equals the stated MultisigGovernanceV3 address.",
                    "Submit each outer proposalTransaction to MultisigGovernanceV3, never to the owned coordinator.",
                    "Record ProposedV3 proposalId, obtain three distinct signer confirmations, and wait at least 86400 seconds.",
                    "Execute only through MultisigGovernanceV3.execute(proposalId).",
                    "Execute proposals in their emitted order; controller preconditions precede every child deploy.",
                    "Verify the constructor-pinned specialized-factory and child runtime hashes against the finalized package before signing.",
                    "Verify deployed contracts enforce the declared pilot caps before signing.",
                    "If a Safe is used, verify it is only one current governance signer and not the owner.",
                    "Review calldata and governance simulation; this file itself never broadcasts.",
                ],
            },
        },
        "proposals": proposals,
        "postDeploymentFollowUp": {
            "status": "receipt-bound-template",
            "constructionBatchContainsSetWriteEnabled": False,
            "requiredExecutionReceipts": [
                "executed-deployPartnership-proposal-id-and-child-address",
                "executed-deployAccountability-proposal-id-and-child-address",
                "child-runtime-codehashes-match-constructor-pinned-values",
            ],
            "requiredGovernedActionsAfterReceipts": [
                "MultisigGovernanceV3.addGuardianPauseTarget(actualPartnershipChild)",
                "MultisigGovernanceV3.addGuardianPauseTarget(actualAccountabilityChild)",
                "verify-controller-hasGuardianPauseCoverage-for-each-child",
                "separate-governed-controller-setWriteEnabled(true)-only-after-coverage",
            ],
            "setWriteEnabledCalldata": None,
        },
    }


def _parse_decimal_uint(text: str) -> int:
    """Argparse adapter that rejects signs, floats, hex, and blank cap values."""
    if not re.fullmatch(r"[0-9]+", text):
        raise argparse.ArgumentTypeError("must be a non-negative base-10 integer")
    return int(text, 10)


def _parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(
        description="Generate a deterministic, offline-only Base MultisigGovernanceV3 proposal package."
    )
    parser.add_argument(
        "--chain",
        default="base",
        help="Pilot chain name; Base is the default and only allowlisted value.",
    )
    parser.add_argument(
        "--governance",
        "--v3-multisig",
        dest="governance",
        required=True,
        help="Deployed MultisigGovernanceV3 on-chain owner and sole proposal recipient.",
    )
    parser.add_argument(
        "--safe-signer",
        help="Optional Safe that is one governance signer only; it is never an owned target's owner.",
    )
    parser.add_argument(
        "--factory-coordinator",
        "--factory",
        "--coordinator",
        dest="factory_coordinator",
        required=True,
        help="SafeBondFactoryV3 governed target address; never a direct transaction recipient.",
    )
    parser.add_argument(
        "--controller",
        required=True,
        help="BasePilotAccessControllerV3 governed target; it receives the required construction preconditions first.",
    )
    parser.add_argument(
        "--deployment-manifest",
        type=Path,
        required=True,
        help="Finalization-candidate bonds-only manifest; it is the sole source of controller and bond cap values.",
    )
    parser.add_argument(
        "--design-partner",
        required=True,
        help="The one allowlisted design-partner public address for both pilot child deployments.",
    )
    parser.add_argument(
        "--partnership-factory",
        required=True,
        help="Declared PartnershipBondFactoryV3 address from the coordinator getter.",
    )
    parser.add_argument(
        "--accountability-factory",
        required=True,
        help="Declared AccountabilityBondFactoryV3 address from the coordinator getter.",
    )
    parser.add_argument(
        "--created-at",
        default=0,
        type=_parse_decimal_uint,
        help="Deterministic Unix timestamp for the batch; default: 0 (never uses wall-clock time).",
    )
    parser.add_argument(
        "--dry-run",
        action="store_true",
        help="Required. Print an unsigned template without writing it or contacting any service.",
    )
    return parser


def main(argv: Sequence[str] | None = None) -> int:
    """CLI entry point."""
    parser = _parser()
    args = parser.parse_args(argv)
    if not args.dry_run:
        parser.error("--dry-run is required: this utility never writes a production payload")
    try:
        deployment_manifest = json.loads(args.deployment_manifest.read_text(encoding="utf-8"))
        if not isinstance(deployment_manifest, dict):
            raise ValidationError("deployment manifest root must be an object")
        batch = build(
            BuildConfig(
                chain=args.chain,
                governance=args.governance,
                safe_signer=args.safe_signer,
                factory_coordinator=args.factory_coordinator,
                controller=args.controller,
                design_partner=args.design_partner,
                partnership_factory=args.partnership_factory,
                accountability_factory=args.accountability_factory,
                deployment_manifest=deployment_manifest,
                created_at=args.created_at,
            )
        )
    except (OSError, json.JSONDecodeError, ValidationError) as error:
        parser.error(str(error))

    rendered = json.dumps(batch, indent=2, sort_keys=False) + "\n"
    sys.stdout.write(rendered)
    print("DRY RUN ONLY — no file written, no signature created, and nothing broadcast.", file=sys.stderr)
    return 0


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