"""Tests for ``check_ci`` in .opencode/scripts/pipeline-status.py — CI phase.

Covers CI gate via ``gh pr view --json statusCheckRollup`` (aggregates ALL
checks on PR head SHA — CI, CI (always), ADR check, etc.). All gh/git calls
are mocked via monkeypatch on the module's ``run_cmd`` helper. ``time.sleep``
is mocked to no-op via autouse fixture (polling loop would otherwise hang
tests for up to 5 minutes).

Mocking strategy: ``check_ci`` issues calls in sequence:
1. ``git remote get-url origin`` → returns remote URL (for ``get_repo_full_name``)
2. ``gh pr view N --json statusCheckRollup`` → returns rollup JSON; may be
   called multiple times (polling loop / no-checks retries).

``mock_run_cmd`` dispatches by command prefix (single response per prefix).
``mock_run_cmd_seq`` supports a sequence of responses for the ``gh pr view``
prefix (consumed in order; last repeats if exhausted) — for polling tests.

``get_repo_full_name`` is cached via ``functools.cache`` — cleared before
each test via the ``_clear_repo_cache`` autouse fixture.
"""

import importlib.util
import sys
from pathlib import Path

import pytest

SCRIPT_PATH = (
    Path(__file__).resolve().parent.parent / ".opencode" / "scripts" / "pipeline-status.py"
)
spec = importlib.util.spec_from_file_location("pipeline_status_ci", SCRIPT_PATH)
ps = importlib.util.module_from_spec(spec)
sys.modules["pipeline_status_ci"] = ps
spec.loader.exec_module(ps)

GIT_REMOTE_MOCK: tuple[tuple[str, ...], tuple[int, str, str]] = (
    ("git", "remote"),
    (0, "https://github.com/slaid098/opencode-config.git\n", ""),
)


@pytest.fixture(autouse=True)
def _clear_repo_cache():
    """Clear ``functools.cache`` on ``get_repo_full_name`` + mock ``time.sleep``.

    ``time.sleep`` must be mocked — polling loop sleeps ``CI_POLL_INTERVAL``
    between queries, up to ``CI_WAIT_TIMEOUT`` (default 300s). Without mock,
    tests polling until timeout would hang for minutes.
    """
    ps.get_repo_full_name.cache_clear()
    sleep_calls: list[float] = []
    monkey = pytest.MonkeyPatch()

    def _record_sleep(seconds: float) -> None:
        sleep_calls.append(seconds)

    monkey.setattr(ps.time, "sleep", _record_sleep)
    ps._test_sleep_calls = sleep_calls  # type: ignore[attr-defined]
    yield
    monkey.undo()
    ps.get_repo_full_name.cache_clear()
    del ps._test_sleep_calls  # type: ignore[attr-defined]


def mock_run_cmd(responses: dict[tuple, tuple[int, str, str]]):
    """Factory: mock run_cmd matching by command prefix (single response).

    Keys are tuples of leading args (e.g. ("gh", "pr", "view")),
    values are (returncode, stdout, stderr).

    Automatically includes a ``git remote get-url origin`` mock
    (``GIT_REMOTE_MOCK``) so ``get_repo_full_name()`` works without extra
    boilerplate in every test.
    """
    merged = {GIT_REMOTE_MOCK[0]: GIT_REMOTE_MOCK[1], **responses}

    def _mock(args: list[str]) -> tuple[int, str, str]:
        for prefix, result in merged.items():
            if tuple(args[: len(prefix)]) == tuple(prefix):
                return result
        return (1, "", f"unmocked call: {args}")

    return _mock


