r"""Orchestrate parsing an X12 271 (Health Care Eligibility Benefit Response) file.

Single-pass walker over the tokenized segment list:

- ISA / GS / ST — envelope
- BHT (Beginning of Hierarchical Transaction) — header
- HL*20 / NM1*PR — information source (payer)
- HL*21 / NM1*1P — information receiver (provider)
- HL*22 / NM1*IL + DMG — subscriber
- HL*23 / NM1*QC + DMG — patient (optional, when the patient differs from
  the subscriber)
- EB — eligibility/benefit (the heart of the 271)
- DTP — date/time period (folded into ``CoverageBenefit.benefit_date``)
- MSG — free-text description (folded into ``BenefitAdditionalInfo``)
- AAA — request validation (rejection: surfaced as additional info)
- SE / GE / IEA

Per spec section 3.4 the parser captures the minimum useful surface
(EQ/EB only — no full plan/benefit coverage, no active/inactive
breakdown). The ``EB01`` service-type code is resolved via
:func:`cyclone.parsers.models_271.service_type_description` and
populated on :class:`CoverageBenefit.service_type_description`.

EB segment layout (005010X279A1):
  EB01 — service type code (e.g. "1" = Medical Care)
  EB02 — coverage level (e.g. "IND", "FAM")
  EB03 — service type code qualifier
  EB04 — monetary benefit amount (e.g. "100")
  EB05 — in-plan-network indicator ("Y" / "N")
  EB06 — authorized indicator ("Y" / "N")
  EB07 — plan coverage description (free text)
"""

from __future__ import annotations

import logging
from datetime import date
from decimal import Decimal, InvalidOperation

from cyclone.parsers.exceptions import CycloneParseError
from cyclone.parsers.models import BatchSummary, Envelope
from cyclone.parsers.models_271 import (
    BenefitAdditionalInfo,
    CoverageBenefit,
    InformationReceiver271,
    InformationSource271,
    ParseResult271,
    Patient271,
    Subscriber271,
)
from cyclone.parsers.segments import tokenize

log = logging.getLogger(__name__)


# --------------------------------------------------------------------------- #
# Date parsing
# --------------------------------------------------------------------------- #


def _parse_yyyymmdd(s: str) -> date | None:
    """Parse an 8-digit CCYYMMDD string. Returns None on bad input."""
    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_yn(value: str) -> bool | None:
    """Map a "Y"/"N" indicator to a tri-state bool. None if not a known value."""
    v = (value or "").strip().upper()
    if v == "Y":
        return True
    if v == "N":
        return False
    return None


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


def _build_envelope(segments: list[list[str]], input_file: str) -> tuple[Envelope, date | None]:
    """Build the 271 envelope from ISA/GS/ST. Returns (envelope, txn_date)."""
    summary = BatchSummary(input_file=input_file)
    envelope: Envelope | None = None
    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),  # overwritten by GS04
                    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_yyyymmdd(seg[3]) or txn_date
        elif seg[0] == "ST" and envelope is not None:
            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, txn_date


# --------------------------------------------------------------------------- #
# NM1 / HL helpers
# --------------------------------------------------------------------------- #


def _consume_nm1_name(seg: list[str]) -> tuple[str | None, str | None]:
    """Return ``(first_name, last_name)`` for a person NM1, or
    ``(None, org_name)`` for a non-person NM1 (NM102 == "2").
    """
    if len(seg) < 4:
        return None, None
    ent_type = seg[2] if len(seg) > 2 else "1"
    if ent_type == "2":
        return None, seg[3] if len(seg) > 3 else None
    return (
        seg[4] if len(seg) > 4 else None,
        seg[3] if len(seg) > 3 else None,
    )


def _nm1_id_qualifier(seg: list[str]) -> str | None:
    if len(seg) > 8:
        return seg[8] or None
    return None


def _consume_information_source(segments: list[list[str]], idx: int) -> tuple[InformationSource271, int]:
    """HL*20 + NM1*PR → payer."""
    name: str | None = None
    pid: str | None = None
    i = idx
    if i < len(segments) and segments[i][0] == "HL":
        i += 1
    while i < len(segments) and segments[i][0] not in {"HL", "SE", "GE", "IEA"}:
        seg = segments[i]
        if seg[0] == "NM1" and len(seg) > 1 and seg[1] == "PR":
            first, last = _consume_nm1_name(seg)
            nm102 = seg[2] if len(seg) > 2 else "1"
            if nm102 == "2":
                name = last or (seg[3] if len(seg) > 3 else None)
            else:
                name = last
            qual = _nm1_id_qualifier(seg)
            if qual in {"PI", "XV", "24"} and len(seg) > 9:
                pid = seg[9] or pid
        i += 1
    return InformationSource271(name=name or "", id=pid), i


