"""Cmd2 unit/functional testing"""

import io
import os
import signal
import sys
import tempfile
from code import InteractiveConsole
from typing import (
    NoReturn,
    cast,
)
from unittest import mock

import pytest
from prompt_toolkit.auto_suggest import AutoSuggestFromHistory
from prompt_toolkit.completion import DummyCompleter
from prompt_toolkit.input import DummyInput, create_pipe_input
from prompt_toolkit.output import DummyOutput
from prompt_toolkit.shortcuts import PromptSession
from rich.style import Style
from rich.text import Text

import cmd2
from cmd2 import (
    COMMAND_NAME,
    Cmd2Style,
    Color,
    CommandSet,
    Completions,
    SubcommandRecord,
    clipboard,
    constants,
    exceptions,
    plugin,
    stylize,
    utils,
)
from cmd2 import rich_utils as ru
from cmd2 import string_utils as su
from cmd2.types import BoundCommandFunc

from .conftest import (
    SHORTCUTS_TXT,
    normalize,
    odd_file_names,
    run_cmd,
    verify_help_text,
    with_ansi_style,
)


def create_outsim_app():
    c = cmd2.Cmd()
    c.stdout = utils.StdSim(c.stdout)
    return c


@pytest.fixture
def outsim_app():
    return create_outsim_app()


def test_version(base_app) -> None:
    assert cmd2.__version__


def test_complete_in_thread() -> None:
    # Test default
    app_default = cmd2.Cmd()
    assert app_default.main_session.complete_in_thread is True

    # Test True
    app_true = cmd2.Cmd(complete_in_thread=True)
    assert app_true.main_session.complete_in_thread is True

    # Test False
    app_false = cmd2.Cmd(complete_in_thread=False)
    assert app_false.main_session.complete_in_thread is False


def test_not_in_main_thread(base_app, capsys) -> None:
    import threading

    # Mock threading.main_thread() to return our fake thread
    saved_main_thread = threading.main_thread
    fake_main = threading.Thread()
    threading.main_thread = mock.MagicMock(name="main_thread", return_value=fake_main)

    with pytest.raises(RuntimeError) as excinfo:
        base_app.cmdloop()

    # Restore threading.main_thread()
    threading.main_thread = saved_main_thread
    assert "cmdloop must be run in the main thread" in str(excinfo.value)


def test_empty_statement(base_app) -> None:
    out, _err = run_cmd(base_app, "")
    expected = normalize("")
    assert out == expected


def test_base_help(base_app) -> None:
    out, _err = run_cmd(base_app, "help")
    assert base_app.last_result is True
    verify_help_text(base_app, out)


def test_base_help_verbose(base_app) -> None:
    out, _err = run_cmd(base_app, "help -v")
    assert base_app.last_result is True
    verify_help_text(base_app, out)

    # Make sure :param type lines are filtered out of help summary
    help_doc = base_app.do_help.__func__.__doc__
    help_doc += "\n:param fake param"
    base_app.do_help.__func__.__doc__ = help_doc

    out, _err = run_cmd(base_app, "help --verbose")
    assert base_app.last_result is True
    verify_help_text(base_app, out)
    assert ":param" not in "".join(out)


def test_base_argparse_help(base_app) -> None:
    # Verify that "set -h" gives the same output as "help set" and that it starts in a way that makes sense
    out1, _err1 = run_cmd(base_app, "set -h")
    out2, _err2 = run_cmd(base_app, "help set")

    assert out1 == out2
    assert out1[0].startswith("Usage: set")
    assert out1[1] == ""
    assert out1[2].startswith("Set a settable parameter")


def test_base_invalid_option(base_app) -> None:
    _out, err = run_cmd(base_app, "set -z")
    assert err[0] == "Usage: set [-h] [param] [value]"
    assert "Error: unrecognized arguments: -z" in err[1]


def test_base_shortcuts(base_app) -> None:
    out, _err = run_cmd(base_app, "shortcuts")
    expected = normalize(SHORTCUTS_TXT)
    assert out == expected
    assert base_app.last_result is True


def test_command_starts_with_shortcut() -> None:
    expected_err = "Invalid command name 'help'"
    with pytest.raises(ValueError, match=expected_err):
        cmd2.Cmd(shortcuts={"help": "fake"})


def test_base_set(base_app) -> None:
    # Make sure all settables appear in output.
    out, _err = run_cmd(base_app, "set")
    settables = sorted(base_app.settables.keys())

    # The settables will appear in order in the table.
    # Go line-by-line until all settables are found.
    for line in out:
        if not settables:
            break
        if line.lstrip().startswith(settables[0]):
            settables.pop(0)

    # This will be empty if we found all settables in the output.
    assert not settables

    # Make sure all settables appear in last_result.
    assert len(base_app.last_result) == len(base_app.settables)
    for param in base_app.last_result:
        assert base_app.last_result[param] == base_app.settables[param].value


def test_set(base_app) -> None:
    out, err = run_cmd(base_app, "set quiet True")
    assert not out
    assert base_app.last_result is True

    # Test quiet respect
    out, err = run_cmd(base_app, "set timing False")
    assert not out
    assert not err
    assert base_app.last_result is True

    # Show one settable (this always goes to out)
    line_found = False
    out, _err = run_cmd(base_app, "set quiet")
    for line in out:
        if "quiet" in line and "True" in line and "False" not in line:
            line_found = True
            break

    assert line_found
    assert len(base_app.last_result) == 1
    assert base_app.last_result["quiet"] is True
    base_app.quiet = False


def test_set_val_empty(base_app) -> None:
    base_app.editor = "fake"
    _out, _err = run_cmd(base_app, 'set editor ""')
    assert base_app.editor == ""
    assert base_app.last_result is True


def test_set_val_is_flag(base_app) -> None:
    base_app.editor = "fake"
    _out, _err = run_cmd(base_app, 'set editor "-h"')
    assert base_app.editor == "-h"
    assert base_app.last_result is True


def test_set_not_supported(base_app) -> None:
    _out, err = run_cmd(base_app, "set qqq True")
    expected = normalize(
        """
Parameter 'qqq' not supported (type 'set' for list of parameters).
"""
    )
    assert err == expected
    assert base_app.last_result is False


def test_set_no_settables(base_app) -> None:
    base_app._settables.clear()
    _out, err = run_cmd(base_app, "set quiet True")
    expected = normalize("There are no settable parameters")
    assert err == expected
    assert base_app.last_result is False


@pytest.mark.parametrize(
    ("new_val", "is_valid", "expected"),
    [
        (ru.AllowStyle.NEVER, True, ru.AllowStyle.NEVER),
        ("neVeR", True, ru.AllowStyle.NEVER),
        (ru.AllowStyle.TERMINAL, True, ru.AllowStyle.TERMINAL),
        ("TeRMInal", True, ru.AllowStyle.TERMINAL),
        (ru.AllowStyle.ALWAYS, True, ru.AllowStyle.ALWAYS),
        ("AlWaYs", True, ru.AllowStyle.ALWAYS),
        ("invalid", False, ru.AllowStyle.TERMINAL),
    ],
)
@with_ansi_style(ru.AllowStyle.TERMINAL)
def test_set_allow_style(base_app: cmd2.Cmd, new_val: str, is_valid: bool, expected: ru.AllowStyle) -> None:
    from cmd2.pt_utils import pt_resolve_color_depth

    # Use the set command to alter allow_style
    out, err = run_cmd(base_app, f"set allow_style {new_val}")
    assert base_app.last_result is is_valid

    # Verify the results
    assert expected == ru.ALLOW_STYLE
    if is_valid:
        assert out
        assert not err
        assert base_app.main_session.color_depth == pt_resolve_color_depth()


def test_set_traceback_show_locals(base_app: cmd2.Cmd) -> None:
    """Test the set command for reading and setting traceback_show_locals."""
    import inspect

    from rich.traceback import Traceback

    # Get Traceback's default value for "show_locals"
    traceback_sig = inspect.signature(Traceback.__init__)
    default_val = traceback_sig.parameters["show_locals"].default

    # Clear any existing value
    base_app.traceback_kwargs.pop("show_locals", None)
    assert "show_locals" not in base_app.traceback_kwargs

    # Test that we receive the default value if not present
    orig_val = base_app.traceback_show_locals
    assert orig_val is default_val
    assert "show_locals" not in base_app.traceback_kwargs

    # Test setting it
    new_val = not orig_val
    run_cmd(base_app, f"set traceback_show_locals {new_val}")
    assert base_app.traceback_show_locals is new_val
    assert base_app.traceback_kwargs["show_locals"] is new_val

    # Test invalid input
    _out, err = run_cmd(base_app, "set traceback_show_locals invalid")
    assert base_app.last_result is False
    assert "must be True or False (case-insensitive)" in err[0]


def test_set_traceback_width(base_app: cmd2.Cmd) -> None:
    """Test the set command for reading and setting traceback_width."""

    import inspect

    from rich.traceback import Traceback

    # Get Traceback's default value for "width"
    traceback_sig = inspect.signature(Traceback.__init__)
    default_val = traceback_sig.parameters["width"].default

    # Clear any existing value
    base_app.traceback_kwargs.pop("width", None)
    assert "width" not in base_app.traceback_kwargs

    # Test that we receive the default value if not present
    orig_val = base_app.traceback_width
    assert orig_val == default_val
    assert "width" not in base_app.traceback_kwargs

    # Test setting it to an integer (handling case where orig_val is None)
    new_val = (orig_val or 0) + 100
    run_cmd(base_app, f"set traceback_width {new_val}")
    assert base_app.traceback_width == new_val
    assert base_app.traceback_kwargs["width"] == new_val

    # Test setting to "None" (case-insensitive)
    run_cmd(base_app, "set traceback_width None")
    assert base_app.traceback_width is None
    assert base_app.traceback_kwargs["width"] is None  # type: ignore[unreachable]

    run_cmd(base_app, "set traceback_width none")
    assert base_app.traceback_width is None

    # Test invalid input
    _out, err = run_cmd(base_app, "set traceback_width invalid")
    assert base_app.last_result is False
    assert "must be an integer or None" in err[0]


def test_set_with_choices(base_app) -> None:
    """Test choices validation of Settables"""
    fake_choices = ["valid", "choices"]
    base_app.fake = fake_choices[0]

    fake_settable = cmd2.Settable("fake", type(base_app.fake), "fake description", base_app, choices=fake_choices)
    base_app.add_settable(fake_settable)

    # Try a valid choice
    out, err = run_cmd(base_app, f"set fake {fake_choices[1]}")
    assert base_app.last_result is True
    assert not err
    assert out[0].startswith("fake")
    assert out[0].endswith(f"─> {fake_choices[1]!r}")

    # Try an invalid choice
    _out, err = run_cmd(base_app, "set fake bad_value")
    assert base_app.last_result is False
    assert err[0].startswith("Error setting fake: invalid choice")


def test_set_with_rich_description(base_app) -> None:
    """Test with rich Text description."""
    description = "Rich text description"
    base_app.rich_settable = "old"
    rich_settable = cmd2.Settable(
        "rich_settable", type(base_app.rich_settable), Text(description, Style(color="red", bold=True)), base_app
    )
    base_app.add_settable(rich_settable)

    # Try see list settables info
    out, err = run_cmd(base_app, "set rich_settable")
    assert not err
    name, value, text = out[-1].strip().split(maxsplit=2)
    assert name == "rich_settable"
    assert value == "old"
    assert text == description

    # Try to set a valid value
    out, err = run_cmd(base_app, "set rich_settable new")
    assert base_app.last_result is True
    assert not err
    assert out[0].startswith("rich_settable")
    assert out[0].endswith("─> 'new'")


class OnChangeHookApp(cmd2.Cmd):
    def __init__(self, *args, **kwargs) -> None:
        super().__init__(*args, **kwargs)
        self.add_settable(utils.Settable("quiet", bool, "my description", self, onchange_cb=self._onchange_quiet))

    def _onchange_quiet(self, name, old, new) -> None:
        """Runs when quiet is changed via set command"""
        self.poutput("You changed " + name)


@pytest.fixture
def onchange_app():
    return OnChangeHookApp()


def test_set_onchange_hook(onchange_app) -> None:
    out, err = run_cmd(onchange_app, "set quiet True")
    assert out == ["You changed quiet"]
    # quiet: False -> True is not shown because quiet is now True
    assert not err
    assert onchange_app.last_result is True


def test_base_shell(base_app, monkeypatch) -> None:
    m = mock.Mock()
    monkeypatch.setattr("{}.Popen".format("subprocess"), m)
    out, _err = run_cmd(base_app, "shell echo a")
    assert out == []
    assert m.called


def test_shell_last_result(base_app) -> None:
    base_app.last_result = None
    run_cmd(base_app, "shell fake")
    assert base_app.last_result is not None


def test_shell_manual_call(base_app) -> None:
    # Verifies crash from Issue #986 doesn't happen
    cmds = ['echo "hi"', 'echo "there"', 'echo "cmd2!"']
    cmd = ";".join(cmds)

    base_app.do_shell(cmd)

    cmd = "&&".join(cmds)

    base_app.do_shell(cmd)


def test_base_error(base_app) -> None:
    _out, err = run_cmd(base_app, "meow")
    assert "is not a recognized command" in err[0]


def test_base_error_suggest_command(base_app) -> None:
    try:
        old_suggest_similar_command = base_app.suggest_similar_command
        base_app.suggest_similar_command = True
        _out, err = run_cmd(base_app, "historic")
        assert "history" in err[1]
    finally:
        base_app.suggest_similar_command = old_suggest_similar_command


def test_run_script(base_app, request) -> None:
    test_dir = os.path.dirname(request.module.__file__)
    filename = os.path.join(test_dir, "script.txt")

    assert base_app._script_dir == []
    assert base_app._current_script_dir is None

    # Get output out the script
    script_out, script_err = run_cmd(base_app, f"run_script {filename}")
    assert base_app.last_result is True

    assert base_app._script_dir == []
    assert base_app._current_script_dir is None

    # Now run the commands manually and compare their output to script's
    with open(filename, encoding="utf-8") as file:
        script_commands = file.read().splitlines()

    manual_out = []
    manual_err = []
    for cmdline in script_commands:
        out, err = run_cmd(base_app, cmdline)
        manual_out.extend(out)
        manual_err.extend(err)

    assert script_out == manual_out
    assert script_err == manual_err


def test_run_script_with_empty_args(base_app) -> None:
    _out, err = run_cmd(base_app, "run_script")
    assert "the following arguments are required" in err[1]
    assert base_app.last_result is None


def test_run_script_with_invalid_file(base_app, request) -> None:
    # Path does not exist
    _out, err = run_cmd(base_app, "run_script does_not_exist.txt")
    assert "Problem accessing script from " in err[0]
    assert base_app.last_result is False

    # Path is a directory
    test_dir = os.path.dirname(request.module.__file__)
    _out, err = run_cmd(base_app, f"run_script {test_dir}")
    assert "Problem accessing script from " in err[0]
    assert base_app.last_result is False


def test_run_script_with_empty_file(base_app, request) -> None:
    test_dir = os.path.dirname(request.module.__file__)
    filename = os.path.join(test_dir, "scripts", "empty.txt")
    out, err = run_cmd(base_app, f"run_script {filename}")
    assert not out
    assert not err
    assert base_app.last_result is True


def test_run_script_with_binary_file(base_app, request) -> None:
    test_dir = os.path.dirname(request.module.__file__)
    filename = os.path.join(test_dir, "scripts", "binary.bin")
    _out, err = run_cmd(base_app, f"run_script {filename}")
    assert "is not an ASCII or UTF-8 encoded text file" in err[0]
    assert base_app.last_result is False


def test_run_script_with_python_file(base_app, request, monkeypatch) -> None:
    read_input_mock = mock.MagicMock(name="read_input", return_value="2")
    monkeypatch.setattr("cmd2.Cmd.read_input", read_input_mock)

    test_dir = os.path.dirname(request.module.__file__)
    filename = os.path.join(test_dir, "pyscript", "stop.py")
    _out, err = run_cmd(base_app, f"run_script {filename}")
    assert "appears to be a Python file" in err[0]
    assert base_app.last_result is False


def test_run_script_with_utf8_file(base_app, request) -> None:
    test_dir = os.path.dirname(request.module.__file__)
    filename = os.path.join(test_dir, "scripts", "utf8.txt")

    assert base_app._script_dir == []
    assert base_app._current_script_dir is None

    # Get output out the script
    script_out, script_err = run_cmd(base_app, f"run_script {filename}")
    assert base_app.last_result is True

    assert base_app._script_dir == []
    assert base_app._current_script_dir is None

    # Now run the commands manually and compare their output to script's
    with open(filename, encoding="utf-8") as file:
        script_commands = file.read().splitlines()

    manual_out = []
    manual_err = []
    for cmdline in script_commands:
        out, err = run_cmd(base_app, cmdline)
        manual_out.extend(out)
        manual_err.extend(err)

    assert script_out == manual_out
    assert script_err == manual_err


def test_scripts_add_to_history(base_app, request) -> None:
    test_dir = os.path.dirname(request.module.__file__)
    filename = os.path.join(test_dir, "scripts", "help.txt")
    command = f"run_script {filename}"

    # Add to history
    base_app.scripts_add_to_history = True
    base_app.history.clear()
    run_cmd(base_app, command)
    assert len(base_app.history) == 2
    assert base_app.history.get(1).raw == command
    assert base_app.history.get(2).raw == "help -v"

    # Do not add to history
    base_app.scripts_add_to_history = False
    base_app.history.clear()
    run_cmd(base_app, command)
    assert len(base_app.history) == 1
    assert base_app.history.get(1).raw == command


def test_run_script_nested_run_scripts(base_app, request) -> None:
    # Verify that running a script with nested run_script commands works correctly,
    # and runs the nested script commands in the correct order.
    test_dir = os.path.dirname(request.module.__file__)
    filename = os.path.join(test_dir, "scripts", "nested.txt")

    # Run the top level script
    initial_run = "run_script " + filename
    run_cmd(base_app, initial_run)
    assert base_app.last_result is True

    # Check that the right commands were executed.
    expected = f"""
{initial_run}
_relative_run_script precmds.txt
set allow_style Always
help
shortcuts
_relative_run_script postcmds.txt
set allow_style Terminal"""
    out, _err = run_cmd(base_app, "history -s")
    assert out == normalize(expected)


def test_runcmds_plus_hooks(base_app, request) -> None:
    test_dir = os.path.dirname(request.module.__file__)
    prefilepath = os.path.join(test_dir, "scripts", "precmds.txt")
    postfilepath = os.path.join(test_dir, "scripts", "postcmds.txt")

    base_app.runcmds_plus_hooks(["run_script " + prefilepath, "help", "shortcuts", "run_script " + postfilepath])
    expected = f"""
run_script {prefilepath}
set allow_style Always
help
shortcuts
run_script {postfilepath}
set allow_style Terminal"""

    out, _err = run_cmd(base_app, "history -s")
    assert out == normalize(expected)


def test_runcmds_plus_hooks_ctrl_c(base_app, capsys) -> None:
    """Test Ctrl-C while in runcmds_plus_hooks"""
    import types

    def do_keyboard_interrupt(self, _) -> NoReturn:
        raise KeyboardInterrupt("Interrupting this command")

    base_app.do_keyboard_interrupt = types.MethodType(do_keyboard_interrupt, base_app)

    # Default behavior is to not stop runcmds_plus_hooks() on Ctrl-C
    base_app.history.clear()
    base_app.runcmds_plus_hooks(["help", "keyboard_interrupt", "shortcuts"])
    _out, err = capsys.readouterr()
    assert not err
    assert len(base_app.history) == 3

    # Ctrl-C should stop runcmds_plus_hooks() in this case
    base_app.history.clear()
    base_app.runcmds_plus_hooks(["help", "keyboard_interrupt", "shortcuts"], stop_on_keyboard_interrupt=True)
    _out, err = capsys.readouterr()
    assert err.startswith("Interrupting this command")
    assert len(base_app.history) == 2


