"""CLI dispatch tests for obliteratus.cli.main().

These tests verify argument parsing and subcommand routing without
downloading real models or running any pipeline.  They use
``unittest.mock.patch`` to capture stdout/stderr and
``pytest.raises(SystemExit)`` for argparse exits.
"""

from __future__ import annotations

import json
from io import StringIO
from types import SimpleNamespace
from unittest.mock import MagicMock
from unittest.mock import patch

import pytest

from obliteratus.cli import main


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------


def _capture_exit(argv: list[str] | None, *, expect_code: int | None = None):
    """Call main(argv), expecting SystemExit; return captured stderr text."""
    buf = StringIO()
    with pytest.raises(SystemExit) as exc_info, patch("sys.stderr", buf):
        main(argv)
    if expect_code is not None:
        assert exc_info.value.code == expect_code
    return buf.getvalue()


# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------


class TestCLIDispatch:
    """Test suite for CLI argument parsing and subcommand dispatch."""

    # 1. No args -> prints help / exits with error
    def test_main_no_args_prints_help(self):
        """Calling main() with no args should exit (subcommand is required)."""
        stderr_text = _capture_exit([], expect_code=2)
        # argparse prints usage info to stderr on error
        assert "usage" in stderr_text.lower() or "required" in stderr_text.lower()

    # 2. models command lists models without error
    def test_models_command(self):
        """Calling main(['models']) should list models without raising."""
        with patch("obliteratus.cli.console") as mock_console:
            main(["models"])
        # console.print is called at least once to render the table
        assert mock_console.print.call_count >= 1

    # 3. obliterate without model arg -> error
    def test_obliterate_requires_model(self):
        """Calling main(['obliterate']) without a model arg should error."""
        stderr_text = _capture_exit(["obliterate"], expect_code=2)
        assert "model" in stderr_text.lower() or "required" in stderr_text.lower()

    # 4. obliterate --method accepts valid methods
    def test_obliterate_valid_methods(self):
        """Test that --method accepts all 9 pipeline methods."""
        valid_methods = [
            "basic", "advanced", "aggressive", "spectral_cascade",
            "informed", "surgical", "optimized", "inverted", "nuclear",
        ]
        for method in valid_methods:
            # Patch the actual pipeline execution so nothing runs
            with patch("obliteratus.cli._cmd_abliterate") as mock_cmd:
                main(["obliterate", "fake/model", "--method", method])
                mock_cmd.assert_called_once()
                args_passed = mock_cmd.call_args[0][0]
                assert args_passed.method == method

    # 4b. invalid methods are rejected
    def test_obliterate_rejects_invalid_method(self):
        """The CLI --method flag rejects unknown method names."""
        stderr_text = _capture_exit(
            ["obliterate", "fake/model", "--method", "nonexistent"],
            expect_code=2,
        )
        assert "invalid choice" in stderr_text.lower()

    # 5. run requires config path
    def test_run_requires_config(self):
        """Calling main(['run']) without a config path should error."""
        stderr_text = _capture_exit(["run"], expect_code=2)
        assert "config" in stderr_text.lower() or "required" in stderr_text.lower()

    # 6. aggregate with nonexistent dir handles gracefully
    def test_aggregate_command_missing_dir(self):
        """Calling main(['aggregate']) with nonexistent dir should handle gracefully."""
        with patch("obliteratus.cli.console") as mock_console:
            main(["aggregate", "--dir", "/nonexistent/path/to/nowhere"])
        # The command prints a message about no contributions found and returns
        printed_text = " ".join(
            str(call) for call in mock_console.print.call_args_list
        )
        assert "no contributions found" in printed_text.lower() or mock_console.print.called

    def test_aggregate_accepts_format_metric_min_runs(self):
        """aggregate accepts --format, --metric and --min-runs flags."""
        with patch("obliteratus.cli._cmd_aggregate") as mock_cmd:
            main([
                "aggregate",
                "--format", "latex",
                "--metric", "refusal_rate",
                "--min-runs", "3",
            ])
            mock_cmd.assert_called_once()
            args_passed = mock_cmd.call_args[0][0]
            assert args_passed.format == "latex"
            assert args_passed.metric == "refusal_rate"
            assert args_passed.min_runs == 3

    def test_aggregate_rejects_invalid_format(self):
        """aggregate rejects unknown --format choices."""
        stderr_text = _capture_exit(
            ["aggregate", "--format", "invalid"],
            expect_code=2,
        )
        assert "invalid choice" in stderr_text.lower()

    # 7. --help flag prints help
    def test_help_flag(self):
        """Calling main(['--help']) should print help and exit 0."""
        buf = StringIO()
        with pytest.raises(SystemExit) as exc_info, patch("sys.stdout", buf):
            main(["--help"])
        assert exc_info.value.code == 0
        output = buf.getvalue()
        assert "obliteratus" in output.lower() or "usage" in output.lower()

    # 8. interactive subcommand is registered
    def test_interactive_command_exists(self):
        """Verify 'interactive' subcommand is registered and dispatches."""
        with patch("obliteratus.cli._cmd_interactive") as mock_cmd:
            main(["interactive"])
            mock_cmd.assert_called_once()

    # 9. --contribute and --contribute-notes are accepted on obliterate
    def test_contribute_flags_on_obliterate(self):
        """Verify --contribute and --contribute-notes are accepted args."""
        with patch("obliteratus.cli._cmd_abliterate") as mock_cmd:
            main([
                "obliterate", "fake/model",
                "--contribute",
                "--contribute-notes", "Testing contribution system",
            ])
            mock_cmd.assert_called_once()
            args_passed = mock_cmd.call_args[0][0]
            assert args_passed.contribute is True
            assert args_passed.contribute_notes == "Testing contribution system"

    @pytest.mark.parametrize("flag", ["--prompt-pairs-file", "--prompt-pair-file"])
    @pytest.mark.parametrize("command", ["obliterate", "abliterate"])
    def test_prompt_pairs_file_flag_is_available_on_obliterate_and_alias(
        self,
        command,
        flag,
        tmp_path,
    ):
        """Explicit prompt-pair files are parsed for both commands and flag spellings."""
        path = tmp_path / "pairs.json"
        path.write_text("{}", encoding="utf-8")
        with patch("obliteratus.cli._cmd_abliterate") as mock_cmd:
            main([command, "fake/model", flag, str(path)])
            args_passed = mock_cmd.call_args[0][0]
            assert args_passed.prompt_pairs_file == str(path)

    @pytest.mark.parametrize("flag", ["--prompt-pairs-file", "--prompt-pair-file"])
    @pytest.mark.parametrize("command", ["obliterate", "abliterate"])
    def test_prompt_pairs_file_is_mutually_exclusive_with_residue_files(
        self,
        command,
        flag,
        tmp_path,
    ):
        """Explicit prompt-pair files and mined residue construction cannot be mixed."""
        path = tmp_path / "pairs.json"
        path.write_text("{}", encoding="utf-8")
        stderr_text = _capture_exit(
            [
                command,
                "fake/model",
                flag,
                str(path),
                "--residue-file",
                "audit.json",
            ],
            expect_code=2,
        )
        assert "not allowed with argument" in stderr_text.lower()

    @pytest.mark.parametrize(
        ("option", "value"),
        [
            ("--dataset", "custom"),
            ("--residue-weight", "7"),
            ("--residue-max", "3"),
        ],
    )
    @pytest.mark.parametrize("flag", ["--prompt-pairs-file", "--prompt-pair-file"])
    @pytest.mark.parametrize("command", ["obliterate", "abliterate"])
    def test_prompt_pairs_file_rejects_residue_only_options(
        self,
        command,
        flag,
        option,
        value,
        tmp_path,
    ):
        """Explicit prompt-pair files cannot be mixed with residue-only options."""
        path = tmp_path / "pairs.json"
        path.write_text("{}", encoding="utf-8")
        stderr_text = _capture_exit(
            [command, "fake/model", flag, str(path), option, value],
            expect_code=2,
        )
        assert option in stderr_text
        assert "can only be used with --residue-file" in stderr_text


