"""Regression: GET endpoints must not mutate screening_jobs state.

Port of crystallise-master commit ef47ce3. Pre-fix, a plain GET would
flip an orphaned 'running' row to 'failed' with a fabricated
'Job lost due to server restart' error attributed to the navigating
user's session. Both `_get_active_job_for_project` and
`get_screening_job` did this auto-mark-failed-on-read.

After fix:
- GET /screening/jobs/{id}                  -> pure read
- GET /screening/jobs/{id} via active-job   -> pure read
- POST /screening/jobs                      -> reaps orphans via
  `_cleanup_stale_running_jobs` BEFORE the 409 duplicate check.
"""
from __future__ import annotations

from datetime import datetime, timezone

import pytest
from fastapi.testclient import TestClient


@pytest.fixture(autouse=True)
def _reset_screening_table_flag():
    from api.routers import screening as scr
    scr._table_created = False
    scr._db_available = True
    scr._active_jobs.clear()
    yield


@pytest.fixture()
def client():
    from api.main import app
    return TestClient(app)


def _seed_orphan_running_job(job_id: str = "stale-orphan-id", project_id: int = 1) -> str:
    """Insert a screening_jobs row in 'running' state with NO _active_jobs entry.
    Mirrors the post-prod-incident state where _save_job at completion failed
    and the row was left at its last heartbeat (status='running')."""
    from api.routers import screening as scr

    scr._ensure_table()
    now = datetime.now(timezone.utc).isoformat()
    job = {
        "id": job_id,
        "project_id": project_id,
        "status": "running",
        "progress": 0.95,
        "stage": "Assigning clusters",
        "config": {"model": "gpt-5-nano", "papers_count": 100},
        "results": None,
        "clusters": None,
        "stage_timings": {},
        "duration_ms": None,
        "estimated_cost_usd": 1.0,
        "error": None,
        "error_category": None,
        "error_retryable": None,
        "model_version": "gpt-5-nano",
        "created_at": now,
        "completed_at": None,
    }
    scr._save_job(job)
    return job_id


def _read_screening_job_row(job_id: str) -> dict:
    from crystallise.db.backend import get_backend

    db = get_backend()
    with db.get_connection() as conn:
        cur = db.execute(
            conn,
            "SELECT status, error, error_category, completed_at FROM screening_jobs WHERE id = ?",
            (job_id,),
        )
        cols = [d[0] for d in cur.description]
        row = cur.fetchone()
    return dict(zip(cols, row)) if row else {}


class TestGetScreeningJobIsPure:
    def test_get_screening_job_does_not_mutate_stale_running_row(self, client):
        jid = _seed_orphan_running_job()

        before = _read_screening_job_row(jid)
        assert before["status"] == "running"
        assert before["error"] is None
        assert before["completed_at"] is None

        resp = client.get(f"/screening/jobs/{jid}")
        assert resp.status_code == 200, resp.text

        after = _read_screening_job_row(jid)
        assert after["status"] == "running", (
            "GET should not have mutated the row. The old read-mutates-state path "
            f"flipped it to {after['status']!r} with error={after['error']!r}."
        )
        assert after["error"] is None
        assert after["completed_at"] is None


class TestGetActiveJobInMemoryOnly:
    def test_active_job_for_project_only_returns_in_memory_entries(self):
        """After the fix, _get_active_job_for_project never touches the DB.
        A 'running' row that's not in _active_jobs is invisible to this helper."""
        from api.routers import screening as scr

        # Seed an orphan in DB only.
        _seed_orphan_running_job("orphan-not-in-memory", project_id=42)
        assert "orphan-not-in-memory" not in scr._active_jobs

        # The helper must NOT see it (no DB fallback), so no mutation either.
        result = scr._get_active_job_for_project(42)
        assert result is None

        after = _read_screening_job_row("orphan-not-in-memory")
        assert after["status"] == "running"

    def test_active_job_returns_in_memory_pending_running_job(self):
        from api.routers import screening as scr

        scr._active_jobs["live-job"] = {
            "id": "live-job",
            "project_id": 7,
            "status": "running",
        }
        try:
            result = scr._get_active_job_for_project(7)
            assert result is not None
            assert result["id"] == "live-job"
        finally:
            scr._active_jobs.pop("live-job", None)


class TestPostJobsCleansUpOrphans:
    def test_post_jobs_reaps_orphan_before_creating_new_run(self, client):
        """A stale 'running' row from a prior crashed run must not 409-block
        a legitimate retry. POST /jobs reaps the orphan first."""
        orphan_jid = _seed_orphan_running_job("prior-crash-id", project_id=99)

        payload = {
            "project_id": 99,
            "papers": [{"id": "p1", "title": "T1", "abstract": "A1"}],
            "criteria": [{"text": "Adults", "criterion_type": "include"}],
            "questions": [],
            "model": "gpt-5-nano",
            "repetitions": 1,
            "threshold": 0.5,
            "clusters_type": "include",
            "mock": True,
        }
        resp = client.post("/screening/jobs", json=payload)
        assert resp.status_code == 200, (
            f"orphan should have been reaped; got {resp.status_code}: {resp.text}"
        )
        new_job_id = resp.json()["job_id"]
        assert new_job_id != orphan_jid

        orphan = _read_screening_job_row(orphan_jid)
        assert orphan["status"] in ("failed", "cancelled"), (
            f"orphan still {orphan['status']!r} after POST; error={orphan['error']!r}"
        )
        assert orphan["error"] is not None
