"""Runs waiting on a decision that can never come (ADR-0011).

A run paused at `awaiting_approval` holds the id of the proposal it waits for.
When that proposal no longer exists — a restored database, a rolled-back
migration, a proposal deleted by somebody holding the grant — the run waits
forever. The decision poller skips a *decision* it cannot match to a run; it had
no answer for a *run* it could not match to a proposal, so each was re-read from
the store on every poll and the agent did steadily more work to reach the same
conclusion about documents nobody could act on.

Staging held forty of them, which is how the gap was found.

The rule these defend is the one the compatibility gate follows: **refuse on
knowledge, never on ignorance.** A run is ended only when the CMS says the
proposal is not there. Every other outcome — unreachable, refused, rate limited —
leaves it exactly as it was, because failing a run because the network blinked
would destroy work that was merely waiting.
"""

from __future__ import annotations

import pytest

from app.api.client import AgentApiError, NotFound, PermissionDenied
from app.workflow.state import RunState, StepState


class Step:
    def __init__(self, proposal_id=None, state=StepState.AWAITING_APPROVAL):
        self.name = "file"
        self.state = state
        self.output = {"proposal_id": proposal_id} if proposal_id else {}


class Run:
    def __init__(self, run_id, proposal_id=None, state=RunState.AWAITING_APPROVAL,
                 step_state=StepState.AWAITING_APPROVAL):
        self.id = run_id
        self.workflow = "document_intake"
        self.state = state
        self.error = None
        self.steps = [Step(proposal_id, step_state)]


class Store:
    def __init__(self, runs):
        self._runs = runs
        self.saved = []

    def resumable(self):
        return list(self._runs)

    def save(self, run):
        self.saved.append(run)


def container_with(runs, cms):
    class FakeEngine:
        store = Store(runs)

    class FakeContainer:
        engine = FakeEngine()

    FakeContainer.cms = cms

    return FakeContainer


class Gone:
    """A CMS that has never heard of the proposal."""

    def __init__(self):
        self.asked = []

    def get_proposal(self, proposal_id):
        self.asked.append(proposal_id)
        raise NotFound("no such proposal", status=404, reason="not_found")


class Present:
    def __init__(self):
        self.asked = []

    def get_proposal(self, proposal_id):
        self.asked.append(proposal_id)

        return {"id": proposal_id, "status": "pending"}


class TestItEndsRunsThatCannotBeDecided:
    def test_a_run_whose_proposal_is_gone_is_failed(self):
        from app import __main__ as entry

        run = Run("run-1", proposal_id=42)
        container = container_with([run], Gone())

        entry._retire_orphan_runs(container)

        assert run.state is RunState.FAILED
        assert "42" in run.error
        assert container.engine.store.saved == [run]

    def test_the_error_says_the_document_is_unaffected(self):
        from app import __main__ as entry

        run = Run("run-1", proposal_id=42)
        entry._retire_orphan_runs(container_with([run], Gone()))

        # Whoever reads this needs to know the client's document is safe. The
        # intake record in the CMS is the copy that matters; this only stops the
        # agent waiting on a conversation the other side has forgotten.
        assert "unaffected" in run.error

    def test_a_run_whose_proposal_exists_is_left_alone(self):
        from app import __main__ as entry

        run = Run("run-1", proposal_id=42)
        container = container_with([run], Present())

        entry._retire_orphan_runs(container)

        assert run.state is RunState.AWAITING_APPROVAL
        assert container.engine.store.saved == []


class TestItRefusesOnIgnorance:
    @pytest.mark.parametrize("failure", [
        ConnectionError("unreachable"),
        AgentApiError("rate limited", status=429, reason="rate_limited"),
        PermissionDenied("no grant", status=403, reason="permission_denied"),
    ])
    def test_anything_other_than_a_definite_absence_changes_nothing(self, failure):
        from app import __main__ as entry

        class Unhelpful:
            def get_proposal(self, proposal_id):
                raise failure

        run = Run("run-1", proposal_id=42)
        container = container_with([run], Unhelpful())

        entry._retire_orphan_runs(container)

        # Failing a run because the network blinked would destroy work that was
        # merely waiting. Only 404 means the proposal is genuinely not there.
        assert run.state is RunState.AWAITING_APPROVAL
        assert container.engine.store.saved == []

    def test_an_unreadable_store_is_not_an_error(self):
        from app import __main__ as entry

        class Broken:
            def resumable(self):
                raise RuntimeError("database is down")

        class FakeEngine:
            store = Broken()

        class FakeContainer:
            engine = FakeEngine()
            cms = Gone()

        # Must not raise: this runs on a schedule beside jobs that matter more.
        entry._retire_orphan_runs(FakeContainer)


class TestItLooksOnlyWhereItShould:
    def test_runs_in_other_states_are_not_checked(self):
        from app import __main__ as entry

        cms = Gone()
        runs = [
            Run("r1", proposal_id=1, state=RunState.RUNNING),
            Run("r2", proposal_id=2, state=RunState.COMPLETED),
            Run("r3", proposal_id=3, state=RunState.FAILED),
        ]

        entry._retire_orphan_runs(container_with(runs, cms))

        # Only a run stopped on a proposal can be orphaned by one.
        assert cms.asked == []

    def test_a_run_with_no_proposal_id_is_skipped(self):
        from app import __main__ as entry

        cms = Gone()
        run = Run("r1", proposal_id=None)

        entry._retire_orphan_runs(container_with([run], cms))

        assert cms.asked == []
        assert run.state is RunState.AWAITING_APPROVAL

    def test_a_pass_is_bounded(self):
        from app import __main__ as entry
        from app.__main__ import _ORPHAN_CHECK_LIMIT

        cms = Gone()
        runs = [Run(f"r{i}", proposal_id=i) for i in range(_ORPHAN_CHECK_LIMIT + 20)]

        entry._retire_orphan_runs(container_with(runs, cms))

        # One CMS call per run checked, so this is a rate-limit budget. A
        # pathological backlog clears over several passes rather than crowding
        # out the work that matters.
        assert len(cms.asked) == _ORPHAN_CHECK_LIMIT

    def test_one_failure_to_save_does_not_stop_the_pass(self):
        from app import __main__ as entry

        class Fussy(Store):
            def save(self, run):
                if run.id == "r0":
                    raise RuntimeError("write failed")
                self.saved.append(run)

        runs = [Run("r0", proposal_id=1), Run("r1", proposal_id=2)]

        class FakeEngine:
            store = Fussy(runs)

        class FakeContainer:
            engine = FakeEngine()
            cms = Gone()

        entry._retire_orphan_runs(FakeContainer)

        assert [r.id for r in FakeContainer.engine.store.saved] == ["r1"]
