"""Module configuration models, validation, and persistence for Autopilot."""

from __future__ import annotations

import hashlib
import os
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, ClassVar, Literal

from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator, model_validator

try:  # Optional dependency in some environments.
    import yaml
except Exception as exc:  # pragma: no cover - handled at runtime
    yaml = None  # type: ignore
    _yaml_import_error = exc
else:  # pragma: no cover - no error path when yaml present
    _yaml_import_error = None


def _ensure_yaml() -> None:
    if yaml is None:  # pragma: no cover - raised only when dependency missing
        raise RuntimeError("PyYAML is required for module configuration.") from _yaml_import_error


def _canonical_yaml(payload: Any) -> str:
    _ensure_yaml()
    return yaml.safe_dump(
        payload,
        sort_keys=True,
        default_flow_style=False,
        allow_unicode=False,
    )


def _normalize_string_list(values: list[str], *, field_name: str) -> list[str]:
    normalized: list[str] = []
    for value in values:
        cleaned = value.strip()
        if not cleaned:
            raise ValueError(f"{field_name} cannot include empty values")
        normalized.append(cleaned)
    return normalized


def _validate_code_fragment(value: str | None, *, field_name: str) -> str | None:
    if value is None:
        return None
    cleaned = value.strip()
    if not cleaned:
        return None
    lowered = cleaned.lower()
    banned_tokens = ("select ", "insert ", "update ", "delete ", "merge ", "upsert ", "drop ", "alter ")
    if any(token in lowered for token in banned_tokens):
        raise ValueError(f"{field_name} must be a guarded filter fragment, not a full query")
    if ";" in cleaned or "--" in cleaned or "/*" in cleaned or "*/" in cleaned:
        raise ValueError(f"{field_name} contains unsupported control characters")
    return cleaned


class ModuleError(Exception):
    """Base module configuration error."""


class ModuleValidationError(ModuleError):
    """Raised when module payload validation fails."""


class RevisionConflictError(ModuleError):
    """Raised when an update uses a stale revision hash."""


class ModuleCompileError(ModuleError):
    """Raised when runtime policy compilation fails after module validation."""


class FieldMeta(BaseModel):
    model_config = ConfigDict(extra="ignore")

    label: str
    group: str
    widget: str
    choices: list[str] = Field(default_factory=list)
    choice_labels: dict[str, str] = Field(default_factory=dict)
    choice_descriptions: dict[str, str] = Field(default_factory=dict)
    help_text: str | None = None
    placeholder: str | None = None
    warning: str | None = None
    advanced: bool = False


class ModuleConfigBase(BaseModel):
    """Typed config base with field metadata accessors for UI rendering."""

    model_config = ConfigDict(extra="forbid")
    _FIELD_META: ClassVar[dict[str, FieldMeta]] = {}

    @classmethod
    def field_meta(cls, field_name: str) -> FieldMeta | None:
        return cls._FIELD_META.get(field_name)

    @classmethod
    def field_meta_map(cls) -> dict[str, FieldMeta]:
        return dict(cls._FIELD_META)


class RiskTierConfig(BaseModel):
    model_config = ConfigDict(extra="forbid")

    green_max_score: int = Field(default=30, ge=0, le=100)
    yellow_max_score: int = Field(default=70, ge=0, le=100)
    red_min_score: int = Field(default=71, ge=0, le=100)

    @model_validator(mode="after")
    def _validate_order(self) -> "RiskTierConfig":
        if not (self.green_max_score < self.yellow_max_score < self.red_min_score):
            raise ValueError("risk_tiers must satisfy green_max_score < yellow_max_score < red_min_score")
        return self


class Invariant(BaseModel):
    model_config = ConfigDict(extra="forbid")

    name: str
    check: Literal["field_not_null", "field_in", "field_equals", "field_changed", "tag_present"]
    params: dict[str, Any] = Field(default_factory=dict)

    @field_validator("name")
    @classmethod
    def _validate_name(cls, value: str) -> str:
        cleaned = value.strip()
        if not cleaned:
            raise ValueError("invariant name is required")
        return cleaned


class ReversibilityContract(BaseModel):
    model_config = ConfigDict(extra="forbid")

    reversible: bool
    undo_action_type: str | None = None
    undo_ttl_hours: int = Field(default=72, ge=1, le=720)
    undo_requires_approval: bool = False
    side_effects_on_undo: list[str] = Field(default_factory=list)

    @field_validator("undo_action_type")
    @classmethod
    def _validate_undo_action_type(cls, value: str | None) -> str | None:
        if value is None:
            return None
        cleaned = value.strip()
        return cleaned or None

    @field_validator("side_effects_on_undo")
    @classmethod
    def _validate_side_effects(cls, values: list[str]) -> list[str]:
        return _normalize_string_list(values, field_name="side_effects_on_undo")


class ModuleSafetyManifest(BaseModel):
    model_config = ConfigDict(extra="forbid")

    blast_radius_max_records_per_run: int = Field(default=500, ge=1, le=100000)
    blast_radius_max_records_per_day: int = Field(default=2000, ge=1, le=1000000)
    max_actions_per_entity_per_day: int = Field(default=3, ge=1, le=1000)
    idempotency_scope: str = "record_id+tool+date"
    invariants: list[Invariant] = Field(default_factory=list)
    requires_integrations: list[str] = Field(default_factory=list)
    noop_when_unconfigured: bool = True

    @field_validator("idempotency_scope")
    @classmethod
    def _validate_idempotency_scope(cls, value: str) -> str:
        cleaned = value.strip()
        if not cleaned:
            raise ValueError("idempotency_scope is required")
        return cleaned

    @field_validator("requires_integrations")
    @classmethod
    def _validate_requires_integrations(cls, values: list[str]) -> list[str]:
        normalized = [value.strip().lower() for value in values]
        return _normalize_string_list(normalized, field_name="requires_integrations")


class ReconciliationRule(BaseModel):
    model_config = ConfigDict(extra="forbid")

    crm_field: str
    contract_field: str
    tolerance: Literal["exact", "date_day", "currency_cents", "percent_0_1"]

    @field_validator("crm_field", "contract_field")
    @classmethod
    def _validate_required_strings(cls, value: str) -> str:
        cleaned = value.strip()
        if not cleaned:
            raise ValueError("reconciliation fields must be non-empty")
        return cleaned


class L2AConfig(ModuleConfigBase):
    match_threshold: int = Field(default=85, ge=0, le=100)
    normalize_companies: bool = True
    domain_normalization: bool = True
    blocking_keys: list[str] = Field(default_factory=lambda: ["domain", "company_name"])
    conflict_resolution: Literal["highest_confidence", "manual_review", "most_recent_activity"] = (
        "highest_confidence"
    )
    auto_assign_owner: bool = False
    owner_assignment_rule: Literal["lead_owner", "round_robin", "territory_based"] = "lead_owner"
    exclude_patterns: list[str] = Field(default_factory=list)
    salesforce_lead_filter: str | None = None
    salesforce_account_filter: str | None = None

    @field_validator("blocking_keys", "exclude_patterns")
    @classmethod
    def _validate_lists(cls, values: list[str], info) -> list[str]:
        return _normalize_string_list(values, field_name=info.field_name)

    @field_validator("salesforce_lead_filter", "salesforce_account_filter")
    @classmethod
    def _validate_soql_fragments(cls, value: str | None, info) -> str | None:
        return _validate_code_fragment(value, field_name=info.field_name)


