"""Tests for launcher.ci_runner — tribunal ci standalone mode."""

from __future__ import annotations

import json
import subprocess
from pathlib import Path
from unittest.mock import MagicMock, patch

import pytest

from launcher.ci_runner import (
    CIReport,
    FileResult,
    _hook_dir,
    discover_changed_files,
    measure_coverage,
    run_ci,
    run_file_check,
    run_secrets_check,
    run_tdd_check,
)

# ---------------------------------------------------------------------------
# File discovery tests
# ---------------------------------------------------------------------------


class TestDiscoverChangedFiles:
    """Test file discovery logic."""

    def test_uses_git_diff_when_available(self, tmp_path: Path) -> None:
        """Should return files from git diff HEAD~1 HEAD when available."""
        with patch("subprocess.run") as mock_run:
            mock_run.return_value = MagicMock(
                returncode=0,
                stdout="src/foo.py\nsrc/bar.py\n",
                stderr="",
            )
            files = discover_changed_files(tmp_path)
        assert len(files) == 2
        assert files[0].name == "foo.py"
        assert files[1].name == "bar.py"

    def test_falls_back_to_ls_files_when_no_diff(self, tmp_path: Path) -> None:
        """Should fall back to git ls-files when git diff returns nothing."""
        call_count = 0

        def fake_run(cmd, **kwargs):  # noqa: ANN001
            nonlocal call_count
            call_count += 1
            result = MagicMock()
            result.stderr = ""
            if "diff" in cmd:
                result.returncode = 0
                result.stdout = ""  # empty — no diff
            else:
                # ls-files
                result.returncode = 0
                result.stdout = "main.py\nutils.py\n"
            return result

        with patch("subprocess.run", side_effect=fake_run):
            files = discover_changed_files(tmp_path)

        assert call_count == 2  # diff then ls-files
        assert any(f.name == "main.py" for f in files)

    def test_falls_back_to_rglob_when_not_git(self, tmp_path: Path) -> None:
        """Should rglob for all files when not in a git repo."""
        (tmp_path / "app.py").write_text("# hello")
        (tmp_path / "lib.py").write_text("# lib")

        with patch("subprocess.run", side_effect=FileNotFoundError("git not found")):
            files = discover_changed_files(tmp_path)

        names = {f.name for f in files}
        assert "app.py" in names
        assert "lib.py" in names

    def test_falls_back_to_rglob_on_timeout(self, tmp_path: Path) -> None:
        """Should rglob when subprocess times out."""
        (tmp_path / "service.py").write_text("pass")

        with patch("subprocess.run", side_effect=subprocess.TimeoutExpired("git", 15)):
            files = discover_changed_files(tmp_path)

        assert any(f.name == "service.py" for f in files)


# ---------------------------------------------------------------------------
# JSON output tests
# ---------------------------------------------------------------------------


class TestJSONOutput:
    """Test --json flag produces machine-readable output."""

    def test_json_output_format(self, tmp_path: Path, capsys: pytest.CaptureFixture) -> None:
        """Should output valid JSON with required top-level keys."""
        py_file = tmp_path / "module.py"
        py_file.write_text("def hello(): pass\n")

        with (
            patch("launcher.ci_runner.run_secrets_check", return_value=("pass", [])),
            patch("launcher.ci_runner.run_file_check", return_value=("pass", [])),
            patch("launcher.ci_runner.run_tdd_check", return_value=("pass", [])),
        ):
            run_ci(files=[str(py_file)], as_json=True)

        captured = capsys.readouterr()
        data = json.loads(captured.out)

        assert "total" in data
        assert "passed" in data
        assert "failed" in data
        assert "warned" in data
        assert "files" in data
        assert isinstance(data["files"], list)

    def test_json_file_entry_schema(self, tmp_path: Path, capsys: pytest.CaptureFixture) -> None:
        """Each file entry should have file, secrets, tests, overall, details keys."""
        py_file = tmp_path / "widget.py"
        py_file.write_text("x = 1\n")

        with (
            patch("launcher.ci_runner.run_secrets_check", return_value=("pass", [])),
            patch("launcher.ci_runner.run_file_check", return_value=("pass", [])),
            patch("launcher.ci_runner.run_tdd_check", return_value=("pass", [])),
        ):
            run_ci(files=[str(py_file)], as_json=True)

        data = json.loads(capsys.readouterr().out)
        assert len(data["files"]) == 1
        entry = data["files"][0]
        for key in ("file", "secrets", "tests", "overall", "details"):
            assert key in entry, f"Missing key: {key}"

    def test_json_totals_match_results(self, tmp_path: Path, capsys: pytest.CaptureFixture) -> None:
        """JSON totals should accurately reflect pass/fail/warn counts."""
        pass_file = tmp_path / "good.py"
        fail_file = tmp_path / "bad.py"
        pass_file.write_text("pass\n")
        fail_file.write_text("pass\n")

        def fake_secrets(fpath: Path):  # noqa: ANN001
            if "bad" in fpath.name:
                return ("fail", ["hardcoded key"])
            return ("pass", [])

        with (
            patch("launcher.ci_runner.run_secrets_check", side_effect=fake_secrets),
            patch("launcher.ci_runner.run_file_check", return_value=("pass", [])),
            patch("launcher.ci_runner.run_tdd_check", return_value=("pass", [])),
        ):
            run_ci(files=[str(pass_file), str(fail_file)], as_json=True)

        data = json.loads(capsys.readouterr().out)
        assert data["total"] == 2
        assert data["failed"] == 1
        assert data["passed"] == 1


