from pathlib import Path

import pytest

from pulka.api.runtime import Runtime
from pulka.testing.data import make_df, write_df
from pulka.tui.keymap import KEY_ONLY_ACTION_SPECS, _quit_back_with_confirmation
from pulka.tui.screen import Screen


@pytest.fixture
def sample_dataset(tmp_path: Path) -> str:
    df = make_df("mini_nav", rows=8, cols=3, seed=123)
    path = tmp_path / "runtime.parquet"
    write_df(df, path, "parquet")
    return str(path)


def _session_without_entrypoints(path: str):
    runtime = Runtime(load_entry_points=False)
    return runtime.open(path, viewport_rows=6)


def test_key_only_help_entries_match_spec(sample_dataset: str) -> None:
    session = _session_without_entrypoints(sample_dataset)
    screen = Screen(session.viewer)
    try:
        help_entries = screen.commands_help_entries()
        expected = {
            (spec.help_keys, spec.help_action, spec.help_description)
            for spec in KEY_ONLY_ACTION_SPECS
            if spec.add_help
        }
        expected.add(("g", "Go family", "Show go family hints"))
        expected.add(("m", "Materialize family", "Show materialize family hints"))
        expected.add(("r", "Reset family", "Show reset family hints"))
        expected.add(("y", "Yank family", "Show yank family hints"))
        expected.add(("z", "Viewport family", "Show viewport family hints"))
        expected.add(("rt<id>", "Remove transform", "Remove transform by id"))
        actual = {(entry.aliases, entry.command, entry.description) for entry in help_entries}
        assert actual == expected
    finally:
        unsubscribe = getattr(screen, "_view_stack_unsubscribe", None)
        if unsubscribe is not None:
            unsubscribe()
        session.close()


class _StubApp:
    def __init__(self) -> None:
        self.exit_called = False

    def exit(self) -> None:
        self.exit_called = True


class _StubEvent:
    def __init__(self, app: _StubApp) -> None:
        self.app = app


def test_q_confirms_before_exit_at_root(sample_dataset: str) -> None:
    session = _session_without_entrypoints(sample_dataset)
    screen = Screen(session.viewer)
    app = _StubApp()
    event = _StubEvent(app)
    recorded_modal: dict[str, object] = {}
    refreshed = False
    try:
        screen._record_key_event = lambda _event: None  # type: ignore[method-assign]

        def _capture_modal(**kwargs) -> None:  # type: ignore[no-untyped-def]
            recorded_modal.update(kwargs)

        def _refresh() -> None:
            nonlocal refreshed
            refreshed = True

        screen._open_confirmation_modal = _capture_modal  # type: ignore[method-assign]
        screen.refresh = _refresh  # type: ignore[method-assign]

        _quit_back_with_confirmation(screen, event)

        assert not app.exit_called
        assert recorded_modal.get("title") == "Exit pulka?"
        assert refreshed

        on_confirm = recorded_modal.get("on_confirm")
        assert callable(on_confirm)
        on_confirm()
        assert app.exit_called
    finally:
        unsubscribe = getattr(screen, "_view_stack_unsubscribe", None)
        if unsubscribe is not None:
            unsubscribe()
        session.close()


def test_q_pops_viewer_when_derived(sample_dataset: str) -> None:
    session = _session_without_entrypoints(sample_dataset)
    screen = Screen(session.viewer)
    app = _StubApp()
    event = _StubEvent(app)
    pop_called = False
    modal_called = False
    refreshed = False
    try:
        screen._record_key_event = lambda _event: None  # type: ignore[method-assign]

        class _DerivedStack:
            def __len__(self) -> int:
                return 2

        def _pop() -> None:
            nonlocal pop_called
            pop_called = True

        def _modal(**_kwargs) -> None:  # type: ignore[no-untyped-def]
            nonlocal modal_called
            modal_called = True

        def _refresh() -> None:
            nonlocal refreshed
            refreshed = True

        screen.view_stack = _DerivedStack()  # type: ignore[assignment]
        screen._pop_viewer = _pop  # type: ignore[method-assign]
        screen._open_confirmation_modal = _modal  # type: ignore[method-assign]
        screen.refresh = _refresh  # type: ignore[method-assign]

        _quit_back_with_confirmation(screen, event)

        assert pop_called
        assert refreshed
        assert not modal_called
        assert not app.exit_called
    finally:
        unsubscribe = getattr(screen, "_view_stack_unsubscribe", None)
        if unsubscribe is not None:
            unsubscribe()
        session.close()


