"""What the CMS is told while a document is being worked on (ADR-0010).

Registration made a document visible. This is about keeping what is shown true:
a record stuck at Received while OCR runs, or at Processing forever because the
agent died mid-read, is the CMS showing something that stopped being the case —
which is a quieter version of the same failure.

The asymmetry between registering and reporting is deliberate and tested here.
Registration blocks, because a document the CMS has never heard of is invisible.
A status push does not, because a stale record is worse than accurate and far
better than absent.
"""

from __future__ import annotations

import pytest

from app.intake import InMemoryOutbox
from app.whatsapp.inbox import InboxFilter
from app.whatsapp.messages import InboundMessage, MediaReference, MessageType
from app.whatsapp.pending import InMemoryPendingPdfs
from app.whatsapp.webhook import InboundQueue
from tests.intake_support import RecordingCms

OWNER = "923049637232"


class TrackingCms(RecordingCms):
    """Remembers the status pushes as well as the registrations."""

    def __init__(self) -> None:
        super().__init__()
        self.pushes: list[tuple[str, str | None]] = []
        self.unfinished: list[dict] = []

    def update_intake(self, reference, **fields):
        self.pushes.append((reference, fields.get("status")))

        return {"reference": reference, **fields}

    def pending_intake(self, status=None, per_page=None):
        # Filters like the real endpoint does, so a test cannot pass by being
        # handed records the CMS would never have sent.
        records = [r for r in self.unfinished if status is None or r.get("status") == status]

        return records, False

    @property
    def statuses(self) -> list[str | None]:
        return [status for _, status in self.pushes]


@pytest.fixture
def container(tmp_path):
    class FakeContainer:
        inbound_queue = InboundQueue()
        incoming_dir = tmp_path
        inbox = InboxFilter()
        pending_pdfs = InMemoryPendingPdfs()
        model = None
        cms = TrackingCms()
        intake_outbox = InMemoryOutbox()

        class whatsapp:
            @staticmethod
            def download_media(media, destination):
                destination.write_bytes(b"%PDF-1.4 statement")

                return destination

    return FakeContainer


def document(message_id="msg-1", filename="Account Statement.pdf"):
    return InboundMessage(
        provider_message_id=message_id,
        sender=OWNER,
        type=MessageType.DOCUMENT,
        text="",
        media=MediaReference(
            handle="h", mime_type="application/pdf", filename=filename, size_bytes=6910
        ),
    )


def run_that(state: str, *, skipped_filing: bool = False):
    from app.workflow.state import RunState, StepState

    class Step:
        name = "file"
        state = StepState.SKIPPED if skipped_filing else StepState.SUCCEEDED

    class Run:
        id = "run-1"
        steps = [Step()]

    Run.state = RunState(state)

    return Run()


class TestReportingWhereADocumentGot:
    def test_a_finished_run_is_reported_as_completed(self, container):
        from app import __main__ as entry

        entry._take_in(container, document())
        entry._push_outcome(container, entry._REFERENCES.get("msg-1"), run_that("completed"), "document_intake")

        assert container.cms.statuses == ["completed"]

    def test_a_failed_run_is_reported_as_failed(self, container):
        from app import __main__ as entry

        entry._take_in(container, document())
        entry._push_outcome(container, entry._REFERENCES.get("msg-1"), run_that("failed"), "document_intake")

        assert container.cms.statuses == ["failed"]

    def test_a_proposal_awaiting_a_reviewer_is_reported_as_approval(self, container):
        from app import __main__ as entry

        entry._take_in(container, document())
        entry._push_outcome(container, entry._REFERENCES.get("msg-1"), run_that("awaiting_approval"), "document_intake")

        assert container.cms.statuses == ["approval"]

    def test_a_run_that_named_nobody_is_waiting_not_approval(self, container):
        from app import __main__ as entry

        entry._take_in(container, document())
        entry._push_outcome(
            container,
            "msg-1",
            run_that("awaiting_approval", skipped_filing=True),
            "document_intake",
        )

        # It read the document, named nobody and submitted nothing. Calling that
        # Awaiting approval sends a reviewer to a queue where they would open it
        # and find there is nothing to decide.
        assert container.cms.statuses == ["waiting"]

    def test_a_run_still_going_reports_nothing(self, container):
        from app import __main__ as entry

        entry._take_in(container, document())
        entry._push_outcome(container, entry._REFERENCES.get("msg-1"), run_that("running"), "document_intake")

        assert container.cms.statuses == []

    def test_an_unregistered_document_reports_nothing(self, container):
        from app import __main__ as entry

        # No reference: the document was never registered, so there is nothing
        # to report against. The callers get None from _reference() for exactly
        # this case.
        entry._push_outcome(container, None, run_that("completed"), "x")

        assert container.cms.statuses == []


class TestAFailedPushDoesNotStopTheWork:
    def test_the_document_still_proceeds(self, container):
        from app import __main__ as entry

        class Refusing(TrackingCms):
            def update_intake(self, reference, **fields):
                raise ConnectionError("connection refused")

        container.cms = Refusing()

        path = entry._take_in(container, document())

        assert path is not None

        # A status push that fails leaves the record stale, which is bad. Failing
        # the document because of it would be worse — the record exists and the
        # work is real, so the work goes on.
        assert entry._push(container, "INT-1001", "processing") is False