def test_relative_run_script(base_app, request) -> None:
    test_dir = os.path.dirname(request.module.__file__)
    filename = os.path.join(test_dir, "script.txt")

    assert base_app._script_dir == []
    assert base_app._current_script_dir is None

    # Get output out the script
    script_out, script_err = run_cmd(base_app, f"_relative_run_script {filename}")
    assert base_app.last_result is True

    assert base_app._script_dir == []
    assert base_app._current_script_dir is None

    # Now run the commands manually and compare their output to script's
    with open(filename, encoding="utf-8") as file:
        script_commands = file.read().splitlines()

    manual_out = []
    manual_err = []
    for cmdline in script_commands:
        out, err = run_cmd(base_app, cmdline)
        manual_out.extend(out)
        manual_err.extend(err)

    assert script_out == manual_out
    assert script_err == manual_err


@pytest.mark.parametrize("file_name", odd_file_names)
def test_relative_run_script_with_odd_file_names(base_app, file_name, monkeypatch) -> None:
    """Test file names with various patterns"""
    # Mock out the do_run_script call to see what args are passed to it
    run_script_mock = mock.MagicMock(name="do_run_script")
    monkeypatch.setattr("cmd2.Cmd.do_run_script", run_script_mock)

    run_cmd(base_app, f"_relative_run_script {su.quote(file_name)}")
    run_script_mock.assert_called_once_with(su.quote(file_name))


def test_relative_run_script_requires_an_argument(base_app) -> None:
    _out, err = run_cmd(base_app, "_relative_run_script")
    assert "Error: the following arguments" in err[1]
    assert base_app.last_result is None


def test_in_script(request) -> None:
    class HookApp(cmd2.Cmd):
        def __init__(self, *args, **kwargs) -> None:
            super().__init__(*args, **kwargs)
            self.register_cmdfinalization_hook(self.hook)

        def hook(self: cmd2.Cmd, data: plugin.CommandFinalizationData) -> plugin.CommandFinalizationData:
            if self.in_script():
                self.poutput("WE ARE IN SCRIPT")
            return data

    hook_app = HookApp()
    test_dir = os.path.dirname(request.module.__file__)
    filename = os.path.join(test_dir, "script.txt")
    out, _err = run_cmd(hook_app, f"run_script {filename}")

    assert "WE ARE IN SCRIPT" in out[-1]


def test_system_exit_in_command(base_app, capsys) -> None:
    """Test raising SystemExit in a command"""
    import types

    exit_code = 5

    def do_system_exit(self, _) -> NoReturn:
        raise SystemExit(exit_code)

    base_app.do_system_exit = types.MethodType(do_system_exit, base_app)

    stop = base_app.onecmd_plus_hooks("system_exit")
    assert stop
    assert base_app.exit_code == exit_code


def test_passthrough_exception_in_command(base_app) -> None:
    """Test raising a PassThroughException in a command"""
    import types

    expected_err = "Pass me up"

    def do_passthrough(self, _) -> NoReturn:
        wrapped_ex = OSError(expected_err)
        raise exceptions.PassThroughException(wrapped_ex=wrapped_ex)

    base_app.do_passthrough = types.MethodType(do_passthrough, base_app)

    with pytest.raises(OSError, match=expected_err):
        base_app.onecmd_plus_hooks("passthrough")


class RedirectionApp(cmd2.Cmd):
    def __init__(self, *args, **kwargs) -> None:
        super().__init__(*args, **kwargs)

    def do_print_output(self, _: str) -> None:
        """Print output to sys.stdout and self.stdout."""

        # Only data written to self.stdout is redirected.
        # Therefore the print() call will not be.
        print("print")
        self.poutput("poutput")

    def do_print_feedback(self, _: str) -> None:
        """Call pfeedback."""
        self.pfeedback("feedback")


@pytest.fixture
def redirection_app() -> RedirectionApp:
    return RedirectionApp()


def test_output_redirection(redirection_app: RedirectionApp, capsys: pytest.CaptureFixture[str]) -> None:
    """Verify that redirection captures data written to self.stdout and not sys.stdout."""
    fd, filename = tempfile.mkstemp(prefix="cmd2_test", suffix=".txt")
    os.close(fd)

    try:
        # Verify that writing to a file works
        run_cmd(redirection_app, f"print_output > {filename}")

        # Verify print() went to sys.stdout
        out, _err = capsys.readouterr()
        assert out == "print\n"

        with open(filename) as f:
            lines = f.read().splitlines()

        # Only data written to self.stdout should be in the file
        assert len(lines) == 1
        assert lines[0] == "poutput"

        # Verify that appending to a file also works
        run_cmd(redirection_app, f"print_output >> {filename}")

        out, _err = capsys.readouterr()
        assert out == "print\n"

        with open(filename) as f:
            lines = f.read().splitlines()

        assert len(lines) == 2
        assert lines[0] == "poutput"
        assert lines[1] == "poutput"
    finally:
        os.remove(filename)


def test_output_redirection_to_nonexistent_directory(redirection_app) -> None:
    filename = "~/fakedir/this_does_not_exist.txt"

    _out, err = run_cmd(redirection_app, f"print_output > {filename}")
    assert "Failed to redirect" in err[0]

    _out, err = run_cmd(redirection_app, f"print_output >> {filename}")
    assert "Failed to redirect" in err[0]


def test_output_redirection_to_too_long_filename(redirection_app) -> None:
    filename = (
        "~/sdkfhksdjfhkjdshfkjsdhfkjsdhfkjdshfkjdshfkjshdfkhdsfkjhewfuihewiufhweiufhiweufhiuewhiuewhfiuwehfia"
        "ewhfiuewhfiuewhfiuewhiuewhfiuewhfiuewfhiuwehewiufhewiuhfiweuhfiuwehfiuewfhiuwehiuewfhiuewhiewuhfiueh"
        "fiuwefhewiuhewiufhewiufhewiufhewiufhewiufhewiufhewiufhewiuhewiufhewiufhewiuheiufhiuewheiwufhewiufheu"
        "fheiufhieuwhfewiuhfeiufhiuewfhiuewheiwuhfiuewhfiuewhfeiuwfhewiufhiuewhiuewhfeiuwhfiuwehfuiwehfiuehie"
        "whfieuwfhieufhiuewhfeiuwfhiuefhueiwhfw"
    )

    _out, err = run_cmd(redirection_app, f"print_output > {filename}")
    assert "Failed to redirect" in err[0]

    _out, err = run_cmd(redirection_app, f"print_output >> {filename}")
    assert "Failed to redirect" in err[0]


def test_feedback(redirection_app) -> None:
    f, filename = tempfile.mkstemp(prefix="cmd2_test", suffix=".txt")
    os.close(f)

    try:
        run_cmd(redirection_app, f"print_feedback > {filename}")
        with open(filename) as f:
            content = f.read().splitlines()
        assert "feedback" in content
    finally:
        os.remove(filename)


def test_disallow_redirection(redirection_app: RedirectionApp, capsys: pytest.CaptureFixture[str]) -> None:
    # Set allow_redirection to False
    redirection_app.allow_redirection = False

    filename = "test_allow_redirect.txt"

    # Verify output wasn't redirected
    out, _err = run_cmd(redirection_app, f"print_output > {filename}")
    assert "poutput" in out

    # Verify that no file was created
    assert not os.path.exists(filename)


def test_pipe_to_shell(redirection_app: RedirectionApp, capsys: pytest.CaptureFixture[str]) -> None:
    out, err = run_cmd(redirection_app, "print_output | sort")

    # Verify print() went to sys.stdout
    captured = capsys.readouterr()
    assert captured.out == "print\n"

    # Verify only data written to self.stdout was piped
    assert "print" not in out
    assert "poutput" in out
    assert not err


def test_pipe_to_shell_and_redirect(redirection_app) -> None:
    filename = "out.txt"
    out, err = run_cmd(redirection_app, f"print_output | sort > {filename}")
    assert not out
    assert not err
    assert os.path.exists(filename)
    os.remove(filename)


def test_pipe_to_shell_error(redirection_app) -> None:
    # Try to pipe command output to a shell command that doesn't exist in order to produce an error
    out, err = run_cmd(redirection_app, "print_output | foobarbaz.this_does_not_exist")
    assert not out
    assert "Pipe process exited with code" in err[0]


try:
    # try getting the contents of the clipboard
    _ = clipboard.get_paste_buffer()
    # pyperclip raises at least the following types of exceptions
    #   FileNotFoundError on Windows Subsystem for Linux (WSL) when Windows paths are removed from $PATH
    #   ValueError for headless Linux systems without Gtk installed
    #   AssertionError can be raised by paste_klipper().
    #   PyperclipException for pyperclip-specific exceptions
except Exception:  # noqa: BLE001
    can_paste = False
else:
    can_paste = True


@pytest.mark.skipif(not can_paste, reason="Pyperclip could not find a copy/paste mechanism for your system")
def test_send_to_paste_buffer(redirection_app: RedirectionApp, capsys: pytest.CaptureFixture[str]) -> None:
    # Test writing to the PasteBuffer/Clipboard
    run_cmd(redirection_app, "print_output >")

    # Verify print() went to sys.stdout
    out, _err = capsys.readouterr()
    assert out == "print\n"

    lines = cmd2.clipboard.get_paste_buffer().splitlines()
    assert len(lines) == 1
    assert lines[0] == "poutput"

    # Test appending to the PasteBuffer/Clipboard
    run_cmd(redirection_app, "print_output >>")

    out, _err = capsys.readouterr()
    assert out == "print\n"

    lines = cmd2.clipboard.get_paste_buffer().splitlines()
    assert len(lines) == 2
    assert lines[0] == "poutput"
    assert lines[1] == "poutput"


def test_get_paste_buffer_exception(redirection_app, mocker, capsys) -> None:
    # Force get_paste_buffer to throw an exception
    pastemock = mocker.patch("pyperclip.paste")
    pastemock.side_effect = ValueError("foo")

    # Redirect command output to the clipboard
    redirection_app.onecmd_plus_hooks("print_output > ")

    # Make sure we got the exception output
    out, err = capsys.readouterr()
    assert out == ""
    # this just checks that cmd2 is surfacing whatever error gets raised by pyperclip.paste
    assert "ValueError" in err
    assert "foo" in err


def test_allow_clipboard_initializer(redirection_app) -> None:
    assert redirection_app.allow_clipboard is True
    noclipcmd = cmd2.Cmd(allow_clipboard=False)
    assert noclipcmd.allow_clipboard is False


# if clipboard access is not allowed, cmd2 should check that first
# before it tries to do anything with pyperclip, that's why we can
# safely run this test without skipping it if pyperclip doesn't
# work in the test environment, like we do for test_send_to_paste_buffer()
def test_allow_clipboard(base_app) -> None:
    base_app.allow_clipboard = False
    out, err = run_cmd(base_app, "help >")
    assert not out
    assert "Clipboard access not allowed" in err


def test_base_timing(base_app) -> None:
    out, err = run_cmd(base_app, "set timing True")
    assert out[0].startswith("timing")
    assert out[0].endswith("─> True")

    if sys.platform == "win32":
        assert err[0].startswith("Elapsed: 0:00:00")
    else:
        assert err[0].startswith("Elapsed: 0:00:00.0")


def test_base_debug(base_app) -> None:
    # Purposely set the editor to None
    base_app.editor = None

    # Make sure we get an exception, but cmd2 handles it
    out, err = run_cmd(base_app, "edit")
    assert "ValueError: Please use 'set editor'" in err[0]
    assert "To enable full traceback" in err[3]

    # Set debug true
    out, err = run_cmd(base_app, "set debug True")
    assert not err
    assert out[0].startswith("debug")
    assert out[0].endswith("─> True")

    # Verify that we now see the exception traceback
    out, err = run_cmd(base_app, "edit")
    assert "Traceback (most recent call last)" in err[0]


def test_debug_not_settable(base_app) -> None:
    # Set debug to False and make it unsettable
    base_app.debug = False
    base_app.remove_settable("debug")

    # Cause an exception by setting editor to None and running edit
    base_app.editor = None
    _out, err = run_cmd(base_app, "edit")

    # Since debug is unsettable, the user will not be given the option to enable a full traceback
    assert err == ["ValueError: Please use 'set editor' to specify your text editing program of", "choice."]


def test_blank_exception(mocker, base_app):
    mocker.patch("cmd2.Cmd.do_help", side_effect=Exception)
    _out, err = run_cmd(base_app, "help")

    # When an exception has no message, the first error line is just its type.
    assert err[0] == "Exception"


def test_remove_settable_keyerror(base_app) -> None:
    with pytest.raises(KeyError):
        base_app.remove_settable("fake")


def test_edit_file(base_app, request, monkeypatch) -> None:
    # Set a fake editor just to make sure we have one.  We aren't really going to call it due to the mock
    base_app.editor = "fooedit"

    # Mock out the subprocess.Popen call so we don't actually open an editor
    m = mock.MagicMock(name="Popen")
    monkeypatch.setattr("subprocess.Popen", m)

    test_dir = os.path.dirname(request.module.__file__)
    filename = os.path.join(test_dir, "script.txt")

    run_cmd(base_app, f"edit {filename}")

    # We think we have an editor, so should expect a Popen call
    m.assert_called_once()


@pytest.mark.parametrize("file_name", odd_file_names)
def test_edit_file_with_odd_file_names(base_app, file_name, monkeypatch) -> None:
    """Test editor and file names with various patterns"""
    # Mock out the do_shell call to see what args are passed to it
    shell_mock = mock.MagicMock(name="do_shell")
    monkeypatch.setattr("cmd2.Cmd.do_shell", shell_mock)

    base_app.editor = "fooedit"
    file_name = su.quote("nothingweird.py")
    run_cmd(base_app, f"edit {su.quote(file_name)}")
    shell_mock.assert_called_once_with(f'"fooedit" {su.quote(file_name)}')


def test_edit_file_with_spaces(base_app, request, monkeypatch) -> None:
    # Set a fake editor just to make sure we have one.  We aren't really going to call it due to the mock
    base_app.editor = "fooedit"

    # Mock out the subprocess.Popen call so we don't actually open an editor
    m = mock.MagicMock(name="Popen")
    monkeypatch.setattr("subprocess.Popen", m)

    test_dir = os.path.dirname(request.module.__file__)
    filename = os.path.join(test_dir, "my commands.txt")

    run_cmd(base_app, f'edit "{filename}"')

    # We think we have an editor, so should expect a Popen call
    m.assert_called_once()


def test_edit_blank(base_app, monkeypatch) -> None:
    # Set a fake editor just to make sure we have one.  We aren't really going to call it due to the mock
    base_app.editor = "fooedit"

    # Mock out the subprocess.Popen call so we don't actually open an editor
    m = mock.MagicMock(name="Popen")
    monkeypatch.setattr("subprocess.Popen", m)

    run_cmd(base_app, "edit")

    # We have an editor, so should expect a Popen call
    m.assert_called_once()


def test_base_py_interactive(base_app) -> None:
    # Mock out the InteractiveConsole.interact() call so we don't actually wait for a user's response on stdin
    m = mock.MagicMock(name="interact")
    InteractiveConsole.interact = m

    run_cmd(base_app, "py")

    # Make sure our mock was called once and only once
    m.assert_called_once()


def test_base_cmdloop_with_startup_commands() -> None:
    intro = "Hello World, this is an intro ..."

    # Need to patch sys.argv so cmd2 doesn't think it was called with arguments equal to the py.test args
    testargs = ["prog", "quit"]
    expected = intro + "\n"

    with mock.patch.object(sys, "argv", testargs):
        app = create_outsim_app()

    # Run the command loop with custom intro
    app.cmdloop(intro=intro)

    out = app.stdout.getvalue()
    assert out == expected


def test_base_cmdloop_without_startup_commands(monkeypatch) -> None:
    # Need to patch sys.argv so cmd2 doesn't think it was called with arguments equal to the py.test args
    testargs = ["prog"]
    with mock.patch.object(sys, "argv", testargs):
        app = create_outsim_app()

    app.intro = "Hello World, this is an intro ..."

    read_command_mock = mock.MagicMock(name="_read_command_line", return_value="quit")
    monkeypatch.setattr("cmd2.Cmd._read_command_line", read_command_mock)

    expected = app.intro + "\n"

    # Run the command loop
    app.cmdloop()
    out = app.stdout.getvalue()
    assert out == expected


def test_cmdfinalizations_runs(base_app, monkeypatch) -> None:
    """Make sure _run_cmdfinalization_hooks is run after each command."""
    with (
        mock.patch("sys.stdin.isatty", mock.MagicMock(name="isatty", return_value=True)),
        mock.patch("sys.stdin.fileno", mock.MagicMock(name="fileno", return_value=0)),
    ):
        monkeypatch.setattr(base_app.stdin, "fileno", lambda: 0)
        monkeypatch.setattr(base_app.stdin, "isatty", lambda: True)

        cmd_fin = mock.MagicMock(name="cmdfinalization")
        monkeypatch.setattr("cmd2.Cmd._run_cmdfinalization_hooks", cmd_fin)

        base_app.onecmd_plus_hooks("help")
        cmd_fin.assert_called_once()


@pytest.mark.skipif(sys.platform.startswith("win"), reason="termios is not available on Windows")
@pytest.mark.parametrize(
    ("is_tty", "settings_set", "raised_exception", "should_call"),
    [
        (True, True, None, True),
        (True, True, "termios_error", True),
        (True, True, "unsupported_operation", True),
        (False, True, None, False),
        (True, False, None, False),
    ],
)
def test_restore_termios_settings(base_app, monkeypatch, is_tty, settings_set, raised_exception, should_call):
    """Test that terminal settings are restored after a command and that errors are suppressed."""
    import io
    import termios  # Mock termios since it's imported within the method

    termios_mock = mock.MagicMock()
    # The error attribute needs to be the actual exception for isinstance checks
    termios_mock.error = termios.error
    monkeypatch.setitem(sys.modules, "termios", termios_mock)

    # Set the exception to be raised by tcsetattr
    if raised_exception == "termios_error":
        termios_mock.tcsetattr.side_effect = termios.error("test termios error")
    elif raised_exception == "unsupported_operation":
        termios_mock.tcsetattr.side_effect = io.UnsupportedOperation("test io error")

    # Set initial termios settings so the logic will run
    if settings_set:
        termios_settings = ["dummy settings"]
        base_app._initial_termios_settings = termios_settings
    else:
        base_app._initial_termios_settings = None
        termios_settings = None  # for the assert

    # Mock stdin to make it look like a TTY
    monkeypatch.setattr(base_app.stdin, "isatty", lambda: is_tty)
    monkeypatch.setattr(base_app.stdin, "fileno", lambda: 0)

    # Run a command to trigger _run_cmdfinalization_hooks
    # This should not raise an exception
    base_app.onecmd_plus_hooks("help")

    # Verify that tcsetattr was called with the correct arguments
    if should_call:
        termios_mock.tcsetattr.assert_called_once_with(0, termios_mock.TCSANOW, termios_settings)
    else:
        termios_mock.tcsetattr.assert_not_called()


def test_sigint_handler(base_app) -> None:
    # No KeyboardInterrupt should be raised when using sigint_protection
    with base_app.sigint_protection:
        base_app.sigint_handler(signal.SIGINT, 1)

    # Without sigint_protection, a KeyboardInterrupt is raised
    with pytest.raises(KeyboardInterrupt):
        base_app.sigint_handler(signal.SIGINT, 1)


def test_raise_keyboard_interrupt(base_app) -> None:
    with pytest.raises(KeyboardInterrupt) as excinfo:
        base_app._raise_keyboard_interrupt()
    assert "Got a keyboard interrupt" in str(excinfo.value)


@pytest.mark.skipif(sys.platform.startswith("win"), reason="SIGTERM only handled on Linux/Mac")
def test_termination_signal_handler(base_app) -> None:
    with pytest.raises(SystemExit) as excinfo:
        base_app.termination_signal_handler(signal.SIGHUP, 1)
    assert excinfo.value.code == signal.SIGHUP + 128

    with pytest.raises(SystemExit) as excinfo:
        base_app.termination_signal_handler(signal.SIGTERM, 1)
    assert excinfo.value.code == signal.SIGTERM + 128


