"""Shared provider contract registry for research-table columns.

This module is intentionally lightweight so it can be imported by the API,
workers, and CLI without pulling in request/DB dependencies.
"""

from __future__ import annotations

from copy import deepcopy
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional, Tuple


@dataclass(frozen=True)
class AutoRunExecutionPolicy:
    name: str
    orchestrator_provider: str
    paid: bool
    force_rerun: bool
    respect_existing_done: bool
    default_batch_size: int
    batch_size_env: Optional[str] = None
    use_mcp_from_config: bool = False
    save_hook_triggers: Tuple[str, ...] = ()
    save_hook_policies: Tuple[str, ...] = ()


@dataclass(frozen=True)
class ResearchTableProviderContract:
    key: str
    display_name: str
    kind: str
    behavior_class: str = "standard_enrichment"
    provider_aliases: Tuple[str, ...] = ()
    origin_aliases: Tuple[str, ...] = ()
    recipe_type: Optional[str] = None
    recipe_payload: Optional[Dict[str, Any]] = None
    recipe_notes: Tuple[str, ...] = ()
    column_type: Optional[Dict[str, Any]] = None
    setup_contract: Dict[str, Any] = field(default_factory=dict)
    execution_policy: Optional[AutoRunExecutionPolicy] = None
    dispatch_strategy: str = "bulk_queue"
    readiness_strategy: Optional[str] = None
    save_time_entrypoint: Optional[str] = None
    is_action_provider: bool = False
    composition_contract: Dict[str, Any] = field(default_factory=dict)

    def matches(self, *, kind: str, provider: str, origin: str) -> bool:
        normalized_kind = _normalize(kind)
        normalized_provider = _normalize(provider)
        normalized_origin = _normalize(origin)
        expected_kind = _normalize(self.kind)
        if expected_kind == "formatter":
            if normalized_kind != "formatter":
                return False
            return True
        if normalized_kind and normalized_kind != expected_kind:
            return False
        return (
            normalized_provider in {_normalize(value) for value in self.provider_aliases}
            or normalized_origin in {_normalize(value) for value in self.origin_aliases}
        )

    def recipe_payload_copy(self) -> Optional[Dict[str, Any]]:
        return deepcopy(self.recipe_payload) if isinstance(self.recipe_payload, dict) else None

    def recipe_notes_list(self) -> List[str]:
        return list(self.recipe_notes or ())

    def column_type_copy(self) -> Optional[Dict[str, Any]]:
        return deepcopy(self.column_type) if isinstance(self.column_type, dict) else None

    def capabilities_contract_copy(self) -> Dict[str, Any]:
        payload: Dict[str, Any] = {
            "display_name": self.display_name,
            "kind": self.kind,
            "behavior_class": self.behavior_class,
            "recipe_type": self.recipe_type,
            "provider_aliases": list(self.provider_aliases or ()),
            "origin_aliases": list(self.origin_aliases or ()),
            "dispatch_strategy": self.dispatch_strategy,
            "readiness_strategy": self.readiness_strategy,
            "action_provider": bool(self.is_action_provider),
            "setup_contract": deepcopy(self.setup_contract),
            "composition_contract": deepcopy(self.composition_contract),
        }
        policy = self.execution_policy
        if policy:
            payload["execution_policy"] = {
                "orchestrator_provider": policy.orchestrator_provider,
                "paid": bool(policy.paid),
                "force_rerun": bool(policy.force_rerun),
                "respect_existing_done": bool(policy.respect_existing_done),
                "default_batch_size": int(policy.default_batch_size),
                "batch_size_env": policy.batch_size_env,
                "use_mcp_from_config": bool(policy.use_mcp_from_config),
            }
            payload["save_time_backfill"] = {
                "supported": bool(self.save_time_entrypoint and policy.save_hook_triggers),
                "entrypoint": self.save_time_entrypoint,
                "triggers": list(policy.save_hook_triggers or ()),
                "policies": list(policy.save_hook_policies or ()),
            }
        else:
            payload["execution_policy"] = None
            payload["save_time_backfill"] = {
                "supported": False,
                "entrypoint": self.save_time_entrypoint,
                "triggers": [],
                "policies": [],
            }
        return payload


class ProviderContractValidationError(ValueError):
    def __init__(
        self,
        code: str,
        message: str,
        *,
        hint: Optional[str] = None,
        detail: Optional[Dict[str, Any]] = None,
    ) -> None:
        super().__init__(message)
        self.code = str(code)
        self.message = str(message)
        self.hint = str(hint) if hint else None
        self.detail = detail or {}

    def as_dict(self) -> Dict[str, Any]:
        payload: Dict[str, Any] = {
            "code": self.code,
            "message": self.message,
        }
        if self.hint:
            payload["hint"] = self.hint
        if self.detail:
            payload["detail"] = deepcopy(self.detail)
        return payload


def _normalize(value: Any) -> str:
    if value is None:
        return ""
    if hasattr(value, "value"):
        value = getattr(value, "value")
    return str(value).strip().lower().replace("_", "")


def _text(value: Any) -> str:
    return str(value or "").strip()


def _mapping_text(value: Any) -> str:
    if isinstance(value, dict):
        return _text(value.get("column"))
    return _text(value)


def _llm_provider_values() -> Tuple[str, ...]:
    return ("llm", "gemini", "xai", "openai", "anthropic", "groq")


def _llm_like_column(column: Dict[str, Any], config: Dict[str, Any]) -> bool:
    kind = _normalize(column.get("kind"))
    if kind and kind != "enrichment":
        return False

    provider = _normalize(config.get("provider"))
    if provider in {_normalize(value) for value in _llm_provider_values()}:
        return True

    mode = _normalize(config.get("mode"))
    if mode in {"basic", "agent"}:
        return True

    promptish_keys = (
        "prompt",
        "advancedPrompt",
        "advanced_prompt",
        "instructions",
        "promptSource",
        "prompt_source",
    )
    return any(_text(config.get(key)) for key in promptish_keys)


def _effective_kind_for_validation(column: Dict[str, Any], config: Dict[str, Any]) -> str:
    kind = _normalize(column.get("kind"))
    if kind:
        return kind
    if _text(config.get("formula")):
        return "formatter"
    if _text(config.get("provider")) or _llm_like_column(column, config):
        return "enrichment"
    return kind


_FORMATTER_POLICY = AutoRunExecutionPolicy(
    name="formatter",
    orchestrator_provider="formatter",
    paid=False,
    force_rerun=True,
    respect_existing_done=False,
    default_batch_size=100,
    batch_size_env="FORMULA_BATCH_SIZE",
    save_hook_triggers=("create", "update"),
    save_hook_policies=("onsourceupdate",),
)

