"""Bank statement intake — filing a document nobody reads.

The forwarding path. Somebody puts a bank statement in their firm's own WhatsApp
self-chat with a file number or a CNIC in the caption, and it reaches that
client's record after a reviewer approves.

Two things are worth testing hard, and they are not the happy path. The first is
that nothing here ever opens the document: no OCR tool is even registered in
these tests, so a workflow that tried to read one would fail loudly rather than
quietly succeed. The second is the identifier, which is the entire basis for
deciding whose record this lands on — a misread one does not fail, it succeeds
against a stranger.

The CMS is faked at the HTTP boundary. The real client, the real signing, the
real tools, the real engine, with only the network replaced.
"""

from __future__ import annotations

from pathlib import Path

import pytest

from app.api.client import AgentApiError, CmsClient
from app.config.settings import Settings
from app.tools.client_tools import FindClientByIdentifierTool
from app.tools.document_tools import SubmitFilingProposalTool
from app.tools.registry import ToolRegistry
from app.documents import identifiers
from app.workflow.bank_statement_intake import BANK_STATEMENT_INTAKE
from app.workflow.decisions import DecisionPoller
from app.workflow.document_intake import DOCUMENT_INTAKE
from app.workflow.engine import WorkflowEngine
from app.workflow.state import RunState

ALI = {"id": 42, "name": "Muhammad Ali", "file_number": "1420"}
FATIMA = {"id": 77, "name": "Fatima Khan", "file_number": "1421"}


class FakeCms(CmsClient):
    """The real client with only the wire replaced.

    Answers each search type from its own table, so a test can make the file
    number and the CNIC name different people — which is the case the whole
    precedence rule exists for and cannot be set up any other way.
    """

    def __init__(self, *, by_file=None, by_cnic=None, fail_submit: str | None = None):
        super().__init__(
            Settings(
                cms_base_url="https://cms.test",
                agent_api_key="tpa_test",
                agent_api_secret="s" * 64,
            )
        )
        self.by_file = [ALI] if by_file is None else by_file
        self.by_cnic = [ALI] if by_cnic is None else by_cnic
        self.fail_submit = fail_submit
        self.submitted: list[dict] = []
        self.decisions: list[dict] = []
        self.searches: list[tuple[str, str]] = []

    def _request(self, method, path, params=None, body=None):  # noqa: ANN001
        if path == "clients":
            search_type = (params or {}).get("search_type")
            self.searches.append((search_type, (params or {}).get("search")))
            matches = self.by_file if search_type == "file_number" else self.by_cnic

            return {"ok": True, "data": matches, "meta": {"total": len(matches)}}

        if path == "proposals" and method == "POST":
            if self.fail_submit:
                raise AgentApiError(self.fail_submit)

            key = (body or {}).get("idempotency_key")

            for existing in self.submitted:
                if existing["idempotency_key"] == key:
                    return {"ok": True, "data": {"id": existing["id"], "status": "pending"}}

            record = dict(body or {})
            record["id"] = 900 + len(self.submitted) + 1
            self.submitted.append(record)

            return {"ok": True, "data": {"id": record["id"], "status": "pending",
                                         "risk_level": "medium"}}

        if path == "proposals" and method == "GET":
            return {"ok": True, "data": self.decisions}

        raise AssertionError(f"Unexpected call to {method} {path}")

    def decide(self, proposal_id: int, status: str, **extra) -> None:
        self.decisions = [
            {"id": proposal_id, "status": status,
             "updated_at": "2026-07-31T10:00:00+00:00", **extra}
        ]


@pytest.fixture
def statement(tmp_path) -> Path:
    path = tmp_path / "statement.pdf"
    path.write_bytes(b"%PDF-1.4 pretend bank statement")

    return path


def build(cms: FakeCms) -> WorkflowEngine:
    """A registry holding only what this workflow may use.

    No OCR tool, no classifier, no extractor, no memory. Not an economy — it is
    the assertion. A step that tried to read the document would fail with "no
    tool named ocr_document" rather than quietly working.
    """
    registry = ToolRegistry()
    registry.register(FindClientByIdentifierTool(cms))
    registry.register(SubmitFilingProposalTool(cms))

    return WorkflowEngine(registry)


