"""Partner contract CLI commands for Centris Control Contract (CCC)."""

from __future__ import annotations

import base64
import datetime
import hashlib
import json
import os
import time
from pathlib import Path
from typing import Any, Dict, Optional

import click

from centris_sdk.cli.result_envelope import build_result_envelope, emit_result_envelope

ContractDict = Dict[str, Any]
TrustPolicyDict = Dict[str, Any]
VALID_SAFETY_CLASSES = {"read", "write", "destructive"}
VALID_SIGNATURE_ALGORITHMS = {"sha256", "ed25519"}
DEFAULT_TRUST_FILE = Path(".centris/partner-trust.json")


def _to_slug(value: str) -> str:
    out = []
    last_dash = False
    for char in value.strip().lower():
        if char.isalnum():
            out.append(char)
            last_dash = False
            continue
        if not last_dash:
            out.append("-")
            last_dash = True
    slug = "".join(out).strip("-")
    return slug or "app"


def _stable_json(value: Any) -> str:
    return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False)


def _strip_none(value: Any) -> Any:
    if isinstance(value, dict):
        out: Dict[str, Any] = {}
        for key, item in value.items():
            if item is None:
                continue
            out[key] = _strip_none(item)
        return out
    if isinstance(value, list):
        return [_strip_none(item) for item in value]
    return value


def _canonical_contract_payload(contract: ContractDict) -> str:
    clone = json.loads(json.dumps(contract))
    clone.pop("contractHash", None)
    clone.pop("trust", None)
    return _stable_json(_strip_none(clone))


def _compute_contract_hash(contract: ContractDict) -> str:
    digest = hashlib.sha256(_canonical_contract_payload(contract).encode("utf-8")).hexdigest()
    return f"sha256:{digest}"


def _sanitize_string_list(value: Any) -> list[str]:
    if not isinstance(value, list):
        return []
    return [item for item in value if isinstance(item, str) and item.strip()]


def _read_json_object(path: Path) -> Dict[str, Any]:
    if not path.exists():
        raise click.ClickException(f"File not found: {path}")
    try:
        raw = json.loads(path.read_text(encoding="utf-8"))
    except json.JSONDecodeError as exc:
        raise click.ClickException(f"Invalid JSON in {path}: {exc}") from exc
    if not isinstance(raw, dict):
        raise click.ClickException(f"Expected JSON object in {path}")
    return raw


def _normalize_action(action_id: str, raw: Any) -> Optional[Dict[str, Any]]:
    if not isinstance(raw, dict):
        return None

    safety_class = raw.get("safetyClass")
    if safety_class not in VALID_SAFETY_CLASSES:
        safety_class = "read"

    checks = raw.get("requiredSuccessChecks")
    if checks is None:
        checks = raw.get("successChecks")
    if checks is None:
        checks = raw.get("verify")
    if not isinstance(checks, list):
        checks = []

    binding = raw.get("binding")
    normalized_binding: Optional[Dict[str, Any]] = None
    if isinstance(binding, dict):
        kind = binding.get("kind")
        if kind in {"action-api", "http", "sdk"}:
            normalized_binding = {
                "kind": kind,
                "method": binding.get("method") if isinstance(binding.get("method"), str) else None,
                "endpoint": (
                    binding.get("endpoint") if isinstance(binding.get("endpoint"), str) else None
                ),
                "fixedParams": (
                    binding.get("fixedParams")
                    if isinstance(binding.get("fixedParams"), dict)
                    else None
                ),
                "inputMap": (
                    binding.get("inputMap") if isinstance(binding.get("inputMap"), dict) else None
                ),
            }

    return {
        "description": raw.get("description") if isinstance(raw.get("description"), str) else action_id,
        "inputSchema": raw.get("inputSchema") if isinstance(raw.get("inputSchema"), dict) else {},
        "outputSchema": raw.get("outputSchema") if isinstance(raw.get("outputSchema"), dict) else {},
        "safetyClass": safety_class,
        "requiredSuccessChecks": checks,
        "scopes": _sanitize_string_list(raw.get("scopes")),
        "plugins": _sanitize_string_list(raw.get("plugins")),
        "skills": _sanitize_string_list(raw.get("skills")),
        "binding": normalized_binding,
    }


