"""Tests for the pure reconciliation functions.

These tests use lightweight stand-in objects (namedtuples) rather than
SQLAlchemy ORM instances, so the reconcile module has zero DB dependency.
"""

from __future__ import annotations

from dataclasses import dataclass
from datetime import date, datetime, timezone
from decimal import Decimal
from typing import Optional

import pytest

from cyclone import db, reconcile
from cyclone.db import (
    ActivityEvent,
    Batch,
    Claim,
    ClaimState,
    Match,
    Remittance,
)
from cyclone.reconcile import Match as ReconcileMatch


# --- Stand-in types -----------------------------------------------------------


@dataclass
class FakeClaim:
    id: str
    patient_control_number: str
    service_date_from: Optional[date]
    state: str = "submitted"
    matched_remittance_id: Optional[str] = None


@dataclass
class FakeRemit:
    id: str
    payer_claim_control_number: str
    service_date: Optional[date]
    is_reversal: bool = False


# --- match() ------------------------------------------------------------------


def test_match_same_pcn_within_window_matches():
    claims = [FakeClaim("CLM-1", "PCN-A", date(2026, 6, 1))]
    remits = [FakeRemit("CLP-1", "PCN-A", date(2026, 6, 2))]
    matches = reconcile.match(claims, remits, window_days=7)
    assert len(matches) == 1
    assert matches[0].claim.id == "CLM-1"
    assert matches[0].remittance.id == "CLP-1"
    assert matches[0].strategy == "pcn-exact"


def test_match_dates_outside_window_unmatched():
    claims = [FakeClaim("CLM-1", "PCN-A", date(2026, 6, 1))]
    remits = [FakeRemit("CLP-1", "PCN-A", date(2026, 7, 1))]  # 30d apart
    matches = reconcile.match(claims, remits, window_days=7)
    assert matches == []


def test_match_different_pcn_unmatched():
    claims = [FakeClaim("CLM-1", "PCN-A", date(2026, 6, 1))]
    remits = [FakeRemit("CLP-1", "PCN-B", date(2026, 6, 1))]
    matches = reconcile.match(claims, remits)
    assert matches == []


def test_match_multiple_claims_same_pcn_picks_closest_date():
    claims = [
        FakeClaim("CLM-1", "PCN-A", date(2026, 5, 1)),
        FakeClaim("CLM-2", "PCN-A", date(2026, 6, 1)),  # closer
    ]
    remits = [FakeRemit("CLP-1", "PCN-A", date(2026, 6, 2))]
    matches = reconcile.match(claims, remits)
    assert len(matches) == 1
    assert matches[0].claim.id == "CLM-2"


def test_match_multiple_remits_same_pcn_each_gets_own_claim():
    claims = [
        FakeClaim("CLM-1", "PCN-A", date(2026, 6, 1)),
        FakeClaim("CLM-2", "PCN-A", date(2026, 6, 15)),
    ]
    remits = [
        FakeRemit("CLP-1", "PCN-A", date(2026, 6, 2)),
        FakeRemit("CLP-2", "PCN-A", date(2026, 6, 16)),
    ]
    matches = reconcile.match(claims, remits)
    assert len(matches) == 2
    claim_ids = {m.claim.id for m in matches}
    assert claim_ids == {"CLM-1", "CLM-2"}


def test_match_skips_already_matched_claims():
    claims = [FakeClaim("CLM-1", "PCN-A", date(2026, 6, 1), matched_remittance_id="OLD")]
    remits = [FakeRemit("CLP-1", "PCN-A", date(2026, 6, 1))]
    matches = reconcile.match(claims, remits)
    assert matches == []


def test_match_falls_back_to_first_claim_when_no_service_date():
    """When the remit has no service_date, match the first claim for that PCN."""
    claims = [
        FakeClaim("CLM-1", "PCN-A", None),
        FakeClaim("CLM-2", "PCN-A", None),
    ]
    remits = [FakeRemit("CLP-1", "PCN-A", None)]
    matches = reconcile.match(claims, remits)
    assert len(matches) == 1
    assert matches[0].claim.id == "CLM-1"


def test_match_reversal_flag_propagates():
    claims = [FakeClaim("CLM-1", "PCN-A", date(2026, 6, 1))]
    remits = [FakeRemit("CLP-1", "PCN-A", date(2026, 6, 1), is_reversal=True)]
    matches = reconcile.match(claims, remits)
    assert matches[0].is_reversal is True


# ApplyIntent + apply_payment / apply_reversal / split_unmatched ----------------


def test_apply_payment_full_amount_returns_paid():
    claim = FakeClaim("CLM-1", "PCN-A", None, state=ClaimState.SUBMITTED.value)
    remit = FakeRemit("CLP-1", "PCN-A", None)
    intent = reconcile.apply_payment(claim, remit, charge=Decimal("100"), paid=Decimal("100"))
    assert intent.new_state == ClaimState.PAID
    assert intent.activity_kind == "reconcile"
    assert intent.skipped is False


