"""Version negotiation between this release and the CMS it is attached to.

The question these answer is not "does it parse a version string" but "what
happens to a running installation when the two products drift apart" — because
the CMS updates itself over the air, so drift is a thing that happens on a
Tuesday afternoon without anyone touching this side.
"""

from __future__ import annotations

import pytest

from app.api.client import CmsClient, IncompatibleCms
from app.api.compatibility import (
    ASSUMED_CONTRACT,
    REQUIRED_CONTRACT,
    SUPPORTED_MAJOR,
    Level,
    agent_is_new_enough,
    evaluate,
)
from app.config.settings import Settings
from app.runtime import preflight


def whoami(contract: str | None = "1.2", version: str = "1.1.4", minimum_agent: str | None = None):
    """A whoami payload shaped like the CMS's.

    Defaults to the contract this release requires, so "a normal CMS" in these
    tests means one this agent can actually work with. It said 1.0 until the
    floor rose to 1.1 with the Intake Center, then 1.2 with announced arrivals.
    """
    described: dict = {"version": version}

    if contract is not None:
        described["api_contract"] = contract

    if minimum_agent is not None:
        described["minimum_agent_version"] = minimum_agent

    return {
        "name": "TaxPilot AI",
        "permissions": ["clients.read", "proposals.submit"],
        "cms": described,
    }


class FakeCms:
    """Stands in for CmsClient, with the same handshake surface."""

    def __init__(self, identity=None, raises: Exception | None = None) -> None:
        self._payload = identity if identity is not None else whoami()
        self._raises = raises
        self.identity = None
        self.compatibility = None
        self.handshakes = 0

    def handshake(self):
        self.handshakes += 1

        if self._raises is not None:
            raise self._raises

        self.identity = self._payload
        self.compatibility = evaluate(self._payload)

        return self.compatibility


class TestTheContract:
    def test_a_matching_contract_is_fine(self):
        verdict = evaluate(whoami(contract="1.2"))

        assert verdict.level is Level.OK
        assert verdict.is_usable

    def test_a_later_minor_is_fine(self):
        """MINOR is additive. A CMS that gained an endpoint is still one this
        release can talk to — refusing would mean every CMS feature release
        breaks every agent."""
        assert evaluate(whoami(contract="1.7")).level is Level.OK

    def test_an_earlier_minor_than_required_is_refused(self):
        required_minor = int(REQUIRED_CONTRACT.split(".")[1])

        if required_minor == 0:
            pytest.skip("nothing is below 1.0 to test against")

        verdict = evaluate(whoami(contract=f"1.{required_minor - 1}"))

        assert verdict.level is Level.INCOMPATIBLE

    def test_a_newer_major_is_refused(self):
        """A breaking change is breaking. This release does not know what
        changed, and "probably fine" is not a basis on which to file somebody's
        tax documents."""
        verdict = evaluate(whoami(contract=f"{SUPPORTED_MAJOR + 1}.0"))

        assert verdict.level is Level.INCOMPATIBLE
        assert "Update TaxPilot AI" in verdict.detail

    def test_an_older_major_is_refused(self):
        verdict = evaluate(whoami(contract="0.9"))

        assert verdict.level is Level.INCOMPATIBLE
        assert "Update the CMS" in verdict.detail

    def test_the_refusal_says_which_way_to_move(self):
        """An operator reading this at 2am needs to know which product to
        update, not that something is wrong."""
        newer = evaluate(whoami(contract="2.0")).detail
        older = evaluate(whoami(contract="0.1")).detail

        assert "newer than" in newer
        assert "older than" in older

    @pytest.mark.parametrize("contract", ["1", "1.2.3", "one.two", "v1.0", "1.x"])
    def test_an_unreadable_contract_is_refused(self, contract):
        """Something was said and it cannot be understood — which is not the
        same as nothing being said. See the empty case below."""
        assert evaluate(whoami(contract=contract)).level is Level.INCOMPATIBLE

    def test_an_empty_contract_counts_as_saying_nothing(self):
        """Still treated as silence — but silence no longer passes.

        This used to be deliberately lenient: an empty field is more likely a
        CMS config glitch than an old build, and refusing meant one bad config
        value stopped every agent from starting.

        That trade changed when the floor rose to 1.1. Silence is assumed to be
        1.0, and a 1.0 CMS genuinely cannot serve this release — so the lenient
        reading no longer buys a working deployment, it buys one that starts,
        fills its outbox with documents the CMS will never accept, and then
        stops taking new ones.

        The leniency argument has not gone away, it has been outweighed: a
        refusal is loud and diagnosable in a minute, and the alternative failure
        is silent and takes a firm's intake down without saying so.
        """
        verdict = evaluate(whoami(contract=""))

        assert verdict.level is Level.INCOMPATIBLE
        assert not verdict.is_usable


