"""PTY-backed terminal emulator widget using pyte + Rich.

Spawns a subprocess inside a real PTY via Python's pty module, feeds
output into pyte for ANSI parsing, and renders the screen as Rich Text
objects on a timer (10 FPS refresh).

Linux/macOS only — pty.openpty() and loop.add_reader() are POSIX APIs.
This is fine because the installer runs on the target VPS (always Linux).

Key concepts:
  - master_fd: our side of the PTY — we read output and write keystrokes
  - slave_fd: the subprocess's side — it thinks it's a real terminal
  - pyte.HistoryScreen: maintains the virtual terminal state (cursor,
    colors, scrollback) from raw ANSI byte sequences
  - Rich Text: we render pyte's screen lines as styled Text objects
"""

from __future__ import annotations

import asyncio
import fcntl
import logging
import os
import pty
import signal
import struct
import subprocess
import termios
from collections import deque
from typing import Optional

import pyte
from rich.style import Style
from rich.text import Text
from textual.widget import Widget
from textual.strip import Strip
from textual.geometry import Size

logger = logging.getLogger(__name__)

# How often we re-render the terminal screen (seconds)
_REFRESH_INTERVAL = 0.1  # 10 FPS

# Default scrollback history (lines above the visible screen)
_HISTORY_SIZE = 5000

# Max bytes to read from PTY per event loop callback
_READ_SIZE = 65536

# pyte color name -> Rich color mapping
_PYTE_COLORS = {
    "black": "black",
    "red": "red",
    "green": "green",
    "brown": "yellow",
    "blue": "blue",
    "magenta": "magenta",
    "cyan": "cyan",
    "white": "white",
    "default": "default",
    # Bright variants
    "brightblack": "bright_black",
    "brightred": "bright_red",
    "brightgreen": "bright_green",
    "brightbrown": "bright_yellow",
    "brightblue": "bright_blue",
    "brightmagenta": "bright_magenta",
    "brightcyan": "bright_cyan",
    "brightwhite": "bright_white",
}


def _pyte_char_to_style(char: pyte.screens.Char) -> Style:
    """Convert a pyte Char's attributes to a Rich Style."""
    fg = _PYTE_COLORS.get(char.fg, None) if char.fg != "default" else None
    bg = _PYTE_COLORS.get(char.bg, None) if char.bg != "default" else None

    # Handle 256-color and truecolor as hex strings from pyte
    if char.fg and char.fg not in _PYTE_COLORS and char.fg != "default":
        fg = char.fg  # pyte gives hex like "ff5500"
        if len(fg) == 6 and all(c in "0123456789abcdef" for c in fg.lower()):
            fg = f"#{fg}"
    if char.bg and char.bg not in _PYTE_COLORS and char.bg != "default":
        bg = char.bg
        if len(bg) == 6 and all(c in "0123456789abcdef" for c in bg.lower()):
            bg = f"#{bg}"

    return Style(
        color=fg,
        bgcolor=bg,
        bold=char.bold,
        italic=char.italics,
        underline=char.underscore,
        reverse=char.reverse,
        strike=char.strikethrough,
    )


