"""One document, all the way through, across the real boundary.

WHY THIS EXISTS WHEN BOTH SIDES ARE ALREADY WELL TESTED

Because neither side has ever spoken to the other.

`test_document_intake.py` drives the whole workflow against `FakeCms`, a
subclass that answers without HTTP. The CMS's own tests build proposal payloads
by hand. Both are thorough, both pass, and between them sits a boundary nothing
crosses: two languages, two JSON encoders, two date libraries, two ideas of what
a timezone is.

Every integration bug this project has had lived exactly there:

  * agent timestamps arriving five hours early, because a UTC ISO-8601 string
    was read as Asia/Karachi wall-clock
  * `+05:00` in a query string decoding to a space, so the decision poller
    silently returned nothing — indistinguishable from a quiet queue
  * a signature computed over a re-encoded body rather than the raw bytes

None of those could be caught by either suite alone. This is the test that
catches that class.

WHAT IT DOES

Runs the real OCR engine over a real image, submits over real HTTP with a real
HMAC signature to a real Laravel application on a real MySQL database, has a
human approve it through the real review service, and polls the real decision
endpoint until the run finishes — then asserts the document was actually filed,
the client timeline recorded it, and the activity log names who did it.

HOW TO RUN IT

Skipped unless configured, like the PostgreSQL contract tests. It needs a CMS
that is running, so it cannot be a default.

    # once
    mysql -e "CREATE DATABASE life_associate_e2e"
    cd <cms>
    DB_DATABASE=life_associate_e2e php artisan migrate --force
    DB_DATABASE=life_associate_e2e php artisan ai:e2e-provision > fixture.json
    DB_DATABASE=life_associate_e2e php artisan serve --port=8931

    # then
    TAXPILOT_E2E_CMS=http://127.0.0.1:8931 \
    TAXPILOT_E2E_FIXTURE=<path>/fixture.json \
    TAXPILOT_E2E_CMS_PATH="<cms>" \
    pytest tests/test_end_to_end.py -v

Both artisan commands refuse to run against anything but a database whose name
ends `_e2e` — see ProvisionE2eFixture for why that guard is the database name
and not APP_ENV.
"""

from __future__ import annotations

import json
import os
import subprocess
from pathlib import Path

import pytest

pytestmark = pytest.mark.e2e

#: `tests/fixtures/cnic.jpg` is not an image — it is 23 bytes of the literal
#: text "pretend-image-bytes", which is all the fake-OCR tests ever needed. The
#: real engine refuses it, correctly, with "Image read Error".
#:
#: So the document is rendered here instead, carrying the CNIC of the client the
#: harness provisioned. That is better than a photograph anyway: what the page
#: says is known exactly, so a failure to find the client is a failure of
#: extraction or search, never an argument about whether the image was legible.


def _configured() -> bool:
    return bool(os.environ.get("TAXPILOT_E2E_CMS") and os.environ.get("TAXPILOT_E2E_FIXTURE"))


pytest.importorskip("paddleocr", reason="the end-to-end test reads a real document")

if not _configured():  # pragma: no cover - the ordinary case
    pytest.skip(
        "Set TAXPILOT_E2E_CMS and TAXPILOT_E2E_FIXTURE to run the end-to-end test.",
        allow_module_level=True,
    )


@pytest.fixture(scope="module")
def document(tmp_path_factory, fixture) -> Path:
    """A CNIC-like document naming the provisioned client."""
    Image = pytest.importorskip("PIL.Image", reason="Pillow is not installed")
    ImageDraw = pytest.importorskip("PIL.ImageDraw")

    path = tmp_path_factory.mktemp("e2e") / "cnic.png"
    image = Image.new("RGB", (900, 300), "white")
    draw = ImageDraw.Draw(image)

    # Drawn large: PIL's default bitmap font renders below the detector's
    # minimum text height, which would make this a test of the fixture.
    for y, line in (
        (60, "NATIONAL IDENTITY CARD"),
        (130, f"Name: {fixture['client_name']}"),
        (200, f"Identity Number: {fixture['client_cnic']}"),
    ):
        draw.text((40, y), line, fill="black", font_size=34)

    image.save(path)

    return path


