"""
Test harness for pyos: MockScreen + TestApplication.

MockScreen replaces the real curses screen with a 2D text buffer.
TestApplication subclasses Application to provide non-blocking, synchronous
control for tests: start activities, send keys, assert rendered output.
"""

import time
from concurrent.futures import Future
from queue import Empty

from pyos.Application import Application, Segue
from pyos.CentralDispatch import CentralDispatch
from pyos.EventTypes import (
    KeyStroke, KeyRelease, ExceptionOccured, StopApplication,
    MouseDown, MouseUp, MouseClick, MouseScroll, MouseDrag, MouseMove, MouseButton,
)
from pyos.Service import ServiceState
from pyos import Keys
from pyos.Attrs import NORMAL


class MockScreen:
    """Fake curses screen backed by a 2D character buffer."""

    def __init__(self, rows=24, cols=80):
        self.rows = rows
        self.cols = cols
        self.buffer = [[" "] * cols for _ in range(rows)]
        self.attr_buffer = [[NORMAL] * cols for _ in range(rows)]
        self.cursor_y = 0
        self.cursor_x = 0
        self.refresh_count = 0

    # -- curses screen interface --

    def getmaxyx(self):
        return (self.rows, self.cols)

    def addstr(self, y, x, text, attr=NORMAL):
        text = str(text)
        for i, ch in enumerate(text):
            col = x + i
            if col >= self.cols:
                break
            if 0 <= y < self.rows and 0 <= col:
                self.buffer[y][col] = ch
                self.attr_buffer[y][col] = attr
        self.cursor_y = y
        self.cursor_x = min(x + len(text), self.cols)

    def clear(self):
        self.buffer = [[" "] * self.cols for _ in range(self.rows)]
        self.attr_buffer = [[NORMAL] * self.cols for _ in range(self.rows)]

    def refresh(self):
        self.refresh_count += 1

    def move(self, y, x):
        self.cursor_y = y
        self.cursor_x = x

    def clrtoeol(self):
        if 0 <= self.cursor_y < self.rows:
            for col in range(self.cursor_x, self.cols):
                self.buffer[self.cursor_y][col] = " "
                self.attr_buffer[self.cursor_y][col] = NORMAL

    def request_cursor(self, y, x):
        """Track cursor request (mirrors BlessedScreen for test assertions)."""
        self.cursor_y = y
        self.cursor_x = x

    def timeout(self, ms):
        pass

    def getch(self):
        # Sleep briefly to avoid busy-spinning when used in the key monitor loop.
        time.sleep(0.01)
        return -1

    # -- assertion / inspection helpers --

    def get_row_text(self, row):
        """Return the text content of a single row (trailing spaces stripped)."""
        if 0 <= row < self.rows:
            return "".join(self.buffer[row]).rstrip()
        return ""

    def get_all_text(self):
        """Return all rows joined by newlines."""
        return "\n".join(self.get_row_text(r) for r in range(self.rows))

    def find_text(self, needle):
        """Return True if *needle* appears anywhere on screen."""
        for row in range(self.rows):
            line = "".join(self.buffer[row])
            if needle in line:
                return True
        return False

    def find_text_row(self, needle):
        """Return the row index where *needle* first appears, or -1."""
        for row in range(self.rows):
            line = "".join(self.buffer[row])
            if needle in line:
                return row
        return -1

    def get_attr_at(self, row, col):
        """Return the curses attribute stored at (row, col)."""
        if 0 <= row < self.rows and 0 <= col < self.cols:
            return self.attr_buffer[row][col]
        return NORMAL

    def row_has_attr(self, row, attr):
        """Return True if any cell in *row* has the given attribute."""
        if 0 <= row < self.rows:
            return any((a & attr) == attr for a in self.attr_buffer[row])
        return False

    def assert_text_on_screen(self, needle):
        assert self.find_text(needle), (
            f"Expected to find {needle!r} on screen but did not.\n"
            f"Screen content:\n{self.get_all_text()}"
        )

    def assert_text_not_on_screen(self, needle):
        assert not self.find_text(needle), (
            f"Expected {needle!r} to NOT be on screen but it was found.\n"
            f"Screen content:\n{self.get_all_text()}"
        )

    def find_text_at(self, needle):
        """Return (row, col) of first occurrence of *needle*, or None."""
        for row in range(self.rows):
            line = "".join(self.buffer[row])
            col = line.find(needle)
            if col != -1:
                return (row, col)
        return None

    def assert_text_has_attr(self, needle, attr):
        """Assert *needle* is on screen and every character has *attr*."""
        pos = self.find_text_at(needle)
        assert pos is not None, (
            f"Expected to find {needle!r} on screen but did not.\n"
            f"Screen content:\n{self.get_all_text()}"
        )
        row, col = pos
        for i in range(len(needle)):
            actual = self.attr_buffer[row][col + i]
            assert (actual & attr) == attr, (
                f"Character {needle[i]!r} at ({row}, {col + i}) has attr {actual}, "
                f"expected {attr}.\nScreen content:\n{self.get_all_text()}"
            )

    def assert_text_not_has_attr(self, needle, attr):
        """Assert *needle* is on screen and none of its characters have *attr*."""
        pos = self.find_text_at(needle)
        assert pos is not None, (
            f"Expected to find {needle!r} on screen but did not.\n"
            f"Screen content:\n{self.get_all_text()}"
        )
        row, col = pos
        for i in range(len(needle)):
            actual = self.attr_buffer[row][col + i]
            assert (actual & attr) != attr, (
                f"Character {needle[i]!r} at ({row}, {col + i}) has attr {actual}, "
                f"but expected it NOT to have {attr}.\n"
                f"Screen content:\n{self.get_all_text()}"
            )

    def get_text_with_attr(self, attr):
        """Return contiguous text segments painted with *attr*."""
        segments = []
        for row in range(self.rows):
            current = []
            for col in range(self.cols):
                if (self.attr_buffer[row][col] & attr) == attr:
                    current.append(self.buffer[row][col])
                else:
                    if current:
                        segments.append("".join(current))
                        current = []
            if current:
                segments.append("".join(current))
        return segments

    def count_text(self, needle):
        """Count non-overlapping occurrences of *needle* across all rows."""
        count = 0
        for row in range(self.rows):
            line = "".join(self.buffer[row])
            count += line.count(needle)
        return count

    def resize(self, rows, cols):
        """Change screen dimensions, reallocate buffers, preserve fitting content, clamp cursor."""
        new_buffer = [[" "] * cols for _ in range(rows)]
        new_attr = [[NORMAL] * cols for _ in range(rows)]
        for r in range(min(self.rows, rows)):
            for c in range(min(self.cols, cols)):
                new_buffer[r][c] = self.buffer[r][c]
                new_attr[r][c] = self.attr_buffer[r][c]
        self.buffer = new_buffer
        self.attr_buffer = new_attr
        self.rows = rows
        self.cols = cols
        self.cursor_y = min(self.cursor_y, rows - 1)
        self.cursor_x = min(self.cursor_x, cols - 1)

    def dump(self):
        """Print the full screen buffer (useful for debugging)."""
        for r in range(self.rows):
            print(f"{r:3d} |{''.join(self.buffer[r])}|")