class TestRecoveringAfterACrash:
    def test_a_document_left_processing_is_marked_failed(self, container):
        from app import __main__ as entry

        # What the CMS holds after the agent died mid-read: Processing, with
        # nothing behind it, and no way to tell that from work in progress.
        container.cms.unfinished = [
            {"reference": "INT-1042", "status": "processing"},
            {"reference": "INT-1043", "status": "waiting"},
        ]

        entry._recover_in_flight(container)

        # Only the stranded one. Waiting is a real state somebody may still
        # answer, and rewriting it would destroy a document's only route forward.
        assert container.cms.pushes == [("INT-1042", "failed")]

    def test_it_says_the_document_was_not_lost(self, container):
        from app import __main__ as entry

        pushed: list[dict] = []

        class Capturing(TrackingCms):
            def update_intake(self, reference, **fields):
                pushed.append(fields)

                return {"reference": reference}

        container.cms = Capturing()
        container.cms.unfinished = [{"reference": "INT-1042", "status": "processing"}]

        entry._recover_in_flight(container)

        assert "not lost" in pushed[0]["reason"]

    def test_nothing_stranded_means_nothing_to_do(self, container):
        from app import __main__ as entry

        container.cms.unfinished = [{"reference": "INT-1", "status": "waiting"}]

        entry._recover_in_flight(container)

        assert container.cms.pushes == []

    def test_a_cms_that_cannot_be_asked_does_not_stop_startup(self, container):
        from app import __main__ as entry

        class Silent(TrackingCms):
            def pending_intake(self, status=None, per_page=None):
                raise ConnectionError("connection refused")

        container.cms = Silent()

        # Must not raise: an agent that refuses to boot because the CMS is down
        # cannot process the backlog when it comes back.
        entry._recover_in_flight(container)


class TestWhatAFailedRunTellsSomebody:
    """"The workflow failed" was true and useless.

    A reviewer opening the Intake Center could not tell "this image is corrupt,
    ask the client to resend it" from "the agent had a bad afternoon, press
    retry". The first needs a phone call and the second needs a click, and the
    record could not distinguish them.
    """

    def _failed_run(self, error):
        from app.workflow.state import RunState, StepState

        class Step:
            name = "read"
            state = StepState.FAILED

        class Run:
            id = "run-1"
            steps = [Step()]

        Run.state = RunState.FAILED
        Run.error = error

        return Run()

    def test_the_run_s_own_error_reaches_the_record(self, container):
        from app import __main__ as entry

        entry._take_in(container, document())
        entry._push_outcome(
            container,
            entry._REFERENCES.get("msg-1"),
            self._failed_run("PaddleOCR failed: Image read Error"),
            "document_intake",
        )

        assert container.cms.pushes[-1][1] == "failed"

    def test_only_the_first_line_is_kept(self):
        from app import __main__ as entry

        reason = entry._why_it_failed(self._failed_run(
            "PaddleOCR failed: Image read Error\n"
            "Traceback (most recent call last):\n"
            '  File "engine.py", line 88, in read\n'
        ))

        # The rest describes this system's internals to somebody who wants to
        # know about their client's document.
        assert reason == "PaddleOCR failed: Image read Error"

    def test_a_very_long_error_is_cut_to_something_readable(self):
        from app import __main__ as entry

        reason = entry._why_it_failed(self._failed_run("x" * 900))

        assert len(reason) <= entry._REASON_LIMIT
        assert reason.endswith("…")

    def test_a_run_that_recorded_nothing_says_so(self):
        from app import __main__ as entry

        # Saying "no reason was recorded" beats inventing a cause, and beats the
        # old text, which implied one had been established.
        assert "without recording a reason" in entry._why_it_failed(self._failed_run(None))
        assert "without recording a reason" in entry._why_it_failed(self._failed_run("   "))


