"""Unit tests for runner.py hardening changes.

Tests cover individual components rather than full PTY integration so that
they run without a real terminal and without spawning long-lived processes.
"""

import contextlib
import os
import sys
import time
import unittest

import pytest

from auto_yes.detector import PromptDetector
from auto_yes.runner import Runner, _is_escape_sequence

_unix_only = pytest.mark.skipif(sys.platform == "win32", reason="Unix PTY/fork not available on Windows")

# ==================================================================
# 1. Null byte filtering
# ==================================================================


class TestNullByteFiltering(unittest.TestCase):
    """Null bytes in PTY output must not crash prompt detection."""

    def setUp(self):
        self.detector = PromptDetector()

    def test_null_bytes_in_output_buf(self):
        """Buffer with null bytes mixed into a real prompt is still detected."""
        # Simulate PTY output that contains null bytes alongside a prompt.
        raw = b"\x00\x00some output\x00Continue? [y/n]\x00"
        text = raw.decode("utf-8", errors="replace")
        result = self.detector.detect(text)
        assert result is not None, (
            "Prompt should still be detected when null bytes are present"
        )

    def test_all_null_buffer(self):
        """A buffer that is entirely null bytes returns None without crashing."""
        raw = b"\x00" * 256
        text = raw.decode("utf-8", errors="replace")
        result = self.detector.detect(text)
        assert result is None, (
            "All-null buffer should produce no detection result"
        )


# ==================================================================
# 2. _is_escape_sequence
# ==================================================================


class TestIsEscapeSequence(unittest.TestCase):
    """_is_escape_sequence must distinguish terminal-generated sequences from
    real user keystrokes."""

    def test_focus_in_event(self):
        assert _is_escape_sequence(b"\x1b[I") is True

    def test_focus_out_event(self):
        assert _is_escape_sequence(b"\x1b[O") is True

    def test_cursor_position_report(self):
        # ESC [ <row> ; <col> R
        assert _is_escape_sequence(b"\x1b[24;80R") is True

    def test_arrow_key_not_escape(self):
        # Arrow-up: ESC [ A — a real user keystroke
        assert _is_escape_sequence(b"\x1b[A") is False

    def test_regular_char_not_escape(self):
        assert _is_escape_sequence(b"a") is False

    def test_function_key_not_escape(self):
        # F1: ESC [ 11 ~
        assert _is_escape_sequence(b"\x1b[11~") is False


# ==================================================================
# 3. Runner._reap_child
# ==================================================================


@_unix_only
class TestReapChild(unittest.TestCase):
    """_reap_child must return None for a running child and the exit code for
    a child that has terminated."""

    def test_no_child_returns_none(self):
        """A child that has not exited yields None from _reap_child."""
        # Fork a child that sleeps briefly so it is definitely still running
        # when we call _reap_child.
        pid = os.fork()
        if pid == 0:
            # child: sleep long enough that the parent wins the race
            time.sleep(5)
            os._exit(0)

        try:
            result = Runner._reap_child(pid)
            # The child is still running; must get None back.
            assert result is None, (
                f"Expected None for running child, got {result!r}"
            )
        finally:
            # Clean up: kill the child so it does not linger.
            with contextlib.suppress(ProcessLookupError):
                os.kill(pid, 9)
            with contextlib.suppress(ChildProcessError):
                os.waitpid(pid, 0)

    def test_exited_child_returns_code(self):
        """A child that exits with code 42 is reaped and returns 42."""
        pid = os.fork()
        if pid == 0:
            os._exit(42)

        # Give the child a moment to exit.
        time.sleep(0.05)

        result = Runner._reap_child(pid)
        assert result == 42, (
            f"Expected exit code 42, got {result!r}"
        )


# ==================================================================
# 4. Runner._timing_allows
# ==================================================================


class TestTimingAllows(unittest.TestCase):
    """_timing_allows must correctly gate auto-responses based on timestamps."""

    def _make_runner(self):
        """Return a Runner with all timestamps zeroed (far in the past)."""
        r = Runner.__new__(Runner)
        r.cooldown = 0.5
        r.verbose = False
        r.delay = 0.0
        r.profile = None
        r.response = "y"
        r.detector = PromptDetector()
        r._last_response_time = 0.0
        r._last_match_time = 0.0
        r._last_user_input = 0.0
        r._pid = os.getpid()
        r._log_fh = None
        return r

    def test_fresh_runner_allows(self):
        """All timestamps at epoch → timing_allows returns True."""
        r = self._make_runner()
        now = time.time()
        assert r._timing_allows(now) is True

    def test_recent_user_input_suppresses(self):
        """_last_user_input set to now suppresses detection."""
        r = self._make_runner()
        now = time.time()
        r._last_user_input = now
        assert r._timing_allows(now) is False

    def test_cooldown_suppresses(self):
        """_last_response_time set to now suppresses detection."""
        r = self._make_runner()
        now = time.time()
        r._last_response_time = now
        assert r._timing_allows(now) is False

    def test_match_cooldown_suppresses(self):
        """_last_match_time set to 10 ms ago suppresses detection."""
        r = self._make_runner()
        now = time.time()
        r._last_match_time = now - 0.01
        assert r._timing_allows(now) is False

    def test_all_expired_allows(self):
        """All timestamps 10 s in the past → timing_allows returns True."""
        r = self._make_runner()
        now = time.time()
        r._last_user_input = now - 10.0
        r._last_response_time = now - 10.0
        r._last_match_time = now - 10.0
        assert r._timing_allows(now) is True


if __name__ == "__main__":
    unittest.main()
