"""
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 curses
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, ExceptionOccured, StopApplication
from pyos import Keys


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 = [[0] * 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=0):
        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 = [[0] * 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] = 0

    def timeout(self, ms):
        pass

    def getch(self):
        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 0

    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 = [[0] * 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.shutdown_signal = Future()
        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 requires curses.KEY_F1
        # and would push LogViewerActivity, which we don't want in tests.

    def teardown(self):
        """Shut down dispatch queues."""
        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

    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_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 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):
        """Drain and return any StopApplication events sitting in event_queue.

        Activities that pop the last item off the stack put a StopApplication
        into event_queue.  In production the _event_monitor loop picks these
        up; in tests they silently accumulate.  Call this after operations
        that empty the stack to inspect or discard them.
        """
        stop_events = []
        remaining = []
        while True:
            try:
                event = self.event_queue.get_nowait()
            except Empty:
                break
            if isinstance(event, StopApplication):
                stop_events.append(event)
            else:
                remaining.append(event)
        for event in remaining:
            self.event_queue.put(event)
        return stop_events

    # -- 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 collected instead of dispatched — they
        would have no subscribers and would just accumulate.
        """
        while True:
            try:
                event = self.event_queue.get_nowait()
            except Empty:
                break
            if isinstance(event, StopApplication):
                continue
            self.dispatch_event(event)
            self.drain()