def test_apply_payment_partial_amount_returns_partial():
    claim = FakeClaim("CLM-1", "PCN-A", None, state=ClaimState.SUBMITTED.value)
    remit = FakeRemit("CLP-1", "PCN-A", None)
    intent = reconcile.apply_payment(claim, remit, charge=Decimal("100"), paid=Decimal("60"))
    assert intent.new_state == ClaimState.PARTIAL


def test_apply_payment_zero_paid_returns_received():
    claim = FakeClaim("CLM-1", "PCN-A", None, state=ClaimState.SUBMITTED.value)
    remit = FakeRemit("CLP-1", "PCN-A", None)
    intent = reconcile.apply_payment(claim, remit, charge=Decimal("100"), paid=Decimal("0"))
    assert intent.new_state == ClaimState.RECEIVED


def test_apply_payment_status_4_returns_denied():
    claim = FakeClaim("CLM-1", "PCN-A", None, state=ClaimState.SUBMITTED.value)
    remit = FakeRemit("CLP-1", "PCN-A", None)
    intent = reconcile.apply_payment(claim, remit, charge=Decimal("100"), paid=Decimal("0"),
                                       status_code="4")
    assert intent.new_state == ClaimState.DENIED


def test_apply_payment_already_paid_is_noop():
    claim = FakeClaim("CLM-1", "PCN-A", None, state=ClaimState.PAID.value)
    remit = FakeRemit("CLP-1", "PCN-A", None)
    intent = reconcile.apply_payment(claim, remit, charge=Decimal("100"), paid=Decimal("100"))
    assert intent.skipped is True
    assert intent.activity_kind == "invalid_state"
    assert intent.new_state is None


def test_apply_reversal_of_paid_returns_reversed_with_prior_state():
    claim = FakeClaim("CLM-1", "PCN-A", None, state=ClaimState.PAID.value)
    remit = FakeRemit("CLP-1", "PCN-A", None, is_reversal=True)
    intent = reconcile.apply_reversal(claim, remit)
    assert intent.new_state == ClaimState.REVERSED
    assert intent.prior_claim_state == ClaimState.PAID
    assert intent.activity_kind == "reversal"
    assert intent.skipped is False


def test_apply_reversal_of_partial_works_same_way():
    claim = FakeClaim("CLM-1", "PCN-A", None, state=ClaimState.PARTIAL.value)
    remit = FakeRemit("CLP-1", "PCN-A", None, is_reversal=True)
    intent = reconcile.apply_reversal(claim, remit)
    assert intent.new_state == ClaimState.REVERSED
    assert intent.prior_claim_state == ClaimState.PARTIAL


def test_apply_reversal_of_denied_is_noop():
    claim = FakeClaim("CLM-1", "PCN-A", None, state=ClaimState.DENIED.value)
    remit = FakeRemit("CLP-1", "PCN-A", None, is_reversal=True)
    intent = reconcile.apply_reversal(claim, remit)
    assert intent.skipped is True
    assert intent.activity_kind == "reversal_skipped"


def test_apply_reversal_of_already_reversed_is_noop():
    claim = FakeClaim("CLM-1", "PCN-A", None, state=ClaimState.REVERSED.value)
    remit = FakeRemit("CLP-1", "PCN-A", None, is_reversal=True)
    intent = reconcile.apply_reversal(claim, remit)
    assert intent.skipped is True


def test_split_unmatched_partitions_by_match_set():
    claims = [FakeClaim("CLM-1", "A", None), FakeClaim("CLM-2", "B", None)]
    remits = [FakeRemit("CLP-1", "A", None), FakeRemit("CLP-2", "C", None)]
    matches = [ReconcileMatch(claim=claims[0], remittance=remits[0], strategy="pcn-exact", is_reversal=False)]
    unmatched_claims, unmatched_remits = reconcile.split_unmatched(claims, remits, matches)
    assert [c.id for c in unmatched_claims] == ["CLM-2"]
    assert [r.id for r in unmatched_remits] == ["CLP-2"]


# --- reconcile.run() integration tests ----------------------------------------


@pytest.fixture
def fixture_835(tmp_path, monkeypatch):
    """Set up a clean DB and return an empty helper for building remits."""
    monkeypatch.setenv("CYCLONE_DB_URL", f"sqlite:///{tmp_path}/reconcile.db")
    db._reset_for_tests()
    db.init_db()
    yield
    db._reset_for_tests()


def _make_batch(s, batch_id="b1", kind="835"):
    s.add(Batch(
        id=batch_id, kind=kind, input_filename="x",
        parsed_at=datetime(2026, 6, 19, tzinfo=timezone.utc),
    ))
    s.flush()


def _make_claim(s, claim_id="CLM-1", pcn="PCN-A", charge="100.00",
                service_date=None, state=ClaimState.SUBMITTED):
    s.add(Claim(
        id=claim_id, batch_id="b1", patient_control_number=pcn,
        service_date_from=service_date, charge_amount=Decimal(charge),
        state=state,
    ))
    s.flush()


