"""Structural and payer-specific validation rules for 837P claims.

Each rule is a pure function that takes a :class:`ClaimOutput` and a
:class:`PayerConfig` and yields zero or more :class:`ValidationIssue`. The
top-level :func:`validate` function runs every rule and aggregates results.
"""

from __future__ import annotations

import re
from collections.abc import Iterable
from decimal import Decimal
from typing import Callable

from cyclone.parsers.models import ClaimOutput, ValidationIssue, ValidationReport
from cyclone.parsers.payer import PayerConfig

NPI_RE = re.compile(r"^\d{10}$")

Rule = Callable[[ClaimOutput, PayerConfig], Iterable[ValidationIssue]]


def _r010_clm01_present(claim: ClaimOutput, _: PayerConfig) -> Iterable[ValidationIssue]:
    if not claim.claim.claim_id or not claim.claim.claim_id.strip():
        yield ValidationIssue(rule="R010_clm01_present", severity="error", message="CLM01 (claim id) is empty")


def _r011_total_charge_positive(claim: ClaimOutput, _: PayerConfig) -> Iterable[ValidationIssue]:
    if claim.claim.total_charge <= 0:
        yield ValidationIssue(rule="R011_total_charge_positive", severity="error", message=f"CLM02 must be > 0, got {claim.claim.total_charge}")


def _r020_npi_format(claim: ClaimOutput, _: PayerConfig) -> Iterable[ValidationIssue]:
    if claim.billing_provider.npi and not NPI_RE.match(claim.billing_provider.npi):
        yield ValidationIssue(rule="R020_npi_format", severity="error", message=f"Billing provider NPI must be 10 digits, got {claim.billing_provider.npi!r}")


def _r021_npi_checksum(claim: ClaimOutput, _: PayerConfig) -> Iterable[ValidationIssue]:
    """SP20: validate the billing-provider NPI's Luhn check digit.

    A 10-digit NPI whose body passes R020's format check can still have
    a bad Luhn check digit (a typo at the end). Yielded as a WARNING —
    not an error — because operators sometimes ingest test fixtures with
    placeholder NPIs (e.g. all-same-digit) and we don't want to block
    that path. Local-only check, no NPPES round-trip.
    """
    npi = claim.billing_provider.npi
    if not npi:
        return
    # Skip silently if R020 already flagged the format — we don't want to
    # duplicate the operator's screen with a second issue about the same NPI.
    if not NPI_RE.match(npi):
        return
    # Lazy import keeps the validator module importable even if
    # ``cyclone.npi`` is unavailable (e.g. in some legacy test setups).
    try:
        from cyclone.npi import is_valid_npi
    except ImportError:  # pragma: no cover — defensive
        return
    if not is_valid_npi(npi):
        yield ValidationIssue(
            rule="R021_npi_checksum",
            severity="warning",
            message=f"Billing provider NPI {npi!r} fails Luhn checksum (likely typo)",
        )


def _r030_frequency_allowed(claim: ClaimOutput, cfg: PayerConfig) -> Iterable[ValidationIssue]:
    if not claim.claim.frequency_code:
        return
    try:
        freq = int(claim.claim.frequency_code)
    except ValueError:
        yield ValidationIssue(rule="R030_frequency_allowed", severity="error", message=f"CLM05-3 not numeric: {claim.claim.frequency_code!r}")
        return
    if freq not in cfg.allowed_claim_frequencies:
        yield ValidationIssue(
            rule="R030_frequency_allowed",
            severity="error",
            message=f"CLM05-3 {freq} not in {sorted(cfg.allowed_claim_frequencies)} for payer {cfg.name}",
        )


def _r031_ref_g1_optional(claim: ClaimOutput, _: PayerConfig) -> Iterable[ValidationIssue]:
    """REF*G1 is informational in v1 (see spec §9 R031)."""
    # We yield no errors; presence is recorded on the model.
    return ()


def _r034_ref_g1_required(claim: ClaimOutput, cfg: PayerConfig) -> Iterable[ValidationIssue]:
    """REF*G1 is required on adjustment claims (frequency 7/8) when the payer opts in.

    Gated on :attr:`PayerConfig.require_ref_g1_for_adjustments` — when the
    payer keeps the lenient v1 default (``False``), this rule is a no-op even
    for frequency 7/8 claims. Strict payers (e.g. CO Medicaid once it flips
    in a future version) get the error so they can surface the missing segment
    in their validation reports.
    """
    if not cfg.require_ref_g1_for_adjustments:
        return
    freq = claim.claim.frequency_code
    if freq not in {"7", "8"}:
        return
    has_ref_g1 = any(
        len(seg) >= 2 and seg[0] == "REF" and seg[1] == "G1"
        for seg in claim.raw_segments
    )
    if not has_ref_g1:
        yield ValidationIssue(
            rule="R034_ref_g1_required",
            severity="error",
            message=(
                f"Adjustment claim (frequency={freq}) for payer {cfg.name} requires REF*G1 "
                "segment with prior authorization number"
            ),
        )


