"""
PTY-based end-to-end test harness for pyos.

Runs the real Application in a forked child process against a pseudo-terminal,
with pyte parsing ANSI output on the parent side.  This exercises the full
stack — blessed Terminal, BlessedScreen rendering, real threading, real timers
— while remaining headless and CI-friendly.

Requires: pyte (``pip install pyte``)
Unix-only (uses ``pty.fork()``).
"""

import os
import sys
import time
import select
import signal
import struct
import fcntl
import termios
import threading

import pyte

from pyos import Keys

# ---------------------------------------------------------------------------
# Key-code to escape-sequence mapping
# ---------------------------------------------------------------------------

_KEY_ESCAPE_MAP = {
    Keys.UP: b'\x1b[A',
    Keys.DOWN: b'\x1b[B',
    Keys.LEFT: b'\x1b[D',
    Keys.RIGHT: b'\x1b[C',
    Keys.HOME: b'\x1b[H',
    Keys.END: b'\x1b[F',
    Keys.ENTER: b'\r',
    Keys.ESC: b'\x1b',
    Keys.BACKSPACE: b'\x7f',
    Keys.TAB: b'\t',
    Keys.F1: b'\x1bOP',
    Keys.F5: b'\x1b[15~',
}


class PtyHarness:
    """End-to-end test harness that runs an Application over a real PTY.

    Usage::

        with PtyHarness(MyActivity) as h:
            h.wait_for_text("My Title")
            h.send_key(Keys.DOWN)
            assert h.find_text("selected item")
    """

    def __init__(self, activity_cls, app_cls=None, rows=24, cols=80,
                 enable_kitty=False):
        self.activity_cls = activity_cls
        self.app_cls = app_cls
        self.rows = rows
        self.cols = cols
        self.enable_kitty = enable_kitty

        self._pid = None
        self._master_fd = None
        self._screen = pyte.Screen(cols, rows)
        self._stream = pyte.Stream(self._screen)
        self._lock = threading.Lock()  # protects _screen access
        self._reader_thread = None
        self._reader_stop = threading.Event()

    # -- context manager ---------------------------------------------------

    def __enter__(self):
        self.start()
        return self

    def __exit__(self, exc_type, exc_val, exc_tb):
        self.shutdown()
        return False

    # -- lifecycle ---------------------------------------------------------

    def start(self):
        """Fork a child process with a PTY; start the Application there."""
        pid, master_fd = os.forkpty()

        if pid == 0:
            # --- child process ---
            self._run_child()
            # _run_child calls os._exit; should never reach here
            os._exit(1)

        # --- parent process ---
        self._pid = pid
        self._master_fd = master_fd

        # Set the PTY size so blessed sees the right dimensions
        self._set_pty_size(self.rows, self.cols)

        # Start background reader thread
        self._reader_stop.clear()
        self._reader_thread = threading.Thread(
            target=self._reader_loop, daemon=True
        )
        self._reader_thread.start()

    def _run_child(self):
        """Child process: start the pyos Application."""
        try:
            # After os.forkpty(), fds 0/1/2 are the PTY slave, but Python's
            # sys.stdout / sys.__stdout__ may still be pytest capture wrappers
            # or stale objects from before the fork.  Reconnect them to the
            # real PTY slave file descriptors so blessed and print() output
            # goes through the PTY.
            sys.stdin = open(0, 'r', closefd=False)
            sys.stdout = open(1, 'w', closefd=False)
            sys.stderr = open(2, 'w', closefd=False)
            sys.__stdin__ = sys.stdin
            sys.__stdout__ = sys.stdout
            sys.__stderr__ = sys.stderr

            os.environ["TERM"] = "xterm-256color"
            # Suppress loguru output in child
            os.environ["LOGURU_AUTOINIT"] = "0"

            from pyos.Application import Application

            app_cls = self.app_cls if self.app_cls is not None else Application
            app = app_cls(enable_kitty=self.enable_kitty)
            app.start(self.activity_cls())
        except Exception:
            pass
        finally:
            os._exit(0)

    def shutdown(self):
        """Gracefully stop the child process and clean up."""
        if self._pid is None:
            return

        # 1. Send Ctrl-C
        try:
            os.write(self._master_fd, b'\x03')
        except OSError:
            pass

        # 2. Wait up to 3 seconds for child to exit
        if not self._wait_for_child(timeout=3.0):
            # 3. SIGTERM
            try:
                os.kill(self._pid, signal.SIGTERM)
            except OSError:
                pass
            if not self._wait_for_child(timeout=1.0):
                # 4. SIGKILL
                try:
                    os.kill(self._pid, signal.SIGKILL)
                except OSError:
                    pass
                self._wait_for_child(timeout=2.0)

        # Stop reader thread
        self._reader_stop.set()
        if self._reader_thread is not None:
            self._reader_thread.join(timeout=2.0)
            self._reader_thread = None

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

        self._pid = None

    def _wait_for_child(self, timeout):
        """Poll waitpid until child exits or timeout expires. Returns True if exited."""
        deadline = time.monotonic() + timeout
        while time.monotonic() < deadline:
            try:
                wpid, status = os.waitpid(self._pid, os.WNOHANG)
                if wpid != 0:
                    return True
            except ChildProcessError:
                return True
            time.sleep(0.05)
        return False

    # -- PTY helpers -------------------------------------------------------

    def _set_pty_size(self, rows, cols):
        """Set the PTY window size via ioctl."""
        winsize = struct.pack("HHHH", rows, cols, 0, 0)
        fcntl.ioctl(self._master_fd, termios.TIOCSWINSZ, winsize)

    def _reader_loop(self):
        """Background thread: read PTY output and feed to pyte."""
        master_fd = self._master_fd
        while not self._reader_stop.is_set():
            try:
                ready, _, _ = select.select([master_fd], [], [], 0.05)
            except (ValueError, OSError):
                break
            if ready:
                try:
                    data = os.read(master_fd, 65536)
                except OSError:
                    break
                if not data:
                    break
                with self._lock:
                    self._stream.feed(data.decode("utf-8", errors="replace"))

    # -- input methods -----------------------------------------------------

    def send_key(self, key_code):
        """Write the escape sequence for *key_code* to the PTY."""
        seq = _KEY_ESCAPE_MAP.get(key_code)
        if seq is None:
            # Assume printable ASCII
            if 0 <= key_code <= 127:
                seq = bytes([key_code])
            else:
                raise ValueError(f"No escape sequence for key code {key_code}")
        os.write(self._master_fd, seq)

    def send_kitty_press(self, key_code):
        """Write a Kitty keyboard protocol press sequence (CSI code u)."""
        os.write(self._master_fd, f"\x1b[{key_code}u".encode())

    def send_key_release(self, key_code):
        """Write a kitty keyboard protocol release sequence (CSI code;1:3u)."""
        os.write(self._master_fd, f"\x1b[{key_code};1:3u".encode())

    def send_text(self, text):
        """Write each character of *text* as raw bytes to the PTY."""
        os.write(self._master_fd, text.encode("utf-8"))

    # -- screen inspection -------------------------------------------------

    def get_screen_text(self):
        """Return all pyte screen rows joined by newlines."""
        with self._lock:
            return "\n".join(
                self._screen.display[row].rstrip()
                for row in range(self._screen.lines)
            )

    def get_row_text(self, row):
        """Return a single row's text (trailing spaces stripped)."""
        with self._lock:
            if 0 <= row < self._screen.lines:
                return self._screen.display[row].rstrip()
            return ""

    def find_text(self, needle):
        """Return True if *needle* appears anywhere on the pyte screen."""
        with self._lock:
            for row in range(self._screen.lines):
                if needle in self._screen.display[row]:
                    return True
        return False

    def wait_for_text(self, needle, timeout=5.0):
        """Poll until *needle* appears on screen. Raises TimeoutError."""
        deadline = time.monotonic() + timeout
        while time.monotonic() < deadline:
            if self.find_text(needle):
                return
            time.sleep(0.05)
        raise TimeoutError(
            f"Timed out waiting for {needle!r} after {timeout}s.\n"
            f"Screen content:\n{self.get_screen_text()}"
        )

    def wait_for_no_text(self, needle, timeout=5.0):
        """Poll until *needle* disappears from screen. Raises TimeoutError."""
        deadline = time.monotonic() + timeout
        while time.monotonic() < deadline:
            if not self.find_text(needle):
                return
            time.sleep(0.05)
        raise TimeoutError(
            f"Timed out waiting for {needle!r} to disappear after {timeout}s.\n"
            f"Screen content:\n{self.get_screen_text()}"
        )
