"""Tests for the Ctrl+S prompt stash wiring inside HermesCLI.

The state machine itself is covered by tests/cli/test_prompt_stash.py. These
tests verify the cli.py side:
  - HermesCLI.__init__ creates a PromptStash
  - the layout hook makes room for the stash browse panel
  - _render_stash_panel renders bounded, display-width-correct rows
  - the status-bar indicator appears / disappears with stash contents

Follows the prompt_toolkit-stub construction pattern from
tests/cli/test_cli_extension_hooks.py.
"""

from __future__ import annotations

import importlib
import sys
from unittest.mock import MagicMock, patch

import pytest


def _make_cli(**kwargs):
    """Create a HermesCLI with prompt_toolkit stubbed out."""
    _clean_config = {
        "model": {
            "default": "anthropic/claude-opus-4.6",
            "base_url": "https://openrouter.ai/api/v1",
            "provider": "auto",
        },
        "display": {"compact": False, "tool_progress": "all"},
        "agent": {},
        "terminal": {"env_type": "local"},
    }
    clean_env = {"LLM_MODEL": "", "HERMES_MAX_ITERATIONS": ""}
    prompt_toolkit_stubs = {
        "prompt_toolkit": MagicMock(),
        "prompt_toolkit.history": MagicMock(),
        "prompt_toolkit.styles": MagicMock(),
        "prompt_toolkit.patch_stdout": MagicMock(),
        "prompt_toolkit.application": MagicMock(),
        "prompt_toolkit.layout": MagicMock(),
        "prompt_toolkit.layout.processors": MagicMock(),
        "prompt_toolkit.filters": MagicMock(),
        "prompt_toolkit.layout.dimension": MagicMock(),
        "prompt_toolkit.layout.menus": MagicMock(),
        "prompt_toolkit.widgets": MagicMock(),
        "prompt_toolkit.key_binding": MagicMock(),
        "prompt_toolkit.completion": MagicMock(),
        "prompt_toolkit.formatted_text": MagicMock(),
        "prompt_toolkit.auto_suggest": MagicMock(),
    }
    with patch.dict(sys.modules, prompt_toolkit_stubs), patch.dict(
        "os.environ", clean_env, clear=False
    ):
        import cli as _cli_mod

        _cli_mod = importlib.reload(_cli_mod)
        with patch.object(_cli_mod, "get_tool_definitions", return_value=[]), patch.dict(
            _cli_mod.__dict__, {"CLI_CONFIG": _clean_config}
        ):
            return _cli_mod.HermesCLI(**kwargs)


@pytest.fixture(scope="module")
def cli():
    return _make_cli()


class TestStashStateInit:
    def test_cli_has_prompt_stash(self, cli):
        assert hasattr(cli, "_prompt_stash")

    def test_stash_starts_empty(self, cli):
        # Duck-typed rather than isinstance: _make_cli reloads the `cli`
        # module, which re-imports hermes_cli.prompt_toolkit stubs and can
        # yield a distinct-but-equivalent PromptStash class object.
        stash = cli._prompt_stash
        assert type(stash).__name__ == "PromptStash"
        assert len(stash) == 0
        assert stash.panel_open is False
        assert stash.indicator() == ""
        assert stash.placeholder_hint() == ""

    def test_stash_is_per_instance_not_shared(self):
        """Two CLIs must not share one stash — drafts would leak across sessions."""
        a = _make_cli()
        b = _make_cli()
        a._prompt_stash.stash("only in a")
        assert len(a._prompt_stash) == 1
        assert len(b._prompt_stash) == 0


class TestKeybindingRegistration:
    """Behavioral coverage for the stash keybinding surface.

    NOTE: the Ctrl+S / panel-navigation handlers are registered inside
    ``HermesCLI.run()``'s local ``KeyBindings`` instance, so they are not
    importable without launching the TUI. Asserting on cli.py's SOURCE TEXT
    to prove they exist is the banned change-detector antipattern (root
    AGENTS.md, "Never read source code in tests"): it passes when the
    handler exists but is wired wrong, and fails on a correct rename.

    The regression that broke PR #4771 (a rebase silently dropping the
    keybinding) is instead guarded where the behavior actually lives — the
    stash state machine below and in test_prompt_stash.py, which every
    handler delegates to. Extracting run()'s bindings into a standalone
    registrar would make direct handler tests possible; that refactor is
    deliberately out of scope for this salvage.
    """

    def test_extension_hook_still_a_noop(self, cli):
        """The stash binding lives in run(), not in the wrapper extension hook."""
        from prompt_toolkit.key_binding import KeyBindings

        kb = KeyBindings()
        assert cli._register_extra_tui_keybindings(kb, input_area=None) is None
        assert kb.bindings == []


class TestLayoutSlot:
    def test_layout_includes_stash_panel_when_present(self, cli):
        cli._stash_panel_widget = "stash-panel"
        try:
            children = cli._build_tui_layout_children(
                sudo_widget="sudo",
                secret_widget="secret",
                approval_widget="approval",
                clarify_widget="clarify",
                spinner_widget="spinner",
                spacer="spacer",
                status_bar="status",
                input_rule_top="top-rule",
                image_bar="image-bar",
                input_area="input-area",
                input_rule_bot="bottom-rule",
                voice_status_bar="voice-status",
                completions_menu="completions-menu",
            )
            assert "stash-panel" in children
            # Panel sits directly above the status bar.
            assert children.index("stash-panel") < children.index("status")
        finally:
            cli._stash_panel_widget = None

    def test_layout_omits_stash_panel_when_absent(self, cli):
        cli._stash_panel_widget = None
        children = cli._build_tui_layout_children(
            sudo_widget="sudo",
            secret_widget="secret",
            approval_widget="approval",
            clarify_widget="clarify",
            spinner_widget="spinner",
            spacer="spacer",
            status_bar="status",
            input_rule_top="top-rule",
            image_bar="image-bar",
            input_area="input-area",
            input_rule_bot="bottom-rule",
            voice_status_bar="voice-status",
            completions_menu="completions-menu",
        )
        assert None not in children


