"""
FEATURE-005: Interactive Console v4.0

OutputBuffer: Circular buffer for terminal output (10KB)
PersistentSession: PTY wrapper with session persistence
SessionManager: Session lifecycle management
PTYSession: PTY process wrapper
"""
import os
import re
import threading
import time
import uuid
from collections import deque
from datetime import datetime
from typing import Dict, Optional, Any, Callable

from x_ipe.tracing import x_ipe_tracing


# Constants for session management
BUFFER_MAX_CHARS = 10240  # 10KB limit for output buffer
SESSION_TIMEOUT = 3600   # 1 hour in seconds (fallback for legacy)
HEARTBEAT_TIMEOUT = 120  # 2 minutes — session considered dead if no heartbeat
CLEANUP_INTERVAL = 60    # 1 minute for cleanup task (faster heartbeat detection)

# ANSI escape sequence pattern for stripping terminal control codes
_ANSI_RE = re.compile(r'\x1b\[[0-9;]*[a-zA-Z]|\x1b\][^\x07]*\x07|\x1b[()][AB012]')

# Shell prompt suffixes used to detect idle state
SHELL_PROMPT_SUFFIXES = ('$ ', '% ', '> ', '# ')


def strip_ansi(text: str) -> str:
    """Remove ANSI escape sequences from terminal output."""
    return _ANSI_RE.sub('', text)


class OutputBuffer:
    """
    Circular buffer for terminal output.
    Uses deque with maxlen for automatic circular behavior.
    Stores up to 10KB of output for replay on reconnection.
    """
    
    def __init__(self, max_chars: int = BUFFER_MAX_CHARS):
        self._buffer: deque = deque(maxlen=max_chars)
    
    @x_ipe_tracing()
    def append(self, data: str) -> None:
        """Append data character by character to maintain limit."""
        for char in data:
            self._buffer.append(char)
    
    @x_ipe_tracing()
    def get_contents(self) -> str:
        """Get all buffered content as string."""
        return ''.join(self._buffer)
    
    @x_ipe_tracing()
    def clear(self) -> None:
        """Clear the buffer."""
        self._buffer.clear()
    
    def __len__(self) -> int:
        return len(self._buffer)


class PersistentSession:
    """
    Terminal session that persists across WebSocket disconnections.
    
    Wraps PTYSession with:
    - Output buffer for replay on reconnection
    - State tracking (connected/disconnected)
    - Expiry tracking for 1-hour timeout
    """
    
    def __init__(self, session_id: str):
        self.session_id = session_id
        self.name: str = session_id
        self.pty_session: Optional[Any] = None
        self.output_buffer = OutputBuffer()
        self.socket_sid: Optional[str] = None
        self.emit_callback: Optional[Callable[[str], None]] = None
        self.disconnect_time: Optional[datetime] = None
        self.state = 'disconnected'
        self.created_at = datetime.now()
        self._last_output_time: float = 0.0
        self.last_heartbeat: float = time.time()
        self._lock = threading.Lock()
    
    @x_ipe_tracing()
    def start_pty(self, rows: int = 24, cols: int = 80) -> None:
        """Start the underlying PTY process."""
        def buffered_emit(data: str) -> None:
            # Always buffer output
            self.output_buffer.append(data)
            self._last_output_time = time.time()
            # Emit under lock to avoid TOCTOU race with detach()
            with self._lock:
                if self.emit_callback and self.state == 'connected':
                    try:
                        self.emit_callback(data)
                    except Exception:
                        pass
        
        self.pty_session = PTYSession(self.session_id, buffered_emit)
        self.pty_session.start(rows, cols)
    
    @x_ipe_tracing()
    def attach(self, socket_sid: str, emit_callback: Callable[[str], None]) -> None:
        """Attach a WebSocket connection to this session."""
        with self._lock:
            self.socket_sid = socket_sid
            self.emit_callback = emit_callback
            self.state = 'connected'
            self.disconnect_time = None
            self.last_heartbeat = time.time()
    
    @x_ipe_tracing()
    def detach(self) -> None:
        """Detach WebSocket, keeping PTY alive for reconnection."""
        with self._lock:
            self.socket_sid = None
            self.emit_callback = None
            self.state = 'disconnected'
            self.disconnect_time = datetime.now()
    
    @x_ipe_tracing()
    def heartbeat(self) -> None:
        """Update heartbeat timestamp — called by frontend ping."""
        self.last_heartbeat = time.time()
    
    @x_ipe_tracing()
    def get_buffer(self) -> str:
        """Get buffered output for replay."""
        return self.output_buffer.get_contents()
    
    @x_ipe_tracing()
    def write(self, data: str) -> None:
        """Write input to PTY."""
        if self.pty_session:
            self.pty_session.write(data)
    
    @x_ipe_tracing()
    def resize(self, rows: int, cols: int) -> None:
        """Resize the PTY."""
        if self.pty_session:
            self.pty_session._set_size(rows, cols)
    
    @x_ipe_tracing()
    def is_expired(self, timeout_seconds: int = SESSION_TIMEOUT) -> bool:
        """Check if session has expired based on heartbeat.
        
        A session is expired if no heartbeat has been received within
        HEARTBEAT_TIMEOUT, regardless of connected/disconnected state.
        This catches both graceful disconnects and crashed frontends.
        """
        heartbeat_age = time.time() - self.last_heartbeat
        return heartbeat_age > HEARTBEAT_TIMEOUT
    
    @x_ipe_tracing()
    def is_idle(self, idle_timeout: float = 2.0) -> bool:
        """Check if session is idle (shell waiting for input, no subprocess).

        Primary: OS-level check via tcgetpgrp — determines if the shell
        (not a child process like vim/build) is the foreground process.
        Fallback: prompt-suffix matching on the output buffer when the
        PTY fd is unavailable.
        Both paths require no output for *idle_timeout* seconds.
        """
        if self.state != 'connected':
            return False
        if time.time() - self._last_output_time < idle_timeout:
            return False
        # Primary: process-based detection via PTY
        if self.pty_session and self.pty_session.isalive():
            return self.pty_session.is_shell_foreground()
        # Fallback: prompt-suffix matching on buffer
        contents = self.output_buffer.get_contents()
        if not contents:
            return False
        lines = contents.rstrip('\n\r').split('\n')
        last_line = strip_ansi(lines[-1]) if lines else ''
        return any(last_line.endswith(s) for s in SHELL_PROMPT_SUFFIXES)

    @x_ipe_tracing()
    def close(self) -> None:
        """Close session and cleanup resources."""
        if self.pty_session:
            self.pty_session.close()
            self.pty_session = None
        self.output_buffer.clear()


