"""The model stage, and the rule it runs under.

ADR-0009: a model may read a document's text when, and only when, it runs on
this host. Almost everything here is about that rule holding, because it is the
one thing in this system whose failure is silent, total, and discovered — if
ever — long afterwards.

The classification itself is nearly incidental. What matters is that the stage
is off unless configured, refused unless local, checked again on every call,
incapable of overruling a person, and incapable of inventing a document type.
"""

from __future__ import annotations

from pathlib import Path

import pytest

from app.documents import model as model_module
from app.documents.classifier import ACCEPT, UNKNOWN, Method, classify
from app.documents.model import MAX_CONFIDENCE, LocalHttpModel, ModelVerdict, is_local_endpoint
from app.documents.signals import Signals
from app.runtime import preflight

#: A real payslip the rule engine cannot name, taken from the staging run.
#:
#: `Statutory deductions` is a salary_slip marker worth 1.5 — below the engine's
#: MIN_SCORE of 3.0 and its requirement of two hits, so it returns `unknown`.
#: That single hit is exactly the tie the model is allowed to break, and it is
#: what corroborates the model's answer.
#:
#: This was a placeholder sentence with nothing in it a marker could match, from
#: before the corroboration rule existed. It made the model stage look able to
#: name anything, which is precisely what the rule now forbids.
SALARY_TEXT = (
    "ORION FOODS LIMITED\nREMUNERATION ADVICE\n"
    "Emoluments for the month 90,000\nStatutory deductions 7,400\n"
    "Amount credited to account 82,600"
)


@pytest.fixture
def document(tmp_path) -> Path:
    path = tmp_path / "scan001.pdf"
    path.write_bytes(b"%PDF")

    return path


class FakeModel:
    """A model that answers whatever the test decided, and records what it saw."""

    def __init__(self, verdict: ModelVerdict | None = None):
        self._verdict = verdict
        self.seen: list[str] = []

    def classify(self, text: str) -> ModelVerdict | None:
        self.seen.append(text)

        return self._verdict


# ── The locality rule ─────────────────────────────────────────────────────


class TestWhatCountsAsLocal:
    @pytest.mark.parametrize(
        "url",
        [
            "http://127.0.0.1:11434",
            "http://localhost:8000",
            "http://[::1]:11434",
            "http://192.168.1.50:11434",
            "http://10.0.0.4:8000",
        ],
    )
    def test_loopback_and_private_addresses_are_allowed(self, url):
        assert is_local_endpoint(url) is True

    @pytest.mark.parametrize(
        "url",
        [
            "https://api.openai.com",
            "https://api.anthropic.com/v1",
            "http://8.8.8.8:11434",
            "https://some-model-host.example.com",
        ],
    )
    def test_public_endpoints_are_refused(self, url):
        # The whole of ADR-0009 in one assertion. A document's text is Level 3
        # and does not leave the installation.
        assert is_local_endpoint(url) is False

    @pytest.mark.parametrize("url", ["", "not-a-url", "http://", "ftp://"])
    def test_anything_unresolvable_is_refused(self, url):
        # A name that cannot be checked is not a name that can be trusted, and
        # "it was probably fine" is not a basis for sending a bank statement.
        assert is_local_endpoint(url) is False

    def test_a_name_resolving_to_both_local_and_public_is_refused(self, monkeypatch):
        """Every address, not just the first.

        A hostname answering with one loopback address and one public one is not
        local, and checking only the first is how it would pass.
        """
        def resolve(host, port):  # noqa: ARG001
            return [
                (2, 1, 6, "", ("127.0.0.1", 0)),
                (2, 1, 6, "", ("93.184.216.34", 0)),
            ]

        monkeypatch.setattr(model_module.socket, "getaddrinfo", resolve)

        assert is_local_endpoint("http://mixed.example.com") is False