def mock_run_cmd_seq(
    pr_view_responses: list[tuple[int, str, str]] | None = None,
    extra: dict[tuple, tuple[int, str, str]] | None = None,
):
    """Factory: mock run_cmd with sequence of responses for ``gh pr view``.

    ``gh pr view`` returns ``pr_view_responses[i]`` on the i-th call; if
    exhausted, repeats the last response (so polling loops don't run out of
    responses and hit the "unmocked" fallback). ``extra`` adds fixed overrides
    for other prefixes (e.g. ``git remote``).
    """
    pr_view_responses = pr_view_responses or []
    view_idx = [0]
    merged = {GIT_REMOTE_MOCK[0]: GIT_REMOTE_MOCK[1], **(extra or {})}

    def _mock(args: list[str]) -> tuple[int, str, str]:
        for prefix, result in merged.items():
            if tuple(args[: len(prefix)]) == tuple(prefix):
                return result
        if tuple(args[:3]) == ("gh", "pr", "view"):
            if not pr_view_responses:
                return (1, "", "no pr_view_responses configured")
            idx = min(view_idx[0], len(pr_view_responses) - 1)
            view_idx[0] += 1
            return pr_view_responses[idx]
        return (1, "", f"unmocked call: {args}")

    return _mock


# ── check_ci — completed (no polling) ──────────────────────────────────────────


def test_ci_success(monkeypatch):
    """Rollup: 2 checks COMPLETED+SUCCESS → DONE, 0 sleeps."""
    monkeypatch.setattr(
        ps,
        "run_cmd",
        mock_run_cmd(
            {
                ("gh", "pr", "view"): (
                    0,
                    '{"statusCheckRollup": ['
                    '{"name": "CI", "status": "COMPLETED", "conclusion": "SUCCESS"}, '
                    '{"name": "ADR check", "status": "COMPLETED", "conclusion": "SUCCESS"}'
                    "]}",
                    "",
                ),
            }
        ),
    )
    result = ps.check_ci(46)
    assert result.status == ps.PhaseStatus.DONE
    assert "CI green" in result.detail
    assert ps._test_sleep_calls == []  # type: ignore[attr-defined]


def test_ci_failure(monkeypatch):
    """Rollup: 1 check COMPLETED+FAILURE → NOT_DONE, 0 sleeps."""
    monkeypatch.setattr(
        ps,
        "run_cmd",
        mock_run_cmd(
            {
                ("gh", "pr", "view"): (
                    0,
                    '{"statusCheckRollup": ['
                    '{"name": "CI", "status": "COMPLETED", "conclusion": "FAILURE"}'
                    "]}",
                    "",
                ),
            }
        ),
    )
    result = ps.check_ci(46)
    assert result.status == ps.PhaseStatus.NOT_DONE
    assert "failure" in result.detail
    assert ps._test_sleep_calls == []  # type: ignore[attr-defined]


def test_ci_docs_only_pr(monkeypatch):
    """Docs-only PR: CI (always) + ADR check, both SUCCESS → DONE.

    Key test for PR#120 bug: docs-only PRs don't trigger ``ci.yml``
    (paths-ignore), only ``always-ci.yml``. Old code filtered by
    ``name=="CI"`` → missed → AMBIGUOUS. statusCheckRollup aggregates all.
    """
    monkeypatch.setattr(
        ps,
        "run_cmd",
        mock_run_cmd(
            {
                ("gh", "pr", "view"): (
                    0,
                    '{"statusCheckRollup": ['
                    '{"name": "CI (always)", "status": "COMPLETED", "conclusion": "SUCCESS"}, '
                    '{"name": "ADR Refs", "status": "COMPLETED", "conclusion": "SUCCESS"}'
                    "]}",
                    "",
                ),
            }
        ),
    )
    result = ps.check_ci(46)
    assert result.status == ps.PhaseStatus.DONE
    assert "CI green" in result.detail
    assert ps._test_sleep_calls == []  # type: ignore[attr-defined]


# ── check_ci — polling loop ────────────────────────────────────────────────────