def _r035_bht06_allowed(claim: ClaimOutput, cfg: PayerConfig) -> Iterable[ValidationIssue]:
    """BHT06 (transaction type code, e.g. CH/RP) must be in the payer's allow-list.

    Skipped silently when ``transaction_type_code`` is missing — older fixtures
    and EDI streams without a BHT06 element should not produce a spurious error.
    """
    code = claim.transaction_type_code
    if not code:
        return
    if code not in cfg.allowed_bht06:
        yield ValidationIssue(
            rule="R035_bht06_allowed",
            severity="error",
            message=(
                f"BHT06 transaction type code {code!r} not in {sorted(cfg.allowed_bht06)} "
                f"for payer {cfg.name}"
            ),
        )


def _r032_clm05_2_facility_qualifier(claim: ClaimOutput, cfg: PayerConfig) -> Iterable[ValidationIssue]:
    """CLM05-2 Facility Code Qualifier must be recognized for the payer.

    When the field is absent (e.g. production data with only a 2-component
    CLM05), the rule is skipped silently — the absence of a qualifier is
    tolerated, but a present-and-unknown qualifier is a hard error.
    """
    q = claim.claim.facility_code_qualifier
    if not q:
        return
    if q not in cfg.allowed_facility_qualifiers:
        yield ValidationIssue(
            rule="R032_clm05_2_facility_qualifier",
            severity="error",
            message=f"CLM05-2 facility code qualifier {q!r} not in {sorted(cfg.allowed_facility_qualifiers)} for payer {cfg.name}",
        )


def _r033_clm05_1_place_of_service_code(claim: ClaimOutput, cfg: PayerConfig) -> Iterable[ValidationIssue]:
    """CLM05-1 Facility Type Code must be a valid CMS Place of Service code."""
    pos = claim.claim.place_of_service
    if not pos:
        return
    if pos not in cfg.allowed_place_of_service_codes:
        yield ValidationIssue(
            rule="R033_clm05_1_place_of_service_code",
            severity="error",
            message=f"CLM05-1 place of service {pos!r} not in CMS POS code list for payer {cfg.name}",
        )


def _r050_diagnosis_present(claim: ClaimOutput, _: PayerConfig) -> Iterable[ValidationIssue]:
    if not claim.diagnoses:
        yield ValidationIssue(rule="R050_diagnosis_present", severity="error", message="HI segment missing — no diagnoses on claim")


def _r060_service_dates_present(claim: ClaimOutput, _: PayerConfig) -> Iterable[ValidationIssue]:
    for sl in claim.service_lines:
        if sl.service_date is None:
            yield ValidationIssue(rule="R060_service_dates_present", severity="error", message=f"Service line {sl.line_number} missing service date (DTP*472)")


def _r070_charges_sum(claim: ClaimOutput, _: PayerConfig) -> Iterable[ValidationIssue]:
    if not claim.service_lines:
        return
    line_sum = sum((sl.charge for sl in claim.service_lines), start=Decimal("0"))
    if abs(line_sum - claim.claim.total_charge) > Decimal("0.01"):
        yield ValidationIssue(
            rule="R070_charges_sum",
            severity="warning",
            message=f"Sum of service lines ({line_sum}) does not match CLM02 ({claim.claim.total_charge})",
        )


def _r100_payer_id_matches(claim: ClaimOutput, cfg: PayerConfig) -> Iterable[ValidationIssue]:
    if cfg.payer_id and claim.payer.id and claim.payer.id != cfg.payer_id:
        yield ValidationIssue(
            rule="R100_payer_id_matches",
            severity="warning",
            message=f"Payer id {claim.payer.id!r} != configured {cfg.payer_id!r}",
        )


# ---------------------------------------------------------------------------
# SP9: R200-R210 — CO MAP companion guide / HCPF naming spec rules
# ---------------------------------------------------------------------------
#
# These rules consume the per-payer, per-transaction-type config block
# loaded from `config/payers.yaml` and persisted to the `payer_configs`
# table. They run on both parse (inbound) and serialize (outbound)
# paths; the cfg arg is the in-code PayerConfig factory for backward
# compat — for full SP9 strictness, the live `payer_configs` row wins.