class TestBuildingOne:
    def test_no_url_means_no_model(self):
        # The ordinary state. A deployment that sets nothing sends nothing.
        assert model_module.build(None, "llama3") is None

    def test_a_url_without_a_model_name_is_off(self):
        assert model_module.build("http://127.0.0.1:11434", None) is None

    def test_a_public_url_is_refused_rather_than_used(self, caplog):
        with caplog.at_level("ERROR"):
            built = model_module.build("https://api.openai.com", "gpt-4")

        assert built is None
        assert "not a local endpoint" in caplog.text
        # The reason names the ADRs, because the person reading this log line is
        # deciding whether it is a bug or a rule.
        assert "ADR-0002" in caplog.text

    def test_a_refused_url_does_not_stop_the_deployment(self):
        # Raising would take down document intake over a stage the deployment
        # runs perfectly well without.
        assert model_module.build("https://api.openai.com", "gpt-4") is None

    def test_a_local_url_builds(self):
        built = model_module.build("http://127.0.0.1:11434", "llama3")

        assert isinstance(built, LocalHttpModel)


class TestTheCheckRunsAgainOnEveryCall:
    def test_a_url_that_stops_being_local_is_refused_at_call_time(self, monkeypatch, caplog):
        """Constructed while local, called after the name moved.

        A long-running process that trusted the answer from boot would post a
        client's documents to wherever the name points an hour later.
        """
        built = LocalHttpModel("http://model.internal:11434", "llama3")

        monkeypatch.setattr(model_module, "is_local_endpoint", lambda url: False)

        def explode(*args, **kwargs):  # pragma: no cover - must never be reached
            raise AssertionError("It tried to send the document text.")

        monkeypatch.setattr(model_module.urllib.request, "urlopen", explode)

        with caplog.at_level("ERROR"):
            assert built.classify("some text") is None

        assert "Refusing to send document text" in caplog.text


class TestWhatItWillBelieve:
    def _answer(self, content: str) -> dict:
        return {"choices": [{"message": {"content": content}}]}

    def test_a_valid_answer_is_believed(self):
        verdict = model_module._believe(
            self._answer('{"document_type": "salary_slip", "confidence": 0.8, "reason": "payroll"}')
        )

        assert verdict.document_type == "salary_slip"
        assert verdict.confidence == 0.8

    def test_a_type_the_registry_does_not_hold_is_refused(self):
        # The CMS would reject the filing anyway, and a reviewer would be
        # looking at a proposal nobody could approve.
        assert model_module._believe(
            self._answer('{"document_type": "vibe_check", "confidence": 0.99}')
        ) is None

    def test_unknown_is_an_abstention_not_an_answer(self):
        assert model_module._believe(
            self._answer('{"document_type": "unknown", "confidence": 0.9}')
        ) is None

    def test_confidence_is_capped_however_sure_the_model_claims_to_be(self):
        # Some local models answer 1.0 to everything.
        verdict = model_module._believe(
            self._answer('{"document_type": "invoice", "confidence": 1.0}')
        )

        assert verdict.confidence == MAX_CONFIDENCE

    def test_the_cap_keeps_a_model_below_a_person(self):
        # A caption is somebody looking at the document and saying what it is.
        # No model answer may outrank that, or the ordering of the pipeline
        # stops meaning anything.
        assert MAX_CONFIDENCE < ACCEPT

    @pytest.mark.parametrize(
        "content",
        ["not json at all", "", "{", '{"confidence": 0.9}', "[]"],
    )
    def test_anything_unparseable_is_refused(self, content):
        assert model_module._believe(self._answer(content)) is None

    def test_json_wrapped_in_prose_is_still_read(self):
        # Small models wrap answers however firmly they are asked not to.
        verdict = model_module._believe(
            self._answer('Sure! ```json\n{"document_type": "receipt", "confidence": 0.7}\n```')
        )

        assert verdict.document_type == "receipt"

    def test_a_malformed_envelope_is_refused(self):
        assert model_module._believe({"nonsense": True}) is None


# ── The stage, in the pipeline ────────────────────────────────────────────


