"""ORCH-068 (TR-4): tests for the Plane states cache TTL self-heal.

The per-project ``_STATES_CACHE`` used to live for the whole process lifetime,
so a status added to Plane after start was never seen without a restart
("stale set -> no pipeline action"). ORCH-068 adds a TTL: an entry is
re-fetched once it is older than ``plane_states_ttl_s`` (default 300s); ``0``
disables the TTL (strictly the previous lifetime cache).

All tests are offline: the Plane API (httpx) and the monotonic clock are mocked.
"""

import os
import tempfile
from unittest.mock import MagicMock, patch

import pytest

os.environ.setdefault("ORCH_PLANE_API_URL", "http://plane.local")
os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token")
os.environ.setdefault("ORCH_PLANE_WORKSPACE_SLUG", "test-ws")
os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token")

_test_db = os.path.join(tempfile.gettempdir(), "test_plane_states_cache.db")
os.environ["ORCH_DB_PATH"] = _test_db

import src.plane_sync as ps  # noqa: E402

_PROJECT = "proj-ttl"
_ET_PROJECT = "7a79f0a9-5278-49cd-9007-9a338f238f9c"


def _resp(data: dict, status: int = 200):
    m = MagicMock()
    m.status_code = status
    m.json.return_value = data
    if status >= 400:
        from httpx import HTTPStatusError
        m.raise_for_status.side_effect = HTTPStatusError(
            "error", request=MagicMock(), response=MagicMock()
        )
    else:
        m.raise_for_status.return_value = None
    return m


def _states_response(in_progress_uuid: str) -> dict:
    """A minimal /states/ payload; In Progress carries the given UUID."""
    return {
        "results": [
            {"id": in_progress_uuid, "name": "In Progress", "group": "started"},
            {"id": "uuid-done", "name": "Done", "group": "completed"},
        ]
    }


@pytest.fixture(autouse=True)
def reset_cache():
    ps.reload_project_states()
    yield
    ps.reload_project_states()


# ---------------------------------------------------------------------------
# TC-11 (AC-12): a stale cache entry self-heals after the TTL — no restart.
# ---------------------------------------------------------------------------
def test_tc11_stale_cache_refreshes_after_ttl(monkeypatch):
    monkeypatch.setattr(ps.settings, "plane_states_ttl_s", 300)
    clock = {"t": 1000.0}
    monkeypatch.setattr(ps.time, "monotonic", lambda: clock["t"])

    responses = iter([
        _resp(_states_response("uuid-A")),   # first fetch: old set
        _resp(_states_response("uuid-B")),   # second fetch: new status appeared
    ])
    mock_get = MagicMock(side_effect=lambda *a, **k: next(responses))
    monkeypatch.setattr(ps.httpx, "get", mock_get)

    # t=1000: first call -> fetch set A.
    s1 = ps.get_project_states(_PROJECT)
    assert s1["in_progress"] == "uuid-A"
    assert mock_get.call_count == 1

    # t=1100: within TTL -> served from cache, no new fetch.
    clock["t"] = 1100.0
    s2 = ps.get_project_states(_PROJECT)
    assert s2["in_progress"] == "uuid-A"
    assert mock_get.call_count == 1

    # t=1400: TTL (300s) elapsed -> re-fetch -> fresh set B (self-heal).
    clock["t"] = 1400.0
    s3 = ps.get_project_states(_PROJECT)
    assert s3["in_progress"] == "uuid-B"
    assert mock_get.call_count == 2


def test_tc11_ttl_zero_keeps_lifetime_cache(monkeypatch):
    """plane_states_ttl_s=0 -> strictly the previous lifetime cache (back-compat)."""
    monkeypatch.setattr(ps.settings, "plane_states_ttl_s", 0)
    clock = {"t": 1000.0}
    monkeypatch.setattr(ps.time, "monotonic", lambda: clock["t"])

    responses = iter([
        _resp(_states_response("uuid-A")),
        _resp(_states_response("uuid-B")),
    ])
    mock_get = MagicMock(side_effect=lambda *a, **k: next(responses))
    monkeypatch.setattr(ps.httpx, "get", mock_get)

    assert ps.get_project_states(_PROJECT)["in_progress"] == "uuid-A"
    clock["t"] = 1_000_000.0  # far in the future
    # TTL disabled -> still the cached A, never re-fetched.
    assert ps.get_project_states(_PROJECT)["in_progress"] == "uuid-A"
    assert mock_get.call_count == 1


def test_tc11_groups_exposed_via_accessor(monkeypatch):
    """get_project_state_groups returns {uuid -> group} from the same record."""
    monkeypatch.setattr(ps.settings, "plane_states_ttl_s", 300)
    monkeypatch.setattr(ps.httpx, "get", lambda *a, **k: _resp(_states_response("uuid-A")))

    ps.get_project_states(_PROJECT)
    groups = ps.get_project_state_groups(_PROJECT)
    assert groups["uuid-A"] == "started"
    assert groups["uuid-done"] == "completed"


def test_tc11_groups_empty_when_uncached(monkeypatch):
    """No cache record (e.g. API fell back to defaults) -> groups == {}."""
    assert ps.get_project_state_groups("never-fetched") == {}


# ---------------------------------------------------------------------------
# TC-12 (AC-13): default-config compatibility — enduro UUIDs + API-error fallback.
# ---------------------------------------------------------------------------
def test_tc12_enduro_uuids_unchanged(monkeypatch):
    """enduro project still resolves its own UUIDs (return shape unchanged)."""
    body = {
        "results": [
            {"id": "b873d9eb-993c-48cd-97ac-99a9b1623967",
             "name": "In Progress", "group": "started"},
        ]
    }
    monkeypatch.setattr(ps.httpx, "get", lambda *a, **k: _resp(body))
    states = ps.get_project_states(_ET_PROJECT)
    assert states["in_progress"] == "b873d9eb-993c-48cd-97ac-99a9b1623967"
    # Missing keys are still backfilled from _DEFAULT_STATES (complete mapping).
    assert states["done"] == ps._DEFAULT_STATES["done"]


def test_tc12_api_error_falls_back_to_defaults(monkeypatch):
    """API failure with nothing cached -> _DEFAULT_STATES (fallback preserved)."""
    monkeypatch.setattr(
        ps.httpx, "get", MagicMock(side_effect=Exception("network error"))
    )
    states = ps.get_project_states(_PROJECT)
    assert states is ps._DEFAULT_STATES


def test_tc12_stale_served_when_refresh_fails(monkeypatch):
    """TTL expiry + transient API failure -> serve the stale (project-correct)
    set rather than reverting to enduro defaults."""
    monkeypatch.setattr(ps.settings, "plane_states_ttl_s", 300)
    clock = {"t": 1000.0}
    monkeypatch.setattr(ps.time, "monotonic", lambda: clock["t"])

    calls = {"n": 0}

    def flaky_get(*a, **k):
        calls["n"] += 1
        if calls["n"] == 1:
            return _resp(_states_response("uuid-A"))
        raise Exception("transient outage")

    monkeypatch.setattr(ps.httpx, "get", flaky_get)

    assert ps.get_project_states(_PROJECT)["in_progress"] == "uuid-A"
    clock["t"] = 2000.0  # past TTL -> refresh attempt fails
    states = ps.get_project_states(_PROJECT)
    assert states["in_progress"] == "uuid-A"  # stale-but-correct, not defaults
    assert states is not ps._DEFAULT_STATES
