"""Inbox lane state and manual match/unmatch operations.

``list_unmatched`` returns the 5-lane inbox view (unmatched claims +
unmatched remits). ``manual_match`` and ``manual_unmatch`` invoke the
reconcile pure functions and persist the resulting Match row (or
remove it). Invalid state transitions surface as ``InvalidStateError``.
"""

from cyclone import db
from cyclone.db import ActivityEvent, Claim, ClaimState, Match, Remittance

from .exceptions import AlreadyMatchedError, InvalidStateError, NotMatchedError
from . import utcnow
from .ui import to_ui_claim_from_orm, to_ui_remittance_from_orm


# -- manual reconciliation (T12) -----------------------------------

def list_unmatched(*, kind: str = "both") -> dict:
    """Return unmatched claims and/or remittances.

    An unmatched claim is one with ``matched_remittance_id IS NULL`` —
    either auto-match never paired it, or it was unpaired by
    ``manual_unmatch``. An unmatched remittance is one with
    ``claim_id IS NULL`` — symmetric FK on the remittance side; we
    update this in ``manual_match`` so the filter reflects the pair.

    Note: T10's ``reconcile.run`` only writes the claim-side FK
    (``Claim.matched_remittance_id``) when auto-pairing. Auto-matched
    remittances therefore still show as "unmatched" here until the
    pair is touched (re-ingest, manual unmatch + rematch). That gap
    is intentional for T12 — fixing it requires modifying T10.

    ``kind`` selects which side(s) to return:
      - "claims": only claims
      - "remittances": only remittances
      - "both": both (default)

    Returns ``{"claims": [...], "remittances": [...]}`` with the
    unused side always an empty list (never absent) so callers can
    unconditionally index.
    """
    if kind not in ("claims", "remittances", "both"):
        raise ValueError(
            f"list_unmatched: unknown kind={kind!r} "
            "(expected 'claims', 'remittances', or 'both')"
        )

    result: dict = {"claims": [], "remittances": []}
    with db.SessionLocal()() as s:
        if kind in ("claims", "both"):
            rows = (
                s.query(Claim)
                .filter(Claim.matched_remittance_id.is_(None))
                .order_by(Claim.id.asc())
                .all()
            )
            for r in rows:
                parsed_at = (
                    r.batch.parsed_at
                    if r.batch is not None
                    else r.service_date_from or utcnow()
                )
                result["claims"].append(
                    to_ui_claim_from_orm(
                        r, batch_id=r.batch_id, parsed_at=parsed_at,
                        # list_unmatched filters matched_remittance_id IS NULL,
                        # so every row has no remittance yet.
                        received_total=0.0,
                    )
                )

        if kind in ("remittances", "both"):
            rows = (
                s.query(Remittance)
                .filter(Remittance.claim_id.is_(None))
                .order_by(Remittance.id.asc())
                .all()
            )
            for r in rows:
                parsed_at = (
                    r.batch.parsed_at
                    if r.batch is not None
                    else r.received_at
                )
                result["remittances"].append(
                    to_ui_remittance_from_orm(
                        r, batch_id=r.batch_id, parsed_at=parsed_at,
                    )
                )
    return result

def manual_match(claim_id: str, remit_id: str) -> dict:
    """Pair a claim with a remittance manually (operator override).

    Steps:
      1. Load the claim; raise ``AlreadyMatchedError`` if it already
         has a match (we never silently overwrite an existing pair).
      2. Load the remittance; raise ``LookupError`` if missing.
      3. Compute the new claim state via ``reconcile.apply_payment``
         (or ``apply_reversal`` for status codes 21/22).
      4. Insert a ``Match`` row with ``strategy="manual"``.
      5. Update the claim (``state``, ``matched_remittance_id``) AND
         the remittance (``claim_id``) so the symmetric FK reflects
         the pair — required for ``list_unmatched`` to drop them.
      6. Record an ``ActivityEvent(kind="manual_match", ...)``.
      7. Commit; return ``{"claim": <ui>, "match": <ui>}``.

    ``reconcile.apply_payment`` may return a noop (claim in terminal
    state); we surface that as ``InvalidStateError`` rather than
    silently pairing, because the operator clearly intended a state
    change. The T15 API endpoint maps this to a 409 Conflict.
    """
    from cyclone import reconcile as _reconcile

    with db.SessionLocal()() as s:
        claim = s.get(Claim, claim_id)
        if claim is None:
            raise LookupError(f"claim {claim_id} not found")
        if claim.matched_remittance_id is not None:
            raise AlreadyMatchedError(
                f"claim {claim_id} already matched to "
                f"{claim.matched_remittance_id}"
            )

        remit = s.get(Remittance, remit_id)
        if remit is None:
            raise LookupError(f"remittance {remit_id} not found")

        prior_state = claim.state
        if remit.is_reversal:
            intent = _reconcile.apply_reversal(claim, remit)
        else:
            intent = _reconcile.apply_payment(
                claim, remit,
                charge=claim.charge_amount,
                paid=remit.total_paid,
                status_code=remit.status_code,
            )

        if intent.skipped or intent.new_state is None:
            current = (
                claim.state.value
                if hasattr(claim.state, "value")
                else str(claim.state)
            )
            raise InvalidStateError(
                current_state=current,
                activity_kind=intent.activity_kind,
            )

        new_state = intent.new_state
        now = utcnow()

        s.add(Match(
            claim_id=claim_id,
            remittance_id=remit_id,
            strategy="manual",
            matched_at=now,
            prior_claim_state=prior_state,
            is_reversal=remit.is_reversal,
        ))
        claim.state = new_state
        claim.matched_remittance_id = remit_id
        # Symmetric FK update — see list_unmatched docstring for why
        # we don't rely on T10 to do this.
        remit.claim_id = claim_id

        # SP7: line-level reconciliation + claim-level CAS aggregate.
        # Skipped for reversals — they don't have SV1↔SVC line pairs.
        if not remit.is_reversal:
            _reconcile._reconcile_pair(s, claim, remit)

        s.add(ActivityEvent(
            ts=now,
            kind="manual_match",
            batch_id=remit.batch_id,
            claim_id=claim_id,
            remittance_id=remit_id,
            payload_json={
                "strategy": "manual",
                "new_state": new_state.value,
                "prior_state": prior_state.value,
                "is_reversal": remit.is_reversal,
            },
        ))

        s.commit()

        parsed_at = (
            claim.batch.parsed_at
            if claim.batch is not None
            else now
        )
        claim_dict = to_ui_claim_from_orm(
            claim, batch_id=claim.batch_id, parsed_at=parsed_at,
            received_total=float(remit.total_paid or 0),
        )
        matched_at_iso = now.isoformat().replace("+00:00", "Z")
        return {
            "claim": claim_dict,
            "match": {
                "strategy": "manual",
                "claimId": claim_id,
                "remittanceId": remit_id,
                "matchedAt": matched_at_iso,
                "isReversal": remit.is_reversal,
                "priorState": prior_state.value,
                "newState": new_state.value,
            },
        }