class TestTheStageInThePipeline:
    def test_it_does_not_run_when_no_model_is_configured(self, document):
        signals = Signals(path=document, filename="scan001.pdf",
                          read_first_page=lambda: SALARY_TEXT)

        result = classify(signals)

        assert result.filing_type == UNKNOWN
        assert result.method is not Method.MODEL

    def test_it_names_what_nothing_else_could(self, document):
        model = FakeModel(ModelVerdict("salary_slip", 0.8, "payroll wording"))
        signals = Signals(path=document, filename="scan001.pdf",
                          read_first_page=lambda: SALARY_TEXT, model=model)

        result = classify(signals)

        assert result.filing_type == "salary_slip"
        assert result.method is Method.MODEL
        assert "local model" in result.reason

    def test_a_caption_settles_it_before_the_model_is_asked(self, document):
        """The ordering that makes the cost and the risk both bounded.

        A model is the slowest and the only stage that reads Level 3 text into a
        second process. It must never run when something cheaper already knows.
        """
        model = FakeModel(ModelVerdict("invoice", 0.85, "should not be asked"))
        signals = Signals(path=document, caption="Salary Slip",
                          read_first_page=lambda: SALARY_TEXT, model=model)

        result = classify(signals)

        assert result.filing_type == "salary_slip"
        assert result.method is Method.CAPTION
        assert model.seen == []

    def test_the_rule_engine_settles_it_before_the_model_is_asked(self, document):
        model = FakeModel(ModelVerdict("invoice", 0.85, "should not be asked"))
        signals = Signals(
            path=document,
            filename="scan001.pdf",
            read_first_page=lambda: "SALARY SLIP\nGross Salary 1\nNet Pay 2",
            model=model,
        )

        result = classify(signals)

        assert result.method is Method.RULES
        assert model.seen == []

    def test_it_is_sent_the_text_and_nothing_else(self, document):
        model = FakeModel(ModelVerdict("salary_slip", 0.8, "payroll"))
        signals = Signals(
            path=document,
            caption="something about client 1420",
            filename="private-client-name.pdf",
            read_first_page=lambda: SALARY_TEXT,
            model=model,
        )

        classify(signals)

        # One argument, and it is the page. Not the filename, not the caption,
        # not a client.
        assert model.seen == [SALARY_TEXT]

    def test_a_model_that_abstains_leaves_the_answer_alone(self, document):
        model = FakeModel(None)
        signals = Signals(path=document, filename="Meezan_Bank_Statement.pdf",
                          read_first_page=lambda: SALARY_TEXT, model=model)

        result = classify(signals)

        # The filename's answer stands. A model being down must not cost a
        # classification that was already made.
        assert result.filing_type == "bank_statement"
        assert result.method is Method.FILENAME

    def test_a_model_is_not_asked_about_a_document_with_no_text(self, document):
        model = FakeModel(ModelVerdict("invoice", 0.8, "x"))
        signals = Signals(path=document, filename="scan001.pdf",
                          read_first_page=lambda: "", model=model)

        classify(signals)

        assert model.seen == []

    def test_the_stage_is_counted_as_the_model(self, document):
        from app.documents.statistics import STATS

        STATS.reset()
        model = FakeModel(ModelVerdict("salary_slip", 0.8, "payroll"))

        classify(Signals(path=document, filename="scan001.pdf",
                         read_first_page=lambda: SALARY_TEXT, model=model))

        # The figure that says whether the stage is earning its cost.
        assert STATS.report()["by_method"] == {Method.MODEL.value: 1}
        STATS.reset()


class TestThePreflightCheck:
    class _Container:
        def __init__(self, env, model):
            self._env = env
            self.model = model

    def test_no_model_configured_is_reported_as_fine(self):
        check = preflight.check_model(self._Container({}, None))

        assert check.outcome is preflight.Outcome.OK
        assert "the stage is off" in check.detail

    def test_a_refused_endpoint_is_reported_at_boot(self):
        """The case this check exists for.

        Without it the deployment runs for weeks believing it has a model stage
        that has never once fired.
        """
        check = preflight.check_model(
            self._Container({"TAXPILOT_MODEL_URL": "https://api.openai.com"}, None)
        )

        assert check.outcome is preflight.Outcome.DEGRADED
        assert "ADR-0009" in check.detail

    def test_a_working_model_is_reported_with_its_address(self):
        check = preflight.check_model(
            self._Container(
                {"TAXPILOT_MODEL_URL": "http://127.0.0.1:11434"},
                LocalHttpModel("http://127.0.0.1:11434", "llama3"),
            )
        )

        assert check.outcome is preflight.Outcome.OK
        assert "127.0.0.1" in check.detail