class DealDeskConfig(ModuleConfigBase):
    """Configuration for Deal Desk seller intake, close certification, and operator review posture."""

    operating_mode: Literal["quote_readiness", "quote_execution", "close_verification"] = "close_verification"
    salesforce_org_alias: str | None = None
    preferred_quote_engine: Literal["auto", "standard_quotes", "salesforce_cpq"] = "auto"
    readiness_opportunity_id: str | None = None
    history_lookback_months: int = Field(default=12, ge=1, le=24)
    close_date_source: Literal["opportunity", "contract", "latest_signature"] = "opportunity"
    required_documents: list[str] = Field(default_factory=lambda: ["signed_order_form"])
    signature_requirements: list[str] = Field(default_factory=lambda: ["customer_signature"])
    field_reconciliation_rules: list[ReconciliationRule] = Field(default_factory=list)
    completion_criteria: Literal[
        "required_documents_complete",
        "required_documents_and_signatures_complete",
        "required_documents_or_exec_override",
    ] = "required_documents_complete"
    failure_action: Literal["create_exception", "hold_pipeline", "notify_owner"] = "create_exception"
    reference_record_ids: list[str] = Field(default_factory=list)

    @field_validator("salesforce_org_alias", "readiness_opportunity_id")
    @classmethod
    def _validate_optional_text(cls, value: str | None) -> str | None:
        if value is None:
            return None
        cleaned = value.strip()
        return cleaned or None

    @field_validator("completion_criteria", mode="before")
    @classmethod
    def _coerce_completion_criteria(cls, value: Any) -> Any:
        if isinstance(value, str):
            return value.strip()
        if isinstance(value, list):
            normalized = _normalize_string_list(
                [str(entry) for entry in value],
                field_name="completion_criteria",
            )
            for candidate in normalized:
                if candidate in {
                    "required_documents_complete",
                    "required_documents_and_signatures_complete",
                    "required_documents_or_exec_override",
                }:
                    return candidate
            raise ValueError(
                "completion_criteria must include one of: "
                "required_documents_complete, required_documents_and_signatures_complete, "
                "required_documents_or_exec_override"
            )
        return value

    @field_validator("required_documents", "signature_requirements", "reference_record_ids")
    @classmethod
    def _validate_lists(cls, values: list[str], info) -> list[str]:
        return _normalize_string_list(values, field_name=info.field_name)


class SequencePauseConfig(ModuleConfigBase):
    """Config for sequence auto-pause module."""

    blast_radius_max_records_per_run: int = Field(default=50, ge=1, le=5000)
    blast_radius_max_records_per_day: int = Field(default=200, ge=1, le=20000)
    max_actions_per_entity_per_day: int = Field(default=3, ge=1, le=100)
    idempotency_scope: str = "record_id+sequence_id+reason_code+date"
    tag_prefix: str = "gremlin:paused"
    auto_resume_on_clear: bool = False


class SlackRoutingConfig(ModuleConfigBase):
    """Config for Slack exception routing module."""

    default_channel: str = "#revops-alerts"
    default_severity: Literal["info", "low", "medium", "high", "critical"] = "high"
    title_prefix: str = "Autopilot Exception"
    max_records_in_message: int = Field(default=5, ge=1, le=25)
    include_record_details: bool = True


class OffboardingCleanupConfig(ModuleConfigBase):
    """Config for offboarding cleanup module."""

    blast_radius_max_records_per_run: int = Field(default=200, ge=1, le=20000)
    blast_radius_max_records_per_day: int = Field(default=500, ge=1, le=100000)
    max_actions_per_entity_per_day: int = Field(default=3, ge=1, le=100)
    idempotency_scope: str = "record_id+former_owner_id+date"
    tag_prefix: str = "gremlin:offboarded"
    reassign_to_manager_when_available: bool = True
    close_open_tasks: bool = False
    remove_from_sequences: bool = False


class AutoEnrichMissingFieldsConfig(ModuleConfigBase):
    """Config for auto-enrich module."""

    blast_radius_max_records_per_run: int = Field(default=100, ge=1, le=20000)
    blast_radius_max_records_per_day: int = Field(default=500, ge=1, le=100000)
    max_actions_per_entity_per_day: int = Field(default=5, ge=1, le=100)
    idempotency_scope: str = "record_id+field_name+provider+date"
    providers: list[str] = Field(default_factory=lambda: ["zoominfo", "apollo", "clearbit"])
    confidence_floor: float = Field(default=0.7, ge=0.0, le=1.0)
    max_provider_calls_per_run: int = Field(default=100, ge=1, le=10000)
    only_fill_empty_fields: bool = True

    @field_validator("providers")
    @classmethod
    def _validate_providers(cls, values: list[str]) -> list[str]:
        normalized = [str(value).strip().lower() for value in values]
        normalized = _normalize_string_list(normalized, field_name="providers")
        deduped: list[str] = []
        seen: set[str] = set()
        for value in normalized:
            if value in seen:
                continue
            deduped.append(value)
            seen.add(value)
        return deduped


class FollowUpEnforcementConfig(ModuleConfigBase):
    """Config for meeting follow-up enforcement module."""

    blast_radius_max_records_per_run: int = Field(default=50, ge=1, le=5000)
    blast_radius_max_records_per_day: int = Field(default=200, ge=1, le=20000)
    max_actions_per_entity_per_day: int = Field(default=2, ge=1, le=100)
    idempotency_scope: str = "event_id+owner_id+date"
    create_task: bool = True
    apply_stage_hold: bool = False
    hold_stage_name: str = "Follow-up Required"
    notify_slack: bool = False
    slack_channel: str = "#revops-alerts"
    task_subject_prefix: str = "Autopilot follow-up"


