"""Serialize a :class:`ClaimOutput` to a complete X12 837P text.

Full rebuild (Approach A — see plan amendment 2026-06-20). Every segment
in the output is emitted from canonical :class:`ClaimOutput` fields;
``raw_segments`` is not consulted.

Why full rebuild instead of the spec's §3.1 hybrid:

- ``ClaimOutput.raw_segments`` only captures post-CLM segments (CLM,
  REF*G1, HI, LX, SV1 pairs). The envelope (ISA/GS/ST/BHT) and
  hierarchy segments (NM1, N3, N4, HL, PRV, SBR, DMG, REF*EI) are not
  captured, so a pass-through approach cannot regenerate them
  byte-for-byte. Expanding ``raw_segments`` would require modifying
  ``parse_837.py`` (out of scope for this SP).

- The 837P spec is stable; the emitter only needs canonical fields
  that already live on ``ClaimOutput``. No data loss vs. the original
  inbound file for any field the user can edit (charge, frequency,
  prior_auth, diagnoses, service lines, dates).

The serializer emits:

- ISA / GS / ST / SE / GE / IEA — envelope (caller supplies the
  control numbers via ``interchange_control_number`` /
  ``group_control_number`` so back-to-back resubmits get unique values)
- BHT — from ``claim.transaction_type_code`` + ``claim.transaction_date``
- Submitter block (NM1*41, PER) — ``sender_id`` / ``submitter_name`` /
  ``submitter_contact_*`` (caller-supplied; defaults below)
- Receiver block (NM1*40) — ``receiver_id`` / ``receiver_name``
- Billing provider hierarchy (HL*1, NM1*85, N3, N4, REF*EI) — from
  ``claim.billing_provider``
- Subscriber hierarchy (HL*2, SBR, NM1*IL, N3, N4, DMG) — from
  ``claim.subscriber``
- Payer (NM1*PR) — from ``claim.payer``
- CLM / REF*G1 / HI — from ``claim.claim`` / ``claim.diagnoses``
- Per service line: LX / SV1 / DTP*472 / REF*6R — from
  ``claim.service_lines``

If ``claim.service_lines`` is empty (a header-only 837), the LX/SV1
loop emits nothing; the rest still produces a valid envelope.

Round-trip is verified by ``backend/tests/test_serialize_837.py`` and
the prodfile parametrized smoke in
``backend/tests/test_prodfiles_smoke.py``.
"""
from __future__ import annotations

import logging
from datetime import date, datetime
from decimal import Decimal
from types import SimpleNamespace

from cyclone.parsers.models import ClaimOutput

_SEG = "~"
_ELEM = "*"
_ISA_COMPONENT_SEPARATOR = ":"
_ISA_REPETITION_SEPARATOR = "^"
_IMPLEMENTATION_GUIDE = "005010X222A1"
_FUNCTIONAL_ID_HEALTH_CARE = "HC"


class SerializeError(Exception):
    """Raised when a claim cannot be serialized."""


# ---------------------------------------------------------------------------
# Envelope helpers
# ---------------------------------------------------------------------------


def _today_yymmdd() -> str:
    t = date.today()
    return f"{t.year % 100:02d}{t.month:02d}{t.day:02d}"


def _today_yyyymmdd() -> str:
    t = date.today()
    return f"{t.year:04d}{t.month:02d}{t.day:02d}"


def _today_hhmm() -> str:
    t = datetime.now()
    return f"{t.hour:02d}{t.minute:02d}"


def _pad(s: str | None, width: int) -> str:
    return (s or "").ljust(width)[:width]


def _build_isa(sender_id: str, receiver_id: str, interchange_control_number: str) -> str:
    """Fixed-width 106-char ISA envelope (mirrors serialize_999 layout)."""
    parts = [
        "ISA",
        "00", _pad("", 10),
        "00", _pad("", 10),
        "ZZ", _pad(sender_id, 15),
        "ZZ", _pad(receiver_id, 15),
        _today_yymmdd(),
        _today_hhmm(),
        _ISA_REPETITION_SEPARATOR,
        "00501",
        _pad(interchange_control_number, 9),
        "0",
        "P",
        _ISA_COMPONENT_SEPARATOR,
    ]
    return _ELEM.join(parts) + _SEG


