"""Session types: protocols, enums, and configuration dataclass."""

from __future__ import annotations

import enum
from collections.abc import Callable, Sequence
from dataclasses import dataclass, field
from pathlib import Path
from typing import TYPE_CHECKING, Any, Literal, Protocol, cast, runtime_checkable

from easycat._bounded_queue import BoundedAudioQueue
from easycat.errors import EasyConfigError
from easycat.events import EventBus
from easycat.llm_output_processing import LLMOutputProcessor
from easycat.providers import (
    EchoCanceller,
    NoiseReducer,
    STTProvider,
    Transport,
    TTSProvider,
    VADProvider,
)
from easycat.session.actions import SessionActionExecutor, SessionActions
from easycat.timeouts import TimeoutConfig
from easycat.turn_manager import TurnManager, TurnManagerConfig, TurnManagerState

if TYPE_CHECKING:
    from easycat.runtime.artifacts import ArtifactStore
    from easycat.runtime.journal import ExecutionJournal
    from easycat.telephony.compliance import DNCStore

# ── Agent protocol (lightweight — agent adapters provide real implementations) ──


@runtime_checkable
class Agent(Protocol):
    """Minimal agent interface: receive text, produce text."""

    async def run(self, text: str) -> str: ...


@runtime_checkable
class SessionHelper(Protocol):
    """Lifecycle-managed session helper component."""

    def start(self) -> None: ...

    def stop(self) -> None: ...


# ── Turn state ─────────────────────────────────────────────────────


class TurnState(enum.Enum):
    IDLE = "idle"
    LISTENING = "listening"
    PROCESSING = "processing"
    BOT_SPEAKING = "bot_speaking"


# ── Call identity ──────────────────────────────────────────────────


CallDirection = Literal["inbound", "outbound", "unknown"]
CallerIdExposure = Literal["off", "system_message", "tools_only"]

_CALL_DIRECTIONS = frozenset({"inbound", "outbound", "unknown"})
_CALLER_ID_EXPOSURES = frozenset({"off", "system_message", "tools_only"})
_JOURNAL_DETAILS = frozenset({"off", "light", "full"})
_INTERRUPTION_MODES = frozenset({"truncate", "message"})
_RUNTIME_MODES = frozenset({"chained_pipeline", "text_session"})
_JOURNAL_REDACTIONS = frozenset({"secrets", "pii"})


def _validate_policy(name: str, value: object, allowed: frozenset[str]) -> str:
    """Return a validated string policy or raise with an actionable message."""
    if not isinstance(value, str) or value not in allowed:
        raise ValueError(f"Invalid {name}={value!r}. Must be one of {sorted(allowed)}.")
    return value


def _validate_caller_id_exposure(value: object) -> CallerIdExposure:
    """Validate the privacy boundary shared by config and live Session state."""
    return cast(
        CallerIdExposure,
        _validate_policy("caller_id_exposure", value, _CALLER_ID_EXPOSURES),
    )


@dataclass(frozen=True)
class CallIdentity:
    """Caller / callee metadata for a telephony session.

    Populated from the Twilio ``<Stream>`` ``customParameters`` for
    inbound calls and from :meth:`OutboundCallManager.place_call` for
    outbound calls.  Exposed to the agent and to tools via
    :attr:`Session.call_identity`; visibility to tools and the agent
    prompt is controlled by :attr:`SessionConfig.caller_id_exposure`.

    ``caller_number`` is the far-end (the human), ``called_number`` is
    the near-end (the bot's DID).  ``display_name`` is the optional
    caller-ID name if the carrier provided one.  ``city`` / ``state``
    / ``zip_code`` / ``country`` carry Twilio's geographic metadata
    (``FromCity`` / ``FromState`` / ``FromZip`` / ``FromCountry`` on
    the voice webhook) when the inbound TwiML forwards them.
    ``custom_fields`` carries extra metadata the transport or app
    attached (e.g. CRM account id, SIP headers).
    """

    caller_number: str = ""
    called_number: str = ""
    direction: CallDirection = "unknown"
    display_name: str | None = None
    call_sid: str | None = None
    city: str | None = None
    state: str | None = None
    zip_code: str | None = None
    country: str | None = None
    custom_fields: dict[str, str] = field(default_factory=dict)

    def __post_init__(self) -> None:
        _validate_policy("direction", self.direction, _CALL_DIRECTIONS)