_LLM_POLICY = AutoRunExecutionPolicy(
    name="llm",
    orchestrator_provider="gemini",
    paid=True,
    force_rerun=False,
    respect_existing_done=True,
    default_batch_size=50,
    use_mcp_from_config=True,
    save_hook_triggers=("create", "update"),
    save_hook_policies=("oninsert", "onsourceupdate"),
)

_EMAIL_FINDER_POLICY = AutoRunExecutionPolicy(
    name="email_finder",
    orchestrator_provider="email_finder",
    paid=True,
    force_rerun=False,
    respect_existing_done=True,
    default_batch_size=50,
    save_hook_triggers=("create", "update"),
    save_hook_policies=("onsourceupdate",),
)

_PHONE_FINDER_POLICY = AutoRunExecutionPolicy(
    name="phone_finder",
    orchestrator_provider="phone_finder",
    paid=True,
    force_rerun=False,
    respect_existing_done=True,
    default_batch_size=50,
    save_hook_triggers=("create", "update"),
    save_hook_policies=("onsourceupdate",),
)

_LEAD_FINDER_POLICY = AutoRunExecutionPolicy(
    name="lead_finder",
    orchestrator_provider="lead_finder",
    paid=True,
    force_rerun=False,
    respect_existing_done=True,
    default_batch_size=5,
    batch_size_env="LEAD_FINDER_BATCH_SIZE",
    save_hook_triggers=("create", "update"),
    save_hook_policies=("onsourceupdate",),
)

_ADD_TO_CRM_POLICY = AutoRunExecutionPolicy(
    name="add_to_crm",
    orchestrator_provider="add_to_crm",
    paid=True,
    force_rerun=True,
    respect_existing_done=False,
    default_batch_size=25,
    batch_size_env="ADD_TO_CRM_BATCH_SIZE",
)

_SYNC_TO_TABLE_POLICY = AutoRunExecutionPolicy(
    name="sync_to_table",
    orchestrator_provider="sync_to_table",
    paid=False,
    force_rerun=True,
    respect_existing_done=False,
    default_batch_size=50,
    batch_size_env="SYNC_TO_TABLE_BATCH_SIZE",
)

_ADD_TO_SEQUENCE_POLICY = AutoRunExecutionPolicy(
    name="add_to_sequence",
    orchestrator_provider="add_to_sequence",
    paid=False,
    force_rerun=False,
    respect_existing_done=True,
    default_batch_size=50,
    batch_size_env="ADD_TO_SEQUENCE_BATCH_SIZE",
)

_HTTP_REQUEST_POLICY = AutoRunExecutionPolicy(
    name="http_request",
    orchestrator_provider="http_request",
    paid=False,
    force_rerun=True,
    respect_existing_done=False,
    default_batch_size=25,
    batch_size_env="HTTP_REQUEST_BATCH_SIZE",
    save_hook_triggers=("create", "update"),
    save_hook_policies=("onsourceupdate",),
)

_HTTP_REQUEST_METHODS: Tuple[str, ...] = ("GET", "POST", "PUT", "PATCH", "DELETE")


