r"""Orchestrate parsing an X12 270 (Health Care Eligibility Benefit Inquiry) file.

Single-pass walker over the tokenized segment list:

- ISA / GS / ST — envelope
- BHT (Beginning of Hierarchical Transaction) — header (we don't surface
  this in v1; the envelope's ``transaction_date`` is sourced from GS04)
- 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, only when the patient
  differs from the subscriber)
- EQ — eligibility/benefit inquiry
- SE / GE / IEA

Errors at the file level raise :class:`CycloneParseError`. Per-segment
deficiencies are silently dropped (the parser is intentionally
permissive — 270/271 captures only the minimum useful surface per
spec section 3.4).

The 270 ``EQ01`` element is a composite service-type code list. When
the inquiry lists multiple service types in a single composite (e.g.
``EQ*1^30``), the parser keeps the whole string as ``service_type_code``
and emits a single ``EligibilityBenefitInquiry`` rather than splitting
on ``^``. Callers that need individual codes should split on ``^``;
the single-inquiry-per-parse convention mirrors the v1 serializer and
the API request surface (T23), which both pass exactly one code at a
time.
"""

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_270 import (
    EligibilityBenefitInquiry,
    InformationReceiver270,
    InformationSource270,
    ParseResult270,
    Patient270,
    Subscriber270,
)
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_yymmdd(s: str) -> date | None:
    """Parse a 6-digit YYMMDD string with a 30-year pivot. Returns None on bad input."""
    if not s or len(s) != 6 or not s.isdigit():
        return None
    try:
        yy = int(s[0:2])
        mm = int(s[2:4])
        dd = int(s[4:6])
        year = 2000 + yy if yy < 30 else 1900 + yy
        return date(year, mm, dd)
    except ValueError:
        return None


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


