"""Behave step implementations for repl_coverage.feature.

Provides full unit-level coverage of every function and branch in
``src/cleveragents/cli/commands/repl.py``.
"""

from __future__ import annotations

import os
import tempfile
from io import StringIO
from pathlib import Path
from unittest.mock import MagicMock, call, patch

from behave import given, then, when
from behave.runner import Context

# ===================================================================
# Helpers
# ===================================================================


class _FakeInputCov:
    """Feed a list of strings then raise EOFError."""

    def __init__(self, lines: list[str]) -> None:
        self._lines = list(lines)
        self._idx = 0

    def __call__(self, prompt: str = "") -> str:
        if self._idx >= len(self._lines):
            raise EOFError
        line = self._lines[self._idx]
        self._idx += 1
        if line == "__KEYBOARD_INTERRUPT__":
            raise KeyboardInterrupt
        return line


def _make_console_and_buf():
    """Return a (Console, StringIO) pair for capturing Rich output."""
    from rich.console import Console

    buf = StringIO()
    console = Console(file=buf, no_color=True, width=200)
    return console, buf


# ===================================================================
# Constants
# ===================================================================


@given("replcov the repl module is imported")
def step_import_repl_module(context: Context) -> None:
    import cleveragents.cli.commands.repl as mod

    context.repl_mod = mod


@then("the constant _MULTILINE_CONTINUATION equals backslash")
def step_check_multiline_continuation(context: Context) -> None:
    assert context.repl_mod._MULTILINE_CONTINUATION == "\\"


@then('the constant _LAST_COMMAND_TOKEN equals "!!"')
def step_check_last_command_token(context: Context) -> None:
    assert context.repl_mod._LAST_COMMAND_TOKEN == "!!"


@then("the _REPL_COMMANDS list is non-empty")
def step_repl_commands_non_empty(context: Context) -> None:
    assert len(context.repl_mod._REPL_COMMANDS) > 0


@then("the _BUILTIN_COMMANDS list contains help exit and quit")
def step_builtin_commands(context: Context) -> None:
    builtins_list = context.repl_mod._BUILTIN_COMMANDS
    for token in (":help", ":exit", ":quit"):
        assert token in builtins_list, f"{token} not in _BUILTIN_COMMANDS"


# ===================================================================
# _get_prompt_context
# ===================================================================


@given("neither CLEVERAGENTS_PROJECT nor CLEVERAGENTS_PLAN is set")
def step_clear_prompt_env(context: Context) -> None:
    context.saved_project = os.environ.pop("CLEVERAGENTS_PROJECT", None)
    context.saved_plan = os.environ.pop("CLEVERAGENTS_PLAN", None)

    def _restore():
        if context.saved_project is not None:
            os.environ["CLEVERAGENTS_PROJECT"] = context.saved_project
        if context.saved_plan is not None:
            os.environ["CLEVERAGENTS_PLAN"] = context.saved_plan

    if not hasattr(context, "_cleanup_handlers"):
        context._cleanup_handlers = []
    context._cleanup_handlers.append(_restore)


@given('CLEVERAGENTS_PROJECT is set to "proj1" and CLEVERAGENTS_PLAN is unset')
def step_project_only_env(context: Context) -> None:
    context.saved_project = os.environ.get("CLEVERAGENTS_PROJECT")
    context.saved_plan = os.environ.get("CLEVERAGENTS_PLAN")
    os.environ["CLEVERAGENTS_PROJECT"] = "proj1"
    os.environ.pop("CLEVERAGENTS_PLAN", None)

    def _restore():
        if context.saved_project is not None:
            os.environ["CLEVERAGENTS_PROJECT"] = context.saved_project
        else:
            os.environ.pop("CLEVERAGENTS_PROJECT", None)
        if context.saved_plan is not None:
            os.environ["CLEVERAGENTS_PLAN"] = context.saved_plan

    if not hasattr(context, "_cleanup_handlers"):
        context._cleanup_handlers = []
    context._cleanup_handlers.append(_restore)


