"""Handle a 999 Implementation Acknowledgment file (SP27 Task 2).

Lifted verbatim from ``scheduler.py:_handle_999``. The handler owns
its own DB session, dispatches to ``parse_999_text``, applies 999
rejections to any matched claims via ``inbox_state.apply_999_rejections``,
persists the ack row, and returns a ``(parser_used, claim_count)``
tuple.

The actor tag (``"999-parser-scheduler"``) is preserved so the audit
log keeps tracing back to the same source after extraction. Both
the FastAPI endpoint and the scheduler path (re)use this module —
see Task 6 for the API migration that drops the inline copy.

``claim_count`` mirrors ``parsed.received`` — the count of AK2
(set-level) responses in the 999, which is the authoritative
per-claim accept/reject signal (see ``_ack_id`` for why AK9 is
ignored).
"""
from __future__ import annotations

import json
import logging

from cyclone import db
from cyclone.audit_log import AuditEvent, append_event
from cyclone.claim_acks import apply_999_acceptances
from cyclone.handlers._ack_id import (
    ack_count_summary,
    ack_synthetic_source_batch_id,
)
from cyclone.inbox_state import apply_999_rejections
from cyclone.parsers.exceptions import CycloneParseError
from cyclone.parsers.parse_999 import parse_999_text
from cyclone.store import store as cycl_store

log = logging.getLogger(__name__)


def handle(
    text: str,
    source_file: str,
) -> tuple[str, int]:
    """Parse a 999, apply rejections, persist ack row.

    Args:
        text: Raw 999 document bytes (decoded).
        source_file: Filename the 999 came from. Used to derive a
            unique synthetic ``batches.id`` (see ``_ack_id``).

    Returns:
        ``(parser_used, claim_count)`` tuple. Matches the scheduler
        ``_download_and_parse`` destructure; the HandleResult
        migration happens in Task 7.

    Raises:
        ValueError: on parser-level failure (wraps CycloneParseError).

    SP25: the store (``cyclone.store.acks.add_999_ack``) now owns the
    publish-from-store contract; the handler no longer needs an
    ``event_bus`` kwarg. Both the scheduler path (no bus) and the
    FastAPI endpoint path (passes the bus to the store directly) see
    the same row, the same event, and the same payload.
    """
    try:
        result = parse_999_text(text, input_file=source_file)
    except CycloneParseError as exc:
        raise ValueError(f"999 parse error: {exc}") from exc

    received, accepted, rejected, ack_code = ack_count_summary(result)
    icn = result.envelope.control_number
    pcn = (
        result.set_responses[0].set_control_number
        if result.set_responses else None
    )
    synthetic_id = ack_synthetic_source_batch_id(
        icn, pcn=pcn, source_filename=source_file,
    )

    # Build the batch envelope index BEFORE opening the work session
    # — cycl_store.batch_envelope_index opens its own short-lived
    # session, and SQLite + concurrent sessions causes "database is
    # locked" errors.
    batch_index = cycl_store.batch_envelope_index()

    with db.SessionLocal()() as session:
        def _lookup(pcn: str):
            return (
                session.query(db.Claim)
                .filter_by(patient_control_number=pcn)
                .first()
            )
        rejection_result = apply_999_rejections(
            session, result,
            claim_lookup=_lookup,
            batch_envelope_index=batch_index,
        )
        if rejection_result.matched:
            for cid in rejection_result.matched:
                append_event(session, AuditEvent(
                    event_type="claim.rejected",
                    entity_type="claim",
                    entity_id=cid,
                    payload={"source_batch_id": synthetic_id},
                    actor="999-parser-scheduler",
                ))
        row = cycl_store.add_ack(
            source_batch_id=synthetic_id,
            accepted_count=accepted,
            rejected_count=rejected,
            received_count=received,
            ack_code=ack_code,
            raw_json=json.loads(result.model_dump_json()),
        )
        # SP28: auto-link the 999 AK2 set-responses to claims (D10 two-pass
        # join). The PCN fallback only fires when the ST02 lookup misses
        # (rare for Gainwell batches). Each created ClaimAck row is
        # persisted via cycl_store.add_claim_ack so the publish-from-store
        # contract owns the live-tail event.
        def _pcn_lookup(pcn: str):
            return (
                session.query(db.Claim)
                .filter_by(patient_control_number=pcn)
                .first()
            )

        link_result = apply_999_acceptances(
            session, result, ack_id=row.id,
            batch_envelope_index=batch_index,
            pc_claim_lookup=_pcn_lookup,
        )
        # Snapshot the rows before closing the work session — SQLite
        # + concurrent sessions from the same thread cause "database
        # is locked" errors when add_claim_ack opens its own session
        # while this one is still open.
        link_rows = list(link_result.linked)
        orphans = list(link_result.orphans)
        session.commit()

    for link_row in link_rows:
        cycl_store.add_claim_ack(
            claim_id=link_row.claim_id,
            batch_id=link_row.batch_id,
            ack_id=row.id,
            ack_kind="999",
            ak2_index=link_row.ak2_index,
            set_control_number=link_row.set_control_number,
            set_accept_reject_code=link_row.set_accept_reject_code,
            linked_by="auto",
        )
    if orphans:
        log.warning(
            "999 had %d orphan set refs (no matching claim): %s",
            len(orphans),
            orphans[:5],
        )

    return ("parse_999", received)