def _build_gs(sender_id: str, receiver_id: str, group_control_number: str) -> str:
    parts = [
        "GS",
        _FUNCTIONAL_ID_HEALTH_CARE,
        sender_id,
        receiver_id,
        # GS-04 must be CCYYMMDD (8 digits) per X12 — ISA uses YYMMDD
        # (6 digits) for the older format, but the GS segment is the
        # newer ANSI X12 format and requires the full year.
        _today_yyyymmdd(),
        _today_hhmm(),
        group_control_number,
        "X",
        _IMPLEMENTATION_GUIDE,
    ]
    return _ELEM.join(parts) + _SEG


def _build_st(control_number: str) -> str:
    return _ELEM.join(["ST", "837", control_number, _IMPLEMENTATION_GUIDE]) + _SEG


def _build_se(count: int, control_number: str) -> str:
    return _ELEM.join(["SE", str(count), control_number]) + _SEG


def _build_bht(
    *,
    transaction_type_code: str | None,
    reference_id: str,
    transaction_date: date | None,
    transaction_time: str | None,
) -> str:
    """BHT segment. BHT06 transaction type code defaults to 'CH' (chargeable)."""
    bht06 = transaction_type_code or "CH"
    date_str = transaction_date.strftime("%Y%m%d") if transaction_date else _today_yyyymmdd()
    time_str = transaction_time or _today_hhmm()
    parts = [
        "BHT",
        "0019",        # BHT01 — hierarchical structure (originator)
        "00",          # BHT02 — purpose code (original)
        reference_id,  # BHT03 — reference identification
        date_str,      # BHT04 — transaction set creation date
        time_str,      # BHT05 — transaction set creation time
        bht06,         # BHT06 — transaction type code
    ]
    return _ELEM.join(parts) + _SEG


# ---------------------------------------------------------------------------
# Hierarchy segment builders
# ---------------------------------------------------------------------------


def _build_nm1(entity_id_qualifier: str, entity_type: str, name: str,
               id_code_qualifier: str | None, id_code: str | None) -> str:
    """Generic NM1 segment. entity_type is the 2nd element ('85', 'IL', 'PR', etc.)."""
    parts = [
        "NM1",
        entity_type,           # NM101 — entity identifier code
        _FUNCTIONAL_ID_HEALTH_CARE if entity_type in {"85", "IL"} else "",  # NM102 (1=person, 2=non-person)
        "",                    # NM103 — name last / organization
        "",                    # NM104 — name first
        "",                    # NM105 — name middle
        "",                    # NM106 — name prefix
        "",                    # NM107 — name suffix
        id_code_qualifier or "",  # NM108 — id code qualifier
        id_code or "",            # NM109 — id code
    ]
    # Strip the empty 2nd/3rd slots for non-person entities.
    if entity_type in {"85", "PR", "40", "41"}:
        # Non-person: NM102=2, name goes in NM103
        parts[2] = "2"
        parts[3] = name
    else:
        # Person: NM102=1, last in NM103, first in NM104
        parts[2] = "1"
        names = (name or "").rsplit(" ", 1)
        parts[3] = names[0] if names else ""
        parts[4] = names[1] if len(names) > 1 else ""
    return _ELEM.join(parts) + _SEG


def _build_per(
    contact_name: str | None,
    contact_phone: str | None,
    contact_email: str | None = None,
    email_qual: str = "EM",
) -> str:
    """PER segment — submitter contact (Loop 1000A).

    X12 005010X222A1 *requires* at least one PER segment in Loop 1000A
    (Submitter Name) and at least PER01 must be present, so this
    builder always emits a segment. PER01 = "IC" (Information Contact).

    The remaining elements are filled from the available contact info:
    name, then email (preferred — Gainwell/HCPF expect this), then phone.
    """
    parts = ["PER", "IC"]
    if contact_name:
        parts.append(contact_name)
    if contact_email:
        parts.append(email_qual)        # PER03 — email qualifier (default "EM")
        parts.append(contact_email)     # PER04 — the email itself
    elif contact_phone:
        parts.append("TE")              # PER03 — phone qualifier
        parts.append(contact_phone)     # PER04 — the phone itself
    return _ELEM.join(parts) + _SEG