def test_ci_wait_then_success(monkeypatch):
    """IN_PROGRESS → IN_PROGRESS → COMPLETED+SUCCESS → DONE, sleep called 2 times."""
    monkeypatch.setattr(
        ps,
        "run_cmd",
        mock_run_cmd_seq(
            pr_view_responses=[
                (
                    0,
                    '{"statusCheckRollup": ['
                    '{"name": "CI", "status": "IN_PROGRESS", "conclusion": null}'
                    "]}",
                    "",
                ),
                (
                    0,
                    '{"statusCheckRollup": ['
                    '{"name": "CI", "status": "IN_PROGRESS", "conclusion": null}'
                    "]}",
                    "",
                ),
                (
                    0,
                    '{"statusCheckRollup": ['
                    '{"name": "CI", "status": "COMPLETED", "conclusion": "SUCCESS"}'
                    "]}",
                    "",
                ),
            ],
        ),
    )
    result = ps.check_ci(46)
    assert result.status == ps.PhaseStatus.DONE
    assert "CI green" in result.detail
    assert len(ps._test_sleep_calls) == 2  # type: ignore[attr-defined]


def test_ci_wait_timeout(monkeypatch):
    """All calls IN_PROGRESS, timeout 1s via env → AMBIGUOUS with "после 1s"."""
    monkeypatch.setenv("OPENCODE_CI_WAIT_TIMEOUT", "1")
    monkeypatch.setattr(
        ps,
        "run_cmd",
        mock_run_cmd_seq(
            pr_view_responses=[
                (
                    0,
                    '{"statusCheckRollup": ['
                    '{"name": "CI", "status": "IN_PROGRESS", "conclusion": null}'
                    "]}",
                    "",
                ),
            ],
        ),
    )
    result = ps.check_ci(46)
    assert result.status == ps.PhaseStatus.AMBIGUOUS
    assert "после 1s" in result.detail
    assert ps._test_sleep_calls == []  # type: ignore[attr-defined]


def test_ci_wait_poll_interval(monkeypatch):
    """Poll interval is used as sleep argument — capture via mock."""
    monkeypatch.setenv("OPENCODE_CI_POLL_INTERVAL", "7")
    monkeypatch.setattr(
        ps,
        "run_cmd",
        mock_run_cmd_seq(
            pr_view_responses=[
                (
                    0,
                    '{"statusCheckRollup": ['
                    '{"name": "CI", "status": "IN_PROGRESS", "conclusion": null}'
                    "]}",
                    "",
                ),
                (
                    0,
                    '{"statusCheckRollup": ['
                    '{"name": "CI", "status": "COMPLETED", "conclusion": "SUCCESS"}'
                    "]}",
                    "",
                ),
            ],
        ),
    )
    result = ps.check_ci(46)
    assert result.status == ps.PhaseStatus.DONE
    assert ps._test_sleep_calls == [7]  # type: ignore[attr-defined]


def test_ci_in_progress(monkeypatch):
    """IN_PROGRESS, all polls IN_PROGRESS, timeout 1s → AMBIGUOUS."""
    monkeypatch.setenv("OPENCODE_CI_WAIT_TIMEOUT", "1")
    monkeypatch.setattr(
        ps,
        "run_cmd",
        mock_run_cmd_seq(
            pr_view_responses=[
                (
                    0,
                    '{"statusCheckRollup": ['
                    '{"name": "CI", "status": "IN_PROGRESS", "conclusion": null}'
                    "]}",
                    "",
                ),
            ],
        ),
    )
    result = ps.check_ci(46)
    assert result.status == ps.PhaseStatus.AMBIGUOUS
    assert "после 1s" in result.detail


def test_ci_queued(monkeypatch):
    """QUEUED, all polls IN_PROGRESS, timeout 1s → AMBIGUOUS."""
    monkeypatch.setenv("OPENCODE_CI_WAIT_TIMEOUT", "1")
    monkeypatch.setattr(
        ps,
        "run_cmd",
        mock_run_cmd_seq(
            pr_view_responses=[
                (
                    0,
                    '{"statusCheckRollup": ['
                    '{"name": "CI", "status": "QUEUED", "conclusion": null}'
                    "]}",
                    "",
                ),
            ],
        ),
    )
    result = ps.check_ci(46)
    assert result.status == ps.PhaseStatus.AMBIGUOUS
    assert "после 1s" in result.detail