@given('CLEVERAGENTS_PROJECT is set to "proj1" and CLEVERAGENTS_PLAN is set to "plan1"')
def step_project_and_plan_env(context: Context) -> None:
    context.saved_project = os.environ.get("CLEVERAGENTS_PROJECT")
    context.saved_plan = os.environ.get("CLEVERAGENTS_PLAN")
    os.environ["CLEVERAGENTS_PROJECT"] = "proj1"
    os.environ["CLEVERAGENTS_PLAN"] = "plan1"

    def _restore():
        if context.saved_project is not None:
            os.environ["CLEVERAGENTS_PROJECT"] = context.saved_project
        else:
            os.environ.pop("CLEVERAGENTS_PROJECT", None)
        if context.saved_plan is not None:
            os.environ["CLEVERAGENTS_PLAN"] = context.saved_plan
        else:
            os.environ.pop("CLEVERAGENTS_PLAN", None)

    if not hasattr(context, "_cleanup_handlers"):
        context._cleanup_handlers = []
    context._cleanup_handlers.append(_restore)


@when("I call _get_prompt_context")
def step_call_get_prompt_context(context: Context) -> None:
    from cleveragents.cli.commands.repl import _get_prompt_context

    context.prompt_result = _get_prompt_context()


@then('the prompt result is "{expected}"')
def step_check_prompt_result(context: Context, expected: str) -> None:
    assert context.prompt_result == expected, (
        f"Expected {expected!r}, got {context.prompt_result!r}"
    )


# ===================================================================
# _setup_history
# ===================================================================


@given("a temporary history file that exists")
def step_temp_history_exists(context: Context) -> None:
    fd, path = tempfile.mkstemp(suffix="_hist")
    os.close(fd)
    context.history_path = Path(path)

    def _cleanup():
        context.history_path.unlink(missing_ok=True)

    if not hasattr(context, "_cleanup_handlers"):
        context._cleanup_handlers = []
    context._cleanup_handlers.append(_cleanup)


@given("a temporary history path that does not exist")
def step_temp_history_not_exists(context: Context) -> None:
    tmpdir = tempfile.mkdtemp()
    context.history_path = Path(tmpdir) / "subdir" / "nonexistent_history"

    def _cleanup():
        import shutil

        shutil.rmtree(tmpdir, ignore_errors=True)

    if not hasattr(context, "_cleanup_handlers"):
        context._cleanup_handlers = []
    context._cleanup_handlers.append(_cleanup)


@when("I call _setup_history with that path")
def step_call_setup_history(context: Context) -> None:
    from cleveragents.cli.commands.repl import _setup_history

    with patch("cleveragents.cli.commands.repl.readline") as mock_rl:
        _setup_history(context.history_path)
        context.mock_readline = mock_rl


@then("readline.read_history_file is called with the path")
def step_rl_read_called(context: Context) -> None:
    context.mock_readline.read_history_file.assert_called_once_with(
        str(context.history_path)
    )


@then("readline.read_history_file is not called")
def step_rl_read_not_called(context: Context) -> None:
    context.mock_readline.read_history_file.assert_not_called()


@then("readline.set_history_length is called with 10000")
def step_rl_set_length(context: Context) -> None:
    context.mock_readline.set_history_length.assert_called_once_with(10_000)


# ===================================================================
# _save_history
# ===================================================================


@given("a temporary history path for saving")
def step_temp_save_path(context: Context) -> None:
    context.save_path = Path(tempfile.mkdtemp()) / "save_hist"


@when("I call _save_history with that path")
def step_call_save_history(context: Context) -> None:
    from cleveragents.cli.commands.repl import _save_history

    with patch("cleveragents.cli.commands.repl.readline") as mock_rl:
        _save_history(context.save_path)
        context.mock_readline = mock_rl


@then("readline.write_history_file is called with the path")
def step_rl_write_called(context: Context) -> None:
    context.mock_readline.write_history_file.assert_called_once_with(
        str(context.save_path)
    )


