"""Structural and payer-specific validation rules for 835 ERA payments.

Each rule is a pure function that takes a :class:`ParseResult835` and a
:class:`PayerConfig835` and yields zero or more :class:`ValidationIssue`.
The top-level :func:`validate` function runs every rule and aggregates
results into a single :class:`ValidationReport` for the batch.

Unlike 837P (where each claim has its own report), the 835 ERA validates
the whole batch as one unit because the BPR/CLP/SVC balancing rules are
cross-cutting.
"""

from __future__ import annotations

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

from cyclone.parsers.models import ValidationIssue, ValidationReport
from cyclone.parsers.models_835 import ParseResult835
from cyclone.parsers.payer import PayerConfig835

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

Rule = Callable[[ParseResult835, PayerConfig835], Iterable[ValidationIssue]]

# Tolerance for monetary balancing (USD; the 835 standard's smallest
# representable currency unit is 0.01).
TOLERANCE = Decimal("0.01")


# --------------------------------------------------------------------------- #
# BPR rules
# --------------------------------------------------------------------------- #


def _r001_bpr01_handling_code_allowed(result: ParseResult835, cfg: PayerConfig835) -> Iterable[ValidationIssue]:
    if result.financial_info.handling_code not in cfg.allowed_handling_codes:
        yield ValidationIssue(
            rule="R835_BPR01_handling_code_allowed",
            severity="error",
            message=f"BPR01 {result.financial_info.handling_code!r} not in {sorted(cfg.allowed_handling_codes)} for payer {cfg.name}",
        )


def _r002_bpr02_positive(result: ParseResult835, _: PayerConfig835) -> Iterable[ValidationIssue]:
    if result.financial_info.paid_amount <= 0:
        yield ValidationIssue(
            rule="R835_BPR02_positive",
            severity="error",
            message=f"BPR02 (paid amount) must be > 0, got {result.financial_info.paid_amount}",
        )


def _r003_bal_bpr_vs_clp04(result: ParseResult835, _: PayerConfig835) -> Iterable[ValidationIssue]:
    """BPR02 must equal the sum of CLP04 across all claims in the batch.

    Skipped when BPR01="I" (Information Only / Non-Payment 835): in that
    mode the BPR02 is informational, the per-claim CLP04 totals are the
    authoritative numbers, and the BPR02/CLP04-sum is allowed to differ
    by more than the $0.01 tolerance.
    """
    if not result.claims:
        return
    if result.financial_info.handling_code == "I":
        return
    clp_sum = sum((c.total_paid for c in result.claims), start=Decimal("0"))
    diff = (result.financial_info.paid_amount - clp_sum).copy_abs()
    if diff > TOLERANCE:
        yield ValidationIssue(
            rule="R835_BAL_BPR_vs_CLP04",
            severity="error",
            message=(
                f"BPR02 ({result.financial_info.paid_amount}) does not match "
                f"sum of CLP04 ({clp_sum}); diff={diff}"
            ),
        )


# --------------------------------------------------------------------------- #
# Per-claim CLP / SVC rules
# --------------------------------------------------------------------------- #


def _r004_bal_clp04_vs_svc03(result: ParseResult835, _: PayerConfig835) -> Iterable[ValidationIssue]:
    """Each CLP04 must equal the sum of its SVC03 service payments."""
    for c in result.claims:
        if not c.service_payments:
            # No service lines — cannot balance, but R835_CLP03_present
            # already covers a zero-charge claim.
            continue
        svc_sum = sum((sp.payment for sp in c.service_payments), start=Decimal("0"))
        diff = (c.total_paid - svc_sum).copy_abs()
        if diff > TOLERANCE:
            yield ValidationIssue(
                rule="R835_BAL_CLP04_vs_SVC03",
                severity="error",
                message=(
                    f"[claim {c.payer_claim_control_number}] CLP04 ({c.total_paid}) "
                    f"does not match sum of SVC03 ({svc_sum}); diff={diff}"
                ),
            )


def _r005_clp01_present(result: ParseResult835, _: PayerConfig835) -> Iterable[ValidationIssue]:
    for c in result.claims:
        if not c.payer_claim_control_number or not c.payer_claim_control_number.strip():
            yield ValidationIssue(
                rule="R835_CLP01_present",
                severity="error",
                message="CLP01 (payer claim control number) is empty",
            )


def _r006_clp02_status_allowed(result: ParseResult835, cfg: PayerConfig835) -> Iterable[ValidationIssue]:
    for c in result.claims:
        if c.status_code and c.status_code not in cfg.allowed_status_codes:
            yield ValidationIssue(
                rule="R835_CLP02_status_allowed",
                severity="error",
                message=(
                    f"[claim {c.payer_claim_control_number}] CLP02 status "
                    f"{c.status_code!r} not in {sorted(cfg.allowed_status_codes)} for payer {cfg.name}"
                ),
            )