_PROVIDER_CONTRACTS: Tuple[ResearchTableProviderContract, ...] = (
    ResearchTableProviderContract(
        key="formatter",
        display_name="Formatter",
        kind="formatter",
        behavior_class="formatter",
        recipe_type="formatter",
        recipe_payload={
            "key": "engagement_statement",
            "label": "Engagement Statement",
            "kind": "formatter",
            "dataType": "text",
            "origin": "manual",
            "autoRun": "onSourceUpdate",
            "config": {
                "formula": "return (row['first_name'] || '') + ' ' + (row['last_name'] || '') + \" reacted to David Wilkins' post: \\\"\" + (row['post_content'] || '') + \"\\\"\";",
                "sourceColumns": ["first_name", "last_name", "post_content"],
            },
        },
        recipe_notes=(
            "Formatter formulas must reference row keys (row['first_name'] or row.first_name).",
            "Formatter autoRun is normalized to onSourceUpdate by the backend.",
        ),
        column_type={
            "type": "formatter_formula",
            "kind": "formatter",
            "runnable": True,
            "description": "Derived value from formula/transform logic.",
            "billing": {"billable": False, "credits": 0},
        },
        setup_contract={
            "formula_field": "config.formula",
            "source_columns_field": "config.sourceColumns",
            "auto_run_policy": "onSourceUpdate",
        },
        composition_contract={
            "consumes": {
                "kind": "row_fields",
                "shape": "flat row values from sourceColumns",
            },
            "produces": {
                "kind": "derived_cell_value",
                "shape": "single formatter output cell",
            },
            "common_next_steps": [
                "use as a dependency/input column for enrichment or action providers",
            ],
        },
        execution_policy=_FORMATTER_POLICY,
        dispatch_strategy="bulk_queue",
        save_time_entrypoint="formatter_scheduler",
    ),
    ResearchTableProviderContract(
        key="llm",
        display_name="LLM Enrichment",
        kind="enrichment",
        behavior_class="standard_enrichment",
        provider_aliases=_llm_provider_values(),
        recipe_type="llm_enrichment",
        recipe_payload={
            "key": "company_research",
            "label": "Company Research",
            "kind": "enrichment",
            "dataType": "json",
            "origin": "ai",
            "autoRun": "never",
            "config": {
                "instructions": "Research the company in this row and return JSON with icp_fit, summary, and risks.",
                "mode": "agent",
                "temperature": 0.7,
                "batchSize": 50,
                "promptSource": "generated",
                "useCompanyContext": True,
                "structuredOutput": True,
                "useAutoSchema": True,
            },
        },
        recipe_notes=(
            "Recommended default for agent mode: provide instructions and let the API compile the runnable prompt.",
            "When the platform generates the runnable prompt from instructions, keep useAutoSchema=true unless you intentionally want to own the schema yourself.",
            "Do not send user_schema/response_schema in that default generated-prompt path; prompt/schema drift is possible if you later change the generated prompt but keep a frozen custom schema.",
            "Basic is for extraction, scoring, or classification using fields already in the row. Agent is for tasks that may need outside lookup or web research.",
            "Create/update always materializes the runnable prompt into config.advancedPrompt before execution.",
            "Generated agent-mode prompts always include company/requester context.",
            "Basic mode stays manual-prompt-first; write the prompt directly from the user goal/preferences.",
            "Runtime only injects values for placeholders the prompt explicitly references.",
            "Manual/basic prompts should use explicit placeholders like {{company_name}}, {{domain}}, or {{user_value_proposition}} when those values are needed.",
            "Agent mode is JSON-oriented.",
            "Basic mode can be text or JSON; JSON requires dataType=json.",
        ),
        column_type={
            "type": "llm_enrichment",
            "kind": "enrichment",
            "runnable": True,
            "description": "Prompt-based enrichment with server-managed model selection.",
            "modes": {
                "basic": {
                    "label": "Basic",
                    "description": "Single-pass prompt enrichment for standard extraction/transforms.",
                    "recommended_for": [
                        "structured extraction from existing row context",
                        "high-throughput enrichment",
                    ],
                },
                "agent": {
                    "label": "Agent",
                    "description": "Tool-assisted research workflow for harder-to-find or sparse data.",
                    "recommended_for": [
                        "hard-to-find people",
                        "website-discovered contacts (teachers/team pages)",
                        "multi-step web research",
                    ],
                },
            },
            "mode_field": "config.mode",
            "supported_modes": ["basic", "agent"],
            "output_contract": {
                "agent": {
                    "default_data_type": "json",
                    "supported_data_types": ["json"],
                    "notes": "Agent flows are designed for structured JSON output.",
                },
                "basic": {
                    "supported_data_types": ["text", "json"],
                    "json_requires_data_type": "json",
                    "notes": "Basic mode returns text unless JSON data type/schema is configured.",
                },
            },
            "model_selection": {
                "managed_by": "server",
                "user_selectable": False,
            },
            "billing": {
                "billable": True,
                "unit": "credits_per_row",
                "notes": "Usage-based; final charge can vary by internal mode/policy.",
            },
        },
        setup_contract={
            "provider_selection": "server_managed",
            "provider_values": list(_llm_provider_values()),
            "mode_field": "config.mode",
            "supported_modes": ["basic", "agent"],
            "prompt_authoring": {
                "generated": {
                    "instructions_field": "config.instructions",
                    "prompt_source": "generated",
                    "materialized_prompt_fields": ["config.advancedPrompt"],
                },
                "manual": {
                    "prompt_field": "config.advancedPrompt",
                    "prompt_source": "manual",
                },
            },
            "output_contract": {
                "agent": {
                    "default_data_type": "json",
                    "supported_data_types": ["json"],
                },
                "basic": {
                    "supported_data_types": ["text", "json"],
                    "json_requires_data_type": "json",
                },
            },
        },
        composition_contract={
            "consumes": {
                "kind": "row_context",
                "shape": "source row fields plus requester/company context",
            },
            "produces": {
                "kind": "enrichment_output",
                "shape": "text or json depending on mode/dataType",
                "canonical_list_pattern": {
                    "supported": True,
                    "root": "items",
                    "recommended_for": "agent-mode list fanout workflows",
                },
            },
            "decision_hints": {
                "prefer_when": [
                    "research requires web/tool use",
                    "targets are sparse or hard to find",
                    "output shape is custom beyond fixed lead finder filters",
                ],
                "prefer_instead_of": ["lead_finder for explicit buyer discovery at scale"],
            },
            "common_next_steps": [
                "sync_to_table in list mode when the JSON output includes items[]",
                "projection columns when you want stable scalar fields from JSON output",
            ],
        },
        execution_policy=_LLM_POLICY,
        dispatch_strategy="bulk_queue",
        save_time_entrypoint="provider_save_hook",
    ),
    ResearchTableProviderContract(
        key="email_finder",
        display_name="Email Finder",
        kind="enrichment",
        behavior_class="standard_enrichment",
        provider_aliases=("smart_email_finder", "email_validator", "email_finder"),
        origin_aliases=("email_finder",),
        recipe_type="email_finder",
        recipe_payload={
            "key": "work_email",
            "label": "Work Email",
            "kind": "enrichment",
            "dataType": "json",
            "origin": "email_finder",
            "autoRun": "never",
            "config": {
                "provider": "smart_email_finder",
                "strategy": "cost_optimized",
                "lookupStrategy": "linkedin",
                "linkedinOnly": True,
                "enableProfileFallback": False,
                "linkedinUrl": "linkedin_url",
            },
        },
        recipe_notes=(
            "For non-LinkedIn lookup, use lookupStrategy=domain/company with firstName+lastName+domain/company fields.",
            "Primitive source columns map directly. If an upstream source column stores structured JSON, map it as an object with column + path, for example {\"column\": \"linkedin_lookup\", \"path\": \"linkedin_url\"}.",
        ),
        column_type={
            "type": "email_finder",
            "kind": "enrichment",
            "runnable": True,
            "description": "Finds work email for a lead/company context.",
            "provider": {
                "public_key": "email_finder",
                "engine": "smart_email_finder",
                "selection": "server_managed",
            },
            "execution": {
                "lookup_order": "server_managed",
            },
            "billing": {
                "billable": True,
                "unit": "credits",
                "notes": "Usage/caching dependent.",
            },
        },
        setup_contract={
            "primary_provider_value": "smart_email_finder",
            "supported_provider_values": ["smart_email_finder", "email_validator", "email_finder"],
            "strategy_field": "config.strategy",
            "lookup_strategy_field": "config.lookupStrategy",
            "input_fields": ["config.linkedinUrl", "config.firstName", "config.lastName", "config.company", "config.domain"],
            "structured_source_mapping": {
                "rule": "primitive columns map directly; structured columns use {column, path}",
                "examples": [
                    {"linkedinUrl": {"column": "linkedin_lookup", "path": "linkedin_url"}},
                    {"emailColumn": {"column": "email_validation", "path": "response"}},
                ],
            },
        },
        composition_contract={
            "consumes": {
                "kind": "person_identity",
                "shape": "linkedin/email/domain/name inputs from source row",
            },
            "produces": {
                "kind": "contact_channel",
                "shape": "email result payload in source cell",
            },
            "common_next_steps": [
                "add_to_crm when a valid company domain plus email/identity is present",
            ],
        },
        execution_policy=_EMAIL_FINDER_POLICY,
        save_time_entrypoint="provider_save_hook",
    ),
    ResearchTableProviderContract(
        key="phone_finder",
        display_name="Phone Finder",
        kind="enrichment",
        behavior_class="standard_enrichment",
        provider_aliases=("smart_phone_finder", "phone_validator", "phone_finder"),
        origin_aliases=("phone_finder",),
        recipe_type="phone_finder",
        recipe_payload={
            "key": "mobile_phone",
            "label": "Mobile Phone",
            "kind": "enrichment",
            "dataType": "json",
            "origin": "phone_finder",
            "autoRun": "never",
            "config": {
                "provider": "smart_phone_finder",
                "firstName": "first_name",
                "lastName": "last_name",
                "company": "company",
                "linkedinUrl": "linkedin_url",
            },
        },
        recipe_notes=(
            "You can use linkedinUrl or email/workEmail/personalEmail inputs.",
            "Primitive source columns map directly. If an upstream source column stores structured JSON, map it as an object with column + path, for example {\"column\": \"linkedin_lookup\", \"path\": \"linkedin_url\"}.",
        ),
        column_type={
            "type": "phone_finder",
            "kind": "enrichment",
            "runnable": True,
            "description": "Finds phone/mobile values for lead/company context.",
            "provider": {
                "public_key": "phone_finder",
                "engine": "smart_phone_finder",
                "selection": "server_managed",
            },
            "execution": {
                "lookup_order": "server_managed",
            },
            "billing": {
                "billable": True,
                "unit": "credits",
                "notes": "Success-based charging policy.",
            },
        },
        setup_contract={
            "primary_provider_value": "smart_phone_finder",
            "supported_provider_values": ["smart_phone_finder", "phone_validator", "phone_finder"],
            "strategy_field": "config.strategy",
            "input_fields": [
                "config.linkedinUrl",
                "config.email",
                "config.workEmail",
                "config.personalEmail",
                "config.firstName",
                "config.lastName",
                "config.company",
            ],
            "structured_source_mapping": {
                "rule": "primitive columns map directly; structured columns use {column, path}",
                "examples": [
                    {"linkedinUrl": {"column": "linkedin_lookup", "path": "linkedin_url"}},
                    {"phoneColumn": {"column": "mobile_phone", "path": "mobile_number"}},
                ],
            },
        },
        composition_contract={
            "consumes": {
                "kind": "person_identity",
                "shape": "linkedin/email/name/company inputs from source row",
            },
            "produces": {
                "kind": "contact_channel",
                "shape": "phone/mobile result payload in source cell",
            },
            "common_next_steps": [
                "add_to_crm when a valid company domain plus phone/identity is present",
            ],
        },
        execution_policy=_PHONE_FINDER_POLICY,
        save_time_entrypoint="provider_save_hook",
    ),
    ResearchTableProviderContract(
        key="lead_finder",
        display_name="Lead Finder",
        kind="enrichment",
        behavior_class="standard_enrichment",
        provider_aliases=("lead_finder",),
        recipe_type="lead_finder",
        recipe_payload={
            "key": "lead_contacts",
            "label": "Lead Contacts",
            "kind": "enrichment",
            "dataType": "json",
            "origin": "ai",
            "autoRun": "never",
            "config": {
                "provider": "lead_finder",
                "sourceMode": "bulk_companies",
                "companyDomain": "domain",
                "strictness": "flexible_roles",
                "storeAsLeads": True,
                "autoEnrichEmails": True,
                "autoEnrichPhones": False,
                "jobTitles": ["Head of Sales", "VP Sales"],
                "locations": ["United States"],
                "maxResults": 10,
            },
        },
        recipe_notes=(
            "Add more targeting keys as needed (jobFunctions, seniorities, keywords, excludeKeywords, skills).",
            "Primitive source columns map directly. If companyDomain or other mapped inputs come from structured JSON, use {\"column\": \"...\", \"path\": \"...\"} instead of a plain string column key.",
        ),
        column_type={
            "type": "lead_finder",
            "kind": "enrichment",
            "runnable": True,
            "description": "Generates/fetches lead candidates.",
            "recommended_for": [
                "explicit lead list building",
                "company/domain driven discovery",
            ],
            "fallback_for_hard_cases": {
                "type": "llm_enrichment",
                "mode": "agent",
            },
            "billing": {
                "billable": True,
                "unit": "credits",
                "notes": "Usage-based charging by discovered results.",
            },
        },
        setup_contract={
            "primary_provider_value": "lead_finder",
            "source_mode_field": "config.sourceMode",
            "company_domain_field": "config.companyDomain",
            "max_results_field": "config.maxResults",
            "structured_source_mapping": {
                "rule": "primitive columns map directly; structured columns use {column, path}",
                "examples": [
                    {"companyDomain": {"column": "company_lookup", "path": "domain"}},
                ],
            },
        },
        composition_contract={
            "consumes": {
                "kind": "company_context",
                "shape": "company/domain row fields plus targeting filters",
            },
            "produces": {
                "kind": "lead_candidates",
                "shape": "lead_finder result payload and optional lead records when storeAsLeads=true",
            },
            "decision_hints": {
                "prefer_when": [
                    "you want explicit buyer discovery at scale",
                    "the target personas are known up front",
                ],
                "fallback_to": [
                    "llm_enrichment(agent) for hard-to-find people or custom research patterns",
                ],
            },
            "common_next_steps": [
                "add_to_sequence when lead IDs are already available in the table",
                "email_finder or phone_finder on synced contact tables when you need channel enrichment before CRM export",
            ],
        },
        execution_policy=_LEAD_FINDER_POLICY,
        save_time_entrypoint="provider_save_hook",
    ),
    ResearchTableProviderContract(
        key="add_to_crm",
        display_name="Add to Leads",
        kind="enrichment",
        behavior_class="action_enrichment",
        provider_aliases=("add_to_crm",),
        recipe_type="add_to_crm",
        recipe_payload={
            "key": "add_to_leads",
            "label": "Add to Leads",
            "kind": "enrichment",
            "dataType": "json",
            "origin": "manual",
            "autoRun": "onSourceUpdate",
            "config": {
                "provider": "add_to_crm",
                "leadSource": "research_table_export",
                "fieldMappings": {
                    "mode": "single",
                    "linkedinUrl": "linkedin_url",
                    "companyDomain": "domain",
                    "firstName": "first_name",
                    "lastName": "last_name",
                    "title": "title",
                    "companyName": "company",
                    "emailAddresses": [{"column": "work_email", "path": "response", "type": "work"}],
                    "phoneNumbers": [{"column": "mobile_phone", "path": "mobile_number", "type": "mobile"}],
                },
                "sourceColumns": [
                    "linkedin_url",
                    "domain",
                    "first_name",
                    "last_name",
                    "title",
                    "company",
                    "work_email",
                    "mobile_phone",
                ],
            },
        },
        recipe_notes=(
            "companyDomain is required; LinkedIn is optional.",
            "Include at least one usable hard-identity mapping such as LinkedIn, email, or phone.",
            "If companyDomain is missing in the table, derive or enrich that domain column first.",
            "Primitive source columns map directly. If a mapped source column stores structured JSON, send an object with column + path, for example {\"column\": \"work_email\", \"path\": \"response\"} or {\"column\": \"mobile_phone\", \"path\": \"mobile_number\"}.",
            "For firstName and lastName, prefer authoritative structured source fields when available, for example {\"column\": \"linkedin_profile_lookup_raw\", \"path\": \"first_name\"} and {\"column\": \"linkedin_profile_lookup_raw\", \"path\": \"last_name\"}. Avoid mapping a combined full-name column directly unless that is an intentional fallback and you accept imperfect CRM data.",
            "Add to CRM is optional and non-billable.",
            "Creating add_to_crm after upstream email/phone/lead columns already finished does not replay those older source updates; run add_to_crm explicitly or rerun the upstream source column.",
        ),
        column_type={
            "type": "add_to_crm",
            "kind": "enrichment",
            "runnable": True,
            "description": "Add eligible mapped rows into Leads CRM from research-table data.",
            "requirements": {
                "config_provider": "add_to_crm",
                "field_mappings": "required",
                "eligibility": "company domain plus at least one valid LinkedIn URL, email, or phone must resolve from mapped source columns",
            },
            "billing": {
                "billable": False,
                "credits": 0,
                "notes": "Add to Leads export does not consume credits.",
            },
        },
        setup_contract={
            "primary_provider_value": "add_to_crm",
            "field_mappings_field": "config.fieldMappings",
            "source_columns_field": "config.sourceColumns",
            "eligibility": "company domain plus at least one valid LinkedIn URL, email, or phone must resolve from mapped source columns",
            "replay_behavior": "source updates only; explicit run required to backfill older upstream results",
            "structured_source_mapping": {
                "rule": "primitive columns map directly; structured columns use {column, path}",
                "field": "config.fieldMappings.*",
                "examples": [
                    {"emailAddresses": [{"column": "work_email", "path": "response", "type": "work"}]},
                    {"phoneNumbers": [{"column": "mobile_phone", "path": "mobile_number", "type": "mobile"}]},
                    {"linkedinUrl": {"column": "linkedin_lookup", "path": "linkedin_url"}},
                ],
            },
        },
        composition_contract={
            "consumes": {
                "kind": "crm_export_inputs",
                "shape": "mapped source row values including company domain plus LinkedIn/email/phone identity; structured source columns use explicit {column, path} mappings to identify the exact field",
            },
            "produces": {
                "kind": "lead_ids",
                "shape": "lead creation/update result with CRM lead ids stored on the source row",
            },
            "common_next_steps": [
                "add_to_sequence using the column key that stores those lead IDs",
            ],
        },
        execution_policy=_ADD_TO_CRM_POLICY,
        readiness_strategy="add_to_crm",
        is_action_provider=True,
    ),
    ResearchTableProviderContract(
        key="sync_to_table",
        display_name="Sync to Table",
        kind="enrichment",
        behavior_class="action_enrichment",
        provider_aliases=("sync_to_table",),
        recipe_type="sync_to_table",
        recipe_payload={
            "key": "sync_to_table",
            "label": "Sync to Table",
            "kind": "enrichment",
            "dataType": "json",
            "origin": "manual",
            "autoRun": "onSourceUpdate",
            "config": {
                "provider": "sync_to_table",
                "destinationTableId": "<DESTINATION_TABLE_ID>",
                "syncBehavior": "link",
                "columnMappings": [
                    {"sourceKey": "company", "destKey": "company"},
                    {"sourceKey": "domain", "destKey": "company_domain"},
                    {"sourceKey": "work_email", "destKey": "work_email"},
                ],
            },
        },
        recipe_notes=(
            "Use destinationTableId + columnMappings for single-destination sync.",
            "Set config.syncBehavior to link or copy; link is the default.",
            "Router mode is supported via config.routes[] (first matching route wins).",
            "List mode uses syncMode=list with listSourceColumnId and listPath (default items).",
            "List mode column mappings use sourceScope and itemKey, NOT sourceKey with scope.",
            'Item mapping shape: {"sourceScope": "item", "itemKey": "field_name", "destKey": "dest_column_key"}.',
            'Row mapping shape: {"sourceScope": "row", "sourceKey": "source_column_key", "destKey": "dest_column_key"}.',
            "Nested API responses are auto-flattened: use dot-path itemKey values like author.name, author.headline.",
            "Mapped destination columns are auto-created on first run when they do not exist yet.",
        ),
        column_type={
            "type": "sync_to_table",
            "kind": "enrichment",
            "runnable": True,
            "description": "Route eligible source rows into another research table as linked or copied rows.",
            "sync_modes": ["row", "list"],
            "sync_behaviors": ["link", "copy"],
            "routing": {
                "route_conditions_scope": "parent_row",
                "first_match_wins": True,
            },
            "list_contract": {
                "default_path": "items",
                "mapping_scopes": ["row", "item"],
                "blank_item_policy": "skip",
            },
            "billing": {
                "billable": False,
                "credits": 0,
                "notes": "Sync to Table writes linked/copied rows and does not consume credits.",
            },
        },
        setup_contract={
            "primary_provider_value": "sync_to_table",
            "legacy_single_destination": {
                "destination_table_id_field": "config.destinationTableId",
                "column_mappings_field": "config.columnMappings",
            },
            "router_mode": {
                "routes_field": "config.routes",
                "first_match_wins": True,
                "condition_scope": "parent_row",
            },
            "sync_mode_field": "config.syncMode",
            "sync_behavior_field": "config.syncBehavior",
            "supported_sync_modes": ["row", "list"],
            "supported_behaviors": ["link", "copy"],
            "default_behavior": "link",
            "list_mode": {
                "list_source_column_field": "config.listSourceColumnId",
                "list_path_field": "config.listPath",
                "default_list_path": "items",
                "canonical_items_root": "items",
                "mapping_scopes": ["row", "item"],
                "blank_item_policy": "skip",
            },
            "destination_schema": {
                "auto_create_missing_columns": True,
                "auto_create_scope": "explicit mapped destKey values",
            },
        },
        composition_contract={
            "consumes": {
                "kind": "row_or_list_output",
                "shape": "parent row values or list entities from items[] / listPath",
            },
            "produces": {
                "kind": "destination_rows",
                "shape": "linked or copied rows in another table",
            },
            "decision_hints": {
                "prefer_when": [
                    "an upstream column returns items[] that should become rows in another table",
                    "routing depends on source-row conditions",
                ],
            },
            "common_next_steps": [
                "run enrichment/action columns on the destination table",
                "use item-scoped mappings when destination columns should come from list items",
            ],
        },
        execution_policy=_SYNC_TO_TABLE_POLICY,
        dispatch_strategy="sync_to_table_prefilter",
        readiness_strategy="sync_to_table",
        is_action_provider=True,
    ),
    ResearchTableProviderContract(
        key="add_to_sequence",
        display_name="Add to Sequence",
        kind="enrichment",
        behavior_class="action_enrichment",
        provider_aliases=("add_to_sequence",),
        recipe_type="add_to_sequence",
        recipe_payload={
            "key": "add_to_sequence",
            "label": "Add to Sequence",
            "kind": "enrichment",
            "dataType": "json",
            "origin": "manual",
            "autoRun": "onSourceUpdate",
            "config": {
                "provider": "add_to_sequence",
                "sequenceId": "<SEQUENCE_ID>",
                "sourceLeadColumn": "add_to_leads",
            },
        },
        recipe_notes=(
            "sourceLeadColumn should reference the source column key that stores lead IDs (for example add_to_leads), not the provider name add_to_crm.",
            "Set sequenceId to the target workflow sequence ID.",
            "The target sequence must already be ACTIVE for real enrollment.",
            "Common tail order after contact rows or lead IDs exist is email_finder -> add_to_crm -> create/activate sequence -> add_to_sequence.",
            "Creating add_to_sequence after lead IDs already exist does not replay those older updates; run add_to_sequence explicitly or rerun the lead-id source column.",
        ),
        column_type={
            "type": "add_to_sequence",
            "kind": "enrichment",
            "runnable": True,
            "description": "Enroll eligible source rows into an existing outbound sequence.",
            "requirements": {
                "config_provider": "add_to_sequence",
                "sequence_id": "required",
                "source_lead_column": "required",
            },
            "billing": {
                "billable": False,
                "credits": 0,
                "notes": "Sequence enrollment does not consume research credits.",
            },
        },
        setup_contract={
            "primary_provider_value": "add_to_sequence",
            "sequence_id_field": "config.sequenceId",
            "source_lead_column_field": "config.sourceLeadColumn",
            "prerequisites": [
                "lead-id source column already populated or rerunnable",
                "target sequence is ACTIVE",
            ],
            "replay_behavior": "source updates only; explicit run required to backfill older lead-id outputs",
        },
        composition_contract={
            "consumes": {
                "kind": "lead_ids",
                "shape": "lead ids stored in sourceLeadColumn on the same row",
            },
            "produces": {
                "kind": "sequence_enrollment",
                "shape": "enrollment result payload and async bulk job",
            },
            "common_next_steps": [
                "audit enrollments/tasks after the bulk job reaches terminal status",
            ],
        },
        execution_policy=_ADD_TO_SEQUENCE_POLICY,
        readiness_strategy="add_to_sequence",
        is_action_provider=True,
    ),
    ResearchTableProviderContract(
        key="http_request",
        display_name="HTTP Request",
        kind="enrichment",
        behavior_class="standard_enrichment",
        provider_aliases=("http_request",),
        recipe_type="http_request",
        recipe_payload={
            "key": "http_request_column",
            "label": "HTTP Request",
            "kind": "enrichment",
            "dataType": "json",
            "origin": "manual",
            "autoRun": "onSourceUpdate",
            "config": {
                "provider": "http_request",
                "method": "GET",
                "url": "",
                "headers": {},
                "body": None,
                "timeoutSeconds": 30,
            },
        },
        recipe_notes=(
            "Use {{column_key}} placeholders in config.url, config.headers, and config.body to reference row values.",
            "Use {{secrets.name}} to resolve a workspace secret by name.",
            "body can be null, a string template, or an object whose values are templated and sent as JSON.",
            "The stored cell value is the parsed response body (JSON or text), not the full transport envelope.",
            "Preview the resolved request with `autotouch columns test-http-request --table-id <TABLE_ID> --data-file column.json` before creating or running the column.",
        ),
        column_type={
            "type": "http_request",
            "kind": "enrichment",
            "runnable": True,
            "description": "Call an external HTTP endpoint per row and store the parsed response body.",
            "requirements": {
                "config_provider": "http_request",
                "url": "required",
                "method": list(_HTTP_REQUEST_METHODS),
            },
            "billing": {
                "billable": False,
                "credits": 0,
                "notes": "HTTP request columns do not consume research credits.",
            },
        },
        setup_contract={
            "mode": "bulk_queue",
            "orchestrator_provider": "http_request",
            "requires_source_data": True,
            "url_field": "config.url",
            "method_field": "config.method",
            "headers_field": "config.headers",
            "body_field": "config.body",
            "timeout_field": "config.timeoutSeconds",
            "supported_methods": list(_HTTP_REQUEST_METHODS),
            "template_syntax": {
                "row_values": "{{column_key}}",
                "org_secrets": "{{secrets.name}}",
            },
            "preview_endpoint": "POST /api/tables/{table_id}/test-http-request",
        },
        composition_contract={
            "consumes": {
                "kind": "row_data",
                "shape": "{{column_key}} templates resolved from row cell values plus optional {{secrets.name}} workspace secrets",
            },
            "produces": {
                "kind": "json",
                "shape": "HTTP response body parsed as JSON (or raw text)",
            },
            "common_next_steps": [
                "sync_to_table with list mode to fan out array responses",
                "LLM column to extract/summarize from the JSON",
            ],
        },
        execution_policy=_HTTP_REQUEST_POLICY,
    ),
)