@given("readline.write_history_file will raise OSError")
def step_rl_write_raises(context: Context) -> None:
    context.oserror_flag = True


@when("I call _save_history with a dummy path")
def step_call_save_history_oserror(context: Context) -> None:
    from cleveragents.cli.commands.repl import _save_history

    with patch("cleveragents.cli.commands.repl.readline") as mock_rl:
        mock_rl.write_history_file.side_effect = OSError("disk full")
        context.exc = None
        try:
            _save_history(Path("/tmp/dummy_hist"))
        except Exception as exc:
            context.exc = exc


@then("no exception is raised")
def step_no_exception(context: Context) -> None:
    assert context.exc is None, f"Unexpected exception: {context.exc}"


# ===================================================================
# _setup_completer
# ===================================================================


@when("I call _setup_completer")
def step_call_setup_completer(context: Context) -> None:
    from cleveragents.cli.commands.repl import _setup_completer

    with patch("cleveragents.cli.commands.repl.readline") as mock_rl:
        _setup_completer()
        context.mock_readline = mock_rl
        # Grab the completer function that was passed to set_completer
        context.completer_fn = mock_rl.set_completer.call_args[0][0]


@then("readline.set_completer is called with a callable")
def step_rl_set_completer(context: Context) -> None:
    assert context.mock_readline.set_completer.called
    assert callable(context.completer_fn)


@then("readline.set_completer_delims is called")
def step_rl_set_delims(context: Context) -> None:
    context.mock_readline.set_completer_delims.assert_called_once()


@then("readline.parse_and_bind is called with tab complete")
def step_rl_parse_bind(context: Context) -> None:
    context.mock_readline.parse_and_bind.assert_called_once_with("tab: complete")


@given("the completer is installed via _setup_completer")
def step_install_completer(context: Context) -> None:
    from cleveragents.cli.commands.repl import _setup_completer

    with patch("cleveragents.cli.commands.repl.readline") as mock_rl:
        _setup_completer()
        context.completer_fn = mock_rl.set_completer.call_args[0][0]


@when('I query the completer with text "{text}" and state {state:d}')
def step_query_completer(context: Context, text: str, state: int) -> None:
    context.completer_result = context.completer_fn(text, state)


@when("I query the completer with empty text and state {state:d}")
def step_query_completer_empty(context: Context, state: int) -> None:
    context.completer_result = context.completer_fn("", state)


@then('the completer returns "{expected}"')
def step_completer_returns(context: Context, expected: str) -> None:
    assert context.completer_result == expected, (
        f"Expected {expected!r}, got {context.completer_result!r}"
    )


@then("the completer returns None")
def step_completer_returns_none(context: Context) -> None:
    assert context.completer_result is None


@then("the completer returns a non-None value")
def step_completer_returns_non_none(context: Context) -> None:
    assert context.completer_result is not None, "Expected non-None from completer"


# ===================================================================
# _read_multiline
# ===================================================================


@given('builtins.input will return "continued line"')
def step_input_returns_single(context: Context) -> None:
    context.fake_continuation_lines = ["continued line"]
    context.multiline_initial = "first\\"


@given("builtins.input will return chained continuations")
def step_input_returns_chained(context: Context) -> None:
    context.fake_continuation_lines = ["second\\", "third"]
    context.multiline_initial = "first\\"


@given("builtins.input will raise EOFError on continuation")
def step_input_raises_eof(context: Context) -> None:
    context.fake_continuation_lines = "__EOF__"
    context.multiline_initial = "only\\"


@given("builtins.input will raise KeyboardInterrupt on continuation")
def step_input_raises_kbi(context: Context) -> None:
    context.fake_continuation_lines = "__KBI__"
    context.multiline_initial = "only\\"