class HookFailureApp(cmd2.Cmd):
    def __init__(self, *args, **kwargs) -> None:
        super().__init__(*args, **kwargs)
        # register a postparsing hook method
        self.register_postparsing_hook(self.postparsing_precmd)

    def postparsing_precmd(self, data: cmd2.plugin.PostparsingData) -> cmd2.plugin.PostparsingData:
        """Simulate precmd hook failure."""
        data.stop = True
        return data


@pytest.fixture
def hook_failure():
    return HookFailureApp()


def test_precmd_hook_success(base_app) -> None:
    out = base_app.onecmd_plus_hooks("help")
    assert out is False


def test_precmd_hook_failure(hook_failure) -> None:
    out = hook_failure.onecmd_plus_hooks("help")
    assert out is True


class SayApp(cmd2.Cmd):
    def __init__(self, *args, **kwargs) -> None:
        super().__init__(*args, **kwargs)

    def do_say(self, arg) -> None:
        self.poutput(arg)


@pytest.fixture
def say_app():
    app = SayApp(allow_cli_args=False)
    app.stdout = utils.StdSim(app.stdout)
    return app


def test_ctrl_c_at_prompt(say_app, monkeypatch) -> None:
    read_command_mock = mock.MagicMock(name="_read_command_line")
    read_command_mock.side_effect = ["say hello", KeyboardInterrupt(), "say goodbye", "quit"]
    monkeypatch.setattr("cmd2.Cmd._read_command_line", read_command_mock)

    say_app.cmdloop()

    # And verify the expected output to stdout
    out = say_app.stdout.getvalue()
    assert out == "hello\n^C\ngoodbye\n"


def test_ctrl_d_at_prompt(say_app, monkeypatch) -> None:
    read_command_mock = mock.MagicMock(name="_read_command_line")
    read_command_mock.side_effect = ["say hello", EOFError()]
    monkeypatch.setattr("cmd2.Cmd._read_command_line", read_command_mock)

    say_app.cmdloop()

    # And verify the expected output to stdout
    out = say_app.stdout.getvalue()
    assert out == "hello\n\n"


@pytest.mark.skipif(
    sys.platform.startswith("win"),
    reason="Don't have a real Windows console with how we are currently running tests in GitHub Actions",
)
@pytest.mark.parametrize(
    ("msg", "prompt", "is_stale"),
    [
        ("msg_text", None, False),
        ("msg_text", "new_prompt> ", False),
        ("msg_text", "new_prompt> ", True),
        (None, "new_prompt> ", False),
        (None, "new_prompt> ", True),
        # Blank prompt is acceptable
        ("msg_text", "", False),
        (None, "", False),
    ],
)
def test_async_alert(base_app: cmd2.Cmd, msg: str, prompt: str, is_stale: bool) -> None:
    import time

    with (
        mock.patch("cmd2.cmd2.print_formatted_text") as mock_print,
        mock.patch("cmd2.cmd2.get_app") as mock_get_app,
    ):
        # Set up the chained mock: get_app() returns mock_app, which has invalidate()
        mock_app = mock.MagicMock()
        mock_get_app.return_value = mock_app

        base_app.add_alert(msg=msg, prompt=prompt)
        alert = base_app._alert_queue[0]

        # Stale means alert was created before the current prompt.
        if is_stale:
            # In the past
            alert.timestamp = 0.0
        else:
            # In the future
            alert.timestamp = time.monotonic() + 99999999

        with create_pipe_input() as pipe_input:
            base_app.main_session = PromptSession(
                input=pipe_input,
                output=DummyOutput(),
                history=base_app.main_session.history,
                completer=base_app.main_session.completer,
            )
            pipe_input.send_text("quit\n")

            base_app._cmdloop()

            # If there was a message, patch_stdout handles the redraw (no invalidate)
            if msg:
                assert msg in str(mock_print.call_args_list[0])
                mock_app.invalidate.assert_not_called()

            # If there's only a prompt update, we expect invalidate() only if not continuation/stale
            elif prompt is not None:
                if is_stale:
                    mock_app.invalidate.assert_not_called()
                else:
                    mock_app.invalidate.assert_called_once()

            # The state of base_app.prompt should always be correct regardless of redraw
            if prompt is not None:
                if is_stale:
                    assert base_app.prompt != prompt
                else:
                    assert base_app.prompt == prompt


@pytest.mark.skipif(
    sys.platform.startswith("win"),
    reason="Don't have a real Windows console with how we are currently running tests in GitHub Actions",
)
def test_async_alert_rich(base_app: cmd2.Cmd) -> None:
    from prompt_toolkit.formatted_text import ANSI
    from rich.table import Table

    table = Table("Header")
    table.add_row("Value")

    with (
        mock.patch("cmd2.cmd2.print_formatted_text") as mock_print,
        mock.patch("cmd2.cmd2.get_app") as mock_get_app,
    ):
        mock_app = mock.MagicMock()
        mock_get_app.return_value = mock_app

        # Add alert with a Rich table
        base_app.add_alert(msg=table, soft_wrap=False)

        with create_pipe_input() as pipe_input:
            base_app.main_session = PromptSession(
                input=pipe_input,
                output=DummyOutput(),
                history=base_app.main_session.history,
                completer=base_app.main_session.completer,
            )
            pipe_input.send_text("quit\n")
            base_app._cmdloop()

            # Verify that print_formatted_text was called with a formatted ANSI object
            assert mock_print.called
            args, _kwargs = mock_print.call_args
            printed_arg = args[0]
            assert isinstance(printed_arg, ANSI)

            # The printed text should contain our table values
            assert "Header" in printed_arg.value
            assert "Value" in printed_arg.value


def test_add_alert(base_app: cmd2.Cmd) -> None:
    base_app._alert_queue.clear()

    # Nothing is added when both are None
    base_app.add_alert(msg=None, prompt=None)
    assert not base_app._alert_queue

    # Test that soft_wrap defaults to True
    base_app.add_alert(msg="Hello", prompt=None)
    last_alert = base_app._alert_queue[-1]
    assert last_alert.msg == "Hello"
    assert last_alert.soft_wrap is True
    assert last_alert.prompt is None

    # Test that soft_wrap can be set to True
    base_app.add_alert(msg="Hello", soft_wrap=True, prompt=None)
    last_alert = base_app._alert_queue[-1]
    assert last_alert.msg == "Hello"
    assert last_alert.soft_wrap is True
    assert last_alert.prompt is None

    # Test that soft_wrap can be set to False
    base_app.add_alert(msg="Hello", soft_wrap=False, prompt=None)
    last_alert = base_app._alert_queue[-1]
    assert last_alert.msg == "Hello"
    assert last_alert.soft_wrap is False
    assert last_alert.prompt is None

    # Test only prompt
    base_app.add_alert(msg=None, prompt="prompt> ")
    last_alert = base_app._alert_queue[-1]
    assert last_alert.msg is None
    assert last_alert.soft_wrap is True
    assert last_alert.prompt == "prompt> "


def test_visible_prompt() -> None:
    app = cmd2.Cmd()

    # This prompt has nothing which needs to be stripped
    app.prompt = "(Cmd) "
    assert app.visible_prompt == app.prompt
    assert su.str_width(app.prompt) == len(app.prompt)

    # This prompt has color which needs to be stripped
    color_prompt = stylize("InColor", style=Color.CYAN) + "> "
    app.prompt = color_prompt
    assert app.visible_prompt == "InColor> "
    assert su.str_width(app.prompt) == len("InColor> ")


class HelpApp(cmd2.Cmd):
    """Class for testing custom help_* methods which override docstring help."""

    DEFAULT_CATEGORY = "My Default Category."
    MISC_HEADER = "Various topics found here."

    def __init__(self, *args, **kwargs) -> None:
        super().__init__(*args, **kwargs)
        self.doc_leader = "I now present you with a list of help topics."

    def do_squat(self, arg) -> None:
        """This docstring help will never be shown because the help_squat method overrides it."""

    def help_squat(self) -> None:
        self.stdout.write("This command does diddly squat...\n")

    def do_edit(self, arg) -> None:
        """This overrides the edit command and does nothing."""

    # This command has no help text
    def do_no_help(self, arg) -> None:
        pass

    def do_multiline_docstr(self, arg) -> None:
        """This documentation
        is multiple lines
        and there are no
        tabs
        """

    def help_physics(self):
        """A miscellaneous help topic."""
        self.poutput("Here is some help on physics.")

    parser_cmd_parser = cmd2.Cmd2ArgumentParser(description="This is the description.")

    @cmd2.with_argparser(parser_cmd_parser)
    def do_parser_cmd(self, args) -> None:
        """This is the docstring."""


@pytest.fixture
def help_app():
    return HelpApp()


def test_help_headers(capsys) -> None:
    help_app = HelpApp()
    help_app.onecmd_plus_hooks("help")
    out, _err = capsys.readouterr()

    assert help_app.doc_leader in out
    assert HelpApp.DEFAULT_CATEGORY in out
    assert HelpApp.MISC_HEADER in out
    assert help_app.last_result is True


def test_custom_command_help(help_app) -> None:
    out, _err = run_cmd(help_app, "help squat")
    expected = normalize("This command does diddly squat...")
    assert out == expected
    assert help_app.last_result is True


def test_custom_help_menu(help_app) -> None:
    out, _err = run_cmd(help_app, "help")
    verify_help_text(help_app, out)
    assert help_app.last_result is True


def test_help_no_help(help_app) -> None:
    _out, err = run_cmd(help_app, "help no_help")
    assert err[0].startswith("No help on no_help")
    assert help_app.last_result is False


def test_help_overridden_method(help_app) -> None:
    out, _err = run_cmd(help_app, "help edit")
    expected = normalize("This overrides the edit command and does nothing.")
    assert out == expected
    assert help_app.last_result is True


def test_help_multiline_docstring(help_app) -> None:
    out, _err = run_cmd(help_app, "help multiline_docstr")
    expected = normalize("This documentation\nis multiple lines\nand there are no\ntabs")
    assert out == expected
    assert help_app.last_result is True


def test_miscellaneous_help_topic(help_app) -> None:
    out, _err = run_cmd(help_app, "help physics")
    expected = normalize("Here is some help on physics.")
    assert out == expected
    assert help_app.last_result is True


def test_help_verbose(help_app: HelpApp) -> None:
    out, _err = run_cmd(help_app, "help --verbose")
    expected_verbose = utils.strip_doc_annotations(help_app.do_parser_cmd.__doc__)
    verify_help_text(help_app, out, verbose_strings=[expected_verbose])


def test_help_verbose_with_fake_command(capsys) -> None:
    """Verify that only actual command functions appear in verbose output."""
    help_app = HelpApp()

    cmds = ["alias", "fake_command"]
    help_app._print_documented_command_topics(help_app.DEFAULT_CATEGORY, cmds, verbose=True)
    out, _err = capsys.readouterr()
    assert cmds[0] in out
    assert cmds[1] not in out


def test_render_columns_no_strs(help_app: HelpApp) -> None:
    no_strs = []
    result = help_app.render_columns(no_strs)
    assert result == ""


def test_render_columns_one_str(help_app: HelpApp) -> None:
    one_str = ["one_string"]
    result = help_app.render_columns(one_str)
    assert result == "one_string"


def test_render_columns_too_wide(help_app: HelpApp) -> None:
    commands = ["kind_of_long_string", "a_slightly_longer_string"]
    result = help_app.render_columns(commands, display_width=10)

    expected = "kind_of_long_string     \na_slightly_longer_string"
    assert result == expected


def test_columnize(capsys: pytest.CaptureFixture[str]) -> None:
    help_app = HelpApp()
    items = ["one", "two"]
    help_app.columnize(items)
    out, _err = capsys.readouterr()

    # poutput() adds a newline at the end.
    expected = "one  two\n"
    assert out == expected


class HelpCategoriesApp(cmd2.Cmd):
    """Class for testing custom help_* methods which override docstring help."""

    SOME_CATEGORY = "Some Category"
    CUSTOM_CATEGORY = "Custom Category"

    def __init__(self, *args, **kwargs) -> None:
        super().__init__(*args, **kwargs)

    @cmd2.with_category("Some Category")
    def do_diddly(self, arg) -> None:
        """This command does diddly"""

    # This command will be in the "Some Category" section of the help menu even though it has no docstring
    @cmd2.with_category(SOME_CATEGORY)
    def do_cat_nodoc(self, arg) -> None:
        pass

    # This command will show in the category labeled with DEFAULT_CATEGORY
    def do_squat(self, arg) -> None:
        """This docstring help will never be shown because the help_squat method overrides it."""

    def help_squat(self) -> None:
        self.stdout.write("This command does diddly squat...\n")

    def do_edit(self, arg) -> None:
        """This overrides the edit command and does nothing."""

    cmd2.categorize((do_squat, do_edit), CUSTOM_CATEGORY)


@pytest.fixture
def helpcat_app():
    return HelpCategoriesApp()


def test_help_cat_base(helpcat_app) -> None:
    out, _err = run_cmd(helpcat_app, "help")
    assert helpcat_app.last_result is True
    verify_help_text(helpcat_app, out)

    help_text = "".join(out)
    assert helpcat_app.CUSTOM_CATEGORY in help_text
    assert helpcat_app.SOME_CATEGORY in help_text
    assert helpcat_app.DEFAULT_CATEGORY in help_text


def test_help_cat_verbose(helpcat_app) -> None:
    out, _err = run_cmd(helpcat_app, "help --verbose")
    assert helpcat_app.last_result is True
    verify_help_text(helpcat_app, out)

    help_text = "".join(out)
    assert helpcat_app.CUSTOM_CATEGORY in help_text
    assert helpcat_app.SOME_CATEGORY in help_text
    assert helpcat_app.DEFAULT_CATEGORY in help_text


class SelectApp(cmd2.Cmd):
    def do_eat(self, arg) -> None:
        """Eat something, with a selection of sauces to choose from."""
        # Pass in a single string of space-separated selections
        sauce = self.select("sweet salty", "Sauce? ")
        result = "{food} with {sauce} sauce, yum!"
        result = result.format(food=arg, sauce=sauce)
        self.stdout.write(result + "\n")

    def do_study(self, arg) -> None:
        """Learn something, with a selection of subjects to choose from."""
        # Pass in a list of strings for selections
        subject = self.select(["math", "science"], "Subject? ")
        result = f"Good luck learning {subject}!\n"
        self.stdout.write(result)

    def do_procrastinate(self, arg) -> None:
        """Waste time in your manner of choice."""
        # Pass in a list of tuples for selections
        leisure_activity = self.select(
            [("Netflix and chill", "Netflix"), ("YouTube", "WebSurfing")], "How would you like to procrastinate? "
        )
        result = f"Have fun procrasinating with {leisure_activity}!\n"
        self.stdout.write(result)

    def do_play(self, arg) -> None:
        """Play your favorite musical instrument."""
        # Pass in an uneven list of tuples for selections
        instrument = self.select([("Guitar", "Electric Guitar"), ("Drums",)], "Instrument? ")
        result = f"Charm us with the {instrument}...\n"
        self.stdout.write(result)

    def do_return_type(self, arg) -> None:
        """Test that return values can be non-strings"""
        choice = self.select([(1, "Integer"), ("test_str", "String"), (self.do_play, "Method")], "Choice? ")
        result = f"The return type is {type(choice)}\n"
        self.stdout.write(result)


@pytest.fixture
def select_app():
    return SelectApp()


def test_select_options(select_app, monkeypatch) -> None:
    read_input_mock = mock.MagicMock(name="read_input", return_value="2")
    monkeypatch.setattr("cmd2.Cmd.read_input", read_input_mock)

    food = "bacon"
    out, _err = run_cmd(select_app, f"eat {food}")
    expected = normalize(
        f"""
   1. sweet
   2. salty
{food} with salty sauce, yum!
"""
    )

    # Make sure our mock was called with the expected arguments
    read_input_mock.assert_called_once_with("Sauce? ")

    # And verify the expected output to stdout
    assert out == expected


def test_select_invalid_option_too_big(select_app, monkeypatch) -> None:
    read_input_mock = mock.MagicMock(name="read_input")

    # If side_effect is an iterable then each call to the mock will return the next value from the iterable.
    read_input_mock.side_effect = ["3", "1"]  # First pass an invalid selection, then pass a valid one
    monkeypatch.setattr("cmd2.Cmd.read_input", read_input_mock)

    food = "fish"
    out, _err = run_cmd(select_app, f"eat {food}")
    expected = normalize(
        f"""
   1. sweet
   2. salty
'3' isn't a valid choice. Pick a number between 1 and 2:
{food} with sweet sauce, yum!
"""
    )

    # Make sure our mock was called exactly twice with the expected arguments
    arg = "Sauce? "
    calls = [mock.call(arg), mock.call(arg)]
    read_input_mock.assert_has_calls(calls)
    assert read_input_mock.call_count == 2

    # And verify the expected output to stdout
    assert out == expected


def test_select_invalid_option_too_small(select_app, monkeypatch) -> None:
    read_input_mock = mock.MagicMock(name="read_input")

    # If side_effect is an iterable then each call to the mock will return the next value from the iterable.
    read_input_mock.side_effect = ["0", "1"]  # First pass an invalid selection, then pass a valid one
    monkeypatch.setattr("cmd2.Cmd.read_input", read_input_mock)

    food = "fish"
    out, _err = run_cmd(select_app, f"eat {food}")
    expected = normalize(
        f"""
   1. sweet
   2. salty
'0' isn't a valid choice. Pick a number between 1 and 2:
{food} with sweet sauce, yum!
"""
    )

    # Make sure our mock was called exactly twice with the expected arguments
    arg = "Sauce? "
    calls = [mock.call(arg), mock.call(arg)]
    read_input_mock.assert_has_calls(calls)
    assert read_input_mock.call_count == 2

    # And verify the expected output to stdout
    assert out == expected


def test_select_list_of_strings(select_app, monkeypatch) -> None:
    read_input_mock = mock.MagicMock(name="read_input", return_value="2")
    monkeypatch.setattr("cmd2.Cmd.read_input", read_input_mock)

    out, _err = run_cmd(select_app, "study")
    expected = normalize(
        """
   1. math
   2. science
Good luck learning {}!
""".format("science")
    )

    # Make sure our mock was called with the expected arguments
    read_input_mock.assert_called_once_with("Subject? ")

    # And verify the expected output to stdout
    assert out == expected


def test_select_list_of_tuples(select_app, monkeypatch) -> None:
    read_input_mock = mock.MagicMock(name="read_input", return_value="2")
    monkeypatch.setattr("cmd2.Cmd.read_input", read_input_mock)

    out, _err = run_cmd(select_app, "procrastinate")
    expected = normalize(
        """
   1. Netflix
   2. WebSurfing
Have fun procrasinating with {}!
""".format("YouTube")
    )

    # Make sure our mock was called with the expected arguments
    read_input_mock.assert_called_once_with("How would you like to procrastinate? ")

    # And verify the expected output to stdout
    assert out == expected


def test_select_uneven_list_of_tuples(select_app, monkeypatch) -> None:
    read_input_mock = mock.MagicMock(name="read_input", return_value="2")
    monkeypatch.setattr("cmd2.Cmd.read_input", read_input_mock)

    out, _err = run_cmd(select_app, "play")
    expected = normalize(
        """
   1. Electric Guitar
   2. Drums
Charm us with the {}...
""".format("Drums")
    )

    # Make sure our mock was called with the expected arguments
    read_input_mock.assert_called_once_with("Instrument? ")

    # And verify the expected output to stdout
    assert out == expected


@pytest.mark.parametrize(
    ("selection", "type_str"),
    [
        ("1", "<class 'int'>"),
        ("2", "<class 'str'>"),
        ("3", "<class 'method'>"),
    ],
)
def test_select_return_type(select_app, monkeypatch, selection, type_str) -> None:
    read_input_mock = mock.MagicMock(name="read_input", return_value=selection)
    monkeypatch.setattr("cmd2.Cmd.read_input", read_input_mock)

    out, _err = run_cmd(select_app, "return_type")
    expected = normalize(
        f"""
   1. Integer
   2. String
   3. Method
The return type is {type_str}
"""
    )

    # Make sure our mock was called with the expected arguments
    read_input_mock.assert_called_once_with("Choice? ")

    # And verify the expected output to stdout
    assert out == expected