L2AConfig._FIELD_META = {
    "match_threshold": FieldMeta(
        label="Match Threshold",
        group="matching",
        widget="slider",
        help_text="Minimum confidence score required before a lead is auto-matched to an account.",
    ),
    "normalize_companies": FieldMeta(
        label="Normalize Companies",
        group="matching",
        widget="toggle",
        help_text="Standardize company names before matching (suffix cleanup, punctuation normalization).",
    ),
    "domain_normalization": FieldMeta(
        label="Domain Normalization",
        group="matching",
        widget="toggle",
        help_text="Normalize domains (subdomain handling and canonical domain cleanup) before matching.",
    ),
    "blocking_keys": FieldMeta(
        label="Blocking Keys",
        group="matching",
        widget="tag_list",
        help_text="Fields used for candidate generation before scoring.",
        placeholder="domain, company_name",
    ),
    "conflict_resolution": FieldMeta(
        label="Conflict Resolution",
        group="matching",
        widget="dropdown",
        choices=["highest_confidence", "manual_review", "most_recent_activity"],
        choice_labels={
            "highest_confidence": "Highest confidence",
            "manual_review": "Manual review queue",
            "most_recent_activity": "Most recent activity",
        },
        choice_descriptions={
            "highest_confidence": "Automatically picks the top-scoring candidate.",
            "manual_review": "Routes ambiguous matches for human review.",
            "most_recent_activity": "Prefers the account with most recent engagement signal.",
        },
        help_text="How to resolve ties or ambiguous matches.",
    ),
    "auto_assign_owner": FieldMeta(
        label="Auto Assign Owner",
        group="assignment",
        widget="toggle",
        help_text="Automatically reassign owner on successful match using the selected assignment rule.",
    ),
    "owner_assignment_rule": FieldMeta(
        label="Owner Assignment Rule",
        group="assignment",
        widget="dropdown",
        choices=["lead_owner", "round_robin", "territory_based"],
        choice_labels={
            "lead_owner": "Current lead owner",
            "round_robin": "Round robin",
            "territory_based": "Territory based",
        },
        choice_descriptions={
            "lead_owner": "Keeps ownership with current lead owner when possible.",
            "round_robin": "Distributes ownership evenly among eligible reps.",
            "territory_based": "Assigns ownership by territory mapping rules.",
        },
        help_text="Assignment strategy when auto-assign is enabled.",
    ),
    "exclude_patterns": FieldMeta(
        label="Exclude Patterns",
        group="filters",
        widget="tag_list",
        help_text="Pattern list to skip known non-production or low-trust records.",
        placeholder="test.invalid, internal.example",
    ),
    "salesforce_lead_filter": FieldMeta(
        label="Salesforce Lead Filter",
        group="filters",
        widget="code",
        help_text="Optional guarded filter fragment applied to Lead retrieval.",
        placeholder="isConverted = false",
        advanced=True,
    ),
    "salesforce_account_filter": FieldMeta(
        label="Salesforce Account Filter",
        group="filters",
        widget="code",
        help_text="Optional guarded filter fragment applied to Account retrieval.",
        placeholder="IsDeleted = false",
        advanced=True,
    ),
}

DealDeskConfig._FIELD_META = {
    "operating_mode": FieldMeta(
        label="Primary Outcome",
        group="agent",
        widget="dropdown",
        choices=["close_verification", "quote_readiness", "quote_execution"],
        choice_labels={
            "close_verification": "Close certification",
            "quote_readiness": "Environment discovery",
            "quote_execution": "Legacy quote automation",
        },
        choice_descriptions={
            "close_verification": "Gate close progression on trusted CRM data, structured document evidence, and reconciliation packets.",
            "quote_readiness": "Profile the Salesforce quote environment as supporting context for Deal Desk operators.",
            "quote_execution": "Keep legacy quote planning and write paths available without presenting them as the primary Deal Desk wedge.",
        },
        help_text="Choose the current Deal Desk posture. Close certification is the primary operator flow; quote automation remains legacy and explicit.",
    ),
    "salesforce_org_alias": FieldMeta(
        label="Salesforce Org Alias",
        group="signals",
        widget="text",
        help_text="Default Salesforce org alias used for closed-won history scans, close-certification checks, and optional environment probes.",
        placeholder="foundry-pbo",
    ),
    "preferred_quote_engine": FieldMeta(
        label="Environment Probe Bias",
        group="signals",
        widget="dropdown",
        choices=["auto", "standard_quotes", "salesforce_cpq"],
        choice_labels={
            "auto": "Auto-detect",
            "standard_quotes": "Native Salesforce Quotes",
            "salesforce_cpq": "Salesforce CPQ",
        },
        choice_descriptions={
            "auto": "Let the supporting environment probe detect the live quote stack for each org.",
            "standard_quotes": "Bias the supporting probe toward native Salesforce Quote objects.",
            "salesforce_cpq": "Bias the supporting probe toward SBQQ-based environments.",
        },
        help_text="Controls how the optional environment probe interprets the quote surface while the main Deal Desk flow stays centered on close certification.",
    ),
    "readiness_opportunity_id": FieldMeta(
        label="Probe Opportunity ID",
        group="signals",
        widget="text",
        help_text="A representative Opportunity used for the supporting environment probe during onboarding.",
        placeholder="006xx00001ABCdE",
    ),
    "history_lookback_months": FieldMeta(
        label="Closed-Won Lookback (Months)",
        group="signals",
        widget="number",
        help_text="How far back the onboarding scan should look when mining closed-won history for representative close-certification patterns and gold-standard candidates.",
    ),
    "close_date_source": FieldMeta(
        label="Close Date Source",
        group="policy",
        widget="dropdown",
        choices=["opportunity", "contract", "latest_signature"],
        choice_labels={
            "opportunity": "Opportunity close date",
            "contract": "Contract effective date",
            "latest_signature": "Latest signature date",
        },
        help_text="Which date field is treated as the official close date for verification.",
    ),
    "required_documents": FieldMeta(
        label="Required Documents",
        group="policy",
        widget="tag_list",
        help_text="Documents that must exist on the deal packet before the module marks it complete.",
        placeholder="signed_order_form, executed_msa",
    ),
    "signature_requirements": FieldMeta(
        label="Signature Requirements",
        group="policy",
        widget="tag_list",
        help_text="Required signers or signature checks that must pass for completion.",
        placeholder="customer_signature, legal_signoff",
    ),
    "field_reconciliation_rules": FieldMeta(
        label="Field Reconciliation Rules",
        group="reconciliation",
        widget="table",
        help_text="Optional CRM-to-contract field checks (advanced).",
    ),
    "completion_criteria": FieldMeta(
        label="Completion Criteria",
        group="completion",
        widget="dropdown",
        choices=[
            "required_documents_complete",
            "required_documents_and_signatures_complete",
            "required_documents_or_exec_override",
        ],
        choice_labels={
            "required_documents_complete": "All required documents are present",
            "required_documents_and_signatures_complete": "Documents and signatures are both required",
            "required_documents_or_exec_override": "Documents required unless executive override is present",
        },
        choice_descriptions={
            "required_documents_complete": "Fastest default: only checks required documents.",
            "required_documents_and_signatures_complete": "Strict mode: deal must have required docs and signatures.",
            "required_documents_or_exec_override": "Allows approved exceptions without blocking close.",
        },
        help_text="Defines exactly when a deal packet is considered complete.",
    ),
    "failure_action": FieldMeta(
        label="Failure Action",
        group="completion",
        widget="dropdown",
        choices=["create_exception", "hold_pipeline", "notify_owner"],
        choice_labels={
            "create_exception": "Create exception record",
            "hold_pipeline": "Place deal in hold state",
            "notify_owner": "Notify owner only",
        },
        help_text="What the system should do when completion criteria fail.",
    ),
    "reference_record_ids": FieldMeta(
        label="Gold-Standard Opportunity IDs",
        group="quote",
        widget="tag_list",
        help_text="Optional: highlight the opportunities that best represent how Deal Desk should behave in this org. The wizard can also suggest candidates from the last 12 months of closed-won history.",
        placeholder="006xx00001ABCdE, 006xx00001XYZkL",
    ),
}