def start(engine: WorkflowEngine, statement: Path, **context):
    return engine.start(
        BANK_STATEMENT_INTAKE,
        {
            "path": str(statement),
            "file_number": None,
            "cnic": None,
            "source": {
                "channel": "whatsapp",
                "message_id": "WA-MSG-1",
                "number": "923049637232",
                "received_at": "2026-07-31T09:14:22+00:00",
                "file_name": "statement.pdf",
                "file_size": 31,
                "mime_type": "application/pdf",
            },
            **context,
        },
    )


# ── Reading the caption ───────────────────────────────────────────────────


class TestIdentifiers:
    """The entire basis for whose record a forwarded document lands on."""

    @pytest.mark.parametrize(
        "caption",
        [
            "file 1420",
            "File 1420",
            "file no 1420",
            "file no. 1420",
            "file number 1420",
            "file#1420",
            "file: 1420",
            "f-1420",
            "Bank statement for file 1420 please",
        ],
    )
    def test_it_reads_a_labelled_file_number(self, caption):
        assert identifiers.read(caption).file_number == "1420"

    @pytest.mark.parametrize(
        "caption",
        [
            "1420",                    # a bare number is not a file number
            "sent on 12",              # a date
            "3 pages",                 # a count
            "Rs 1420 paid",            # an amount
            "2024 statement",          # a year
            "",
            None,
        ],
    )
    def test_an_unlabelled_number_identifies_nobody(self, caption):
        """The rule the whole module turns on.

        A file number has no recognisable shape — it is a bare integer — so
        taking any number in a caption would file a document against client 12
        because somebody mentioned a date. Requiring the word "file" costs one
        word; not requiring it costs a stranger's bank statement.
        """
        found = identifiers.read(caption)

        assert found.file_number is None
        assert not found.any

    def test_a_file_number_may_carry_a_single_letter_suffix(self):
        # "16 A" is a real file number in this practice. "16 August" is not.
        assert identifiers.read("file 16 A").file_number == "16 A"
        assert identifiers.read("file 16 August").file_number == "16"

    def test_leading_zeros_are_the_same_client(self):
        assert identifiers.read("file 0016").file_number == "16"

    @pytest.mark.parametrize(
        ("written", "expected"),
        [
            ("File No: TP-2026-00125", "TP-2026-00125"),
            ("file no tp-2026-00125", "TP-2026-00125"),
            ("File No: TP/2026/00125", "TP/2026/00125"),
            ("File No: ABC-123", "ABC-123"),
        ],
    )
    def test_a_structured_reference_is_a_file_number_too(self, written, expected):
        """The second numbering scheme, named in the intake rule.

        Kept verbatim apart from case: the parts of a reference carry meaning,
        so stripping or padding them would be inventing a scheme rather than
        reading the one somebody typed.
        """
        assert identifiers.read(written).file_number == expected

    def test_a_near_miss_list_does_not_hide_the_client_who_matches(self):
        """From the first real forward filed against nobody, 1 August 2026.

        The CMS searches file numbers by substring — rightly, for a person
        typing into a box. Asking it for 279 returned 3279, 2799, 2798 … ten
        near-misses and not one of them client 279, who existed. Ten unrelated
        rows read as an ambiguity, so the proposal reached the reviewer saying
        "you choose" between ten people with the wrong number.
        """
        from app.tools.client_tools import FindClientByIdentifierTool

        near_misses = [
            {"id": 1, "name": "A", "file_number": "3279"},
            {"id": 2, "name": "B", "file_number": "2799"},
            {"id": 3, "name": "C", "file_number": "2798"},
        ]
        exact = {"id": 138, "name": "The right one", "file_number": "279"}

        cms = FakeCms(by_file=[*near_misses, exact])
        found = FindClientByIdentifierTool(cms)._search("279", "file_number")

        assert found == [exact]

    def test_near_misses_survive_when_nothing_matches_exactly(self):
        # They are genuinely useful to the person deciding, and the result is
        # still ambiguous — which is the honest answer.
        from app.tools.client_tools import FindClientByIdentifierTool

        near_misses = [
            {"id": 1, "name": "A", "file_number": "3279"},
            {"id": 2, "name": "B", "file_number": "2799"},
        ]

        assert FindClientByIdentifierTool(FakeCms(by_file=near_misses))._search("279", "file_number") == near_misses

    def test_two_clients_sharing_a_file_number_stay_ambiguous(self):
        # A data problem in the CMS, and not this code's to resolve by picking.
        from app.tools.client_tools import FindClientByIdentifierTool

        duplicates = [
            {"id": 1, "name": "A", "file_number": "279"},
            {"id": 2, "name": "B", "file_number": "279"},
        ]
        found = FindClientByIdentifierTool(FakeCms(by_file=duplicates))._search("279", "file_number")

        assert len(found) == 2

    def test_a_letter_suffix_is_matched_as_written(self):
        # "16 A" compared as text. As an integer it would read as 16 and file
        # the document against a different client.
        from app.tools.client_tools import FindClientByIdentifierTool

        sixteen = {"id": 1, "name": "Plain", "file_number": "16"}
        sixteen_a = {"id": 2, "name": "Suffixed", "file_number": "16 A"}

        cms = FakeCms(by_file=[sixteen, sixteen_a])

        assert FindClientByIdentifierTool(cms)._search("16 A", "file_number") == [sixteen_a]
        assert FindClientByIdentifierTool(cms)._search("16", "file_number") == [sixteen]

    def test_a_reference_shape_still_needs_the_label(self):
        # The label is what makes any of this safe to read at all. Without it
        # an ordinary sentence would start naming clients.
        assert identifiers.read("TP-2026-00125").file_number is None

    def test_words_after_the_label_are_not_a_reference(self):
        # "file number for August" must not become client FOR-AUGUST.
        assert identifiers.read("file number for August").file_number is None
        assert identifiers.read("file no for this client").file_number is None

    @pytest.mark.parametrize(
        "caption",
        ["35202-1234567-1", "3520212345671", "cnic 35202-1234567-1"],
    )
    def test_it_reads_a_cnic_without_being_told(self, caption):
        # Unlike a file number, a CNIC states what it is by its shape: five,
        # seven, one. Nobody types that by accident.
        assert identifiers.read(caption).cnic == "3520212345671"

    def test_it_reads_both_when_both_are_written(self):
        found = identifiers.read("file 1420 cnic 35202-1234567-1")

        assert found.file_number == "1420"
        assert found.cnic == "3520212345671"
        assert found.both

    def test_a_caption_with_neither_identifies_nobody(self):
        assert not identifiers.read("here is the statement").any

    def test_describing_it_masks_the_cnic(self):
        # This goes to a log that is collected and kept. The number belongs to
        # the firm's client, not to the firm (ADR-0002).
        described = identifiers.read("35202-1234567-1").describe()

        assert "3520212345671" not in described
        assert "…5671" in described