def test_select_eof(select_app, monkeypatch) -> None:
    # Ctrl-D during select causes an EOFError that just reprompts the user
    read_input_mock = mock.MagicMock(name="read_input", side_effect=[EOFError, 2])
    monkeypatch.setattr("cmd2.Cmd.read_input", read_input_mock)

    food = "fish"
    _out, _err = run_cmd(select_app, f"eat {food}")

    # Make sure our mock was called exactly twice with the expected arguments
    arg = "Sauce? "
    calls = [mock.call(arg), mock.call(arg)]
    read_input_mock.assert_has_calls(calls)
    assert read_input_mock.call_count == 2


def test_select_ctrl_c(outsim_app, monkeypatch) -> None:
    # Ctrl-C during select prints ^C and raises a KeyboardInterrupt
    read_input_mock = mock.MagicMock(name="read_input", side_effect=KeyboardInterrupt)
    monkeypatch.setattr("cmd2.Cmd.read_input", read_input_mock)

    with pytest.raises(KeyboardInterrupt):
        outsim_app.select([("Guitar", "Electric Guitar"), ("Drums",)], "Instrument? ")

    out = outsim_app.stdout.getvalue()
    assert out.rstrip().endswith("^C")


def test_select_choice_tty(outsim_app, monkeypatch) -> None:
    # Mock choice to return the first option
    choice_mock = mock.MagicMock(name="choice", return_value="sweet")
    monkeypatch.setattr("cmd2.cmd2.choice", choice_mock)

    prompt = "Sauce? "
    options = ["sweet", "salty"]

    with create_pipe_input() as pipe_input:
        outsim_app.main_session = PromptSession(
            input=pipe_input,
            output=DummyOutput(),
        )

        result = outsim_app.select(options, prompt)

    assert result == "sweet"
    choice_mock.assert_called_once_with(message=prompt, options=[("sweet", "sweet"), ("salty", "salty")])


def test_select_choice_tty_ctrl_c(outsim_app, monkeypatch) -> None:
    # Mock choice to raise KeyboardInterrupt
    choice_mock = mock.MagicMock(name="choice", side_effect=KeyboardInterrupt)
    monkeypatch.setattr("cmd2.cmd2.choice", choice_mock)

    prompt = "Sauce? "
    options = ["sweet", "salty"]

    # Mock isatty to be True for both stdin and stdout
    with create_pipe_input() as pipe_input:
        outsim_app.main_session = PromptSession(
            input=pipe_input,
            output=DummyOutput(),
        )

        with pytest.raises(KeyboardInterrupt):
            outsim_app.select(options, prompt)

        out = outsim_app.stdout.getvalue()
    assert out.rstrip().endswith("^C")


def test_select_uneven_tuples_labels(outsim_app, monkeypatch) -> None:
    # Test that uneven tuples still work and labels are handled correctly
    # Case 1: (value, label) - normal
    # Case 2: (value,) - label should be value
    # Case 3: (value, None) - label should be value
    options = [("v1", "l1"), ("v2",), ("v3", None)]

    # Mock read_input to return '1'
    read_input_mock = mock.MagicMock(name="read_input", return_value="1")
    monkeypatch.setattr("cmd2.Cmd.read_input", read_input_mock)

    result = outsim_app.select(options, "Choice? ")
    assert result == "v1"

    out = outsim_app.stdout.getvalue()
    assert "1. l1" in out
    assert "2. v2" in out
    assert "3. v3" in out


def test_select_indexable_no_len(outsim_app, monkeypatch) -> None:
    # Test that an object with __getitem__ but no __len__ works.
    # This covers the except (IndexError, TypeError) block in select()
    class IndexableNoLen:
        def __getitem__(self, item: int) -> str:
            if item == 0:
                return "value"
            raise IndexError

    # Mock read_input to return '1'
    read_input_mock = mock.MagicMock(name="read_input", return_value="1")
    monkeypatch.setattr("cmd2.Cmd.read_input", read_input_mock)

    options = [IndexableNoLen()]
    result = outsim_app.select(options, "Choice? ")
    assert result == "value"

    out = outsim_app.stdout.getvalue()
    assert "1. value" in out


class HelpNoDocstringApp(cmd2.Cmd):
    greet_parser = cmd2.Cmd2ArgumentParser()
    greet_parser.add_argument("-s", "--shout", action="store_true", help="N00B EMULATION MODE")

    @cmd2.with_argparser(greet_parser, with_unknown_args=True)
    def do_greet(self, opts, arg) -> None:
        arg = "".join(arg)
        if opts.shout:
            arg = arg.upper()
        self.stdout.write(arg + "\n")


def test_help_with_no_docstring(capsys) -> None:
    app = HelpNoDocstringApp()
    app.onecmd_plus_hooks("greet -h")
    out, err = capsys.readouterr()
    assert err == ""
    assert (
        out
        == """Usage: greet [-h] [-s]

Options:
  -h, --help   show this help message and exit
  -s, --shout  N00B EMULATION MODE

"""
    )


class MultilineApp(cmd2.Cmd):
    def __init__(self, *args, **kwargs) -> None:
        super().__init__(*args, multiline_commands=["orate"], **kwargs)

    orate_parser = cmd2.Cmd2ArgumentParser()
    orate_parser.add_argument("-s", "--shout", action="store_true", help="N00B EMULATION MODE")

    @cmd2.with_argparser(orate_parser, with_unknown_args=True)
    def do_orate(self, opts, arg) -> None:
        arg = "".join(arg)
        if opts.shout:
            arg = arg.upper()
        self.stdout.write(arg + "\n")


@pytest.fixture
def multiline_app():
    return MultilineApp()


def test_multiline_complete_empty_statement_raises_exception(multiline_app) -> None:
    with pytest.raises(exceptions.EmptyStatement):
        multiline_app._complete_statement("")


def test_multiline_complete_statement_without_terminator(multiline_app, monkeypatch) -> None:
    read_command_mock = mock.MagicMock(name="_read_command_line", return_value="\n")
    monkeypatch.setattr("cmd2.Cmd._read_command_line", read_command_mock)

    command = "orate"
    args = "hello world"
    line = f"{command} {args}"
    statement = multiline_app._complete_statement(line)
    assert statement == args
    assert statement.command == command
    assert statement.multiline_command


def test_multiline_complete_statement_with_unclosed_quotes(multiline_app, monkeypatch) -> None:
    read_command_mock = mock.MagicMock(name="_read_command_line", side_effect=["quotes", '" now closed;'])
    monkeypatch.setattr("cmd2.Cmd._read_command_line", read_command_mock)

    line = 'orate hi "partially open'
    statement = multiline_app._complete_statement(line)
    assert statement == 'hi "partially open\nquotes\n" now closed'
    assert statement.command == "orate"
    assert statement.multiline_command
    assert statement.terminator == ";"


def test_multiline_input_line_to_statement(multiline_app, monkeypatch) -> None:
    # Verify _input_line_to_statement saves the fully entered input line for multiline commands
    read_command_mock = mock.MagicMock(name="_read_command_line", side_effect=["person", "\n"])
    monkeypatch.setattr("cmd2.Cmd._read_command_line", read_command_mock)

    line = "orate hi"
    statement = multiline_app._input_line_to_statement(line)
    assert statement.raw == "orate hi\nperson\n\n"
    assert statement == "hi person"
    assert statement.command == "orate"
    assert statement.multiline_command


def test_multiline_history_added(multiline_app, monkeypatch) -> None:
    # Test that multiline commands are added to history as a single item
    run_cmd(multiline_app, "history --clear")

    read_command_mock = mock.MagicMock(name="_read_command_line", side_effect=["person", "\n"])
    monkeypatch.setattr("cmd2.Cmd._read_command_line", read_command_mock)

    # run_cmd calls onecmd_plus_hooks which triggers history addition
    run_cmd(multiline_app, "orate hi")

    assert len(multiline_app.history) == 1
    assert multiline_app.history.get(1).raw == "orate hi\nperson\n\n"


def test_multiline_history_with_quotes(multiline_app, monkeypatch) -> None:
    # Test combined multiline command with quotes is added to history correctly
    run_cmd(multiline_app, "history --clear")

    read_command_mock = mock.MagicMock(name="_read_command_line", side_effect=["  and spaces  ", ' "', " in", "quotes.", ";"])
    monkeypatch.setattr("cmd2.Cmd._read_command_line", read_command_mock)

    line = 'orate Look, "There are newlines'
    run_cmd(multiline_app, line)

    assert len(multiline_app.history) == 1
    history_item = multiline_app.history.get(1)
    history_lines = history_item.raw.splitlines()
    assert history_lines[0] == 'orate Look, "There are newlines'
    assert history_lines[1] == "  and spaces  "
    assert history_lines[2] == ' "'
    assert history_lines[3] == " in"
    assert history_lines[4] == "quotes."
    assert history_lines[5] == ";"


def test_multiline_complete_statement_eof(multiline_app, monkeypatch):
    # Mock poutput to verify it's called
    poutput_mock = mock.MagicMock(name="poutput")
    monkeypatch.setattr(multiline_app, "poutput", poutput_mock)

    read_raw_mock = mock.MagicMock(name="_read_raw_input", side_effect=EOFError)
    monkeypatch.setattr("cmd2.Cmd._read_raw_input", read_raw_mock)

    command = "orate"
    args = "hello world"
    line = f"{command} {args}"

    # This should call _read_command_line, get 'eof', set nextline to '\n',
    # and then parse the line with the newline terminator.
    statement = multiline_app._complete_statement(line)

    assert statement.command == command
    assert statement.args == args
    assert statement.terminator == "\n"

    # Verify that poutput('\n') was called
    poutput_mock.assert_called_once_with("\n")


class CommandResultApp(cmd2.Cmd):
    def __init__(self, *args, **kwargs) -> None:
        super().__init__(*args, **kwargs)

    def do_affirmative(self, arg) -> None:
        self.last_result = cmd2.CommandResult(arg, data=True)

    def do_negative(self, arg) -> None:
        self.last_result = cmd2.CommandResult(arg, data=False)

    def do_affirmative_no_data(self, arg) -> None:
        self.last_result = cmd2.CommandResult(arg)

    def do_negative_no_data(self, arg) -> None:
        self.last_result = cmd2.CommandResult("", arg)


@pytest.fixture
def commandresult_app():
    return CommandResultApp()


def test_commandresult_truthy(commandresult_app) -> None:
    arg = "foo"
    run_cmd(commandresult_app, f"affirmative {arg}")
    assert commandresult_app.last_result
    assert commandresult_app.last_result == cmd2.CommandResult(arg, data=True)

    run_cmd(commandresult_app, f"affirmative_no_data {arg}")
    assert commandresult_app.last_result
    assert commandresult_app.last_result == cmd2.CommandResult(arg)


def test_commandresult_falsy(commandresult_app) -> None:
    arg = "bar"
    run_cmd(commandresult_app, f"negative {arg}")
    assert not commandresult_app.last_result
    assert commandresult_app.last_result == cmd2.CommandResult(arg, data=False)

    run_cmd(commandresult_app, f"negative_no_data {arg}")
    assert not commandresult_app.last_result
    assert commandresult_app.last_result == cmd2.CommandResult("", arg)


@pytest.mark.skipif(sys.platform.startswith("win"), reason="Test is problematic on GitHub Actions Windows runners")
def test_is_text_file_bad_input(base_app) -> None:
    # Test with a non-existent file
    with pytest.raises(FileNotFoundError):
        utils.is_text_file("does_not_exist.txt")

    # Test with a directory
    with pytest.raises(IsADirectoryError):
        utils.is_text_file(".")


def test__eof(base_app) -> None:
    base_app.do_quit = mock.MagicMock(return_value=True)
    assert base_app.do__eof("")
    base_app.do_quit.assert_called_once_with("")


def test_quit(base_app) -> None:
    assert base_app.do_quit("")
    assert base_app.last_result is True


def test_echo(capsys) -> None:
    app = cmd2.Cmd()
    app.echo = True
    commands = ["help history"]

    app.runcmds_plus_hooks(commands)

    out, _err = capsys.readouterr()
    assert out.startswith(f"{app.prompt}{commands[0]}\nUsage: history")


@pytest.mark.skipif(
    sys.platform.startswith("win"),
    reason="Don't have a real Windows console with how we are currently running tests in GitHub Actions",
)
def test_read_raw_input_tty(base_app: cmd2.Cmd) -> None:
    with create_pipe_input() as pipe_input:
        base_app.main_session = PromptSession(
            input=pipe_input,
            output=DummyOutput(),
            history=base_app.main_session.history,
            completer=base_app.main_session.completer,
        )
        pipe_input.send_text("foo\n")

        result = base_app._read_raw_input("prompt> ", base_app.main_session)
        assert result == "foo"


def test_read_raw_input_interactive_pipe(capsys) -> None:
    prompt = "prompt> "
    app = cmd2.Cmd(stdin=io.StringIO("input from pipe\n"))
    app.interactive_pipe = True
    result = app._read_raw_input(prompt, app.main_session)
    assert result == "input from pipe"

    # In interactive mode, _read_raw_input() prints the prompt.
    captured = capsys.readouterr()
    assert captured.out == prompt


def test_read_raw_input_non_interactive_pipe_echo_off(capsys) -> None:
    prompt = "prompt> "
    app = cmd2.Cmd(stdin=io.StringIO("input from pipe\n"))
    app.interactive_pipe = False
    app.echo = False
    result = app._read_raw_input(prompt, app.main_session)
    assert result == "input from pipe"

    # When not echoing in non-interactive mode, _read_raw_input() prints nothing.
    captured = capsys.readouterr()
    assert not captured.out


def test_read_raw_input_non_interactive_pipe_echo_on(capsys) -> None:
    prompt = "prompt> "
    app = cmd2.Cmd(stdin=io.StringIO("input from pipe\n"))
    app.interactive_pipe = False
    app.echo = True
    result = app._read_raw_input(prompt, app.main_session)
    assert result == "input from pipe"

    # When echoing in non-interactive mode, _read_raw_input() prints the prompt and input text.
    captured = capsys.readouterr()
    assert f"{prompt}input from pipe\n" == captured.out


def test_read_raw_input_eof() -> None:
    app = cmd2.Cmd(stdin=io.StringIO(""))
    with pytest.raises(EOFError):
        app._read_raw_input("prompt> ", app.main_session)


def test_resolve_completer_none(base_app: cmd2.Cmd) -> None:
    completer = base_app._resolve_completer()
    assert isinstance(completer, DummyCompleter)


def test_resolve_completer_with_choices(base_app: cmd2.Cmd) -> None:
    from cmd2.pt_utils import Cmd2Completer

    choices = ["apple", "banana", "cherry"]
    completer = base_app._resolve_completer(choices=choices)
    assert isinstance(completer, Cmd2Completer)

    # Verify contents
    settings = completer.custom_settings
    assert settings is not None

    action = settings.parser._actions[-1]
    assert action.choices == choices
    assert not settings.preserve_quotes


def test_resolve_completer_with_choices_provider(base_app: cmd2.Cmd) -> None:
    from cmd2.pt_utils import Cmd2Completer

    mock_provider = mock.MagicMock()
    completer = base_app._resolve_completer(choices_provider=mock_provider)
    assert isinstance(completer, Cmd2Completer)

    # Verify contents
    settings = completer.custom_settings
    assert settings is not None

    action = settings.parser._actions[-1]
    assert action.get_choices_provider() == mock_provider
    assert not settings.preserve_quotes


def test_resolve_completer_with_completer(base_app: cmd2.Cmd) -> None:
    """Verify that providing choices creates a Cmd2Completer with a generated parser."""
    from cmd2.pt_utils import Cmd2Completer

    mock_completer = mock.MagicMock()
    completer = base_app._resolve_completer(completer=mock_completer)
    assert isinstance(completer, Cmd2Completer)

    # Verify contents
    settings = completer.custom_settings
    assert settings is not None

    action = settings.parser._actions[-1]
    assert action.get_completer() == mock_completer
    assert not settings.preserve_quotes


def test_resolve_completer_with_parser(base_app: cmd2.Cmd) -> None:
    from cmd2.pt_utils import Cmd2Completer

    mock_parser = mock.MagicMock()
    completer = base_app._resolve_completer(parser=mock_parser)
    assert isinstance(completer, Cmd2Completer)

    # Verify contents
    settings = completer.custom_settings
    assert settings is not None

    assert settings.parser == mock_parser
    assert not settings.preserve_quotes


def test_resolve_completer_with_bad_input(base_app: cmd2.Cmd) -> None:
    mock_provider = mock.MagicMock()
    mock_completer = mock.MagicMock()
    mock_parser = mock.MagicMock()

    with pytest.raises(ValueError) as excinfo:  # noqa: PT011
        base_app._resolve_completer(
            choices=[],
            choices_provider=mock_provider,
            completer=mock_completer,
            parser=mock_parser,
        )

    assert "None of the following parameters can be used alongside a parser" in str(excinfo.value)


def test_custom_stdout() -> None:
    # Create a custom file-like object (e.g., an in-memory string buffer)
    custom_output = io.StringIO()

    # Instantiate cmd2.Cmd with the custom_output as stdout
    my_app = cmd2.Cmd(stdout=custom_output)

    # Simulate a command
    my_app.onecmd("help")

    # Retrieve the output from the custom_output buffer
    captured_output = custom_output.getvalue()
    assert "history" in captured_output


def test_read_command_line_eof(base_app, monkeypatch) -> None:
    """Test that _read_command_line passes up EOFErrors."""
    read_raw_mock = mock.MagicMock(name="_read_raw_input", side_effect=EOFError)
    monkeypatch.setattr("cmd2.Cmd._read_raw_input", read_raw_mock)

    with pytest.raises(EOFError):
        base_app._read_command_line("Prompt> ")


def test_read_input_eof(base_app, monkeypatch) -> None:
    """Test that read_input passes up EOFErrors."""
    read_raw_mock = mock.MagicMock(name="_read_raw_input", side_effect=EOFError)
    monkeypatch.setattr("cmd2.Cmd._read_raw_input", read_raw_mock)

    with pytest.raises(EOFError):
        base_app.read_input("Prompt> ")


def test_read_secret(base_app, monkeypatch):
    """Test read_secret passes is_password=True to _read_raw_input."""
    with mock.patch.object(base_app, "_read_raw_input") as mock_reader:
        mock_reader.return_value = "my_secret"

        secret = base_app.read_secret("Secret: ")

        assert secret == "my_secret"
        # Verify it called _read_raw_input with is_password=True
        args, kwargs = mock_reader.call_args
        assert args[0] == "Secret: "
        assert kwargs["is_password"] is True


def test_read_secret_eof(base_app, monkeypatch):
    """Test that read_secret passes up EOFErrors."""
    read_raw_mock = mock.MagicMock(name="_read_raw_input", side_effect=EOFError)
    monkeypatch.setattr("cmd2.Cmd._read_raw_input", read_raw_mock)

    with pytest.raises(EOFError):
        base_app.read_secret("Secret: ")


def test_read_input_passes_all_arguments_to_resolver(base_app):
    mock_choices = ["choice1", "choice2"]
    mock_provider = mock.MagicMock(name="provider")
    mock_completer = mock.MagicMock(name="completer")
    mock_parser = mock.MagicMock(name="parser")

    with (
        mock.patch.object(base_app, "_resolve_completer") as mock_resolver,
        mock.patch.object(base_app, "_read_raw_input") as mock_reader,
    ):
        mock_resolver.return_value = mock.MagicMock()
        mock_reader.return_value = mock.MagicMock()

        base_app.read_input(
            prompt="Enter command: ",
            history=["prev_cmd"],
            preserve_quotes=True,
            choices=mock_choices,
            choices_provider=mock_provider,
            completer=mock_completer,
            parser=mock_parser,
        )

        mock_resolver.assert_called_once_with(
            preserve_quotes=True,
            choices=mock_choices,
            choices_provider=mock_provider,
            completer=mock_completer,
            parser=mock_parser,
        )