class TestItNamesTheCauseNotTheSymptom:
    """A workflow keeps going after a step fails, so the run's own error is the
    last thing that went wrong rather than the first.

    Measured on a corrupt photograph: the reader could not decode it, the run
    carried on, and the filing step failed for want of a client. The record said
    "nothing identifies a client" — so a reviewer would have gone looking for the
    client by hand and never learned the image was unreadable.
    """

    def _run_with(self, step_errors, run_error=None):
        from app.workflow.state import RunState, StepState

        steps = []

        for name, error in step_errors:
            step = type("Step", (), {})()
            step.name = name
            step.state = StepState.FAILED if error else StepState.SUCCEEDED
            step.error = error
            step.output = {}
            steps.append(step)

        run = type("Run", (), {})()
        run.id = "run-1"
        run.state = RunState.FAILED
        run.error = run_error
        run.steps = steps

        return run

    def test_the_first_failing_step_is_the_one_reported(self):
        from app import __main__ as entry

        run = self._run_with(
            [
                ("read", "PaddleOCR failed: Image read Error"),
                ("classify", None),
                ("file", "Could not build input: Nothing identifies a client."),
            ],
            run_error="Could not build input: Nothing identifies a client.",
        )

        # The cause, not its consequence. This is the sentence that tells somebody
        # to ask the client to resend the photograph.
        assert entry._why_it_failed(run) == "PaddleOCR failed: Image read Error"

    def test_the_run_error_is_used_when_no_step_recorded_one(self):
        from app import __main__ as entry

        run = self._run_with([("read", None)], run_error="The workflow could not be built.")

        assert entry._why_it_failed(run) == "The workflow could not be built."

    def test_a_run_with_no_steps_at_all_falls_back(self):
        from app import __main__ as entry

        run = self._run_with([], run_error="Nothing to run.")

        assert entry._why_it_failed(run) == "Nothing to run."

    def test_nothing_recorded_anywhere_says_so(self):
        from app import __main__ as entry

        assert "without recording a reason" in entry._why_it_failed(self._run_with([("read", None)]))

    def test_only_the_first_line_of_the_cause_is_kept(self):
        from app import __main__ as entry

        run = self._run_with([("read", "Image read Error\nTraceback:\n  File engine.py")])

        assert entry._why_it_failed(run) == "Image read Error"


class TestAnUnreadableDocumentIsNotABlankOne:
    """The reader is tolerant on purpose, and that hid the cause.

    A document nobody could read must still reach a person, so failing the read
    step would end the workflow instead of queueing it. But a tolerant step that
    records nothing leaves a corrupt photograph and a blank page identical: read
    ok, zero characters, failed later at identify for want of a client. They need
    opposite responses — resend it, or accept the page is empty.
    """

    def _run(self, read_output, later_error):
        from app.workflow.state import RunState, StepState

        def step(name, state, error=None, output=None):
            s = type("Step", (), {})()
            s.name, s.state, s.error, s.output = name, state, error, output or {}

            return s

        run = type("Run", (), {})()
        run.id = "run-1"
        run.state = RunState.FAILED
        run.error = later_error
        run.steps = [
            step("read", StepState.SUCCEEDED, output=read_output),
            step("identify", StepState.FAILED, error=later_error),
        ]

        return run

    def test_a_failed_read_is_named_over_the_later_symptom(self):
        from app import __main__ as entry

        run = self._run(
            {"readable": False, "text": "", "failure": "The document could not be read (Exception)."},
            "Could not build input: Nothing identifies a client.",
        )

        # This is the whole point: the reviewer is told to look at the file, not
        # to go hunting for a client that was never findable.
        assert entry._why_it_failed(run) == "The document could not be read (Exception)."

    def test_a_genuinely_blank_page_still_reports_the_real_failure(self):
        from app import __main__ as entry

        run = self._run(
            {"readable": False, "text": "", "failure": None},
            "Could not build input: Nothing identifies a client.",
        )

        # Nothing went wrong with the reading. The document was read and had
        # nothing on it, so the failure genuinely is the missing client.
        assert entry._why_it_failed(run) == "Could not build input: Nothing identifies a client."


class TestTheTwoRecordsAreLinked:
    """A completed document must stop saying it is awaiting a reviewer.

    The intake record and its proposal never knew about one another: the agent
    pushed status and never sent the proposal's reference, though the endpoint
    has accepted one since contract 1.1. So the self-healer, which corrects an
    intake from its proposal's status, had nothing to match — and every document
    that filed successfully sat at "awaiting a reviewer" for ever with its file
    already on the client's record.

    Found by running one document end to end, which nothing before had done.
    """

    def _run(self, proposal_reference):
        from app.workflow.state import RunState, StepState

        step = type("Step", (), {})()
        step.name = "file"
        step.state = StepState.AWAITING_APPROVAL
        step.error = None
        step.output = {"proposal_id": 65, "proposal_reference": proposal_reference}

        run = type("Run", (), {})()
        run.id = "run-1"
        run.state = RunState.AWAITING_APPROVAL
        run.error = None
        run.steps = [step]

        return run

    def test_the_proposal_reference_is_sent_with_the_outcome(self, container):
        from app import __main__ as entry

        sent = {}

        class Capturing(RecordingCms):
            def update_intake(self, reference, **fields):
                sent.update(fields)

                return {"reference": reference}

        entry._take_in(container, document())
        container.cms = Capturing()

        entry._push_outcome(
            container, entry._REFERENCES.get("msg-1"), self._run("DOC-1065"), "document_intake",
        )

        # This is what lets the self-healer bring the intake record into line when
        # a reviewer approves.
        assert sent["proposal_reference"] == "DOC-1065"

    def test_a_run_that_proposed_nothing_sends_no_reference(self):
        from app import __main__ as entry

        # A document that named nobody, or one that failed before proposing.
        # None is the right answer, not an error.
        assert entry._proposal_reference(self._run(None)) is None
        assert entry._proposal_reference(type("Run", (), {"steps": []})()) is None

    def test_it_is_read_from_the_step_that_submitted(self):
        from app import __main__ as entry

        assert entry._proposal_reference(self._run("DOC-1099")) == "DOC-1099"
