"""High-performance terminal session with asyncio-native I/O.

Replaces blocking select.select() with event-driven asyncio.add_reader()
for sub-frame latency. Includes output batching and flow control.
"""

import asyncio
import logging
import os
import pty
import signal
import struct
import fcntl
import time
import uuid
from collections import deque
from typing import Any

logger = logging.getLogger(__name__)


class TerminalSession:
    """Manages a single terminal session with event-driven PTY I/O.

    Uses asyncio.add_reader() instead of polling for near-zero read latency.
    Output is batched (2ms window) and sent as binary WebSocket frames.
    Flow control prevents memory bloat on fast output.
    """

    # Circular buffer for replay (10KB)
    BUFFER_SIZE = 10 * 1024

    # Idle timeout in seconds (30 minutes)
    IDLE_TIMEOUT = 30 * 60

    # Output batching window in seconds
    BATCH_WINDOW_S = 0.002  # 2ms

    # Flow control: pause PTY reads when unacknowledged bytes exceed this
    FLOW_CONTROL_PAUSE = 64 * 1024

    # Flow control: resume PTY reads when unacknowledged bytes drop below this
    FLOW_CONTROL_RESUME = 32 * 1024

    # Max WebSocket frame size
    MAX_FRAME_SIZE = 64 * 1024

    # Session number counter per instance_id for auto-naming
    _instance_counters: dict[str, int] = {}

    def __init__(
        self,
        session_id: str,
        instance_id: uuid.UUID,
        container_id: str,
        startup_command: str | None = None,
        name: str | None = None,
    ) -> None:
        self.session_id = session_id
        self.instance_id = instance_id
        self.container_id = container_id
        self.startup_command = startup_command
        self.process: asyncio.subprocess.Process | None = None
        self._closed = False
        self._master_fd: int | None = None

        # Circular buffer for output replay
        self._output_buffer: deque[bytes] = deque(maxlen=self.BUFFER_SIZE)
        self._buffer_size = 0

        # WebSocket connections
        self._websockets: set[Any] = set()

        # Activity tracking
        self.last_activity = time.time()

        # Terminal size
        self._cols = 80
        self._rows = 24

        # Session metadata
        self.name = name or self._generate_name(str(instance_id))
        self.status: str = "active"

        # Output batching
        self._batch_buffer = bytearray()
        self._batch_timer: asyncio.TimerHandle | None = None
        self._batch_lock = asyncio.Lock()

        # Flow control
        self._unacknowledged_bytes = 0
        self._paused = False
        self._read_handler_set = False
        self._flow_control_lock = asyncio.Lock()

        # Ack timeout fallback
        self._ack_timeout_handle: asyncio.TimerHandle | None = None

    @classmethod
    def _generate_name(cls, instance_id: str) -> str:
        """Generate an auto-incremented session name for the instance."""
        count = cls._instance_counters.get(instance_id, 0) + 1
        cls._instance_counters[instance_id] = count
        return f"Session {count}"

    async def start(self, startup_command: str | None = None) -> None:
        """Start the docker exec process with a shell using a PTY."""
        # Create a pseudo-terminal on the host
        self._master_fd, slave_fd = pty.openpty()

        # Set the terminal size initially
        self._set_terminal_size(self._cols, self._rows)
        logger.debug(
            "Starting terminal session %s for container %s with initial size %sx%s",
            self.session_id,
            self.container_id,
            self._cols,
            self._rows,
        )

        # Build the shell command
        cmd = startup_command or self.startup_command
        if cmd:
            shell_cmd = f'bash -c "{cmd}" || true; exec bash -il'
            logger.debug(
                "Using startup command for session %s: %s",
                self.session_id,
                cmd,
            )
        else:
            shell_cmd = "bash -il"

        # Start docker exec with the slave fd as stdin/stdout/stderr
        self.process = await asyncio.create_subprocess_exec(
            "docker",
            "exec",
            "-it",
            "-e",
            "TERM=xterm-256color",
            self.container_id,
            "bash",
            "-c",
            shell_cmd,
            stdin=slave_fd,
            stdout=slave_fd,
            stderr=slave_fd,
        )

        # Close slave fd in parent process
        os.close(slave_fd)

        self.last_activity = time.time()

        # Start event-driven reading
        self._start_reading()

    def _start_reading(self) -> None:
        """Register PTY master fd with asyncio event loop for event-driven reads."""
        if self._read_handler_set or self._master_fd is None or self._closed:
            return
        try:
            loop = asyncio.get_event_loop()
            loop.add_reader(self._master_fd, self._on_fd_readable)
            self._read_handler_set = True
            logger.debug("Started event-driven reading for session %s", self.session_id)
        except Exception as exc:
            logger.error(
                "Failed to start reading for session %s: %s", self.session_id, exc
            )

    def _stop_reading(self) -> None:
        """Unregister PTY master fd from asyncio event loop."""
        if not self._read_handler_set or self._master_fd is None:
            return
        try:
            loop = asyncio.get_event_loop()
            loop.remove_reader(self._master_fd)
            self._read_handler_set = False
        except Exception:
            pass

    def _on_fd_readable(self) -> None:
        """Callback when PTY master fd has data available (called by event loop)."""
        if self._master_fd is None or self._closed:
            return

        try:
            data = os.read(self._master_fd, 4096)
        except (OSError, IOError) as exc:
            logger.debug("PTY read error for session %s: %s", self.session_id, exc)
            self._handle_eof()
            return

        if not data:
            # EOF: docker exec process exited
            logger.debug("PTY EOF for session %s", self.session_id)
            self._handle_eof()
            return

        self._add_to_buffer(data)
        self.last_activity = time.time()

        # Queue for batching + flow control
        self._queue_output(data)

    def _add_to_buffer(self, data: bytes) -> None:
        """Add data to circular buffer, maintaining size limit."""
        self._output_buffer.append(data)
        self._buffer_size += len(data)
        while self._buffer_size > self.BUFFER_SIZE and self._output_buffer:
            removed = self._output_buffer.popleft()
            self._buffer_size -= len(removed)

    def _queue_output(self, data: bytes) -> None:
        """Add output to batch buffer and schedule flush."""
        self._batch_buffer.extend(data)
        self._unacknowledged_bytes += len(data)

        # Check flow control
        if self._unacknowledged_bytes > self.FLOW_CONTROL_PAUSE and not self._paused:
            self._pause_output()

        # Schedule batch flush if not already scheduled
        if self._batch_timer is None:
            loop = asyncio.get_event_loop()
            self._batch_timer = loop.call_later(
                self.BATCH_WINDOW_S,
                self._flush_batch_sync,
            )

    def _flush_batch_sync(self) -> None:
        """Synchronous entry point for batch flush (called from event loop)."""
        self._batch_timer = None
        if not self._batch_buffer or not self._websockets:
            self._batch_buffer.clear()
            return

        payload = bytes(self._batch_buffer)
        self._batch_buffer.clear()

        # Send to all websockets (asyncio.create_task for async send)
        dead_sockets = set()
        for ws in list(self._websockets):
            try:
                asyncio.create_task(self._send_bytes(ws, payload))
            except Exception:
                dead_sockets.add(ws)

        if dead_sockets:
            self._websockets -= dead_sockets

    async def _send_bytes(self, ws: Any, payload: bytes) -> None:
        """Send bytes to a single websocket, catching errors."""
        try:
            await ws.send_bytes(payload)
        except Exception:
            self._websockets.discard(ws)

    def acknowledge_data(self, char_count: int) -> None:
        """Client acknowledges processing char_count bytes.

        Called from the WebSocket handler when the client sends an 'ack' message.
        """
        self._unacknowledged_bytes = max(0, self._unacknowledged_bytes - char_count)

        if self._paused and self._unacknowledged_bytes < self.FLOW_CONTROL_RESUME:
            self._resume_output()

        # Reset ack timeout
        if self._ack_timeout_handle:
            self._ack_timeout_handle.cancel()
        loop = asyncio.get_event_loop()
        self._ack_timeout_handle = loop.call_later(5.0, self._ack_timeout_fallback)

    def _ack_timeout_fallback(self) -> None:
        """If no ack received for 5s, assume client is dead and resume."""
        logger.warning(
            "Flow control ack timeout for session %s, resuming output",
            self.session_id,
        )
        self._unacknowledged_bytes = 0
        if self._paused:
            self._resume_output()

    def _pause_output(self) -> None:
        """Pause reading from PTY due to flow control."""
        self._paused = True
        self._stop_reading()
        logger.debug(
            "Paused output for session %s (%d unacked)",
            self.session_id,
            self._unacknowledged_bytes,
        )

    def _resume_output(self) -> None:
        """Resume reading from PTY."""
        self._paused = False
        self._start_reading()
        logger.debug("Resumed output for session %s", self.session_id)

    def get_buffer(self) -> bytes:
        """Get buffered output for replay."""
        return b"".join(self._output_buffer)

    def _handle_eof(self) -> None:
        """Handle PTY EOF: process died, close websockets to force reconnect."""
        self._stop_reading()
        # Mark process as done so is_alive() returns False
        if self.process is not None and self.process.returncode is None:
            # Force returncode to a non-None value since the process is dead
            # but asyncio.subprocess may not have set it yet
            try:
                self.process._transport.close()  # type: ignore[attr-defined]
            except Exception:
                pass
        # Close all websockets to force frontend reconnection
        dead_sockets = set(self._websockets)
        self._websockets.clear()
        for ws in dead_sockets:
            try:
                asyncio.create_task(
                    ws.close(code=4001, reason="Session process exited")
                )
            except Exception:
                pass
        logger.info("Session %s EOF handled, websockets closed", self.session_id)

    async def write_input(self, data: bytes) -> None:
        """Write input to the PTY master."""
        if self._master_fd is None or self._closed:
            return
        try:
            os.write(self._master_fd, data)
            self.last_activity = time.time()
        except (OSError, IOError) as exc:
            logger.debug("PTY write error for session %s: %s", self.session_id, exc)
            self._handle_eof()

    def _set_terminal_size(self, cols: int, rows: int) -> None:
        """Set the terminal size using TIOCSWINSZ."""
        if self._master_fd is None:
            logger.warning("Cannot resize: master_fd is None (session not started)")
            return
        TIOCSWINSZ = 0x5414
        size = struct.pack("HHHH", rows, cols, 0, 0)
        try:
            fcntl.ioctl(self._master_fd, TIOCSWINSZ, size)
            logger.debug("Resized PTY to %sx%s (fd=%s)", cols, rows, self._master_fd)
        except (OSError, IOError) as e:
            logger.error("Failed to resize PTY: %s", e)

    async def resize(self, cols: int, rows: int) -> None:
        """Resize the terminal."""
        if self._closed:
            logger.warning("Cannot resize: session is closed")
            return

        if cols == self._cols and rows == self._rows:
            return

        self._cols = cols
        self._rows = rows
        logger.debug(
            "resize() called for session %s: %sx%s", self.session_id, cols, rows
        )
        self._set_terminal_size(cols, rows)

        # Send SIGWINCH to docker exec process
        if self.process and self.process.pid:
            try:
                os.kill(self.process.pid, signal.SIGWINCH)
            except ProcessLookupError:
                logger.warning("docker exec process %s not found", self.process.pid)
            except Exception as e:
                logger.warning("Failed to send SIGWINCH: %s", e)

    async def reset(self) -> None:
        """Reset the session by killing the process and clearing state."""
        self.status = "resetting"
        await self.close()
        self._closed = False
        self._output_buffer.clear()
        self._buffer_size = 0
        self._websockets.clear()
        self._batch_buffer.clear()
        self._batch_timer = None
        self._unacknowledged_bytes = 0
        self._paused = False
        self._read_handler_set = False
        self.process = None
        self._master_fd = None
        self.status = "active"

    async def close(self) -> None:
        """Close the session and cleanup."""
        if self._closed:
            return
        self._closed = True
        self.status = "closed"

        self._stop_reading()

        if self._batch_timer:
            self._batch_timer.cancel()
            self._batch_timer = None

        if self._ack_timeout_handle:
            self._ack_timeout_handle.cancel()
            self._ack_timeout_handle = None

        if self._master_fd is not None:
            try:
                os.close(self._master_fd)
            except OSError:
                pass
            self._master_fd = None

        if self.process is not None:
            try:
                self.process.kill()
                await asyncio.wait_for(self.process.wait(), timeout=2.0)
            except (asyncio.TimeoutError, ProcessLookupError):
                pass

    def is_alive(self) -> bool:
        """Check if the session process is still running."""
        if self.process is None:
            return False
        return self.process.returncode is None

    def is_idle(self) -> bool:
        """Check if the session has been idle for too long."""
        if self._websockets:
            return False
        return time.time() - self.last_activity > self.IDLE_TIMEOUT

    def attach_websocket(self, websocket: Any) -> None:
        """Attach a WebSocket to this session."""
        self._websockets.add(websocket)
        self.last_activity = time.time()

    def detach_websocket(self, websocket: Any) -> None:
        """Detach a WebSocket from this session."""
        self._websockets.discard(websocket)

    def has_websockets(self) -> bool:
        """Check if any WebSockets are attached."""
        return len(self._websockets) > 0

    async def send_to_all(self, data: bytes) -> None:
        """Send data to all attached WebSockets (used for control messages)."""
        dead_sockets = set()
        for ws in self._websockets:
            try:
                await ws.send_bytes(data)
            except Exception:
                dead_sockets.add(ws)
        for ws in dead_sockets:
            self._websockets.discard(ws)

    async def read_output(self) -> bytes:
        """Legacy method: read output synchronously.

        With event-driven I/O, output is automatically sent to websockets.
        This method returns any buffered data for callers that poll.
        """
        return b""