def _make_remit(s, remit_id="CLP-1", pcn="PCN-A", status="1",
                charge="100.00", paid="100.00", service_date=None,
                is_reversal=False):
    rid = remit_id + ":b1xxxxxx"
    s.add(Remittance(
        id=rid, batch_id="b1", payer_claim_control_number=pcn,
        status_code=status, total_charge=Decimal(charge), total_paid=Decimal(paid),
        received_at=datetime(2026, 6, 19, tzinfo=timezone.utc),
        service_date=service_date, is_reversal=is_reversal,
    ))
    s.flush()
    return rid


def test_run_matches_and_updates_state(fixture_835):
    with db.SessionLocal()() as s:
        _make_batch(s)
        _make_claim(s, "CLM-1", "PCN-A", "100.00", date(2026, 6, 1))
        _make_remit(s, "CLP-1", "PCN-A", "1", "100.00", "100.00", date(2026, 6, 1))
        s.commit()

    with db.SessionLocal()() as s:
        from cyclone import reconcile
        result = reconcile.run(s, "b1")
        s.commit()

    assert result.matched == 1
    assert result.unmatched_remittances == 0
    assert result.unmatched_claims == 0

    with db.SessionLocal()() as s:
        claim = s.get(Claim, "CLM-1")
        assert claim.state == ClaimState.PAID
        assert claim.matched_remittance_id == "CLP-1:b1xxxxxx"


def test_run_orphan_remit_leaves_claim_unmatched(fixture_835):
    with db.SessionLocal()() as s:
        _make_batch(s)
        _make_claim(s, "CLM-1", "PCN-A", "100.00", date(2026, 6, 1))
        _make_remit(s, "CLP-2", "PCN-NEW", "1", "50.00", "50.00", date(2026, 6, 5))
        s.commit()

    with db.SessionLocal()() as s:
        from cyclone import reconcile
        result = reconcile.run(s, "b1")
        s.commit()

    assert result.matched == 0
    assert result.unmatched_remittances == 1
    # Spec-bug fix (T10): claim PCN-A does not match remit PCN-NEW, so the
    # claim stays in the unmatched bucket. Original spec asserted 0.
    assert result.unmatched_claims == 1


def test_run_reversal_flips_paid_to_reversed(fixture_835):
    with db.SessionLocal()() as s:
        _make_batch(s)
        # Spec-bug fix (T10): dates are 5 days apart so match() picks this claim
        # (default window_days=7). Original spec used 2026-06-01 vs 2026-06-10
        # (9 days, outside window) which produced no match.
        _make_claim(s, "CLM-1", "PCN-A", "100.00", date(2026, 6, 5),
                    state=ClaimState.PAID)
        # Match row already exists from prior reconcile.
        s.add(Match(
            claim_id="CLM-1", remittance_id="CLP-OLD:b1xxxxxx",
            strategy="pcn-exact", matched_at=datetime.now(timezone.utc),
            is_reversal=False,
        ))
        s.flush()
        _make_remit(s, "CLP-REV", "PCN-A", "22", "100.00", "-100.00",
                    date(2026, 6, 10), is_reversal=True)
        s.commit()

    with db.SessionLocal()() as s:
        from cyclone import reconcile
        result = reconcile.run(s, "b1")
        s.commit()

    assert result.matched == 1
    with db.SessionLocal()() as s:
        claim = s.get(Claim, "CLM-1")
        assert claim.state == ClaimState.REVERSED
        # Match rows: original + reversal.
        matches = s.query(Match).filter(Match.claim_id == "CLM-1").all()
        assert len(matches) == 2
        reversal_match = [m for m in matches if m.is_reversal][0]
        assert reversal_match.prior_claim_state == ClaimState.PAID


def test_run_reconcile_raise_in_session_leaves_prior_commits_alone(fixture_835):
    """A ``reconcile.run`` raise inside an open session does not damage
    previously-committed rows.

    The test seeds a batch + remit, commits them in one session, then
    calls ``reconcile.run`` in a fresh session with ``match`` monkey-
    patched to raise. The pre-existing rows must survive — they're on
    disk from the prior commit, separate from the rolling-back
    session. This pins the unit-level invariant that ``reconcile.run``
    itself never commits and never silently mutates rows outside the
    session it was given; it is the *caller's* responsibility (now
    ``CycloneStore.add`` per SP27 Task 10) to control the transaction
    boundary.
    """
    with db.SessionLocal()() as s:
        _make_batch(s)
        _make_remit(s, "CLP-1", "PCN-A", "1", "100.00", "100.00")
        s.commit()

    # Monkeypatch reconcile.match to raise.
    from cyclone import reconcile
    real_match = reconcile.match
    def boom(*a, **kw): raise RuntimeError("synthetic fault")
    reconcile.match = boom
    try:
        with db.SessionLocal()() as s:
            try:
                reconcile.run(s, "b1")
                s.commit()
            except RuntimeError:
                pass  # caller is responsible for catch; reconcile.run itself shouldn't raise
    finally:
        reconcile.match = real_match

    # Batch + remit still present.
    with db.SessionLocal()() as s:
        b = s.get(Batch, "b1")
        assert b is not None
        r = s.query(Remittance).first()
        assert r is not None


