"""Modality registry — single dispatch surface for all generation modalities.

Introduced in CP-4. Locked design (per SYNTHESIS § Locked Decision #1):
no closed enum, no class hierarchy, no clever indirection. The registry
takes modality strings and maps to runner instances.

Adding a new modality is a one-line edit:

    from recoil.pipeline.core.registry import register_runner
    register_runner("my_new_modality", MyNewRunner())

Public API:
    - ModalityRunner Protocol (the contract every runner satisfies)
    - RunResult dataclass (the typed output of every run() call)
    - RunnerRegistry — instance class (Phase D MF-11; canonical surface)
    - register_runner(modality_id, runner) — register an instance
    - register_runner_factory(modality_id, factory) — register a lazy factory
    - get_runner(modality_id) — resolve a registered modality, raises KeyError if missing
    - list_modalities() — list all registered modality strings
    - is_registered(modality_id) — bool, no exception
    - MODALITY_* constants — canonical strings

CP-4 registers four modalities (two LIVE, two STUB):
    - MODALITY_IMAGE_T2I    → ImageRunner            (LIVE)
    - MODALITY_VIDEO_I2V    → VideoRunner            (LIVE)
    - MODALITY_AUDIO_T2A    → AudioRunner            (STUB — raises NotImplementedError)
    - MODALITY_LIPSYNC_POST → LipSyncPostProcessor   (STUB — raises NotImplementedError)

The `pipeline.core.runners` package's __init__ imports each runner module
for its side effect of registering with this registry.

**Import-direction rule (no circular imports):**
This module MUST NOT import from `pipeline.core.runners` (or any submodule
thereof). The dependency arrow is strictly `runners → registry`. Adding
`from pipeline.core.runners import ...` here will create a circular import
and crash the package at load time.
"""

from __future__ import annotations

from dataclasses import dataclass, field
from typing import Any, Callable, Optional, Protocol, runtime_checkable


# ── Canonical modality strings (locked at CP-4; CP-9 adds three eval modalities) ──
MODALITY_IMAGE_T2I = "image_t2i"
MODALITY_VIDEO_I2V = "video_i2v"
MODALITY_AUDIO_T2A = "audio_t2a"
MODALITY_LIPSYNC_POST = "lipsync_post"
MODALITY_R2V_MULTI = "r2v_multi"
MODALITY_STORYBOARD = "storyboard"
# CP-9 eval modalities (added 2026-04-28, Phase 3):
MODALITY_EVAL_IMAGE_V1 = "eval_image_v1"
MODALITY_EVAL_VIDEO_V1 = "eval_video_v1"
MODALITY_EVAL_AUDIO_V1 = "eval_audio_v1"

# Future CP-N+ modality strings (NOT registered in CP-9):
#   "video_r2v", "video_pass", "image_previz",
#   "storyboard" is live as of REC-124 Phase 1.
#   "eval_continuity_v1" (cross-take continuity, deferred per Locked Decision #10),
#   "eval_lipsync_v1"   (lipsync-quality critic, deferred per CP-8 hand-off followup #10)


# ── RunResult ────────────────────────────────────────────────────────
@dataclass
class RunResult:
    """Typed result returned by every ModalityRunner.run() call.

    Locked schema (CP-4). CP-6 will bind this to a WorkflowStep; CP-7
    will associate it with a Take. Adding fields post-CP-4 is safe;
    renaming or removing existing fields is a contract break.

    Fields:
        id: Stable result id (e.g. f"{shot_id}_{modality}_{ts}").
        modality: Canonical modality string the runner handled.
        output_path: Local filesystem path to the produced artifact (mp4,
            png, wav). None for failures or stubs.
        output_url: Remote URL to the produced artifact (fal CDN).
            None when not applicable.
        metadata: Opaque dict — duration, cost, model, prompt_hash, gate
            verdicts, take_index, sidecar_path, anything else the runner
            wants to surface. Keys are not enforced at the registry layer.
        success: True iff the run completed without error AND any gates
            attached to the payload passed.
        error: Error message string on failure, else None.
    """

    id: str
    modality: str
    output_path: Optional[str] = None
    output_url: Optional[str] = None
    metadata: dict[str, Any] = field(default_factory=dict)
    success: bool = False
    error: Optional[str] = None