def _build_n3(line1: str | None, line2: str | None) -> str:
    if not line1:
        return ""
    parts = ["N3", line1]
    if line2:
        parts.append(line2)
    return _ELEM.join(parts) + _SEG


def _build_n4(city: str | None, state: str | None, zip_code: str | None) -> str:
    if not city and not state and not zip_code:
        return ""
    parts = ["N4", city or "", state or "", zip_code or ""]
    return _ELEM.join(parts) + _SEG


def _build_dmg(dob: date | None, gender: str | None) -> str:
    """DMG segment — subscriber demographics."""
    if not dob and not gender:
        return ""
    parts = ["DMG", "D8", dob.strftime("%Y%m%d") if dob else "", gender or ""]
    return _ELEM.join(parts) + _SEG


def _build_hl(hl_id: str, parent_id: str, level_code: str, child_code: str) -> str:
    """HL segment — hierarchical level. child_code='0' when this HL has no children."""
    parts = [
        "HL",
        hl_id,
        parent_id if parent_id else "",
        level_code,
        child_code,
    ]
    return _ELEM.join(parts) + _SEG


def _build_sbr(
    individual_relationship_code: str | None,
    claim_filing_indicator_code: str | None,
) -> str:
    """SBR segment — subscriber information.

    Slot layout (X12 005010X222A1):
      SBR01 — Payer Responsibility Sequence Number Code. Default ``"P"``
              (Patient = primary). The parser does not capture this
              field on ``ClaimOutput`` so we default it.
      SBR02 — Individual Relationship Code. ``"18"`` = self, ``"01"`` = spouse, etc.
              The parser does not capture this either; we default ``"18"``
              for the common self-pay case.
      SBR09 — Claim Filing Indicator Code. ``"MC"`` for Medicaid,
              ``"16"`` for Medicare Part B, etc. The canonical
              PayerConfig837 carries ``sbr09_default``; we thread it in
              from the caller.

    The member_id and payer name do NOT belong in SBR — the member_id
    lives in NM109 of the NM1*IL segment, and the payer name is in
    NM103 of NM1*PR. (Earlier revisions of this function put them in
    SBR06 / SBR09, which is wrong and rejected by HCPF.)
    """
    parts = [
        "SBR",
        "P",                                    # SBR01 — primary
        individual_relationship_code or "18",   # SBR02 — self
        "",                                     # SBR03 — group number
        "",                                     # SBR04 — group name
        "",                                     # SBR05 — insurance type code
        "",                                     # SBR06 — coordination of benefits
        "",                                     # SBR07 — yes/no condition
        "",                                     # SBR08 — employment status code
        claim_filing_indicator_code or "",      # SBR09 — claim filing indicator
    ]
    return _ELEM.join(parts) + _SEG


def _build_ref(qualifier: str, value: str | None) -> str:
    if not value:
        return ""
    return _ELEM.join(["REF", qualifier, value]) + _SEG


# ---------------------------------------------------------------------------
# Claim-level segment builders
# ---------------------------------------------------------------------------


def _build_clm(claim) -> str:
    """CLM01..CLM09.

    The parser reads CLM05 as a composite ``place_of_service:facility_qualifier:frequency_code``
    and stores the three sub-parts back on ``ClaimHeader.place_of_service``,
    ``.facility_code_qualifier``, ``.frequency_code``. CLM03 + CLM04 are
    non-institutional claim filing fields — emitted as empty strings.
    """
    charge = f"{Decimal(claim.total_charge or 0):.2f}"

    # CLM05 composite — composed from the three canonical fields.
    clm05_parts: list[str] = []
    if claim.place_of_service:
        clm05_parts.append(claim.place_of_service)
    if claim.facility_code_qualifier:
        clm05_parts.append(claim.facility_code_qualifier)
    if claim.frequency_code:
        clm05_parts.append(claim.frequency_code)
    clm05 = ":".join(clm05_parts)

    parts = [
        "CLM",
        claim.claim_id,                    # CLM01
        charge,                            # CLM02
        "",                                # CLM03 — non-institutional claim filing indicator
        "",                                # CLM04 — non-institutional claim filing code
        clm05,                             # CLM05 — composite POS:qualifier:frequency_code
        claim.provider_signature or "Y",   # CLM06
        claim.assignment or "Y",           # CLM07
        # CLM08 — Benefits Assignment Certification. X12 837P requires
        # this when CLM07 = "Y" (the common case for in-network
        # professional claims). Default to "Y" when the source did
        # not capture one — matches what 99% of HCPF files look like.
        claim.benefits_assignment_certification or "Y",  # CLM08
        claim.release_of_info or "Y",      # CLM09
    ]
    return _ELEM.join(parts) + _SEG