class SessionManager:
    """
    Manages persistent terminal sessions.
    Singleton pattern - one instance per application.
    """
    
    def __init__(self):
        self.sessions: Dict[str, PersistentSession] = {}
        self._lock = threading.Lock()
        self._cleanup_timer: Optional[threading.Timer] = None
        self._running = False
    
    @x_ipe_tracing()
    def create_session(self, emit_callback: Callable[[str], None],
                       rows: int = 24, cols: int = 80) -> str:
        """Create new persistent session, returns session_id."""
        session_id = str(uuid.uuid4())
        session = PersistentSession(session_id)
        session.start_pty(rows, cols)
        session.attach(session_id, emit_callback)
        
        with self._lock:
            self.sessions[session_id] = session
        
        return session_id
    
    @x_ipe_tracing()
    def get_session(self, session_id: str) -> Optional[PersistentSession]:
        """Get session by ID."""
        with self._lock:
            return self.sessions.get(session_id)
    
    @x_ipe_tracing()
    def has_session(self, session_id: str) -> bool:
        """Check if session exists."""
        with self._lock:
            return session_id in self.sessions
    
    @x_ipe_tracing()
    def remove_session(self, session_id: str) -> None:
        """Remove and close a session."""
        with self._lock:
            session = self.sessions.pop(session_id, None)
        if session:
            session.close()
    
    @x_ipe_tracing()
    def cleanup_expired(self) -> int:
        """Remove expired sessions. Returns count removed."""
        expired_ids = []
        with self._lock:
            for session_id, session in self.sessions.items():
                if session.is_expired():
                    expired_ids.append(session_id)
        
        for session_id in expired_ids:
            self.remove_session(session_id)
        
        return len(expired_ids)
    
    @x_ipe_tracing()
    def list_sessions(self) -> list:
        """List all sessions with their state info."""
        result = []
        with self._lock:
            for session_id, session in self.sessions.items():
                result.append({
                    'session_id': session_id,
                    'state': session.state,
                    'created_at': session.created_at.isoformat(),
                    'socket_sid': session.socket_sid,
                })
        return result
    
    @x_ipe_tracing()
    def destroy_sessions(self, session_ids: list) -> int:
        """Destroy specific sessions by ID. Returns count destroyed."""
        count = 0
        for session_id in session_ids:
            if self.has_session(session_id):
                self.remove_session(session_id)
                count += 1
        return count
    
    @x_ipe_tracing()
    def find_idle_session(self) -> Optional[PersistentSession]:
        """Find the first idle connected session."""
        with self._lock:
            for session in self.sessions.values():
                if session.is_idle():
                    return session
        return None
    
    @x_ipe_tracing()
    def claim_session_for_action(self, session_id: str, wf_name: str, action_name: str) -> bool:
        """Rename a session for a workflow action. Returns True if successful."""
        with self._lock:
            session = self.sessions.get(session_id)
            if not session or session.state != 'connected':
                return False
            session.name = f"wf-{wf_name}-{action_name}"
            return True
    
    @x_ipe_tracing()
    def start_cleanup_task(self) -> None:
        """Start background cleanup task (every 5 minutes)."""
        self._running = True
        self._schedule_cleanup()
    
    @x_ipe_tracing()
    def stop_cleanup_task(self) -> None:
        """Stop the cleanup task."""
        self._running = False
        if self._cleanup_timer:
            self._cleanup_timer.cancel()
    
    def _schedule_cleanup(self) -> None:
        if not self._running:
            return
        self._cleanup_timer = threading.Timer(
            CLEANUP_INTERVAL, self._cleanup_and_reschedule
        )
        self._cleanup_timer.daemon = True
        self._cleanup_timer.start()
    
    def _cleanup_and_reschedule(self) -> None:
        try:
            self.cleanup_expired()
        finally:
            self._schedule_cleanup()