def _consume_information_receiver(segments: list[list[str]], idx: int) -> tuple[InformationReceiver271, int]:
    """HL*21 + NM1*1P → provider."""
    name: str | None = None
    pid: str | None = None
    i = idx
    if i < len(segments) and segments[i][0] == "HL":
        i += 1
    while i < len(segments) and segments[i][0] not in {"HL", "SE", "GE", "IEA"}:
        seg = segments[i]
        if seg[0] == "NM1" and len(seg) > 1 and seg[1] == "1P":
            first, last = _consume_nm1_name(seg)
            nm102 = seg[2] if len(seg) > 2 else "1"
            if nm102 == "2":
                name = last or (seg[3] if len(seg) > 3 else None)
            else:
                name = last
            qual = _nm1_id_qualifier(seg)
            if qual in {"XX", "PI", "XV"} and len(seg) > 9 and seg[9]:
                pid = seg[9]
        i += 1
    return InformationReceiver271(name=name or "", id=pid), i


def _consume_subscriber(segments: list[list[str]], idx: int) -> tuple[Subscriber271, int]:
    """HL*22 + TRN + NM1*IL + DMG → subscriber.

    Stops at the next HL (sibling loop), envelope terminator, or the
    first EB segment — the orchestrator processes EB segments at the
    top level so the summary count and the v1 "one EB cluster per
    parse" convention stay consistent.
    """
    member_id: str = ""
    first_name: str | None = None
    last_name: str | None = None
    i = idx
    if i < len(segments) and segments[i][0] == "HL":
        i += 1
    while i < len(segments) and segments[i][0] not in {"HL", "SE", "GE", "IEA", "EB"}:
        seg = segments[i]
        if seg[0] == "NM1" and len(seg) > 1 and seg[1] == "IL":
            first, last = _consume_nm1_name(seg)
            if first:
                first_name = first
            if last:
                last_name = last
            qual = _nm1_id_qualifier(seg)
            if qual in {"MI", "II", "MC", "HN"} and len(seg) > 9 and seg[9]:
                member_id = seg[9]
            elif not member_id and len(seg) > 9 and seg[9]:
                member_id = seg[9]
        elif seg[0] == "DMG" and len(seg) > 2 and seg[1] == "D8":
            # Subscriber date-of-birth — surfaced on the subscriber
            # model in the 270 but not the 271. We don't have a DOB
            # field on Subscriber271, so we read-and-discard.
            pass
        i += 1
    return Subscriber271(
        member_id=member_id,
        first_name=first_name,
        last_name=last_name,
    ), i


def _consume_patient(segments: list[list[str]], idx: int) -> tuple[Patient271 | None, int]:
    """HL*23 + NM1*QC + DMG → patient (optional)."""
    first_name: str | None = None
    last_name: str | None = None
    relationship: str | None = None
    saw_nm1 = False
    i = idx
    if i < len(segments) and segments[i][0] == "HL":
        if len(segments[i]) > 4 and segments[i][4]:
            relationship = segments[i][4]
        i += 1
    while i < len(segments) and segments[i][0] not in {"HL", "SE", "GE", "IEA"}:
        seg = segments[i]
        if seg[0] == "NM1" and len(seg) > 1 and seg[1] == "QC":
            saw_nm1 = True
            first, last = _consume_nm1_name(seg)
            if first:
                first_name = first
            if last:
                last_name = last
        i += 1
    if not saw_nm1:
        return None, i
    return Patient271(
        first_name=first_name,
        last_name=last_name,
        relationship=relationship,
    ), i


# --------------------------------------------------------------------------- #
# EB / MSG / AAA / DTP
# --------------------------------------------------------------------------- #