# ── check_ci — no checks (short retry) ─────────────────────────────────────────


def test_ci_no_checks_retry_then_appears(monkeypatch):
    """1st empty, 2nd empty, 3rd SUCCESS → DONE (after CI_NO_RUNS_RETRY retries)."""
    monkeypatch.setattr(
        ps,
        "run_cmd",
        mock_run_cmd_seq(
            pr_view_responses=[
                (0, '{"statusCheckRollup": []}', ""),
                (0, '{"statusCheckRollup": []}', ""),
                (
                    0,
                    '{"statusCheckRollup": ['
                    '{"name": "CI", "status": "COMPLETED", "conclusion": "SUCCESS"}'
                    "]}",
                    "",
                ),
            ],
        ),
    )
    result = ps.check_ci(46)
    assert result.status == ps.PhaseStatus.DONE
    assert "CI green" in result.detail


def test_ci_no_checks_retry_exhausted(monkeypatch):
    """All calls empty, CI_NO_RUNS_RETRY=3 → AMBIGUOUS."""
    monkeypatch.setattr(
        ps,
        "run_cmd",
        mock_run_cmd_seq(
            pr_view_responses=[
                (0, '{"statusCheckRollup": []}', ""),
            ],
        ),
    )
    result = ps.check_ci(46)
    assert result.status == ps.PhaseStatus.AMBIGUOUS
    assert "нет CI checks" in result.detail


def test_ci_no_checks(monkeypatch):
    """Empty rollup with single response → AMBIGUOUS (retry exhausted)."""
    monkeypatch.setattr(
        ps,
        "run_cmd",
        mock_run_cmd(
            {
                ("gh", "pr", "view"): (0, '{"statusCheckRollup": []}', ""),
            }
        ),
    )
    result = ps.check_ci(46)
    assert result.status == ps.PhaseStatus.AMBIGUOUS
    assert "нет CI checks" in result.detail


# ── check_ci — API error (no retry) ────────────────────────────────────────────


def test_ci_api_error(monkeypatch):
    """gh pr view returns rc=1 (403) → AMBIGUOUS, no retries/sleeps."""
    monkeypatch.setattr(
        ps,
        "run_cmd",
        mock_run_cmd(
            {
                ("gh", "pr", "view"): (
                    1,
                    "",
                    "HTTP 403: Forbidden",
                ),
            }
        ),
    )
    result = ps.check_ci(46)
    assert result.status == ps.PhaseStatus.AMBIGUOUS
    assert "PR API error" in result.detail
    assert ps._test_sleep_calls == []  # type: ignore[attr-defined]


def test_ci_api_error_no_retry(monkeypatch):
    """1st call rc=1 (403) → сразу AMBIGUOUS, 0 sleeps/retries."""
    monkeypatch.setattr(
        ps,
        "run_cmd",
        mock_run_cmd_seq(
            pr_view_responses=[
                (1, "", "HTTP 403: Forbidden"),
                (
                    0,
                    '{"statusCheckRollup": ['
                    '{"name": "CI", "status": "COMPLETED", "conclusion": "SUCCESS"}'
                    "]}",
                    "",
                ),
            ],
        ),
    )
    result = ps.check_ci(46)
    assert result.status == ps.PhaseStatus.AMBIGUOUS
    assert "PR API error" in result.detail
    assert ps._test_sleep_calls == []  # type: ignore[attr-defined]


# ── check_ci — partial status (multiple checks) ───────────────────────────────


