"""TDD: /api/inbox/* endpoints.

Plan: docs/superpowers/plans/2026-06-20-cyclone-workflow-automation.md
Tasks 7-10 (T7-T10).
"""
from __future__ import annotations

import io
import zipfile
from datetime import datetime, timezone

import pytest
from fastapi.testclient import TestClient

from cyclone import db
from cyclone.api import app
from cyclone.db import Batch, Claim, ClaimState, Remittance


@pytest.fixture(autouse=True)
def _setup(tmp_path, monkeypatch):
    monkeypatch.setenv("CYCLONE_DB_URL", f"sqlite:///{tmp_path}/inbox.db")
    db._reset_for_tests()
    db.init_db()
    # Initialize app state used by inbox endpoints.
    app.state.dismissed_pairs = set()
    yield


@pytest.fixture
def client():
    with TestClient(app) as c:
        yield c


def _seed_batch() -> None:
    with db.SessionLocal()() as s:
        if s.get(Batch, "B-1") is not None:
            return
        s.add(Batch(
            id="B-1", kind="837p", input_filename="x.txt",
            parsed_at=datetime.now(timezone.utc),
            totals_json={"total_claims": 1},
            validation_json={"passed": True, "warnings": [], "errors": []},
            raw_result_json={"_": "stub"},
        ))
        s.commit()


def test_lanes_endpoint_returns_five_keys(client: TestClient):
    """SP10 added the payer_rejected lane (distinct from 999 envelope rejection)."""
    r = client.get("/api/inbox/lanes")
    assert r.status_code == 200
    body = r.json()
    assert set(body.keys()) == {
        "rejected", "payer_rejected", "candidates", "unmatched", "done_today",
    }
    for v in body.values():
        assert isinstance(v, list)


def test_match_endpoint_links_remit_to_claim(client: TestClient):
    _seed_batch()
    with db.SessionLocal()() as s:
        s.add(Claim(id="C1", batch_id="B-1", patient_control_number="PCN-1",
                    state=ClaimState.SUBMITTED))
        s.add(Remittance(id="R1", batch_id="B-1",
                         payer_claim_control_number="PCN-1",
                         status_code="1", status_label="P",
                         total_charge=100, total_paid=0,
                         received_at=datetime.now(timezone.utc)))
        s.commit()

    r = client.post("/api/inbox/candidates/R1/match", json={"claim_id": "C1"})
    assert r.status_code == 200, r.text

    with db.SessionLocal()() as s:
        # Migration 0014: composite PK — filter-by-id returns the single
        # claim with this id (no cross-batch duplicates in this fixture).
        c = s.query(Claim).filter(Claim.id == "C1").first()
        assert c.matched_remittance_id == "R1"


def test_match_409_if_claim_already_matched(client: TestClient):
    _seed_batch()
    with db.SessionLocal()() as s:
        s.add(Claim(id="C1", batch_id="B-1", patient_control_number="X",
                    state=ClaimState.SUBMITTED,
                    matched_remittance_id="R-OTHER"))
        s.add(Remittance(id="R1", batch_id="B-1",
                         payer_claim_control_number="X",
                         status_code="1", status_label="P",
                         total_charge=100, total_paid=0,
                         received_at=datetime.now(timezone.utc)))
        s.commit()

    r = client.post("/api/inbox/candidates/R1/match", json={"claim_id": "C1"})
    assert r.status_code == 409
    assert "current_state" in r.json()["detail"]


def test_dismiss_endpoint_adds_pair_to_session_set(client: TestClient):
    r = client.post(
        "/api/inbox/candidates/dismiss",
        json={"pairs": [{"claim_id": "C1", "remit_id": "R1"}]},
    )
    assert r.status_code == 200
    assert frozenset({"C1", "R1"}) in client.app.state.dismissed_pairs


def test_resubmit_moves_rejected_to_submitted_and_increments_count(client: TestClient):
    _seed_batch()
    with db.SessionLocal()() as s:
        s.add(Claim(id="C1", batch_id="B-1", patient_control_number="X",
                    state=ClaimState.REJECTED,
                    rejection_reason="old",
                    resubmit_count=2))
        s.commit()

    r = client.post("/api/inbox/rejected/resubmit", json={"claim_ids": ["C1"]})
    assert r.status_code == 200, r.text

    with db.SessionLocal()() as s:
        # Migration 0014: composite PK (batch_id, id).
        c = s.get(Claim, ("B-1", "C1"))
        assert c.state == ClaimState.SUBMITTED
        assert c.rejection_reason is None
        assert c.resubmit_count == 3


def test_resubmit_returns_conflicts_for_non_rejected_claim(client: TestClient):
    """Resubmit returns 200 with a conflicts list (does NOT raise 409)."""
    _seed_batch()
    with db.SessionLocal()() as s:
        s.add(Claim(id="C1", batch_id="B-1", patient_control_number="X",
                    state=ClaimState.SUBMITTED))
        s.commit()

    r = client.post("/api/inbox/rejected/resubmit", json={"claim_ids": ["C1"]})
    assert r.status_code == 200
    body = r.json()
    assert body["resubmitted"] == []
    assert any(c["claim_id"] == "C1" for c in body["conflicts"])


