"""Parse an X12 277CA (Claim Acknowledgment) file.

The 277CA is the *semantic* ack a payer (or its clearinghouse) sends
back after they accept our 837P batch. It contrasts with the 999 ACK
which only reports the *syntactic* envelope status. A 999 "Accepted"
plus a 277CA with STC*A6 means: the file was syntactically valid, but
one or more specific claims were rejected by the payer.

Layout (simplified, single claim)::

    ISA*…~
    GS*HN*…*…*20240620*1200*1*X*005010X214~
    ST*277*0001*005010X214~  (HCPF sometimes sends ST*277CA*0001*005010X214)
    BHT*0085*08*REFNUM*20240620*1200*TH~
    HL*1**20*1~  (Information Source — the payer)
    NM1*PR*2*COLORADO MEDICAL ASSISTANCE PROGRAM*****PI*COMEDASSISTPROG~
    HL*2*1*21*1~  (Information Receiver — us)
    NM1*41*2*DZINESCO*****46*DZINESCO~
    HL*3*2*19*1~  (Subscriber)
    NM1*IL*1*DOE*JOHN****MI*MEMBERID~
    HL*4*3*PT~  (Patient — a claim follows)
    NM1*QC*1*DOE*JANE~
    REF*1K*PAYERCLAIMID123~
    REF*EJ*721587149~
    STC*A6:19:PR*20240620*U*150.00~  (rejected)
    QTY*90*1~
    AMT*YU*150.00~
    DTP*472*RD8*20240601-20240601~
    SE*XX*0001~
    GE*1*1~
    IEA*1*000000001~

The parser walks the HL hierarchy and, for each ``HL*…*PT`` segment,
collects the trailing ``STC*``, ``REF*1K``, ``REF*EJ``, ``AMT*YU`` and
``DTP*472`` siblings into a :class:`ClaimStatus`. STC segments under
non-Patient HLs (provider- or subscriber-level) go into
``unscoped_statuses`` — HCPF sometimes sends a blanket reject at the
subscriber level which we want to surface even though we can't tie it
to a specific Cyclone claim row.
"""
from __future__ import annotations

import logging
from datetime import date

from cyclone.parsers.exceptions import CycloneParseError
from cyclone.parsers.models import BatchSummary, Envelope
from cyclone.parsers.models_277ca import (
    AcknowledgmentHeader,
    ClaimStatus,
    ParseResult277CA,
    classify_status_code,
)
from cyclone.parsers.segments import tokenize

log = logging.getLogger(__name__)


# --------------------------------------------------------------------------- #
# Date / amount parsing
# --------------------------------------------------------------------------- #


def _parse_yyyymmdd(s: str) -> date | None:
    if not s or len(s) != 8 or not s.isdigit():
        return None
    try:
        return date(int(s[0:4]), int(s[4:6]), int(s[6:8]))
    except ValueError:
        return None


def _parse_yymmdd(s: str) -> date | None:
    if not s or len(s) != 6 or not s.isdigit():
        return None
    try:
        return date(2000 + int(s[0:2]), int(s[2:4]), int(s[4:6]))
    except ValueError:
        return None


def _parse_date_loose(s: str) -> date | None:
    """Parse an X12 date — accept 8-digit CCYYMMDD or 6-digit YYMMDD."""
    if not s or not s.isdigit():
        return None
    if len(s) == 8:
        return _parse_yyyymmdd(s)
    if len(s) == 6:
        return _parse_yymmdd(s)
    return None


def _parse_service_date(s: str) -> date | None:
    """Parse DTP*472 values. HCPF sends ``RD8*20240601-20240601`` (range) or ``D8*20240601``.

    For ranges we return the start date — that's what matches the
    Cyclone claim's ``service_date_from``.
    """
    if not s:
        return None
    # RD8 = range of dates (CCYYMMDD-CCYYMMDD); take the start.
    if "-" in s:
        start = s.split("-", 1)[0]
        return _parse_date_loose(start)
    return _parse_date_loose(s)


def _parse_amount(s: str) -> float | None:
    """Parse an X12 monetary value. Returns None on bad input."""
    if not s:
        return None
    try:
        return float(s)
    except ValueError:
        return None


# --------------------------------------------------------------------------- #
# Envelope
# --------------------------------------------------------------------------- #