SequencePauseConfig._FIELD_META = {
    "blast_radius_max_records_per_run": FieldMeta(
        label="Max Records Per Run",
        group="safety",
        widget="number",
        help_text="Hard cap on sequence pause actions in a single run.",
    ),
    "blast_radius_max_records_per_day": FieldMeta(
        label="Max Records Per Day",
        group="safety",
        widget="number",
        help_text="Daily cap across all runs for this module.",
    ),
    "max_actions_per_entity_per_day": FieldMeta(
        label="Max Actions Per Entity/Day",
        group="safety",
        widget="number",
        help_text="Maximum number of pause actions allowed for a single record per day.",
    ),
    "idempotency_scope": FieldMeta(
        label="Idempotency Scope",
        group="safety",
        widget="text",
        help_text="Deterministic key used to prevent duplicate actions for the same condition.",
        placeholder="record_id+sequence_id+reason_code+date",
    ),
    "tag_prefix": FieldMeta(
        label="Tag Prefix",
        group="behavior",
        widget="text",
        help_text="Tag prefix applied to paused records for traceability and reversibility.",
        placeholder="gremlin:paused",
    ),
    "auto_resume_on_clear": FieldMeta(
        label="Auto Resume On Clear",
        group="behavior",
        widget="toggle",
        help_text="Automatically resume paused sequences when blocking condition is cleared.",
        warning="Disabled by default in v1.",
    ),
}

SlackRoutingConfig._FIELD_META = {
    "default_channel": FieldMeta(
        label="Default Channel",
        group="routing",
        widget="text",
        help_text="Fallback Slack channel for exception notifications.",
        placeholder="#revops-alerts",
    ),
    "default_severity": FieldMeta(
        label="Default Severity",
        group="routing",
        widget="dropdown",
        choices=["info", "low", "medium", "high", "critical"],
        choice_labels={
            "info": "Info",
            "low": "Low",
            "medium": "Medium",
            "high": "High",
            "critical": "Critical",
        },
        choice_descriptions={
            "info": "Operational notice with low urgency.",
            "low": "Minor issue; track but no immediate action needed.",
            "medium": "Needs triage during normal operations.",
            "high": "Urgent issue requiring same-day action.",
            "critical": "Sev1-like incident requiring immediate response.",
        },
        help_text="Severity applied when an event does not provide one.",
    ),
    "title_prefix": FieldMeta(
        label="Title Prefix",
        group="message",
        widget="text",
        help_text="Prefix added to each Slack notification title.",
        placeholder="Autopilot Exception",
    ),
    "max_records_in_message": FieldMeta(
        label="Records In Message",
        group="message",
        widget="number",
        help_text="Maximum number of records summarized per Slack message.",
    ),
    "include_record_details": FieldMeta(
        label="Include Record Details",
        group="message",
        widget="toggle",
        help_text="Include per-record details in message body when enabled.",
    ),
}

OffboardingCleanupConfig._FIELD_META = {
    "blast_radius_max_records_per_run": FieldMeta(
        label="Max Records Per Run",
        group="safety",
        widget="number",
        help_text="Hard cap on owner cleanup actions in a single run.",
    ),
    "blast_radius_max_records_per_day": FieldMeta(
        label="Max Records Per Day",
        group="safety",
        widget="number",
        help_text="Daily cap across all offboarding cleanup runs.",
    ),
    "max_actions_per_entity_per_day": FieldMeta(
        label="Max Actions Per Entity/Day",
        group="safety",
        widget="number",
        help_text="Maximum reassign/cleanup actions on the same record per day.",
    ),
    "idempotency_scope": FieldMeta(
        label="Idempotency Scope",
        group="safety",
        widget="text",
        help_text="Deterministic key used to avoid duplicate reassignment actions.",
        placeholder="record_id+former_owner_id+date",
    ),
    "tag_prefix": FieldMeta(
        label="Tag Prefix",
        group="behavior",
        widget="text",
        help_text="Tag prefix applied to records touched by offboarding cleanup.",
        placeholder="gremlin:offboarded",
    ),
    "reassign_to_manager_when_available": FieldMeta(
        label="Prefer Manager Reassign",
        group="behavior",
        widget="toggle",
        help_text="When available, assign records to former owner's manager before fallback logic.",
    ),
    "close_open_tasks": FieldMeta(
        label="Close Open Tasks",
        group="behavior",
        widget="toggle",
        help_text="Close open tasks for records reassigned during offboarding cleanup.",
    ),
    "remove_from_sequences": FieldMeta(
        label="Remove From Sequences",
        group="behavior",
        widget="toggle",
        help_text="Remove reassigned records from active outbound sequences.",
    ),
}

AutoEnrichMissingFieldsConfig._FIELD_META = {
    "blast_radius_max_records_per_run": FieldMeta(
        label="Max Records Per Run",
        group="safety",
        widget="number",
        help_text="Hard cap on enrichment writes in a single run.",
    ),
    "blast_radius_max_records_per_day": FieldMeta(
        label="Max Records Per Day",
        group="safety",
        widget="number",
        help_text="Daily cap across all enrichment runs.",
    ),
    "max_actions_per_entity_per_day": FieldMeta(
        label="Max Actions Per Entity/Day",
        group="safety",
        widget="number",
        help_text="Maximum enrichment writes for the same record per day.",
    ),
    "idempotency_scope": FieldMeta(
        label="Idempotency Scope",
        group="safety",
        widget="text",
        help_text="Deterministic key used to prevent duplicate enrichment writes.",
        placeholder="record_id+field_name+provider+date",
    ),
    "providers": FieldMeta(
        label="Provider Precedence",
        group="behavior",
        widget="tag_list",
        choices=["zoominfo", "apollo", "clearbit"],
        choice_labels={
            "zoominfo": "ZoomInfo",
            "apollo": "Apollo",
            "clearbit": "Clearbit",
        },
        help_text="Provider order used when multiple vendors can fill the same field.",
        placeholder="zoominfo, apollo, clearbit",
    ),
    "confidence_floor": FieldMeta(
        label="Confidence Floor",
        group="behavior",
        widget="slider",
        help_text="Minimum confidence required before writing enriched values.",
    ),
    "max_provider_calls_per_run": FieldMeta(
        label="Max Provider Calls / Run",
        group="behavior",
        widget="number",
        help_text="Safety cap on external provider API calls per run.",
    ),
    "only_fill_empty_fields": FieldMeta(
        label="Only Fill Empty Fields",
        group="behavior",
        widget="toggle",
        help_text="When enabled, enrichment only writes to currently empty fields.",
    ),
}