def _normalize_contract(raw: Dict[str, Any]) -> ContractDict:
    ccc = raw.get("ccc")
    if not isinstance(ccc, str):
        contract_version = raw.get("contractVersion")
        ccc = contract_version if isinstance(contract_version, str) else "1.0"

    url_patterns_raw = raw.get("urlPatterns")
    if not isinstance(url_patterns_raw, list):
        url_patterns_raw = raw.get("url_patterns")
    url_patterns = [item.strip() for item in _sanitize_string_list(url_patterns_raw)]

    actions: Dict[str, Dict[str, Any]] = {}
    actions_raw = raw.get("actions")
    if isinstance(actions_raw, dict):
        for action_id, value in actions_raw.items():
            normalized = _normalize_action(action_id, value)
            if normalized is not None:
                actions[action_id] = normalized

    auth_raw = raw.get("auth")
    auth: Optional[Dict[str, Any]] = None
    if isinstance(auth_raw, dict):
        model = auth_raw.get("model")
        if model not in {"none", "oauth2", "api_key", "session", "hybrid"}:
            model = "none"
        auth = {
            "model": model,
            "scopes": _sanitize_string_list(auth_raw.get("scopes")),
        }

    skills_graph = raw.get("skillsGraph")
    normalized_graph: Optional[Dict[str, Any]] = None
    if isinstance(skills_graph, dict):
        intents_raw = skills_graph.get("intents")
        skills_raw = skills_graph.get("skills")
        intents = (
            {
                intent: _sanitize_string_list(actions_list)
                for intent, actions_list in intents_raw.items()
                if isinstance(intent, str)
            }
            if isinstance(intents_raw, dict)
            else {}
        )
        skills = (
            {
                skill_id: {
                    "description": (
                        definition.get("description")
                        if isinstance(definition.get("description"), str)
                        else None
                    ),
                    "actions": _sanitize_string_list(definition.get("actions")),
                    "plugins": _sanitize_string_list(definition.get("plugins")),
                }
                for skill_id, definition in skills_raw.items()
                if isinstance(skill_id, str) and isinstance(definition, dict)
            }
            if isinstance(skills_raw, dict)
            else {}
        )
        if intents or skills:
            normalized_graph = {}
            if intents:
                normalized_graph["intents"] = intents
            if skills:
                normalized_graph["skills"] = skills

    trust_raw = raw.get("trust")
    trust: Optional[Dict[str, Any]] = None
    if isinstance(trust_raw, dict):
        signature_algorithm = trust_raw.get("signatureAlgorithm")
        if signature_algorithm not in VALID_SIGNATURE_ALGORITHMS:
            signature_algorithm = None
        trust = {
            "keyId": trust_raw.get("keyId") if isinstance(trust_raw.get("keyId"), str) else None,
            "signature": (
                trust_raw.get("signature") if isinstance(trust_raw.get("signature"), str) else None
            ),
            "signatureAlgorithm": signature_algorithm,
            "signedAt": (
                trust_raw.get("signedAt") if isinstance(trust_raw.get("signedAt"), str) else None
            ),
        }

    return {
        "ccc": ccc,
        "app": raw.get("app") if isinstance(raw.get("app"), str) else "",
        "publisher": raw.get("publisher") if isinstance(raw.get("publisher"), str) else "",
        "urlPatterns": url_patterns,
        "appVersion": raw.get("appVersion") if isinstance(raw.get("appVersion"), str) else None,
        "version": raw.get("version") if isinstance(raw.get("version"), str) else None,
        "generatedAt": raw.get("generatedAt") if isinstance(raw.get("generatedAt"), str) else None,
        "contractHash": (
            raw.get("contractHash") if isinstance(raw.get("contractHash"), str) else None
        ),
        "auth": auth,
        "plugins": _sanitize_string_list(raw.get("plugins")),
        "actions": actions,
        "skillsGraph": normalized_graph,
        "trust": trust,
    }