@pytest.fixture(scope="module")
def fixture() -> dict:
    return json.loads(Path(os.environ["TAXPILOT_E2E_FIXTURE"]).read_text(encoding="utf-8"))


@pytest.fixture(scope="module")
def cms(fixture):
    """A real signed client against the running CMS."""
    from app.api.client import CmsClient
    from app.config.settings import Settings

    return CmsClient(
        Settings(
            os.environ["TAXPILOT_E2E_CMS"],
            fixture["agent_key"],
            fixture["agent_secret"],
        )
    )


def artisan(*arguments: str) -> dict:
    """Play the reviewer, through the CMS's own console."""
    root = os.environ.get("TAXPILOT_E2E_CMS_PATH")

    if not root:
        pytest.skip("Set TAXPILOT_E2E_CMS_PATH to the CMS directory.")

    environment = dict(os.environ)
    environment["DB_DATABASE"] = "life_associate_e2e"

    completed = subprocess.run(  # noqa: S603 - fixed argv, operator-configured root
        [environment.get("TAXPILOT_E2E_PHP", "php"), "artisan", *arguments],
        cwd=root,
        env=environment,
        capture_output=True,
        text=True,
        timeout=300,
        check=False,
    )

    if completed.returncode != 0:
        raise AssertionError(f"artisan {' '.join(arguments)} failed:\n{completed.stdout}\n{completed.stderr}")

    start = completed.stdout.index("{")

    return json.loads(completed.stdout[start:])


# ── The handshake ─────────────────────────────────────────────────────────


class TestTheConnection:
    def test_the_agent_can_authenticate(self, cms):
        """A real HMAC over real bytes, verified by PHP.

        Everything below depends on this, and it is the step that has broken
        before — a signature computed over a re-encoded body rather than the
        raw one verifies in neither direction.
        """
        assert cms.whoami()["name"]

    def test_the_two_clocks_agree(self, cms):
        """A signature is refused outside a 300s window, so a drifting clock
        looks exactly like a forged request."""
        from datetime import UTC, datetime

        reported = cms.whoami()["server_time"]
        skew = abs((datetime.now(UTC) - datetime.fromisoformat(reported)).total_seconds())

        assert skew < 120, f"the clocks are {skew:.0f}s apart"

    def test_the_contract_is_understood(self, cms):
        from app.api.compatibility import Level

        verdict = cms.handshake()

        assert verdict.level is not Level.INCOMPATIBLE, verdict.detail

    def test_the_vocabulary_comes_from_the_installation(self, cms):
        """Fetched rather than hard-coded: a classifier inventing a type
        produces a failed upload instead of a filed document."""
        assert cms.document_types()

    def test_the_provisioned_client_is_findable(self, cms, fixture):
        found = cms.search_clients(cnic=fixture["client_cnic"])

        assert [c["id"] for c in found["data"]] == [fixture["client_id"]]


# ── The whole path ────────────────────────────────────────────────────────