FollowUpEnforcementConfig._FIELD_META = {
    "blast_radius_max_records_per_run": FieldMeta(
        label="Max Records Per Run",
        group="safety",
        widget="number",
        help_text="Hard cap on follow-up enforcement actions in a single run.",
    ),
    "blast_radius_max_records_per_day": FieldMeta(
        label="Max Records Per Day",
        group="safety",
        widget="number",
        help_text="Daily cap across all follow-up enforcement runs.",
    ),
    "max_actions_per_entity_per_day": FieldMeta(
        label="Max Actions Per Entity/Day",
        group="safety",
        widget="number",
        help_text="Maximum number of follow-up actions for one event/record per day.",
    ),
    "idempotency_scope": FieldMeta(
        label="Idempotency Scope",
        group="safety",
        widget="text",
        help_text="Deterministic key used to avoid duplicate task/stage actions.",
        placeholder="event_id+owner_id+date",
    ),
    "create_task": FieldMeta(
        label="Create Follow-up Task",
        group="behavior",
        widget="toggle",
        help_text="Create a follow-up task after qualifying meetings.",
    ),
    "apply_stage_hold": FieldMeta(
        label="Apply Stage Hold",
        group="behavior",
        widget="toggle",
        help_text="Apply a pipeline stage hold until follow-up requirements are met.",
        warning="Disabled by default in v1.",
    ),
    "hold_stage_name": FieldMeta(
        label="Hold Stage Name",
        group="behavior",
        widget="text",
        help_text="Pipeline stage used when stage hold is enabled.",
        placeholder="Follow-up Required",
    ),
    "notify_slack": FieldMeta(
        label="Notify Slack",
        group="behavior",
        widget="toggle",
        help_text="Send Slack nudges when follow-up enforcement triggers.",
    ),
    "slack_channel": FieldMeta(
        label="Slack Channel",
        group="behavior",
        widget="text",
        help_text="Destination Slack channel for follow-up notifications.",
        placeholder="#revops-alerts",
    ),
    "task_subject_prefix": FieldMeta(
        label="Task Subject Prefix",
        group="behavior",
        widget="text",
        help_text="Prefix added to created follow-up task subjects.",
        placeholder="Autopilot follow-up",
    ),
}


class ModuleEnvelope(BaseModel):
    model_config = ConfigDict(extra="ignore")

    module_id: str
    display_name: str
    description: str
    icon: str = "[module]"
    category: str = "general"
    schema_version: int = 1

    enabled: bool = False
    schedule_ref: str | None = None
    exception_routing: str | None = None
    risk_tiers: RiskTierConfig = Field(default_factory=RiskTierConfig)
    config: dict[str, Any] = Field(default_factory=dict)

    updated_at: str | None = None
    updated_by: str | None = None
    revision_hash: str | None = None
    policy_hash: str | None = None


class ModulePutPayload(BaseModel):
    """Full-replace PUT payload contract used by server handlers."""

    model_config = ConfigDict(extra="forbid")

    revision_hash: str
    enabled: bool
    schedule_ref: str | None
    exception_routing: str | None
    risk_tiers: RiskTierConfig
    config: dict[str, Any]


@dataclass(frozen=True)
class ModuleDefinition:
    module_id: str
    display_name: str
    description: str
    config_model: type[ModuleConfigBase]
    icon: str = "[module]"
    category: str = "general"
    schema_version: int = 1
    write_class_keys: tuple[str, ...] = ()

    def default_config(self) -> dict[str, Any]:
        return self.config_model().model_dump(exclude_none=False)


@dataclass(frozen=True)
class ModuleSaveResult:
    envelope: ModuleEnvelope
    previous_revision_hash: str | None
    policy_hash: str | None


MODULE_REGISTRY: dict[str, ModuleDefinition] = {
    "deal_desk": ModuleDefinition(
        module_id="deal_desk",
        display_name="Deal Desk",
        description="Policy-controlled close certification, operator review, and post-close control with Slack-first seller intake; legacy quote automation remains explicit and secondary.",
        icon="[dealdesk]",
        category="operations",
        config_model=DealDeskConfig,
        write_class_keys=("dealdesk.closed_won_verification",),
    ),
    "lead_to_account": ModuleDefinition(
        module_id="lead_to_account",
        display_name="Lead to Account",
        description="Lead-to-account matching thresholds, filters, and owner assignment behavior.",
        icon="[l2a]",
        category="matching",
        config_model=L2AConfig,
        write_class_keys=("l2a.match",),
    ),
    "sequence_pause": ModuleDefinition(
        module_id="sequence_pause",
        display_name="Sequence Auto-Pause",
        description="Pauses outbound sequence enrollments for DNC and compliance findings.",
        icon="[pause]",
        category="compliance",
        config_model=SequencePauseConfig,
        write_class_keys=("sequence.pause",),
    ),
    "offboarding_cleanup": ModuleDefinition(
        module_id="offboarding_cleanup",
        display_name="Offboarding Cleanup",
        description="Reassigns records from inactive owners with reversible safety controls.",
        icon="[offboard]",
        category="ownership",
        config_model=OffboardingCleanupConfig,
        write_class_keys=("user.offboard",),
    ),
    "auto_enrich_missing_fields": ModuleDefinition(
        module_id="auto_enrich_missing_fields",
        display_name="Auto-Enrich Missing Fields",
        description="Fills blank firmographic fields from configured enrichment providers.",
        icon="[enrich]",
        category="data_quality",
        config_model=AutoEnrichMissingFieldsConfig,
        write_class_keys=("record.enrich",),
    ),
    "follow_up_enforcement": ModuleDefinition(
        module_id="follow_up_enforcement",
        display_name="Follow-up Enforcement",
        description="Creates post-meeting follow-up tasks with optional stage hold and Slack nudges.",
        icon="[followup]",
        category="pipeline",
        config_model=FollowUpEnforcementConfig,
        write_class_keys=("meeting.followup.enforce",),
    ),
    "slack_routing": ModuleDefinition(
        module_id="slack_routing",
        display_name="Slack Exception Routing",
        description="Routes scout exceptions to Slack for deterministic owner triage.",
        icon="[slack]",
        category="notifications",
        config_model=SlackRoutingConfig,
        write_class_keys=("notification.slack",),
    ),
}


def infer_module_write_class_from_tool(tool_name: str) -> tuple[str | None, str | None]:
    """Map tool name to module id + write class for receipt/pilot analytics."""
    if tool_name.startswith("dealdesk."):
        return "deal_desk", "dealdesk.closed_won_verification"
    if "lead_to_account" in tool_name or tool_name.startswith("l2a."):
        return "lead_to_account", "l2a.match"
    if tool_name == "sequence.pause":
        return "sequence_pause", "sequence.pause"
    if tool_name in {"sfdc.record.reassign_owner", "user.offboard"}:
        return "offboarding_cleanup", "user.offboard"
    if tool_name in {"sfdc.record.enrich_missing_fields", "record.enrich"}:
        return "auto_enrich_missing_fields", "record.enrich"
    if tool_name in {"sfdc.followup.enforce", "meeting.followup.enforce"}:
        return "follow_up_enforcement", "meeting.followup.enforce"
    if tool_name in {"slack.notify", "notification.slack"}:
        return "slack_routing", "notification.slack"
    return None, None


