"""Core Kryten client implementation."""

import asyncio
import json
import logging
import time
import uuid
from collections import defaultdict
from collections.abc import Callable
from datetime import UTC, datetime
from typing import Any

import nats
from nats.aio.client import Client as NATSClient

from kryten.config import KrytenConfig
from kryten.exceptions import (
    KrytenConnectionError,
    KrytenValidationError,
    PublishError,
)
from kryten.health import ChannelInfo, HealthStatus
from kryten.models import (
    ChangeMediaEvent,
    ChatMessageEvent,
    PlaylistUpdateEvent,
    RawEvent,
    UserJoinEvent,
    UserLeaveEvent,
)
from kryten.subject_builder import build_command_subject


class KrytenClient:
    """High-level client for CyTube interaction via NATS.

    Examples:
        Basic usage with context manager:

        >>> config = {
        ...     "nats": {"servers": ["nats://localhost:4222"]},
        ...     "channels": [{"domain": "cytu.be", "channel": "lounge"}]
        ... }
        >>>
        >>> async with KrytenClient(config) as client:
        ...     @client.on("chatmsg")
        ...     async def handle_chat(event: ChatMessageEvent):
        ...         print(f"{event.username}: {event.message}")
        ...
        ...     await client.send_chat("lounge", "Hello from bot!")
        ...     await client.run()  # Run until stopped
    """

    def __init__(
        self,
        config: dict[str, Any] | KrytenConfig,
        logger: logging.Logger | None = None,
    ) -> None:
        """Initialize client with configuration.

        Args:
            config: Dictionary or KrytenConfig model with NATS and channel settings
            logger: Optional logger instance (creates default if None)

        Raises:
            KrytenValidationError: If configuration is invalid
        """
        # Parse and validate configuration
        if isinstance(config, dict):
            try:
                self.config = KrytenConfig(**config)
            except Exception as e:
                raise KrytenValidationError(f"Invalid configuration: {e}") from e
        else:
            self.config = config

        # Set up logging
        self.logger = logger or logging.getLogger(__name__)
        self.logger.setLevel(self.config.log_level)

        # NATS connection
        self._nats: NATSClient | None = None
        self._connected = False
        self._connection_time: float | None = None

        # Event handlers: {event_name: [(handler, channel_filter, domain_filter), ...]}
        self._handlers: dict[
            str, list[tuple[Callable[[Any], Any], str | None, str | None]]
        ] = defaultdict(list)

        # Subscriptions
        self._subscriptions: list[Any] = []

        # Control flow
        self._running = False
        self._stop_requested = False

        # Metrics
        self._events_received = 0
        self._commands_sent = 0
        self._errors = 0
        self._event_latencies: list[float] = []
        self._last_event_time: datetime | None = None
        self._channel_metrics: dict[str, int] = defaultdict(int)

    async def connect(self) -> None:
        """Establish NATS connection and subscribe to configured channels.

        Raises:
            KrytenConnectionError: If NATS connection fails after retries
            KrytenValidationError: If configuration is invalid
        """
        if self._connected:
            self.logger.warning("Already connected to NATS")
            return

        self.logger.info(
            "Connecting to NATS",
            extra={"servers": self.config.nats.servers},
        )

        try:
            # Create NATS client
            self._nats = await nats.connect(
                servers=self.config.nats.servers,
                user=self.config.nats.user,
                password=self.config.nats.password,
                token=self.config.nats.token,
                connect_timeout=self.config.nats.connect_timeout,
                reconnect_time_wait=self.config.nats.reconnect_time_wait,
                max_reconnect_attempts=self.config.nats.max_reconnect_attempts,
                ping_interval=self.config.nats.ping_interval,
                error_cb=self._on_error,
                disconnected_cb=self._on_disconnected,
                reconnected_cb=self._on_reconnected,
                closed_cb=self._on_closed,
            )

            self._connected = True
            self._connection_time = time.time()

            # Subscribe to channels
            await self._setup_subscriptions()

            self.logger.info(
                "Connected to NATS successfully",
                extra={"channels": [f"{c.domain}/{c.channel}" for c in self.config.channels]},
            )

        except Exception as e:
            self.logger.error(f"Failed to connect to NATS: {e}", exc_info=True)
            raise KrytenConnectionError(f"NATS connection failed: {e}") from e

    async def disconnect(self) -> None:
        """Gracefully close NATS connection and cleanup resources."""
        if not self._connected or self._nats is None:
            self.logger.debug("Not connected, nothing to disconnect")
            return

        self.logger.info("Disconnecting from NATS")

        try:
            # Unsubscribe from all subscriptions
            for sub in self._subscriptions:
                try:
                    await sub.unsubscribe()
                except Exception as e:
                    self.logger.warning(f"Error unsubscribing: {e}")

            self._subscriptions.clear()

            # Close NATS connection
            await self._nats.drain()
            await self._nats.close()

            self._connected = False
            self._nats = None
            self._connection_time = None

            self.logger.info("Disconnected from NATS successfully")

        except Exception as e:
            self.logger.error(f"Error during disconnect: {e}", exc_info=True)

    async def __aenter__(self) -> "KrytenClient":
        """Async context manager entry (calls connect)."""
        await self.connect()
        return self

    async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
        """Async context manager exit (calls disconnect)."""
        await self.disconnect()

    def on(
        self,
        event_name: str,
        channel: str | None = None,
        domain: str | None = None,
    ) -> Callable[[Callable[[Any], Any]], Callable[[Any], Any]]:
        """Decorator to register event handler.

        Args:
            event_name: CyTube event type (e.g., "chatmsg", "adduser")
            channel: Optional specific channel filter (None = all channels)
            domain: Optional specific domain filter (None = all domains)

        Returns:
            Decorator function

        Examples:
            Handle all chat messages:

            >>> @client.on("chatmsg")
            ... async def on_chat(event: ChatMessageEvent):
            ...     print(event.message)

            Handle chat only from specific channel:

            >>> @client.on("chatmsg", channel="lounge")
            ... async def on_lounge_chat(event: ChatMessageEvent):
            ...     print(f"Lounge: {event.message}")
        """

        def decorator(func: Callable[[Any], Any]) -> Callable[[Any], Any]:
            self._handlers[event_name.lower()].append((func, channel, domain))
            self.logger.debug(
                f"Registered handler for event '{event_name}'",
                extra={"channel": channel, "domain": domain},
            )
            return func

        return decorator

    async def run(self) -> None:
        """Start event processing loop (runs until stop() called).

        This method blocks and processes events indefinitely. Call stop()
        from another task or signal handler to exit gracefully.
        """
        if self._running:
            self.logger.warning("Client already running")
            return

        if not self._connected:
            raise KrytenConnectionError("Must connect before running")

        self._running = True
        self._stop_requested = False

        self.logger.info("Event processing loop started")

        try:
            # Keep running until stop requested
            while not self._stop_requested:
                await asyncio.sleep(0.1)

        except asyncio.CancelledError:
            self.logger.info("Event processing cancelled")
            raise

        finally:
            self._running = False
            self.logger.info("Event processing loop stopped")

    async def stop(self) -> None:
        """Request graceful shutdown of event processing loop."""
        if not self._running:
            self.logger.debug("Client not running, stop is no-op")
            return

        self.logger.info("Requesting stop")
        self._stop_requested = True

        # Wait for run loop to finish (with timeout)
        timeout = 5.0
        start_time = time.time()
        while self._running and (time.time() - start_time) < timeout:
            await asyncio.sleep(0.1)

        if self._running:
            self.logger.warning("Stop did not complete within timeout")

    # Command Publishing - Chat

    async def send_chat(
        self,
        channel: str,
        message: str,
        *,
        domain: str | None = None,
    ) -> str:
        """Send chat message to channel.

        Args:
            channel: Channel name
            message: Message text
            domain: Optional domain (uses first configured if None)

        Returns:
            Correlation ID for tracking

        Raises:
            KrytenConnectionError: If not connected
            PublishError: If publish fails

        Examples:
            >>> correlation_id = await client.send_chat("lounge", "Hello!")
        """
        return await self._send_command(
            channel=channel,
            action="chat",
            data={"message": message},
            domain=domain,
        )

    async def send_pm(
        self,
        channel: str,
        username: str,
        message: str,
        *,
        domain: str | None = None,
    ) -> str:
        """Send private message to user.

        Args:
            channel: Channel name
            username: Target username
            message: Message text
            domain: Optional domain

        Returns:
            Correlation ID

        Raises:
            KrytenConnectionError: If not connected
            PublishError: If publish fails
        """
        return await self._send_command(
            channel=channel,
            action="pm",
            data={"to": username, "message": message},
            domain=domain,
        )

    # Command Publishing - Playlist

    async def add_media(
        self,
        channel: str,
        media_type: str,
        media_id: str,
        *,
        position: str = "end",
        domain: str | None = None,
    ) -> str:
        """Add media to playlist.

        Args:
            channel: Channel name
            media_type: Media type (e.g., "yt", "vm", "dm")
            media_id: Media ID (YouTube video ID, etc.)
            position: "end" or "next"
            domain: Optional domain

        Returns:
            Correlation ID

        Examples:
            >>> await client.add_media("lounge", "yt", "dQw4w9WgXcQ")
        """
        return await self._send_command(
            channel=channel,
            action="queue",
            data={"type": media_type, "id": media_id, "pos": position},
            domain=domain,
        )

    async def delete_media(
        self,
        channel: str,
        uid: int,
        *,
        domain: str | None = None,
    ) -> str:
        """Delete media from playlist.

        Args:
            channel: Channel name
            uid: Playlist item unique ID
            domain: Optional domain

        Returns:
            Correlation ID
        """
        return await self._send_command(
            channel=channel,
            action="delete",
            data={"uid": uid},
            domain=domain,
        )

    async def move_media(
        self,
        channel: str,
        uid: int,
        position: int,
        *,
        domain: str | None = None,
    ) -> str:
        """Move media to new position in playlist."""
        return await self._send_command(
            channel=channel,
            action="move",
            data={"from": uid, "after": position},
            domain=domain,
        )

    async def jump_to(
        self,
        channel: str,
        uid: int,
        *,
        domain: str | None = None,
    ) -> str:
        """Jump to specific media in playlist."""
        return await self._send_command(
            channel=channel,
            action="jump",
            data={"uid": uid},
            domain=domain,
        )

    async def clear_playlist(
        self,
        channel: str,
        *,
        domain: str | None = None,
    ) -> str:
        """Clear entire playlist."""
        return await self._send_command(
            channel=channel,
            action="clear",
            data={},
            domain=domain,
        )

    async def shuffle_playlist(
        self,
        channel: str,
        *,
        domain: str | None = None,
    ) -> str:
        """Shuffle playlist order."""
        return await self._send_command(
            channel=channel,
            action="shuffle",
            data={},
            domain=domain,
        )

    async def set_temp(
        self,
        channel: str,
        uid: int,
        is_temp: bool = True,
        *,
        domain: str | None = None,
    ) -> str:
        """Set temporary flag on playlist item."""
        return await self._send_command(
            channel=channel,
            action="settemp",
            data={"uid": uid, "temp": is_temp},
            domain=domain,
        )

    # Command Publishing - Playback

    async def pause(
        self,
        channel: str,
        *,
        domain: str | None = None,
    ) -> str:
        """Pause current media."""
        return await self._send_command(
            channel=channel,
            action="pause",
            data={},
            domain=domain,
        )

    async def play(
        self,
        channel: str,
        *,
        domain: str | None = None,
    ) -> str:
        """Resume playback."""
        return await self._send_command(
            channel=channel,
            action="play",
            data={},
            domain=domain,
        )

    async def seek(
        self,
        channel: str,
        time_seconds: float,
        *,
        domain: str | None = None,
    ) -> str:
        """Seek to specific time in current media."""
        return await self._send_command(
            channel=channel,
            action="seek",
            data={"time": time_seconds},
            domain=domain,
        )

    # Command Publishing - Moderation

    async def kick_user(
        self,
        channel: str,
        username: str,
        reason: str | None = None,
        *,
        domain: str | None = None,
    ) -> str:
        """Kick user from channel."""
        data: dict[str, Any] = {"name": username}
        if reason:
            data["reason"] = reason
        return await self._send_command(
            channel=channel,
            action="kick",
            data=data,
            domain=domain,
        )

    async def ban_user(
        self,
        channel: str,
        username: str,
        reason: str | None = None,
        *,
        domain: str | None = None,
    ) -> str:
        """Ban user from channel."""
        data: dict[str, Any] = {"name": username}
        if reason:
            data["reason"] = reason
        return await self._send_command(
            channel=channel,
            action="ban",
            data=data,
            domain=domain,
        )

    async def voteskip(
        self,
        channel: str,
        *,
        domain: str | None = None,
    ) -> str:
        """Vote to skip current media."""
        return await self._send_command(
            channel=channel,
            action="voteskip",
            data={},
            domain=domain,
        )

    # Status & Health

    def health(self) -> HealthStatus:
        """Get current health status and metrics.

        Returns:
            HealthStatus model with connection state and statistics
        """
        uptime = 0.0
        if self._connection_time:
            uptime = time.time() - self._connection_time

        avg_latency = 0.0
        if self._event_latencies:
            avg_latency = sum(self._event_latencies) / len(self._event_latencies) * 1000

        state = "connected" if self._connected else "disconnected"
        if not self._connected and self._connection_time:
            state = "connecting"

        return HealthStatus(
            connected=self._connected,
            state=state,
            uptime_seconds=uptime,
            channels=[f"{c.domain}/{c.channel}" for c in self.config.channels],
            events_received=self._events_received,
            commands_sent=self._commands_sent,
            errors=self._errors,
            avg_event_latency_ms=avg_latency,
            last_event_time=self._last_event_time,
            handlers_registered=sum(len(handlers) for handlers in self._handlers.values()),
        )

    @property
    def is_connected(self) -> bool:
        """Check if NATS connection is active."""
        return self._connected

    @property
    def channels(self) -> list[ChannelInfo]:
        """Get list of configured channels."""
        return [
            ChannelInfo(
                domain=c.domain,
                channel=c.channel,
                subscribed=self._connected,
                events_received=self._channel_metrics.get(f"{c.domain}/{c.channel}", 0),
            )
            for c in self.config.channels
        ]

    # Private methods

    async def _setup_subscriptions(self) -> None:
        """Set up NATS subscriptions for all configured channels."""
        if self._nats is None:
            raise KrytenConnectionError("NATS client not initialized")

        for channel_config in self.config.channels:
            # Subscribe to all events from this channel using wildcard
            subject = (
                f"cytube.events.{channel_config.domain.lower()}.{channel_config.channel.lower()}.>"
            )

            self.logger.info(f"Subscribing to: {subject}")

            sub = await self._nats.subscribe(subject, cb=self._on_message)
            self._subscriptions.append(sub)

    async def _on_message(self, msg: Any) -> None:
        """Handle incoming NATS message."""
        start_time = time.time()

        try:
            # Parse message
            data = json.loads(msg.data.decode("utf-8"))
            raw_event = RawEvent(**data)

            self._events_received += 1
            self._last_event_time = datetime.now(UTC)
            self._channel_metrics[f"{raw_event.domain}/{raw_event.channel}"] += 1

            # Find matching handlers
            event_name = raw_event.event_name.lower()
            handlers = self._handlers.get(event_name, [])

            # Invoke handlers concurrently
            tasks = []
            for handler, channel_filter, domain_filter in handlers:
                # Check filters
                if channel_filter and channel_filter != raw_event.channel:
                    continue
                if domain_filter and domain_filter != raw_event.domain:
                    continue

                # Create task for handler
                task = asyncio.create_task(self._invoke_handler(handler, raw_event))
                tasks.append(task)

            # Wait for all handlers
            if tasks:
                await asyncio.gather(*tasks, return_exceptions=True)

            # Track latency
            elapsed = time.time() - start_time
            self._event_latencies.append(elapsed)
            # Keep only last 1000 latencies
            if len(self._event_latencies) > 1000:
                self._event_latencies = self._event_latencies[-1000:]

        except Exception as e:
            self._errors += 1
            self.logger.error(f"Error processing message: {e}", exc_info=True)

    def _convert_to_typed_event(self, raw_event: RawEvent) -> Any:
        """Convert RawEvent to specific typed event based on event_name.

        Args:
            raw_event: Raw event from NATS

        Returns:
            Typed event object (ChatMessageEvent, UserJoinEvent, etc.) or RawEvent if no conversion available

        Note:
            Falls back to returning the RawEvent if conversion fails or event type is unknown.
        """
        event_name = raw_event.event_name.lower()
        payload = raw_event.payload

        # Skip conversion if payload is not a dictionary
        if not isinstance(payload, dict):
            return raw_event

        try:
            if event_name == "chatmsg":
                # Extract user info from nested structure or flat structure
                if "user" in payload and isinstance(payload["user"], dict):
                    username = payload["user"].get("name", "")
                    rank = payload["user"].get("rank", 0)
                else:
                    username = payload.get("username", "")
                    rank = payload.get("rank", 0)
                
                # Message field can be 'msg' or 'message'
                message = payload.get("msg", payload.get("message", ""))
                
                # Time is Unix timestamp in milliseconds
                time_ms = payload.get("time", 0)
                timestamp = datetime.fromtimestamp(time_ms / 1000, tz=UTC) if time_ms else raw_event.timestamp

                return ChatMessageEvent(
                    username=username,
                    message=message,
                    timestamp=timestamp,
                    rank=rank,
                    channel=raw_event.channel,
                    domain=raw_event.domain,
                    correlation_id=raw_event.correlation_id,
                )

            elif event_name == "pm":
                # PM events have similar structure to chat messages
                if "user" in payload and isinstance(payload["user"], dict):
                    username = payload["user"].get("name", "")
                    rank = payload["user"].get("rank", 0)
                else:
                    username = payload.get("username", "")
                    rank = payload.get("rank", 0)
                
                message = payload.get("msg", payload.get("message", ""))
                time_ms = payload.get("time", 0)
                timestamp = datetime.fromtimestamp(time_ms / 1000, tz=UTC) if time_ms else raw_event.timestamp

                return ChatMessageEvent(
                    username=username,
                    message=message,
                    timestamp=timestamp,
                    rank=rank,
                    channel=raw_event.channel,
                    domain=raw_event.domain,
                    correlation_id=raw_event.correlation_id,
                )

            elif event_name == "adduser":
                username = payload.get("name", "")
                rank = payload.get("rank", 0)
                time_ms = payload.get("time", 0)
                timestamp = datetime.fromtimestamp(time_ms / 1000, tz=UTC) if time_ms else raw_event.timestamp

                return UserJoinEvent(
                    username=username,
                    rank=rank,
                    timestamp=timestamp,
                    channel=raw_event.channel,
                    domain=raw_event.domain,
                    correlation_id=raw_event.correlation_id,
                )

            elif event_name == "userleave":
                username = payload.get("name", "")
                time_ms = payload.get("time", 0)
                timestamp = datetime.fromtimestamp(time_ms / 1000, tz=UTC) if time_ms else raw_event.timestamp

                return UserLeaveEvent(
                    username=username,
                    timestamp=timestamp,
                    channel=raw_event.channel,
                    domain=raw_event.domain,
                    correlation_id=raw_event.correlation_id,
                )

            elif event_name == "changemedia":
                media_type = payload.get("type", "")
                media_id = payload.get("id", "")
                title = payload.get("title", "")
                duration = payload.get("seconds", 0)
                uid = payload.get("uid", 0)
                time_ms = payload.get("time", 0)
                timestamp = datetime.fromtimestamp(time_ms / 1000, tz=UTC) if time_ms else raw_event.timestamp

                return ChangeMediaEvent(
                    media_type=media_type,
                    media_id=media_id,
                    title=title,
                    duration=duration,
                    uid=uid,
                    timestamp=timestamp,
                    channel=raw_event.channel,
                    domain=raw_event.domain,
                    correlation_id=raw_event.correlation_id,
                )

            elif event_name == "playlist":
                action = payload.get("action", "")
                uid = payload.get("uid")
                time_ms = payload.get("time", 0)
                timestamp = datetime.fromtimestamp(time_ms / 1000, tz=UTC) if time_ms else raw_event.timestamp

                return PlaylistUpdateEvent(
                    action=action,
                    uid=uid,
                    timestamp=timestamp,
                    channel=raw_event.channel,
                    domain=raw_event.domain,
                    correlation_id=raw_event.correlation_id,
                )

        except Exception as e:
            self.logger.warning(
                f"Failed to convert {event_name} to typed event: {e}. Falling back to RawEvent.",
                exc_info=True,
            )

        # Default: return raw event if conversion fails or event type unknown
        return raw_event

    async def _invoke_handler(
        self, handler: Callable[[Any], Any], event: RawEvent
    ) -> None:
        """Invoke event handler with timeout."""
        try:
            # Convert RawEvent to specific typed event based on event_name
            typed_event = self._convert_to_typed_event(event)
            await asyncio.wait_for(
                handler(typed_event),
                timeout=self.config.handler_timeout,
            )
        except asyncio.TimeoutError:
            self._errors += 1
            self.logger.error(
                f"Handler {handler.__name__} timed out",
                extra={"timeout": self.config.handler_timeout},
            )
        except Exception as e:
            self._errors += 1
            self.logger.error(
                f"Handler {handler.__name__} raised exception: {e}",
                exc_info=True,
            )

    async def _send_command(
        self,
        channel: str,
        action: str,
        data: dict[str, Any],
        domain: str | None = None,
    ) -> str:
        """Send command to NATS.

        Args:
            channel: Channel name
            action: Command action
            data: Command data
            domain: Optional domain

        Returns:
            Correlation ID

        Raises:
            KrytenConnectionError: If not connected
            PublishError: If publish fails
        """
        if not self._connected or self._nats is None:
            raise KrytenConnectionError("Not connected to NATS")

        # Generate correlation ID
        correlation_id = str(uuid.uuid4())

        # Build subject
        subject = build_command_subject(channel, action)

        # Build payload
        payload = {
            "action": action,
            "data": data,
            "correlation_id": correlation_id,
            "timestamp": datetime.now(UTC).isoformat(),
        }

        # Publish with retry
        for attempt in range(self.config.retry_attempts + 1):
            try:
                await self._nats.publish(subject, json.dumps(payload).encode("utf-8"))

                self._commands_sent += 1

                self.logger.info(
                    f"Published command '{action}' to channel '{channel}'",
                    extra={
                        "subject": subject,
                        "correlation_id": correlation_id,
                    },
                )

                return correlation_id

            except Exception as e:
                if attempt < self.config.retry_attempts:
                    delay = self.config.retry_delay * (2**attempt)
                    self.logger.warning(
                        f"Publish failed (attempt {attempt + 1}/{self.config.retry_attempts + 1}), "
                        f"retrying in {delay}s: {e}"
                    )
                    await asyncio.sleep(delay)
                else:
                    self._errors += 1
                    self.logger.error(f"Publish failed permanently: {e}", exc_info=True)
                    raise PublishError(f"Failed to publish command: {e}") from e

        # Should not reach here
        raise PublishError("Failed to publish command after retries")

    # NATS callbacks

    async def _on_error(self, e: Exception) -> None:
        """Handle NATS error."""
        self._errors += 1
        self.logger.error(f"NATS error: {e}", exc_info=True)

    async def _on_disconnected(self) -> None:
        """Handle NATS disconnection."""
        self.logger.warning("Disconnected from NATS")

    async def _on_reconnected(self) -> None:
        """Handle NATS reconnection."""
        self.logger.info("Reconnected to NATS")

    async def _on_closed(self) -> None:
        """Handle NATS connection closed."""
        self.logger.info("NATS connection closed")
        self._connected = False


__all__ = ["KrytenClient"]