def test_read_input_history_is_passed_to_session(base_app, monkeypatch, mocker):
    mock_session_cls = mocker.patch("cmd2.cmd2.PromptSession")
    mock_history_cls = mocker.patch("cmd2.cmd2.InMemoryHistory")
    read_raw_mock = mocker.MagicMock(name="_read_raw_input", return_value="command")
    monkeypatch.setattr("cmd2.Cmd._read_raw_input", read_raw_mock)

    # Test with custom history first
    my_history_list = ["help", "help alias", "help help"]
    base_app.read_input(history=my_history_list)
    mock_history_cls.assert_called_once_with(my_history_list)

    called_kwargs = mock_session_cls.call_args.kwargs
    assert called_kwargs["history"] == mock_history_cls.return_value

    # Test with no history
    mock_history_cls.reset_mock()
    mock_session_cls.reset_mock()
    my_history_list = ["help", "help alias", "help help"]
    base_app.read_input(history=None)
    mock_history_cls.assert_called_once_with()

    called_kwargs = mock_session_cls.call_args.kwargs
    assert called_kwargs["history"] == mock_history_cls.return_value


def test_read_raw_input_session_usage_and_restore(base_app, mocker):
    mock_session = mocker.MagicMock(name="temp_session")
    base_app.main_session = mocker.MagicMock(name="main_session")

    # Make sure we look like a terminal
    mocker.patch.object(base_app, "_is_tty_session", return_value=True)

    command_text = "help alias"

    def check_and_return_input(*args, **kwargs):
        # Check if the active session was the one we passed in
        assert base_app.active_session == mock_session
        return command_text

    mock_session.prompt.side_effect = check_and_return_input

    # Mock patch_stdout to prevent it from attempting to access the Windows
    # console buffer in a Windows test environment.
    with mock.patch("cmd2.cmd2.patch_stdout"):
        result = base_app._read_raw_input("prompt> ", mock_session)

    assert result == command_text

    # Check if session.prompt() was called
    mock_session.prompt.assert_called_once()

    # Verify that active session was restored
    assert base_app.active_session == base_app.main_session


def test_read_raw_input_restores_on_error(base_app, mocker):
    mock_session = mocker.MagicMock()
    base_app.main_session = mocker.MagicMock(name="main_session")

    # Make sure we look like a terminal
    mocker.patch.object(base_app, "_is_tty_session", return_value=True)

    def check_and_raise(*args, **kwargs):
        # Check if the active session was the one we passed in
        assert base_app.active_session == mock_session
        raise KeyboardInterrupt

    mock_session.prompt.side_effect = check_and_raise

    # Mock patch_stdout to prevent it from attempting to access the Windows
    # console buffer in a Windows test environment.
    with mock.patch("cmd2.cmd2.patch_stdout"), pytest.raises(KeyboardInterrupt):
        base_app._read_raw_input("prompt> ", mock_session)

    # Even though an error occurred, the finally block restored active session
    assert base_app.active_session == base_app.main_session


def test_poutput_string(outsim_app) -> None:
    msg = "This is a test"
    outsim_app.poutput(msg)
    out = outsim_app.stdout.getvalue()
    expected = msg + "\n"
    assert out == expected


def test_poutput_zero(outsim_app) -> None:
    msg = 0
    outsim_app.poutput(msg)
    out = outsim_app.stdout.getvalue()
    expected = str(msg) + "\n"
    assert out == expected


def test_poutput_empty_string(outsim_app) -> None:
    msg = ""
    outsim_app.poutput(msg)
    out = outsim_app.stdout.getvalue()
    expected = "\n"
    assert out == expected


def test_poutput_none(outsim_app) -> None:
    msg = None
    outsim_app.poutput(msg)
    out = outsim_app.stdout.getvalue()
    expected = "None\n"
    assert out == expected


@with_ansi_style(ru.AllowStyle.ALWAYS)
@pytest.mark.parametrize(
    # Test a Rich Text and a string.
    ("styled_msg", "expected"),
    [
        (Text("A Text object", style="cyan"), "\x1b[36mA Text object\x1b[0m\n"),
        (su.stylize("A str object", style="blue"), "\x1b[34mA str object\x1b[0m\n"),
    ],
)
def test_poutput_ansi_always(styled_msg, expected, outsim_app) -> None:
    outsim_app.poutput(styled_msg)
    out = outsim_app.stdout.getvalue()
    assert out == expected


@with_ansi_style(ru.AllowStyle.NEVER)
@pytest.mark.parametrize(
    # Test a Rich Text and a string.
    ("styled_msg", "expected"),
    [
        (Text("A Text object", style="cyan"), "A Text object\n"),
        (su.stylize("A str object", style="blue"), "A str object\n"),
    ],
)
def test_poutput_ansi_never(styled_msg, expected, outsim_app) -> None:
    outsim_app.poutput(styled_msg)
    out = outsim_app.stdout.getvalue()
    assert out == expected


@with_ansi_style(ru.AllowStyle.TERMINAL)
def test_poutput_ansi_terminal(outsim_app) -> None:
    """Test that AllowStyle.TERMINAL strips style when redirecting."""
    msg = "testing..."
    colored_msg = Text(msg, style="cyan")
    outsim_app._redirecting = True
    outsim_app.poutput(colored_msg)
    out = outsim_app.stdout.getvalue()
    expected = msg + "\n"
    assert out == expected


@with_ansi_style(ru.AllowStyle.ALWAYS)
def test_poutput_highlight(outsim_app):
    outsim_app.poutput("My IP Address is 192.168.1.100.", highlight=True)
    out = outsim_app.stdout.getvalue()
    assert out == "My IP Address is \x1b[1;92m192.168.1.100\x1b[0m.\n"


@with_ansi_style(ru.AllowStyle.ALWAYS)
def test_poutput_markup(outsim_app):
    outsim_app.poutput("The leaves are [green]green[/green].", markup=True)
    out = outsim_app.stdout.getvalue()
    assert out == "The leaves are \x1b[32mgreen\x1b[0m.\n"


@with_ansi_style(ru.AllowStyle.ALWAYS)
def test_poutput_emoji(outsim_app):
    outsim_app.poutput("Look at the emoji :1234:.", emoji=True)
    out = outsim_app.stdout.getvalue()
    assert out == "Look at the emoji 🔢.\n"


@with_ansi_style(ru.AllowStyle.ALWAYS)
def test_poutput_justify_and_width(outsim_app):
    rich_print_kwargs = {"width": 10}

    # Use a styled-string when justifying to check if its display width is correct.
    outsim_app.poutput(
        su.stylize("Hello", style="blue"),
        justify="right",
        rich_print_kwargs=rich_print_kwargs,
    )
    out = outsim_app.stdout.getvalue()
    assert out == "     \x1b[34mHello\x1b[0m\n"


def test_rich_print_kwargs(outsim_app):
    rich_print_kwargs = {"no_wrap": True, "overflow": "ellipsis", "width": 10}

    outsim_app.poutput(
        "This is longer than width.",
        soft_wrap=False,
        rich_print_kwargs=rich_print_kwargs,
    )
    out = outsim_app.stdout.getvalue()
    assert out.startswith("This is l…\n")


@with_ansi_style(ru.AllowStyle.ALWAYS)
def test_poutput_pretty_print(outsim_app):
    """Test that cmd2 passes objects through so they can be pretty-printed when highlighting is enabled."""
    dictionary = {1: "hello", 2: "person", 3: "who", 4: "codes"}

    outsim_app.poutput(dictionary, highlight=True)
    out = outsim_app.stdout.getvalue()
    assert out.startswith("\x1b[1m{\x1b[0m\x1b[1;36m1\x1b[0m: \x1b[32m'hello'\x1b[0m")


@pytest.mark.parametrize(
    "stream",
    ["stdout", "stderr"],
)
@pytest.mark.parametrize(
    ("emoji", "markup", "highlight"),
    [
        (True, True, True),
        (False, False, False),
        (True, False, True),
    ],
)
def test_get_core_print_console_caching(base_app: cmd2.Cmd, stream: str, emoji: bool, markup: bool, highlight: bool) -> None:
    """Test that printing consoles are cached and reused when settings match."""
    file = sys.stderr if stream == "stderr" else base_app.stdout

    # Initial creation
    console1 = base_app._get_core_print_console(
        file=file,
        emoji=emoji,
        markup=markup,
        highlight=highlight,
    )

    # Verify it's in the cache
    cached = getattr(base_app._console_cache, stream)
    assert cached is console1

    # Identical request should return the same object
    console2 = base_app._get_core_print_console(
        file=file,
        emoji=emoji,
        markup=markup,
        highlight=highlight,
    )
    assert console2 is console1


@pytest.mark.parametrize(
    "stream",
    ["stdout", "stderr"],
)
def test_get_core_print_console_invalidation(base_app: cmd2.Cmd, stream: str) -> None:
    """Test that changing settings, theme, or ALLOW_STYLE invalidates the cache."""
    file = sys.stderr if stream == "stderr" else base_app.stdout

    # Initial creation
    console1 = base_app._get_core_print_console(
        file=file,
        emoji=True,
        markup=True,
        highlight=True,
    )

    # Changing emoji should create a new console
    console2 = base_app._get_core_print_console(
        file=file,
        emoji=False,
        markup=True,
        highlight=True,
    )
    assert console2 is not console1
    assert getattr(base_app._console_cache, stream) is console2

    # Changing markup should create a new console
    console3 = base_app._get_core_print_console(
        file=file,
        emoji=False,
        markup=False,
        highlight=True,
    )
    assert console3 is not console2
    assert getattr(base_app._console_cache, stream) is console3

    # Changing highlight should create a new console
    console4 = base_app._get_core_print_console(
        file=file,
        emoji=False,
        markup=False,
        highlight=False,
    )
    assert console4 is not console3
    assert getattr(base_app._console_cache, stream) is console4

    # Changing ALLOW_STYLE should create a new console
    orig_allow_style = ru.ALLOW_STYLE
    try:
        ru.ALLOW_STYLE = ru.AllowStyle.ALWAYS if orig_allow_style != ru.AllowStyle.ALWAYS else ru.AllowStyle.NEVER
        console5 = base_app._get_core_print_console(
            file=file,
            emoji=False,
            markup=False,
            highlight=False,
        )
        assert console5 is not console4
        assert getattr(base_app._console_cache, stream) is console5
    finally:
        ru.ALLOW_STYLE = orig_allow_style

    # Changing the theme object should create a new console
    from rich.theme import Theme

    from cmd2 import theme

    old_theme = theme.get_theme()
    try:
        theme._THEME = Theme()
        console6 = base_app._get_core_print_console(
            file=file,
            emoji=False,
            markup=False,
            highlight=False,
        )
        assert console6 is not console5
        assert getattr(base_app._console_cache, stream) is console6
    finally:
        theme._THEME = old_theme


def test_get_core_print_console_non_cached(base_app: cmd2.Cmd) -> None:
    """Test that arbitrary file objects are not cached."""
    file = io.StringIO()

    console1 = base_app._get_core_print_console(
        file=file,
        emoji=True,
        markup=True,
        highlight=True,
    )

    # Cache for stdout/stderr should still be None (assuming they haven't been touched yet)
    assert base_app._console_cache.stdout is None
    assert base_app._console_cache.stderr is None

    # A second request for the same file should still create a new object
    console2 = base_app._get_core_print_console(
        file=file,
        emoji=True,
        markup=True,
        highlight=True,
    )
    assert console2 is not console1


def test_broken_pipe_error(outsim_app, monkeypatch, capsys):
    write_mock = mock.MagicMock()
    write_mock.side_effect = BrokenPipeError
    monkeypatch.setattr("cmd2.utils.StdSim.write", write_mock)

    outsim_app.broken_pipe_warning = "The pipe broke"
    outsim_app.poutput("My test string")

    out, err = capsys.readouterr()
    assert not out
    assert outsim_app.broken_pipe_warning in err


# These are invalid names for aliases and macros
invalid_command_name = [
    '""',  # Blank name
    constants.COMMENT_CHAR,
    "!no_shortcut",
    '">"',
    '"no>pe"',
    '"no spaces"',
    '"nopipe|"',
    '"noterm;"',
    'noembedded"quotes',
]


def test_get_alias_choices(base_app: cmd2.Cmd) -> None:
    run_cmd(base_app, "alias create fake run_pyscript")
    run_cmd(base_app, "alias create ls !ls -hal")

    choices = base_app._get_alias_choices()

    aliases = base_app.aliases
    assert len(choices) == len(aliases)

    for cur_choice in choices:
        assert cur_choice.text in aliases
        assert cur_choice.display_meta == aliases[cur_choice.text]
        assert cur_choice.table_data == (aliases[cur_choice.text],)


def test_get_macro_choices(base_app: cmd2.Cmd) -> None:
    run_cmd(base_app, "macro create foo !echo foo")
    run_cmd(base_app, "macro create bar !echo bar")

    choices = base_app._get_macro_choices()

    macros = base_app.macros
    assert len(choices) == len(macros)

    for cur_choice in choices:
        assert cur_choice.text in macros
        assert cur_choice.display_meta == macros[cur_choice.text].value
        assert cur_choice.table_data == (macros[cur_choice.text].value,)


def test_get_commands_aliases_and_macros_choices(base_app: cmd2.Cmd) -> None:
    # Add an alias and a macro
    run_cmd(base_app, "alias create fake_alias help")
    run_cmd(base_app, "macro create fake_macro !echo macro")

    # Add a command without a docstring
    import types

    def do_no_doc(self, arg):
        pass

    base_app.do_no_doc = types.MethodType(do_no_doc, base_app)

    choices = base_app._get_commands_aliases_and_macros_choices()

    # All visible commands + our new command + alias + macro
    expected_count = len(base_app.get_visible_commands()) + len(base_app.aliases) + len(base_app.macros)
    assert len(choices) == expected_count

    # Verify alias
    alias_item = next((item for item in choices if item == "fake_alias"), None)
    assert alias_item is not None
    assert alias_item.display_meta == "Alias for: help"

    # Verify macro
    macro_item = next((item for item in choices if item == "fake_macro"), None)
    assert macro_item is not None
    assert macro_item.display_meta == "Macro: !echo macro"

    # Verify command with docstring (help)
    help_item = next((item for item in choices if item == "help"), None)
    assert help_item is not None
    # First line of help docstring
    assert "List available commands" in help_item.display_meta

    # Verify command without docstring
    no_doc_item = next((item for item in choices if item == "no_doc"), None)
    assert no_doc_item is not None
    assert no_doc_item.display_meta == ""


def test_get_settable_choices(base_app: cmd2.Cmd) -> None:
    choices = base_app._get_settable_choices()
    assert len(choices) == len(base_app.settables)

    for cur_choice in choices:
        cur_settable = base_app.settables.get(cur_choice.text)
        assert cur_settable is not None

        # Convert fields so we can compare them
        str_value = str(cur_settable.value)

        choice_value = cur_choice.table_data[0]
        if isinstance(choice_value, Text):
            choice_value = ru.rich_text_to_string(choice_value)

        choice_description = cur_choice.table_data[1]
        if isinstance(choice_description, Text):
            choice_description = ru.rich_text_to_string(choice_description)

        assert str_value in cur_choice.display_meta
        assert choice_value == str_value
        assert choice_description == cur_settable.description


def test_alias_no_subcommand(base_app) -> None:
    _out, err = run_cmd(base_app, "alias")
    assert "Usage: alias [-h]" in err[0]
    assert "Error: the following arguments are required: SUBCOMMAND" in err[1]


def test_alias_create(base_app) -> None:
    # Create the alias
    out, err = run_cmd(base_app, "alias create fake run_pyscript")
    assert out == normalize("Alias 'fake' created")
    assert base_app.last_result is True

    # Use the alias
    out, err = run_cmd(base_app, "fake")
    assert "the following arguments are required: script_path" in err[1]

    # See a list of aliases
    out, err = run_cmd(base_app, "alias list")
    assert out == normalize("alias create fake run_pyscript")
    assert len(base_app.last_result) == len(base_app.aliases)
    assert base_app.last_result["fake"] == "run_pyscript"

    # Look up the new alias
    out, err = run_cmd(base_app, "alias list fake")
    assert out == normalize("alias create fake run_pyscript")
    assert len(base_app.last_result) == 1
    assert base_app.last_result["fake"] == "run_pyscript"

    # Overwrite alias
    out, err = run_cmd(base_app, "alias create fake help")
    assert out == normalize("Alias 'fake' overwritten")
    assert base_app.last_result is True

    # Look up the updated alias
    out, err = run_cmd(base_app, "alias list fake")
    assert out == normalize("alias create fake help")
    assert len(base_app.last_result) == 1
    assert base_app.last_result["fake"] == "help"


def test_nested_alias_usage(base_app) -> None:
    run_cmd(base_app, "alias create nested help")
    run_cmd(base_app, "alias create wrapper nested")
    nested_out = run_cmd(base_app, "nested")
    wrapper_out = run_cmd(base_app, "wrapper")
    help_out = run_cmd(base_app, "help")

    assert nested_out == wrapper_out == help_out


def test_alias_create_with_quoted_tokens(base_app) -> None:
    """Demonstrate that quotes in alias value will be preserved"""
    alias_name = "fake"
    alias_command = 'help ">" "out file.txt" ";"'
    create_command = f"alias create {alias_name} {alias_command}"

    # Create the alias
    out, _err = run_cmd(base_app, create_command)
    assert out == normalize("Alias 'fake' created")

    # Look up the new alias and verify all quotes are preserved
    out, _err = run_cmd(base_app, "alias list fake")
    assert out == normalize(create_command)
    assert len(base_app.last_result) == 1
    assert base_app.last_result[alias_name] == alias_command


@pytest.mark.parametrize("alias_name", invalid_command_name)
def test_alias_create_invalid_name(base_app, alias_name, capsys) -> None:
    _out, err = run_cmd(base_app, f"alias create {alias_name} help")
    assert "Invalid alias name" in err[0]
    assert base_app.last_result is False


def test_alias_create_with_command_name(base_app) -> None:
    _out, err = run_cmd(base_app, "alias create help stuff")
    assert "Alias cannot have the same name as a command" in err[0]
    assert base_app.last_result is False


def test_alias_create_with_macro_name(base_app) -> None:
    macro = "my_macro"
    run_cmd(base_app, f"macro create {macro} help")
    _out, err = run_cmd(base_app, f"alias create {macro} help")
    assert "Alias cannot have the same name as a macro" in err[0]
    assert base_app.last_result is False


def test_alias_that_resolves_into_comment(base_app) -> None:
    # Create the alias
    out, err = run_cmd(base_app, "alias create fake " + constants.COMMENT_CHAR + " blah blah")
    assert out == normalize("Alias 'fake' created")

    # Use the alias
    out, err = run_cmd(base_app, "fake")
    assert not out
    assert not err


def test_alias_list_invalid_alias(base_app) -> None:
    # Look up invalid alias
    _out, err = run_cmd(base_app, "alias list invalid")
    assert "Alias 'invalid' not found" in err[0]
    assert base_app.last_result == {}


def test_alias_delete(base_app) -> None:
    # Create an alias
    run_cmd(base_app, "alias create fake run_pyscript")

    # Delete the alias
    out, _err = run_cmd(base_app, "alias delete fake")
    assert out == normalize("Alias 'fake' deleted")
    assert base_app.last_result is True


def test_alias_delete_all(base_app) -> None:
    out, _err = run_cmd(base_app, "alias delete --all")
    assert out == normalize("All aliases deleted")
    assert base_app.last_result is True


def test_alias_delete_non_existing(base_app) -> None:
    _out, err = run_cmd(base_app, "alias delete fake")
    assert "Alias 'fake' does not exist" in err[0]
    assert base_app.last_result is True


def test_alias_delete_no_name(base_app) -> None:
    _out, err = run_cmd(base_app, "alias delete")
    assert "Either --all or alias name(s)" in err[0]
    assert base_app.last_result is False


def test_multiple_aliases(base_app) -> None:
    alias1 = "h1"
    alias2 = "h2"
    run_cmd(base_app, f"alias create {alias1} help")
    run_cmd(base_app, f"alias create {alias2} help -v")
    out, _err = run_cmd(base_app, alias1)
    verify_help_text(base_app, out)

    out, _err = run_cmd(base_app, alias2)
    verify_help_text(base_app, out)


def test_macro_no_subcommand(base_app) -> None:
    _out, err = run_cmd(base_app, "macro")
    assert "Usage: macro [-h]" in err[0]
    assert "Error: the following arguments are required: SUBCOMMAND" in err[1]