def list_module_definitions() -> list[ModuleDefinition]:
    return [MODULE_REGISTRY[key] for key in sorted(MODULE_REGISTRY.keys())]


def get_module_definition(module_id: str) -> ModuleDefinition:
    definition = MODULE_REGISTRY.get(module_id)
    if definition is None:
        raise ModuleValidationError(f"Unknown module_id: {module_id}")
    return definition


def _normalized_contract_config(module_id: str, config: dict[str, Any] | None) -> dict[str, Any]:
    definition = get_module_definition(module_id)
    payload = config if isinstance(config, dict) else definition.default_config()
    try:
        return validate_module_config(
            module_id,
            payload,
            require_full=False,
            allow_unknown=True,
        )
    except Exception:
        return definition.default_config()


def resolve_reversibility_contract(
    module_id: str,
    config: dict[str, Any] | None = None,
) -> ReversibilityContract:
    del config
    normalized = str(module_id or "").strip().lower()
    if normalized == "sequence_pause":
        return ReversibilityContract(
            reversible=True,
            undo_action_type="resume_sequence",
            undo_ttl_hours=72,
            undo_requires_approval=True,
            side_effects_on_undo=["remove_pause_tag"],
        )
    if normalized == "deal_desk":
        return ReversibilityContract(
            reversible=True,
            undo_action_type="restore_fields",
            undo_ttl_hours=72,
            undo_requires_approval=False,
        )
    if normalized == "lead_to_account":
        return ReversibilityContract(
            reversible=True,
            undo_action_type="restore_match_assignment",
            undo_ttl_hours=72,
            undo_requires_approval=False,
        )
    if normalized == "offboarding_cleanup":
        return ReversibilityContract(
            reversible=True,
            undo_action_type="reassign_owner",
            undo_ttl_hours=168,
            undo_requires_approval=True,
            side_effects_on_undo=["restore_original_owner"],
        )
    if normalized == "auto_enrich_missing_fields":
        return ReversibilityContract(
            reversible=True,
            undo_action_type="clear_field",
            undo_ttl_hours=72,
            undo_requires_approval=False,
            side_effects_on_undo=["clear_enriched_value"],
        )
    if normalized == "follow_up_enforcement":
        return ReversibilityContract(
            reversible=True,
            undo_action_type="close_task",
            undo_ttl_hours=72,
            undo_requires_approval=False,
            side_effects_on_undo=["restore_previous_stage"],
        )
    if normalized == "slack_routing":
        return ReversibilityContract(
            reversible=False,
            undo_action_type=None,
            undo_ttl_hours=1,
            undo_requires_approval=False,
            side_effects_on_undo=["notification_is_not_retractable"],
        )
    return ReversibilityContract(reversible=True)


def resolve_module_safety_manifest(
    module_id: str,
    config: dict[str, Any] | None = None,
) -> ModuleSafetyManifest:
    normalized_module_id = str(module_id or "").strip().lower()
    normalized_config = _normalized_contract_config(normalized_module_id, config)

    if normalized_module_id == "sequence_pause":
        try:
            max_per_run = int(normalized_config.get("blast_radius_max_records_per_run", 50))
        except Exception:
            max_per_run = 50
        try:
            max_per_day = int(normalized_config.get("blast_radius_max_records_per_day", 200))
        except Exception:
            max_per_day = 200
        try:
            max_per_entity = int(normalized_config.get("max_actions_per_entity_per_day", 3))
        except Exception:
            max_per_entity = 3
        idempotency_scope = str(
            normalized_config.get("idempotency_scope") or "record_id+sequence_id+reason_code+date"
        ).strip()
        tag_prefix = str(normalized_config.get("tag_prefix") or "gremlin:paused").strip()
        return ModuleSafetyManifest(
            blast_radius_max_records_per_run=max(1, max_per_run),
            blast_radius_max_records_per_day=max(1, max_per_day),
            max_actions_per_entity_per_day=max(1, max_per_entity),
            idempotency_scope=idempotency_scope or "record_id+sequence_id+reason_code+date",
            invariants=[
                Invariant(
                    name="sequence_status_valid",
                    check="field_in",
                    params={"field": "sequence_status", "values": ["paused", "pause_requested"]},
                ),
                Invariant(
                    name="pause_tag_present",
                    check="tag_present",
                    params={"tag_prefix": tag_prefix or "gremlin:paused"},
                ),
            ],
            requires_integrations=["salesforce", "outreach"],
            noop_when_unconfigured=True,
        )

    if normalized_module_id == "deal_desk":
        return ModuleSafetyManifest(
            blast_radius_max_records_per_run=200,
            blast_radius_max_records_per_day=1000,
            max_actions_per_entity_per_day=5,
            idempotency_scope="record_id+tool+date",
            invariants=[],
            requires_integrations=["salesforce"],
            noop_when_unconfigured=True,
        )

    if normalized_module_id == "lead_to_account":
        return ModuleSafetyManifest(
            blast_radius_max_records_per_run=200,
            blast_radius_max_records_per_day=1000,
            max_actions_per_entity_per_day=5,
            idempotency_scope="record_id+tool+date",
            invariants=[],
            requires_integrations=["salesforce"],
            noop_when_unconfigured=True,
        )

    if normalized_module_id == "offboarding_cleanup":
        try:
            max_per_run = int(normalized_config.get("blast_radius_max_records_per_run", 200))
        except Exception:
            max_per_run = 200
        try:
            max_per_day = int(normalized_config.get("blast_radius_max_records_per_day", 500))
        except Exception:
            max_per_day = 500
        try:
            max_per_entity = int(normalized_config.get("max_actions_per_entity_per_day", 3))
        except Exception:
            max_per_entity = 3
        idempotency_scope = str(
            normalized_config.get("idempotency_scope") or "record_id+former_owner_id+date"
        ).strip()
        return ModuleSafetyManifest(
            blast_radius_max_records_per_run=max(1, max_per_run),
            blast_radius_max_records_per_day=max(1, max_per_day),
            max_actions_per_entity_per_day=max(1, max_per_entity),
            idempotency_scope=idempotency_scope or "record_id+former_owner_id+date",
            invariants=[],
            requires_integrations=["salesforce"],
            noop_when_unconfigured=True,
        )

    if normalized_module_id == "auto_enrich_missing_fields":
        try:
            max_per_run = int(normalized_config.get("blast_radius_max_records_per_run", 100))
        except Exception:
            max_per_run = 100
        try:
            max_per_day = int(normalized_config.get("blast_radius_max_records_per_day", 500))
        except Exception:
            max_per_day = 500
        try:
            max_per_entity = int(normalized_config.get("max_actions_per_entity_per_day", 5))
        except Exception:
            max_per_entity = 5
        idempotency_scope = str(
            normalized_config.get("idempotency_scope") or "record_id+field_name+provider+date"
        ).strip()
        providers = normalized_config.get("providers") if isinstance(normalized_config.get("providers"), list) else []
        requires_integrations = [str(value).strip().lower() for value in providers if str(value).strip()]
        if not requires_integrations:
            requires_integrations = ["zoominfo", "apollo", "clearbit"]
        return ModuleSafetyManifest(
            blast_radius_max_records_per_run=max(1, max_per_run),
            blast_radius_max_records_per_day=max(1, max_per_day),
            max_actions_per_entity_per_day=max(1, max_per_entity),
            idempotency_scope=idempotency_scope or "record_id+field_name+provider+date",
            invariants=[],
            requires_integrations=requires_integrations,
            noop_when_unconfigured=True,
        )

    if normalized_module_id == "follow_up_enforcement":
        try:
            max_per_run = int(normalized_config.get("blast_radius_max_records_per_run", 50))
        except Exception:
            max_per_run = 50
        try:
            max_per_day = int(normalized_config.get("blast_radius_max_records_per_day", 200))
        except Exception:
            max_per_day = 200
        try:
            max_per_entity = int(normalized_config.get("max_actions_per_entity_per_day", 2))
        except Exception:
            max_per_entity = 2
        idempotency_scope = str(
            normalized_config.get("idempotency_scope") or "event_id+owner_id+date"
        ).strip()
        requires_integrations = ["salesforce"]
        if bool(normalized_config.get("notify_slack")):
            requires_integrations.append("slack")
        return ModuleSafetyManifest(
            blast_radius_max_records_per_run=max(1, max_per_run),
            blast_radius_max_records_per_day=max(1, max_per_day),
            max_actions_per_entity_per_day=max(1, max_per_entity),
            idempotency_scope=idempotency_scope or "event_id+owner_id+date",
            invariants=[],
            requires_integrations=requires_integrations,
            noop_when_unconfigured=True,
        )

    if normalized_module_id == "slack_routing":
        return ModuleSafetyManifest(
            blast_radius_max_records_per_run=200,
            blast_radius_max_records_per_day=2000,
            max_actions_per_entity_per_day=2,
            idempotency_scope="record_id+reason_code+tool+date",
            invariants=[],
            requires_integrations=["slack"],
            noop_when_unconfigured=True,
        )

    return ModuleSafetyManifest()