_AUTOMATION_WORKFLOW_BLUEPRINTS: Dict[str, Dict[str, Any]] = {
    "lead_finder_to_sequence": {
        "goal": "Discover buyer leads from company rows and enroll them into an active sequence.",
        "recommended_when": [
            "the user wants targeted lead discovery at scale",
            "buyer titles/functions are known up front",
        ],
        "decision_hints": {
            "prefer_over": [
                "llm_enrichment + sync_to_table when the main need is explicit lead discovery rather than custom research",
            ],
        },
        "steps": [
            {
                "provider": "lead_finder",
                "purpose": "discover lead candidates from company context",
                "required_contract_fields": ["config.companyDomain", "config.jobTitles"],
                "notes": [
                    "storeAsLeads=true lets downstream steps reference lead ids directly when available.",
                ],
            },
            {
                "provider": "add_to_sequence",
                "purpose": "enroll discovered lead ids into an existing sequence",
                "required_contract_fields": ["config.sequenceId", "config.sourceLeadColumn"],
                "notes": [
                    "The target sequence must already be ACTIVE.",
                ],
            },
        ],
    },
    "single_buyer_agent": {
        "goal": "Find exactly one best-fit buyer per company row with a deterministic flat JSON contract.",
        "recommended_when": [
            "the operator wants one person per row instead of a list",
            "downstream enrichment should start from stable flat projection keys",
        ],
        "decision_hints": {
            "prefer_over": [
                "lead_finder when provider coverage is sparse or the buying owner is ambiguous",
            ],
        },
        "output_contract": {
            "shape": "single_object",
            "keys": [
                "first_name",
                "last_name",
                "title",
                "company_name",
                "company_website",
                "linkedin_url",
            ],
        },
        "steps": [
            {
                "provider": "llm",
                "purpose": "return exactly one buyer per row in a flat JSON object",
                "required_contract_fields": ["config.mode=agent", "config.structuredOutput=true"],
                "notes": [
                    "Do not return arrays/lists for this workflow.",
                ],
            },
            {
                "provider": "json_projections",
                "purpose": "split the flat JSON object into deterministic source columns",
                "required_contract_fields": ["items[].sourceColumnId", "items[].path"],
            },
        ],
    },
    "single_buyer_agent_to_crm": {
        "goal": "Find one buyer per row, enrich channels, then sync the result into Leads CRM.",
        "recommended_when": [
            "the user wants a dependency-ordered buyer-discovery pipeline",
            "the next step after agent research is email/phone enrichment plus CRM sync",
        ],
        "decision_hints": {
            "prefer_over": [
                "manual step-by-step operator assembly when you want a built-in scaffold with placeholders and ordering",
            ],
        },
        "output_contract": {
            "shape": "single_object",
            "keys": [
                "first_name",
                "last_name",
                "title",
                "company_name",
                "company_website",
                "company_domain",
                "linkedin_url",
            ],
        },
        "steps": [
            {
                "provider": "llm",
                "purpose": "return exactly one buyer per row in a flat JSON object",
                "required_contract_fields": ["config.mode=agent", "config.structuredOutput=true"],
            },
            {
                "provider": "json_projections",
                "purpose": "split agent output into deterministic columns before downstream enrichment",
                "required_contract_fields": ["items[].sourceColumnId", "items[].path"],
            },
            {
                "provider": "email_finder",
                "purpose": "enrich projected buyer rows with work email",
            },
            {
                "provider": "phone_finder",
                "purpose": "enrich projected buyer rows with mobile phone",
            },
            {
                "provider": "add_to_crm",
                "purpose": "create or update CRM lead records from the projected buyer fields",
            },
        ],
    },
    "llm_items_to_contacts_then_sequence": {
        "goal": "Research contacts with agent LLM output, fan them out into a contacts table, enrich channels, then export/enroll.",
        "recommended_when": [
            "the agent needs tool-assisted research to find hard-to-find people",
            "the upstream output is a structured list under items[]",
        ],
        "decision_hints": {
            "prefer_over": [
                "lead_finder only when the contact set is custom or discovered through research rather than fixed targeting",
            ],
        },
        "steps": [
            {
                "provider": "llm",
                "purpose": "produce structured JSON output with contacts in items[]",
                "required_contract_fields": ["config.mode=agent"],
            },
            {
                "provider": "sync_to_table",
                "purpose": "fan each items[] entity into rows on a destination contacts table",
                "required_contract_fields": ["config.routes[].syncMode=list", "config.routes[].listSourceColumnId"],
                "notes": [
                    "Explicit mapped destKey columns are auto-created on the destination table if missing.",
                ],
            },
            {
                "provider": "email_finder",
                "purpose": "enrich destination contact rows with email channels",
            },
            {
                "provider": "add_to_crm",
                "purpose": "create or update CRM lead records from the contact table",
            },
            {
                "provider": "add_to_sequence",
                "purpose": "enroll CRM lead ids into an ACTIVE sequence",
                "required_contract_fields": ["config.sequenceId", "config.sourceLeadColumn"],
            },
        ],
    },
}