class TestACmsThatSaysNothing:
    """A CMS built before the handshake existed.

    These tests once protected such a CMS from being locked out. They now assert
    that it *is* locked out, and the reversal is deliberate rather than a test
    bent to fit a change.

    What altered is not the judgement but the facts. When the floor was 1.0, a
    silent CMS was old but perfectly able to do the work, so refusing it would
    have caused the outage the check existed to prevent. Since ADR-0010 this
    release registers every arriving document before doing anything with it, and
    those endpoints do not exist on a pre-handshake CMS. There is no longer a
    working configuration to protect.
    """

    def test_silence_still_means_contract_one_zero(self):
        """Unchanged, and still not a guess: nothing had changed when those
        builds shipped, so their API *is* 1.0. Only the consequence moved."""
        assert evaluate({"name": "agent", "permissions": []}).contract == ASSUMED_CONTRACT

    def test_it_is_refused_because_this_release_needs_more_than_it_has(self):
        verdict = evaluate(whoami(contract=None))

        assert verdict.level is Level.INCOMPATIBLE
        assert not verdict.is_usable

    def test_the_refusal_names_the_product_to_update(self):
        """An operator meeting this has an agent that will not talk to their
        CMS. The one thing they need is which side to move."""
        assert "Update the CMS" in evaluate(whoami(contract=None)).detail

    def test_refusing_it_is_now_the_point_rather_than_the_hazard(self):
        """The inverse of what this file used to assert, kept under a name that
        says so.

        Locking out a CMS that cannot serve this release is the correct
        outcome — the agent stops at the handshake with a stated reason. The
        behaviour being prevented is the alternative: a start that looks healthy,
        registrations that 404 forever, an outbox filling, and documents refused
        once it is full, with nothing anywhere saying the CMS is simply too old.
        """
        assert evaluate(whoami(contract=None)).is_usable is False

    def test_an_unusable_response_is_refused(self):
        for payload in (None, [], "nope", 7):
            assert evaluate(payload).level is Level.INCOMPATIBLE


class TestTheAgentFloor:
    def test_a_newer_agent_satisfies_the_floor(self):
        assert agent_is_new_enough("1.0.0", "1.2.0") is True

    def test_an_older_agent_does_not(self):
        assert agent_is_new_enough("1.2.0", "1.0.0") is False

    def test_no_floor_means_no_objection(self):
        assert agent_is_new_enough(None, "0.1.0") is True
        assert agent_is_new_enough("", "0.1.0") is True

    def test_an_unreadable_floor_does_not_stop_a_start(self):
        """A malformed field is not a reason to refuse to boot. The real
        checks — permissions, endpoints, the contract — still apply."""
        assert agent_is_new_enough("latest", "1.0.0") is True


class TestTheGate:
    """One check, on the one way out (ADR-0005)."""

    def client(self, identity) -> CmsClient:
        client = CmsClient(Settings("https://cms.test", "key", "secret"))
        client._verdict = evaluate(identity)  # noqa: SLF001

        return client

    def test_an_incompatible_cms_refuses_every_call(self):
        client = self.client(whoami(contract="2.0"))

        with pytest.raises(IncompatibleCms):
            client.search_clients(name="anything")

    def test_it_refuses_writes_too(self):
        client = self.client(whoami(contract="2.0"))

        with pytest.raises(IncompatibleCms):
            client.submit_proposal({"idempotency_key": "k"})

    def test_the_handshake_itself_is_never_blocked(self):
        """Blocking it would make the incompatibility permanent: nothing could
        re-ask, so a CMS rolled back to a working version would never be
        noticed."""
        client = self.client(whoami(contract="2.0"))

        # Reaches the transport and fails there — on the network, not the gate.
        with pytest.raises(Exception) as caught:
            client.whoami()

        assert not isinstance(caught.value, IncompatibleCms)

    def test_a_client_that_has_not_handshaken_is_not_blocked(self):
        """The gate refuses on knowledge, not on ignorance."""
        client = CmsClient(Settings("https://cms.test", "key", "secret"))

        assert client.compatibility is None

        with pytest.raises(Exception) as caught:
            client.search_clients(name="anything")

        assert not isinstance(caught.value, IncompatibleCms)

    def test_a_degraded_verdict_still_allows_work(self):
        """Built by hand, because nothing currently produces one.

        Degraded used to arrive via a CMS that stated no contract. With the
        floor at 1.1 and silence assumed to be 1.0, that path is refused before
        it can reach the degraded branch — so `evaluate` has no route to this
        level today.

        The gate's rule is still worth holding: only INCOMPATIBLE blocks, and
        degraded means "worth saying out loud, not worth stopping for". The next
        thing that wants to report a soft concern will rely on it, and a rule
        left untested until then is a rule that has quietly changed.
        """
        from app.api.compatibility import Verdict

        client = CmsClient(Settings("https://cms.test", "key", "secret"))
        client._verdict = Verdict(Level.DEGRADED, "something worth mentioning")  # noqa: SLF001

        with pytest.raises(Exception) as caught:
            client.search_clients(name="anything")

        assert not isinstance(caught.value, IncompatibleCms)

    def test_it_is_an_api_error_so_callers_do_not_crash(self):
        from app.api.client import AgentApiError

        assert issubclass(IncompatibleCms, AgentApiError)