def resolve_module_execution_contract(
    module_id: str,
    config: dict[str, Any] | None = None,
) -> tuple[ReversibilityContract, ModuleSafetyManifest]:
    return (
        resolve_reversibility_contract(module_id, config),
        resolve_module_safety_manifest(module_id, config),
    )


def _editable_envelope_payload(envelope: ModuleEnvelope) -> dict[str, Any]:
    return {
        "enabled": envelope.enabled,
        "schedule_ref": envelope.schedule_ref,
        "exception_routing": envelope.exception_routing,
        "risk_tiers": envelope.risk_tiers.model_dump(exclude_none=False),
        "config": envelope.config,
    }


def compute_revision_hash(payload: ModuleEnvelope | dict[str, Any]) -> str:
    if isinstance(payload, ModuleEnvelope):
        editable_payload = _editable_envelope_payload(payload)
    else:
        editable_payload = {
            "enabled": payload.get("enabled"),
            "schedule_ref": payload.get("schedule_ref"),
            "exception_routing": payload.get("exception_routing"),
            "risk_tiers": payload.get("risk_tiers") or {},
            "config": payload.get("config") or {},
        }
    digest = hashlib.sha256(_canonical_yaml(editable_payload).encode("utf-8")).hexdigest()
    return f"sha256:{digest}"


def default_module_envelope(module_id: str) -> ModuleEnvelope:
    definition = get_module_definition(module_id)
    envelope = ModuleEnvelope(
        module_id=definition.module_id,
        display_name=definition.display_name,
        description=definition.description,
        icon=definition.icon,
        category=definition.category,
        schema_version=definition.schema_version,
        config=definition.default_config(),
    )
    envelope.revision_hash = compute_revision_hash(envelope)
    return envelope


def validate_module_config(
    module_id: str,
    config: dict[str, Any],
    *,
    require_full: bool,
    allow_unknown: bool = False,
) -> dict[str, Any]:
    definition = get_module_definition(module_id)
    if not isinstance(config, dict):
        raise ModuleValidationError("config must be an object")

    payload = dict(config)
    known_fields = set(definition.config_model.model_fields.keys())

    extra_fields = sorted([key for key in payload.keys() if key not in known_fields])
    if extra_fields and not allow_unknown:
        raise ModuleValidationError(f"Unsupported config fields for {module_id}: {', '.join(extra_fields)}")
    if extra_fields and allow_unknown:
        payload = {key: value for key, value in payload.items() if key in known_fields}

    if require_full:
        missing_fields = sorted([key for key in known_fields if key not in payload])
        if missing_fields:
            raise ModuleValidationError(
                f"config must include all fields for full-replace PUT: {', '.join(missing_fields)}"
            )

    try:
        typed = definition.config_model.model_validate(payload)
    except ValidationError as exc:
        raise ModuleValidationError(f"Invalid config for module {module_id}: {exc}") from exc
    return typed.model_dump(exclude_none=False)


def validate_module_envelope(
    envelope: ModuleEnvelope,
    *,
    schedule_keys: set[str] | None = None,
    routing_targets: set[str] | None = None,
    require_full_config: bool,
    allow_unknown_config: bool = False,
) -> ModuleEnvelope:
    definition = get_module_definition(envelope.module_id)

    if envelope.schedule_ref and schedule_keys is not None and envelope.schedule_ref not in schedule_keys:
        valid = ", ".join(sorted(schedule_keys))
        raise ModuleValidationError(
            f"schedule_ref '{envelope.schedule_ref}' is not defined in policy schedules ({valid})"
        )

    if (
        envelope.exception_routing
        and routing_targets is not None
        and envelope.exception_routing not in routing_targets
    ):
        valid_targets = ", ".join(sorted(routing_targets))
        raise ModuleValidationError(
            f"exception_routing '{envelope.exception_routing}' is not a configured routing target ({valid_targets})"
        )

    normalized_config = validate_module_config(
        envelope.module_id,
        envelope.config,
        require_full=require_full_config,
        allow_unknown=allow_unknown_config,
    )

    normalized = envelope.model_copy(
        update={
            "module_id": definition.module_id,
            "display_name": definition.display_name,
            "description": definition.description,
            "icon": definition.icon,
            "category": definition.category,
            "schema_version": definition.schema_version,
            "config": normalized_config,
        }
    )
    return normalized


def _dump_yaml(path: Path, payload: dict[str, Any]) -> None:
    _ensure_yaml()
    with open(path, "w", encoding="utf-8") as handle:
        yaml.safe_dump(
            payload,
            handle,
            sort_keys=False,
            default_flow_style=False,
            allow_unicode=False,
        )


def _load_yaml(path: Path) -> dict[str, Any]:
    _ensure_yaml()
    with open(path, encoding="utf-8") as handle:
        payload = yaml.safe_load(handle) or {}
    if not isinstance(payload, dict):
        raise ModuleValidationError(f"Invalid module payload in {path}")
    return payload