class TestOneDocumentAllTheWay:
    """WhatsApp → OCR → classify → extract → find the client → propose →
    approve → file → timeline → activity log → run complete."""

    @pytest.fixture(scope="class")
    def submitted(self, cms, fixture, document) -> dict:
        """Run the real intake workflow, with the real engine, against the real CMS."""
        from app.memory.repository import InMemoryRepository
        from app.ocr.config import OcrConfig
        from app.ocr.paddle import PaddleOcrEngine
        from app.tools.client_tools import SearchClientTool
        from app.tools.document_tools import (
            ClassifyDocumentTool,
            ExtractFieldsTool,
            OcrTool,
            SubmitFilingProposalTool,
        )
        from app.tools.memory_tools import RememberTool
        from app.tools.registry import ToolRegistry
        from app.workflow.document_intake import DOCUMENT_INTAKE
        from app.workflow.engine import WorkflowEngine

        memory = InMemoryRepository()
        registry = ToolRegistry()
        registry.register(OcrTool(PaddleOcrEngine(OcrConfig())))
        registry.register(ClassifyDocumentTool())
        registry.register(ExtractFieldsTool())
        registry.register(SearchClientTool(cms))
        registry.register(SubmitFilingProposalTool(cms))
        registry.register(RememberTool(memory))

        engine = WorkflowEngine(registry)
        run = engine.start(DOCUMENT_INTAKE, {"path": str(document), "sender": "923001234567"})

        return {"run": run, "engine": engine, "memory": memory}

    def test_the_document_was_actually_read(self, submitted):
        """The real engine on a real image. A test that stubs OCR proves the
        plumbing and nothing about whether this platform can read a CNIC."""
        from app.workflow.state import StepState

        assert submitted["run"].step("read").state is StepState.SUCCEEDED
        assert submitted["run"].context["read"]["text"].strip()

    def test_it_was_classified(self, submitted):
        assert submitted["run"].context["classify"]["document_type"]

    def test_the_client_was_found_from_the_document(self, submitted, fixture):
        """Extraction, the search query, and the CMS's own visibility scope, all
        working together over HTTP. The CNIC on the image is the provisioned
        client's."""
        # `matches`, which is what SearchClientTool returns. It also reports
        # `unambiguous`, because picking the first hit silently is how a
        # document ends up filed against the wrong client.
        identified = submitted["run"].step("identify").output
        matches = identified.get("matches") or []

        assert fixture["client_id"] in [m.get("id") for m in matches], identified
        assert identified.get("unambiguous") is True

    def test_a_proposal_reached_the_cms_and_nothing_was_filed(self, submitted, cms):
        """The whole design in one assertion (ADR-0008): everything up to the
        write happened autonomously, and the write did not happen."""
        from app.workflow.state import RunState

        assert submitted["run"].state is RunState.AWAITING_APPROVAL

        proposal_id = submitted["run"].step("file").output["proposal_id"]
        proposal = cms.get_proposal(proposal_id)

        assert proposal["status"] == "pending"
        assert not proposal.get("document_id")

        submitted["proposal_id"] = proposal_id

    def test_approving_files_the_document(self, submitted):
        """The step no unit test on either side can reach: a reviewer approves
        in the CMS, and a document appears against the right client."""
        assert "proposal_id" in submitted, "the proposal step must run first"

        result = artisan("ai:e2e-decide", str(submitted["proposal_id"]))

        assert result["succeeded"], result["message"]
        assert result["document_exists"], "approval reported success but filed nothing"

        submitted["decision"] = result

    def test_it_was_filed_against_the_right_client(self, submitted, fixture):
        assert submitted["decision"]["document_client"] == fixture["client_id"]

    def test_the_client_timeline_records_it(self, submitted):
        """What the firm sees on the client's own page. A filing nobody can see
        happened is a filing nobody trusts."""
        assert submitted["decision"]["timeline"] >= 1

    def test_the_activity_log_records_it(self, submitted):
        assert submitted["decision"]["activity"] >= 1

    def test_the_proposal_kept_its_event_trail(self, submitted):
        assert submitted["decision"]["events"] >= 1

    def test_the_agent_learns_the_decision_by_polling(self, submitted, cms):
        """The other half of the boundary, and the half that broke before: the
        poller passes a timestamp in a query string, and `+05:00` decoded to a
        space — returning nothing, indistinguishable from a quiet queue."""
        from app.workflow.decisions import DecisionPoller
        from app.workflow.document_intake import DOCUMENT_INTAKE

        outcomes = DecisionPoller(cms, submitted["engine"], DOCUMENT_INTAKE).poll()

        assert any(o.proposal_id == submitted["proposal_id"] for o in outcomes), outcomes

    def test_the_run_finishes(self, submitted):
        from app.workflow.state import RunState

        run = submitted["engine"].store.load(submitted["run"].id)

        assert run.state is RunState.COMPLETED, f"the run ended {run.state}"

    def test_it_remembered_what_happened(self, submitted, fixture):
        """Memory is the last step. A summary, never a transcript — raw OCR text
        would leave the installation the first time anyone asked about the
        client."""
        # len(), not all() — the repository is a mapping, not a list.
        assert len(submitted["memory"]) >= 1, "nothing was remembered"