def _build_ref_g1(prior_auth: str | None) -> str:
    if not prior_auth:
        return ""
    return _ELEM.join(["REF", "G1", prior_auth]) + _SEG


def _build_hi(diagnoses) -> str:
    """One HI segment with up to 12 diagnoses (qualifier:code)."""
    parts = ["HI"]
    for diag in (diagnoses or [])[:12]:
        qualifier = diag.qualifier or "BK"
        parts.append(f"{qualifier}:{diag.code}")
    return _ELEM.join(parts) + _SEG


def _build_lx(line_number: int) -> str:
    return _ELEM.join(["LX", str(line_number)]) + _SEG


def _build_sv1(line, *, dx_pointer: str | None = None) -> str:
    """SV1 segment — professional service line.

    X12 005010X222A1 layout (837P):
      SV1-01 composite procedure identifier
      SV1-02 monetary amount (charge)
      SV1-03 unit of basis measurement (UN, MJ, etc.) — ``line.unit_type``
      SV1-04 service unit count — ``line.units``
      SV1-05 place of service code — ``line.place_of_service``
      SV1-06 **NOT USED** by this guide (must be empty)
      SV1-07 diagnosis code pointer — ``dx_pointer`` (required when the
            parent claim has an HI segment)

    The parser captures the original SV1-07 pointer when present; the
    serializer defaults it to ``"1"`` (pointing at the first HI
    diagnosis) when the claim has diagnoses and no explicit pointer
    was captured. When the claim has no HI segment we leave SV1-07
    empty to match the spec.
    """
    proc = line.procedure
    code = proc.code if proc else ""
    mods = proc.modifiers if proc else []
    composite = "HC:" + code + "".join(f":{m}" for m in (mods or [])[:4])
    charge = f"{Decimal(line.charge or 0):.2f}"
    units = f"{Decimal(line.units):g}" if line.units is not None else "1"
    sv1_07 = dx_pointer or ""
    parts = [
        "SV1",
        composite,                              # SV1-01
        charge,                                 # SV1-02
        line.unit_type or "UN",                 # SV1-03 — unit basis code
        units,                                  # SV1-04
        line.place_of_service or "",            # SV1-05
        "",                                     # SV1-06 — NOT USED in 837P
        sv1_07,                                 # SV1-07 — diagnosis pointer
    ]
    return _ELEM.join(parts) + _SEG


def _build_dtp_472(service_date: date | None) -> str:
    """DTP*472 (service date). D8 qualifier with CCYYMMDD single date."""
    if service_date is None:
        return ""
    stamp = service_date.strftime("%Y%m%d")
    return _ELEM.join(["DTP", "472", "D8", stamp]) + _SEG


# ---------------------------------------------------------------------------
# Main entry point
# ---------------------------------------------------------------------------


def _build_submitter_block(
    sender_id: str,
    submitter_name: str | None,
    contact_name: str | None,
    contact_phone: str | None,
    contact_email: str | None = None,
    email_qual: str = "EM",
) -> list[str]:
    # SP40: PER-02 (Name) and at least one PER-03/04 pair are required
    # by Edifabric's x12/validate — emitting only PER-01 ("IC") makes
    # the file invalid. Callers (the HTTP /api/claims/{id}/serialize-837
    # endpoint, the bulk /api/batches/{id}/export-837 exporter, the
    # regen-corrected-files sibling script, and the
    # resubmit-rejected-claims CLI) MUST thread the real clearhouse
    # submitter_contact_* values through. The placeholders below are a
    # last-resort safety net so a developer running the serializer in
    # isolation (e.g. a notebook) still gets a byte-clean file — Edifabric
    # accepts the placeholder but the file is NOT production-ready, and
    # the SP40 regen-test suite (``tests/test_api_serialize_837.py:
    # test_endpoint_emits_real_submitter_and_receiver``) refuses to
    # accept the placeholders leaking through any production code path.
    if not any([contact_name, contact_phone, contact_email]):
        contact_name = "CUSTOMER SERVICE"
        contact_phone = "8005550100"
    out = [
        _build_nm1("41", "41", submitter_name or sender_id, "46", sender_id),
    ]
    # PER is required by X12 (at least PER01). _build_per always emits
    # the segment; the submitter block always has exactly one.
    out.append(_build_per(contact_name, contact_phone, contact_email, email_qual))
    return out