def _major_version(version: str) -> int:
    head = (version or "0").split(".", 1)[0]
    try:
        return int(head)
    except ValueError:
        return 0


def _read_trust_policy(path: Path) -> TrustPolicyDict:
    if not path.exists():
        return {}
    raw = _read_json_object(path)
    allowed_publishers = _sanitize_string_list(raw.get("allowedPublishers"))
    allowed_apps_raw = raw.get("allowedAppsByPublisher")
    allowed_apps: Dict[str, list[str]] = {}
    if isinstance(allowed_apps_raw, dict):
        for publisher, apps in allowed_apps_raw.items():
            if isinstance(publisher, str):
                allowed_apps[publisher] = _sanitize_string_list(apps)
    public_keys_raw = raw.get("publicKeys")
    public_keys: Dict[str, str] = {}
    if isinstance(public_keys_raw, dict):
        for key_id, value in public_keys_raw.items():
            if isinstance(key_id, str) and isinstance(value, str):
                public_keys[key_id] = value
    return {
        "allowUnsignedExternal": raw.get("allowUnsignedExternal") is True,
        "allowedPublishers": allowed_publishers,
        "allowedAppsByPublisher": allowed_apps,
        "publicKeys": public_keys,
    }


def _write_trust_policy(path: Path, policy: TrustPolicyDict) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text(f"{json.dumps(policy, indent=2)}\n", encoding="utf-8")


def _verify_ed25519_signature(
    contract: ContractDict, signature: str, key_material: str
) -> tuple[bool, str]:
    try:
        from cryptography.hazmat.primitives import serialization
        from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
    except Exception:
        return False, "ed25519 verification requires `cryptography` package"

    try:
        if "BEGIN PUBLIC KEY" in key_material:
            key = serialization.load_pem_public_key(key_material.encode("utf-8"))
        else:
            key = serialization.load_der_public_key(base64.b64decode(key_material))
        if not isinstance(key, Ed25519PublicKey):
            return False, "public key is not ed25519"
        key.verify(base64.b64decode(signature), _canonical_contract_payload(contract).encode("utf-8"))
        return True, "ed25519 signature verified"
    except Exception:
        return False, "ed25519 signature mismatch"


def _verify_signature(
    contract: ContractDict, public_keys: Dict[str, str]
) -> tuple[bool, str, str]:
    expected_hash = _compute_contract_hash(contract)
    contract_hash = contract.get("contractHash")
    if isinstance(contract_hash, str) and contract_hash and contract_hash != expected_hash:
        return False, "contractHash mismatch", expected_hash

    trust = contract.get("trust")
    if not isinstance(trust, dict):
        return False, "missing signature metadata", expected_hash
    algorithm = trust.get("signatureAlgorithm")
    signature = trust.get("signature")
    if algorithm not in VALID_SIGNATURE_ALGORITHMS or not isinstance(signature, str):
        return False, "missing signature metadata", expected_hash

    if algorithm == "sha256":
        expected = expected_hash.replace("sha256:", "").strip().lower()
        actual = signature.replace("sha256:", "").strip().lower()
        ok = expected == actual
        return ok, "sha256 signature verified" if ok else "sha256 signature mismatch", expected_hash

    key_id = trust.get("keyId")
    if not isinstance(key_id, str) or not key_id:
        return False, "missing public key keyId", expected_hash
    key_material = public_keys.get(key_id)
    if not key_material:
        return False, f"missing public key for keyId {key_id}", expected_hash
    ok, reason = _verify_ed25519_signature(contract, signature, key_material)
    return ok, reason, expected_hash