def test_ci_partial_failure(monkeypatch):
    """1 SUCCESS + 1 FAILURE → NOT_DONE (any failure blocks)."""
    monkeypatch.setattr(
        ps,
        "run_cmd",
        mock_run_cmd(
            {
                ("gh", "pr", "view"): (
                    0,
                    '{"statusCheckRollup": ['
                    '{"name": "CI", "status": "COMPLETED", "conclusion": "SUCCESS"}, '
                    '{"name": "ADR check", "status": "COMPLETED", "conclusion": "FAILURE"}'
                    "]}",
                    "",
                ),
            }
        ),
    )
    result = ps.check_ci(46)
    assert result.status == ps.PhaseStatus.NOT_DONE
    assert "failure" in result.detail
    assert ps._test_sleep_calls == []  # type: ignore[attr-defined]


def test_ci_partial_in_progress(monkeypatch):
    """1 COMPLETED+SUCCESS + 1 IN_PROGRESS → AMBIGUOUS (poll)."""
    monkeypatch.setenv("OPENCODE_CI_WAIT_TIMEOUT", "1")
    monkeypatch.setattr(
        ps,
        "run_cmd",
        mock_run_cmd_seq(
            pr_view_responses=[
                (
                    0,
                    '{"statusCheckRollup": ['
                    '{"name": "CI", "status": "COMPLETED", "conclusion": "SUCCESS"}, '
                    '{"name": "ADR check", "status": "IN_PROGRESS", "conclusion": null}'
                    "]}",
                    "",
                ),
            ],
        ),
    )
    result = ps.check_ci(46)
    assert result.status == ps.PhaseStatus.AMBIGUOUS
    assert "после 1s" in result.detail


# ── check_ci — config priority (CLI > env > constant) ──────────────────────────


def test_ci_wait_env_var_override(monkeypatch):
    """OPENCODE_CI_WAIT_TIMEOUT=1 → timeout 1s instead of default 300s."""
    monkeypatch.setenv("OPENCODE_CI_WAIT_TIMEOUT", "1")
    monkeypatch.setattr(
        ps,
        "run_cmd",
        mock_run_cmd_seq(
            pr_view_responses=[
                (
                    0,
                    '{"statusCheckRollup": ['
                    '{"name": "CI", "status": "IN_PROGRESS", "conclusion": null}'
                    "]}",
                    "",
                ),
            ],
        ),
    )
    result = ps.check_ci(46)
    assert result.status == ps.PhaseStatus.AMBIGUOUS
    assert "после 1s" in result.detail


def test_ci_wait_cli_flag_overrides_env(monkeypatch):
    """CLI flag --ci-wait-timeout 2 + env OPENCODE_CI_WAIT_TIMEOUT=1 → timeout 2s."""
    monkeypatch.setenv("OPENCODE_CI_WAIT_TIMEOUT", "1")

    def _fixed_config(argv=None):
        return ps.CiPollConfig(wait_timeout=2, poll_interval=ps.CI_POLL_INTERVAL)

    monkeypatch.setattr(ps, "_load_ci_config", _fixed_config)
    monkeypatch.setattr(
        ps,
        "run_cmd",
        mock_run_cmd_seq(
            pr_view_responses=[
                (
                    0,
                    '{"statusCheckRollup": ['
                    '{"name": "CI", "status": "IN_PROGRESS", "conclusion": null}'
                    "]}",
                    "",
                ),
            ],
        ),
    )
    result = ps.check_ci(46)
    assert result.status == ps.PhaseStatus.AMBIGUOUS
    assert "после 2s" in result.detail


def test_ci_wait_failure_no_wait(monkeypatch):
    """1st call COMPLETED+FAILURE → сразу NOT_DONE, 0 sleeps (don't wait for fail)."""
    monkeypatch.setattr(
        ps,
        "run_cmd",
        mock_run_cmd_seq(
            pr_view_responses=[
                (
                    0,
                    '{"statusCheckRollup": ['
                    '{"name": "CI", "status": "COMPLETED", "conclusion": "FAILURE"}'
                    "]}",
                    "",
                ),
            ],
        ),
    )
    result = ps.check_ci(46)
    assert result.status == ps.PhaseStatus.NOT_DONE
    assert "failure" in result.detail
    assert ps._test_sleep_calls == []  # type: ignore[attr-defined]