class TestCorroboration:
    """A model may break a tie, not invent an answer.

    Every case here is taken from what the 0.5B model on staging actually said.
    The rule has to reject all of the wrong answers and keep the right one, or
    it is either useless or worse than useless.
    """

    def test_an_invented_answer_is_discarded(self, document):
        """The measured failure this rule exists for.

        Asked about a covering note, the model answered "invoice, 0.85, clearly
        an invoice". Nothing in the text is an invoice marker.
        """
        note = "Dear Sir, please find attached the file we discussed at the meeting. Regards."
        model = FakeModel(ModelVerdict("invoice", 0.85, "clearly an invoice"))

        result = classify(
            Signals(path=document, filename="note.pdf",
                    read_first_page=lambda: note, model=model)
        )

        assert result.filing_type == UNKNOWN
        assert result.method is not Method.MODEL

    def test_a_payslip_called_a_tax_return_is_discarded(self, document):
        # Also measured: "Remuneration Advice / Emoluments" answered tax_return.
        # No tax_return marker appears in it.
        payslip = (
            "ORION FOODS LIMITED\nREMUNERATION ADVICE\n"
            "Emoluments for the month 90,000\nAmount credited to account 82,600"
        )
        model = FakeModel(ModelVerdict("tax_return", 0.85, "remuneration and credits"))

        result = classify(
            Signals(path=document, filename="advice.pdf",
                    read_first_page=lambda: payslip, model=model)
        )

        assert result.filing_type == UNKNOWN

    def test_an_answer_the_text_supports_is_kept(self, document):
        """The one the rule must not throw away.

        The same payslip with "Statutory deductions" in it — a salary_slip
        marker. The rule engine could not reach its own bar on one hit, which is
        exactly the tie a model is allowed to break.
        """
        payslip = (
            "ORION FOODS LIMITED\nREMUNERATION ADVICE\n"
            "Emoluments for the month 90,000\nStatutory deductions 7,400\n"
            "Amount credited to account 82,600"
        )
        model = FakeModel(ModelVerdict("salary_slip", 0.85, "payroll wording"))

        result = classify(
            Signals(path=document, filename="advice.pdf",
                    read_first_page=lambda: payslip, model=model)
        )

        assert result.filing_type == "salary_slip"
        assert result.method is Method.MODEL

    def test_the_reason_names_what_corroborated_it(self, document):
        # The difference between a reviewer trusting this and a reviewer having
        # only a model's word for it.
        payslip = "REMUNERATION ADVICE\nStatutory deductions 7,400"
        model = FakeModel(ModelVerdict("salary_slip", 0.85, "payroll"))

        result = classify(
            Signals(path=document, filename="a.pdf",
                    read_first_page=lambda: payslip, model=model)
        )

        assert "deductions" in result.reason.lower()

    def test_a_discarded_answer_leaves_an_earlier_stage_standing(self, document):
        # A filename that had already classified it must survive the model
        # being wrong — this rule must not cost answers that were already made.
        model = FakeModel(ModelVerdict("invoice", 0.85, "invented"))

        result = classify(
            Signals(
                path=document,
                filename="Meezan_Bank_Statement.pdf",
                read_first_page=lambda: "Dear Sir, regards.",
                model=model,
            )
        )

        assert result.filing_type == "bank_statement"
        assert result.method is Method.FILENAME

    def test_other_can_never_be_corroborated(self, document):
        # `other` is a filing decision a person makes, not a recognition, and it
        # carries no markers — so a model naming it can never be believed.
        model = FakeModel(ModelVerdict("other", 0.85, "not sure"))

        result = classify(
            Signals(path=document, filename="x.pdf",
                    read_first_page=lambda: "anything at all", model=model)
        )

        assert result.filing_type == UNKNOWN