class TestPreflight:
    def test_an_incompatible_cms_is_fatal(self):
        """Fatal, not degraded. The requests this release knows how to make no
        longer mean what it thinks they mean, and they file documents against a
        tax firm's client records."""
        cms = FakeCms(whoami(contract="2.0"))
        preflight.check_cms(cms)

        assert preflight.check_compatibility(cms).is_fatal

    def test_a_matching_cms_passes(self):
        cms = FakeCms(whoami(contract="1.2"))
        preflight.check_cms(cms)

        check = preflight.check_compatibility(cms)

        assert check.outcome is preflight.Outcome.OK

    def test_a_silent_cms_is_now_fatal_at_startup(self):
        """It stops the agent starting, and that is the intended outcome.

        Previously degraded-not-fatal, on the reasoning that a CMS predating the
        handshake still worked. It no longer does: this release needs the intake
        endpoints, a pre-handshake CMS has none, and every document that arrived
        would be held for a registration that cannot succeed.

        Failing at preflight puts that in front of whoever started the process,
        instead of leaving it to be discovered when a firm notices their
        documents stopped appearing.
        """
        cms = FakeCms(whoami(contract=None))
        preflight.check_cms(cms)

        check = preflight.check_compatibility(cms)

        assert check.outcome is preflight.Outcome.FATAL
        assert check.is_fatal

    def test_an_agent_below_the_cms_floor_is_fatal(self):
        cms = FakeCms(whoami(minimum_agent="9.0.0"))
        preflight.check_cms(cms)

        check = preflight.check_compatibility(cms, running_version="0.1.0")

        assert check.is_fatal
        assert "9.0.0" in check.detail

    def test_an_unchecked_cms_is_not_assumed_good(self):
        check = preflight.check_compatibility(FakeCms())

        assert check.outcome is preflight.Outcome.DEGRADED
        assert "not asked" in check.detail

    def test_check_cms_performs_the_handshake(self):
        """The two checks share one call. check_compatibility depends on it
        having happened."""
        cms = FakeCms()
        preflight.check_cms(cms)

        assert cms.handshakes == 1
        assert cms.compatibility is not None

    def test_an_unreachable_cms_is_fatal_as_before(self):
        cms = FakeCms(raises=RuntimeError("connection refused"))

        check = preflight.check_cms(cms)

        assert check.is_fatal
        assert "Cannot reach" in check.detail


class TestHealthReporting:
    def container(self, cms):
        class Container:
            def __init__(self, cms):
                self.cms = cms

        return Container(cms)

    def test_an_incompatible_cms_takes_the_service_out_of_rotation(self):
        """FAILING, so an orchestrator stops routing to it. Reporting merely
        degraded would leave traffic going to a service whose every CMS call is
        being refused."""
        from app.runtime.health import Status, _compatibility

        cms = FakeCms(whoami(contract="2.0"))
        cms.handshake()

        assert _compatibility(self.container(cms)).status is Status.FAILING

    def test_a_matching_cms_is_ok(self):
        from app.runtime.health import Status, _compatibility

        cms = FakeCms(whoami(contract="1.2"))
        cms.handshake()

        assert _compatibility(self.container(cms)).status is Status.OK

    def test_before_the_first_handshake_it_is_not_claimed_healthy(self):
        from app.runtime.health import Status, _compatibility

        assert _compatibility(self.container(FakeCms())).status is Status.DEGRADED


class TestWhatTheReleaseDeclares:
    def test_the_manifest_floor_comes_from_the_code(self):
        """The policy is what gets enforced at boot, so a manifest that
        disagreed with it would be the thing that was wrong."""
        from app.api.compatibility import MINIMUM_CMS_VERSION
        from app.release.version import is_valid

        assert is_valid(MINIMUM_CMS_VERSION)

    def test_the_required_contract_is_readable(self):
        from app.api.compatibility import CONTRACT_PATTERN

        assert CONTRACT_PATTERN.match(REQUIRED_CONTRACT)
        assert int(REQUIRED_CONTRACT.split(".")[0]) == SUPPORTED_MAJOR