# ── check_ci — dynamic repo full name ──────────────────────────────────────────


def test_ci_uses_dynamic_repo_full_name(monkeypatch):
    """``get_repo_full_name`` is derived from ``git remote``, not hardcoded.

    Mocks a non-opencode remote (``slaid098/media-gen``) and asserts that the
    ``gh pr view`` call uses ``--repo slaid098/media-gen`` (dynamic), not a
    hardcoded ``slaid098/opencode-config``.
    """
    captured_args: list[list[str]] = []

    def _capture_mock(args: list[str]) -> tuple[int, str, str]:
        captured_args.append(args)
        if tuple(args[:3]) == ("git", "remote", "get-url"):
            return (0, "https://github.com/slaid098/media-gen.git\n", "")
        if tuple(args[:3]) == ("gh", "pr", "view"):
            return (
                0,
                '{"statusCheckRollup": ['
                '{"name": "CI", "status": "COMPLETED", "conclusion": "SUCCESS"}'
                "]}",
                "",
            )
        return (1, "", f"unmocked call: {args}")

    monkeypatch.setattr(ps, "run_cmd", _capture_mock)
    result = ps.check_ci(46)
    assert result.status == ps.PhaseStatus.DONE

    pr_view_calls = [a for a in captured_args if a[:3] == ["gh", "pr", "view"]]
    assert len(pr_view_calls) == 1
    assert "--repo" in pr_view_calls[0]
    assert "slaid098/media-gen" in " ".join(pr_view_calls[0])
    assert "slaid098/opencode-config" not in " ".join(pr_view_calls[0])


# ── _load_ci_config — priority ────────────────────────────────────────────────


def test_load_ci_config_defaults(monkeypatch):
    """No env, no CLI → constants."""
    monkeypatch.delenv("OPENCODE_CI_WAIT_TIMEOUT", raising=False)
    monkeypatch.delenv("OPENCODE_CI_POLL_INTERVAL", raising=False)
    cfg = ps._load_ci_config(argv=[])
    assert cfg.wait_timeout == ps.CI_WAIT_TIMEOUT
    assert cfg.poll_interval == ps.CI_POLL_INTERVAL


def test_load_ci_config_env_override(monkeypatch):
    """Env vars override constants."""
    monkeypatch.setenv("OPENCODE_CI_WAIT_TIMEOUT", "42")
    monkeypatch.setenv("OPENCODE_CI_POLL_INTERVAL", "7")
    cfg = ps._load_ci_config(argv=[])
    assert cfg.wait_timeout == 42
    assert cfg.poll_interval == 7


def test_load_ci_config_cli_overrides_env(monkeypatch):
    """CLI flags override env vars."""
    monkeypatch.setenv("OPENCODE_CI_WAIT_TIMEOUT", "1")
    monkeypatch.setenv("OPENCODE_CI_POLL_INTERVAL", "1")
    cfg = ps._load_ci_config(argv=["--ci-wait-timeout", "2", "--ci-poll-interval", "3"])
    assert cfg.wait_timeout == 2
    assert cfg.poll_interval == 3


def test_load_ci_config_env_invalid_falls_back(monkeypatch):
    """Invalid env var value → fall back to constant."""
    monkeypatch.setenv("OPENCODE_CI_WAIT_TIMEOUT", "not-a-number")
    cfg = ps._load_ci_config(argv=[])
    assert cfg.wait_timeout == ps.CI_WAIT_TIMEOUT


if __name__ == "__main__":
    pytest.main([__file__, "-v"])