# --- SP31: content-keys fallback matcher (pure helper) -----------------------


def _make_remit_for_keys(remit_id: str, pcn: str, charge, npi: str):
    """Tiny shim with the three fields _content_keys_match reads."""
    from cyclone.reconcile import _RemitLike  # protocol not enforced at runtime
    return type("R", (), {
        "id": remit_id,
        "payer_claim_control_number": pcn,
        "total_charge_amount": charge,
        "rendering_provider_npi": npi,
    })()


def _make_claim_for_keys(claim_id: str, pcn: str, charge, npi: str):
    return type("C", (), {
        "id": claim_id,
        "patient_control_number": pcn,
        "total_charge": charge,
        "rendering_provider_npi": npi,
    })()


def test_content_keys_pcn_plus_charge_matches():
    """PCN + charge match (NPI mismatched) → 2-of-3, returns ``{"pcn","charge"}``."""
    from cyclone.reconcile import _content_keys_match
    from decimal import Decimal
    r = _make_remit_for_keys("r1", "ABC", Decimal("100.00"), "")
    c = _make_claim_for_keys("c1", "ABC", Decimal("100.00"), "1111111111")
    assert _content_keys_match(r, c) == {"pcn", "charge"}


def test_content_keys_pcn_plus_npi_matches():
    """PCN + NPI match (charge mismatched) → 2-of-3, returns ``{"pcn","npi"}``."""
    from cyclone.reconcile import _content_keys_match
    from decimal import Decimal
    r = _make_remit_for_keys("r1", "ABC", Decimal("100.00"), "1111111111")
    c = _make_claim_for_keys("c1", "ABC", Decimal("200.00"), "1111111111")
    assert _content_keys_match(r, c) == {"pcn", "npi"}


def test_content_keys_charge_plus_npi_matches():
    """Charge + NPI match (PCN mismatched) → 2-of-3, returns ``{"charge","npi"}``."""
    from cyclone.reconcile import _content_keys_match
    from decimal import Decimal
    r = _make_remit_for_keys("r1", "ABC", Decimal("100.00"), "1111111111")
    c = _make_claim_for_keys("c1", "XYZ", Decimal("100.00"), "1111111111")
    assert _content_keys_match(r, c) == {"charge", "npi"}


def test_content_keys_all_three_match():
    """All 3 match → 3-of-3, returns ``{"pcn","charge","npi"}``."""
    from cyclone.reconcile import _content_keys_match
    from decimal import Decimal
    r = _make_remit_for_keys("r1", "ABC", Decimal("100.00"), "1111111111")
    c = _make_claim_for_keys("c1", "ABC", Decimal("100.00"), "1111111111")
    assert _content_keys_match(r, c) == {"pcn", "charge", "npi"}


def test_content_keys_only_pcn_does_not_match():
    """Only PCN matches (charge + NPI both wrong) → set is just ``{"pcn"}``."""
    from cyclone.reconcile import _content_keys_match
    from decimal import Decimal
    r = _make_remit_for_keys("r1", "ABC", Decimal("100.00"), "1111111111")
    c = _make_claim_for_keys("c1", "ABC", Decimal("200.00"), "2222222222")
    assert _content_keys_match(r, c) == {"pcn"}


def test_content_keys_only_charge_does_not_match():
    """Only charge matches (PCN + NPI both wrong) → set is just ``{"charge"}``."""
    from cyclone.reconcile import _content_keys_match
    from decimal import Decimal
    r = _make_remit_for_keys("r1", "ABC", Decimal("100.00"), "1111111111")
    c = _make_claim_for_keys("c1", "XYZ", Decimal("100.00"), "2222222222")
    assert _content_keys_match(r, c) == {"charge"}


def test_content_keys_none_match():
    """All 3 fields disagree → empty set."""
    from cyclone.reconcile import _content_keys_match
    from decimal import Decimal
    r = _make_remit_for_keys("r1", "ABC", Decimal("100.00"), "1111111111")
    c = _make_claim_for_keys("c1", "XYZ", Decimal("200.00"), "2222222222")
    assert _content_keys_match(r, c) == set()


def test_content_keys_charge_tolerance_is_one_cent():
    """Charge differs by exactly $0.005 (rounding) → charge counts as matched."""
    from cyclone.reconcile import _content_keys_match
    from decimal import Decimal
    r = _make_remit_for_keys("r1", "ABC", Decimal("100.005"), "1111111111")
    c = _make_claim_for_keys("c1", "ABC", Decimal("100.00"), "1111111111")
    assert _content_keys_match(r, c) == {"pcn", "charge", "npi"}