def test_cmd_abliterate_wires_prompt_pairs_file_into_pipeline(tmp_path):
    """Loaded prompt-pair files are passed directly to AbliterationPipeline."""
    path = tmp_path / "pairs.json"
    path.write_text(
        json.dumps(
            {
                "harmful": [f"harm {index}" for index in range(5)],
                "harmless": [f"safe {index}" for index in range(5)],
            }
        ),
        encoding="utf-8",
    )
    result_path = tmp_path / "result"
    result_path.mkdir()
    pipeline = MagicMock()
    pipeline.run.return_value = str(result_path)

    class FakeLive:
        def __init__(self, *_args, **_kwargs):
            self.update = MagicMock()

        def __enter__(self):
            return self

        def __exit__(self, *_args):
            return False

    args = SimpleNamespace(
        model="org/model",
        output_dir=str(tmp_path / "out"),
        device="cpu",
        dtype="float32",
        method="basic",
        n_directions=1,
        direction_method=None,
        regularization=None,
        refinement_passes=1,
        min_layer_fraction=None,
        max_layer_fraction=None,
        harmless_pc_count=None,
        shield_concept_count=None,
        shield_ridge=None,
        shield_residualize=None,
        shield_layer_penalty=None,
        projection_target=None,
        projection_row_fraction=None,
        quantization=None,
        gpu_memory_utilization=None,
        large_model=False,
        verify_sample_size=1,
        refusal_max_tokens=1,
        residue_file=[],
        dataset="builtin",
        residue_weight=5,
        residue_max=None,
        prompt_pairs_file=str(path),
        contribute=False,
        contribute_notes="",
    )
    with (
        patch("rich.live.Live", FakeLive),
        patch("obliteratus.abliterate.AbliterationPipeline", return_value=pipeline) as factory,
        patch("obliteratus.telemetry.maybe_send_pipeline_report"),
    ):
        from obliteratus import cli

        cli._cmd_abliterate(args)

    assert factory.call_args.kwargs["harmful_prompts"] == [f"harm {index}" for index in range(5)]
    assert factory.call_args.kwargs["harmless_prompts"] == [f"safe {index}" for index in range(5)]

    args.prompt_pairs_file = None
    pipeline.reset_mock()
    pipeline.run.return_value = str(result_path)
    with (
        patch("rich.live.Live", FakeLive),
        patch("obliteratus.abliterate.AbliterationPipeline", return_value=pipeline) as factory,
        patch("obliteratus.telemetry.maybe_send_pipeline_report"),
    ):
        from obliteratus import cli

        cli._cmd_abliterate(args)

    assert "harmful_prompts" not in factory.call_args.kwargs
    assert "harmless_prompts" not in factory.call_args.kwargs