class TerminalWidget(Widget):
    """Interactive terminal emulator backed by a real PTY.

    Usage:
        terminal = TerminalWidget(id="terminal")
        await terminal.spawn("bash")
        terminal.write_input("ls -la\\n")
    """

    DEFAULT_CSS = """
    TerminalWidget {
        height: 100%;
        background: #0c0c0c;
    }
    """

    def __init__(
        self,
        *,
        scrollback: int = _HISTORY_SIZE,
        name: str | None = None,
        id: str | None = None,
        classes: str | None = None,
    ) -> None:
        super().__init__(name=name, id=id, classes=classes)
        self.can_focus = False  # Focus managed by App via on_key()

        self._scrollback = scrollback

        # PTY state
        self._master_fd: Optional[int] = None
        self._slave_fd: Optional[int] = None
        self._child_pid: Optional[int] = None
        self._process: Optional[subprocess.Popen] = None
        self._exit_code: Optional[int] = None

        # pyte virtual terminal
        self._screen: Optional[pyte.HistoryScreen] = None
        self._stream: Optional[pyte.Stream] = None

        # Rolling output history for agent monitoring
        self._output_history: deque[str] = deque(maxlen=500)

        # Rendering state
        self._dirty = True  # Force initial render
        self._refresh_timer = None

        # Track size for PTY resize
        self._term_cols = 80
        self._term_rows = 24

    # ------------------------------------------------------------------
    # Public API
    # ------------------------------------------------------------------

    async def spawn(self, cmd: str, env: dict | None = None) -> None:
        """Spawn a subprocess in a PTY running the given command.

        Args:
            cmd: Shell command to execute (e.g., "bash" or "openclaw onboard")
            env: Optional environment variables (merged with os.environ)
        """
        if self._master_fd is not None:
            logger.warning("TerminalWidget: spawn() called but PTY already active")
            return

        # Compute terminal size from widget dimensions
        size = self.size
        self._term_cols = max(size.width, 40)
        self._term_rows = max(size.height, 10)

        # Create PTY pair
        self._master_fd, self._slave_fd = pty.openpty()

        # Set terminal size on slave fd
        self._set_pty_size(self._term_cols, self._term_rows)

        # Initialize pyte with matching dimensions
        self._screen = pyte.HistoryScreen(
            self._term_cols, self._term_rows,
            history=self._scrollback,
        )
        self._screen.set_mode(pyte.modes.LNM)  # Line feed = newline
        self._stream = pyte.Stream(self._screen)

        # Build environment
        spawn_env = dict(os.environ)
        spawn_env["TERM"] = "xterm-256color"
        spawn_env["COLUMNS"] = str(self._term_cols)
        spawn_env["LINES"] = str(self._term_rows)
        if env:
            spawn_env.update(env)

        # Spawn subprocess with slave as stdin/stdout/stderr
        self._process = subprocess.Popen(
            cmd,
            shell=True,
            stdin=self._slave_fd,
            stdout=self._slave_fd,
            stderr=self._slave_fd,
            env=spawn_env,
            preexec_fn=os.setsid,  # New session so Ctrl+C goes to child
        )
        self._child_pid = self._process.pid

        # Close slave fd in parent — child owns it now
        os.close(self._slave_fd)
        self._slave_fd = None

        # Set master fd to non-blocking
        flags = fcntl.fcntl(self._master_fd, fcntl.F_GETFL)
        fcntl.fcntl(self._master_fd, fcntl.F_SETFL, flags | os.O_NONBLOCK)

        # Register async reader for PTY output
        loop = asyncio.get_event_loop()
        loop.add_reader(self._master_fd, self._on_pty_data)

        # Start refresh timer for rendering
        self._refresh_timer = self.set_interval(
            _REFRESH_INTERVAL, self._refresh_screen
        )

        logger.info(
            "TerminalWidget: spawned PID %d (%dx%d): %s",
            self._child_pid, self._term_cols, self._term_rows, cmd,
        )

    def write_input(self, data: str) -> None:
        """Send keystrokes/text to the PTY subprocess.

        Args:
            data: Raw string to write (e.g., "ls\\n" or "\\x03" for Ctrl+C)
        """
        if self._master_fd is None:
            return
        try:
            os.write(self._master_fd, data.encode("utf-8"))
        except OSError as exc:
            logger.debug("TerminalWidget: write_input error: %s", exc)

    def get_screen_text(self) -> str:
        """Get the current terminal screen as plain text.

        Returns the visible screen content (no scrollback), useful for
        the agent to "look" at what's currently displayed.
        """
        if self._screen is None:
            return ""
        lines = []
        for row in range(self._screen.lines):
            line_chars = []
            for col in range(self._screen.columns):
                char = self._screen.buffer[row][col]
                line_chars.append(char.data)
            lines.append("".join(line_chars).rstrip())
        return "\n".join(lines)

    def get_recent_output(self, max_lines: int = 200) -> str:
        """Get recent terminal output from the rolling history buffer.

        This is the agent's "stream of consciousness" view — everything
        the terminal has produced, not just what's currently visible.
        """
        recent = list(self._output_history)[-max_lines:]
        return "\n".join(recent)

    @property
    def is_alive(self) -> bool:
        """Whether the subprocess is still running."""
        if self._process is None:
            return False
        return self._process.poll() is None

    @property
    def exit_code(self) -> int | None:
        """Exit code of the subprocess, or None if still running."""
        if self._process is None:
            return self._exit_code
        code = self._process.poll()
        if code is not None:
            self._exit_code = code
        return self._exit_code

    @property
    def child_pid(self) -> int | None:
        """PID of the spawned subprocess."""
        return self._child_pid

    # ------------------------------------------------------------------
    # PTY I/O (async event loop callbacks)
    # ------------------------------------------------------------------

    def _on_pty_data(self) -> None:
        """Called by the event loop when data is available on master_fd."""
        if self._master_fd is None:
            return

        try:
            data = os.read(self._master_fd, _READ_SIZE)
        except OSError:
            # PTY closed — child probably exited
            self._cleanup_reader()
            return

        if not data:
            self._cleanup_reader()
            return

        # Feed raw bytes into pyte for ANSI parsing
        try:
            text = data.decode("utf-8", errors="replace")
            self._stream.feed(text)
        except Exception:
            logger.exception("TerminalWidget: pyte feed error")

        # Append to rolling output history (line by line)
        for line in text.split("\n"):
            stripped = line.rstrip("\r")
            if stripped:
                self._output_history.append(stripped)

        self._dirty = True

    def _cleanup_reader(self) -> None:
        """Remove the event loop reader for the PTY fd."""
        if self._master_fd is not None:
            try:
                loop = asyncio.get_event_loop()
                loop.remove_reader(self._master_fd)
            except Exception:
                pass

    # ------------------------------------------------------------------
    # Rendering (Rich Text from pyte screen)
    # ------------------------------------------------------------------

    def _refresh_screen(self) -> None:
        """Periodic refresh — triggers re-render if screen changed."""
        # Also check if child has exited
        if self._process is not None and self._process.poll() is not None:
            if self._exit_code is None:
                self._exit_code = self._process.poll()
                self._dirty = True
                logger.info(
                    "TerminalWidget: child exited with code %d",
                    self._exit_code,
                )

        if self._dirty:
            self._dirty = False
            self.refresh()

    def get_content_width(self, container: Size, viewport: Size) -> int:
        return container.width

    def get_content_height(self, container: Size, viewport: Size, width: int) -> int:
        return container.height

    def render_line(self, y: int) -> Strip:
        """Render a single line of the terminal screen as a Rich Strip."""
        if self._screen is None:
            # Not yet spawned — blank line
            return Strip([])

        if y >= self._screen.lines:
            return Strip([])

        line_text = Text()
        row = y
        col = 0
        while col < self._screen.columns:
            char = self._screen.buffer[row][col]
            style = _pyte_char_to_style(char)
            data = char.data if char.data else " "
            line_text.append(data, style=style)
            col += 1

        # Show cursor position
        if (
            self._screen.cursor.y == row
            and 0 <= self._screen.cursor.x < self._screen.columns
        ):
            cursor_pos = self._screen.cursor.x
            if cursor_pos < len(line_text):
                line_text.stylize("reverse", cursor_pos, cursor_pos + 1)

        return Strip(line_text.render(self.app.console))

    # ------------------------------------------------------------------
    # Resize handling
    # ------------------------------------------------------------------

    def on_resize(self, event) -> None:
        """When the widget resizes, update the PTY and pyte screen."""
        new_cols = max(event.size.width, 40)
        new_rows = max(event.size.height, 10)

        if new_cols == self._term_cols and new_rows == self._term_rows:
            return

        self._term_cols = new_cols
        self._term_rows = new_rows

        # Resize PTY
        if self._master_fd is not None:
            self._set_pty_size(new_cols, new_rows)
            # Send SIGWINCH to child process group
            if self._child_pid is not None and self.is_alive:
                try:
                    os.kill(self._child_pid, signal.SIGWINCH)
                except ProcessLookupError:
                    pass

        # Resize pyte screen
        if self._screen is not None:
            self._screen.resize(new_rows, new_cols)

        self._dirty = True

    def _set_pty_size(self, cols: int, rows: int) -> None:
        """Set the PTY window size via ioctl TIOCSWINSZ."""
        fd = self._slave_fd if self._slave_fd is not None else self._master_fd
        if fd is None:
            return
        try:
            winsize = struct.pack("HHHH", rows, cols, 0, 0)
            fcntl.ioctl(fd, termios.TIOCSWINSZ, winsize)
        except OSError as exc:
            logger.debug("TerminalWidget: set_pty_size error: %s", exc)

    # ------------------------------------------------------------------
    # Cleanup
    # ------------------------------------------------------------------

    def on_unmount(self) -> None:
        """Clean up PTY and subprocess on widget removal."""
        self._cleanup()

    def _cleanup(self) -> None:
        """Release all PTY resources."""
        # Stop refresh timer
        if self._refresh_timer is not None:
            self._refresh_timer.stop()
            self._refresh_timer = None

        # Remove event loop reader
        self._cleanup_reader()

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

        # Kill subprocess if still running
        if self._process is not None and self._process.poll() is None:
            try:
                os.killpg(os.getpgid(self._process.pid), signal.SIGTERM)
            except (ProcessLookupError, OSError):
                pass
            try:
                self._process.wait(timeout=5)
            except subprocess.TimeoutExpired:
                try:
                    os.killpg(os.getpgid(self._process.pid), signal.SIGKILL)
                except (ProcessLookupError, OSError):
                    pass