def test_content_keys_charge_differs_by_two_cents_no_match():
    """Charge differs by $0.02 → charge does NOT match (outside tolerance)."""
    from cyclone.reconcile import _content_keys_match
    from decimal import Decimal
    # PCN and NPI deliberately differ so the only potential match is charge.
    # Charge is outside the $0.01 tolerance → 0 of 3 match → empty set.
    r = _make_remit_for_keys("r1", "ABC", Decimal("100.02"), "1111111111")
    c = _make_claim_for_keys("c1", "XYZ", Decimal("100.00"), "2222222222")
    assert _content_keys_match(r, c) == set()


def test_content_keys_empty_npi_does_not_count():
    """Empty NPI on remit counts as not-matched (falls back to PCN+charge rule)."""
    from cyclone.reconcile import _content_keys_match
    from decimal import Decimal
    r = _make_remit_for_keys("r1", "ABC", Decimal("100.00"), "")
    c = _make_claim_for_keys("c1", "ABC", Decimal("100.00"), "1111111111")
    assert _content_keys_match(r, c) == {"pcn", "charge"}  # NPI skipped on remit
    c2 = _make_claim_for_keys("c2", "XYZ", Decimal("100.00"), "1111111111")
    assert _content_keys_match(r, c2) == {"charge"}  # only charge, no PCN


# --- SP31: content-keys fallback matcher (DB helper) ------------------------


def test_score_fallback_unique_match_returns_claim_id(db_session, make_claim, make_remit):
    """One claim in pool matches 2-of-3 → returns (claim_id, keys, candidate_count)."""
    from decimal import Decimal
    from datetime import date, timedelta
    from cyclone.reconcile import _score_fallback_candidates
    c1 = make_claim(patient_control_number="ABC", total_charge=Decimal("100.00"),
                    rendering_provider_npi="1111111111",
                    service_date_from=date(2026, 6, 1))
    c2 = make_claim(patient_control_number="XYZ", total_charge=Decimal("999.99"),
                    rendering_provider_npi="2222222222",
                    service_date_from=date(2026, 6, 1))
    r = make_remit(payer_claim_control_number="ABC",
                   total_charge_amount=Decimal("100.00"),
                   rendering_provider_npi="1111111111",
                   service_date=date(2026, 6, 5))  # ±30-day window
    result = _score_fallback_candidates(db_session, r)
    assert result is not None
    claim_id, keys_matched, candidate_count = result
    assert claim_id == c1.id
    assert keys_matched == {"pcn", "charge", "npi"}  # 3-of-3
    assert candidate_count == 2  # pool had 2 candidates


def test_score_fallback_zero_matches_returns_none(db_session, make_claim, make_remit):
    """No claim in pool matches 2-of-3 → returns None."""
    from decimal import Decimal
    from datetime import date
    from cyclone.reconcile import _score_fallback_candidates
    make_claim(patient_control_number="XYZ", total_charge=Decimal("999.99"),
               rendering_provider_npi="2222222222",
               service_date_from=date(2026, 6, 1))
    r = make_remit(payer_claim_control_number="ABC",
                   total_charge_amount=Decimal("100.00"),
                   rendering_provider_npi="1111111111",
                   service_date=date(2026, 6, 5))
    assert _score_fallback_candidates(db_session, r) is None


def test_score_fallback_two_matches_returns_none(db_session, make_claim, make_remit):
    """Two claims in pool both match 2-of-3 → returns None (ambiguous)."""
    from decimal import Decimal
    from datetime import date
    from cyclone.reconcile import _score_fallback_candidates
    # Both claims have same PCN and same charge — both would match PCN+charge.
    make_claim(patient_control_number="ABC", total_charge=Decimal("100.00"),
               rendering_provider_npi="1111111111",
               service_date_from=date(2026, 6, 1))
    make_claim(patient_control_number="ABC", total_charge=Decimal("100.00"),
               rendering_provider_npi="1111111111",
               service_date_from=date(2026, 6, 2))
    r = make_remit(payer_claim_control_number="ABC",
                   total_charge_amount=Decimal("100.00"),
                   rendering_provider_npi="1111111111",
                   service_date=date(2026, 6, 5))
    assert _score_fallback_candidates(db_session, r) is None


def test_score_fallback_skips_already_matched(db_session, make_claim, make_remit):
    """Claim with matched_remittance_id set → excluded from pool."""
    from decimal import Decimal
    from datetime import date
    from cyclone.reconcile import _score_fallback_candidates
    c1 = make_claim(patient_control_number="ABC", total_charge=Decimal("100.00"),
                    rendering_provider_npi="1111111111",
                    service_date_from=date(2026, 6, 1),
                    matched_remittance_id="some-other-remit-id")
    r = make_remit(payer_claim_control_number="ABC",
                   total_charge_amount=Decimal("100.00"),
                   rendering_provider_npi="1111111111",
                   service_date=date(2026, 6, 5))
    assert _score_fallback_candidates(db_session, r) is None  # only candidate was excluded