@when("I call _read_multiline with initial line ending in backslash")
def step_call_read_multiline(context: Context) -> None:
    from cleveragents.cli.commands.repl import _read_multiline

    if context.fake_continuation_lines == "__EOF__":
        fake = MagicMock(side_effect=EOFError)
    elif context.fake_continuation_lines == "__KBI__":
        fake = MagicMock(side_effect=KeyboardInterrupt)
    else:
        fake = _FakeInputCov(context.fake_continuation_lines)

    with patch("builtins.input", fake):
        context.multiline_result = _read_multiline(context.multiline_initial)


@then('the multiline result is "{expected}"')
def step_check_multiline_result(context: Context, expected: str) -> None:
    assert context.multiline_result == expected, (
        f"Expected {expected!r}, got {context.multiline_result!r}"
    )


# ===================================================================
# _print_repl_help
# ===================================================================


@when("I call _print_repl_help")
def step_call_print_repl_help(context: Context) -> None:
    from cleveragents.cli.commands.repl import _print_repl_help

    console, buf = _make_console_and_buf()
    context.help_exc = None
    try:
        with patch("cleveragents.cli.commands.repl._console", console):
            _print_repl_help()
    except Exception as exc:
        context.help_exc = exc
    context.help_output = buf.getvalue()


@then("no exception is raised from _print_repl_help")
def step_no_help_exception(context: Context) -> None:
    assert context.help_exc is None, f"Unexpected exception: {context.help_exc}"


# ===================================================================
# dispatch_command
# ===================================================================


@given("the root Typer app is mocked to succeed")
def step_mock_typer_success(context: Context) -> None:
    context.dispatch_typer_side_effect = None


@given("the root Typer app raises SystemExit with code 42")
def step_mock_typer_sysexit_42(context: Context) -> None:
    context.dispatch_typer_side_effect = SystemExit(42)


@given("the root Typer app raises SystemExit with None code")
def step_mock_typer_sysexit_none(context: Context) -> None:
    context.dispatch_typer_side_effect = SystemExit(None)


@given("the root Typer app raises KeyboardInterrupt")
def step_mock_typer_kbi(context: Context) -> None:
    context.dispatch_typer_side_effect = KeyboardInterrupt()


@given("the root Typer app raises RuntimeError boom")
def step_mock_typer_runtime_error(context: Context) -> None:
    context.dispatch_typer_side_effect = RuntimeError("boom")


@when('I call dispatch_command with args "{arg}"')
def step_call_dispatch_command(context: Context, arg: str) -> None:
    from cleveragents.cli.commands.repl import dispatch_command

    console, buf = _make_console_and_buf()
    mock_app = MagicMock()
    if context.dispatch_typer_side_effect is not None:
        mock_app.side_effect = context.dispatch_typer_side_effect

    with (
        patch("cleveragents.cli.commands.repl._console", console),
        patch(
            "cleveragents.cli.main._register_subcommands",
            MagicMock(),
        ),
        patch("cleveragents.cli.main.app", mock_app),
    ):
        context.dispatch_rc = dispatch_command([arg])
    context.dispatch_output = buf.getvalue()


@then("the dispatch return code is {code:d}")
def step_check_dispatch_rc(context: Context, code: int) -> None:
    assert context.dispatch_rc == code, (
        f"Expected dispatch rc={code}, got {context.dispatch_rc}"
    )


@then('the dispatch error output contains "boom"')
def step_dispatch_output_boom(context: Context) -> None:
    assert "boom" in context.dispatch_output, (
        f"Expected 'boom' in output: {context.dispatch_output}"
    )


# ===================================================================
# run_repl — shared helpers
# ===================================================================