def _build_receiver_block(receiver_id: str, receiver_name: str | None) -> list[str]:
    return [
        _build_nm1("40", "40", receiver_name or receiver_id, "46", receiver_id),
    ]


def _build_billing_provider_block(provider) -> list[str]:
    """HL*1 → NM1*85 → N3 → N4 → REF*EI."""
    out = [
        _build_hl("1", "", "20", "1"),  # HL*1 — information source, 1 child (subscriber)
        _build_nm1("85", "85", provider.name, "XX", provider.npi),
    ]
    addr = provider.address
    if addr:
        n3 = _build_n3(addr.line1, addr.line2)
        n4 = _build_n4(addr.city, addr.state, addr.zip)
        if n3:
            out.append(n3)
        if n4:
            out.append(n4)
    ref_ei = _build_ref("EI", provider.tax_id)
    if ref_ei:
        out.append(ref_ei)
    return out


def _build_subscriber_block(
    subscriber,
    claim_filing_indicator_code: str | None,
) -> list[str]:
    """HL*2 → SBR → NM1*IL → N3 → N4 → DMG. Subscriber has no children."""
    # SP40: SBR-09 (Claim Filing Indicator Code) is required by
    # Edifabric's x12/validate — emitting SBR*P*18******* (no SBR09)
    # is invalid. Callers MUST thread the per-payer
    # ``PayerConfig837.sbr09_claim_filing`` (or
    # ``PayerConfigORM.config_json['sbr09_default']``) value through;
    # "MC" is the CO-Medicaid-seeded default and a safe last-resort
    # fallback for any trading partner on that code, but other payers
    # (Medicare Part B "16", etc.) MUST override.
    if not claim_filing_indicator_code:
        claim_filing_indicator_code = "MC"
    out = [
        _build_hl("2", "1", "22", "0"),  # HL*2 — subscriber, 0 children
        _build_sbr("18", claim_filing_indicator_code),
        _build_nm1(
            "IL", "IL",
            f"{subscriber.last_name} {subscriber.first_name}".strip(),
            "MI",
            subscriber.member_id,
        ),
    ]
    addr = subscriber.address
    if addr:
        n3 = _build_n3(addr.line1, addr.line2)
        n4 = _build_n4(addr.city, addr.state, addr.zip)
        if n3:
            out.append(n3)
        if n4:
            out.append(n4)
    dmg = _build_dmg(subscriber.dob, subscriber.gender)
    if dmg:
        out.append(dmg)
    return out


def _build_payer_block(payer) -> list[str]:
    name, pid = _normalize_payer_id(payer)
    return [
        _build_nm1("PR", "PR", name, "PI", pid),
    ]


# Payer ids that must be normalized to CO_TXIX for CO Medicaid submissions.
# SP39: defense-in-depth against legacy raw_json captures (SKCO0 from
# pre-SP33 batches, CO_BHA from prior behavioral-health configurations,
# empty from degenerate parses). Foreign payer IDs are emitted verbatim.
_NORMALIZE_TO_CO_TXIX_IDS = frozenset({"", "SKCO0", "CO_BHA"})
_NORMALIZE_TO_CO_TXIX_NAMES = frozenset({"", "COHCPF", "CO_BHA"})
_CO_TXIX = "CO_TXIX"

_log = logging.getLogger(__name__)