def test_score_fallback_skips_terminal_state(db_session, make_claim, make_remit):
    """Claim in PAID/DENIED/REJECTED/REVERSED/RECONCILED → excluded from pool."""
    from decimal import Decimal
    from datetime import date
    from cyclone.db import ClaimState
    from cyclone.reconcile import _score_fallback_candidates
    make_claim(patient_control_number="ABC", total_charge=Decimal("100.00"),
               rendering_provider_npi="1111111111",
               service_date_from=date(2026, 6, 1),
               state=ClaimState.PAID)
    r = make_remit(payer_claim_control_number="ABC",
                   total_charge_amount=Decimal("100.00"),
                   rendering_provider_npi="1111111111",
                   service_date=date(2026, 6, 5))
    assert _score_fallback_candidates(db_session, r) is None


def test_score_fallback_widened_window_catches_late_remit(db_session, make_claim, make_remit):
    """Remit 25 days after claim → still in pool (30-day window)."""
    from decimal import Decimal
    from datetime import date, timedelta
    from cyclone.reconcile import _score_fallback_candidates
    claim_date = date(2026, 6, 1)
    remit_date = claim_date + timedelta(days=25)
    c1 = make_claim(patient_control_number="ABC", total_charge=Decimal("100.00"),
                    rendering_provider_npi="1111111111",
                    service_date_from=claim_date)
    r = make_remit(payer_claim_control_number="ABC",
                   total_charge_amount=Decimal("100.00"),
                   rendering_provider_npi="1111111111",
                   service_date=remit_date)
    result = _score_fallback_candidates(db_session, r)
    assert result is not None
    claim_id, keys_matched, candidate_count = result
    assert claim_id == c1.id
    assert candidate_count == 1


def test_score_fallback_outside_window_excluded(db_session, make_claim, make_remit):
    """Remit 60 days after claim → excluded (outside 30-day window)."""
    from decimal import Decimal
    from datetime import date, timedelta
    from cyclone.reconcile import _score_fallback_candidates
    claim_date = date(2026, 6, 1)
    remit_date = claim_date + timedelta(days=60)
    make_claim(patient_control_number="ABC", total_charge=Decimal("100.00"),
               rendering_provider_npi="1111111111",
               service_date_from=claim_date)
    r = make_remit(payer_claim_control_number="ABC",
                   total_charge_amount=Decimal("100.00"),
                   rendering_provider_npi="1111111111",
                   service_date=remit_date)
    assert _score_fallback_candidates(db_session, r) is None


# --- SP31 Task 3 fix: Remittance NPI lives in raw_json ----------------------


def test_content_keys_npi_from_remit_raw_json(db_session, make_claim):
    """NPI on the Remit is read from ``raw_json`` when the attribute is absent.

    The 835 parser stores rendering_provider_npi on ``Remittance.raw_json``
    (the ORM has no dedicated column) — reconcile.run() hands a raw
    ``Remittance`` instance to ``_content_keys_match``, so the helper must
    fall through ``getattr(...rendering_provider_npi...)`` to the raw_json
    read. This test pins that path with a real ORM row (no transient
    ``setattr`` shadowing the production layout).

    Configuration: PCN mismatched, charge matches, NPI matches via raw_json
    → 2 of 3 keys agree → True.
    """
    from decimal import Decimal
    from datetime import date, datetime, timezone
    from cyclone.db import Remittance
    from cyclone.reconcile import _content_keys_match
    claim = make_claim(
        patient_control_number="ABC",
        total_charge=Decimal("100.00"),
        rendering_provider_npi="1111111111",
        service_date_from=date(2026, 6, 1),
    )
    # Real Remittance ORM instance — NPI lives ONLY in raw_json, not as a
    # transient attribute. (The 835 parser populates raw_json that way;
    # the make_remit factory's transient ``rendering_provider_npi`` setattr
    # would shadow the production read.)
    remit = Remittance(
        id="real-remit-1",
        batch_id="test-batch",
        payer_claim_control_number="OTHER-PCN",  # PCN: won't match "ABC"
        status_code="1",
        total_charge=Decimal("100.00"),          # charge: matches
        received_at=datetime(2026, 6, 19, tzinfo=timezone.utc),
        raw_json={"rendering_provider_npi": "1111111111"},  # NPI: matches
    )
    db_session.add(remit)
    db_session.flush()
    assert _content_keys_match(remit, claim) == {"charge", "npi"}  # PCN mismatched


# --- SP31: end-to-end integration via reconcile.run() -----------------------