def iter_provider_contracts() -> Tuple[ResearchTableProviderContract, ...]:
    return _PROVIDER_CONTRACTS


def get_provider_contract(key: str) -> Optional[ResearchTableProviderContract]:
    normalized = _normalize(key)
    for contract in _PROVIDER_CONTRACTS:
        if _normalize(contract.key) == normalized:
            return contract
    return None


def resolve_provider_contract(
    column: Dict[str, Any],
    config: Dict[str, Any],
) -> Optional[ResearchTableProviderContract]:
    kind = _normalize(column.get("kind"))
    origin = _normalize(column.get("origin"))
    provider = _normalize(config.get("provider"))
    for contract in _PROVIDER_CONTRACTS:
        if contract.matches(kind=kind, provider=provider, origin=origin):
            return contract
    return None


def resolve_effective_provider_contract(
    column: Dict[str, Any],
    config: Optional[Dict[str, Any]],
) -> Optional[ResearchTableProviderContract]:
    cfg = config if isinstance(config, dict) else {}
    kind = _effective_kind_for_validation(column if isinstance(column, dict) else {}, cfg)
    if kind == "formatter":
        return get_provider_contract("formatter")
    if _llm_like_column(column if isinstance(column, dict) else {}, cfg):
        return get_provider_contract("llm")
    return resolve_provider_contract(column if isinstance(column, dict) else {}, cfg)