def test_open_prefix_hint_sets_inline_status_fragments(sample_dataset: str) -> None:
    session = _session_without_entrypoints(sample_dataset)
    screen = Screen(session.viewer)
    try:
        opened = screen._open_prefix_hint(family="yank.root")

        assert opened is True
        assert screen.is_prefix_hint_active("yank.root")
        fragments = screen.viewer.status_inline_fragments
        text = "".join(part for _, part in fragments)
        assert text == (
            "yank [what?]: y cell, p path, n column name, C visible columns, s schema, t table"
        )
        assert fragments[1][0] == ("status", "status.command")
        assert fragments[1][1] == "y"
    finally:
        unsubscribe = getattr(screen, "_view_stack_unsubscribe", None)
        if unsubscribe is not None:
            unsubscribe()
        session.close()


def test_prefix_hint_transitions_and_executes(sample_dataset: str) -> None:
    session = _session_without_entrypoints(sample_dataset)
    screen = Screen(session.viewer)
    commands: list[str] = []
    refresh_count = 0
    try:
        screen._execute_command = lambda name: commands.append(name)  # type: ignore[method-assign]

        def _refresh() -> None:
            nonlocal refresh_count
            refresh_count += 1

        screen.refresh = _refresh  # type: ignore[method-assign]

        screen._open_prefix_hint(family="yank.root")
        screen._advance_prefix_hint(family="yank.table")
        screen._execute_prefixed_command("yank_table_markdown")

        assert screen.is_prefix_hint_active() is False
        assert commands == ["yank_table_markdown"]
        assert refresh_count == 2
        assert not hasattr(screen.viewer, "status_inline_fragments")
    finally:
        unsubscribe = getattr(screen, "_view_stack_unsubscribe", None)
        if unsubscribe is not None:
            unsubscribe()
        session.close()


def test_reset_transform_hint_is_dynamic(sample_dataset: str) -> None:
    session = _session_without_entrypoints(sample_dataset)
    screen = Screen(session.viewer)
    try:
        screen._execute_command("sort_asc")
        opened = screen._open_prefix_hint(family="reset.root")

        assert opened is True
        root_text = "".join(part for _, part in screen.viewer.status_inline_fragments)
        assert "t transform" in root_text

        screen._advance_prefix_hint(family="reset.transform")

        assert screen.is_prefix_hint_active("reset.transform")
        text = "".join(part for _, part in screen.viewer.status_inline_fragments)
        assert text.startswith("remove transform [id?]: ")
        assert "0 sort" in text
    finally:
        unsubscribe = getattr(screen, "_view_stack_unsubscribe", None)
        if unsubscribe is not None:
            unsubscribe()
        session.close()


def test_reset_hint_displays_space_key_as_visible_glyph(sample_dataset: str) -> None:
    session = _session_without_entrypoints(sample_dataset)
    screen = Screen(session.viewer)
    try:
        opened = screen._open_prefix_hint(family="reset.root")

        assert opened is True
        text = "".join(part for _, part in screen.viewer.status_inline_fragments)
        assert "␣ selection" in text
    finally:
        unsubscribe = getattr(screen, "_view_stack_unsubscribe", None)
        if unsubscribe is not None:
            unsubscribe()
        session.close()


def test_go_hint_does_not_offer_clear_selection(sample_dataset: str) -> None:
    session = _session_without_entrypoints(sample_dataset)
    screen = Screen(session.viewer)
    try:
        opened = screen._open_prefix_hint(family="go.root")

        assert opened is True
        text = "".join(part for _, part in screen.viewer.status_inline_fragments)
        assert "clear selection" not in text
        assert "␣" not in text
    finally:
        unsubscribe = getattr(screen, "_view_stack_unsubscribe", None)
        if unsubscribe is not None:
            unsubscribe()
        session.close()