def test_macro_create(base_app) -> None:
    # Create the macro
    out, err = run_cmd(base_app, "macro create fake run_pyscript")
    assert out == normalize("Macro 'fake' created")
    assert base_app.last_result is True

    # Use the macro
    out, err = run_cmd(base_app, "fake")
    assert "the following arguments are required: script_path" in err[1]

    # See a list of macros
    out, err = run_cmd(base_app, "macro list")
    assert out == normalize("macro create fake run_pyscript")
    assert len(base_app.last_result) == len(base_app.macros)
    assert base_app.last_result["fake"] == "run_pyscript"

    # Look up the new macro
    out, err = run_cmd(base_app, "macro list fake")
    assert out == normalize("macro create fake run_pyscript")
    assert len(base_app.last_result) == 1
    assert base_app.last_result["fake"] == "run_pyscript"

    # Overwrite macro
    out, err = run_cmd(base_app, "macro create fake help")
    assert out == normalize("Macro 'fake' overwritten")
    assert base_app.last_result is True

    # Look up the updated macro
    out, err = run_cmd(base_app, "macro list fake")
    assert out == normalize("macro create fake help")
    assert len(base_app.last_result) == 1
    assert base_app.last_result["fake"] == "help"


def test_macro_create_with_quoted_tokens(base_app) -> None:
    """Demonstrate that quotes in macro value will be preserved"""
    macro_name = "fake"
    macro_command = 'help ">" "out file.txt" ";"'
    create_command = f"macro create {macro_name} {macro_command}"

    # Create the macro
    out, _err = run_cmd(base_app, create_command)
    assert out == normalize("Macro 'fake' created")

    # Look up the new macro and verify all quotes are preserved
    out, _err = run_cmd(base_app, "macro list fake")
    assert out == normalize(create_command)
    assert len(base_app.last_result) == 1
    assert base_app.last_result[macro_name] == macro_command


@pytest.mark.parametrize("macro_name", invalid_command_name)
def test_macro_create_invalid_name(base_app, macro_name) -> None:
    _out, err = run_cmd(base_app, f"macro create {macro_name} help")
    assert "Invalid macro name" in err[0]
    assert base_app.last_result is False


def test_macro_create_with_command_name(base_app) -> None:
    _out, err = run_cmd(base_app, "macro create help stuff")
    assert "Macro cannot have the same name as a command" in err[0]
    assert base_app.last_result is False


def test_macro_create_with_alias_name(base_app) -> None:
    macro = "my_macro"
    run_cmd(base_app, f"alias create {macro} help")
    _out, err = run_cmd(base_app, f"macro create {macro} help")
    assert "Macro cannot have the same name as an alias" in err[0]
    assert base_app.last_result is False


def test_macro_create_with_args(base_app) -> None:
    # Create the macro
    out, _err = run_cmd(base_app, "macro create fake {1} {2}")
    assert out == normalize("Macro 'fake' created")

    # Run the macro
    out, _err = run_cmd(base_app, "fake help -v")
    verify_help_text(base_app, out)


def test_macro_create_with_escaped_args(base_app) -> None:
    # Create the macro
    out, err = run_cmd(base_app, "macro create fake help {{1}}")
    assert out == normalize("Macro 'fake' created")

    # Run the macro
    out, err = run_cmd(base_app, "fake")
    assert err[0].startswith("No help on {1}")


def test_macro_usage_with_missing_args(base_app) -> None:
    # Create the macro
    out, err = run_cmd(base_app, "macro create fake help {1} {2}")
    assert out == normalize("Macro 'fake' created")

    # Run the macro
    out, err = run_cmd(base_app, "fake arg1")
    assert "expects at least 2 arguments" in err[0]


def test_macro_usage_with_extra_args(base_app) -> None:
    # Create the macro
    out, _err = run_cmd(base_app, "macro create fake help {1}")
    assert out == normalize("Macro 'fake' created")

    # Run the macro
    out, _err = run_cmd(base_app, "fake alias create")
    assert "Usage: alias create" in out[0]


def test_nested_macro_usage(base_app) -> None:
    run_cmd(base_app, "macro create nested help")
    run_cmd(base_app, "macro create wrapper nested {1}")
    nested_out = run_cmd(base_app, "nested")
    help_out = run_cmd(base_app, "help")
    assert nested_out == help_out

    wrapper_out = run_cmd(base_app, "wrapper alias")
    help_alias_out = run_cmd(base_app, "help alias")

    assert wrapper_out == help_alias_out


def test_macro_create_with_missing_arg_nums(base_app) -> None:
    # Create the macro
    _out, err = run_cmd(base_app, "macro create fake help {1} {3}")
    assert "Not all numbers between 1 and 3" in err[0]
    assert base_app.last_result is False


def test_macro_create_with_invalid_arg_num(base_app) -> None:
    # Create the macro
    _out, err = run_cmd(base_app, "macro create fake help {1} {-1} {0}")
    assert "Argument numbers must be greater than 0" in err[0]
    assert base_app.last_result is False


def test_macro_create_with_unicode_numbered_arg(base_app) -> None:
    # Create the macro expecting 1 argument
    out, err = run_cmd(base_app, "macro create fake help {\N{ARABIC-INDIC DIGIT ONE}}")
    assert out == normalize("Macro 'fake' created")

    # Run the macro
    out, err = run_cmd(base_app, "fake")
    assert "expects at least 1 argument" in err[0]


def test_macro_create_with_missing_unicode_arg_nums(base_app) -> None:
    _out, err = run_cmd(base_app, "macro create fake help {1} {\N{ARABIC-INDIC DIGIT THREE}}")
    assert "Not all numbers between 1 and 3" in err[0]
    assert base_app.last_result is False


def test_macro_that_resolves_into_comment(base_app) -> None:
    # Create the macro
    out, err = run_cmd(base_app, "macro create fake {1} blah blah")
    assert out == normalize("Macro 'fake' created")

    # Use the macro
    out, err = run_cmd(base_app, "fake " + constants.COMMENT_CHAR)
    assert not out
    assert not err


def test_macro_list_invalid_macro(base_app) -> None:
    # Look up invalid macro
    _out, err = run_cmd(base_app, "macro list invalid")
    assert "Macro 'invalid' not found" in err[0]
    assert base_app.last_result == {}


def test_macro_delete(base_app) -> None:
    # Create an macro
    run_cmd(base_app, "macro create fake run_pyscript")

    # Delete the macro
    out, _err = run_cmd(base_app, "macro delete fake")
    assert out == normalize("Macro 'fake' deleted")
    assert base_app.last_result is True


def test_macro_delete_all(base_app) -> None:
    out, _err = run_cmd(base_app, "macro delete --all")
    assert out == normalize("All macros deleted")
    assert base_app.last_result is True


def test_macro_delete_non_existing(base_app) -> None:
    _out, err = run_cmd(base_app, "macro delete fake")
    assert "Macro 'fake' does not exist" in err[0]
    assert base_app.last_result is True


def test_macro_delete_no_name(base_app) -> None:
    _out, err = run_cmd(base_app, "macro delete")
    assert "Either --all or macro name(s)" in err[0]
    assert base_app.last_result is False


def test_multiple_macros(base_app) -> None:
    macro1 = "h1"
    macro2 = "h2"
    run_cmd(base_app, f"macro create {macro1} help")
    run_cmd(base_app, f"macro create {macro2} help -v")
    out, _err = run_cmd(base_app, macro1)
    verify_help_text(base_app, out)

    out2, _err2 = run_cmd(base_app, macro2)
    verify_help_text(base_app, out2)
    assert len(out2) > len(out)


def test_nonexistent_macro(base_app) -> None:
    from cmd2.parsing import (
        StatementParser,
    )

    exception = None

    try:
        base_app._resolve_macro(StatementParser().parse("fake"))
    except KeyError as e:
        exception = e

    assert exception is not None


@pytest.mark.parametrize(
    # The line of text and whether to continue prompting to finish a multiline command.
    ("line", "should_continue"),
    [
        # Empty lines
        ("", False),
        ("   ", False),
        # Single-line commands
        ("help", False),
        ("help alias", False),
        # Multi-line commands
        ("orate", True),
        ("orate;", False),
        ("orate\n", False),
        ("orate\narg", True),
        ("orate\narg;", False),
        ("orate\narg\n", False),
        # Single-line macros
        ("single_mac", False),  # macro resolution error returns False (no arg passed)
        ("single_mac arg", False),
        # Multi-line macros
        ("multi_mac", False),  # macro resolution error returns False (no arg passed)
        ("multi_mac arg", True),
        ("multi_mac arg;", False),
        ("multi_mac arg\n", False),
        ("multi_mac\narg", True),
        ("multi_mac\narg;", False),
        ("multi_mac\narg\n", False),
        # Nested multi-line macros
        ("wrapper_mac", False),  # macro resolution error returns False (no args passed)
        ("wrapper_mac arg", False),  # macro resolution error returns False (not enough args passed)
        ("wrapper_mac arg arg2", True),
        ("wrapper_mac arg\narg2;", False),
    ],
)
def test_should_continue_multiline(multiline_app: MultilineApp, line: str, should_continue: bool) -> None:
    mock_buffer = mock.MagicMock()
    mock_buffer.text = line

    mock_app = mock.MagicMock()
    mock_app.current_buffer = mock_buffer

    run_cmd(multiline_app, "macro create single_mac help {1}")
    run_cmd(multiline_app, "macro create multi_mac orate {1}")
    run_cmd(multiline_app, "macro create wrapper_mac multi_mac {1} {2}")

    with mock.patch("cmd2.cmd2.get_app", return_value=mock_app):
        assert multiline_app._should_continue_multiline() is should_continue


@with_ansi_style(ru.AllowStyle.ALWAYS)
def test_perror_style(base_app, capsys) -> None:
    msg = "testing..."
    base_app.perror(msg)
    _out, err = capsys.readouterr()
    assert err == "\x1b[91mtesting...\x1b[0m\n"


@with_ansi_style(ru.AllowStyle.ALWAYS)
def test_perror_no_style(base_app, capsys) -> None:
    msg = "testing..."
    end = "\n"
    base_app.perror(msg, style=None)
    _out, err = capsys.readouterr()
    assert err == msg + end


@with_ansi_style(ru.AllowStyle.ALWAYS)
def test_psuccess(outsim_app) -> None:
    msg = "testing..."
    end = "\n"
    outsim_app.psuccess(msg)

    expected = su.stylize(msg + end, style=Cmd2Style.SUCCESS)
    assert outsim_app.stdout.getvalue() == expected


@with_ansi_style(ru.AllowStyle.ALWAYS)
def test_pwarning(base_app, capsys) -> None:
    msg = "testing..."
    end = "\n"
    base_app.pwarning(msg)

    expected = su.stylize(msg + end, style=Cmd2Style.WARNING)
    _out, err = capsys.readouterr()
    assert err == expected


@with_ansi_style(ru.AllowStyle.ALWAYS)
def test_pexcept_style(base_app, capsys) -> None:
    msg = Exception("testing...")

    base_app.pexcept(msg)
    _out, err = capsys.readouterr()
    expected = su.stylize("Exception: ", style="traceback.exc_type")
    assert err.startswith(expected)


@with_ansi_style(ru.AllowStyle.NEVER)
def test_pexcept_no_style(base_app, capsys) -> None:
    msg = Exception("testing...")

    base_app.pexcept(msg)
    _out, err = capsys.readouterr()
    assert err.startswith("Exception: testing...")


@pytest.mark.parametrize("chop", [True, False])
def test_ppaged_with_pager(outsim_app, monkeypatch, chop) -> None:
    """Force ppaged() to run the pager by mocking an actual terminal state."""

    # Make it look like we're in a terminal
    stdin_mock = mock.MagicMock()
    stdin_mock.isatty.return_value = True
    monkeypatch.setattr(outsim_app, "stdin", stdin_mock)

    stdout_mock = mock.MagicMock()
    stdout_mock.isatty.return_value = True
    monkeypatch.setattr(outsim_app, "stdout", stdout_mock)

    if not sys.platform.startswith("win") and os.environ.get("TERM") is None:
        monkeypatch.setenv("TERM", "simulated")

    # This will force ppaged to call Popen to run a pager
    popen_mock = mock.MagicMock(name="Popen")
    monkeypatch.setattr("subprocess.Popen", popen_mock)
    outsim_app.ppaged("Test", chop=chop)

    # Verify the correct pager was run
    expected_cmd = outsim_app.pager_chop if chop else outsim_app.pager
    assert len(popen_mock.call_args_list) == 1
    assert expected_cmd == popen_mock.call_args_list[0].args[0]


def test_ppaged_no_pager(outsim_app) -> None:
    """Since we're not in a fully-functional terminal, ppaged() will just call poutput()."""
    msg = "testing..."
    end = "\n"
    outsim_app.ppaged(msg)
    out = outsim_app.stdout.getvalue()
    assert out == msg + end


@pytest.mark.skipif(sys.platform.startswith("win"), reason="termios is not available on Windows")
@pytest.mark.parametrize("has_tcsetpgrp", [True, False])
def test_ppaged_terminal_restoration(outsim_app, monkeypatch, has_tcsetpgrp) -> None:
    """Test terminal restoration in ppaged() after pager exits."""
    # Make it look like we're in a terminal
    stdin_mock = mock.MagicMock()
    stdin_mock.isatty.return_value = True
    stdin_mock.fileno.return_value = 0
    monkeypatch.setattr(outsim_app, "stdin", stdin_mock)

    stdout_mock = mock.MagicMock()
    stdout_mock.isatty.return_value = True
    monkeypatch.setattr(outsim_app, "stdout", stdout_mock)

    if not sys.platform.startswith("win") and os.environ.get("TERM") is None:
        monkeypatch.setenv("TERM", "simulated")

    # Mock termios and signal since they are imported within the method
    termios_mock = mock.MagicMock()
    # The error attribute needs to be the actual exception for isinstance checks
    import termios

    termios_mock.error = termios.error
    monkeypatch.setitem(sys.modules, "termios", termios_mock)

    signal_mock = mock.MagicMock()
    monkeypatch.setitem(sys.modules, "signal", signal_mock)

    # Mock os.tcsetpgrp and os.getpgrp
    if has_tcsetpgrp:
        monkeypatch.setattr(os, "tcsetpgrp", mock.Mock(), raising=False)
        monkeypatch.setattr(os, "getpgrp", mock.Mock(return_value=123), raising=False)
    else:
        monkeypatch.delattr(os, "tcsetpgrp", raising=False)

    # Mock subprocess.Popen
    popen_mock = mock.MagicMock(name="Popen")
    monkeypatch.setattr("subprocess.Popen", popen_mock)

    # Set initial termios settings so the logic will run
    dummy_settings = ["dummy settings"]
    outsim_app._initial_termios_settings = dummy_settings

    # Call ppaged
    outsim_app.ppaged("Test")

    # Verify restoration logic
    if has_tcsetpgrp:
        os.tcsetpgrp.assert_called_once_with(0, 123)
        signal_mock.signal.assert_any_call(signal_mock.SIGTTOU, signal_mock.SIG_IGN)

    termios_mock.tcsetattr.assert_called_once_with(0, termios_mock.TCSANOW, dummy_settings)


@pytest.mark.skipif(sys.platform.startswith("win"), reason="termios is not available on Windows")
def test_ppaged_terminal_restoration_exceptions(outsim_app, monkeypatch) -> None:
    """Test that terminal restoration in ppaged() handles exceptions gracefully."""
    # Make it look like we're in a terminal
    stdin_mock = mock.MagicMock()
    stdin_mock.isatty.return_value = True
    stdin_mock.fileno.return_value = 0
    monkeypatch.setattr(outsim_app, "stdin", stdin_mock)

    stdout_mock = mock.MagicMock()
    stdout_mock.isatty.return_value = True
    monkeypatch.setattr(outsim_app, "stdout", stdout_mock)

    if not sys.platform.startswith("win") and os.environ.get("TERM") is None:
        monkeypatch.setenv("TERM", "simulated")

    # Mock termios and make it raise an error
    termios_mock = mock.MagicMock()
    import termios

    termios_mock.error = termios.error
    termios_mock.tcsetattr.side_effect = termios.error("Restoration failed")
    monkeypatch.setitem(sys.modules, "termios", termios_mock)

    monkeypatch.setitem(sys.modules, "signal", mock.MagicMock())

    # Mock os.tcsetpgrp and os.getpgrp to prevent OSError before tcsetattr
    monkeypatch.setattr(os, "tcsetpgrp", mock.Mock(), raising=False)
    monkeypatch.setattr(os, "getpgrp", mock.Mock(return_value=123), raising=False)

    # Mock subprocess.Popen
    popen_mock = mock.MagicMock(name="Popen")
    monkeypatch.setattr("subprocess.Popen", popen_mock)

    # Set initial termios settings
    outsim_app._initial_termios_settings = ["dummy settings"]

    # Call ppaged - should not raise exception
    outsim_app.ppaged("Test")

    # Verify tcsetattr was attempted
    assert termios_mock.tcsetattr.called


@pytest.mark.skipif(sys.platform.startswith("win"), reason="termios is not available on Windows")
def test_ppaged_terminal_restoration_no_settings(outsim_app, monkeypatch) -> None:
    """Test that terminal restoration in ppaged() is skipped if no settings are saved."""
    # Make it look like we're in a terminal
    stdin_mock = mock.MagicMock()
    stdin_mock.isatty.return_value = True
    stdin_mock.fileno.return_value = 0
    monkeypatch.setattr(outsim_app, "stdin", stdin_mock)

    stdout_mock = mock.MagicMock()
    stdout_mock.isatty.return_value = True
    monkeypatch.setattr(outsim_app, "stdout", stdout_mock)

    if not sys.platform.startswith("win") and os.environ.get("TERM") is None:
        monkeypatch.setenv("TERM", "simulated")

    # Mock termios
    termios_mock = mock.MagicMock()
    monkeypatch.setitem(sys.modules, "termios", termios_mock)

    # Mock subprocess.Popen
    popen_mock = mock.MagicMock(name="Popen")
    monkeypatch.setattr("subprocess.Popen", popen_mock)

    # Ensure initial termios settings is None
    outsim_app._initial_termios_settings = None

    # Call ppaged
    outsim_app.ppaged("Test")

    # Verify tcsetattr was NOT called
    assert not termios_mock.tcsetattr.called


@pytest.mark.skipif(sys.platform.startswith("win"), reason="termios is not available on Windows")
def test_ppaged_terminal_restoration_oserror(outsim_app, monkeypatch) -> None:
    """Test that terminal restoration in ppaged() handles OSError gracefully."""
    # Make it look like we're in a terminal
    stdin_mock = mock.MagicMock()
    stdin_mock.isatty.return_value = True
    stdin_mock.fileno.return_value = 0
    monkeypatch.setattr(outsim_app, "stdin", stdin_mock)

    stdout_mock = mock.MagicMock()
    stdout_mock.isatty.return_value = True
    monkeypatch.setattr(outsim_app, "stdout", stdout_mock)

    if not sys.platform.startswith("win") and os.environ.get("TERM") is None:
        monkeypatch.setenv("TERM", "simulated")

    # Mock signal
    monkeypatch.setitem(sys.modules, "signal", mock.MagicMock())

    # Mock os.tcsetpgrp to raise OSError
    monkeypatch.setattr(os, "tcsetpgrp", mock.Mock(side_effect=OSError("Permission denied")), raising=False)
    monkeypatch.setattr(os, "getpgrp", mock.Mock(return_value=123), raising=False)

    # Mock termios
    termios_mock = mock.MagicMock()
    import termios

    termios_mock.error = termios.error
    monkeypatch.setitem(sys.modules, "termios", termios_mock)

    # Mock subprocess.Popen
    popen_mock = mock.MagicMock(name="Popen")
    monkeypatch.setattr("subprocess.Popen", popen_mock)

    # Set initial termios settings
    outsim_app._initial_termios_settings = ["dummy settings"]

    # Call ppaged - should not raise exception
    outsim_app.ppaged("Test")

    # Verify tcsetpgrp was attempted and OSError was caught
    assert os.tcsetpgrp.called
    # tcsetattr should have been skipped due to OSError being raised before it
    assert not termios_mock.tcsetattr.called