def _consume_eb(segments: list[list[str]], idx: int) -> tuple[CoverageBenefit, int]:
    """Read one EB segment + its child MSG/AAA/REF/DTP and return a CoverageBenefit.

    The orchestrator passes ``idx`` pointing at the EB segment. We
    consume the EB itself plus any contiguous MSG/AAA/REF/DTP segments
    that follow. Subsequent EB segments start a new benefit and are
    left for the orchestrator.
    """
    eb = segments[idx]
    service_type_code = eb[1] if len(eb) > 1 else ""
    coverage_level = eb[2] if len(eb) > 2 and eb[2] else None
    benefit_amount: Decimal | None = None
    if len(eb) > 4 and eb[4]:
        try:
            benefit_amount = Decimal(eb[4])
        except (InvalidOperation, ValueError):
            benefit_amount = None
    in_plan_network = _parse_yn(eb[5]) if len(eb) > 5 else None
    authorization_required = _parse_yn(eb[6]) if len(eb) > 6 else None
    plan_coverage_description = eb[7] if len(eb) > 7 and eb[7] else None
    time_qualifier: str | None = None
    benefit_date: date | None = None
    additional_info: list[BenefitAdditionalInfo] = []
    pending_msg: str | None = None
    pending_codes: list[str] = []

    def _flush_msg() -> None:
        """Move any pending MSG description + codes into additional_info."""
        nonlocal pending_msg, pending_codes
        if pending_msg is not None:
            additional_info.append(
                BenefitAdditionalInfo(
                    description=pending_msg,
                    codes=list(pending_codes),
                )
            )
            pending_msg = None
            pending_codes = []

    i = idx + 1
    while i < len(segments) and segments[i][0] not in {"EB", "HL", "SE", "GE", "IEA"}:
        s = segments[i]
        if s[0] == "MSG":
            # Per X12 005010X279A1, MSG attached to an EB is a free-text
            # description. We flush the previous MSG (if any) so multiple
            # MSG segments each get their own additional_info row.
            _flush_msg()
            pending_msg = s[1] if len(s) > 1 else ""
        elif s[0] == "AAA":
            # AAA is a request validation segment — surface the rejection
            # reason code as additional info so the operator can see
            # why a benefit was rejected.
            if len(s) > 3 and s[3]:
                pending_codes.append(s[3])
        elif s[0] == "DTP" and len(s) > 2:
            # DTP01 = date/time qualifier (e.g. "291" = Plan, "292" = Benefit).
            # DTP02 = format qualifier ("D8" = CCYYMMDD).
            # DTP03 = the date itself.
            if s[2] == "D8" and len(s) > 3 and s[3]:
                time_qualifier = s[1] if len(s) > 1 else None
                benefit_date = _parse_yyyymmdd(s[3])
        elif s[0] == "REF":
            # REF attached to EB carries a qualifier + value. We capture
            # the value as a code on the next additional_info row.
            if len(s) > 2 and s[2]:
                pending_codes.append(s[2])
        else:
            # Unknown child — flush and skip.
            _flush_msg()
        i += 1
    _flush_msg()

    return (
        CoverageBenefit(
            service_type_code=service_type_code,
            coverage_level=coverage_level,
            benefit_amount=benefit_amount,
            in_plan_network=in_plan_network,
            authorization_required=authorization_required,
            plan_coverage_description=plan_coverage_description,
            time_qualifier=time_qualifier,
            benefit_date=benefit_date,
            additional_info=additional_info,
        ),
        i,
    )


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


def parse(text: str, *, input_file: str = "") -> ParseResult271:
    """Parse a complete 271 eligibility response document.

    Whole-document problems (missing ISA) raise
    :class:`CycloneParseError`. Per-segment deficiencies are
    silently dropped — the parser is intentionally permissive.
    """
    segments = tokenize(text)
    envelope, _txn_date = _build_envelope(segments, input_file=input_file)

    information_source = InformationSource271(name="", id=None)
    information_receiver = InformationReceiver271(name="", id=None)
    subscriber = Subscriber271(member_id="")
    patient: Patient271 | None = None
    coverage_benefits: list[CoverageBenefit] = []

    i = 0
    while i < len(segments):
        seg = segments[i]
        if seg[0] in {"ISA", "GS", "ST", "SE", "GE", "IEA", "BHT", "TRN", "DMG"}:
            i += 1
            continue
        if seg[0] == "HL":
            if len(seg) > 3 and seg[3] == "20":
                information_source, i = _consume_information_source(segments, i)
                continue
            if len(seg) > 3 and seg[3] == "21":
                information_receiver, i = _consume_information_receiver(segments, i)
                continue
            if len(seg) > 3 and seg[3] == "22":
                subscriber, i = _consume_subscriber(segments, i)
                continue
            if len(seg) > 3 and seg[3] == "23":
                patient, i = _consume_patient(segments, i)
                continue
            i += 1
            continue
        if seg[0] == "NM1" and len(seg) > 1 and seg[1] in {"PR", "1P", "IL", "QC"}:
            i += 1
            continue
        if seg[0] == "EB":
            try:
                cb, i = _consume_eb(segments, i)
                coverage_benefits.append(cb)
            except (IndexError, ValueError, InvalidOperation) as exc:
                log.warning("Bad EB at segment %d: %s", i, exc)
                i += 1
            continue
        i += 1

    summary = BatchSummary(
        input_file=input_file,
        control_number=envelope.control_number,
        transaction_date=envelope.transaction_date,
        total_claims=len(coverage_benefits),
        passed=len(coverage_benefits),
        failed=0,
    )

    return ParseResult271(
        envelope=envelope,
        information_source=information_source,
        information_receiver=information_receiver,
        subscriber=subscriber,
        patient=patient,
        coverage_benefits=coverage_benefits,
        summary=summary,
    )


__all__ = ["parse"]