def _validate_contract_policy(
    contract: ContractDict,
    *,
    strict: bool,
    target_version: Optional[str],
) -> tuple[ContractDict, list[str], list[str]]:
    normalized = _normalize_contract(contract)
    warnings: list[str] = []
    errors: list[str] = []

    if _major_version(normalized["ccc"]) > 1 and strict:
        errors.append(f"Unsupported contract major version: {normalized['ccc']}")
    if target_version and normalized["ccc"] != target_version:
        msg = f"Contract version {normalized['ccc']} does not match target {target_version}"
        if strict:
            errors.append(msg)
        else:
            warnings.append(msg)

    if not normalized.get("app"):
        errors.append("Contract app is required")
    if not normalized.get("publisher"):
        errors.append("Contract publisher is required")
    if not normalized.get("urlPatterns"):
        errors.append("Contract urlPatterns must be a non-empty array")

    actions = normalized.get("actions", {})
    if not actions:
        errors.append("Contract defines no actions")

    graph = normalized.get("skillsGraph") if isinstance(normalized.get("skillsGraph"), dict) else {}
    graph_skills = (
        graph.get("skills") if isinstance(graph.get("skills"), dict) else {}
    )
    graph_intents = (
        graph.get("intents") if isinstance(graph.get("intents"), dict) else {}
    )
    action_ids = set(actions.keys())

    for action_id, action in actions.items():
        if not isinstance(action.get("description"), str) or not action["description"].strip():
            errors.append(f"Action description is required for {action_id}")
        if not isinstance(action.get("inputSchema"), dict):
            errors.append(f"inputSchema must be an object for {action_id}")
        if not isinstance(action.get("outputSchema"), dict):
            errors.append(f"outputSchema must be an object for {action_id}")
        safety = action.get("safetyClass")
        if safety not in VALID_SAFETY_CLASSES:
            action["safetyClass"] = "read"
            safety = "read"
        checks = action.get("requiredSuccessChecks")
        if not isinstance(checks, list):
            checks = []
            action["requiredSuccessChecks"] = checks
        if safety in {"write", "destructive"} and not checks:
            msg = f"Action {action_id} requires success checks for {safety} safety class"
            if strict:
                errors.append(msg)
            else:
                warnings.append(msg)
        if safety == "destructive" and not _sanitize_string_list(action.get("scopes")):
            msg = f"Destructive action {action_id} should declare at least one scope"
            if strict:
                errors.append(msg)
            else:
                warnings.append(msg)

        binding = action.get("binding")
        if isinstance(binding, dict):
            kind = binding.get("kind")
            if kind in {"action-api", "sdk"} and not isinstance(binding.get("method"), str):
                errors.append(f"{kind} binding requires method for {action_id}")
            if kind == "http" and not isinstance(binding.get("endpoint"), str):
                errors.append(f"http binding requires endpoint for {action_id}")

        for skill_id in _sanitize_string_list(action.get("skills")):
            if skill_id not in graph_skills:
                msg = f"Skill reference not found in skillsGraph: {skill_id} ({action_id})"
                if strict:
                    errors.append(msg)
                else:
                    warnings.append(msg)

    for intent_id, mapped_actions in graph_intents.items():
        if not isinstance(intent_id, str):
            continue
        for action_id in _sanitize_string_list(mapped_actions):
            if action_id not in action_ids:
                errors.append(f"Intent {intent_id} maps unknown action {action_id}")

    for skill_id, skill_def in graph_skills.items():
        if not isinstance(skill_def, dict):
            continue
        for action_id in _sanitize_string_list(skill_def.get("actions")):
            if action_id not in action_ids:
                errors.append(f"Skill {skill_id} maps unknown action {action_id}")

    computed_hash = _compute_contract_hash(normalized)
    existing_hash = normalized.get("contractHash")
    if isinstance(existing_hash, str) and existing_hash and existing_hash != computed_hash:
        errors.append("contractHash does not match canonical payload")
    else:
        normalized["contractHash"] = computed_hash

    return normalized, warnings, errors