def test_ppretty(base_app: cmd2.Cmd) -> None:
    # Mock the Pretty class and the print_to() method
    with mock.patch("cmd2.cmd2.Pretty") as mock_pretty, mock.patch.object(cmd2.Cmd, "print_to") as mock_print_to:
        # Set up the mock return value for Pretty
        mock_pretty_obj = mock.Mock()
        mock_pretty.return_value = mock_pretty_obj

        test_obj = {"key": "value"}

        # Call ppretty() with some custom arguments
        base_app.ppretty(
            test_obj,
            indent_size=2,
            max_depth=5,
            expand_all=True,
            end="\n\n",
        )

        # Verify Pretty was instantiated with the correct arguments
        mock_pretty.assert_called_once_with(
            test_obj,
            indent_size=2,
            indent_guides=True,
            max_length=None,
            max_string=None,
            max_depth=5,
            expand_all=True,
            overflow="ignore",
        )

        # Verify print_to() was called with the mock pretty object and soft_wrap=True
        # It should default to self.stdout when no file is provided
        mock_print_to.assert_called_once_with(
            base_app.stdout,
            mock_pretty_obj,
            soft_wrap=True,
            end="\n\n",
        )


def test_onecmd_raw_str_continue(outsim_app) -> None:
    line = "help"
    stop = outsim_app.onecmd(line)
    out = outsim_app.stdout.getvalue()
    assert not stop
    verify_help_text(outsim_app, out)


def test_onecmd_raw_str_quit(outsim_app) -> None:
    line = "quit"
    stop = outsim_app.onecmd(line)
    out = outsim_app.stdout.getvalue()
    assert stop
    assert out == ""


def test_onecmd_add_to_history(outsim_app) -> None:
    line = "help"
    saved_hist_len = len(outsim_app.history)

    # Allow command to be added to history
    outsim_app.onecmd(line, add_to_history=True)
    new_hist_len = len(outsim_app.history)
    assert new_hist_len == saved_hist_len + 1

    saved_hist_len = new_hist_len

    # Prevent command from being added to history
    outsim_app.onecmd(line, add_to_history=False)
    new_hist_len = len(outsim_app.history)
    assert new_hist_len == saved_hist_len


def test_get_all_commands(base_app) -> None:
    # Verify that the base app has the expected commands
    commands = base_app.get_all_commands()
    expected_commands = [
        "_eof",
        "_relative_run_script",
        "alias",
        "edit",
        "help",
        "history",
        "ipy",
        "macro",
        "py",
        "quit",
        "run_pyscript",
        "run_script",
        "set",
        "shell",
        "shortcuts",
    ]
    assert commands == expected_commands


def test_get_help_topics(base_app) -> None:
    # Verify that the base app has no additional help_foo methods
    custom_help = base_app.get_help_topics()
    assert len(custom_help) == 0


def test_get_help_topics_hidden() -> None:
    # Verify get_help_topics() filters out hidden commands
    class TestApp(cmd2.Cmd):
        def __init__(self, *args, **kwargs) -> None:
            super().__init__(*args, **kwargs)

        def do_my_cmd(self, args) -> None:
            pass

        def help_my_cmd(self, args) -> None:
            pass

    app = TestApp()
    assert "my_cmd" in app.get_help_topics()

    app.hidden_commands.append("my_cmd")
    assert "my_cmd" not in app.get_help_topics()


class ReplWithExitCode(cmd2.Cmd):
    """Example cmd2 application where we can specify an exit code when existing."""

    def __init__(self) -> None:
        super().__init__(allow_cli_args=False)

    @cmd2.with_argument_list
    def do_exit(self, arg_list) -> bool:
        """Exit the application with an optional exit code.

        Usage:  exit [exit_code]
            Where:
                * exit_code - integer exit code to return to the shell
        """
        # If an argument was provided
        if arg_list:
            try:
                self.exit_code = int(arg_list[0])
            except ValueError:
                self.perror(f"{arg_list[0]} isn't a valid integer exit code")
                self.exit_code = 1

        # Return True to stop the command loop
        return True

    def postloop(self) -> None:
        """Hook method executed once when the cmdloop() method is about to return."""
        self.poutput(f"exiting with code: {self.exit_code}")


@pytest.fixture
def exit_code_repl():
    app = ReplWithExitCode()
    app.stdout = utils.StdSim(app.stdout)
    return app


def test_exit_code_default(exit_code_repl, monkeypatch) -> None:
    app = exit_code_repl

    read_command_mock = mock.MagicMock(name="_read_command_line", return_value="exit")
    monkeypatch.setattr("cmd2.Cmd._read_command_line", read_command_mock)

    expected = "exiting with code: 0\n"

    # Run the command loop
    app.cmdloop()
    out = app.stdout.getvalue()
    assert out == expected


def test_exit_code_nonzero(exit_code_repl, monkeypatch) -> None:
    app = exit_code_repl

    read_input_mock = mock.MagicMock(name="_read_command_line", return_value="exit 23")
    monkeypatch.setattr("cmd2.Cmd._read_command_line", read_input_mock)

    expected = "exiting with code: 23\n"

    # Run the command loop
    app.cmdloop()
    out = app.stdout.getvalue()
    assert out == expected


class AnsiApp(cmd2.Cmd):
    def __init__(self, *args, **kwargs) -> None:
        super().__init__(*args, **kwargs)

    def do_echo(self, args) -> None:
        self.poutput(args)
        self.perror(args)

    def do_echo_error(self, args) -> None:
        self.poutput(args, style=Cmd2Style.ERROR)
        self.perror(args)


@with_ansi_style(ru.AllowStyle.ALWAYS)
def test_ansi_pouterr_always_tty(mocker, capsys) -> None:
    app = AnsiApp()
    mocker.patch.object(app.stdout, "isatty", return_value=True)
    mocker.patch.object(sys.stderr, "isatty", return_value=True)

    expected_plain = "oopsie\n"
    expected_styled = su.stylize("oopsie\n", Cmd2Style.ERROR)

    app.onecmd_plus_hooks("echo_error oopsie")
    out, err = capsys.readouterr()
    assert out == expected_styled
    assert err == expected_styled

    app.onecmd_plus_hooks("echo oopsie")
    out, err = capsys.readouterr()
    assert out == expected_plain
    assert err == expected_styled


@with_ansi_style(ru.AllowStyle.ALWAYS)
def test_ansi_pouterr_always_notty(mocker, capsys) -> None:
    app = AnsiApp()
    mocker.patch.object(app.stdout, "isatty", return_value=False)
    mocker.patch.object(sys.stderr, "isatty", return_value=False)

    expected_plain = "oopsie\n"
    expected_styled = su.stylize("oopsie\n", Cmd2Style.ERROR)

    app.onecmd_plus_hooks("echo_error oopsie")
    out, err = capsys.readouterr()
    assert out == expected_styled
    assert err == expected_styled

    app.onecmd_plus_hooks("echo oopsie")
    out, err = capsys.readouterr()
    assert out == expected_plain
    assert err == expected_styled


@with_ansi_style(ru.AllowStyle.TERMINAL)
def test_ansi_terminal_tty(mocker, capsys) -> None:
    app = AnsiApp()
    mocker.patch.object(app.stdout, "isatty", return_value=True)
    mocker.patch.object(sys.stderr, "isatty", return_value=True)

    expected_plain = "oopsie\n"
    expected_styled = su.stylize("oopsie\n", Cmd2Style.ERROR)

    app.onecmd_plus_hooks("echo_error oopsie")
    out, err = capsys.readouterr()
    assert out == expected_styled
    assert err == expected_styled

    app.onecmd_plus_hooks("echo oopsie")
    out, err = capsys.readouterr()
    assert out == expected_plain
    assert err == expected_styled


@with_ansi_style(ru.AllowStyle.TERMINAL)
def test_ansi_terminal_notty(mocker, capsys) -> None:
    app = AnsiApp()
    mocker.patch.object(app.stdout, "isatty", return_value=False)
    mocker.patch.object(sys.stderr, "isatty", return_value=False)

    app.onecmd_plus_hooks("echo_error oopsie")
    out, err = capsys.readouterr()
    assert out == err == "oopsie\n"

    app.onecmd_plus_hooks("echo oopsie")
    out, err = capsys.readouterr()
    assert out == err == "oopsie\n"


@with_ansi_style(ru.AllowStyle.NEVER)
def test_ansi_never_tty(mocker, capsys) -> None:
    app = AnsiApp()
    mocker.patch.object(app.stdout, "isatty", return_value=True)
    mocker.patch.object(sys.stderr, "isatty", return_value=True)

    app.onecmd_plus_hooks("echo_error oopsie")
    out, err = capsys.readouterr()
    assert out == err == "oopsie\n"

    app.onecmd_plus_hooks("echo oopsie")
    out, err = capsys.readouterr()
    assert out == err == "oopsie\n"


@with_ansi_style(ru.AllowStyle.NEVER)
def test_ansi_never_notty(mocker, capsys) -> None:
    app = AnsiApp()
    mocker.patch.object(app.stdout, "isatty", return_value=False)
    mocker.patch.object(sys.stderr, "isatty", return_value=False)

    app.onecmd_plus_hooks("echo_error oopsie")
    out, err = capsys.readouterr()
    assert out == err == "oopsie\n"

    app.onecmd_plus_hooks("echo oopsie")
    out, err = capsys.readouterr()
    assert out == err == "oopsie\n"


class DisableCommandsApp(cmd2.Cmd):
    """Class for disabling commands"""

    DEFAULT_CATEGORY = "DisabledApp Commands"
    category_name = "Test Category"

    def __init__(self, *args, **kwargs) -> None:
        super().__init__(*args, **kwargs)

    @cmd2.with_category(category_name)
    def do_has_helper_func(self, arg) -> None:
        self.poutput("The real has_helper_func")

    def help_has_helper_func(self) -> None:
        self.poutput("Help for has_helper_func")

    def complete_has_helper_func(self, *args) -> Completions:
        return Completions.from_values(["result"])

    @cmd2.with_category(category_name)
    def do_has_no_helper_func(self, arg) -> None:
        """Help for has_no_helper_func"""
        self.poutput("The real has_no_helper_func")

    def do_is_not_decorated(self, arg) -> None:
        """This will be in the DEFAULT_CATEGORY."""
        self.poutput("The real is_not_decorated")

    @cmd2.with_argparser(cmd2.Cmd2ArgumentParser())
    def do_argparse_command(self, args) -> None:
        """Help for argparse_command"""


class DisableCommandSet(CommandSet[cmd2.Cmd]):
    """Test registering a command which is in a disabled category"""

    category_name = "CommandSet Test Category"
    DEFAULT_CATEGORY = "DisableCommandSet Commands"

    @cmd2.with_category(category_name)
    def do_new_command(self, arg) -> None:
        self._cmd.poutput("The real new_command")

    def do_cs_is_not_decorated(self, arg) -> None:
        """This will be in the DEFAULT_CATEGORY."""
        self._cmd.poutput("The real cs_is_not_decorated")


@pytest.fixture
def disable_commands_app() -> DisableCommandsApp:
    return DisableCommandsApp()


def test_disable_and_enable_category(disable_commands_app: DisableCommandsApp) -> None:
    ##########################################################################
    # Disable the category
    ##########################################################################
    message_to_print = "These commands are currently disabled"

    # Disable commands which are decorated with a category
    disable_commands_app.disable_category(disable_commands_app.category_name, message_to_print)

    # Disable commands in the default category
    disable_commands_app.disable_category(disable_commands_app.DEFAULT_CATEGORY, message_to_print)

    # Make sure all the commands and help on those commands displays the message
    out, err = run_cmd(disable_commands_app, "has_helper_func")
    assert err[0].startswith(message_to_print)

    out, err = run_cmd(disable_commands_app, "help has_helper_func")
    assert err[0].startswith(message_to_print)

    out, err = run_cmd(disable_commands_app, "has_no_helper_func")
    assert err[0].startswith(message_to_print)

    out, err = run_cmd(disable_commands_app, "help has_no_helper_func")
    assert err[0].startswith(message_to_print)

    out, err = run_cmd(disable_commands_app, "is_not_decorated")
    assert err[0].startswith(message_to_print)

    out, err = run_cmd(disable_commands_app, "help is_not_decorated")
    assert err[0].startswith(message_to_print)

    # Make sure neither function completes
    text = ""
    line = f"has_helper_func {text}"
    endidx = len(line)
    begidx = endidx - len(text)

    completions = disable_commands_app.complete(text, line, begidx, endidx)
    assert not completions

    text = ""
    line = f"has_no_helper_func {text}"
    endidx = len(line)
    begidx = endidx - len(text)

    completions = disable_commands_app.complete(text, line, begidx, endidx)
    assert not completions

    # Make sure both commands are invisible
    visible_commands = disable_commands_app.get_visible_commands()
    assert "has_helper_func" not in visible_commands
    assert "has_no_helper_func" not in visible_commands

    # Make sure get_help_topics() filters out disabled commands
    help_topics = disable_commands_app.get_help_topics()
    assert "has_helper_func" not in help_topics

    ##########################################################################
    # Enable the category
    ##########################################################################
    # Enable commands which are decorated with a category
    disable_commands_app.enable_category(disable_commands_app.category_name)

    # Enable commands in the default category
    disable_commands_app.enable_category(disable_commands_app.DEFAULT_CATEGORY)

    # Make sure all the commands and help on those commands are restored
    out, err = run_cmd(disable_commands_app, "has_helper_func")
    assert out[0] == "The real has_helper_func"

    out, err = run_cmd(disable_commands_app, "help has_helper_func")
    assert out[0] == "Help for has_helper_func"

    out, err = run_cmd(disable_commands_app, "has_no_helper_func")
    assert out[0] == "The real has_no_helper_func"

    out, err = run_cmd(disable_commands_app, "help has_no_helper_func")
    assert out[0] == "Help for has_no_helper_func"

    out, err = run_cmd(disable_commands_app, "is_not_decorated")
    assert out[0] == "The real is_not_decorated"

    # has_helper_func should complete now
    text = ""
    line = f"has_helper_func {text}"
    endidx = len(line)
    begidx = endidx - len(text)

    completions = disable_commands_app.complete(text, line, begidx, endidx)
    assert completions[0].text == "result"

    # has_no_helper_func had no completer originally, so there should be no results
    text = ""
    line = f"has_no_helper_func {text}"
    endidx = len(line)
    begidx = endidx - len(text)

    completions = disable_commands_app.complete(text, line, begidx, endidx)
    assert not completions

    # Make sure all commands are visible
    visible_commands = disable_commands_app.get_visible_commands()
    assert "has_helper_func" in visible_commands
    assert "has_no_helper_func" in visible_commands
    assert "is_not_decorated" in visible_commands

    # Make sure get_help_topics() contains our help function
    help_topics = disable_commands_app.get_help_topics()
    assert "has_helper_func" in help_topics


def test_enable_enabled_command(disable_commands_app) -> None:
    # Test enabling a command that is not disabled
    saved_len = len(disable_commands_app.disabled_commands)
    disable_commands_app.enable_command("has_helper_func")

    # The number of disabled commands should not have changed
    assert saved_len == len(disable_commands_app.disabled_commands)


def test_disable_fake_command(disable_commands_app) -> None:
    with pytest.raises(AttributeError):
        disable_commands_app.disable_command("fake", "fake message")


def test_disable_command_twice(disable_commands_app) -> None:
    saved_len = len(disable_commands_app.disabled_commands)
    message_to_print = "These commands are currently disabled"
    disable_commands_app.disable_command("has_helper_func", message_to_print)

    # The number of disabled commands should have increased one
    new_len = len(disable_commands_app.disabled_commands)
    assert saved_len == new_len - 1
    saved_len = new_len

    # Disable again and the length should not change
    disable_commands_app.disable_command("has_helper_func", message_to_print)
    new_len = len(disable_commands_app.disabled_commands)
    assert saved_len == new_len


def test_disabled_command_not_in_history(disable_commands_app) -> None:
    message_to_print = "These commands are currently disabled"
    disable_commands_app.disable_command("has_helper_func", message_to_print)

    saved_len = len(disable_commands_app.history)
    run_cmd(disable_commands_app, "has_helper_func")
    assert saved_len == len(disable_commands_app.history)


def test_get_parser_while_disabled(disable_commands_app: DisableCommandsApp) -> None:
    """Test that command_parsers can find a disabled command's parser."""
    # Get parser before disabling
    parser_before = disable_commands_app.command_parsers.get(disable_commands_app.do_argparse_command)
    assert parser_before is not None

    # Disable command
    disable_commands_app.disable_command("argparse_command", "Disabled")

    # Get parser after disabling
    parser_after = disable_commands_app.command_parsers.get(disable_commands_app.do_argparse_command)
    assert parser_after is not None
    assert parser_after is parser_before


def test_metadata_preservation_while_disabled(disable_commands_app: DisableCommandsApp) -> None:
    orig_cmd_func = disable_commands_app.do_has_helper_func
    orig_help = disable_commands_app.help_has_helper_func
    orig_complete = disable_commands_app.complete_has_helper_func

    disable_commands_app.disable_command("has_helper_func", "Disabled")

    # Names and qualnames should be preserved
    assert disable_commands_app.do_has_helper_func.__name__ == orig_cmd_func.__name__
    assert disable_commands_app.do_has_helper_func.__qualname__ == orig_cmd_func.__qualname__

    assert disable_commands_app.help_has_helper_func.__name__ == orig_help.__name__
    assert disable_commands_app.help_has_helper_func.__qualname__ == orig_help.__qualname__

    assert disable_commands_app.complete_has_helper_func.__name__ == orig_complete.__name__
    assert disable_commands_app.complete_has_helper_func.__qualname__ == orig_complete.__qualname__

    # Docstrings should be preserved
    assert disable_commands_app.do_has_helper_func.__doc__ == orig_cmd_func.__doc__
    assert disable_commands_app.help_has_helper_func.__doc__ == orig_help.__doc__
    assert disable_commands_app.complete_has_helper_func.__doc__ == orig_complete.__doc__


def test_disabled_completer_returns_empty(disable_commands_app: DisableCommandsApp) -> None:
    disable_commands_app.disable_command("has_helper_func", "Disabled")
    completions = disable_commands_app.complete_has_helper_func("", "has_helper_func ", 16, 16)
    assert len(completions) == 0


def test_disabled_message_command_name(disable_commands_app: DisableCommandsApp) -> None:
    message_to_print = f"{COMMAND_NAME} is currently disabled"
    disable_commands_app.disable_command("has_helper_func", message_to_print)

    _out, err = run_cmd(disable_commands_app, "has_helper_func")
    assert err[0].startswith("has_helper_func is currently disabled")


def test_help_argparse_command_while_disabled(disable_commands_app: DisableCommandsApp) -> None:
    message_to_print = "This command is disabled"
    disable_commands_app.disable_command("argparse_command", message_to_print)

    # help <command> should show the disabled message
    _out, err = run_cmd(disable_commands_app, "help argparse_command")
    assert err[0].startswith(message_to_print)

    # Re-enabling should restore the real help
    disable_commands_app.enable_command("argparse_command")
    out, _err = run_cmd(disable_commands_app, "help argparse_command")
    assert "Usage: argparse_command" in out[0]


def test_help_disabled_no_help_func(base_app: cmd2.Cmd) -> None:
    from cmd2.cmd2 import DisabledCommand

    # Intentionally bypass disable_command() to test the fallback in do_help()
    command = "quit"
    command_func = cast(BoundCommandFunc, base_app.get_command_func(command))
    base_app.disabled_commands[command] = DisabledCommand(command_func=command_func, help_func=None, completer_func=None)

    _out, err = run_cmd(base_app, f"help {command}")
    assert err[0].startswith(f"{command} is currently disabled.")


def test_register_command_in_enabled_category(disable_commands_app) -> None:
    # Enable commands which are decorated with a category
    disable_commands_app.enable_category(DisableCommandSet.category_name)

    # Enable commands in the default category
    disable_commands_app.enable_category(DisableCommandSet.DEFAULT_CATEGORY)

    cs = DisableCommandSet()
    disable_commands_app.register_command_set(cs)

    out, _err = run_cmd(disable_commands_app, "new_command")
    assert out[0] == "The real new_command"

    out, _err = run_cmd(disable_commands_app, "cs_is_not_decorated")
    assert out[0] == "The real cs_is_not_decorated"