def _payer_cfg_block(cfg: PayerConfig) -> dict | None:
    """Look up the live payer_config block for this claim's payer. Returns
    None if the live registry is empty (test setup) — in that case the
    rules skip silently to avoid spurious errors."""
    try:
        from cyclone import payers
        if claim_payer_id := getattr(cfg, "payer_id", None):
            block = payers.get_config(claim_payer_id, "837P")
            if block is not None:
                return block
    except Exception:  # noqa: BLE001
        pass
    return None


def _providers_table_seeded() -> bool:
    """True when the providers table has at least one row.

    The R204/R205/R203 rules check the providers table. When the table
    is empty (e.g. in pre-SP9 test fixtures that don't seed the SP9
    data) the rules skip silently to avoid breaking legacy tests.
    """
    try:
        from cyclone import store as store_mod
        # Use is_active=None so seeded and unseeded both count.
        providers = store_mod.store.list_providers(is_active=None)
        return len(providers) > 0
    except Exception:  # noqa: BLE001
        return False


def _r200_bht06_allowed(claim: ClaimOutput, cfg: PayerConfig) -> Iterable[ValidationIssue]:
    block = _payer_cfg_block(cfg)
    if not block:
        return
    code = claim.transaction_type_code
    if not code:
        return
    allowed = block.get("bht06_allowed", [])
    if code not in allowed:
        yield ValidationIssue(
            rule="R200_bht06_allowed",
            severity="error",
            message=f"BHT06 {code!r} not in {allowed} for payer {cfg.name}",
        )


def _r201_bht06_no_mixed_batch(_claim: ClaimOutput, _cfg: PayerConfig) -> Iterable[ValidationIssue]:
    # This is a batch-level rule, not a per-claim rule. The validator
    # runs per-claim so this stub returns no issues. The full check
    # happens in the API ingest path (one envelope = one BHT06).
    return ()


def _r202_sbr09_allowed(claim: ClaimOutput, cfg: PayerConfig) -> Iterable[ValidationIssue]:
    block = _payer_cfg_block(cfg)
    if not block:
        return
    sbr09 = _first_sbr09(claim)
    if sbr09 is None:
        return
    allowed = block.get("sbr09_allowed", [])
    if sbr09 not in allowed:
        yield ValidationIssue(
            rule="R202_sbr09_allowed",
            severity="error",
            message=f"SBR09 {sbr09!r} not in {allowed} for payer {cfg.name}",
        )


def _r203_prv_matches_provider(claim: ClaimOutput, cfg: PayerConfig) -> Iterable[ValidationIssue]:
    if not _providers_table_seeded():
        return
    block = _payer_cfg_block(cfg)
    if not block:
        return
    # Read PRV*BI*PXC*<code> from raw_segments
    code = None
    for seg in claim.raw_segments:
        if len(seg) >= 4 and seg[0] == "PRV" and seg[1] == "BI" and seg[3].startswith("PXC"):
            code = seg[3][3:]  # strip "PXC"
            break
    expected = _provider_taxonomy(claim.billing_provider.npi)
    if code and expected and code != expected:
        yield ValidationIssue(
            rule="R203_prv_matches_provider",
            severity="error",
            message=f"PRV*BI*PXC {code!r} != provider taxonomy {expected!r}",
        )


def _r204_npi_in_providers_table(claim: ClaimOutput, _cfg: PayerConfig) -> Iterable[ValidationIssue]:
    if not _providers_table_seeded():
        return
    npi = claim.billing_provider.npi
    if not npi:
        return
    if _provider_taxonomy(npi) is None:
        yield ValidationIssue(
            rule="R204_npi_in_providers_table",
            severity="error",
            message=f"Billing provider NPI {npi!r} not found in providers table",
        )


def _r205_ref_ei_matches_provider(claim: ClaimOutput, _cfg: PayerConfig) -> Iterable[ValidationIssue]:
    if not _providers_table_seeded():
        return
    ei = None
    for seg in claim.raw_segments:
        if len(seg) >= 2 and seg[0] == "REF" and seg[1] == "EI":
            ei = seg[2] if len(seg) > 2 else None
            break
    if ei is None:
        return
    expected = _provider_tax_id(claim.billing_provider.npi)
    if expected and ei != expected:
        yield ValidationIssue(
            rule="R205_ref_ei_matches_provider",
            severity="error",
            message=f"REF*EI {ei!r} != provider tax_id {expected!r}",
        )