def test_export_csv_streams_rows_with_csv_content_type(client: TestClient):
    r = client.get("/api/inbox/export.csv?lane=rejected")
    assert r.status_code == 200
    assert "text/csv" in r.headers["content-type"]
    body = r.text
    assert "id" in body.splitlines()[0]


# ---------------------------------------------------------------------------
# /api/inbox/rejected/resubmit?download=true — SP8 bundle download
# ---------------------------------------------------------------------------
#
# These tests seed a REJECTED claim with a real ClaimOutput in raw_json
# (produced by ingesting a real 837 fixture) so the download path has
# something to serialize. The bundle is built in-memory; we unzip the
# response body and assert the per-claim .x12 files round-trip back
# through the parser.


def _seed_rejected_claim_with_raw_837(client: TestClient) -> str:
    """Parse the canonical CO Medicaid 837, move its claim into REJECTED,
    and return the claim_id. The raw_json column gets populated by the
    /api/parse-837 endpoint as a side effect of ingesting the file."""
    from pathlib import Path

    fixture = Path("tests/fixtures/co_medicaid_837p.txt").read_text()
    r = client.post(
        "/api/parse-837",
        files={"file": ("claim.txt", io.BytesIO(fixture.encode()), "text/plain")},
        headers={"Accept": "application/json"},
    )
    assert r.status_code == 200, r.text
    real_id = r.json()["claims"][0]["claim_id"]
    _seed_batch()
    with db.SessionLocal()() as s:
        # Migration 0014: filter-by-id since the parse-837 batch id is
        # a fresh UUID we don't know here.
        c = s.query(Claim).filter(Claim.id == real_id).first()
        assert c is not None
        c.state = ClaimState.REJECTED
        c.rejection_reason = "test fixture"
        s.commit()
    return real_id


def test_resubmit_with_download_returns_zip_with_one_x12_per_accepted_claim(client: TestClient):
    """Happy path: 1 rejected claim → zip with 1 .x12 file that round-trips
    back through the parser. Filename in Content-Disposition reflects count."""
    from cyclone.parsers.parse_837 import parse as parse_837
    from cyclone.parsers.payer import PayerConfig

    real_id = _seed_rejected_claim_with_raw_837(client)

    r = client.post(
        "/api/inbox/rejected/resubmit?download=true",
        json={"claim_ids": [real_id]},
    )
    assert r.status_code == 200, r.text
    assert r.headers["content-type"].startswith("application/zip")
    cd = r.headers["content-disposition"]
    assert "attachment" in cd
    # Count in filename comes from the number of accepted (resubmitted)
    # claims, not the input list length — conflicts are excluded.
    assert "resubmit-1-claims.zip" in cd

    # The zip body is a real archive with one x12 per accepted claim.
    buf = io.BytesIO(r.content)
    with zipfile.ZipFile(buf) as zf:
        names = zf.namelist()
        assert names == [f"claim-{real_id}.x12"]
        body = zf.read(names[0]).decode("utf-8")

    # And it parses back to the same claim id.
    result = parse_837(body, PayerConfig(name="CO_MEDICAID"))
    assert any(c.claim_id == real_id for c in result.claims)

    # Side effect: claim actually moved to SUBMITTED.
    with db.SessionLocal()() as s:
        # Migration 0014: filter-by-id (batch_id is a fresh UUID).
        c = s.query(Claim).filter(Claim.id == real_id).first()
        assert c.state == ClaimState.SUBMITTED


def test_resubmit_with_download_excludes_conflicts_from_zip(client: TestClient):
    """The download is a 'give me the files I asked for' payload — only
    claims that were successfully resubmitted end up in the zip. Missing
    ids and non-rejected conflicts are omitted (the user already saw them
    in the JSON response on the non-download path).
    """
    _seed_batch()
    accepted_id = _seed_rejected_claim_with_raw_837(client)
    conflict_id = "C-NOT-REJECTED"
    with db.SessionLocal()() as s:
        s.add(Claim(id=conflict_id, batch_id="B-1",
                    patient_control_number="X",
                    state=ClaimState.SUBMITTED))
        s.commit()

    r = client.post(
        "/api/inbox/rejected/resubmit?download=true",
        json={"claim_ids": [accepted_id, conflict_id, "C-DOES-NOT-EXIST"]},
    )
    assert r.status_code == 200, r.text
    # Filename count = accepted count, not input length.
    assert "resubmit-1-claims.zip" in r.headers["content-disposition"]

    buf = io.BytesIO(r.content)
    with zipfile.ZipFile(buf) as zf:
        names = set(zf.namelist())
        assert names == {f"claim-{accepted_id}.x12"}
