"""
ESPal daemon client.

Provides a client interface for communicating with the ESPal daemon
via Unix socket.
"""

import os
import socket
import subprocess
import sys
import threading
import time
import logging
import base64
from typing import Optional, Callable, Any, Iterator
from dataclasses import dataclass

from .protocol import (
    serialize, parse_json_line, parse_response, MessageFramer, ProtocolError,
    SubscribeCommand, BuildCommand, UploadCommand, StatusCommand, ShutdownCommand,
    DataMessage, MarkerMessage, StatusMessage, ErrorMessage,
    BuildOutputMessage, BuildCompleteMessage, SubscribedMessage, ShutdownNotice,
    ResponseType
)
from .config import get_socket_path, get_pid_path, DaemonConfig


logger = logging.getLogger(__name__)


class DaemonNotRunning(Exception):
    """Raised when daemon is not running and auto-spawn fails."""
    pass


class ClientError(Exception):
    """General client error."""
    pass


@dataclass
class MonitorOptions:
    """Options for monitor mode."""
    mode: str = "live"
    marker: Optional[str] = None
    lines: Optional[int] = None


class ESPalClient:
    """
    Client for communicating with the ESPal daemon.

    Handles:
    - Connection to daemon via Unix socket
    - Auto-spawn of daemon if not running
    - Message sending and receiving
    - Subscription to data stream
    """

    def __init__(
        self,
        vid: Optional[str] = None,
        pid: Optional[str] = None,
        device: Optional[str] = None,
        auto_spawn: bool = True
    ):
        """
        Initialise the client.

        Args:
            vid: USB Vendor ID
            pid: USB Product ID
            device: Device path
            auto_spawn: Automatically spawn daemon if not running
        """
        self._vid = vid
        self._pid = pid
        self._device = device
        self._auto_spawn = auto_spawn

        self._socket_path = get_socket_path(vid, pid, device)
        self._pid_path = get_pid_path(vid, pid, device)

        self._socket: Optional[socket.socket] = None
        self._connected = False
        self._framer = MessageFramer()

        # Callbacks
        self._on_data: Optional[Callable[[DataMessage], None]] = None
        self._on_marker: Optional[Callable[[MarkerMessage], None]] = None
        self._on_build_output: Optional[Callable[[BuildOutputMessage], None]] = None
        self._on_build_complete: Optional[Callable[[BuildCompleteMessage], None]] = None
        self._on_disconnect: Optional[Callable[[], None]] = None

        # Receive thread
        self._receive_thread: Optional[threading.Thread] = None
        self._running = False

    @property
    def connected(self) -> bool:
        """Whether connected to daemon."""
        return self._connected

    @property
    def socket_path(self) -> str:
        """Path to daemon socket."""
        return self._socket_path

    def _is_daemon_running(self) -> bool:
        """Check if daemon is running."""
        if not os.path.exists(self._pid_path):
            return False

        try:
            with open(self._pid_path, "r") as f:
                pid = int(f.read().strip())
            os.kill(pid, 0)
            return True
        except (ValueError, ProcessLookupError, PermissionError):
            return False

    def _spawn_daemon(self) -> bool:
        """
        Spawn a new daemon process.

        Returns:
            True if daemon started successfully
        """
        logger.info("Spawning daemon...")

        # Build command
        cmd = [sys.executable, "-m", "esppal.daemon"]

        if self._vid:
            cmd.extend(["--vid", self._vid])
        if self._pid:
            cmd.extend(["--pid", self._pid])
        if self._device:
            cmd.extend(["--device", self._device])

        try:
            # Start daemon in background
            subprocess.Popen(
                cmd,
                stdout=subprocess.DEVNULL,
                stderr=subprocess.DEVNULL,
                start_new_session=True
            )

            # Wait for daemon to start
            for _ in range(50):  # 5 seconds
                if os.path.exists(self._socket_path):
                    time.sleep(0.1)  # Brief pause before connecting
                    return True
                time.sleep(0.1)

            logger.error("Daemon did not start in time")
            return False

        except Exception as e:
            logger.error(f"Failed to spawn daemon: {e}")
            return False

    def connect(self) -> bool:
        """
        Connect to the daemon.

        If auto_spawn is enabled and daemon is not running, spawns it.

        Returns:
            True if connected successfully

        Raises:
            DaemonNotRunning: If daemon not running and spawn fails
        """
        if self._connected:
            return True

        # Check if daemon is running
        if not self._is_daemon_running():
            if self._auto_spawn:
                if not self._spawn_daemon():
                    raise DaemonNotRunning("Failed to spawn daemon")
            else:
                raise DaemonNotRunning("Daemon is not running")

        # Connect to socket
        try:
            self._socket = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
            self._socket.connect(self._socket_path)
            self._socket.settimeout(5.0)
            self._connected = True
            self._framer.clear()
            logger.info(f"Connected to daemon at {self._socket_path}")
            return True

        except socket.error as e:
            logger.error(f"Failed to connect to daemon: {e}")
            self._socket = None
            return False

    def disconnect(self) -> None:
        """Disconnect from the daemon."""
        self._running = False

        if self._receive_thread and self._receive_thread.is_alive():
            self._receive_thread.join(timeout=2.0)

        if self._socket:
            try:
                self._socket.close()
            except Exception:
                pass
            self._socket = None

        self._connected = False
        logger.info("Disconnected from daemon")

    def _send(self, msg: Any) -> bool:
        """Send a message to the daemon."""
        if not self._connected or not self._socket:
            return False

        try:
            data = serialize(msg)
            self._socket.sendall(data)
            return True
        except socket.error as e:
            logger.error(f"Send error: {e}")
            self._connected = False
            return False

    def _receive_one(self, timeout: float = 5.0) -> Optional[ResponseType]:
        """
        Receive one message from the daemon.

        Args:
            timeout: Receive timeout

        Returns:
            Parsed response or None on timeout/error
        """
        if not self._connected or not self._socket:
            return None

        try:
            self._socket.settimeout(timeout)
            data = self._socket.recv(4096)

            if not data:
                self._connected = False
                return None

            for line in self._framer.feed(data):
                msg_dict = parse_json_line(line)
                return parse_response(msg_dict)

            return None

        except socket.timeout:
            return None
        except (socket.error, ProtocolError) as e:
            logger.error(f"Receive error: {e}")
            self._connected = False
            return None

    def _receive_loop(self) -> None:
        """Background receive loop for subscriptions."""
        self._socket.settimeout(1.0)

        while self._running and self._connected:
            try:
                data = self._socket.recv(4096)

                if not data:
                    self._connected = False
                    break

                for line in self._framer.feed(data):
                    try:
                        msg_dict = parse_json_line(line)
                        msg = parse_response(msg_dict)
                        self._dispatch_message(msg)
                    except ProtocolError as e:
                        logger.warning(f"Protocol error: {e}")

            except socket.timeout:
                continue
            except socket.error as e:
                logger.error(f"Receive error: {e}")
                self._connected = False
                break

        if self._on_disconnect:
            self._on_disconnect()

    def _dispatch_message(self, msg: ResponseType) -> None:
        """Dispatch a received message to the appropriate callback."""
        if isinstance(msg, DataMessage):
            if self._on_data:
                self._on_data(msg)
        elif isinstance(msg, MarkerMessage):
            if self._on_marker:
                self._on_marker(msg)
        elif isinstance(msg, BuildOutputMessage):
            if self._on_build_output:
                self._on_build_output(msg)
        elif isinstance(msg, BuildCompleteMessage):
            if self._on_build_complete:
                self._on_build_complete(msg)
        elif isinstance(msg, ShutdownNotice):
            logger.info(f"Daemon shutting down: {msg.reason}")
            self._connected = False
        elif isinstance(msg, ErrorMessage):
            logger.error(f"Error from daemon: {msg.code}: {msg.message}")

    def subscribe(
        self,
        mode: str = "live",
        marker: Optional[str] = None,
        lines: Optional[int] = None,
        on_data: Optional[Callable[[DataMessage], None]] = None,
        on_marker: Optional[Callable[[MarkerMessage], None]] = None,
        on_disconnect: Optional[Callable[[], None]] = None
    ) -> bool:
        """
        Subscribe to the data stream.

        Args:
            mode: Subscription mode ("live", "since", "tail", "replay")
            marker: Marker name for "since" mode
            lines: Number of lines for "tail" mode
            on_data: Callback for data messages
            on_marker: Callback for marker messages
            on_disconnect: Callback when disconnected

        Returns:
            True if subscription successful
        """
        if not self.connect():
            return False

        self._on_data = on_data
        self._on_marker = on_marker
        self._on_disconnect = on_disconnect

        # Send subscribe command
        cmd = SubscribeCommand(mode=mode, marker=marker, lines=lines)
        if not self._send(cmd):
            return False

        # Wait for confirmation
        response = self._receive_one()
        if not isinstance(response, SubscribedMessage):
            if isinstance(response, ErrorMessage):
                logger.error(f"Subscribe failed: {response.message}")
            return False

        logger.info(f"Subscribed in {mode} mode, starting at position {response.position}")

        # Start receive thread
        self._running = True
        self._receive_thread = threading.Thread(target=self._receive_loop, daemon=True)
        self._receive_thread.start()

        return True

    def status(self) -> Optional[StatusMessage]:
        """
        Get daemon status.

        Returns:
            StatusMessage or None on error
        """
        if not self.connect():
            return None

        if not self._send(StatusCommand()):
            return None

        response = self._receive_one()
        if isinstance(response, StatusMessage):
            return response

        return None

    def build(
        self,
        args: Optional[list[str]] = None,
        environment: Optional[str] = None,
        on_output: Optional[Callable[[BuildOutputMessage], None]] = None,
        on_complete: Optional[Callable[[BuildCompleteMessage], None]] = None,
        wait: bool = True
    ) -> Optional[BuildCompleteMessage]:
        """
        Trigger a build operation.

        Args:
            args: Additional build arguments
            environment: Build environment
            on_output: Callback for build output
            on_complete: Callback when build completes
            wait: Wait for build to complete

        Returns:
            BuildCompleteMessage if wait=True, None otherwise
        """
        if not self.connect():
            return None

        self._on_build_output = on_output
        self._on_build_complete = on_complete

        cmd = BuildCommand(args=args or [], environment=environment)
        if not self._send(cmd):
            return None

        if not wait:
            # Start receive thread to handle callbacks
            self._running = True
            self._receive_thread = threading.Thread(target=self._receive_loop, daemon=True)
            self._receive_thread.start()
            return None

        # Wait for completion
        while True:
            response = self._receive_one(timeout=60.0)
            if response is None:
                logger.warning("Timeout waiting for build")
                return None

            if isinstance(response, BuildOutputMessage):
                if on_output:
                    on_output(response)
            elif isinstance(response, BuildCompleteMessage):
                if on_complete:
                    on_complete(response)
                return response
            elif isinstance(response, ErrorMessage):
                logger.error(f"Build error: {response.message}")
                return None

    def upload(
        self,
        args: Optional[list[str]] = None,
        environment: Optional[str] = None,
        on_output: Optional[Callable[[BuildOutputMessage], None]] = None,
        on_complete: Optional[Callable[[BuildCompleteMessage], None]] = None,
        wait: bool = True
    ) -> Optional[BuildCompleteMessage]:
        """
        Trigger an upload operation.

        Args:
            args: Additional upload arguments
            environment: Build environment
            on_output: Callback for upload output
            on_complete: Callback when upload completes
            wait: Wait for upload to complete

        Returns:
            BuildCompleteMessage if wait=True, None otherwise
        """
        if not self.connect():
            return None

        self._on_build_output = on_output
        self._on_build_complete = on_complete

        cmd = UploadCommand(args=args or [], environment=environment)
        if not self._send(cmd):
            return None

        if not wait:
            self._running = True
            self._receive_thread = threading.Thread(target=self._receive_loop, daemon=True)
            self._receive_thread.start()
            return None

        # Wait for completion
        while True:
            response = self._receive_one(timeout=120.0)  # Upload can take longer
            if response is None:
                logger.warning("Timeout waiting for upload")
                return None

            if isinstance(response, BuildOutputMessage):
                if on_output:
                    on_output(response)
            elif isinstance(response, BuildCompleteMessage):
                if on_complete:
                    on_complete(response)
                return response
            elif isinstance(response, ErrorMessage):
                logger.error(f"Upload error: {response.message}")
                return None

    def shutdown(self) -> bool:
        """
        Request daemon shutdown.

        Returns:
            True if shutdown request sent
        """
        if not self.connect():
            return False

        return self._send(ShutdownCommand())

    def wait_for_disconnect(self, timeout: Optional[float] = None) -> bool:
        """
        Wait for the connection to close.

        Args:
            timeout: Maximum seconds to wait

        Returns:
            True if disconnected, False on timeout
        """
        if self._receive_thread and self._receive_thread.is_alive():
            self._receive_thread.join(timeout=timeout)
            return not self._receive_thread.is_alive()
        return True


def print_data_message(msg: DataMessage) -> None:
    """Default handler to print data messages."""
    try:
        text = msg.data.decode("utf-8", errors="replace")
        sys.stdout.write(text)
        sys.stdout.flush()
    except Exception:
        pass


def print_build_output(msg: BuildOutputMessage) -> None:
    """Default handler to print build output."""
    prefix = "[ERR] " if msg.stream == "stderr" else ""
    print(f"{prefix}{msg.line}")