def _detect_source_kind(path: Path, cwd: Path) -> str:
    abs_path = path.resolve()
    abs_cwd = cwd.resolve()
    path_str = str(abs_path)
    if str(abs_path).startswith(str(abs_cwd)):
        return "workspace"
    if f"{os.sep}.centris{os.sep}" in path_str:
        return "global"
    if f"{os.sep}.openclaw{os.sep}" in path_str:
        return "overlay"
    if f"{os.sep}node_modules{os.sep}" in path_str:
        return "registry"
    return "external"


def _evaluate_trust(
    contract: ContractDict,
    *,
    source_kind: str,
    trust_policy: TrustPolicyDict,
    allow_unsigned_override: Optional[bool],
) -> tuple[bool, str]:
    if source_kind == "workspace":
        return True, "workspace-local"

    publisher = contract.get("publisher")
    if not isinstance(publisher, str) or not publisher.strip():
        if allow_unsigned_override is True or trust_policy.get("allowUnsignedExternal") is True:
            return True, "unsigned-external-allowed"
        return False, "missing publisher metadata"

    allowed_publishers = _sanitize_string_list(trust_policy.get("allowedPublishers"))
    if allowed_publishers and publisher not in allowed_publishers:
        return False, f"publisher not allowlisted: {publisher}"

    allowed_apps = trust_policy.get("allowedAppsByPublisher")
    if isinstance(allowed_apps, dict):
        app_list = _sanitize_string_list(allowed_apps.get(publisher))
        app = contract.get("app")
        if app_list and isinstance(app, str) and app not in app_list:
            return False, f"app {app} not allowlisted for {publisher}"

    trust = contract.get("trust")
    has_signature = (
        isinstance(trust, dict)
        and isinstance(trust.get("signature"), str)
        and bool(trust.get("signature").strip())
    )
    if not has_signature:
        if allow_unsigned_override is True or trust_policy.get("allowUnsignedExternal") is True:
            return True, "unsigned-external-allowed"
        return False, "missing signature metadata"

    public_keys = trust_policy.get("publicKeys")
    verified, reason, _ = _verify_signature(
        contract, public_keys if isinstance(public_keys, dict) else {}
    )
    return verified, reason


def _build_starter_contract(app: str, publisher: str, url_patterns: list[str]) -> ContractDict:
    app_slug = _to_slug(app)
    first_pattern = url_patterns[0] if url_patterns else f"{app_slug}.com/*"
    return {
        "ccc": "1.0",
        "app": app_slug,
        "publisher": publisher,
        "urlPatterns": url_patterns,
        "appVersion": "0.1.0",
        "version": "0.1.0",
        "generatedAt": _utc_now_iso(),
        "auth": {"model": "oauth2", "scopes": ["app:read", "app:write"]},
        "plugins": ["web-memory"],
        "actions": {
            "core.navigate": {
                "description": "Navigate to a typed URL inside the app",
                "inputSchema": {
                    "type": "object",
                    "required": ["url"],
                    "properties": {"url": {"type": "string"}},
                },
                "outputSchema": {
                    "type": "object",
                    "properties": {"ok": {"type": "boolean"}, "url": {"type": "string"}},
                },
                "safetyClass": "read",
                "requiredSuccessChecks": [{"type": "url_contains", "value": first_pattern}],
                "scopes": ["app:read"],
                "plugins": ["web-memory"],
                "skills": ["web-navigation"],
                "binding": {
                    "kind": "action-api",
                    "method": "route.run",
                    "fixedParams": {"routeId": "core.navigate"},
                    "inputMap": {"url": "input.url"},
                },
            }
        },
        "skillsGraph": {
            "intents": {"intent.core.navigate": ["core.navigate"]},
            "skills": {
                "web-navigation": {
                    "description": "Deterministic app navigation skill",
                    "actions": ["core.navigate"],
                    "plugins": ["web-memory"],
                }
            },
        },
    }


def _utc_now_iso() -> str:
    return datetime.datetime.now(datetime.timezone.utc).isoformat().replace("+00:00", "Z")