# ── Finding the client ────────────────────────────────────────────────────


class TestFindingTheClient:
    def test_a_file_number_names_one_client(self, statement):
        cms = FakeCms()
        run = start(build(cms), statement, file_number="1420")

        assert run.context["identify"]["unambiguous"] is True
        assert run.context["identify"]["matches"] == [ALI]
        assert ("file_number", "1420") in cms.searches

    def test_a_cnic_names_one_client(self, statement):
        cms = FakeCms()
        run = start(build(cms), statement, cnic="3520212345671")

        assert run.context["identify"]["unambiguous"] is True
        assert ("cnic", "3520212345671") in cms.searches

    def test_both_identifiers_are_looked_up_not_just_the_preferred_one(self, statement):
        cms = FakeCms()
        start(build(cms), statement, file_number="1420", cnic="3520212345671")

        # Preferring the file number and skipping the second search would throw
        # away the one piece of evidence capable of showing the caption is wrong.
        assert {t for t, _ in cms.searches} == {"file_number", "cnic"}

    def test_two_identifiers_agreeing_is_recorded_as_agreement(self, statement):
        run = start(build(FakeCms()), statement, file_number="1420", cnic="3520212345671")
        identifier = run.context["identify"]["identifier"]

        assert identifier["both_supplied"] is True
        assert identifier["agreed"] is True
        assert run.context["identify"]["unambiguous"] is True

    def test_two_identifiers_naming_two_people_choose_neither(self, statement):
        cms = FakeCms(by_file=[ALI], by_cnic=[FATIMA])
        run = start(build(cms), statement, file_number="1420", cnic="3520212345671")

        identify = run.context["identify"]

        # Never guess. The file number is not "probably right" here — the
        # contradiction is itself the finding.
        assert identify["conflict"] is True
        assert identify["unambiguous"] is False
        assert identify["identifier"]["agreed"] is False
        assert {m["id"] for m in identify["matches"]} == {42, 77}

    def test_the_file_number_wins_when_only_it_matches(self, statement):
        cms = FakeCms(by_file=[ALI], by_cnic=[])
        run = start(build(cms), statement, file_number="1420", cnic="3520212345671")

        assert run.context["identify"]["matches"] == [ALI]
        assert run.context["identify"]["identifier"]["type"] == "file_number"

    def test_the_cnic_is_used_when_the_file_number_matches_nothing(self, statement):
        cms = FakeCms(by_file=[], by_cnic=[ALI])
        run = start(build(cms), statement, file_number="9999", cnic="3520212345671")

        assert run.context["identify"]["matches"] == [ALI]
        assert run.context["identify"]["identifier"]["type"] == "cnic"

    def test_several_clients_on_one_identifier_choose_none(self, statement):
        cms = FakeCms(by_file=[ALI, FATIMA])
        run = start(build(cms), statement, file_number="14")

        # Two clients sharing a file number is a data problem in the CMS, not a
        # reason to pick one of them.
        assert run.context["identify"]["unambiguous"] is False
        assert len(run.context["identify"]["matches"]) == 2