def _build_envelope(segments: list[list[str]], input_file: str) -> tuple[Envelope, date | None]:
    """Build the 270 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 below
                    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


# --------------------------------------------------------------------------- #
# Per-NM1 helpers
# --------------------------------------------------------------------------- #


def _consume_nm1_name(seg: list[str]) -> tuple[str | None, str | None, str | None, str | None]:
    """Pull (first_name, last_name, mid_name_or_init, id_qualifier, id_value) from an NM1.

    NM1*IL*1*DOE*JOHN*A***MI*MEMBER123
      NM101 = entity identifier (e.g. "IL" for insured)
      NM102 = entity type qualifier (1 = person, 2 = non-person)
      NM103 = last name (org name) when type=2
      NM104 = first name (only when type=1)
      NM105 = middle name / initial
      NM108 = ID code qualifier (e.g. "MI" member id, "XX" NPI, "PI" payer id)
      NM109 = ID value

    Returns ``(first_name, last_name, middle, raw_id_value)``; the
    caller resolves the ID qualifier from NM108.
    """
    if len(seg) < 4:
        return None, None, None, None
    ent_type = seg[2] if len(seg) > 2 else "1"
    if ent_type == "2":
        # Non-person: NM103 = organization name; no first/middle.
        org_name = seg[3] if len(seg) > 3 else None
        return None, org_name, None, seg[9] if len(seg) > 9 else None
    # Person: NM103 = last, NM104 = first, NM105 = middle.
    last_name = seg[3] if len(seg) > 3 else None
    first_name = seg[4] if len(seg) > 4 else None
    middle = seg[5] if len(seg) > 5 else None
    id_value = seg[9] if len(seg) > 9 else None
    return first_name, last_name, middle, id_value


def _nm1_id_qualifier(seg: list[str]) -> str | None:
    """Return NM108 (ID code qualifier) or None if absent."""
    if len(seg) > 8:
        return seg[8] or None
    return None


# --------------------------------------------------------------------------- #
# Per-HL-loop helpers
# --------------------------------------------------------------------------- #


def _consume_information_source(segments: list[list[str]], idx: int) -> tuple[InformationSource270, int]:
    """Read HL*20 + NM1*PR and return the payer (information source).

    Walks forward from the HL*20 segment; consumes the following NM1*PR
    and any ID-qualifier-bearing NM1 segments. Stops at the next HL or
    SE/GE/IEA.
    """
    name: str | None = None
    pid: str | None = None
    i = idx
    if i < len(segments) and segments[i][0] == "HL":
        i += 1  # skip the HL segment itself
    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":
            _, org_name, _, id_value = _consume_nm1_name(seg)
            if org_name:
                name = org_name
            elif len(seg) > 3 and seg[3]:
                name = seg[3]
            # Pick up the ID qualifier + value (e.g. PI / SKCO0).
            qual = _nm1_id_qualifier(seg)
            if id_value and qual in {"PI", "XV", "24"}:
                pid = id_value
        i += 1
    return InformationSource270(name=name or "", id=pid), i


def _consume_information_receiver(segments: list[list[str]], idx: int) -> tuple[InformationReceiver270, int]:
    """Read HL*21 + NM1*1P and return the provider (information receiver)."""
    name: str | None = None
    pid: str | None = None
    npi: 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":
            _, org_name, _, id_value = _consume_nm1_name(seg)
            if org_name:
                name = org_name
            elif len(seg) > 3 and seg[3]:
                name = seg[3]
            qual = _nm1_id_qualifier(seg)
            if id_value:
                if qual == "XX":
                    npi = id_value
                elif qual in {"PI", "XV", "24"} and pid is None:
                    pid = id_value
        i += 1
    return InformationReceiver270(name=name or "", id=pid, npi=npi), i


def _consume_subscriber(segments: list[list[str]], idx: int) -> tuple[Subscriber270, int]:
    """Read HL*22 + TRN + NM1*IL + DMG and return the subscriber.

    Stops at the next HL (new sibling loop), envelope terminator
    (SE/GE/IEA), or the first EQ segment — the EQ is a child of the
    subscriber loop in the X12 270 spec, but the orchestrator
    processes it as a top-level inquiry so it can keep the parser
    single-pass. Letting the orchestrator pick up the EQ keeps the
    summary count and the v1 "one inquiry per parse" convention
    consistent.
    """
    member_id: str = ""
    first_name: str | None = None
    last_name: str | None = None
    dob: date | 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", "EQ"}:
        seg = segments[i]
        if seg[0] == "NM1" and len(seg) > 1 and seg[1] == "IL":
            first, last, _middle, id_value = _consume_nm1_name(seg)
            if first:
                first_name = first
            if last:
                last_name = last
            qual = _nm1_id_qualifier(seg)
            if id_value and qual in {"MI", "II", "MC", "HN"}:
                member_id = id_value
            elif id_value and not member_id:
                # Fall back: take whatever ID the payer sent if no recognized
                # qualifier is present. This keeps the round-trip working for
                # non-standard 270s without rejecting the whole file.
                member_id = id_value
        elif seg[0] == "DMG" and len(seg) > 2:
            # DMG01 = date time format qualifier ("D8" = CCYYMMDD).
            if seg[1] == "D8":
                dob = _parse_yyyymmdd(seg[2]) if len(seg) > 2 else None
        i += 1
    return Subscriber270(
        member_id=member_id,
        first_name=first_name,
        last_name=last_name,
        dob=dob,
    ), i


def _consume_patient(segments: list[list[str]], idx: int) -> tuple[Patient270 | None, int]:
    """Read HL*23 + NM1*QC + DMG and return the patient (optional).

    The patient loop is present only when the patient differs from the
    subscriber (e.g. a dependent). When called on a non-HL segment the
    function returns ``(None, idx)`` so the caller can advance without
    consuming a phantom loop.
    """
    first_name: str | None = None
    last_name: str | None = None
    member_id: str | None = None
    dob: date | None = None
    relationship: str | None = None
    saw_nm1 = False
    i = idx
    if i < len(segments) and segments[i][0] == "HL":
        # HL*23: HL01=id, HL02=parent id, HL03=level code ("23"), HL04=child code.
        # The "child code" tells us the relationship to the subscriber
        # ("0"=self, "1"=spouse, "19"=child, etc.).
        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", "EQ"}:
        seg = segments[i]
        if seg[0] == "NM1" and len(seg) > 1 and seg[1] == "QC":
            saw_nm1 = True
            first, last, _middle, id_value = _consume_nm1_name(seg)
            if first:
                first_name = first
            if last:
                last_name = last
            if id_value:
                member_id = id_value
        elif seg[0] == "DMG" and len(seg) > 2 and seg[1] == "D8":
            dob = _parse_yyyymmdd(seg[2])
        i += 1
    if not saw_nm1:
        # No QC segment found — the HL wasn't a patient loop; don't surface a row.
        return None, i
    return Patient270(
        first_name=first_name,
        last_name=last_name,
        member_id=member_id,
        dob=dob,
        relationship=relationship,
    ), i


# --------------------------------------------------------------------------- #
# EQ helper
# --------------------------------------------------------------------------- #


def _consume_eq(segments: list[list[str]], idx: int) -> tuple[EligibilityBenefitInquiry, int]:
    """Read a single EQ segment and return the inquiry. The full composite
    ``EQ01`` is kept as ``service_type_code`` (e.g. ``"1^30"``).
    """
    seg = segments[idx]
    service_type_code = seg[1] if len(seg) > 1 else ""
    coverage_info = seg[2] if len(seg) > 2 and seg[2] else None
    return (
        EligibilityBenefitInquiry(
            service_type_code=service_type_code,
            coverage_info=coverage_info,
        ),
        idx + 1,
    )


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


def parse(text: str, *, input_file: str = "") -> ParseResult270:
    """Parse a complete 270 eligibility inquiry document.

    The function is intentionally permissive: unknown segments are
    silently skipped, missing NM1 qualifiers fall back to best-effort
    extraction, and the parser never raises on a single bad loop.
    Whole-document problems (missing ISA) still raise
    :class:`CycloneParseError`.
    """
    segments = tokenize(text)
    envelope, _txn_date = _build_envelope(segments, input_file=input_file)

    information_source = InformationSource270(name="", id=None)
    information_receiver = InformationReceiver270(name="", id=None, npi=None)
    subscriber = Subscriber270(member_id="")
    patient: Patient270 | None = None
    inquiries: list[EligibilityBenefitInquiry] = []

    i = 0
    while i < len(segments):
        seg = segments[i]
        if seg[0] in {"ISA", "GS", "ST", "SE", "GE", "IEA", "BHT", "TRN"}:
            i += 1
            continue
        if seg[0] == "HL":
            # HL03 = level code: 20 (information source), 21 (information
            # receiver), 22 (subscriber), 23 (patient).
            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
            # Unknown HL level code — skip one segment to avoid infinite loops.
            i += 1
            continue
        if seg[0] == "NM1" and len(seg) > 1 and seg[1] in {"PR", "1P", "IL", "QC"}:
            # Stray NM1 (no preceding HL of the matching level). Skip
            # defensively to avoid infinite loops; v1 doesn't have any
            # in the wild.
            i += 1
            continue
        if seg[0] == "EQ":
            try:
                inq, i = _consume_eq(segments, i)
                inquiries.append(inq)
            except (IndexError, ValueError) as exc:
                log.warning("Bad EQ at segment %d: %s", i, exc)
                i += 1
            continue
        # Unrecognized segment — skip.
        i += 1

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

    return ParseResult270(
        envelope=envelope,
        information_source=information_source,
        information_receiver=information_receiver,
        subscriber=subscriber,
        patient=patient,
        inquiries=inquiries,
        summary=summary,
    )


__all__ = ["parse"]
