"""Regression tests for #30768, #32383, and #33961.

``_prompt_text_input_modal`` answers destructive-slash confirmations through a
queue-based modal driven by prompt_toolkit key bindings.  When invoked from the
``process_loop`` daemon thread it sets the modal up on the app's event loop via
``call_soon_threadsafe``, so it is safe on every platform — including native
Windows (#33961), where the earlier ``sys.platform == "win32"`` → raw ``input()``
fallback deadlocked the daemon thread against prompt_toolkit's stdin ownership.

These tests verify:
1. Daemon-thread confirm uses the modal via the app loop on Linux AND native
   Windows (#33961) — never the raw stdin fallback, never a hang.
2. Main-thread confirm with a running app uses the modal.
3. The raw stdin fallback is kept ONLY for the safe cases: no running app, and
   (on win32, off-thread) a scheduling failure degrades to a clean cancel.
4. Empty choices returns None.
"""

import sys
import threading
import time
from unittest.mock import MagicMock, patch

import pytest


def _make_cli():
    """Minimal HermesCLI shell exposing the prompt/modal helpers."""
    import cli as cli_mod

    obj = object.__new__(cli_mod.HermesCLI)
    obj._app = MagicMock()
    obj._app.loop = MagicMock()
    obj._status_bar_visible = True
    obj._last_invalidate = 0.0
    obj._modal_input_snapshot = None
    obj._slash_confirm_state = None
    obj._slash_confirm_deadline = 0
    return obj


_SAMPLE_CHOICES = [
    ("once", "Approve Once", "proceed this time only"),
    ("always", "Always Approve", "proceed and silence this prompt permanently"),
    ("cancel", "Cancel", "keep current conversation"),
]


def _answer_modal_when_open(cli, response, stop=None):
    """Push ``response`` onto the modal's response_queue once it opens.

    Gives up after ~2s, or early when ``stop`` is set (the modal will never open,
    e.g. a scheduling failure) so degraded-path tests don't wait the full budget.
    """
    for _ in range(100):
        if stop is not None and stop.is_set():
            return
        state = cli._slash_confirm_state
        if state and "response_queue" in state:
            state["response_queue"].put(response)
            return
        time.sleep(0.02)


def _run_on_daemon(call, cli, *, platform, response, schedule=None):
    """Invoke ``call`` on a daemon thread — as the process_loop does — answering
    the modal with ``response`` once it opens.

    Returns ``{result, stdin_called, capture, restore}``.  ``schedule`` overrides
    the ``call_soon_threadsafe`` side effect (default: run the callback inline);
    pass a raiser to simulate a scheduling failure.  Fails if the worker hangs,
    which is the deadlock canary for #33961.
    """
    outcome = {"capture": [], "restore": [], "result": None, "stdin_called": False}
    done = threading.Event()

    def _worker():
        try:
            with patch.object(sys, "platform", platform), \
                 patch.object(cli._app.loop, "call_soon_threadsafe", side_effect=schedule or (lambda cb: cb())), \
                 patch.object(cli, "_prompt_text_input") as mock_stdin, \
                 patch.object(cli, "_invalidate"), \
                 patch.object(cli, "_capture_modal_input_snapshot", side_effect=lambda: outcome["capture"].append(1)), \
                 patch.object(cli, "_restore_modal_input_snapshot", side_effect=lambda: outcome["restore"].append(1)):
                outcome["result"] = call()
                outcome["stdin_called"] = mock_stdin.called
        finally:
            done.set()

    worker = threading.Thread(target=_worker, daemon=True)
    answerer = threading.Thread(target=_answer_modal_when_open, args=(cli, response, done), daemon=True)
    answerer.start()
    worker.start()
    worker.join(timeout=2.0)
    answerer.join(timeout=2.0)
    assert not worker.is_alive(), "daemon thread hung — modal deadlocked"
    return outcome