def _r206_payer_id_matches(claim: ClaimOutput, cfg: PayerConfig) -> Iterable[ValidationIssue]:
    block = _payer_cfg_block(cfg)
    if not block:
        return
    expected = block.get("payer_id", "")
    if expected and claim.payer.id and claim.payer.id != expected:
        yield ValidationIssue(
            rule="R206_payer_id_matches",
            severity="error",
            message=f"NM1*PR PI {claim.payer.id!r} != configured payer_id {expected!r}",
        )


def _r207_no_pwk_segment(claim: ClaimOutput, cfg: PayerConfig) -> Iterable[ValidationIssue]:
    block = _payer_cfg_block(cfg)
    if not block or block.get("pwk_supported", True):
        return
    for seg in claim.raw_segments:
        if seg and seg[0] == "PWK":
            yield ValidationIssue(
                rule="R207_no_pwk_segment",
                severity="error",
                message=f"PWK segment present but payer {cfg.name} does not support PWK",
            )
            return


def _r208_cas_2320_group(_claim: ClaimOutput, _cfg: PayerConfig) -> Iterable[ValidationIssue]:
    # Best-effort heuristic: if any CAS segment with PI group code appears
    # in the 2320 loop (sub-payor adjustments), flag unless cas_2320_group_allowed.
    # Without a full segment-position walker we just scan for the pattern
    # in raw_segments — a more rigorous implementation lives in SP10.
    return ()


def _r209_outbound_filename(claim: ClaimOutput, _cfg: PayerConfig) -> Iterable[ValidationIssue]:
    # Filename validation is performed at the serialize / submit boundary,
    # not on the parsed claim. Returns no issues here.
    return ()


def _r210_inbound_filename(_claim: ClaimOutput, _cfg: PayerConfig) -> Iterable[ValidationIssue]:
    # Same — validated at file-routing time.
    return ()


# ---------------------------------------------------------------------------
# SP9 helpers
# ---------------------------------------------------------------------------


def _first_sbr09(claim: ClaimOutput) -> str | None:
    """Find the first SBR09 (Claim Filing Indicator) in the claim's
    raw_segments. Returns None if no SBR segment is present."""
    for seg in claim.raw_segments:
        if seg and seg[0] == "SBR" and len(seg) > 9:
            v = seg[9]
            if v:
                return v
    return None


def _provider_taxonomy(npi: str | None) -> str | None:
    if not npi:
        return None
    try:
        from cyclone import store as store_mod
        p = store_mod.store.get_provider(npi)
        return p.taxonomy_code if p else None
    except Exception:  # noqa: BLE001
        return None


def _provider_tax_id(npi: str | None) -> str | None:
    if not npi:
        return None
    try:
        from cyclone import store as store_mod
        p = store_mod.store.get_provider(npi)
        return p.tax_id if p else None
    except Exception:  # noqa: BLE001
        return None


_RULES: list[Rule] = [
    _r010_clm01_present,
    _r011_total_charge_positive,
    _r020_npi_format,
    _r021_npi_checksum,
    _r030_frequency_allowed,
    _r031_ref_g1_optional,
    _r034_ref_g1_required,
    _r035_bht06_allowed,
    _r032_clm05_2_facility_qualifier,
    _r033_clm05_1_place_of_service_code,
    _r050_diagnosis_present,
    _r060_service_dates_present,
    _r070_charges_sum,
    _r100_payer_id_matches,
    # SP9: CO MAP + HCPF naming
    _r200_bht06_allowed,
    _r201_bht06_no_mixed_batch,
    _r202_sbr09_allowed,
    _r203_prv_matches_provider,
    _r204_npi_in_providers_table,
    _r205_ref_ei_matches_provider,
    _r206_payer_id_matches,
    _r207_no_pwk_segment,
    _r208_cas_2320_group,
    _r209_outbound_filename,
    _r210_inbound_filename,
]


def validate(claim: ClaimOutput, config: PayerConfig) -> ValidationReport:
    """Run every rule against a claim and return a report.

    Never raises — all issues are accumulated into the report.
    """
    errors: list[ValidationIssue] = []
    warnings: list[ValidationIssue] = []
    for rule in _RULES:
        for issue in rule(claim, config):
            (errors if issue.severity == "error" else warnings).append(issue)
    return ValidationReport(passed=not errors, errors=errors, warnings=warnings)