def _normalize_payer_id(payer) -> tuple[str, str]:
    """Return (name, id) normalized so CO Medicaid claims always emit CO_TXIX.

    SP39. Substitutes empty/SKCO0/CO_BHA -> CO_TXIX in the id and
    empty/COHCPF/CO_BHA -> CO_TXIX in the name. Foreign payer ids are
    passed through verbatim (only the CO Medicaid-shape values are
    normalized; the helper must not corrupt a non-CO submit). Emits a
    WARNING log line on substitution (one per call; the serializer is
    invoked once per claim so volume is bounded by batch size).
    """
    raw_id = (getattr(payer, "id", None) or "").strip()
    raw_name = (getattr(payer, "name", None) or "").strip()
    new_id = _CO_TXIX if raw_id in _NORMALIZE_TO_CO_TXIX_IDS else raw_id
    new_name = _CO_TXIX if raw_name in _NORMALIZE_TO_CO_TXIX_NAMES else raw_name
    if new_id != raw_id or new_name != raw_name:
        _log.warning(
            "SP39 2010BB payer normalization: id %r -> %r, name %r -> %r",
            raw_id, new_id, raw_name, new_name,
        )
    return new_name, new_id


def _build_service_lines_block(service_lines, *, has_diagnoses: bool = False) -> list[str]:
    """Per line: LX / SV1 / DTP*472 / REF*6R.

    ``has_diagnoses`` is True when the parent claim emits an HI segment;
    in that case SV1-07 is required by X12 and we default each line's
    pointer to ``"1"`` (the first HI diagnosis) unless the source
    captured a different pointer on the line itself.
    """
    out: list[str] = []
    for idx, line in enumerate(service_lines or [], start=1):
        out.append(_build_lx(idx))
        # Prefer the line's captured pointer (parser pulled SV1-07
        # when present). Fall back to "1" only when the claim has
        # diagnoses and the source had no explicit pointer — the
        # common single-diagnosis case.
        line_pointer = getattr(line, "dx_pointer", None)
        effective_pointer = line_pointer or ("1" if has_diagnoses else "")
        out.append(_build_sv1(line, dx_pointer=effective_pointer))
        dtp = _build_dtp_472(line.service_date)
        if dtp:
            out.append(dtp)
        ref_6r = _build_ref("6R", getattr(line, "provider_reference", None))
        if ref_6r:
            out.append(ref_6r)
    return out


def serialize_837(
    claim: ClaimOutput,
    *,
    sender_id: str = "CYCLONE",
    receiver_id: str = "RECEIVER",
    submitter_name: str | None = None,
    submitter_contact_name: str | None = None,
    submitter_contact_phone: str | None = None,
    submitter_contact_email: str | None = None,
    submitter_contact_email_qual: str = "EM",
    receiver_name: str | None = None,
    claim_filing_indicator_code: str | None = None,
    interchange_control_number: str = "000000001",
    group_control_number: str = "1",
) -> str:
    """Serialize a single claim to a complete 837P file (full rebuild).

    Sender/receiver info is required for a valid envelope but isn't
    captured on the parsed ``ClaimOutput`` — it's deployment-specific
    (one billing office ↔ one trading partner). Defaults are provided
    (``"CYCLONE"`` / ``"RECEIVER"``) but real deployments should pass
    the configured values.

    The submitter block (Loop 1000A) always emits a PER segment per the
    X12 spec — the canonical clearhouse config provides the contact
    name and email so callers should pass them through.

    The claim filing indicator (SBR09) is read from the per-payer
    config (``PayerConfig837.sbr09_default``); callers should pass it
    in. If not passed, SBR09 is left empty (which causes the
    :func:`cyclone.parsers.validator._r202_sbr09_allowed` rule to skip
    its check — degraded but not a hard error).

    Editable fields (CLM, REF*G1, HI, service-line SV1, DTP*472) are
    emitted from the canonical ``ClaimOutput`` fields, so post-parse
    edits propagate to the output.

    Returns the complete X12 document as a string (segments
    ``~``-terminated).
    """
    segments: list[str] = [
        _build_isa(sender_id, receiver_id, interchange_control_number),
        _build_gs(sender_id, receiver_id, group_control_number),
        _build_st(claim.control_number or "0001"),
        _build_bht(
            transaction_type_code=claim.transaction_type_code,
            reference_id=claim.claim_id,
            transaction_date=claim.transaction_date,
            transaction_time=None,
        ),
    ]
    segments.extend(_build_submitter_block(
        sender_id, submitter_name,
        submitter_contact_name, submitter_contact_phone, submitter_contact_email,
        submitter_contact_email_qual,
    ))
    segments.extend(_build_receiver_block(receiver_id, receiver_name))
    segments.extend(_build_billing_provider_block(claim.billing_provider))
    segments.extend(_build_subscriber_block(claim.subscriber, claim_filing_indicator_code))
    segments.extend(_build_payer_block(claim.payer))

    # Claim-level editable segments.
    segments.append(_build_clm(claim.claim))
    ref_g1 = _build_ref_g1(claim.claim.prior_auth)
    if ref_g1:
        segments.append(ref_g1)
    if claim.diagnoses:
        segments.append(_build_hi(claim.diagnoses))

    # Service lines (LX / SV1 / DTP*472 / REF*6R).
    segments.extend(_build_service_lines_block(
        claim.service_lines,
        has_diagnoses=bool(claim.diagnoses),
    ))

    # SE segment count includes ST (line 3, 1-based) through SE itself
    # — i.e. the entire ST..SE block inclusive.
    seg_count = len(segments) - 2 + 1  # exclude ISA, GS; +1 for SE
    segments.append(_build_se(seg_count, claim.control_number or "0001"))

    segments.append(f"GE*1*{group_control_number}{_SEG}")
    segments.append(f"IEA*1*{interchange_control_number}{_SEG}")

    return "".join(segments)