class PTYSession:
    """
    PTY session using native pty.fork() approach.
    
    Based on working sample-root implementation.
    Manages a single pseudo-terminal process with:
    - Background reader thread for output
    - Write method for input
    - Resize support
    """
    
    def __init__(self, session_id: str, output_callback: Callable[[str], None]):
        self.session_id = session_id
        self.output_callback = output_callback
        self.fd: Optional[int] = None
        self.pid: Optional[int] = None
        self._running = False
        self._reader_thread: Optional[threading.Thread] = None
        self.rows = 24
        self.cols = 80
    
    @x_ipe_tracing()
    def start(self, rows: int = 24, cols: int = 80) -> None:
        """Spawn PTY with shell and start output reader."""
        import pty
        import select
        
        self.rows = rows
        self.cols = cols
        
        # Fork a new process with a PTY
        pid, fd = pty.fork()
        
        if pid == 0:
            # Child process - execute shell
            env = os.environ.copy()
            env['TERM'] = 'xterm-256color'
            env['LC_ALL'] = 'en_US.UTF-8'
            env['LANG'] = 'en_US.UTF-8'
            
            # Try to find shell
            shell = os.environ.get('SHELL', '/bin/zsh')
            if not os.path.exists(shell):
                shell = '/bin/zsh'
            if not os.path.exists(shell):
                shell = '/bin/bash'
            
            os.execvpe(shell, [shell], env)
        else:
            # Parent process
            self.fd = fd
            self.pid = pid
            self._running = True
            
            # Set terminal size
            self._set_size(rows, cols)
            
            # Start background thread to read output
            self._reader_thread = threading.Thread(
                target=self._read_loop,
                daemon=True
            )
            self._reader_thread.start()
    
    def _read_loop(self) -> None:
        """Background thread to read PTY output."""
        import select
        import codecs
        
        # Use incremental decoder to properly handle multi-byte UTF-8 sequences
        # that may be split across read() calls
        decoder = codecs.getincrementaldecoder('utf-8')('replace')
        
        while self._running and self.fd is not None:
            try:
                # Use select to wait for data with timeout
                r, _, _ = select.select([self.fd], [], [], 0.1)
                if self.fd in r:
                    data = os.read(self.fd, 4096)
                    if data:
                        # Use incremental decoder - it buffers incomplete sequences
                        text = decoder.decode(data)
                        if text:
                            # Emit output to client
                            self.output_callback(text)
                    else:
                        # EOF - process exited
                        self._running = False
                        break
            except (OSError, IOError):
                # FD closed or error
                self._running = False
                break
        
        # Flush any remaining bytes in the decoder
        try:
            final = decoder.decode(b'', final=True)
            if final:
                self.output_callback(final)
        except Exception:
            pass
    
    @x_ipe_tracing()
    def write(self, data: str) -> None:
        """Write input to PTY."""
        if self.fd is not None:
            os.write(self.fd, data.encode('utf-8'))
    
    def _set_size(self, rows: int, cols: int) -> None:
        """Set the terminal size using ioctl."""
        if self.fd is not None:
            import fcntl
            import struct
            import termios
            
            # Ensure rows and cols are integers with defaults (may come as None or strings)
            self.rows = int(rows) if rows is not None else 24
            self.cols = int(cols) if cols is not None else 80
            winsize = struct.pack('HHHH', self.rows, self.cols, 0, 0)
            fcntl.ioctl(self.fd, termios.TIOCSWINSZ, winsize)
    
    @x_ipe_tracing()
    def close(self) -> None:
        """Terminate PTY session and cleanup."""
        import signal
        
        self._running = False
        
        # Close file descriptor
        if self.fd is not None:
            try:
                os.close(self.fd)
            except OSError:
                pass
            self.fd = None
        
        # Kill child process
        if self.pid is not None:
            try:
                os.kill(self.pid, signal.SIGTERM)
                os.waitpid(self.pid, os.WNOHANG)
            except (OSError, ChildProcessError):
                pass
            self.pid = None
    
    @x_ipe_tracing()
    def is_shell_foreground(self) -> bool:
        """Check if the shell is the foreground process of the PTY.

        Uses os.tcgetpgrp() to get the foreground process group ID and
        compares it to the shell's PID (which is also its PGID after
        pty.fork + setsid). When the shell spawns a foreground command
        (e.g. vim, a build script), the command's process group becomes
        the foreground group, so this returns False.
        """
        if self.fd is None or self.pid is None:
            return False
        try:
            fg_pgid = os.tcgetpgrp(self.fd)
            shell_pgid = os.getpgid(self.pid)
            return fg_pgid == shell_pgid
        except OSError:
            return False

    @x_ipe_tracing()
    def isalive(self) -> bool:
        """Check if the PTY process is still running."""
        return self._running and self.fd is not None


# Global singleton
session_manager = SessionManager()