class HarnessApplication(Application):
    """Non-blocking Application subclass for deterministic testing."""

    def setup(self):
        """Initialise dispatch queues and subscriptions (mirrors start() minus blocking)."""
        self.teardown_error = None
        self.stop_events = []
        self.shutdown_signal = Future()
        self._original_exception_handler = CentralDispatch.default_exception_handler
        CentralDispatch.default_exception_handler = self._shutdown_app_exception_handler
        self.main_thread = CentralDispatch.create_serial_queue()
        self.background_thread = CentralDispatch.create_serial_queue()
        self.subscribe(event_type=ExceptionOccured, activity=self, callback=self.on_exception)
        # Do NOT subscribe on_key_stroke from Application — it would push
        # LogViewerActivity on F1, which we don't want in tests.

        # Start the key monitor thread just like production does.  This
        # exercises the real threading path: if start_key_monitor() is ever
        # changed to run on background_thread, it will block its single
        # worker and any test waiting on background work will time out.
        self.start_key_monitor()

    def teardown(self):
        """Shut down all running services, then dispatch queues."""
        # Signal shutdown so the key monitor thread exits its loop.
        if not self.shutdown_signal.done():
            self.shutdown_signal.set_result(StopApplication())
        self._stop_all_services()
        try:
            if self.main_thread:
                self.main_thread.finish_work().result(timeout=5)
                self.main_thread.task_threadpool.shutdown(wait=False)
        except Exception as e:
            self.teardown_error = e
        try:
            if self.background_thread:
                self.background_thread.finish_work().result(timeout=5)
                self.background_thread.task_threadpool.shutdown(wait=False)
        except Exception as e:
            if self.teardown_error is None:
                self.teardown_error = e
        CentralDispatch.default_exception_handler = self._original_exception_handler

    def drain(self):
        """Block until all pending main-thread work is done."""
        self.main_thread.finish_work().result(timeout=5)

    def start_activity(self, activity, segue_type=Segue.PUSH):
        """Start an activity synchronously (non-blocking)."""
        self._segue_to(activity, segue_type)
        self.drain()

    def send_key(self, key_code):
        """Dispatch a single key event and drain all resulting work.

        After the KeyStroke subscribers run, also process any secondary events
        that input handlers may have posted to event_queue (TextBoxSubmit,
        ScrollChange, etc.).
        """
        self.dispatch_event(KeyStroke(Keys.normalize(key_code)))
        self.drain()
        self._process_event_queue()

    def send_key_release(self, key_code):
        """Dispatch a KeyRelease event and drain."""
        self.dispatch_event(KeyRelease(Keys.normalize(key_code)))
        self.drain()
        self._process_event_queue()

    def send_keys(self, *key_codes):
        """Send multiple keys in sequence."""
        for key in key_codes:
            self.send_key(key)

    def send_text(self, text):
        """Type each character of *text* as individual key events."""
        for ch in text:
            self.send_key(ord(ch))

    def send_mouse_click(self, x, y, button=MouseButton.LEFT):
        """Dispatch MouseDown + MouseUp + MouseClick and drain."""
        self.dispatch_event(MouseDown(button, x, y))
        self.drain()
        self.dispatch_event(MouseUp(button, x, y))
        self.drain()
        self.dispatch_event(MouseClick(button, x, y))
        self.drain()
        self._process_event_queue()

    def send_mouse_scroll(self, x, y, direction="up"):
        """Dispatch a MouseScroll event and drain."""
        self.dispatch_event(MouseScroll(direction, x, y))
        self.drain()
        self._process_event_queue()

    def send_mouse_down(self, x, y, button=MouseButton.LEFT):
        """Dispatch a MouseDown event and drain."""
        self.dispatch_event(MouseDown(button, x, y))
        self.drain()
        self._process_event_queue()

    def send_mouse_up(self, x, y, button=MouseButton.LEFT):
        """Dispatch a MouseUp event and drain."""
        self.dispatch_event(MouseUp(button, x, y))
        self.drain()
        self._process_event_queue()

    def send_mouse_move(self, x, y):
        """Dispatch a MouseMove event and drain."""
        self.dispatch_event(MouseMove(x, y))
        self.drain()
        self._process_event_queue()

    def send_mouse_drag(self, start_x, start_y, end_x, end_y, button=MouseButton.LEFT):
        """Dispatch a full drag sequence: down + drag + up."""
        self.dispatch_event(MouseDown(button, start_x, start_y))
        self.drain()
        self.dispatch_event(MouseDrag(button, end_x, end_y, start_x, start_y))
        self.drain()
        self.dispatch_event(MouseUp(button, end_x, end_y))
        self.drain()
        self._process_event_queue()

    def current_activity(self):
        """Return the activity on top of the stack, or None."""
        return self.stack[-1] if self.stack else None

    # -- convenience methods --

    def assert_text_on_screen(self, needle):
        """Proxy to MockScreen.assert_text_on_screen."""
        self.curses_screen.assert_text_on_screen(needle)

    def assert_text_not_on_screen(self, needle):
        """Proxy to MockScreen.assert_text_not_on_screen."""
        self.curses_screen.assert_text_not_on_screen(needle)

    def activity_stack_depth(self):
        """Return the number of activities on the stack."""
        return len(self.stack)

    def activity_stack_types(self):
        """Return a list of class names for activities on the stack."""
        return [type(a).__name__ for a in self.stack]

    def focused_element(self):
        """Return the current activity's focus attribute, or None."""
        act = self.current_activity()
        return getattr(act, "focus", None) if act else None

    def replace_activity(self, activity):
        """Start an activity with Segue.REPLACE."""
        self.start_activity(activity, segue_type=Segue.REPLACE)

    def resize(self, rows, cols):
        """Resize the screen and re-render the current activity."""
        self.curses_screen.resize(rows, cols)
        act = self.current_activity()
        if act:
            act.refresh_screen()

    # -- display_state shortcuts --

    def state(self, key):
        """Shorthand for self.current_activity().display_state[key]."""
        return self.current_activity().display_state[key]

    def selected_item(self, key):
        """Read the currently selected item from a scroll list display_state entry."""
        ctx = self.state(key)
        return ctx["items"][ctx["selected_index"]]

    def input_text(self, key):
        """Read the current text from a text input display_state entry."""
        return self.state(key)["text"]

    # -- wait_for helpers --

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

    def wait_for(self, predicate, timeout=2.0):
        """Poll drain() until *predicate* returns truthy. Raises TimeoutError on expiry."""
        deadline = time.monotonic() + timeout
        while time.monotonic() < deadline:
            self.drain()
            result = predicate()
            if result:
                return result
            time.sleep(0.05)
        raise TimeoutError(f"Timed out waiting for predicate after {timeout}s.")

    def flush_stop_events(self):
        """Return and clear all captured StopApplication events.

        StopApplication events are captured by ``_process_event_queue`` into
        ``self.stop_events`` (rather than being silently discarded).  This
        method also drains any that are still sitting in event_queue from
        ``_pop_activity`` calls, preserving non-stop events.

        Use this to assert that an action would shut down the application::

            app.send_key(ord("q"))
            assert len(app.flush_stop_events()) >= 1
        """
        # Also drain any that _pop_activity put directly into event_queue
        # (these haven't been through _process_event_queue yet).
        remaining = []
        while True:
            try:
                event = self.event_queue.get_nowait()
            except Empty:
                break
            if isinstance(event, StopApplication):
                self.stop_events.append(event)
            else:
                remaining.append(event)
        for event in remaining:
            self.event_queue.put(event)
        result = list(self.stop_events)
        self.stop_events.clear()
        return result

    # -- service helpers --

    def start_service_sync(self, name, timeout=5):
        """Start a service and block until it reaches RUNNING state."""
        future = self.start_service(name)
        svc = self.service(name)
        if not svc.wait_until_running(timeout=timeout):
            raise TimeoutError(f"Service {name!r} did not start within {timeout}s")
        self.drain()
        return svc

    def stop_service_sync(self, name, timeout=5):
        """Stop a service and block until it reaches STOPPED state."""
        future = self.stop_service(name)
        svc = self.service(name)
        if not svc.wait_until_stopped(timeout=timeout):
            raise TimeoutError(f"Service {name!r} did not stop within {timeout}s")
        self.drain()
        return svc

    # -- internal --

    def _process_event_queue(self):
        """Non-blocking drain of the event_queue.

        Input handlers (handle_text_box_input, handle_scroll_list_input)
        post secondary events (TextBoxSubmit, ScrollChange, TextBoxChange)
        to event_queue. In production _event_monitor picks them up.
        In tests we process them synchronously here.

        StopApplication events are captured into ``self.stop_events`` so
        tests can assert on them via ``flush_stop_events()``.
        """
        while True:
            try:
                event = self.event_queue.get_nowait()
            except Empty:
                break
            if isinstance(event, StopApplication):
                self.stop_events.append(event)
                continue
            self.dispatch_event(event)
            self.drain()