class TestModal:
    """Behaviour of _prompt_text_input_modal across platforms and threads."""

    @pytest.mark.parametrize("platform", ["linux", "win32"])
    def test_daemon_thread_uses_modal_via_app_loop(self, platform):
        """Off the process_loop daemon thread, the confirm uses the modal via
        call_soon_threadsafe on every platform — including native Windows, where
        the old win32 early-return deadlocked on raw input() (#33961)."""
        cli = _make_cli()
        outcome = _run_on_daemon(
            lambda: cli._prompt_text_input_modal(
                title="⚠️  /reset",
                detail="This starts a fresh session.",
                choices=_SAMPLE_CHOICES,
                timeout=5,
            ),
            cli,
            platform=platform,
            response="once",
        )
        assert outcome["stdin_called"] is False, "must use the modal, not raw input()"
        assert outcome["result"] == "once"
        assert outcome["capture"] == [1]
        assert outcome["restore"] == [1]
        assert cli._slash_confirm_state is None

    def test_main_thread_with_app_uses_modal(self):
        """On the main thread with a running app, the queue-based modal is used."""
        cli = _make_cli()
        with patch.object(sys, "platform", "darwin"), \
             patch.object(cli, "_capture_modal_input_snapshot"), \
             patch.object(cli, "_restore_modal_input_snapshot"), \
             patch.object(cli, "_invalidate"), \
             patch.object(cli, "_prompt_text_input") as mock_stdin:
            answerer = threading.Thread(target=_answer_modal_when_open, args=(cli, "once"), daemon=True)
            answerer.start()
            result = cli._prompt_text_input_modal(
                title="⚠️  /new",
                detail="This starts a fresh session.",
                choices=_SAMPLE_CHOICES,
                timeout=5,
            )
            answerer.join(timeout=2.0)

        mock_stdin.assert_not_called()
        assert result == "once"

    def test_no_app_falls_back_to_stdin(self):
        """Without a running app (oneshot / non-interactive), use the stdin prompt."""
        cli = _make_cli()
        cli._app = None

        with patch.object(cli, "_prompt_text_input", return_value="3") as mock_stdin:
            result = cli._prompt_text_input_modal(
                title="⚠️  /clear",
                detail="This clears the screen.",
                choices=_SAMPLE_CHOICES,
            )

        mock_stdin.assert_called_once_with("Choice [1/2/3]: ")
        assert result == "3"

    def test_windows_no_app_falls_back_to_stdin(self):
        """win32 without a running app keeps stdin — the only case where the raw
        prompt is safe on Windows, since no app owns the console to deadlock."""
        cli = _make_cli()
        cli._app = None

        with patch.object(sys, "platform", "win32"), \
             patch.object(cli, "_prompt_text_input", return_value="1") as mock_stdin:
            result = cli._prompt_text_input_modal(
                title="⚠️  /new — destroys conversation state",
                detail="This starts a fresh session.",
                choices=_SAMPLE_CHOICES,
            )

        mock_stdin.assert_called_once_with("Choice [1/2/3]: ")
        assert result == "1"

    def test_windows_scheduling_failure_clean_cancels(self):
        """win32 off the main thread: if marshaling onto the app loop fails, cancel
        cleanly (None) rather than fall to raw input() (which deadlocks on native
        Windows) or hang. Asserts the _stdin_fallback guard (#33961)."""
        cli = _make_cli()

        def _raise(_cb):
            raise RuntimeError("loop closed")

        outcome = _run_on_daemon(
            lambda: cli._prompt_text_input_modal(
                title="⚠️  /reset",
                detail="This starts a fresh session.",
                choices=_SAMPLE_CHOICES,
                timeout=5,
            ),
            cli,
            platform="win32",
            response="once",
            schedule=_raise,
        )
        assert outcome["stdin_called"] is False, "win32 off-thread must NOT call raw input()"
        assert outcome["result"] is None
        assert cli._slash_confirm_state is None

    @pytest.mark.parametrize(
        "platform, expect_stdin, expect_result",
        [("win32", False, None), ("linux", True, "1")],
    )
    def test_daemon_thread_no_app_loop_uses_fallback(self, platform, expect_stdin, expect_result):
        """Off the daemon thread with no resolvable app loop (``self._app.loop``
        is None / raises), the modal can never be scheduled, so the method short-
        circuits at the app_loop-is-None site (cli.py ~7260) — a distinct path
        from a call_soon_threadsafe failure. win32 clean-cancels (None) instead of
        deadlocking on raw input(); other platforms keep the stdin prompt."""
        cli = _make_cli()
        cli._app.loop = None  # forces app_loop is None, off the main thread

        outcome = {"result": None, "stdin_called": False}
        done = threading.Event()

        def _worker():
            try:
                with patch.object(sys, "platform", platform), \
                     patch.object(cli, "_prompt_text_input", return_value="1") as mock_stdin, \
                     patch.object(cli, "_invalidate"):
                    outcome["result"] = cli._prompt_text_input_modal(
                        title="⚠️  /reset",
                        detail="This starts a fresh session.",
                        choices=_SAMPLE_CHOICES,
                        timeout=5,
                    )
                    outcome["stdin_called"] = mock_stdin.called
            finally:
                done.set()

        worker = threading.Thread(target=_worker, daemon=True)
        worker.start()
        worker.join(timeout=2.0)
        assert not worker.is_alive(), "daemon thread hung — modal deadlocked"
        assert outcome["stdin_called"] is expect_stdin
        assert outcome["result"] == expect_result
        assert cli._slash_confirm_state is None

    def test_empty_choices_returns_none(self):
        """Empty choices returns None without prompting."""
        cli = _make_cli()

        with patch.object(cli, "_prompt_text_input") as mock_stdin:
            result = cli._prompt_text_input_modal(title="Test", detail="Test", choices=[])

        mock_stdin.assert_not_called()
        assert result is None