def validate_column_provider_contract(
    column: Dict[str, Any],
    config: Optional[Dict[str, Any]],
) -> Optional[ResearchTableProviderContract]:
    base_column = column if isinstance(column, dict) else {}
    cfg = config if isinstance(config, dict) else {}
    kind = _effective_kind_for_validation(base_column, cfg)
    provider = _text(cfg.get("provider"))
    provider_key = _normalize(provider)

    if kind not in {"formatter", "enrichment"}:
        return None

    contract = resolve_effective_provider_contract(base_column, cfg)
    if contract is None:
        if kind == "formatter":
            raise ProviderContractValidationError(
                "formatter_contract_unresolved",
                "Formatter column payload is missing config.formula.",
                hint="Use `autotouch columns recipe --type formatter`.",
            )

        if provider_key in {"sequenceenroll", "sequenceenrollment"}:
            raise ProviderContractValidationError(
                "unknown_provider",
                "Unknown enrichment provider 'sequence_enroll'.",
                hint="Use provider 'add_to_sequence' and set config.sourceLeadColumn to the lead-id column key.",
            )

        if provider:
            raise ProviderContractValidationError(
                "unknown_provider",
                f"Unknown enrichment provider '{provider}'.",
                hint="Use `autotouch columns recipe --type <recipe>` to start from a valid payload.",
                detail={"provider": provider},
            )

        raise ProviderContractValidationError(
            "provider_contract_unresolved",
            "Could not resolve enrichment provider contract from payload.",
            hint="Set config.provider or start from `autotouch columns recipe --type <recipe>`.",
        )

    if contract.key == "formatter":
        if not _text(cfg.get("formula")):
            raise ProviderContractValidationError(
                "missing_required_field",
                "Formatter columns require config.formula.",
                hint="Use `autotouch columns recipe --type formatter`.",
            )
        return contract

    if contract.key == "add_to_sequence":
        if not _text(cfg.get("sequenceId")):
            raise ProviderContractValidationError(
                "missing_required_field",
                "add_to_sequence requires config.sequenceId.",
                hint="Use `autotouch columns recipe --type add_to_sequence`.",
            )
        if not _text(cfg.get("sourceLeadColumn")):
            raise ProviderContractValidationError(
                "missing_required_field",
                "add_to_sequence requires config.sourceLeadColumn.",
                hint="Set sourceLeadColumn to the column key that stores lead IDs, usually add_to_leads.",
            )
        return contract

    if contract.key == "add_to_crm":
        field_mappings = cfg.get("fieldMappings")
        if not isinstance(field_mappings, dict) or not field_mappings:
            raise ProviderContractValidationError(
                "missing_required_field",
                "add_to_crm requires config.fieldMappings.",
                hint="Use `autotouch columns recipe --type add_to_crm`.",
            )
        return contract

    if contract.key == "sync_to_table":
        routes = cfg.get("routes")
        has_routes = isinstance(routes, list) and len(routes) > 0
        has_single_destination = bool(_text(cfg.get("destinationTableId"))) and isinstance(cfg.get("columnMappings"), list) and len(cfg.get("columnMappings") or []) > 0
        if not has_routes and not has_single_destination:
            raise ProviderContractValidationError(
                "missing_required_field",
                "sync_to_table requires config.routes[] or destinationTableId + columnMappings.",
                hint="Use `autotouch columns recipe --type sync_to_table`.",
            )
        return contract

    if contract.key == "email_finder":
        if not any(
            _mapping_text(cfg.get(field_name))
            for field_name in ("linkedinUrl", "firstName", "lastName", "company", "domain", "emailColumn")
        ):
            raise ProviderContractValidationError(
                "missing_required_field",
                "email_finder requires at least one lookup input such as config.linkedinUrl, config.domain, config.emailColumn, or name/company fields.",
                hint="Use `autotouch columns recipe --type email_finder`.",
            )
        return contract

    if contract.key == "phone_finder":
        if not any(
            _mapping_text(cfg.get(field_name))
            for field_name in (
                "linkedinUrl",
                "email",
                "workEmail",
                "personalEmail",
                "firstName",
                "lastName",
                "company",
                "phoneColumn",
            )
        ):
            raise ProviderContractValidationError(
                "missing_required_field",
                "phone_finder requires at least one lookup input such as config.linkedinUrl, config.email/workEmail, or config.phoneColumn.",
                hint="Use `autotouch columns recipe --type phone_finder`.",
            )
        return contract

    if contract.key == "lead_finder":
        if not _mapping_text(cfg.get("companyDomain")):
            raise ProviderContractValidationError(
                "missing_required_field",
                "lead_finder requires config.companyDomain.",
                hint="Use `autotouch columns recipe --type lead_finder`.",
            )
        return contract

    if contract.key == "http_request":
        if not _text(cfg.get("url")):
            raise ProviderContractValidationError(
                "missing_required_field",
                "http_request requires config.url.",
                hint="Use `autotouch columns recipe --type http_request`.",
            )
        method = _text(cfg.get("method")) or "GET"
        if method.upper() not in _HTTP_REQUEST_METHODS:
            raise ProviderContractValidationError(
                "invalid_field",
                f"http_request config.method must be one of {', '.join(_HTTP_REQUEST_METHODS)}.",
                hint="Use `autotouch columns recipe --type http_request` as the starting payload.",
                detail={"method": method},
            )
        headers = cfg.get("headers")
        if headers is not None and not isinstance(headers, dict):
            raise ProviderContractValidationError(
                "invalid_field",
                "http_request config.headers must be an object when provided.",
                hint="Set config.headers to a JSON object such as {\"Authorization\": \"Bearer {{secrets.openai_api_key}}\"}.",
            )
        body = cfg.get("body")
        if body is not None and not isinstance(body, (str, dict)):
            raise ProviderContractValidationError(
                "invalid_field",
                "http_request config.body must be null, a string, or an object.",
                hint="Use a string body template or a JSON object whose values contain {{column_key}} placeholders.",
            )
        return contract

    return contract