def _sign_ed25519(payload: bytes, private_key_text: str) -> str:
    try:
        from cryptography.hazmat.primitives import serialization
        from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
    except Exception as exc:
        raise click.ClickException(
            "ed25519 signing requires `cryptography` package"
        ) from exc

    try:
        if "BEGIN PRIVATE KEY" in private_key_text:
            key = serialization.load_pem_private_key(
                private_key_text.encode("utf-8"), password=None
            )
        else:
            key = serialization.load_der_private_key(
                base64.b64decode(private_key_text), password=None
            )
        if not isinstance(key, Ed25519PrivateKey):
            raise click.ClickException("Private key is not ed25519")
        return base64.b64encode(key.sign(payload)).decode("utf-8")
    except click.ClickException:
        raise
    except Exception as exc:
        raise click.ClickException("Failed to sign contract with ed25519 key") from exc


@click.group("partner")
def partner_group() -> None:
    """Partner integration workflows for Centris Control Contract (CCC)."""


@partner_group.command("init")
@click.argument("app")
@click.option("--publisher", default="example-partner", show_default=True)
@click.option("--out-dir", default=None, help="Output directory (default: connectors/<app>)")
@click.option("--url-pattern", multiple=True, help="URL pattern for routing (repeatable)")
@click.option("--force", is_flag=True, help="Overwrite existing contract file")
def partner_init_command(
    app: str,
    publisher: str,
    out_dir: Optional[str],
    url_pattern: tuple[str, ...],
    force: bool,
) -> None:
    app_slug = _to_slug(app)
    destination_dir = Path(out_dir) if out_dir else Path("connectors") / app_slug
    contract_path = destination_dir / "centris-contract.json"
    if contract_path.exists() and not force:
        raise click.ClickException(
            f"Contract already exists: {contract_path} (use --force to overwrite)"
        )
    patterns = list(url_pattern) if url_pattern else [f"{app_slug}.com/*"]
    contract = _build_starter_contract(app, publisher.strip() or "example-partner", patterns)
    contract["generatedAt"] = _utc_now_iso()
    contract["contractHash"] = _compute_contract_hash(contract)
    destination_dir.mkdir(parents=True, exist_ok=True)
    contract_path.write_text(f"{json.dumps(contract, indent=2)}\n", encoding="utf-8")
    click.echo(f"✓ Created partner contract: {contract_path}")


@partner_group.group("trust")
def partner_trust_group() -> None:
    """Manage trusted partner publishers and signing keys."""


@partner_trust_group.command("add")
@click.option("--publisher", required=True, help="Publisher identity")
@click.option("--key-id", required=True, help="Signing key id")
@click.option("--key", default=None, help="Inline public key material (PEM or base64 SPKI)")
@click.option("--key-file", default=None, help="Path to public key file")
@click.option("--app", "apps", multiple=True, help="Allowlist app for this publisher (repeatable)")
@click.option("--trust-file", default=None, help="Partner trust policy file")
@click.option("--allow-unsigned", is_flag=True, help="Allow unsigned external contracts globally")
def partner_trust_add_command(
    publisher: str,
    key_id: str,
    key: Optional[str],
    key_file: Optional[str],
    apps: tuple[str, ...],
    trust_file: Optional[str],
    allow_unsigned: bool,
) -> None:
    trust_path = Path(trust_file) if trust_file else DEFAULT_TRUST_FILE
    policy = _read_trust_policy(trust_path)
    key_material = key
    if not key_material and key_file:
        key_material = Path(key_file).read_text(encoding="utf-8").strip()
    if not key_material:
        raise click.ClickException("Either --key or --key-file is required")

    allowed_publishers = set(_sanitize_string_list(policy.get("allowedPublishers")))
    allowed_publishers.add(publisher)

    public_keys = policy.get("publicKeys")
    if not isinstance(public_keys, dict):
        public_keys = {}
    public_keys[key_id] = key_material

    allowed_apps_by_publisher = policy.get("allowedAppsByPublisher")
    if not isinstance(allowed_apps_by_publisher, dict):
        allowed_apps_by_publisher = {}
    app_set = set(_sanitize_string_list(allowed_apps_by_publisher.get(publisher)))
    for app in apps:
        if app.strip():
            app_set.add(app.strip())
    if app_set:
        allowed_apps_by_publisher[publisher] = sorted(app_set)

    next_policy: TrustPolicyDict = {
        "allowUnsignedExternal": allow_unsigned or policy.get("allowUnsignedExternal") is True,
        "allowedPublishers": sorted(allowed_publishers),
        "allowedAppsByPublisher": allowed_apps_by_publisher,
        "publicKeys": public_keys,
    }
    _write_trust_policy(trust_path, next_policy)
    click.echo(f"✓ Updated partner trust policy: {trust_path}")