def test_reconcile_run_emits_score_auto_match_when_pcn_misses(db_session, make_claim, make_remit):
    """A remit with no PCN match but matching charge+NPI triggers score-auto."""
    from decimal import Decimal
    from datetime import date
    from sqlalchemy import select
    from cyclone.db import Batch, ClaimState
    from cyclone.reconcile import run as reconcile_run
    claim = make_claim(patient_control_number="UNIQUE-CLM",
                       total_charge=Decimal("100.00"),
                       rendering_provider_npi="1111111111",
                       service_date_from=date(2026, 6, 1))
    remit = make_remit(payer_claim_control_number="DIFFERENT-PCN",
                       total_charge_amount=Decimal("100.00"),
                       rendering_provider_npi="1111111111",
                       service_date=date(2026, 6, 5))
    batch = Batch(id="batch-1", kind="835", input_filename="x.835", parsed_at=date(2026, 6, 5))
    db_session.add(batch)
    db_session.flush()
    # Re-parent the remit to this batch (make_remit doesn't set batch_id).
    remit.batch_id = batch.id
    db_session.flush()

    result = reconcile_run(db_session, batch.id)
    db_session.flush()  # autoflush=False on SessionLocal; force flush pending Match row

    assert result.matched == 1
    # Verify Match row was written with strategy="score-auto"
    from cyclone.db import Match
    matches = list(db_session.execute(
        select(Match).where(Match.remittance_id == remit.id)
    ).scalars().all())
    assert len(matches) == 1
    assert matches[0].strategy == "score-auto"
    assert matches[0].claim_id == claim.id
    # Symmetric FK: the auto-match path sets Remittance.claim_id so the
    # list_unmatched filter (``Remittance.claim_id IS NULL``) correctly
    # drops this pair from the orphan bucket. Mirrors the manual_match
    # contract — both paths must populate both sides of the FK pair.
    db_session.refresh(remit)
    assert remit.claim_id == claim.id
    # Verify ActivityEvent was emitted with kind="auto_matched_835"
    from cyclone.db import ActivityEvent
    events = list(db_session.execute(
        select(ActivityEvent).where(
            ActivityEvent.remittance_id == remit.id,
            ActivityEvent.kind == "auto_matched_835",
        )
    ).scalars().all())
    assert len(events) == 1
    assert events[0].claim_id == claim.id
    # Spec D8: payload records keys_matched + candidate_count so the
    # operator can see *why* the auto-link fired.
    payload = events[0].payload_json or {}
    assert "keys_matched" in payload
    assert "candidate_count" in payload
    assert payload["candidate_count"] == 1
    assert set(payload["keys_matched"]) >= {"charge", "npi"}  # PCN differs in this test
    # Verify claim state was flipped
    db_session.refresh(claim)
    assert claim.state in {ClaimState.PAID, ClaimState.PARTIAL, ClaimState.RECEIVED}


def test_reconcile_run_pcn_path_unchanged(db_session, make_claim, make_remit):
    """Existing PCN-exact match still produces strategy='pcn-exact' (regression guard)."""
    from decimal import Decimal
    from datetime import date
    from sqlalchemy import select
    from cyclone.db import Batch, Match
    from cyclone.reconcile import run as reconcile_run
    claim = make_claim(patient_control_number="SAME-PCN",
                       total_charge=Decimal("100.00"),
                       rendering_provider_npi="1111111111",
                       service_date_from=date(2026, 6, 1))
    remit = make_remit(payer_claim_control_number="SAME-PCN",
                       total_charge_amount=Decimal("100.00"),
                       rendering_provider_npi="1111111111",
                       service_date=date(2026, 6, 2))
    batch = Batch(id="batch-1", kind="835", input_filename="x.835", parsed_at=date(2026, 6, 5))
    db_session.add(batch)
    db_session.flush()
    remit.batch_id = batch.id
    db_session.flush()

    reconcile_run(db_session, batch.id)
    db_session.flush()  # autoflush=False on SessionLocal; force flush pending Match row

    matches = list(db_session.execute(
        select(Match).where(Match.remittance_id == remit.id)
    ).scalars().all())
    assert len(matches) == 1
    assert matches[0].strategy == "pcn-exact"  # NOT "score-auto"


def test_reconcile_run_no_match_leaves_remit_unlinked(db_session, make_claim, make_remit):
    """No 2-of-3 match → no Match row, claim state unchanged."""
    from decimal import Decimal
    from datetime import date
    from sqlalchemy import select
    from cyclone.db import Batch, Match, ClaimState
    from cyclone.reconcile import run as reconcile_run
    claim = make_claim(patient_control_number="UNRELATED",
                       total_charge=Decimal("999.99"),
                       rendering_provider_npi="2222222222",
                       service_date_from=date(2026, 6, 1))
    remit = make_remit(payer_claim_control_number="OTHER",
                       total_charge_amount=Decimal("100.00"),
                       rendering_provider_npi="1111111111",
                       service_date=date(2026, 6, 5))
    batch = Batch(id="batch-1", kind="835", input_filename="x.835", parsed_at=date(2026, 6, 5))
    db_session.add(batch)
    db_session.flush()
    remit.batch_id = batch.id
    db_session.flush()

    result = reconcile_run(db_session, batch.id)

    assert result.matched == 0
    matches = list(db_session.execute(
        select(Match).where(Match.remittance_id == remit.id)
    ).scalars().all())
    assert len(matches) == 0
    db_session.refresh(claim)
    assert claim.state == ClaimState.SUBMITTED  # unchanged
    db_session.refresh(remit)
    assert remit.claim_id is None  # unchanged