# Drift-guard documentation: the 6-key success-path metadata shape produced
# by _step_result_to_run_result() in image_runner.py and video_runner.py
# (video_runner imports the function from image_runner — single source).
# Also consumed by name in client_sequence_runner._run_result_as_step_result().
# NOT enforced at runtime; this constant exists so a future linting pass can
# verify the runners' dict matches this list, and so a maintainer adding a
# new field knows to update this constant in lockstep with the three call
# sites. Failure-path metadata (built by the _failure_metadata_* helpers in
# pipeline/core/runners/_shared.py) has its own per-modality shapes and is
# NOT covered by this constant.
RUN_RESULT_METADATA_KEYS: tuple[str, ...] = (
    "final_state",
    "cost_usd",
    "gate_verdict",
    "take_index",
    "model",
    "pipeline",
)


# ── ModalityRunner Protocol ──────────────────────────────────────────
@runtime_checkable
class ModalityRunner(Protocol):
    """Contract every modality runner must satisfy.

    Runners are stateless after construction. Construction-time deps
    (ExecutionStore, ProjectPaths, cost logger) are typically injected
    by a factory registered via `register_runner_factory(...)`.

    The single method `run(payload: dict) -> RunResult` is synchronous,
    blocking, and idempotent w.r.t. payload. Payload schema is per-runner
    and documented on the concrete runner class. The registry does NOT
    validate payload contents.
    """

    modality: str  # the canonical modality string this runner handles

    def run(self, payload: dict) -> RunResult: ...


# ── RunnerRegistry ───────────────────────────────────────────────────
class RunnerRegistry:
    """Modality runner registry — instance-attached state.

    Phase D MF-11: previously module-level dicts (_RUNNERS, _FACTORIES) with
    implicit invalidation contract. Now an instance — supports hot-reload
    by constructing a fresh registry and swapping `_default_registry`.

    Thread-safety: NOT thread-safe. Callers serialize via process-level
    bootstrap (`pipeline.core.dispatch.register_default_runners` is the
    canonical entry).
    """

    def __init__(self) -> None:
        self._runners: dict[str, ModalityRunner] = {}
        self._factories: dict[str, Callable[..., ModalityRunner]] = {}

    def register(
        self,
        modality_id: str,
        runner: ModalityRunner,
        *,
        force: bool = False,
    ) -> None:
        """Register a runner instance for a modality.

        Args:
            modality_id: canonical modality string.
            runner: object satisfying the ModalityRunner protocol.
            force: if True, overwrite any existing registration without raising.
                Default False — re-registration of a different instance raises
                KeyError. Idempotent re-registration of the SAME instance is
                always allowed regardless of `force`.

        Raises:
            ValueError: if `modality_id` is empty.
            TypeError: if `runner` has no `run()` method.
            KeyError: if `modality_id` is already registered to a different
                instance and `force=False`.
        """
        if not isinstance(modality_id, str) or not modality_id:
            raise ValueError(f"modality_id must be a non-empty string, got {modality_id!r}")
        if not hasattr(runner, "run"):
            raise TypeError(
                f"Runner for {modality_id!r} does not satisfy the ModalityRunner "
                f"protocol (missing run() method): {runner!r}"
            )
        existing = self._runners.get(modality_id)
        if existing is not None and existing is not runner and not force:
            raise KeyError(
                f"Modality {modality_id!r} is already registered to "
                f"{type(existing).__name__}. Cannot re-register to "
                f"{type(runner).__name__}. Pass force=True to overwrite "
                f"(reserved for tests / CP-6+ rebinding)."
            )
        self._runners[modality_id] = runner

    def register_factory(
        self,
        modality_id: str,
        factory: Callable[..., ModalityRunner],
    ) -> None:
        """Register a lazy factory; runner is constructed on first `get` call.

        Useful when runner construction is expensive or has side effects
        that should defer until the modality is actually used.
        """
        if not isinstance(modality_id, str) or not modality_id:
            raise ValueError(f"modality_id must be a non-empty string, got {modality_id!r}")
        if not callable(factory):
            raise TypeError(
                f"factory for {modality_id!r} must be callable, got {factory!r}"
            )
        if modality_id in self._runners or modality_id in self._factories:
            raise KeyError(
                f"Modality {modality_id!r} is already registered. Cannot register factory."
            )
        self._factories[modality_id] = factory

    def get(self, modality_id: str) -> ModalityRunner:
        """Resolve a registered modality. Raises KeyError if missing.

        If a factory was registered for this modality and no instance exists,
        the factory is invoked once and the result cached.

        Args:
            modality_id: Canonical modality string (e.g. "image_t2i").

        Returns:
            The runner instance.

        Raises:
            KeyError: if no runner is registered for `modality_id`. Error
                message lists the currently-registered modalities.
        """
        if modality_id in self._runners:
            return self._runners[modality_id]
        if modality_id in self._factories:
            runner = self._factories[modality_id]()
            if not hasattr(runner, "run"):
                raise TypeError(
                    f"Factory for {modality_id!r} returned an object without "
                    f"run() method: {runner!r}"
                )
            self._runners[modality_id] = runner
            return runner
        registered = sorted(set(self._runners.keys()) | set(self._factories.keys()))
        hint = ""
        if modality_id in (MODALITY_IMAGE_T2I, MODALITY_VIDEO_I2V):
            hint = (
                " These modalities require bootstrap — call "
                "`pipeline.core.dispatch.register_default_runners(step_runner)` "
                "before get_runner()."
            )
        raise KeyError(
            f"No runner registered for modality {modality_id!r}. "
            f"Registered modalities: {registered}.{hint} "
            f"Add via pipeline.core.registry.register_runner({modality_id!r}, MyRunner())."
        )

    def list(self) -> list[str]:
        """Return all registered modality strings (instances + factories), sorted."""
        return sorted(set(self._runners.keys()) | set(self._factories.keys()))

    def is_registered(self, modality_id: str) -> bool:
        """True iff a runner or factory is registered for `modality_id`."""
        return modality_id in self._runners or modality_id in self._factories

    def reset(self) -> None:
        """Test-only — clears the registry."""
        self._runners.clear()
        self._factories.clear()