def _r007_clp03_present(result: ParseResult835, _: PayerConfig835) -> Iterable[ValidationIssue]:
    for c in result.claims:
        if c.total_charge <= 0:
            yield ValidationIssue(
                rule="R835_CLP03_present",
                severity="error",
                message=(
                    f"[claim {c.payer_claim_control_number}] CLP03 (total claim charge) "
                    f"must be > 0, got {c.total_charge}"
                ),
            )


# --------------------------------------------------------------------------- #
# Payer / payee rules
# --------------------------------------------------------------------------- #


def _r008_payer_tax_id_matches(result: ParseResult835, cfg: PayerConfig835) -> Iterable[ValidationIssue]:
    if not cfg.expected_payer_tax_ids:
        return
    tax = result.financial_info.payer_tax_id
    if tax and tax not in cfg.expected_payer_tax_ids:
        yield ValidationIssue(
            rule="R835_PAYER_TAX_ID_matches",
            severity="warning",
            message=(
                f"BPR10 (payer tax id) {tax!r} not in "
                f"{sorted(cfg.expected_payer_tax_ids)} for payer {cfg.name}"
            ),
        )


def _r009_payer_health_plan_id_matches(result: ParseResult835, cfg: PayerConfig835) -> Iterable[ValidationIssue]:
    if not cfg.expected_payer_health_plan_id:
        return
    pid = result.payer.id
    if pid and pid != cfg.expected_payer_health_plan_id:
        yield ValidationIssue(
            rule="R835_PAYER_HEALTH_PLAN_ID_matches",
            severity="warning",
            message=(
                f"N104 (payer health plan id) {pid!r} != configured "
                f"{cfg.expected_payer_health_plan_id!r} for payer {cfg.name}"
            ),
        )


def _r010_payee_npi_format(result: ParseResult835, _: PayerConfig835) -> Iterable[ValidationIssue]:
    npi = result.payee.npi
    if npi and not NPI_RE.match(npi):
        yield ValidationIssue(
            rule="R835_PAYEE_NPI_format",
            severity="error",
            message=f"Payee NPI must be 10 digits, got {npi!r}",
        )


def _r011_trn02_present(result: ParseResult835, _: PayerConfig835) -> Iterable[ValidationIssue]:
    if not result.trace.originating_company_id or not result.trace.originating_company_id.strip():
        yield ValidationIssue(
            rule="R835_REGN01_trn02_present",
            severity="error",
            message="TRN02 (originating company id / payee TPID) is empty",
        )


def _r012_multi_bpr_warning(result: ParseResult835, _: PayerConfig835) -> Iterable[ValidationIssue]:
    """X12 835 spec allows only one BPR per transaction set. CO Medicaid
    (and a few other payers) send multiple BPRs in a single 835 as a
    "split payment" pattern; the parser sums their BPR02 paid_amounts.
    Operators see this as a warning so they know the data is non-standard.
    """
    if result.multi_bpr:
        yield ValidationIssue(
            rule="R835_MULTI_BPR",
            severity="warning",
            message=(
                "File contains multiple BPR segments; BPR02 paid_amounts "
                "were summed. The X12 835 spec allows only one BPR per "
                "transaction set."
            ),
        )


_RULES: list[Rule] = [
    _r001_bpr01_handling_code_allowed,
    _r002_bpr02_positive,
    _r003_bal_bpr_vs_clp04,
    _r004_bal_clp04_vs_svc03,
    _r005_clp01_present,
    _r006_clp02_status_allowed,
    _r007_clp03_present,
    _r008_payer_tax_id_matches,
    _r009_payer_health_plan_id_matches,
    _r010_payee_npi_format,
    _r011_trn02_present,
    _r012_multi_bpr_warning,
]


def validate(result: ParseResult835, config: PayerConfig835) -> ValidationReport:
    """Run every rule against the batch and return a single report.

    Never raises — all issues are accumulated into the report. The
    orchestrator is responsible for promoting warnings to errors under
    ``--strict`` and for setting ``BatchSummary.passed``.
    """
    errors: list[ValidationIssue] = []
    warnings: list[ValidationIssue] = []
    for rule in _RULES:
        for issue in rule(result, config):
            (errors if issue.severity == "error" else warnings).append(issue)
    return ValidationReport(passed=not errors, errors=errors, warnings=warnings)


__all__ = ["validate", "TOLERANCE"]