# --- SP32 Task 4: ORM builders wire rendering_provider_npi -----------------


def test_claim_837_row_includes_rendering_provider_npi():
    """SP32: Claim.rendering_provider_npi is populated from ClaimOutput in ORM builder."""
    from pathlib import Path
    from cyclone.parsers.parse_837 import parse
    from cyclone.parsers.payer import PayerConfig
    from cyclone.store.orm_builders import _claim_837_row

    text = Path("tests/fixtures/co_medicaid_837p_with_renderer.txt").read_text()
    parsed = parse(text, PayerConfig.co_medicaid())
    # Find the claim whose rendering NPI was extracted.
    targets = [c for c in parsed.claims if c.rendering_provider_npi == "1234567893"]
    assert len(targets) == 1
    row = _claim_837_row(targets[0], batch_id="test-batch")
    assert row.rendering_provider_npi == "1234567893"


def test_remittance_835_row_includes_service_provider_npi():
    """SP32: Remittance.rendering_provider_npi is populated from ClaimPayment in ORM builder."""
    from cyclone.parsers.parse_835 import parse as parse_835
    from cyclone.store.orm_builders import _remittance_835_row

    text = (
        "ISA*00*          *00*          *ZZ*RECEIVERID     *ZZ*SENDERID       *250101*1200*^*00501*000000001*0*P*:~"
        "GS*HP*RECEIVER*SENDER*20250101*1200*1*X*005010X221A1~"
        "ST*835*0001~"
        "BPR*I*85.40*C*NON*****01*021000021*DA*123456*1511111**01*031302955*DA*9876543~"
        "TRN*1*1511111*1511111~"
        "DTM*405*20250101~"
        "N1*PR*COLORADO MEDICAID*XV*CO MEDICAID~"
        "N3*PO BOX 1100*~"
        "N4*DENVER*CO*80203~"
        "REF*2U*12345~"
        "PER*BL*SUPPORT*TE*8005551212~"
        "N1*PE*ACME CLINIC*XX*1111111111~"
        "LX*1~"
        "CLP*CLM001*1*85.40*85.40*0.00*MC*CLM001*11*1~"
        "CAS*PR*1*0.00~"
        "NM1*1P*2*RENDERING PROVIDER*****XX*2222222222~"
        "SVC*HC:99213*85.40*85.40**1~"
        "DTM*472*20250101~"
        "SE*18*0001~"
        "GE*1*1~"
        "IEA*1*000000001~"
    )
    parsed = parse_835(text, payer_config=None)  # type: ignore[arg-type]
    assert len(parsed.claims) == 1
    assert parsed.claims[0].service_provider_npi == "2222222222"
    row = _remittance_835_row(parsed.claims[0], batch_id="test-batch")
    assert row.rendering_provider_npi == "2222222222"


# --- SP32 Task 5: typed-column NPI preference in _content_keys_match -------


def test_content_keys_match_typed_npi_columns_match(db_session, make_claim, make_remit):
    """SP32: typed-column NPI contributes to the matched set when both sides have it."""
    from cyclone.reconcile import _content_keys_match
    from datetime import date
    claim = make_claim(
        patient_control_number="PCN1",
        total_charge=Decimal("85.40"),
        rendering_provider_npi="2222222222",
        service_date_from=date(2025, 1, 1),
    )
    remit = make_remit(
        payer_claim_control_number="PCN1",
        total_charge_amount=Decimal("85.40"),
        rendering_provider_npi="2222222222",
        service_date=date(2025, 1, 1),
    )
    matched = _content_keys_match(remit, claim)
    assert "npi" in matched
    assert "pcn" in matched
    assert "charge" in matched


def test_content_keys_match_npi_mismatch_does_not_match(db_session, make_claim, make_remit):
    """SP32: NPI arm does not fire when NPIs differ."""
    from cyclone.reconcile import _content_keys_match
    from datetime import date
    claim = make_claim(
        patient_control_number="PCN2",
        total_charge=Decimal("10.00"),
        rendering_provider_npi="2222222222",
        service_date_from=date(2025, 1, 1),
    )
    remit = make_remit(
        payer_claim_control_number="PCN2",
        total_charge_amount=Decimal("10.00"),
        rendering_provider_npi="9999999999",  # different NPI
        service_date=date(2025, 1, 1),
    )
    matched = _content_keys_match(remit, claim)
    assert "npi" not in matched
    assert "pcn" in matched
    assert "charge" in matched