@click.group("contract")
def contract_group() -> None:
    """Manage Centris Control Contract files and signatures."""


@contract_group.command("validate")
@click.argument("file", required=False, default="centris-contract.json")
@click.option("--strict", is_flag=True, help="Enable strict validation")
@click.option("--target-version", default=None, help="Target CCC spec version")
@click.option("--trust-file", default=None, help="Partner trust policy file")
@click.option("--allow-unsigned", is_flag=True, help="Allow unsigned external contracts")
@click.option("--json-output", "json_output", is_flag=True, help="Output structured JSON envelope")
def contract_validate_command(
    file: str,
    strict: bool,
    target_version: Optional[str],
    trust_file: Optional[str],
    allow_unsigned: bool,
    json_output: bool,
) -> None:
    started_at = time.time()
    file_path = Path(file).resolve()
    raw_contract = _read_json_object(file_path)
    normalized, warnings, errors = _validate_contract_policy(
        raw_contract,
        strict=strict,
        target_version=target_version,
    )
    trust_path = Path(trust_file) if trust_file else DEFAULT_TRUST_FILE
    trust_policy = _read_trust_policy(trust_path)
    trusted, trust_reason = _evaluate_trust(
        normalized,
        source_kind=_detect_source_kind(file_path, Path.cwd()),
        trust_policy=trust_policy,
        allow_unsigned_override=allow_unsigned,
    )
    if not trusted:
        errors.append(f"Trust validation failed: {trust_reason}")

    ok = len(errors) == 0
    if json_output:
        emit_result_envelope(
            build_result_envelope(
                ok=ok,
                operation="contract.validate",
                summary="Contract validation passed" if ok else "Contract validation failed",
                data={
                    "app": normalized.get("app"),
                    "publisher": normalized.get("publisher"),
                    "source": str(file_path),
                    "trusted": trusted,
                    "trustReason": trust_reason,
                },
                warnings=warnings,
                errors=errors,
                duration_ms=int((time.time() - started_at) * 1000),
            )
        )
    else:
        for warning in warnings:
            click.echo(f"⚠ {warning}")
        if ok:
            click.echo(f"✓ Contract is valid: {file_path}")
        else:
            for err in errors:
                click.echo(f"✗ {err}")

    if not ok:
        raise click.ClickException("Contract validation failed.")


@contract_group.command("sign")
@click.argument("file", required=False, default="centris-contract.json")
@click.option("--key-id", required=True, help="Signing key id")
@click.option(
    "--algorithm",
    type=click.Choice(["sha256", "ed25519"]),
    default="ed25519",
    show_default=True,
)
@click.option("--private-key-file", default=None, help="Private key file (required for ed25519)")
@click.option("--publisher", default=None, help="Override publisher before signing")
@click.option("--out", "out_path", default=None, help="Output path (default: overwrite input)")
def contract_sign_command(
    file: str,
    key_id: str,
    algorithm: str,
    private_key_file: Optional[str],
    publisher: Optional[str],
    out_path: Optional[str],
) -> None:
    file_path = Path(file).resolve()
    destination = Path(out_path).resolve() if out_path else file_path
    contract = _normalize_contract(_read_json_object(file_path))
    if publisher and publisher.strip():
        contract["publisher"] = publisher.strip()

    contract_hash = _compute_contract_hash(contract)
    contract["contractHash"] = contract_hash
    if algorithm == "sha256":
        signature = contract_hash.replace("sha256:", "").strip().lower()
    else:
        if not private_key_file:
            raise click.ClickException("ed25519 signing requires --private-key-file")
        private_key_text = Path(private_key_file).read_text(encoding="utf-8").strip()
        signature = _sign_ed25519(_canonical_contract_payload(contract).encode("utf-8"), private_key_text)

    contract["trust"] = {
        "keyId": key_id,
        "signatureAlgorithm": algorithm,
        "signature": signature,
        "signedAt": _utc_now_iso(),
    }
    destination.parent.mkdir(parents=True, exist_ok=True)
    destination.write_text(f"{json.dumps(contract, indent=2)}\n", encoding="utf-8")
    click.echo(f"✓ Signed contract written: {destination}")