# ── The proposal ──────────────────────────────────────────────────────────


class TestTheProposal:
    def test_a_statement_is_proposed_not_filed(self, statement):
        cms = FakeCms()
        run = start(build(cms), statement, file_number="1420")

        assert run.state is RunState.AWAITING_APPROVAL
        assert len(cms.submitted) == 1

    def test_nothing_read_the_document(self, statement):
        cms = FakeCms()
        start(build(cms), statement, file_number="1420")

        evidence = cms.submitted[0]["evidence"]

        # The assertion this whole workflow exists to make. A bank statement's
        # contents are Level 3 (ADR-0002): not read here, not extracted, not
        # sent anywhere, not stored as evidence.
        assert evidence == {"read_attempted": False}
        assert "text" not in evidence
        assert "fields" not in evidence

    def test_it_files_as_a_bank_statement_at_full_confidence(self, statement):
        cms = FakeCms()
        start(build(cms), statement, file_number="1420")

        submitted = cms.submitted[0]

        assert submitted["payload"]["document_type"] == "bank_statement"
        assert submitted["payload"]["title"] == "Bank Statement"
        # Not a model's estimate. Nothing inferred anything — a person typed an
        # identifier and exactly one client holds it.
        assert submitted["confidence"] == 1.0

    def test_the_client_is_named_when_the_identifier_was_unambiguous(self, statement):
        cms = FakeCms()
        start(build(cms), statement, file_number="1420")

        assert cms.submitted[0]["payload"]["client_id"] == 42

    def test_the_reason_says_the_client_was_supplied(self, statement):
        cms = FakeCms()
        start(build(cms), statement, file_number="1420")

        candidate = cms.submitted[0]["payload"]["candidates"][0]

        assert candidate["match_reason"] == "Matched using user supplied identifier"

    def test_it_carries_where_the_document_came_from(self, statement):
        cms = FakeCms()
        start(build(cms), statement, file_number="1420")

        source = cms.submitted[0]["payload"]["source"]

        assert source["channel"] == "whatsapp"
        assert source["message_id"] == "WA-MSG-1"
        assert source["number"] == "923049637232"
        assert source["mime_type"] == "application/pdf"

    def test_it_carries_which_identifier_was_used(self, statement):
        cms = FakeCms()
        start(build(cms), statement, file_number="1420")

        identifier = cms.submitted[0]["payload"]["identifier"]

        assert identifier["type"] == "file_number"
        assert identifier["value"] == "1420"

    def test_an_unmatched_identifier_still_reaches_a_reviewer(self, statement):
        cms = FakeCms(by_file=[])
        run = start(build(cms), statement, file_number="9999")

        # Failing the run would throw the forward away, and the person who sent
        # it would see nothing happen with nothing to look at.
        assert run.state is RunState.AWAITING_APPROVAL
        assert cms.submitted[0]["payload"]["client_id"] is None
        assert any("No client matched" in w for w in cms.submitted[0]["warnings"])

    def test_a_conflict_is_stated_in_the_warnings(self, statement):
        cms = FakeCms(by_file=[ALI], by_cnic=[FATIMA])
        start(build(cms), statement, file_number="1420", cnic="3520212345671")

        assert any(
            "identify different clients" in w for w in cms.submitted[0]["warnings"]
        )

    def test_a_conflict_says_which_identifier_produced_which_name(self, statement):
        cms = FakeCms(by_file=[ALI], by_cnic=[FATIMA])
        start(build(cms), statement, file_number="1420", cnic="3520212345671")

        reasons = {c["name"]: c["match_reason"] for c in cms.submitted[0]["payload"]["candidates"]}

        # The entire content of the disagreement the reviewer is being asked to
        # settle.
        assert reasons["Muhammad Ali"] == "Matched the file number in the message"
        assert reasons["Fatima Khan"] == "Matched the CNIC in the message"

    def test_the_history_records_when_it_arrived(self, statement):
        cms = FakeCms()
        start(build(cms), statement, file_number="1420")

        events = {e["event"]: e for e in cms.submitted[0]["events"]}

        # The proposal's own timestamp is when the run finished, not when the
        # message was sent — and "when did this come in?" is asked about the
        # second one.
        assert events["received"]["occurred_at"] == "2026-07-31T09:14:22+00:00"
        assert "identified" in events

    def test_no_event_message_carries_the_identifier(self, statement):
        cms = FakeCms()
        start(build(cms), statement, cnic="3520212345671")

        for event in cms.submitted[0]["events"]:
            assert "3520212345671" not in (event.get("message") or "")