def _build_envelope(segments: list[list[str]], input_file: str) -> tuple[Envelope, str]:
    """Build the envelope from ISA/GS/ST. Returns ``(envelope, tx_set_id)``.

    ``tx_set_id`` carries the ST01 value (``"277"`` or ``"277CA"``) so
    the rest of the parser doesn't have to re-scan the segment list.
    """
    summary = BatchSummary(input_file=input_file)
    envelope: Envelope | None = None
    tx_set_id: str = ""
    txn_date: date | None = None
    for seg in segments:
        if seg[0] == "ISA":
            try:
                envelope = Envelope(
                    sender_id=seg[6].strip(),
                    receiver_id=seg[8].strip(),
                    control_number=seg[13].strip(),
                    transaction_date=date(2024, 1, 1),
                    implementation_guide=None,
                )
            except (IndexError, ValueError) as exc:
                raise CycloneParseError(f"Bad ISA: {exc}") from exc
        elif seg[0] == "GS" and envelope is not None:
            if len(seg) > 4:
                txn_date = _parse_date_loose(seg[3]) or txn_date
        elif seg[0] == "ST" and envelope is not None:
            if len(seg) > 1:
                tx_set_id = seg[1]
            if len(seg) > 3:
                envelope = envelope.model_copy(update={"implementation_guide": seg[3]})
    if envelope is None:
        raise CycloneParseError("No ISA envelope found")
    if txn_date is not None:
        envelope = envelope.model_copy(update={"transaction_date": txn_date})
    return envelope, tx_set_id


def _build_bht(segments: list[list[str]]) -> AcknowledgmentHeader:
    """Find the BHT segment and return it. Falls back to an empty header."""
    for seg in segments:
        if seg[0] == "BHT":
            return AcknowledgmentHeader(
                hierarchical_structure_code=seg[1] if len(seg) > 1 else "",
                transaction_set_purpose_code=seg[2] if len(seg) > 2 else "",
                reference_identification=seg[3] if len(seg) > 3 and seg[3] else None,
                transaction_set_creation_date=_parse_date_loose(seg[4]) if len(seg) > 4 else None,
                transaction_set_creation_time=seg[5] if len(seg) > 5 and seg[5] else None,
                transaction_type_code=seg[6] if len(seg) > 6 and seg[6] else None,
            )
    return AcknowledgmentHeader(
        hierarchical_structure_code="",
        transaction_set_purpose_code="",
    )


# --------------------------------------------------------------------------- #
# HL-walking and per-claim status construction
# --------------------------------------------------------------------------- #


def _parse_stc(seg: list[str]) -> ClaimStatus:
    """Convert an STC segment into a partial ClaimStatus.

    STC is a composite segment: ``STC*cat:stat:entity*date*action*amount``.
    HCPF only populates ``cat`` (``A6:19:PR``); the rest are optional.
    """
    composite = seg[1] if len(seg) > 1 else ""
    parts = composite.split(":") if composite else []
    category = parts[0] if parts else ""
    status = parts[1] if len(parts) > 1 and parts[1] else None
    entity = parts[2] if len(parts) > 2 and parts[2] else None
    effective = _parse_date_loose(seg[2]) if len(seg) > 2 else None
    action = seg[3] if len(seg) > 3 and seg[3] else None
    amount = _parse_amount(seg[4]) if len(seg) > 4 else None
    return ClaimStatus(
        status_code=category,
        status_description=status,
        entity_identifier=entity,
        status_effective_date=effective,
        status_action_code=action,
        total_claim_charge_amount=amount,
        classification=classify_status_code(category),
    )


def _consume_patient_block(segments: list[list[str]], idx: int) -> tuple[ClaimStatus | None, int]:
    """Read all segments under an HL*…*PT block (until the next HL or SE).

    Returns ``(status, next_idx)``. ``status`` is ``None`` when the block
    contained no STC segments (a Patient HL with no status is unusual
    but not illegal — surface it as ``None`` so the caller can log).
    """
    parts: dict[str, object] = {}
    stc: ClaimStatus | None = None
    i = idx
    while i < len(segments):
        seg = segments[i]
        if seg[0] in {"HL", "SE"}:
            break
        if seg[0] == "REF":
            qualifier = seg[1] if len(seg) > 1 else ""
            value = seg[2] if len(seg) > 2 else ""
            if qualifier == "1K":
                parts["payer_claim_control_number"] = value
            elif qualifier == "EJ":
                parts["billing_provider_tax_id"] = value
        elif seg[0] == "STC":
            # Per X12 005010X214 a Patient HL can have multiple STC
            # segments (one per status). We take the last one as
            # authoritative — that's typically the most recent action
            # (e.g. STC*A1 then STC*A3 means "first pended, then paid").
            stc = _parse_stc(seg)
        elif seg[0] == "AMT":
            qualifier = seg[1] if len(seg) > 1 else ""
            value = _parse_amount(seg[2]) if len(seg) > 2 else None
            if qualifier == "YU" and value is not None:
                parts["total_claim_charge_amount"] = value
        elif seg[0] == "DTP":
            qualifier = seg[1] if len(seg) > 1 else ""
            fmt = seg[2] if len(seg) > 2 else ""
            value = seg[3] if len(seg) > 3 else ""
            if qualifier == "472" and fmt in ("D8", "RD8"):
                parts["service_date"] = _parse_service_date(value)
        i += 1

    if stc is None:
        return None, i

    # Merge the REF/AMT/DTP capture onto the last STC.
    merged = stc.model_copy(update=parts)
    return merged, i