class TestRenderStashPanel:
    """Contributor's panel renderer, now measured in display cells."""

    @staticmethod
    def _rows(cli, count=3, width=100):
        stash = type(cli._prompt_stash)()
        for i in range(count):
            stash.stash(f"draft number {i}")
        return cli._render_stash_panel(stash.panel_rows(), 0, width)

    def test_returns_fragments(self, cli):
        frags = self._rows(cli)
        assert frags
        assert all(isinstance(f, tuple) and len(f) == 2 for f in frags)

    def test_header_and_footer_present(self, cli):
        text = "".join(t for _, t in self._rows(cli))
        assert "📌 Stash" in text
        assert "Ctrl+S" in text
        assert "Enter=restore" in text
        assert "D=delete" in text

    def test_row_per_entry(self, cli):
        text = "".join(t for _, t in self._rows(cli, count=3))
        for i in range(3):
            assert f"[{i + 1}]" in text

    def test_singular_plural_item_label(self, cli):
        one = "".join(t for _, t in self._rows(cli, count=1))
        assert "(1 item)" in one
        two = "".join(t for _, t in self._rows(cli, count=2))
        assert "(2 items)" in two

    @pytest.mark.parametrize("width", [16, 20, 30, 40, 60, 80, 120, 400])
    def test_no_line_exceeds_terminal_width(self, cli, width):
        """Rows must never bleed past the terminal — the bug the PR's three
        follow-up commits kept failing to fix by tweaking len()."""
        from prompt_toolkit.utils import get_cwidth

        text = "".join(t for _, t in self._rows(cli, count=3, width=width))
        for line in text.split("\n"):
            if line:
                assert get_cwidth(line) <= max(width, 12), (
                    f"line {line!r} is {get_cwidth(line)} cells, width={width}"
                )

    def test_multiline_draft_renders_as_one_row(self, cli):
        stash = type(cli._prompt_stash)()
        stash.stash("first line\nsecond line\nthird line")
        frags = cli._render_stash_panel(stash.panel_rows(), 0, 100)
        text = "".join(t for _, t in frags)
        # header + 1 entry row + footer = 3 rendered lines
        assert len([ln for ln in text.split("\n") if ln]) == 3

    def test_wide_glyph_preview_does_not_overflow(self, cli):
        """CJK previews are 2 cells per char — must still fit the box."""
        from prompt_toolkit.utils import get_cwidth

        stash = type(cli._prompt_stash)()
        stash.stash("中文" * 80)
        frags = cli._render_stash_panel(stash.panel_rows(), 0, 60)
        for line in "".join(t for _, t in frags).split("\n"):
            if line:
                assert get_cwidth(line) <= 60

    def test_cursor_row_is_styled_differently(self, cli):
        stash = type(cli._prompt_stash)()
        stash.stash("a")
        stash.stash("b")
        styles = {s for s, _ in cli._render_stash_panel(stash.panel_rows(), 1, 100)}
        assert "class:subagent-selected" in styles

    def test_empty_list_still_renders_frame(self, cli):
        frags = cli._render_stash_panel([], 0, 80)
        text = "".join(t for _, t in frags)
        assert "(0 items)" in text


class TestStatusBarIndicator:
    def test_no_indicator_when_stash_empty(self, cli):
        cli._prompt_stash.clear()
        cli._status_bar_visible = True
        text = "".join(t for _, t in cli._get_status_bar_fragments())
        assert "📌" not in text

    def test_indicator_appears_after_stashing(self, cli):
        cli._prompt_stash.clear()
        cli._status_bar_visible = True
        cli._prompt_stash.stash("a parked draft")
        try:
            text = "".join(t for _, t in cli._get_status_bar_fragments())
            assert "📌 1" in text
        finally:
            cli._prompt_stash.clear()

    def test_indicator_count_tracks_stash_size(self, cli):
        cli._prompt_stash.clear()
        cli._status_bar_visible = True
        cli._prompt_stash.stash("a")
        cli._prompt_stash.stash("b")
        try:
            text = "".join(t for _, t in cli._get_status_bar_fragments())
            assert "📌 2" in text
        finally:
            cli._prompt_stash.clear()

    def test_indicator_clears_after_restore(self, cli):
        cli._prompt_stash.clear()
        cli._status_bar_visible = True
        cli._prompt_stash.stash("a")
        cli._prompt_stash.pop()
        text = "".join(t for _, t in cli._get_status_bar_fragments())
        assert "📌" not in text


class TestFmtStashAge:
    def test_age_buckets(self, cli):
        import time

        now = time.monotonic()
        assert cli._fmt_stash_age(now) == "just now"
        assert cli._fmt_stash_age(now - 30).endswith("s ago")
        assert "min ago" in cli._fmt_stash_age(now - 300)
        assert cli._fmt_stash_age(now - 7200).endswith("h ago")