def recipe_types() -> List[str]:
    return [contract.recipe_type for contract in _PROVIDER_CONTRACTS if contract.recipe_type]


def get_recipe_contract(recipe_type: str) -> Optional[ResearchTableProviderContract]:
    normalized = _normalize(recipe_type)
    for contract in _PROVIDER_CONTRACTS:
        if contract.recipe_type and _normalize(contract.recipe_type) == normalized:
            return contract
    return None


def column_type_entries() -> List[Dict[str, Any]]:
    return [
        contract.column_type_copy()
        for contract in _PROVIDER_CONTRACTS
        if isinstance(contract.column_type, dict)
    ]


def automation_provider_entries() -> Dict[str, Dict[str, Any]]:
    return {
        contract.key: contract.capabilities_contract_copy()
        for contract in _PROVIDER_CONTRACTS
    }


def automation_workflow_blueprints() -> Dict[str, Dict[str, Any]]:
    return deepcopy(_AUTOMATION_WORKFLOW_BLUEPRINTS)


def action_provider_keys() -> Tuple[str, ...]:
    return tuple(contract.key for contract in _PROVIDER_CONTRACTS if contract.is_action_provider)


def behavior_class_keys(behavior_class: str) -> Tuple[str, ...]:
    normalized = _normalize(behavior_class)
    return tuple(
        contract.key
        for contract in _PROVIDER_CONTRACTS
        if _normalize(contract.behavior_class) == normalized
    )


