"""The client reads what the CMS actually sends.

This file exists because of a bug that every other test missed. The intake
methods were unit-tested against a stub that returned whatever shape the test
wanted, and the CMS endpoints were tested against their own HTTP responses — so
each side was correct on its own terms and they disagreed about the envelope.
The first real call on staging failed with a KeyError on 'data'.

The lesson is narrow and worth keeping: a seam covered from both sides is not a
seam that has been tested. These assert against the literal JSON the CMS emits,
copied from its own controller, so a change to one side breaks a test rather
than a deployment.
"""

from __future__ import annotations

import json

import pytest

from app.api import client as client_module
from app.api.client import AgentApiError, CmsClient, RateLimited


class FakeCms(CmsClient):
    """The real intake methods, over a stubbed transport.

    A subclass rather than a patched instance: CmsClient uses slots, and more to
    the point the methods under test are the real ones — only the bytes on the
    wire are supplied here.
    """

    def __init__(self, payload: dict, status: int = 200) -> None:  # noqa: D107
        self.raw = json.dumps(payload).encode()
        self.status = status
        self.calls: list[tuple[str, str]] = []

    def _request(self, method, path, params=None, body=None):
        self.calls.append((method, path))

        return CmsClient._decode(self, self.raw, self.status)

    def _get(self, path, params=None):
        return self._request("GET", path, params)


# The exact envelope IntakeApiController emits. Copied, not paraphrased.
REGISTERED = {
    "ok": True,
    "data": {"intake": {"reference": "INT-1042", "status": "received", "uuid": "u"}},
}


def test_registering_reads_the_record_out_of_the_envelope():
    cms = FakeCms(REGISTERED)

    record = cms.register_intake({"source": "whatsapp"})

    assert record["reference"] == "INT-1042"
    assert record["status"] == "received"


def test_updating_reads_the_record_out_of_the_envelope():
    cms = FakeCms(
        {"ok": True, "data": {"intake": {"reference": "INT-1042", "status": "processing"}, "moved": True}}
    )

    record = cms.update_intake("INT-1042", status="processing")

    assert record["status"] == "processing"
    assert cms.calls == [("PATCH", "intake/INT-1042/status")]


def test_reading_one_record_back():
    cms = FakeCms(REGISTERED)

    assert cms.get_intake("INT-1042")["reference"] == "INT-1042"


def test_the_pending_list_is_a_list():
    cms = FakeCms(
        {"ok": True, "data": {"intake": [{"reference": "INT-1"}, {"reference": "INT-2"}], "count": 2}}
    )

    pending, truncated = cms.pending_intake()

    assert [r["reference"] for r in pending] == ["INT-1", "INT-2"]
    assert truncated is False


def test_an_empty_pending_list_is_not_an_error():
    cms = FakeCms({"ok": True, "data": {"intake": [], "count": 0}})

    assert cms.pending_intake() == ([], False)


def test_a_refusal_raises_rather_than_returning_a_shape():
    cms = FakeCms({"ok": False, "error": "permission_denied"}, status=403)

    # What staging hit first: an agent issued before intake.write existed. The
    # caller has to be able to tell this from an empty list.
    with pytest.raises(AgentApiError):
        cms.pending_intake()


def test_none_values_are_not_sent_as_fields():
    cms = FakeCms({"ok": True, "data": {"intake": {"reference": "INT-1"}}})

    # update_intake takes keyword arguments and drops the unset ones, so a caller
    # can pass only what it knows without blanking the rest of the record.
    cms.update_intake("INT-1", status="waiting", current_step=None)


class RateLimitedThenFine(CmsClient):
    """Refuses with 429 a set number of times, then succeeds.

    Models what the CMS actually does under a burst: the limit is per minute and
    per agent key, so a caller that waits gets through rather than one that
    retries instantly.
    """

    def __init__(self, refusals: int, retry_after: float | None = None) -> None:  # noqa: D107
        self.refusals = refusals
        self.retry_after = retry_after
        self.attempts = 0
        self.waited: list[float] = []

    def _send(self, method, path, params=None, body=None):
        self.attempts += 1

        if self.attempts <= self.refusals:
            raise RateLimited("slow down", retry_after=self.retry_after)

        return {"ok": True, "data": {"intake": {"reference": "INT-1", "status": "received"}}}


def test_a_rate_limit_is_waited_out_rather_than_failed(monkeypatch):
    cms = RateLimitedThenFine(refusals=2)
    monkeypatch.setattr(client_module.time, "sleep", lambda s: cms.waited.append(s))

    record = cms.register_intake({"source": "whatsapp"})

    # The document is registered. Under the old behaviour this raised, the
    # registration fell into the outbox, and a burst looked like a broken
    # pipeline while the CMS was perfectly healthy.
    assert record["reference"] == "INT-1"
    assert cms.attempts == 3
    assert cms.waited == [1, 2]


def test_it_gives_up_eventually_rather_than_blocking_forever(monkeypatch):
    cms = RateLimitedThenFine(refusals=99)
    monkeypatch.setattr(client_module.time, "sleep", lambda s: cms.waited.append(s))

    # A daemon job is blocked while this waits, so it has to end.
    with pytest.raises(AgentApiError) as raised:
        cms.register_intake({"source": "whatsapp"})

    assert raised.value.status == 429
    assert len(cms.waited) == 4


def test_a_server_asking_for_a_long_wait_gets_a_shorter_one(monkeypatch):
    cms = RateLimitedThenFine(refusals=1, retry_after=600.0)
    monkeypatch.setattr(client_module.time, "sleep", lambda s: cms.waited.append(s))

    cms.register_intake({"source": "whatsapp"})

    # Honoured, but capped. Ten minutes inside a two-second daemon loop would
    # stall every other job behind it.
    assert cms.waited == [8.0]


def test_an_ordinary_refusal_is_not_retried(monkeypatch):
    class Denied(CmsClient):
        def __init__(self):
            self.attempts = 0

        def _send(self, method, path, params=None, body=None):
            self.attempts += 1
            raise AgentApiError("nope", status=403, reason="permission_denied")

    cms = Denied()

    with pytest.raises(AgentApiError):
        cms.register_intake({"source": "whatsapp"})

    # Waiting changes nothing about a missing grant.
    assert cms.attempts == 1


def test_a_truncated_answer_is_reported_as_such():
    cms = FakeCms({
        "ok": True,
        "data": {"intake": [{"reference": f"INT-{i}"} for i in range(25)],
                 "count": 25, "truncated": True},
    })

    pending, truncated = cms.pending_intake(status="processing", per_page=25)

    # The whole point of the flag. Recovery that silently sees a fraction of the
    # stranded work is worse than one that says it cannot see it all — a document
    # stranded behind a backlog of twenty-five was never recovered, on an
    # installation where everything looked healthy.
    assert len(pending) == 25
    assert truncated is True


def test_the_status_filter_reaches_the_request():
    cms = FakeCms({"ok": True, "data": {"intake": [], "count": 0}})

    cms.pending_intake(status="processing")

    assert cms.calls == [("GET", "intake/pending")]