def test_cmd_abliterate_reports_invalid_prompt_pairs_file(tmp_path):
    from obliteratus import cli

    args = SimpleNamespace(
        model="org/model",
        output_dir=str(tmp_path / "out"),
        device="cpu",
        dtype="float32",
        method="basic",
        n_directions=1,
        direction_method=None,
        regularization=None,
        refinement_passes=1,
        min_layer_fraction=None,
        max_layer_fraction=None,
        harmless_pc_count=None,
        shield_concept_count=None,
        shield_ridge=None,
        shield_residualize=None,
        shield_layer_penalty=None,
        projection_target=None,
        projection_row_fraction=None,
        quantization=None,
        gpu_memory_utilization=None,
        large_model=False,
        verify_sample_size=1,
        refusal_max_tokens=1,
        residue_file=[],
        dataset="builtin",
        residue_weight=5,
        residue_max=None,
        prompt_pairs_file=str(tmp_path / "missing.json"),
        contribute=False,
        contribute_notes="",
    )

    with pytest.raises(SystemExit) as exc:
        cli._cmd_abliterate(args)

    assert exc.value.code == 2


class _EncodingOnlyStdout:
    """Minimal stream stand-in for encoding-selection tests."""

    def __init__(self, encoding: str | None) -> None:
        self.encoding = encoding


class TestConsoleEncoding:
    """The CLI must remain renderable on legacy Windows code pages."""

    @pytest.mark.parametrize("encoding", ["cp1252", "ascii", "not-a-codec"])
    def test_console_text_falls_back_when_text_is_not_encodable(self, encoding):
        from obliteratus.cli import _console_text

        with patch("sys.stdout", _EncodingOnlyStdout(encoding)):
            assert _console_text("█→", "FALLBACK") == "FALLBACK"

    @pytest.mark.parametrize("encoding", ["utf-8", None])
    def test_console_text_keeps_unicode_on_utf8_compatible_streams(self, encoding):
        from obliteratus.cli import _console_text

        with patch("sys.stdout", _EncodingOnlyStdout(encoding)):
            assert _console_text("█→", "FALLBACK") == "█→"

    def test_banner_degrades_to_ascii_on_cp1252(self):
        from obliteratus.cli import _banner_for_console

        with patch("sys.stdout", _EncodingOnlyStdout("cp1252")):
            banner = _banner_for_console()

        assert "OBLITERATUS" in banner
        assert banner.isascii()

    def test_main_renders_selected_banner(self):
        with (
            patch("sys.stdout", _EncodingOnlyStdout("cp1252")),
            patch("obliteratus.cli.console") as mock_console,
        ):
            main(["models"])

        assert mock_console.print.call_args_list[0].args[0].isascii()
