import logging
import re
import asyncio
from collections.abc import Awaitable, Callable
from typing import Any

from ari.client import AriClient
from ari.debug_log import log_ari_event, summarize_channel
from calls.models import CallDirection, CallState
from calls.registry import CallRegistry
from config import Settings, get_settings
from media.manager import MediaManager

logger = logging.getLogger(__name__)

PROCESSED_CHANNELS: set[str] = set()

_STASIS_KEYWORDS = frozenset({"inbound", "outbound", "customer", "agent", "media", "h"})
_UUID_RE = re.compile(
    r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$",
    re.IGNORECASE,
)

CallResolver = Callable[[str | None, dict[str, Any]], CallState | None]
BotReadyFn = Callable[[CallState], Awaitable[None]]


class CallStateMachine:
    def __init__(
        self,
        ari: AriClient,
        registry: CallRegistry,
        settings: Settings | None = None,
        media_manager: MediaManager | None = None,
        call_resolver: CallResolver | None = None,
        get_agent_endpoint: Callable[[], str | None] | None = None,
        get_registered_extension: Callable[[], str | None] | None = None,
        on_bot_ready: BotReadyFn | None = None,
    ) -> None:
        self.ari = ari
        self.registry = registry
        self.settings = settings or get_settings()
        self.media_manager = media_manager
        self._call_resolver = call_resolver
        self._get_agent_endpoint = get_agent_endpoint
        self._get_registered_extension = get_registered_extension
        self._on_bot_ready = on_bot_ready

    async def handle_event(self, event: dict[str, Any]) -> CallState | None:
        event_type = event.get("type")
        handlers = {
            "StasisStart": self._on_stasis_start,
            "StasisEnd": self._on_stasis_end,
            "ChannelStateChange": self._on_channel_state_change,
            "ChannelDestroyed": self._on_channel_destroyed,
            "PlaybackFinished": self._on_playback_finished,
        }
        handler = handlers.get(event_type)
        if handler:
            return await handler(event)
        return None

    @staticmethod
    def _normalize_arg(raw: Any) -> str:
        """Quita espacios y comillas del dialplan (ej. '21601605' → 21601605)."""
        return str(raw).strip().strip("'\"")

    @staticmethod
    def _looks_like_call_id(value: str) -> bool:
        """UUID de la API; nunca un DNI u otra cifra pura."""
        return bool(_UUID_RE.match(value))

    def _parse_stasis_args(
        self,
        args: list[Any],
        *,
        dialplan_exten: str | None = None,
    ) -> tuple[CallDirection, str | None, str | None, str | None]:
        """
        Devuelve (direction, call_id, dialed_ext, document_id).

        Dialplan bot típico: Stasis(App,${EXTEN},${DOCUMENTO})
        API saliente: Stasis args [uuid, customer]
        """
        direction: CallDirection = "inbound"
        call_id_arg: str | None = None
        numerics: list[str] = []

        for raw in args:
            value = self._normalize_arg(raw)
            if not value:
                continue
            if value in ("outbound", "customer"):
                direction = "outbound"
            elif value == "inbound":
                direction = "inbound"
            elif value in _STASIS_KEYWORDS:
                continue
            elif value.isdigit():
                numerics.append(value)
            elif self._looks_like_call_id(value):
                call_id_arg = value
            else:
                # Texto no-UUID (poco habitual); no usar como call_id
                logger.debug("Arg Stasis ignorado como call_id: %r", value)

        dialed_ext: str | None = None
        document_id: str | None = None
        dp = self._normalize_arg(dialplan_exten) if dialplan_exten else ""
        if dp.isdigit() and dp in numerics:
            dialed_ext = dp
            rest = [n for n in numerics if n != dp]
            document_id = rest[0] if rest else None
        elif len(numerics) >= 2:
            dialed_ext, document_id = numerics[0], numerics[1]
        elif len(numerics) == 1:
            if dp.isdigit() and numerics[0] == dp:
                dialed_ext = numerics[0]
            elif dp.isdigit():
                dialed_ext = dp
                document_id = numerics[0]
            else:
                dialed_ext = numerics[0]

        return direction, call_id_arg, dialed_ext, document_id

    def _caller_number(self, channel: dict[str, Any]) -> str:
        connected = channel.get("connected") or {}
        caller = channel.get("caller") or {}
        number = connected.get("number") or caller.get("number")
        if number:
            return str(number)
        name = connected.get("name") or caller.get("name") or ""
        if "/" in name:
            return name.rsplit("/", 1)[-1]
        return name or "Desconocido"

    def _dialplan_exten(self, event: dict[str, Any]) -> str | None:
        channel = event.get("channel") or {}
        exten = (channel.get("dialplan") or {}).get("exten")
        return str(exten) if exten is not None else None

    def _extract_dialed_extension(self, event: dict[str, Any]) -> str | None:
        args = event.get("args") or []
        _, _, dialed_ext, _ = self._parse_stasis_args(
            list(args), dialplan_exten=self._dialplan_exten(event)
        )
        if dialed_ext:
            return dialed_ext
        exten = self._dialplan_exten(event)
        if exten and exten.isdigit():
            return exten
        return None

    def _inbound_allowed(self, event: dict[str, Any]) -> bool:
        # Flujo bot con documento: no exige extensión registrada en el frontend
        args = event.get("args") or []
        _, _, _, document_id = self._parse_stasis_args(
            list(args), dialplan_exten=self._dialplan_exten(event)
        )
        if document_id:
            return True
        if not self._get_registered_extension:
            return True
        registered = self._get_registered_extension()
        if not registered:
            return False
        if "inbound" in args and not any(
            self._normalize_arg(a).isdigit() for a in args
        ):
            return True
        dialed = self._extract_dialed_extension(event)
        if dialed and dialed != registered:
            return False
        return True

    async def answer_inbound_call(self, call: CallState) -> None:
        if call.direction != "inbound":
            raise ValueError("No es una llamada entrante")
        if call.status not in ("ringing",):
            raise ValueError("La llamada no está sonando")
        channel_id = call.channel_ids[0] if call.channel_ids else None
        if not channel_id:
            raise ValueError("Canal entrante no disponible")

        await self._ensure_bridge(call)
        await self._add_to_call_bridge(call, channel_id)
        await self.ari.answer(channel_id)
        call.status = "answered"
        await self._attach_webrtc_or_agent(call)

    async def _confirm_document_playback(
        self, call: CallState, channel_id: str
    ) -> None:
        """Contesta, reproduce el documento y prepara RTP del bot conversacional."""
        if call.bot_started or call.bot_media_ready:
            await self._maybe_start_bot(call)
            return

        try:
            await self.ari.answer(channel_id)
        except Exception as exc:
            logger.error(
                "No se pudo contestar canal %s (call=%s): %s",
                channel_id,
                call.call_id,
                exc,
            )
            call.status = "failed"
            return

        call.status = "answered"
        logger.info(
            "Bot auto-answer call=%s canal=%s documento=%s",
            call.call_id,
            channel_id,
            call.document_id,
        )

        try:
            await self._ensure_bridge(call)
            await self._add_to_call_bridge(call, channel_id)
        except Exception as exc:
            logger.error(
                "No se pudo armar puente bot call=%s: %s",
                call.call_id,
                exc,
            )
            call.status = "failed"
            return

        # No marcar document_playback_done hasta PlaybackFinished:
        # si el bot arranca durante el readout, el VAD captura los dígitos
        # y dispara STT→LLM en bucle.
        '''
        if call.document_id:
            try:
                playback = await self.ari.play_digits(channel_id, call.document_id)
                call.document_playback_id = playback.get("id")
                call.document_playback_done = False
                logger.info(
                    "Reproduciendo documento %s en canal %s (playback=%s)",
                    call.document_id,
                    channel_id,
                    call.document_playback_id,
                )
                # Fallback si PlaybackFinished no llega (WS ARI caído).
                asyncio.create_task(
                    self._document_playback_timeout(call, timeout=12.0),
                    name=f"doc-playback-{call.call_id[:8]}",
                )
            except Exception as exc:
                logger.error(
                    "Fallo al reproducir documento %s: %s",
                    call.document_id,
                    exc,
                )
                call.document_playback_done = True
        else:
            call.document_playback_done = True
        '''
        call.document_playback_done = True
        
        if self.settings.bot_enabled and self.media_manager:
            try:
                await self.media_manager.attach_bot_external_media(
                    call, self.ari, self.registry
                )
                call.bot_media_ready = True
                logger.info("Media RTP del bot lista call=%s", call.call_id)
            except Exception as exc:
                logger.error(
                    "No se pudo adjuntar media del bot call=%s: %s",
                    call.call_id,
                    exc,
                )
                return

        await self._maybe_start_bot(call)

    async def _document_playback_timeout(
        self, call: CallState, timeout: float
    ) -> None:
        """Si no llega PlaybackFinished, arranca el bot igual tras el timeout."""
        await asyncio.sleep(timeout)
        if call.status in ("ended", "failed") or call.document_playback_done:
            return
        call.document_playback_done = True
        logger.warning(
            "Timeout readout documento call=%s — arrancando bot",
            call.call_id,
        )
        await self._maybe_start_bot(call)

    async def _maybe_start_bot(self, call: CallState) -> None:
        """Arranca la conversación cuando hay media y terminó el readout del DNI."""
        if not self.settings.bot_enabled or call.bot_started:
            return
        if call.status in ("ended", "failed"):
            return
        if not call.bot_media_ready or not call.document_playback_done:
            return
        if not self._on_bot_ready:
            logger.warning(
                "Bot listo call=%s pero sin callback on_bot_ready",
                call.call_id,
            )
            return
        call.bot_started = True
        call.agent_state = "starting"
        try:
            await self._on_bot_ready(call)
        except Exception as exc:
            call.bot_started = False
            call.agent_state = "error"
            logger.error(
                "Fallo al iniciar bot conversacional call=%s: %s",
                call.call_id,
                exc,
            )

    async def _on_playback_finished(self, event: dict[str, Any]) -> CallState | None:
        playback = event.get("playback") or {}
        playback_id = playback.get("id")
        if not playback_id:
            return None

        call: CallState | None = None
        for candidate in self.registry.list_all():
            if candidate.document_playback_id == playback_id:
                call = candidate
                break
        if not call:
            return None

        call.document_playback_done = True
        logger.info(
            "Playback documento finalizado call=%s playback=%s",
            call.call_id,
            playback_id,
        )
        await self._maybe_start_bot(call)
        return call

    async def _on_stasis_start(self, event: dict[str, Any]) -> CallState | None:
        channel = event.get("channel", {})
        channel_id = channel.get("id")
        logger.info("Evento StasisStart: %s", event)
        if not channel_id:
            return None

        args = event.get("args") or []
        direction, call_id_arg, _dialed_ext, document_id = self._parse_stasis_args(
            list(args), dialplan_exten=self._dialplan_exten(event)
        )
        role = self._stasis_role(event)

        if channel_id in PROCESSED_CHANNELS:
            existing = self._resolve_existing_call(channel_id, event)
            if existing and role == "customer" and not existing.external_media_attached:
                # Flujo bot: no reintentar WebRTC
                if existing.document_id:
                    return existing
                logger.info(
                    "Reintento setup media call=%s channel=%s",
                    existing.call_id,
                    channel_id,
                )
                await self._setup_call_media(existing, channel_id, event)
                return existing
            if existing and role == "media" and not existing.external_media_attached:
                await self._setup_call_media(existing, channel_id, event)
                return existing
            logger.debug("Canal ya procesado: %s", channel_id)
            return existing

        PROCESSED_CHANNELS.add(channel_id)

        existing = self._resolve_existing_call(channel_id, event)
        if not existing and call_id_arg and self._looks_like_call_id(call_id_arg):
            existing = self._recover_call(call_id_arg, channel_id, event)

        if existing:
            if document_id and not existing.document_id:
                existing.document_id = document_id
            self.registry.link_channel(existing, channel_id)
            if existing.document_id and role == "customer":
                await self._confirm_document_playback(existing, channel_id)
                return existing
            if existing.direction == "outbound" or role in ("customer", "agent", "media"):
                logger.info(
                    "Setup media call=%s channel=%s role=%s",
                    existing.call_id,
                    channel_id,
                    role,
                )
                await self._setup_call_media(existing, channel_id, event)
            return existing

        logger.info(
            "StasisStart nueva llamada (channel=%s args=%s doc=%s)",
            channel_id,
            args,
            document_id,
        )
        caller_number = self._caller_number(channel)

        # Bot con documento: contestar siempre (sin filtro de extensión UI)
        if document_id:
            call = CallState(
                channel_ids=[channel_id],
                direction="inbound",
                status="ringing",
                number=caller_number,
                document_id=document_id,
            )
            self.registry.add(call)
            await self._confirm_document_playback(call, channel_id)
            return call

        if direction == "inbound":
            if not self._inbound_allowed(event):
                logger.info(
                    "Llamada entrante rechazada (channel=%s args=%s)",
                    channel_id,
                    args,
                )
                try:
                    await self.ari.hangup(channel_id)
                except Exception as exc:
                    logger.warning("No se pudo colgar inbound rechazada: %s", exc)
                PROCESSED_CHANNELS.discard(channel_id)
                return None

        call_fields: dict[str, Any] = {
            "channel_ids": [channel_id],
            "direction": direction,
            "status": "ringing",
            "number": (
                caller_number
                if direction == "inbound"
                else (caller_number or channel.get("dialplan", {}).get("exten"))
            ),
        }
        if call_id_arg:
            call_fields["call_id"] = call_id_arg
        call = CallState(**call_fields)
        self.registry.add(call)

        if direction == "inbound":
            logger.info(
                "Llamada entrante para ext %s desde %s (call_id=%s)",
                self._extract_dialed_extension(event) or "?",
                call.number,
                call.call_id,
            )
            return call
        elif direction == "outbound" or role == "customer":
            await self._setup_call_media(call, channel_id, event)

        return call

    def _recover_call(
        self, call_id: str, channel_id: str, event: dict[str, Any]
    ) -> CallState:
        """Re-vincula Stasis con la llamada originada (p. ej. tras reload del proceso)."""
        args = event.get("args") or []
        direction: CallDirection = (
            "outbound" if ("outbound" in args or "customer" in args) else "inbound"
        )
        channel = event.get("channel", {})
        caller = channel.get("caller", {})
        number = caller.get("number") or channel.get("dialplan", {}).get("exten")
        call = CallState(
            call_id=call_id,
            channel_ids=[channel_id],
            direction=direction,
            status="ringing",
            number=number,
        )
        self.registry.add(call)
        logger.warning(
            "Llamada %s recuperada desde StasisStart (no estaba en registry)",
            call_id,
        )
        return call

    def _resolve_existing_call(
        self, channel_id: str, event: dict[str, Any]
    ) -> CallState | None:
        if self._call_resolver:
            found = self._call_resolver(channel_id, event)
            if found:
                return found

        by_channel = self.registry.get_by_channel(channel_id)
        if by_channel:
            return by_channel

        args = event.get("args") or []
        for raw in args:
            value = self._normalize_arg(raw)
            if value and self._looks_like_call_id(value):
                found = self.registry.get(value)
                if found:
                    return found
        return None

    def _stasis_role(self, event: dict[str, Any]) -> str:
        args = event.get("args") or []
        if "media" in args:
            return "media"
        if "agent" in args:
            return "agent"
        if "customer" in args or "outbound" in args:
            return "customer"
        channel_name = (event.get("channel") or {}).get("name") or ""
        if channel_name.startswith("UnicastRTP/"):
            return "media"
        return "customer"

    async def _ensure_bridge(self, call: CallState) -> str:
        if call.bridge_id:
            return call.bridge_id
        bridge = await self.ari.create_bridge()
        call.bridge_id = bridge["id"]
        logger.info("Puente %s creado para llamada %s", bridge["id"], call.call_id)
        return call.bridge_id

    async def _add_to_call_bridge(self, call: CallState, channel_id: str) -> None:
        bridge_id = await self._ensure_bridge(call)
        await self.ari.add_to_bridge(bridge_id, channel_id)
        logger.info(
            "Canal %s añadido al puente %s (llamada %s, %d canales)",
            channel_id,
            bridge_id,
            call.call_id,
            len(call.channel_ids),
        )

    def _resolve_agent_endpoint(self) -> str | None:
        if self._get_agent_endpoint:
            endpoint = self._get_agent_endpoint()
            if endpoint:
                return endpoint
        return self.settings.agent_endpoint or None

    async def _originate_agent_leg(self, call: CallState) -> None:
        agent_endpoint = self._resolve_agent_endpoint()
        if not agent_endpoint or call.agent_leg_originated:
            return
        call.agent_leg_originated = True
        try:
            channel = await self.ari.originate_channel(
                agent_endpoint,
                caller_id=self.settings.outbound_caller_id,
                use_stasis=True,
                app_args=[call.call_id, "agent"],
            )
            self.registry.link_channel(call, channel["id"])
            logger.info(
                "Pata agente originada: %s → %s (llamada %s)",
                channel["id"],
                agent_endpoint,
                call.call_id,
            )
        except Exception as exc:
            call.agent_leg_originated = False
            logger.error(
                "No se pudo originar pata agente para %s: %s",
                call.call_id,
                exc,
            )

    async def _attach_webrtc_or_agent(self, call: CallState) -> None:
        if (
            self.settings.webrtc_enabled
            and self.media_manager
            and not call.external_media_attached
        ):
            try:
                await self.media_manager.attach_external_media(
                    call, self.ari, self.registry
                )
            except Exception as exc:
                logger.error(
                    "externalMedia falló para %s: %s",
                    call.call_id,
                    exc,
                )
            return

        await self._originate_agent_leg(call)

    async def _setup_call_media(
        self,
        call: CallState,
        channel_id: str,
        event: dict[str, Any],
    ) -> None:
        """Puente: destino + WebRTC (externalMedia) o AGENT_ENDPOINT."""
        role = self._stasis_role(event)
        channel = event.get("channel", {})

        if role == "media":
            try:
                await self._add_to_call_bridge(call, channel_id)
                call.external_media_attached = True
                logger.info(
                    "externalMedia en puente %s — audio navegador↔PJSIP habilitado (llamada %s)",
                    call.bridge_id,
                    call.call_id,
                )
            except Exception as exc:
                logger.error(
                    "Fallo al añadir externalMedia al puente (llamada %s): %s",
                    call.call_id,
                    exc,
                )
                call.status = "failed"
            return

        if role == "agent":
            try:
                await self._add_to_call_bridge(call, channel_id)
            except Exception as exc:
                logger.error(
                    "Fallo al añadir agente al puente (llamada %s): %s",
                    call.call_id,
                    exc,
                )
                call.status = "failed"
            return

        if (
            call.outbound_stasis_setup
            and call.bridge_id
            and call.external_media_attached
        ):
            return
        call.outbound_stasis_setup = True

        if self.settings.ari_debug:
            logger.info(
                "StasisStart outbound call_id=%s channel=%s",
                call.call_id,
                summarize_channel(channel),
            )
            try:
                live = await self.ari.get_channel(channel_id)
                logger.info("Canal en vivo (ARI): %s", summarize_channel(live))
            except Exception as exc:
                logger.warning("No se pudo consultar canal %s: %s", channel_id, exc)

        try:
            await self.ari.answer(channel_id)
        except Exception as exc:
            logger.warning(
                "Answer canal %s (call=%s): %s",
                channel_id,
                call.call_id,
                exc,
            )

        try:
            await self._add_to_call_bridge(call, channel_id)
        except Exception as exc:
            logger.error(
                "Fallo al añadir cliente al puente (llamada %s): %s",
                call.call_id,
                exc,
            )
            call.status = "failed"
            return

        if call.status == "ringing":
            call.status = "answered"

        await self._attach_webrtc_or_agent(call)

        if (
            not self.settings.webrtc_enabled
            and not self._resolve_agent_endpoint()
        ):
            logger.warning(
                "Llamada %s: solo hay 1 canal en el puente (%s). "
                "Activá WEBRTC_ENABLED o definí AGENT_ENDPOINT.",
                call.call_id,
                channel.get("name"),
            )

        if self.settings.ari_debug_play_sound:
            try:
                playback = await self.ari.play_sound(channel_id)
                logger.info(
                    "Reproducción de prueba iniciada en %s (playback_id=%s)",
                    channel_id,
                    playback.get("id"),
                )
            except Exception as exc:
                logger.error("No se pudo reproducir sonido de prueba: %s", exc)

    def _find_call_for_channel(self, channel_id: str | None, event: dict[str, Any]) -> CallState | None:
        if not channel_id:
            return None
        call = self._resolve_existing_call(channel_id, event)
        if call:
            return call
        for candidate in self.registry.list_all():
            if channel_id in candidate.channel_ids:
                return candidate
            if channel_id == candidate.external_media_channel_id:
                return candidate
        return None

    def _is_customer_channel(self, channel: dict[str, Any], call: CallState) -> bool:
        name = channel.get("name") or ""
        if name.startswith("PJSIP/"):
            return True
        channel_id = channel.get("id")
        if not channel_id:
            return False
        if channel_id == call.external_media_channel_id:
            return False
        if name.startswith("UnicastRTP/"):
            return False
        return channel_id in call.channel_ids

    def _end_call(self, call: CallState, reason: str) -> None:
        if call.status in ("ended", "failed"):
            return
        call.finalize("ended")
        logger.info("Llamada %s finalizada (%s)", call.call_id, reason)

    async def _on_stasis_end(self, event: dict[str, Any]) -> CallState | None:
        channel = event.get("channel", {})
        channel_id = channel.get("id")
        call = self._find_call_for_channel(channel_id, event)
        if call and self._is_customer_channel(channel, call):
            self._end_call(call, "StasisEnd cliente")
        return call

    async def _on_channel_state_change(self, event: dict[str, Any]) -> CallState | None:
        channel = event.get("channel", {})
        channel_id = channel.get("id")
        state = channel.get("state")
        if not channel_id:
            return None

        call = self.registry.get_by_channel(channel_id)
        if not call:
            return None

        if state == "Up" and call.status in ("ringing", "answered"):
            call.status = "talking"
            # En flujo humano el "agent" es el operador; en bot lo maneja BotSession
            if not call.document_id:
                call.agent_state = "speaking"
        elif state == "Ringing" and call.status == "ringing":
            pass
        return call

    async def _on_channel_destroyed(self, event: dict[str, Any]) -> CallState | None:
        channel = event.get("channel", {})
        channel_id = channel.get("id")
        if channel_id:
            PROCESSED_CHANNELS.discard(channel_id)

        call = self._find_call_for_channel(channel_id, event)
        if not call:
            return None

        if channel_id in call.channel_ids:
            call.channel_ids = [c for c in call.channel_ids if c != channel_id]
        if call.external_media_channel_id == channel_id:
            call.external_media_channel_id = None
            call.external_media_attached = False

        if self._is_customer_channel(channel, call):
            self._end_call(call, f"canal destruido {channel.get('name')}")

        return call