# Process-singleton — the canonical registry instance for this process.
# Hot-reload tooling can construct a fresh RunnerRegistry() and reassign.
_default_registry = RunnerRegistry()


# ── Module-level free functions — thin wrappers, preserved API ───────
def register_runner(
    modality_id: str,
    runner: ModalityRunner,
    *,
    force: bool = False,
) -> None:
    """Process-singleton convenience wrapper. Tests/hot-reload should
    construct their own RunnerRegistry() and call .register() directly."""
    _default_registry.register(modality_id, runner, force=force)


def register_runner_factory(
    modality_id: str,
    factory: Callable[..., ModalityRunner],
) -> None:
    _default_registry.register_factory(modality_id, factory)


def get_runner(modality_id: str) -> ModalityRunner:
    return _default_registry.get(modality_id)


def list_modalities() -> list[str]:
    return _default_registry.list()


def is_registered(modality_id: str) -> bool:
    return _default_registry.is_registered(modality_id)


def _reset_for_tests() -> None:
    """Test-only helper. Clears the registry. Do NOT call from production code."""
    _default_registry.reset()


__all__ = [
    "RunResult",
    "ModalityRunner",
    "RunnerRegistry",
    "register_runner",
    "register_runner_factory",
    "get_runner",
    "list_modalities",
    "is_registered",
    "MODALITY_IMAGE_T2I",
    "MODALITY_VIDEO_I2V",
    "MODALITY_AUDIO_T2A",
    "MODALITY_LIPSYNC_POST",
    "MODALITY_R2V_MULTI",
    "MODALITY_STORYBOARD",
    "MODALITY_EVAL_IMAGE_V1",
    "MODALITY_EVAL_VIDEO_V1",
    "MODALITY_EVAL_AUDIO_V1",
    "RUN_RESULT_METADATA_KEYS",
]