class TestConfirmDestructiveSlashWindows:
    """End-to-end _confirm_destructive_slash on the native-Windows daemon thread."""

    def _make_interactive_cli(self):
        cli = _make_cli()
        cli.model = "test-model"
        cli._agent_running = False
        cli._spinner_text = ""
        cli._should_exit = False
        cli._command_running = False
        cli.session_id = "test-session"
        cli._pending_tool_info = {}
        cli._tool_start_time = 0.0
        cli._last_scrollback_tool = ""
        return cli

    @pytest.mark.parametrize(
        "response, expected",
        [("once", "once"), ("cancel", None)],
    )
    def test_confirm_destructive_slash_uses_modal_on_windows(self, response, expected):
        """On native Windows, the bare /new confirm drives the modal (not stdin)
        and returns the chosen outcome — the bug #33961 froze this path."""
        cli = self._make_interactive_cli()
        with patch("cli.load_cli_config", return_value={"approvals": {"destructive_slash_confirm": True}}):
            outcome = _run_on_daemon(
                lambda: cli._confirm_destructive_slash(
                    "new",
                    "This starts a fresh session.\nThe current conversation history will be discarded.",
                ),
                cli,
                platform="win32",
                response=response,
            )

        assert outcome["stdin_called"] is False
        assert outcome["result"] == expected


class TestNativeWindowsNoRawInputDeadlock:
    """Anti-regression guard exercising the REAL ``_prompt_text_input``.

    Every other test here mocks ``_prompt_text_input`` away, so they only
    assert *routing* (modal vs. stdin) — they cannot observe the actual hang
    that #33961 was.  The historical regression was precisely that
    ``_prompt_text_input_modal`` delegated to the *real* ``_prompt_text_input``
    on native Windows, which on a non-main thread runs a bare ``input()`` that
    blocks forever against prompt_toolkit's stdin ownership.

    These tests let the real ``_prompt_text_input`` run with a blocking
    ``input()`` and assert the worker thread never hangs.  They fail on the
    pre-#33961 code (win32 → ``_prompt_text_input`` → off-main ``input()``)
    and pass once the modal path / clean-cancel fallback is in place.
    """

    def test_win32_daemon_thread_never_blocks_on_real_input(self):
        """A blocking input() must NOT hang the daemon thread on win32.

        Drives the genuine helper chain (no mock of ``_prompt_text_input``)
        with ``builtins.input`` patched to block forever. The confirm must
        resolve via the app-loop modal (answered on a background thread, as
        the real key bindings would) and never sit in ``input()``.  On the
        pre-#33961 code the win32 early-return routed to the real
        ``_prompt_text_input`` → off-main ``input()`` → permanent hang.
        """
        cli = _make_cli()
        cli._app.loop.call_soon_threadsafe = lambda cb: cb()

        def _blocking_input(prompt=""):  # stands in for "no line ever arrives"
            time.sleep(30)
            return "1"

        outcome = {}
        done = threading.Event()

        def _worker():
            try:
                with patch.object(sys, "platform", "win32"), \
                     patch("builtins.input", side_effect=_blocking_input), \
                     patch.object(cli, "_capture_modal_input_snapshot"), \
                     patch.object(cli, "_restore_modal_input_snapshot"), \
                     patch.object(cli, "_invalidate"):
                    outcome["result"] = cli._prompt_text_input_modal(
                        title="/new",
                        detail="destroys conversation state",
                        choices=_SAMPLE_CHOICES,
                        timeout=3,
                    )
            finally:
                done.set()

        worker = threading.Thread(target=_worker, daemon=True)
        answerer = threading.Thread(
            target=_answer_modal_when_open, args=(cli, "cancel", done), daemon=True
        )
        answerer.start()
        worker.start()
        worker.join(timeout=5.0)
        answerer.join(timeout=5.0)
        assert not worker.is_alive(), (
            "daemon thread hung in real input() — native-Windows confirm "
            "deadlock regressed (#33961)"
        )
        # cancel → None; the point is it RETURNED rather than blocking forever.
        assert outcome.get("result") in (None, "cancel")

    def test_win32_scheduling_failure_cleanly_cancels_no_input(self):
        """If the modal can't be marshaled onto the app loop on native Windows
        (scheduling failure) the off-main-thread path must cancel cleanly —
        NOT fall through to a blocking raw ``input()``.

        This is the degraded branch the pre-#33961 code handled with
        ``return self._prompt_text_input(...)`` (which deadlocks); the fix
        returns ``None`` instead.
        """
        cli = _make_cli()

        def _raise(cb):  # call_soon_threadsafe scheduling failure
            raise RuntimeError("event loop closed")

        cli._app.loop.call_soon_threadsafe = _raise

        input_called = {"n": 0}

        def _tracking_input(prompt=""):
            input_called["n"] += 1
            time.sleep(30)
            return "1"

        outcome = {}

        def _worker():
            with patch.object(sys, "platform", "win32"), \
                 patch("builtins.input", side_effect=_tracking_input), \
                 patch.object(cli, "_invalidate"):
                outcome["result"] = cli._prompt_text_input_modal(
                    title="/new",
                    detail="destroys conversation state",
                    choices=_SAMPLE_CHOICES,
                    timeout=3,
                )

        worker = threading.Thread(target=_worker, daemon=True)
        worker.start()
        worker.join(timeout=5.0)
        assert not worker.is_alive(), (
            "daemon thread hung — win32 scheduling-failure fallback used raw "
            "input() instead of cleanly cancelling (#33961)"
        )
        assert input_called["n"] == 0, "win32 off-thread fallback must not call input()"
        assert outcome.get("result") is None