def serialize_837_for_resubmit(
    claim: ClaimOutput,
    *,
    interchange_index: int,
    **kwargs,
) -> str:
    """Like :func:`serialize_837` but assigns deterministic-but-unique
    interchange + group control numbers for a bundle position.

    Interchange number = ``f"{interchange_index:09d}"``.
    Group number = ``str(interchange_index)``.

    All other keyword arguments (sender_id, receiver_id, submitter_*
    and receiver_* contact info, claim_filing_indicator_code) are
    forwarded to :func:`serialize_837` unchanged so callers — like the
    export and SFTP-submit endpoints — can pass through clearhouse +
    payer config without copying the signature.
    """
    return serialize_837(
        claim,
        interchange_control_number=f"{interchange_index:09d}",
        group_control_number=str(interchange_index),
        **kwargs,
    )


# ---------------------------------------------------------------------------
# Pipeline B overload: MemberWeekBatch → one 837P, one CLM per visit
# ---------------------------------------------------------------------------


def _build_member_week_claim(visit, claim_id: str) -> tuple[str, str, str]:
    """Build the CLM / SV1 / DTP*472 segments for a single MemberWeekBatch visit.

    Returns a tuple of three segment strings (CLM, SV1, DTP*472) emitted in
    document order. The visit is a :class:`cyclone.rebill.reconcile.VisitRow`
    — only ``date`` / ``member_id`` / ``procedure`` / ``billed`` are read.
    """
    # Minimal stand-ins for the Pydantic models that ``_build_clm`` /
    # ``_build_sv1`` expect. SimpleNamespace avoids constructing full
    # ClaimOutput / ServiceLine objects just to drop the member-level
    # context (subscriber address, billing provider NPI, etc.) that
    # Pipeline B doesn't have on its input shape.
    procedure = SimpleNamespace(qualifier="HC", code=visit.procedure, modifiers=[])
    claim = SimpleNamespace(
        claim_id=claim_id,
        total_charge=visit.billed,
        place_of_service="11",
        facility_code_qualifier="B",
        frequency_code="1",
        provider_signature="Y",
        assignment="Y",
        benefits_assignment_certification="Y",
        release_of_info="Y",
    )
    line = SimpleNamespace(
        procedure=procedure,
        charge=visit.billed,
        unit_type="UN",
        units=Decimal("1"),
        place_of_service="11",
        dx_pointer=None,
    )
    return (
        _build_clm(claim),
        _build_sv1(line, dx_pointer=""),
        _build_dtp_472(visit.date),
    )