class TestWhenThingsGoWrong:
    def test_an_unreachable_cms_fails_the_run_rather_than_proposing_blindly(self, statement):
        class Unreachable(FakeCms):
            def _request(self, method, path, params=None, body=None):  # noqa: ANN001
                if path == "clients":
                    raise AgentApiError("Connection refused")

                return super()._request(method, path, params, body)

        cms = Unreachable()
        run = start(build(cms), statement, file_number="1420")

        # A network problem is not a finding about this document. Failing leaves
        # the run retryable with the file on disk; proposing with no client would
        # make a person sort out a connection fault by hand.
        assert run.state is RunState.FAILED
        assert cms.submitted == []

    def test_a_refused_submission_does_not_claim_a_filing(self, statement):
        cms = FakeCms(fail_submit="CMS unavailable")
        run = start(build(cms), statement, file_number="1420")

        assert run.state is RunState.FAILED

    def test_a_lookup_with_nothing_to_look_up_searches_for_nothing(self, statement):
        """This asserted the run FAILED, and its own comment said why that was
        safe: "unreachable from the inbox, which routes on an identifier
        existing".

        That stopped being true when the router began deciding from the
        classification — a statement recognised by filename or text arrives here
        with no caption at all — and real traffic on staging then lost a
        document to it.

        The protection the refusal existed for is kept: nothing is searched, so
        an empty search cannot return the whole client base. What changed is
        that the document reaches a person instead of vanishing.
        """
        cms = FakeCms()
        run = start(build(cms), statement)

        assert run.state is RunState.AWAITING_APPROVAL
        assert cms.searches == []
        assert cms.submitted[0]["payload"]["client_id"] is None


# ── Coming back from the reviewer ─────────────────────────────────────────


