"""
Annulation coopérative des crawl_run.

C'est la moitié qui fait le travail réel : l'API ne fait que poser un drapeau
(voir apps/api/test/cancellation.test.js), c'est le crawler qui doit l'observer
et s'arrêter. Sans ces tests, le bouton « Annuler » pourrait redevenir
cosmétique sans que rien ne le signale.

Aucune connexion Postgres : `get_conn` est remplacé par un faux curseur.
"""

import contextlib
from unittest.mock import patch

import pytest
from src import db
from src.db import RunCancelled, is_cancel_requested, raise_if_cancelled


class FakeCursor:
    """Curseur minimal qui rejoue une file de résultats et compte les requêtes."""

    def __init__(self, results, fail=False):
        self._results = list(results)
        self.queries = []
        self.fail = fail

    def execute(self, sql, params=None):
        if self.fail:
            raise RuntimeError("base injoignable")
        self.queries.append((sql, params))

    def fetchone(self):
        return self._results.pop(0) if self._results else None

    def __enter__(self):
        return self

    def __exit__(self, *a):
        return False


def fake_conn(cursor):
    @contextlib.contextmanager
    def _get_conn():
        class Conn:
            def cursor(self_inner):
                return cursor

        yield Conn()

    return _get_conn


@pytest.fixture(autouse=True)
def _clear_cache():
    """Le drapeau est mis en cache par run_id : repartir propre à chaque test."""
    db._cancel_cache.clear()
    yield
    db._cancel_cache.clear()


class TestIsCancelRequested:
    def test_false_quand_le_drapeau_est_baisse(self):
        cur = FakeCursor([(False,)])
        with patch.object(db, "get_conn", fake_conn(cur)):
            assert is_cancel_requested(1) is False

    def test_true_quand_le_drapeau_est_leve(self):
        cur = FakeCursor([(True,)])
        with patch.object(db, "get_conn", fake_conn(cur)):
            assert is_cancel_requested(1) is True

    def test_false_quand_le_run_nexiste_pas(self):
        cur = FakeCursor([])
        with patch.object(db, "get_conn", fake_conn(cur)):
            assert is_cancel_requested(999) is False

    def test_le_resultat_est_mis_en_cache(self):
        """Le check est appelé à chaque lot : il ne doit pas faire une requête par appel."""
        cur = FakeCursor([(False,), (False,), (False,)])
        with patch.object(db, "get_conn", fake_conn(cur)):
            for _ in range(5):
                is_cancel_requested(1)
        assert len(cur.queries) == 1

    def test_force_contourne_le_cache(self):
        cur = FakeCursor([(False,), (True,)])
        with patch.object(db, "get_conn", fake_conn(cur)):
            assert is_cancel_requested(1) is False
            assert is_cancel_requested(1, force=True) is True
        assert len(cur.queries) == 2

    def test_le_cache_est_par_run(self):
        cur = FakeCursor([(True,), (False,)])
        with patch.object(db, "get_conn", fake_conn(cur)):
            assert is_cancel_requested(1) is True
            assert is_cancel_requested(2) is False

    # Une base momentanément injoignable ne doit PAS faire passer un crawl
    # sain pour un crawl annulé.
    def test_une_erreur_db_ne_declenche_pas_dannulation(self):
        cur = FakeCursor([], fail=True)
        with patch.object(db, "get_conn", fake_conn(cur)):
            assert is_cancel_requested(1) is False

    def test_une_erreur_db_conserve_la_derniere_valeur_connue(self):
        ok = FakeCursor([(True,)])
        with patch.object(db, "get_conn", fake_conn(ok)):
            assert is_cancel_requested(1) is True

        db._cancel_cache[1] = (0.0, True)  # cache expiré, valeur connue = True
        ko = FakeCursor([], fail=True)
        with patch.object(db, "get_conn", fake_conn(ko)):
            assert is_cancel_requested(1) is True

    def test_interroge_bien_la_colonne_cancel_requested(self):
        cur = FakeCursor([(False,)])
        with patch.object(db, "get_conn", fake_conn(cur)):
            is_cancel_requested(42)
        sql, params = cur.queries[0]
        assert "cancel_requested" in sql
        assert "crawl_run" in sql
        assert params == (42,)


class TestRaiseIfCancelled:
    def test_ne_leve_pas_quand_le_drapeau_est_baisse(self):
        cur = FakeCursor([(False,)])
        with patch.object(db, "get_conn", fake_conn(cur)):
            raise_if_cancelled(1)  # ne doit pas lever

    def test_leve_RunCancelled_quand_le_drapeau_est_leve(self):
        cur = FakeCursor([(True,)])
        with patch.object(db, "get_conn", fake_conn(cur)):
            with pytest.raises(RunCancelled):
                raise_if_cancelled(1)

    # RunCancelled doit rester attrapable par les `except Exception` existants
    # tout en étant distinguable — d'où l'ordre des clauses dans jobs.py.
    def test_RunCancelled_derive_de_Exception(self):
        assert issubclass(RunCancelled, Exception)


class TestFinishCrawlRun:
    def _run_finish(self, **kwargs):
        cur = FakeCursor([])
        with patch.object(db, "get_conn", fake_conn(cur)):
            db.finish_crawl_run(1, {"seen": 5}, **kwargs)
        return cur.queries[0]

    def test_statut_done_par_defaut(self):
        _sql, params = self._run_finish()
        assert params[0] == "done"

    def test_statut_error_avec_une_erreur(self):
        _sql, params = self._run_finish(error="boom")
        assert params[0] == "error"

    # Un run annulé n'est pas un échec : il ne doit pas remonter dans les erreurs.
    def test_statut_cancelled_quand_annule(self):
        _sql, params = self._run_finish(cancelled=True)
        assert params[0] == "cancelled"

    def test_cancelled_prime_sur_error(self):
        _sql, params = self._run_finish(error="interrompu", cancelled=True)
        assert params[0] == "cancelled"

    def test_le_cache_dannulation_est_purge(self):
        db._cancel_cache[1] = (999.0, True)
        self._run_finish(cancelled=True)
        assert 1 not in db._cancel_cache