def _ensure_output_path(path_obj: Path, force: bool) -> None:
    path_obj.parent.mkdir(parents=True, exist_ok=True)
    if path_obj.exists() and not force:
        raise click.ClickException(f"Output exists: {path_obj} (use --force to overwrite)")


@contract_group.command("publish")
@click.argument("file", required=False, default="centris-contract.json")
@click.option(
    "--well-known-out",
    default=".well-known/centris-contract.json",
    show_default=True,
    help="Output path for .well-known contract file",
)
@click.option("--package-out-dir", default=None, help="Optional package output directory")
@click.option("--force", is_flag=True, help="Overwrite output files")
@click.option("--dry-run", is_flag=True, help="Print publish plan without writing files")
@click.option("--json-output", "json_output", is_flag=True, help="Output structured JSON envelope")
def contract_publish_command(
    file: str,
    well_known_out: str,
    package_out_dir: Optional[str],
    force: bool,
    dry_run: bool,
    json_output: bool,
) -> None:
    started_at = time.time()
    file_path = Path(file).resolve()
    normalized, _, errors = _validate_contract_policy(
        _read_json_object(file_path),
        strict=True,
        target_version="1.0",
    )
    if errors:
        raise click.ClickException(f"Publish blocked: {' | '.join(errors)}")

    well_known_path = Path(well_known_out).resolve()
    outputs = [str(well_known_path)]
    package_contract_path: Optional[Path] = None
    package_readme_path: Optional[Path] = None
    if package_out_dir:
        package_dir = Path(package_out_dir).resolve()
        package_contract_path = package_dir / "centris-contract.json"
        package_readme_path = package_dir / "README.md"
        outputs.extend([str(package_contract_path), str(package_readme_path)])

    if json_output:
        emit_result_envelope(
            build_result_envelope(
                ok=True,
                operation="contract.publish",
                summary="Dry-run publish plan" if dry_run else "Contract published",
                data={
                    "app": normalized.get("app"),
                    "source": str(file_path),
                    "outputs": outputs,
                    "dry_run": dry_run,
                },
                duration_ms=int((time.time() - started_at) * 1000),
            )
        )
        if dry_run:
            return

    if dry_run:
        click.echo(f"Dry run: {', '.join(outputs)}")
        return

    _ensure_output_path(well_known_path, force)
    well_known_path.write_text(f"{json.dumps(normalized, indent=2)}\n", encoding="utf-8")

    if package_contract_path and package_readme_path:
        _ensure_output_path(package_contract_path, force)
        _ensure_output_path(package_readme_path, force)
        package_contract_path.write_text(f"{json.dumps(normalized, indent=2)}\n", encoding="utf-8")
        package_readme_path.write_text(
            (
                f"# {normalized.get('app', 'Centris')} Centris Control Contract\n\n"
                "This package contains `centris-contract.json` for partner contract distribution.\n\n"
                "Host this contract at `/.well-known/centris-contract.json` for discovery.\n"
            ),
            encoding="utf-8",
        )

    click.echo(f"✓ Published contract to {well_known_path}")
    if package_contract_path:
        click.echo(f"✓ Published package files to {package_contract_path.parent}")