def _consume_subscriber_block(segments: list[list[str]], idx: int) -> tuple[list[ClaimStatus], int]:
    """Read STC segments under a Subscriber (HL*…*19*1) block.

    Subscriber-level STCs have no REF*1K — they apply to the whole
    subscriber's claim batch. Surface them in
    :attr:`ParseResult277CA.unscoped_statuses`.
    """
    out: list[ClaimStatus] = []
    i = idx
    while i < len(segments):
        seg = segments[i]
        if seg[0] in {"HL", "SE"}:
            break
        if seg[0] == "STC":
            out.append(_parse_stc(seg))
        i += 1
    return out, i


# --------------------------------------------------------------------------- #
# Top-level orchestrator
# --------------------------------------------------------------------------- #


def parse_277ca_text(text: str, *, input_file: str = "") -> ParseResult277CA:
    """Parse a complete 277CA document and return a :class:`ParseResult277CA`.

    Both ``ST*277*`` and ``ST*277CA*`` shapes are accepted. Per X12
    005010X214 a 277CA can have multiple Patient HLs — we surface them
    all in ``claim_statuses``. STC segments under Subscriber HLs go
    into ``unscoped_statuses`` because we can't tie them to a Cyclone
    claim row without an explicit REF*1K.

    Whole-document problems (missing ISA, no ST) raise
    :class:`CycloneParseError`. Per-segment quirks are surfaced on the
    result and never raised, matching the 999 parser's contract.
    """
    segments = tokenize(text)
    envelope, tx_set_id = _build_envelope(segments, input_file=input_file)

    if tx_set_id not in ("277", "277CA"):
        raise CycloneParseError(
            f"Expected ST*277 or ST*277CA, got ST*{tx_set_id!r}"
        )

    bht = _build_bht(segments)

    claim_statuses: list[ClaimStatus] = []
    unscoped_statuses: list[ClaimStatus] = []

    # Walk all HL segments, in order.
    for i, seg in enumerate(segments):
        if seg[0] != "HL":
            continue
        # HL04 carries the level code: "20" (info source), "21" (info
        # receiver), "19" (subscriber), "PT" (patient). Only PT and 19
        # carry claim-level STCs.
        level_code = seg[3] if len(seg) > 3 else ""
        if level_code == "PT":
            status, _next = _consume_patient_block(segments, i + 1)
            if status is not None:
                claim_statuses.append(status)
        elif level_code == "19":
            # Subscriber-level: only surface when there's actually an
            # STC under it (otherwise it's a blank frame).
            inner, _next = _consume_subscriber_block(segments, i + 1)
            if inner:
                unscoped_statuses.extend(inner)

    # Summary counts — for the Inbox lane counts.
    total = len(claim_statuses)
    accepted = sum(1 for s in claim_statuses if s.classification == "accepted")
    rejected = sum(1 for s in claim_statuses if s.classification == "rejected")
    pended = sum(1 for s in claim_statuses if s.classification == "pended")
    paid = sum(1 for s in claim_statuses if s.classification == "paid")
    # "failed" is what the Inbox cares about: rejected + pended.
    # Pended claims aren't final (the payer may still pay them) but we
    # surface them under a different lane (Payer-Pended, added later).
    # For the SP10 Payer-Rejected lane only "rejected" counts.
    failed = rejected
    summary = BatchSummary(
        input_file=input_file,
        control_number=envelope.control_number,
        transaction_date=envelope.transaction_date,
        total_claims=total,
        passed=accepted + paid,
        failed=failed,
    )
    _ = pended  # surfaced separately in future SPs; tracked but unused here

    return ParseResult277CA(
        envelope=envelope,
        bht=bht,
        claim_statuses=claim_statuses,
        unscoped_statuses=unscoped_statuses,
        summary=summary,
    )


__all__ = ["parse_277ca_text"]
