"""Empty-selection fail-loud + the .gates/ lock (PAPER-95).

Two adoption-safety behaviours land here:

* A tier/budget filter that excludes *every* collected test must not look
  like a passing run — `_handle_empty_selection` warns loudly and, with
  ``--require-nonempty``, exits non-zero.
* Two concurrent runs on one checkout must not interleave writes to
  ``.gates/`` — `_gates_lock` is an exclusive advisory lock.
"""

from __future__ import annotations

from typing import TYPE_CHECKING

import pytest

from mgf.test_supervisor import __main__ as _main
from mgf.test_supervisor.__main__ import _gates_lock, _handle_empty_selection

if TYPE_CHECKING:
    from pathlib import Path


class TestEmptySelection:
    def test_no_tests_at_all_is_clean(self) -> None:
        assert _handle_empty_selection(0, {0}, require_nonempty=False) == 0

    def test_filtered_to_empty_warns_but_clean_by_default(
        self, capsys: pytest.CaptureFixture[str]
    ) -> None:
        rc = _handle_empty_selection(42, {0}, require_nonempty=False)
        assert rc == 0
        err = capsys.readouterr().err
        assert "0 of 42" in err
        assert "executed NOTHING" in err

    def test_filtered_to_empty_fails_when_required(
        self, capsys: pytest.CaptureFixture[str]
    ) -> None:
        rc = _handle_empty_selection(42, {2}, require_nonempty=True)
        assert rc == 2
        assert "exiting non-zero" in capsys.readouterr().err


class TestTierRunsRequireNonemptyByDefault:
    """M2 Wave-2: an explicit --tier run defaults to --require-nonempty
    (a CI gate wired `mtest --tier 0-2` must never green on an empty
    selection); --allow-empty opts out; no --tier keeps the old default."""

    @staticmethod
    def _tiny_project(tmp_path: Path) -> Path:
        (tmp_path / "pyproject.toml").write_text(
            '[tool.pytest.ini_options]\nmarkers = ["smoke: fast"]\n',
            encoding="utf-8",
        )
        tests = tmp_path / "tests"
        tests.mkdir()
        (tests / "test_t.py").write_text("def test_unmarked():\n    assert True\n")
        return tmp_path

    def _run_main(
        self,
        argv: list[str],
        project: Path,
        monkeypatch: pytest.MonkeyPatch,
    ) -> int:
        import sys as _sys

        monkeypatch.setenv("MGF_TEST_SUPERVISOR_REPO_ROOT", str(project))
        monkeypatch.setenv("MGF_GATES_DIR", str(project / ".gates"))
        # The consumer-venv probe must resolve to THIS interpreter.
        monkeypatch.setattr(
            _main, "_find_pytest_cmd", lambda _root: [_sys.executable, "-m", "pytest"]
        )
        return _main.main(argv)

    @pytest.mark.integration
    def test_explicit_tier_with_empty_selection_exits_2(
        self,
        tmp_path: Path,
        monkeypatch: pytest.MonkeyPatch,
        capsys: pytest.CaptureFixture[str],
    ) -> None:
        project = self._tiny_project(tmp_path)
        rc = self._run_main(["--tier", "3"], project, monkeypatch)  # unmarked test = tier-1
        assert rc == 2
        err = capsys.readouterr().err
        assert "executed NOTHING" in err
        assert "--allow-empty" in err

    @pytest.mark.integration
    def test_allow_empty_opts_out(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
        project = self._tiny_project(tmp_path)
        rc = self._run_main(["--tier", "3", "--allow-empty"], project, monkeypatch)
        assert rc == 0


class TestGatesLock:
    def test_lock_is_exclusive(self, tmp_path: Path) -> None:
        # Outer lock + armed pytest.raises are combined; the INNER acquire
        # must raise inside the body (it can't be folded into the same
        # `with`, or its __enter__ would raise before pytest.raises armed).
        with _gates_lock(tmp_path), pytest.raises(SystemExit, match="another run holds"):  # noqa: SIM117
            with _gates_lock(tmp_path):
                pass

    def test_lock_releases_and_can_reacquire(self, tmp_path: Path) -> None:
        with _gates_lock(tmp_path):
            pass
        # Released — a second acquisition must succeed.
        with _gates_lock(tmp_path):
            pass


class TestSelfCheckConcurrency:
    """`--self-check` runs each scenario in its OWN tmp `.gates` (self_check.py),
    so it must NOT take the shared lock. Parallel CI self-checks — the supervisor
    smoke test across 3 Python-version steps + the dedicated supervisor-self-check
    step — would otherwise fail the exclusive lock. Regression for the woodpecker
    `.gates/.lock` race surfaced by the CI-unification verified sweep."""

    def test_self_check_runs_while_shared_lock_is_held(
        self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        (tmp_path / "pyproject.toml").write_text("[project]\nname = 'x'\n", encoding="utf-8")
        monkeypatch.chdir(tmp_path)
        monkeypatch.setattr(_main, "_cmd_self_check", lambda *a, **k: 0)
        gates = tmp_path / ".gates"
        gates.mkdir()
        with _gates_lock(gates):  # another run holds the shared lock …
            assert _main.main(["--self-check"]) == 0  # … --self-check still runs

    def test_mgf_gates_dir_env_override(
        self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        (tmp_path / "pyproject.toml").write_text("[project]\nname = 'x'\n", encoding="utf-8")
        monkeypatch.chdir(tmp_path)
        alt = tmp_path / "alt-gates"
        monkeypatch.setenv("MGF_GATES_DIR", str(alt))
        seen: dict[str, Path] = {}

        def _stub(repo_root: Path, gates_dir: Path, pytest_cmd: list[str], verbose: bool) -> int:
            seen["gates_dir"] = gates_dir
            return 0

        monkeypatch.setattr(_main, "_cmd_self_check", _stub)
        assert _main.main(["--self-check"]) == 0
        assert seen["gates_dir"] == alt