def serialize_member_week_batch(
    batch: "MemberWeekBatch",
    *,
    payer_id: str = "CO_TXIX",
    tpid: str = "11525703",
) -> bytes:
    """Emit a single 837P envelope containing one CLM per visit in the batch.

    SP41 / Pipeline B. Each :class:`cyclone.rebill.reconcile.VisitRow` in
    ``batch.visits`` becomes its own CLM with one SV1 and one DTP*472
    service-line date. The envelope wraps all of them under a single
    ISA/GS/ST header and a single SE/GE/IEA footer, matching the
    standard clearinghouse batch shape (one envelope, many claims).

    Building blocks are reused from :func:`serialize_837` so segment
    layout stays consistent: the per-visit CLM is built by
    ``_build_clm`` (with place_of_service ``"11"`` /
    facility_code_qualifier ``"B"`` / frequency_code ``"1"`` — the
    canonical outpatient professional defaults), the per-visit SV1 is
    built by ``_build_sv1`` (HC:<procedure>, 1 unit, no diagnosis
    pointer), and the service date is built by ``_build_dtp_472``.

    Args:
        batch: A :class:`cyclone.rebill.pipeline_b.MemberWeekBatch` —
            one member × one ISO-week worth of rebillable visits.
        payer_id: The receiver (NM1*40) identifier. Defaults to
            ``"CO_TXIX"`` for CO Medicaid.
        tpid: The trading-partner / submitter (NM1*41) identifier.
            Defaults to Gainwell's ``"11525703"``.

    Returns:
        The complete 837P document as ASCII bytes. The caller writes
        it to disk with HCPF-spec filenames via
        :func:`cyclone.edi.filenames.build_outbound_filename`.
    """
    # Deterministic control numbers derived from (member, iso_year,
    # iso_week). Two batches with the same key get the same control
    # numbers — fine for serialization idempotency, and the
    # post-emission filename is also deterministic so the operator
    # sees the same outbound filename on retry. Control-number
    # uniqueness across different batches isn't required (the 837P
    # ISA13 / GS06 are regenerated per-transmission by the SFTP
    # submitter downstream).
    control = f"{batch.member_id}{batch.iso_year:04d}{batch.iso_week:02d}"
    interchange_control_number = control[:9].rjust(9, "0")
    group_control_number = control[:9].lstrip("0") or "1"
    st_control_number = control[:9].rjust(4, "0")[-4:]

    segments: list[str] = [
        _build_isa(tpid, payer_id, interchange_control_number),
        _build_gs(tpid, payer_id, group_control_number),
        _build_st(st_control_number),
        _build_bht(
            transaction_type_code="CH",
            reference_id=f"MW-{batch.member_id}-W{batch.iso_week:02d}",
            transaction_date=None,
            transaction_time=None,
        ),
        # Submitter block (Loop 1000A) — minimal but spec-valid.
        # Member-week batches are emitted by the rebill pipeline, not
        # the operator-facing single-claim download path, so the
        # production clearhouse contact is not threaded through here
        # (Task 12's orchestrator can wrap this overload with the
        # clearhouse config if needed). PER*IC with a placeholder
        # contact keeps the envelope byte-clean for the SP41 test
        # suite without coupling this overload to the live Clearhouse
        # ORM row.
        _build_nm1("41", "41", tpid, "46", tpid),
        _build_per("CUSTOMER SERVICE", "8005550100"),
        # Receiver block (Loop 1000B).
        _build_nm1("40", "40", payer_id, "46", payer_id),
    ]

    for idx, visit in enumerate(batch.visits, start=1):
        svc_date = visit.date
        claim_id = f"MW-{batch.member_id}-{svc_date.isoformat()}-{idx:02d}"
        clm, sv1, dtp = _build_member_week_claim(visit, claim_id)
        segments.append(clm)
        segments.append(sv1)
        if dtp:
            segments.append(dtp)

    # SE segment count = ST (1) + everything between ST and SE inclusive.
    # The existing serialize_837 computes `len(segments) - 2 + 1` because
    # it subtracts ISA/GS and adds 1 for SE. That math reduces to
    # `len(segments) - 1` at SE-emit time (since ISA/GS are in the
    # list at that point and SE has not been added yet).
    seg_count = len(segments) - 1
    segments.append(_build_se(seg_count, st_control_number))

    segments.append(f"GE*1*{group_control_number}{_SEG}")
    segments.append(f"IEA*1*{interchange_control_number}{_SEG}")

    return "".join(segments).encode("ascii")