def _run_repl_cov(
    lines: list[str],
    *,
    no_history: bool = True,
    mock_dispatch: bool = False,
    mock_setup_hist: bool = False,
    mock_save_hist: bool = False,
):
    """Run the REPL with mocked input/console and optionally mocked dispatch."""
    from cleveragents.cli.commands.repl import run_repl

    console, buf = _make_console_and_buf()
    fake = _FakeInputCov(lines)

    patches = [
        patch("cleveragents.cli.commands.repl._console", console),
        patch("builtins.input", fake),
    ]

    mock_dispatch_obj = None
    mock_setup_obj = None
    mock_save_obj = None

    if mock_dispatch:
        m = MagicMock(return_value=0)
        patches.append(patch("cleveragents.cli.commands.repl.dispatch_command", m))
        mock_dispatch_obj = m

    if mock_setup_hist:
        m = MagicMock()
        patches.append(patch("cleveragents.cli.commands.repl._setup_history", m))
        mock_setup_obj = m

    if mock_save_hist:
        m = MagicMock()
        patches.append(patch("cleveragents.cli.commands.repl._save_history", m))
        mock_save_obj = m

    for p in patches:
        p.start()
    try:
        code = run_repl(no_history=no_history)
    finally:
        for p in patches:
            p.stop()

    return code, buf.getvalue(), mock_dispatch_obj, mock_setup_obj, mock_save_obj


# --- Given steps for REPL input sequences (using tables) ---


@given("the REPL input sequence is")
def step_repl_input_from_table(context: Context) -> None:
    context.cov_repl_lines = [row["line"] for row in context.table]


@given("the REPL input sequence is empty")
def step_repl_input_empty(context: Context) -> None:
    context.cov_repl_lines = []


@given('the REPL input sequence for multiline is "version \\" then "" then ":exit"')
def step_repl_input_multiline(context: Context) -> None:
    context.cov_repl_lines = ["version \\", "", ":exit"]


@given("the REPL input sequence for interrupt then exit")
def step_repl_input_kbi(context: Context) -> None:
    context.cov_repl_lines = ["__KEYBOARD_INTERRUPT__", ":exit"]


@given("the REPL input sequence has an unclosed quote then exit")
def step_repl_input_shlex_error(context: Context) -> None:
    context.cov_repl_lines = ['"unclosed', ":exit"]


@given("dispatch_command is mocked for run_repl")
def step_mock_dispatch_for_repl(context: Context) -> None:
    context.mock_dispatch_flag = True


# --- When steps ---


@when("I run the REPL loop")
def step_run_repl_loop(context: Context) -> None:
    mock_dispatch = getattr(context, "mock_dispatch_flag", False)
    code, output, mock_d, _, _ = _run_repl_cov(
        context.cov_repl_lines,
        no_history=True,
        mock_dispatch=mock_dispatch,
    )
    context.cov_repl_exit_code = code
    context.cov_repl_output = output
    context.cov_mock_dispatch = mock_d


@when("I run the REPL loop with history enabled")
def step_run_repl_loop_history(context: Context) -> None:
    code, output, _, mock_setup, mock_save = _run_repl_cov(
        context.cov_repl_lines,
        no_history=False,
        mock_setup_hist=True,
        mock_save_hist=True,
    )
    context.cov_repl_exit_code = code
    context.cov_repl_output = output
    context.cov_mock_setup_hist = mock_setup
    context.cov_mock_save_hist = mock_save


@when("I run the REPL loop with no_history")
def step_run_repl_loop_no_history(context: Context) -> None:
    code, output, _, mock_setup, mock_save = _run_repl_cov(
        context.cov_repl_lines,
        no_history=True,
        mock_setup_hist=True,
        mock_save_hist=True,
    )
    context.cov_repl_exit_code = code
    context.cov_repl_output = output
    context.cov_mock_setup_hist = mock_setup
    context.cov_mock_save_hist = mock_save


# ===================================================================
# run_repl — Then steps
# ===================================================================


@then("the REPL loop exit code is {code:d}")
def step_check_repl_loop_ec(context: Context, code: int) -> None:
    assert context.cov_repl_exit_code == code, (
        f"Expected {code}, got {context.cov_repl_exit_code}"
    )


@then('the REPL loop output contains "{text}"')
def step_repl_loop_output_contains(context: Context, text: str) -> None:
    assert text in context.cov_repl_output, (
        f"Expected {text!r} in output:\n{context.cov_repl_output}"
    )