# ---------------------------------------------------------------------------
# Exit code tests
# ---------------------------------------------------------------------------


class TestExitCodes:
    """Test exit code semantics."""

    def test_exit_0_when_all_pass(self, tmp_path: Path) -> None:
        """Should return 0 when all checks pass."""
        f = tmp_path / "clean.py"
        f.write_text("pass\n")

        with (
            patch("launcher.ci_runner.run_secrets_check", return_value=("pass", [])),
            patch("launcher.ci_runner.run_file_check", return_value=("pass", [])),
            patch("launcher.ci_runner.run_tdd_check", return_value=("pass", [])),
        ):
            code = run_ci(files=[str(f)])

        assert code == 0

    def test_exit_1_when_secrets_fail(self, tmp_path: Path) -> None:
        """Should return 1 when secrets check fails."""
        f = tmp_path / "leaky.py"
        f.write_text("pass\n")

        with (
            patch("launcher.ci_runner.run_secrets_check", return_value=("fail", ["AWS key found"])),
            patch("launcher.ci_runner.run_file_check", return_value=("pass", [])),
            patch("launcher.ci_runner.run_tdd_check", return_value=("pass", [])),
        ):
            code = run_ci(files=[str(f)])

        assert code == 1

    def test_exit_1_when_file_check_fails(self, tmp_path: Path) -> None:
        """Should return 1 when file_checker blocks a file."""
        f = tmp_path / "broken.py"
        f.write_text("pass\n")

        with (
            patch("launcher.ci_runner.run_secrets_check", return_value=("pass", [])),
            patch("launcher.ci_runner.run_file_check", return_value=("fail", ["syntax error"])),
            patch("launcher.ci_runner.run_tdd_check", return_value=("pass", [])),
        ):
            code = run_ci(files=[str(f)])

        assert code == 1

    def test_exit_2_when_only_warnings(self, tmp_path: Path) -> None:
        """Should return 2 when there are warnings but no hard failures."""
        f = tmp_path / "impl.py"
        f.write_text("pass\n")

        with (
            patch("launcher.ci_runner.run_secrets_check", return_value=("pass", [])),
            patch("launcher.ci_runner.run_file_check", return_value=("pass", [])),
            patch("launcher.ci_runner.run_tdd_check", return_value=("warn", ["no test file"])),
        ):
            code = run_ci(files=[str(f)])

        assert code == 2

    def test_exit_0_when_no_checkable_files(self, tmp_path: Path) -> None:
        """Should return 0 and report no files when list is empty."""
        # Non-checkable extension
        f = tmp_path / "README.md"
        f.write_text("# hello\n")

        code = run_ci(files=[str(f)])
        assert code == 0

    def test_exit_1_when_coverage_below_threshold(self, tmp_path: Path) -> None:
        """Should return 1 when measured coverage is below --coverage-threshold."""
        f = tmp_path / "app.py"
        f.write_text("pass\n")

        with (
            patch("launcher.ci_runner.run_secrets_check", return_value=("pass", [])),
            patch("launcher.ci_runner.run_file_check", return_value=("pass", [])),
            patch("launcher.ci_runner.run_tdd_check", return_value=("pass", [])),
            patch("launcher.ci_runner.measure_coverage", return_value=(50.0, False)),
        ):
            code = run_ci(files=[str(f)], coverage_threshold=80)

        assert code == 1