def test_register_command_in_disabled_category(disable_commands_app) -> None:
    message_to_print = "CommandSet function is disabled"

    # Disable commands which are decorated with a category
    disable_commands_app.disable_category(DisableCommandSet.category_name, message_to_print)

    # Disable commands in the default category
    disable_commands_app.disable_category(DisableCommandSet.DEFAULT_CATEGORY, message_to_print)

    cs = DisableCommandSet()
    disable_commands_app.register_command_set(cs)

    _out, err = run_cmd(disable_commands_app, "new_command")
    assert err[0] == message_to_print

    _out, err = run_cmd(disable_commands_app, "cs_is_not_decorated")
    assert err[0] == message_to_print


def test_enable_enabled_category(disable_commands_app) -> None:
    # Test enabling a category that is not disabled
    saved_len = len(disable_commands_app.disabled_categories)
    disable_commands_app.enable_category("Test Category")

    # The number of disabled categories should not have changed
    assert saved_len == len(disable_commands_app.disabled_categories)


def test_disable_category_twice(disable_commands_app) -> None:
    saved_len = len(disable_commands_app.disabled_categories)
    message_to_print = "These commands are currently disabled"
    disable_commands_app.disable_category("Test Category", message_to_print)

    # The number of disabled categories should have increased one
    new_len = len(disable_commands_app.disabled_categories)
    assert saved_len == new_len - 1
    saved_len = new_len

    # Disable again and the length should not change
    disable_commands_app.disable_category("Test Category", message_to_print)
    new_len = len(disable_commands_app.disabled_categories)
    assert saved_len == new_len


@pytest.mark.parametrize("silence_startup_script", [True, False])
def test_startup_script(request, capsys, silence_startup_script) -> None:
    test_dir = os.path.dirname(request.module.__file__)
    startup_script = os.path.join(test_dir, ".cmd2rc")
    app = cmd2.Cmd(allow_cli_args=False, startup_script=startup_script, silence_startup_script=silence_startup_script)
    assert len(app._startup_commands) == 1
    app._startup_commands.append("quit")
    app.cmdloop()

    out, _err = capsys.readouterr()
    if silence_startup_script:
        assert not out
    else:
        assert out

    out, _err = run_cmd(app, "alias list")
    assert len(out) > 1
    assert "alias create ls" in out[0]


@pytest.mark.parametrize("startup_script", odd_file_names)
def test_startup_script_with_odd_file_names(startup_script) -> None:
    """Test file names with various patterns"""
    # Mock os.path.exists to trick cmd2 into adding this script to its startup commands
    saved_exists = os.path.exists
    os.path.exists = mock.MagicMock(name="exists", return_value=True)

    app = cmd2.Cmd(allow_cli_args=False, startup_script=startup_script)
    assert len(app._startup_commands) == 1
    assert app._startup_commands[0] == f"run_script {su.quote(os.path.abspath(startup_script))}"

    # Restore os.path.exists
    os.path.exists = saved_exists


def test_command_parser_retrieval(outsim_app: cmd2.Cmd) -> None:
    # Pass something that isn't a method
    not_a_method = "just a string"
    assert outsim_app.command_parsers.get(not_a_method) is None

    # Pass a non-command method
    assert outsim_app.command_parsers.get(outsim_app.__init__) is None


def test_command_synonym_parser() -> None:
    # Make sure a command synonym returns the same parser as what it aliases
    class SynonymApp(cmd2.cmd2.Cmd):
        do_synonym = cmd2.cmd2.Cmd.do_help

    app = SynonymApp()

    synonym_parser = app.command_parsers.get(app.do_synonym)
    help_parser = app.command_parsers.get(app.do_help)

    assert synonym_parser is not None
    assert synonym_parser is help_parser


def test_custom_completekey_ctrl_k():
    from prompt_toolkit.keys import Keys

    # Test setting a custom completekey to <CTRL> + K
    # In prompt_toolkit, this is 'c-k'
    app = cmd2.Cmd(completekey="c-k")

    assert app.main_session.key_bindings is not None

    # Check that we have a binding for c-k (Keys.ControlK)
    found = False
    for binding in app.main_session.key_bindings.bindings:
        # binding.keys is a tuple of keys
        if binding.keys == (Keys.ControlK,):
            found = True
            break

    assert found, "Could not find binding for 'c-k' (Keys.ControlK) in session key bindings"


def test_completekey_empty_string() -> None:
    # Test that an empty string for completekey defaults to DEFAULT_COMPLETEKEY

    with mock.patch("cmd2.Cmd._create_main_session", autospec=True) as create_session_mock:
        create_session_mock.return_value = mock.MagicMock(spec=PromptSession)

        app = cmd2.Cmd(completekey="")
        create_session_mock.assert_called_once()
        _, kwargs = create_session_mock.call_args
        assert kwargs["completekey"] == app.DEFAULT_COMPLETEKEY


def test_create_main_session_exception(monkeypatch):
    # Mock PromptSession to raise ValueError on first call, then succeed
    valid_session_mock = mock.MagicMock(spec=PromptSession)
    mock_session = mock.MagicMock(side_effect=[ValueError, valid_session_mock])
    monkeypatch.setattr("cmd2.cmd2.PromptSession", mock_session)

    # Mock isatty to ensure we enter the try block
    with (
        mock.patch("sys.stdin.isatty", return_value=True),
        mock.patch("sys.stdout.isatty", return_value=True),
    ):
        cmd2.Cmd()

    # Check that fallback to DummyInput/Output happened
    assert mock_session.call_count == 2

    # Check args of second call
    call_args = mock_session.call_args_list[1]
    kwargs = call_args[1]
    assert isinstance(kwargs["input"], DummyInput)
    assert isinstance(kwargs["output"], DummyOutput)


@pytest.mark.skipif(
    not sys.platform.startswith("win"),
    reason="This tests how app.pager is set when running on Windows.",
)
def test_pager_on_windows(monkeypatch):
    app = cmd2.Cmd()
    assert app.pager == "more"
    assert app.pager_chop == "more"


@pytest.mark.skipif(
    not sys.platform.startswith("win"),
    reason="This tests how Cmd._complete_users() behaves on Windows.",
)
def test_path_complete_users_windows(monkeypatch, base_app):
    # Mock os.path.expanduser and isdir
    monkeypatch.setattr("os.path.expanduser", lambda p: "/home/user" if p == "~user" else p)
    monkeypatch.setattr("os.path.isdir", lambda p: p == "/home/user")

    matches = base_app.path_complete("~user", "cmd ~user", 0, 9)
    # Should contain ~user/ (or ~user\ depending on sep)
    # Since we didn't mock os.path.sep, it will use system separator.
    expected = "~user" + os.path.sep
    assert expected in matches


def test_refresh_interval() -> None:
    # Test default value
    default_app = cmd2.Cmd()
    assert default_app.main_session.refresh_interval == 0.0

    # Test custom value
    custom_app = cmd2.Cmd(refresh_interval=5.0)
    assert custom_app.main_session.refresh_interval == 5.0


def test_enable_bottom_toolbar() -> None:
    # Test default
    default_app = cmd2.Cmd()
    assert default_app.main_session.bottom_toolbar is None

    # Test True
    custom_app = cmd2.Cmd(enable_bottom_toolbar=True)
    assert custom_app.main_session.bottom_toolbar == custom_app.get_bottom_toolbar

    # Test False
    custom_app = cmd2.Cmd(enable_bottom_toolbar=False)
    assert custom_app.main_session.bottom_toolbar is None


def test_enable_rprompt() -> None:
    # Test default
    default_app = cmd2.Cmd()
    assert default_app.main_session.rprompt is None

    # Test True
    custom_app = cmd2.Cmd(enable_rprompt=True)
    assert custom_app.main_session.rprompt == custom_app.get_rprompt

    # Test False
    custom_app = cmd2.Cmd(enable_rprompt=False)
    assert custom_app.main_session.rprompt is None


def test_get_bottom_toolbar(base_app: cmd2.Cmd) -> None:
    assert base_app.get_bottom_toolbar() is None


def test_get_rprompt(base_app: cmd2.Cmd) -> None:
    assert base_app.get_rprompt() is None


def test_multiline_complete_statement_keyboard_interrupt(multiline_app, monkeypatch):
    # Mock _read_command_line to raise KeyboardInterrupt
    read_command_mock = mock.MagicMock(name="_read_command_line", side_effect=KeyboardInterrupt)
    monkeypatch.setattr("cmd2.Cmd._read_command_line", read_command_mock)

    # Mock poutput to verify ^C is printed
    poutput_mock = mock.MagicMock(name="poutput")
    monkeypatch.setattr(multiline_app, "poutput", poutput_mock)

    with pytest.raises(exceptions.EmptyStatement):
        multiline_app._complete_statement("orate incomplete")

    poutput_mock.assert_called_with("^C")


def test_create_main_session_no_console_error(monkeypatch):
    from cmd2.cmd2 import NoConsoleScreenBufferError

    # Mock PromptSession to raise NoConsoleScreenBufferError on first call, then succeed
    valid_session_mock = mock.MagicMock(spec=PromptSession)
    mock_session = mock.MagicMock(side_effect=[NoConsoleScreenBufferError, valid_session_mock])
    monkeypatch.setattr("cmd2.cmd2.PromptSession", mock_session)

    # Mock isatty to ensure we enter the try block
    with (
        mock.patch("sys.stdin.isatty", return_value=True),
        mock.patch("sys.stdout.isatty", return_value=True),
    ):
        cmd2.Cmd()

    # Check that fallback to DummyInput/Output happened
    assert mock_session.call_count == 2

    # Check args of second call
    call_args = mock_session.call_args_list[1]
    kwargs = call_args[1]
    assert isinstance(kwargs["input"], DummyInput)
    assert isinstance(kwargs["output"], DummyOutput)


def test_create_main_session_with_custom_tty() -> None:
    # Create a mock stdin with says it's a TTY
    custom_stdin = mock.MagicMock(spec=io.TextIOWrapper)
    custom_stdin.isatty.return_value = True
    assert custom_stdin is not sys.stdin

    # Create a mock stdout with says it's a TTY
    custom_stdout = mock.MagicMock(spec=io.TextIOWrapper)
    custom_stdout.isatty.return_value = True
    assert custom_stdout is not sys.stdout

    # Check if the streams were wrapped
    with (
        mock.patch("cmd2.cmd2.create_input") as mock_create_input,
        mock.patch("cmd2.cmd2.create_output") as mock_create_output,
    ):
        app = cmd2.Cmd()
        app.stdin = custom_stdin
        app.stdout = custom_stdout
        app._create_main_session(
            auto_suggest=True,
            completekey=app.DEFAULT_COMPLETEKEY,
            enable_bottom_toolbar=False,
            enable_rprompt=False,
            complete_in_thread=False,
            refresh_interval=0.0,
        )

        mock_create_input.assert_called_once_with(stdin=custom_stdin)
        mock_create_output.assert_called_once_with(stdout=custom_stdout)


def test_create_main_session_stdin_non_tty() -> None:
    # Set up a mock for a non-TTY stdin stream
    mock_stdin = mock.MagicMock(spec=io.TextIOWrapper)
    mock_stdin.isatty.return_value = False

    app = cmd2.Cmd(stdin=mock_stdin)
    assert isinstance(app.main_session.input, DummyInput)
    assert isinstance(app.main_session.output, DummyOutput)


def test_create_main_session_stdout_non_tty() -> None:
    # Set up a mock for a non-TTY stdout stream
    mock_stdout = mock.MagicMock(spec=io.TextIOWrapper)
    mock_stdout.isatty.return_value = False

    app = cmd2.Cmd(stdout=mock_stdout)
    assert isinstance(app.main_session.input, DummyInput)
    assert isinstance(app.main_session.output, DummyOutput)


def test_no_console_screen_buffer_error_dummy():
    from cmd2.cmd2 import NoConsoleScreenBufferError

    # Check that it behaves like a normal exception
    err = NoConsoleScreenBufferError()
    assert isinstance(err, Exception)


def test_read_command_line_dynamic_prompt(base_app: cmd2.Cmd) -> None:
    """Test that _read_command_line uses a dynamic prompt when provided prompt matches app.prompt"""

    # Mock patch_stdout to prevent it from attempting to access the Windows
    # console buffer in a Windows test environment.
    with mock.patch("cmd2.cmd2.patch_stdout"):
        # Set input to something other than DummyInput so _read_raw_input()
        # will go down the TTY route.
        mock_session = mock.MagicMock()
        mock_session.input = mock.MagicMock()
        base_app.main_session = mock_session
        base_app._read_command_line(base_app.prompt)

        # Check that mock_prompt was called with a callable for the prompt
        args, _ = mock_session.prompt.call_args
        prompt_arg = args[0]
        assert callable(prompt_arg)

        # Verify the callable returns the expected ANSI formatted prompt
        from prompt_toolkit.formatted_text import ANSI

        result = prompt_arg()
        assert isinstance(result, ANSI)
        assert result.value == ANSI(base_app.prompt).value


def test_read_input_history_isolation(base_app: cmd2.Cmd) -> None:
    local_history = ["secret_command", "another_command"]

    # Mock _read_raw_input to prevent actual blocking
    # We want to inspect the session object passed to it
    with mock.patch.object(base_app, "_read_raw_input") as mock_raw:
        mock_raw.return_value = "user_input"

        base_app.read_input("prompt> ", history=local_history)

        # Inspect the session used in the call
        args, _ = mock_raw.call_args
        passed_session = args[1]

        # Verify the session's history contains our list
        loaded_history = list(passed_session.history.load_history_strings())
        assert "secret_command" in loaded_history
        assert "another_command" in loaded_history

        # Verify the main app session was not touched
        # This is the crucial check for isolation
        main_history = base_app.main_session.history.get_strings()
        assert "secret_command" not in main_history


@pytest.mark.skipif(
    sys.platform.startswith("win"),
    reason="Don't have a real Windows console with how we are currently running tests in GitHub Actions",
)
def test_pre_prompt_running_loop(base_app):
    # Test that pre_prompt runs with a running event loop.
    import asyncio

    # Set up pipe input to feed data to prompt_toolkit
    with create_pipe_input() as pipe_input:
        # Create a new session with our pipe input because the input property is read-only
        base_app.main_session = PromptSession(
            input=pipe_input,
            output=DummyOutput(),
            history=base_app.main_session.history,
            completer=base_app.main_session.completer,
        )

        loop_check = {"running": False}

        def my_pre_prompt():
            try:
                asyncio.get_running_loop()
                loop_check["running"] = True
            except RuntimeError:
                loop_check["running"] = False

        base_app.pre_prompt = my_pre_prompt

        # Feed input to exit prompt immediately
        pipe_input.send_text("foo\n")

        # Ensure self.session.prompt is used
        base_app._read_command_line("prompt> ")

        assert loop_check["running"]


def test_auto_suggest_true():
    """Test that auto_suggest=True initializes AutoSuggestFromHistory."""
    app = cmd2.Cmd(auto_suggest=True)
    assert isinstance(app.main_session.auto_suggest, AutoSuggestFromHistory)


def test_auto_suggest_false():
    """Test that auto_suggest=False does not initialize AutoSuggestFromHistory."""
    app = cmd2.Cmd(auto_suggest=False)
    assert app.main_session.auto_suggest is None


def test_auto_suggest_default():
    """Test that auto_suggest defaults to True."""
    app = cmd2.Cmd()
    assert isinstance(app.main_session.auto_suggest, AutoSuggestFromHistory)


def test_subcommand_attachment() -> None:
    import argparse

    class SubcmdApp(cmd2.Cmd):
        def __init__(self) -> None:
            super().__init__()

        root_parser = cmd2.Cmd2ArgumentParser()
        root_parser.add_subparsers()

        @cmd2.with_argparser(root_parser)
        def do_root(self, _args: argparse.Namespace) -> None:
            pass

    app = SubcmdApp()

    # Verify root exists and uses argparse
    root_parser = app.command_parsers.get(app.do_root)
    assert root_parser is not None

    # Attach child to root
    child_parser = cmd2.Cmd2ArgumentParser(prog="child")
    child_parser.add_subparsers()
    child_record = SubcommandRecord(name="child", command="root", parser=child_parser, help="child help")
    app.attach_subcommand(child_record)

    # Verify child was attached
    root_subparsers_action = root_parser.get_subparsers_action()
    assert "child" in root_subparsers_action._name_parser_map
    assert root_subparsers_action._name_parser_map["child"] is child_parser

    # Attach grandchild to child
    grandchild_parser = cmd2.Cmd2ArgumentParser(prog="grandchild")
    grandchild_record = SubcommandRecord(name="grandchild", command="root child", parser=grandchild_parser)
    app.attach_subcommand(grandchild_record)

    # Verify grandchild was attached
    child_subparsers_action = child_parser.get_subparsers_action()
    assert "grandchild" in child_subparsers_action._name_parser_map

    # Detach grandchild
    detached_grandchild_info = app.detach_subcommand("root child", "grandchild")
    assert detached_grandchild_info.parser is grandchild_parser
    assert "grandchild" not in child_subparsers_action._name_parser_map

    # Detach child
    detached_child_info = app.detach_subcommand("root", "child")
    assert detached_child_info.parser is child_parser
    assert "child" not in root_subparsers_action._name_parser_map


def test_subcommand_attachment_errors() -> None:
    class SubcmdErrorApp(cmd2.Cmd):
        def __init__(self) -> None:
            super().__init__()

        test_parser = cmd2.Cmd2ArgumentParser()
        test_parser.add_subparsers(required=True)

        @cmd2.with_argparser(test_parser)
        def do_test(self, _statement: cmd2.Statement) -> None:
            pass

        def do_no_argparse(self, _statement: cmd2.Statement) -> None:
            pass

    app = SubcmdErrorApp()

    # Test empty command
    sub_record = SubcommandRecord(name="sub", command="", parser=cmd2.Cmd2ArgumentParser())
    with pytest.raises(ValueError, match="Command path cannot be empty"):
        app.attach_subcommand(sub_record)

    # Test non-existent command
    nonexistent_record = SubcommandRecord(name="sub", command="fake", parser=cmd2.Cmd2ArgumentParser())
    with pytest.raises(ValueError, match="Root command 'fake' does not exist"):
        app.attach_subcommand(nonexistent_record)

    # Test command that doesn't use argparse
    no_argparse_record = SubcommandRecord(name="sub", command="no_argparse", parser=cmd2.Cmd2ArgumentParser())
    with pytest.raises(ValueError, match="Command 'no_argparse' does not use argparse"):
        app.attach_subcommand(no_argparse_record)

    # Test duplicate subcommand
    duplicate_record = SubcommandRecord(name="sub", command="test", parser=cmd2.Cmd2ArgumentParser())
    app.attach_subcommand(duplicate_record)
    with pytest.raises(ValueError, match="Subcommand 'sub' already exists for 'test'"):
        app.attach_subcommand(duplicate_record)


def test_detach_all_subcommands() -> None:
    import argparse

    class RefactorApp(cmd2.Cmd):
        def __init__(self) -> None:
            super().__init__()

        base_parser = cmd2.Cmd2ArgumentParser()
        base_parser.add_subparsers()

        @cmd2.with_argparser(base_parser)
        def do_base(self, _: argparse.Namespace) -> None:
            pass

    app = RefactorApp()

    child1 = cmd2.Cmd2ArgumentParser()
    child2 = cmd2.Cmd2ArgumentParser()

    child1_record = SubcommandRecord(name="child1", command="base", parser=child1)
    child2_record = SubcommandRecord(name="child2", command="base", parser=child2)

    app.attach_subcommand(child1_record)
    app.attach_subcommand(child2_record)

    removed = app.detach_all_subcommands("base")
    assert len(removed) == 2

    root_parser = cast(cmd2.Cmd2ArgumentParser, app.command_parsers.get(app.do_base))
    subparsers_action = root_parser.get_subparsers_action()
    assert not subparsers_action._name_parser_map