@then('dispatch_command was called at least 2 times with "{arg}"')
def step_dispatch_called_twice(context: Context, arg: str) -> None:
    assert context.cov_mock_dispatch is not None
    calls_matching = [
        c for c in context.cov_mock_dispatch.call_args_list if c == call([arg])
    ]
    assert len(calls_matching) >= 2, (
        f"Expected dispatch(['{arg}']) >= 2 times, got {len(calls_matching)}: "
        f"{context.cov_mock_dispatch.call_args_list}"
    )


@then('dispatch_command was called with args "{arg}"')
def step_dispatch_called_with(context: Context, arg: str) -> None:
    assert context.cov_mock_dispatch is not None
    context.cov_mock_dispatch.assert_any_call([arg])


@then("_setup_history was called")
def step_setup_hist_called(context: Context) -> None:
    assert context.cov_mock_setup_hist.called


@then("_setup_history was not called")
def step_setup_hist_not_called(context: Context) -> None:
    assert not context.cov_mock_setup_hist.called


@then("_save_history was called")
def step_save_hist_called(context: Context) -> None:
    assert context.cov_mock_save_hist.called


@then("_save_history was not called")
def step_save_hist_not_called(context: Context) -> None:
    assert not context.cov_mock_save_hist.called


# ===================================================================
# repl_callback
# ===================================================================


@given("sys.stdout.isatty returns True")
def step_isatty_true(context: Context) -> None:
    context.isatty_value = True


@given("sys.stdout.isatty returns False")
def step_isatty_false(context: Context) -> None:
    context.isatty_value = False


@given("run_repl is mocked to return 0")
def step_mock_run_repl(context: Context) -> None:
    context.mock_run_repl_rc = 0


@given("CLEVERAGENTS_FORCE_REPL is set in the environment")
def step_force_repl_set(context: Context) -> None:
    os.environ["CLEVERAGENTS_FORCE_REPL"] = "1"
    if not hasattr(context, "_cleanup_handlers"):
        context._cleanup_handlers = []
    context._cleanup_handlers.append(
        lambda: os.environ.pop("CLEVERAGENTS_FORCE_REPL", None)
    )


@given("CLEVERAGENTS_FORCE_REPL is not set")
def step_force_repl_unset(context: Context) -> None:
    os.environ.pop("CLEVERAGENTS_FORCE_REPL", None)


@when("I call repl_callback")
def step_call_repl_callback(context: Context) -> None:
    from cleveragents.cli.commands.repl import repl_callback

    mock_isatty = MagicMock(return_value=context.isatty_value)
    mock_run = MagicMock(return_value=getattr(context, "mock_run_repl_rc", 0))

    context.cov_repl_callback_exc = None
    context.cov_mock_run_repl = mock_run

    with (
        patch("cleveragents.cli.commands.repl.sys") as mock_sys,
        patch("cleveragents.cli.commands.repl.run_repl", mock_run),
    ):
        mock_sys.stdout.isatty = mock_isatty
        try:
            repl_callback(no_history=False, history_path=Path("/tmp/fake_hist"))
        except SystemExit as exc:
            context.cov_repl_callback_exc = exc


@then("run_repl was invoked")
def step_run_repl_invoked(context: Context) -> None:
    assert context.cov_mock_run_repl.called, "run_repl was not called"


@then("run_repl was not invoked")
def step_run_repl_not_invoked(context: Context) -> None:
    assert not context.cov_mock_run_repl.called, "run_repl should not have been called"


@then("repl_callback raises SystemExit with code 0")
def step_callback_sysexit_0(context: Context) -> None:
    assert context.cov_repl_callback_exc is not None, "Expected SystemExit"
    assert context.cov_repl_callback_exc.code == 0, (
        f"Expected SystemExit(0), got SystemExit({context.cov_repl_callback_exc.code})"
    )