def manual_unmatch(claim_id: str) -> dict:
    """Unpair a previously matched claim and restore its prior state.

    Reverses ``manual_match`` (and auto-match as a side-effect of
    clearing the FK). Strategy:

      1. Load the claim; raise ``NotMatchedError`` if it isn't
         currently matched.
      2. Delete every ``Match`` row for the claim (there may be more
         than one — reversals create a 2nd row, see T10 spec).
      3. Restore ``claim.state`` from the latest Match's
         ``prior_claim_state``; fall back to ``SUBMITTED`` when
         ``prior_claim_state`` is NULL (auto-match doesn't set it for
         non-reversal payments — see reconcile.run line ~278).
      4. Clear ``claim.matched_remittance_id`` and the symmetric
         ``remit.claim_id`` so ``list_unmatched`` surfaces the pair
         again.
      5. Record ``ActivityEvent(kind="manual_unmatch", ...)`` and
         commit.
      6. Return ``{"claim": <ui>, "deletedMatches": <count>}``.
    """
    with db.SessionLocal()() as s:
        claim = s.get(Claim, claim_id)
        if claim is None:
            raise LookupError(f"claim {claim_id} not found")
        if claim.matched_remittance_id is None:
            raise NotMatchedError(
                f"claim {claim_id} has no active match"
            )

        matches = (
            s.query(Match)
            .filter(Match.claim_id == claim_id)
            .order_by(Match.matched_at.desc())
            .all()
        )
        if not matches:
            # Defensive: matched_remittance_id was set but no Match
            # rows exist. Shouldn't happen, but if it does, fall back
            # to clearing the FK and starting fresh.
            latest = None
            paired_remit = None
            restored_state = ClaimState.SUBMITTED
        else:
            latest = matches[0]
            paired_remit = s.get(Remittance, latest.remittance_id)
            restored_state = (
                latest.prior_claim_state
                if latest.prior_claim_state is not None
                else ClaimState.SUBMITTED
            )

        deleted_count = len(matches)
        for m in matches:
            s.delete(m)

        claim.state = restored_state
        claim.matched_remittance_id = None
        # Clear the symmetric FK on the remittance so list_unmatched
        # surfaces the pair again. The remittance may have been
        # deleted between the match and this call — guard with a
        # None check so we don't blow up on a stale FK.
        if paired_remit is not None:
            paired_remit.claim_id = None

        now = utcnow()
        s.add(ActivityEvent(
            ts=now,
            kind="manual_unmatch",
            claim_id=claim_id,
            payload_json={
                "restored_state": restored_state.value,
                "deleted_matches": deleted_count,
            },
        ))

        s.commit()

        parsed_at = (
            claim.batch.parsed_at
            if claim.batch is not None
            else now
        )
        # ``paired_remit`` is the matched remittance we cleared in
        # the unmatch; use its ``total_paid`` for the response shape
        # so the UI sees what was paid before the unpair. May be
        # ``None`` if the remittance was deleted since the match —
        # default to 0.0 in that case.
        received_total = (
            float(paired_remit.total_paid or 0)
            if paired_remit is not None
            else 0.0
        )
        claim_dict = to_ui_claim_from_orm(
            claim, batch_id=claim.batch_id, parsed_at=parsed_at,
            received_total=received_total,
        )
        return {
            "claim": claim_dict,
            "deletedMatches": deleted_count,
        }

# ------------------------------------------------------------------