def orchestrated_runtime_provider_keys(*, include_formatter: bool = False) -> Tuple[str, ...]:
    providers: List[str] = []
    for contract in _PROVIDER_CONTRACTS:
        policy = contract.execution_policy
        if not policy:
            continue
        normalized_behavior = _normalize(contract.behavior_class)
        if normalized_behavior == "formatter":
            if include_formatter:
                providers.append(policy.orchestrator_provider)
            continue
        if contract.key == "llm":
            providers.extend(["gemini", "xai"])
            continue
        providers.append(policy.orchestrator_provider)
    return tuple(dict.fromkeys(provider for provider in providers if provider))


def resolve_provider_behavior_class(
    column: Dict[str, Any],
    config: Optional[Dict[str, Any]],
) -> Optional[str]:
    contract = resolve_effective_provider_contract(column, config)
    if not contract:
        return None
    return contract.behavior_class


def should_resolve_managed_llm_model(
    column: Dict[str, Any],
    config: Optional[Dict[str, Any]],
) -> bool:
    contract = resolve_effective_provider_contract(column, config)
    if not contract:
        return False
    return contract.key == "llm" and _normalize(column.get("origin")) == "ai"


def resolve_runtime_orchestrator_provider(
    column: Dict[str, Any],
    config: Optional[Dict[str, Any]],
) -> Optional[str]:
    contract = resolve_effective_provider_contract(column, config)
    if not contract or not contract.execution_policy:
        return None
    if _normalize(contract.behavior_class) == "formatter":
        return None
    if contract.key == "llm":
        runtime_provider = _normalize((config or {}).get("provider"))
        if runtime_provider in {"gemini", "xai"}:
            return runtime_provider
        if runtime_provider in {"", "llm"}:
            return contract.execution_policy.orchestrator_provider
        return None
    return contract.execution_policy.orchestrator_provider


def uses_orchestrated_bulk_run(
    column: Dict[str, Any],
    config: Optional[Dict[str, Any]],
) -> bool:
    return resolve_runtime_orchestrator_provider(column, config) is not None