# Mapping from TurnManagerState to the Session-level TurnState.
_TM_TO_TURN_STATE: dict[TurnManagerState, TurnState] = {
    TurnManagerState.IDLE: TurnState.IDLE,
    TurnManagerState.USER_SPEAKING: TurnState.LISTENING,
    TurnManagerState.USER_PAUSED: TurnState.LISTENING,
    TurnManagerState.PROCESSING: TurnState.PROCESSING,
    TurnManagerState.BOT_SPEAKING: TurnState.BOT_SPEAKING,
}


# ── Session configuration ─────────────────────────────────────────


@dataclass
class SessionConfig:
    """Configuration for a Session — the lowest rung of the ladder.

    This is where you supply live provider *instances* (``stt``, ``tts``,
    ``transport``, … already constructed and ready to run). That is a
    different field *type* from :class:`~easycat.EasyConfig`, whose
    matching fields take provider *descriptors* (string shortcuts /
    config objects) that it resolves and wires for you one rung up. Reach
    for ``Session.from_providers(...)`` when you need to hand-build
    providers; use ``SessionConfig`` + :class:`Session` only when you need
    every raw pipeline field. Otherwise ``EasyConfig`` + ``create_session``
    is the path.
    """

    stt: STTProvider | None = None
    tts: TTSProvider | None = None
    vad: VADProvider | None = None
    noise_reducer: NoiseReducer | None = None
    echo_canceller: EchoCanceller | None = None
    transport: Transport | None = None
    agent: Agent | None = None
    event_bus: EventBus | None = None
    turn_manager: TurnManager | None = None
    turn_manager_config: TurnManagerConfig | None = None
    timeout_config: TimeoutConfig | None = None
    journal: ExecutionJournal | None = None
    artifact_store: ArtifactStore | None = None
    # ``light`` omits per-frame Audio/VAD/STT stage spans and artifacts so a
    # bounded journal retains turn/control history. ``full`` keeps the
    # replay-heavy frame detail. Direct SessionConfig users retain the
    # historical full-detail default.
    journal_detail: Literal["off", "light", "full"] = "full"
    warmup: bool = True
    # Auto-export a timestamped debug bundle to this directory during stop().
    # No-op when the session has no debug journal.
    record_to: str | Path | None = None
    session_id: str | None = None
    # Override the played-back (TTS -> transport) audio queue. The default
    # Session queue uses ``DropPolicy.DROP_NEWEST`` (max_size 200) so that,
    # under transport backpressure, only the tail of the bot's utterance is
    # trimmed — never the beginning. Inject a ``BoundedAudioQueue`` with
    # ``DropPolicy.BLOCK`` here when you want real backpressure on the
    # synthesizer instead. ``DropPolicy.DROP_OLDEST`` is unsuitable for
    # played-back speech: it discards the earliest unsent audio, making the
    # listener hear the utterance jump forward mid-sentence.
    outbound_queue: BoundedAudioQueue | None = None
    telephony_helpers: Sequence[SessionHelper] = ()
    audio_gate: Callable[[], bool] | None = None

    # Pipeline flags
    enable_noise_reduction: bool = False
    enable_echo_cancellation: bool = False
    enable_vad: bool = True
    auto_turn_from_stt_final: bool = False
    # Strictly opt-in: journal the bot's far-end AEC reference frame (one
    # artifact + journal row per captured frame) when AEC is enabled, so the
    # debugger can align all three AEC tracks. Off by default because it adds
    # ~50 writes/sec/session of fsync + journal pressure to the live audio
    # loop; the router rate-limits captures to roughly one frame per second.
    # The ``EASYCAT_CAPTURE_AEC_REFERENCE`` env var overrides this at runtime.
    capture_aec_reference: bool = False
    strip_markdown: bool = False
    output_processors: Sequence[LLMOutputProcessor] = ()
    on_agent_failure: str | Callable[[Exception], str] | None = None

    # Interruption behaviour.
    # "truncate" (default): truncate the assistant message to what was
    #   actually spoken and append "..." — compatible with all models.
    # "message": append an explicit system/developer message noting the
    #   interruption — clearer intent but requires model support for
    #   interleaved system messages.
    interruption_mode: Literal["truncate", "message"] = "truncate"
    # Latency budget used when estimating the interruption point.  This can
    # account for transport/network + receiver playback + VAD/ASR detection
    # lag so we don't overestimate what the user actually heard.
    interruption_latency_compensation_ms: int = 0
    # If the newest playback ack before cutoff is older than this threshold,
    # treat acks as stale and allow a bounded heuristic tail.
    interruption_ack_stale_ms: int = 500
    # Maximum extra playout budget (beyond acked bytes) to allow via timing
    # heuristic when playback acks are stale.
    interruption_ack_tail_cap_ms: int = 500

    # Agent-initiated session actions.
    session_actions: SessionActions | None = None
    action_executors: Sequence[SessionActionExecutor] = ()

    # Runtime mode for the session.
    runtime_mode: Literal["chained_pipeline", "text_session"] = "chained_pipeline"
    # Text-mode context to pass through to stages (optional, set by create_text_session).
    text_mode_context: dict[str, Any] | None = None

    # MCP servers surfaced to the agent stage recorder so bridges can
    # read them from ``RecorderContext.mcp_servers``.
    mcp_servers: tuple[str, ...] = ()

    # Do-Not-Call list consulted by outbound telephony pre-dial checks.
    # When set, :class:`OutboundCallManager` blocks ``place_call``
    # attempts to numbers already on the list.
    dnc_list: DNCStore | None = None

    # Greeting synthesized automatically on the first
    # :class:`~easycat.events.CallAnswered` so the bot speaks first —
    # the canonical pattern for outbound calls and a common ask for
    # inbound.  ``None`` (default) leaves the old behaviour (the agent
    # speaks only after the first user utterance).  The greeting text
    # flows through the configured TTS provider; callers that want
    # per-call templating (e.g. ``"Hi {caller_name}"``) can pass an
    # f-string formatted against ``session.call_identity``.
    greeting: str | None = None

    # Telephony caller / callee identity and exposure policy.
    #
    # ``call_identity`` carries the inbound caller's phone number
    # (and/or the outbound callee's) plus direction + optional carrier
    # metadata.  Transports populate it: ``TwilioTransport`` reads
    # ``start.customParameters`` on the ``<Stream>`` event,
    # ``OutboundCallManager.place_call`` sets direction="outbound"
    # with the dialed number.
    #
    # ``caller_id_exposure`` controls who sees it:
    #   - "system_message" injects a short system note into the agent's
    #     per-turn context ("The caller's phone number is +1…"). Good
    #     when the agent must reason about who's on the line (e.g.
    #     name checks, greetings by first name).
    #   - "tools_only" (default) keeps the identity off the LLM prompt
    #     but available to tool code via ``session.call_identity``.
    #     Right for PII-sensitive workflows.
    #   - "off" hides it from both layers.
    #     Internal telephony policy hooks still retain the private
    #     identity for DNC handling.
    call_identity: CallIdentity | None = None
    caller_id_exposure: CallerIdExposure = "tools_only"

    # Keep appended for positional compatibility with older SessionConfig calls.
    capture_audio: bool | Callable[[], bool] = True
    journal_redaction: Literal["secrets", "pii"] = "secrets"

    def __post_init__(self) -> None:
        """Reject invalid runtime policies before any Session resources are wired."""
        _validate_policy("journal_detail", self.journal_detail, _JOURNAL_DETAILS)
        _validate_policy("interruption_mode", self.interruption_mode, _INTERRUPTION_MODES)
        _validate_policy("runtime_mode", self.runtime_mode, _RUNTIME_MODES)
        _validate_caller_id_exposure(self.caller_id_exposure)
        _validate_policy("journal_redaction", self.journal_redaction, _JOURNAL_REDACTIONS)
        self._validate_required_collaborators()

    def _validate_required_collaborators(self) -> None:
        """Reject incomplete raw session wiring at the config boundary."""
        missing: list[str] = []
        if self.runtime_mode == "chained_pipeline":
            if self.agent is None:
                missing.append("agent")
            for name in ("stt", "tts", "transport"):
                if getattr(self, name) is None:
                    missing.append(name)
            if self.enable_vad and self.vad is None:
                missing.append("vad")
        if missing:
            remedy = (
                "Use EasyConfig + create_session/run for automatic provider wiring, "
                "or pass live provider instances to SessionConfig."
            )
            raise EasyConfigError(
                f"SessionConfig(runtime_mode={self.runtime_mode!r}) requires: "
                f"{', '.join(missing)}. {remedy}"
            )