class ModuleStore:
    """State-backed storage for module envelopes and immutable revision history."""

    def __init__(self, state_dir: Path | str) -> None:
        self.state_dir = Path(state_dir).resolve()
        self.modules_dir = self.state_dir / "modules"
        self.modules_history_dir = self.state_dir / "modules_history"
        self.ensure_dirs()

    def ensure_dirs(self) -> None:
        self.modules_dir.mkdir(parents=True, exist_ok=True)
        self.modules_history_dir.mkdir(parents=True, exist_ok=True)

    def module_path(self, module_id: str) -> Path:
        get_module_definition(module_id)
        return self.modules_dir / f"{module_id}.yaml"

    def _history_dir(self, module_id: str) -> Path:
        get_module_definition(module_id)
        return self.modules_history_dir / module_id

    def list_modules(self) -> list[ModuleEnvelope]:
        return [self.load_module(definition.module_id) for definition in list_module_definitions()]

    def load_module(self, module_id: str) -> ModuleEnvelope:
        definition = get_module_definition(module_id)
        path = self.module_path(module_id)
        if not path.exists():
            return default_module_envelope(definition.module_id)

        raw_payload = _load_yaml(path)
        migrated_payload = self._migrate_payload(raw_payload)
        envelope = ModuleEnvelope.model_validate(migrated_payload)
        normalized = validate_module_envelope(
            envelope,
            require_full_config=False,
            allow_unknown_config=True,
        )
        if not normalized.revision_hash:
            normalized.revision_hash = compute_revision_hash(normalized)
        return normalized

    def save_module(
        self,
        module_id: str,
        payload: ModulePutPayload | dict[str, Any],
        *,
        actor: str | None = None,
        schedule_keys: set[str] | None = None,
        routing_targets: set[str] | None = None,
        compile_policy_fn: Any | None = None,
    ) -> ModuleSaveResult:
        put_payload = ModulePutPayload.model_validate(payload)
        current = self.load_module(module_id)
        current_revision = current.revision_hash or ""
        if put_payload.revision_hash != current_revision:
            raise RevisionConflictError(
                "stale revision_hash: expected "
                f"{current_revision or '<empty>'}, received {put_payload.revision_hash or '<empty>'}"
            )

        definition = get_module_definition(module_id)
        candidate = ModuleEnvelope(
            module_id=definition.module_id,
            display_name=definition.display_name,
            description=definition.description,
            icon=definition.icon,
            category=definition.category,
            schema_version=definition.schema_version,
            enabled=put_payload.enabled,
            schedule_ref=put_payload.schedule_ref,
            exception_routing=put_payload.exception_routing,
            risk_tiers=put_payload.risk_tiers,
            config=put_payload.config,
            updated_at=datetime.now(timezone.utc).isoformat(),
            updated_by=(actor.strip() if actor and actor.strip() else None),
            policy_hash=current.policy_hash,
        )
        candidate = validate_module_envelope(
            candidate,
            schedule_keys=schedule_keys,
            routing_targets=routing_targets,
            require_full_config=True,
            allow_unknown_config=False,
        )
        candidate.revision_hash = compute_revision_hash(candidate)

        policy_hash = current.policy_hash
        if compile_policy_fn is not None:
            modules_by_id = {entry.module_id: entry for entry in self.list_modules()}
            modules_by_id[module_id] = candidate
            ordered_modules = [modules_by_id[key] for key in sorted(modules_by_id.keys())]
            try:
                policy_hash = compile_policy_fn(ordered_modules)
            except Exception as exc:
                raise ModuleCompileError(f"Runtime policy compile failed for module {module_id}") from exc
            if policy_hash and not isinstance(policy_hash, str):
                raise ModuleCompileError("compile_policy_fn must return a policy hash string")
            candidate.policy_hash = policy_hash

        self._write_envelope(candidate)
        self._write_history_snapshot(candidate)

        return ModuleSaveResult(
            envelope=candidate,
            previous_revision_hash=current.revision_hash,
            policy_hash=policy_hash,
        )

    def list_module_history(self, module_id: str) -> list[ModuleEnvelope]:
        history_dir = self._history_dir(module_id)
        if not history_dir.exists():
            return []
        history: list[ModuleEnvelope] = []
        for path in sorted(history_dir.glob("*.yaml")):
            try:
                payload = self._migrate_payload(_load_yaml(path))
                envelope = ModuleEnvelope.model_validate(payload)
                normalized = validate_module_envelope(
                    envelope,
                    require_full_config=False,
                    allow_unknown_config=True,
                )
                if not normalized.revision_hash:
                    normalized.revision_hash = compute_revision_hash(normalized)
                history.append(normalized)
            except Exception:
                continue
        return history

    def _write_envelope(self, envelope: ModuleEnvelope) -> None:
        path = self.module_path(envelope.module_id)
        payload = envelope.model_dump(exclude_none=False)
        self._write_yaml_atomic(path, payload)

    def _write_history_snapshot(self, envelope: ModuleEnvelope) -> None:
        history_dir = self._history_dir(envelope.module_id)
        history_dir.mkdir(parents=True, exist_ok=True)
        timestamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S%fZ")
        revision_token = (envelope.revision_hash or "sha256:none").replace(":", "_")
        path = history_dir / f"{timestamp}_{revision_token}.yaml"
        payload = envelope.model_dump(exclude_none=False)
        self._write_yaml_atomic(path, payload)

    def _write_yaml_atomic(self, path: Path, payload: dict[str, Any]) -> None:
        path.parent.mkdir(parents=True, exist_ok=True)
        tmp_path = path.with_suffix(f"{path.suffix}.tmp")
        _dump_yaml(tmp_path, payload)
        os.replace(str(tmp_path), str(path))

    @staticmethod
    def _migrate_payload(payload: dict[str, Any]) -> dict[str, Any]:
        migrated = dict(payload)
        if "revision_hash" not in migrated:
            legacy_hash = migrated.get("config_hash")
            if isinstance(legacy_hash, str) and legacy_hash:
                migrated["revision_hash"] = legacy_hash
        return migrated


__all__ = [
    "FieldMeta",
    "ModuleConfigBase",
    "RiskTierConfig",
    "Invariant",
    "ReversibilityContract",
    "ModuleSafetyManifest",
    "ModuleEnvelope",
    "ModulePutPayload",
    "L2AConfig",
    "DealDeskConfig",
    "SequencePauseConfig",
    "SlackRoutingConfig",
    "OffboardingCleanupConfig",
    "ReconciliationRule",
    "ModuleDefinition",
    "ModuleSaveResult",
    "ModuleError",
    "ModuleValidationError",
    "RevisionConflictError",
    "ModuleCompileError",
    "MODULE_REGISTRY",
    "infer_module_write_class_from_tool",
    "list_module_definitions",
    "get_module_definition",
    "default_module_envelope",
    "compute_revision_hash",
    "validate_module_config",
    "validate_module_envelope",
    "resolve_reversibility_contract",
    "resolve_module_safety_manifest",
    "resolve_module_execution_contract",
    "ModuleStore",
]