# ---------------------------------------------------------------------------
# Coverage threshold tests
# ---------------------------------------------------------------------------


class TestCoverageThreshold:
    """Test --coverage-threshold flag behaviour."""

    def test_threshold_none_always_passes(self, tmp_path: Path) -> None:
        """When threshold is None, coverage is not measured."""
        pct, ok = measure_coverage([], threshold=None)
        assert pct is None
        assert ok is True

    def test_threshold_no_coverable_files(self, tmp_path: Path) -> None:
        """When there are no coverable files, returns (None, True)."""
        pct, ok = measure_coverage([tmp_path / "README.md"], threshold=80)
        assert pct is None
        assert ok is True

    def test_coverage_passed_when_above_threshold(self, tmp_path: Path, capsys) -> None:
        """run_ci should set coverage_passed=True when pct >= threshold."""
        f = tmp_path / "main.py"
        f.write_text("pass\n")

        with (
            patch("launcher.ci_runner.run_secrets_check", return_value=("pass", [])),
            patch("launcher.ci_runner.run_file_check", return_value=("pass", [])),
            patch("launcher.ci_runner.run_tdd_check", return_value=("pass", [])),
            patch("launcher.ci_runner.measure_coverage", return_value=(95.0, True)),
        ):
            code = run_ci(files=[str(f)], as_json=True, coverage_threshold=80)

        data = json.loads(capsys.readouterr().out)
        assert data["coverage_passed"] is True
        assert code == 0

    def test_coverage_failed_when_below_threshold(self, tmp_path: Path, capsys) -> None:
        """run_ci should set coverage_passed=False when pct < threshold."""
        f = tmp_path / "main.py"
        f.write_text("pass\n")

        with (
            patch("launcher.ci_runner.run_secrets_check", return_value=("pass", [])),
            patch("launcher.ci_runner.run_file_check", return_value=("pass", [])),
            patch("launcher.ci_runner.run_tdd_check", return_value=("pass", [])),
            patch("launcher.ci_runner.measure_coverage", return_value=(40.0, False)),
        ):
            code = run_ci(files=[str(f)], as_json=True, coverage_threshold=80)

        data = json.loads(capsys.readouterr().out)
        assert data["coverage_passed"] is False
        assert code == 1


# ---------------------------------------------------------------------------
# CLI integration tests
# ---------------------------------------------------------------------------


class TestCLIIntegration:
    """Verify tribunal ci is wired into the CLI parser."""

    def test_ci_subcommand_registered(self) -> None:
        """tribunal ci should be a valid subcommand."""
        from launcher.cli import _build_parser

        parser = _build_parser()
        args, _ = parser.parse_known_args(["ci"])
        assert args.command == "ci"

    def test_ci_json_flag(self) -> None:
        """tribunal ci --json should set json=True."""
        from launcher.cli import _build_parser

        parser = _build_parser()
        args, _ = parser.parse_known_args(["ci", "--json"])
        assert args.command == "ci"
        assert args.json is True

    def test_ci_coverage_threshold_flag(self) -> None:
        """tribunal ci --coverage-threshold 80 should set the threshold."""
        from launcher.cli import _build_parser

        parser = _build_parser()
        args, _ = parser.parse_known_args(["ci", "--coverage-threshold", "80"])
        assert args.coverage_threshold == 80

    def test_ci_explicit_files(self) -> None:
        """tribunal ci src/foo.py src/bar.py should capture explicit files."""
        from launcher.cli import _build_parser

        parser = _build_parser()
        args, _ = parser.parse_known_args(["ci", "src/foo.py", "src/bar.py"])
        assert "src/foo.py" in args.ci_files
        assert "src/bar.py" in args.ci_files


# ---------------------------------------------------------------------------
# CIReport serialisation
# ---------------------------------------------------------------------------


class TestCIReport:
    """Unit tests for CIReport.to_dict()."""

    def test_to_dict_structure(self) -> None:
        """to_dict should return all expected keys."""
        report = CIReport()
        report.files.append(FileResult(file="x.py", secrets="pass", tests="warn", overall="warn"))
        report.total = 1
        report.warned = 1
        report.coverage_pct = 75.0
        report.coverage_passed = False

        d = report.to_dict()
        assert d["total"] == 1
        assert d["warned"] == 1
        assert d["coverage_pct"] == 75.0
        assert d["coverage_passed"] is False
        assert d["files"][0]["overall"] == "warn"