class TestDecisions:
    def poller(self, cms: FakeCms, engine: WorkflowEngine) -> DecisionPoller:
        # Both workflows, as the deployment wires it. A poller holding one would
        # step a forwarding run through the reading workflow's sequence.
        return DecisionPoller(cms, engine, [DOCUMENT_INTAKE, BANK_STATEMENT_INTAKE])

    def test_an_approved_statement_completes_the_run(self, statement):
        cms = FakeCms()
        engine = build(cms)
        run = start(engine, statement, file_number="1420")

        cms.decide(cms.submitted[0]["id"], "executed", result_id=555)
        self.poller(cms, engine).poll()

        assert engine.store.load(run.id).state is RunState.COMPLETED

    def test_a_rejected_statement_ends_the_run(self, statement):
        cms = FakeCms()
        engine = build(cms)
        run = start(engine, statement, file_number="1420")

        cms.decide(cms.submitted[0]["id"], "rejected", decision_note="Not our client.")
        self.poller(cms, engine).poll()

        assert engine.store.load(run.id).state is RunState.FAILED

    def test_asking_for_another_look_redoes_the_client_search(self, statement):
        cms = FakeCms()
        engine = build(cms)
        run = start(engine, statement, file_number="1420")

        cms.decide(cms.submitted[0]["id"], "changes_requested",
                   directive="rerun_client_search", decision_note="Check the number.")
        self.poller(cms, engine).poll()

        assert len(cms.submitted) == 2
        assert cms.submitted[1]["idempotency_key"] != cms.submitted[0]["idempotency_key"]
        assert engine.store.load(run.id).state is RunState.AWAITING_APPROVAL

    def test_a_directive_this_workflow_has_no_step_for_still_works(self, statement):
        """The reviewer asked for something this workflow does not do.

        "Read the document again" names a step that exists in document intake
        and never will here. Sending the run back to a step it has no name for
        fails it with "no such step" — which turns a reasonable question into a
        lost document. It rewinds to the first step instead, which for this
        workflow is the only thing there is to do again.
        """
        cms = FakeCms()
        engine = build(cms)
        run = start(engine, statement, file_number="1420")

        cms.decide(cms.submitted[0]["id"], "changes_requested", directive="rerun_ocr")
        self.poller(cms, engine).poll()

        assert engine.store.load(run.id).state is RunState.AWAITING_APPROVAL
        assert len(cms.submitted) == 2


class TestADocumentIsNeverLost:
    """The regression real traffic on staging found.

    Since the router began deciding from the classification, this workflow is no
    longer reached only by a captioned forward — a statement recognised from its
    filename or its text arrives with no caption at all. It had nothing to
    search on, the identify step FAILED, and the run failed with it: no
    proposal, no queue entry, nothing for anybody to look at.

    A document that cannot be attributed must still reach a person. That is the
    rule every other path already followed.
    """

    def test_a_statement_with_no_identifier_still_reaches_a_reviewer(self, statement):
        cms = FakeCms()
        engine = build(cms)

        run = engine.start(
            BANK_STATEMENT_INTAKE,
            {"path": str(statement), "file_number": None, "cnic": None, "source": {}},
        )

        assert run.state is RunState.AWAITING_APPROVAL
        assert cms.submitted[0]["payload"]["client_id"] is None
        assert any("No client matched" in w for w in cms.submitted[0]["warnings"])

    def test_nothing_is_searched_for_when_nothing_was_supplied(self, statement):
        # The protection the old refusal existed for: an empty search returns
        # the whole client base. Achieved by not searching, rather than by
        # throwing the document away.
        cms = FakeCms()

        build(cms).start(
            BANK_STATEMENT_INTAKE,
            {"path": str(statement), "file_number": None, "cnic": None, "source": {}},
        )

        assert cms.searches == []

    def test_the_number_it_arrived_from_is_used_as_a_last_resort(self, statement):
        cms = FakeCms()

        run = start(engine := build(cms), statement, sender="923001234567")

        assert run.state is RunState.AWAITING_APPROVAL
        assert ("mobile", "923001234567") in cms.searches
        # A phone is a suggestion, never an answer — so still nothing chosen.
        assert cms.submitted[0]["payload"]["client_id"] is None
