"""Cross-router helpers for the api_routers package.

Private to the package (leading underscore). Only routers in this
package import from here. Helpers graduate here when at least two
routers need them — single-router helpers stay in the router that
uses them.

Helpers currently promoted here:

- :func:`_actor_user_id` — promoted early (during SP36 Task 11
  / clearhouse extraction) because the clearhouse router needs
  it and the 2 remaining call-sites in ``api.py`` (parse-999 ack
  block, parse-277ca ack block) are both inside the parse
  surface that Task 16 will extract. Promoting now is cheaper
  than leaving a cross-module ``from cyclone.api import _actor_user_id``
  that would create an import-cycle at registry load time.

- :data:`PAYER_FACTORIES` / :data:`PAYER_FACTORIES_835` — SP36
  Task 16: payer config dicts lifted from ``api.py`` alongside
  the ``_resolve_payer`` / ``_resolve_payer_835`` helpers that
  consume them. The two helpers each touch a single ``PAYER_FACTORIES*``
  dict; keeping both halves of the pair in one module removes a
  circular import (parse.py → _shared._resolve_payer → api.PAYER_FACTORIES).

- :func:`_resolve_payer` / :func:`_resolve_payer_835` — used by
  ``parse-837`` and ``parse-835`` endpoints respectively. Promoted
  in SP36 Task 16 alongside the PAYER_FACTORIES dicts.

- :func:`_transaction_set_id_from_segments` — used by
  ``parse-837`` and ``parse-835`` envelope guards. Promoted in
  SP36 Task 16.

- :func:`_build_and_persist_ack` — used by ``parse-837`` (when
  ``?ack=true``). Promoted in SP36 Task 16.

- :func:`_reconciliation_summary_for_batch` — used by
  ``parse-835``. Promoted in SP36 Task 16.

- :func:`_ta1_synthetic_source_batch_id` — used by ``parse-ta1``.
  Promoted in SP36 Task 16.

- :func:`_serialize_ta1` — used by ``parse-ta1`` to build the
  raw TA1 round-trip text. Promoted in SP36 Task 16 per the
  plan's "8 helpers" specification; technically a single-router
  helper per D4 but moved here for symmetry with the other parse
  serializers.
"""
from __future__ import annotations

import json
import logging
from typing import Any

from fastapi import HTTPException, Request

from cyclone import db
from cyclone.parsers.batch_ack_builder import build_ack_for_batch
from cyclone.parsers.payer import PayerConfig, PayerConfig835
from cyclone.parsers.serialize_999 import serialize_999
from cyclone.store import store

log = logging.getLogger(__name__)


# --------------------------------------------------------------------------- #
# Actor user id (SP36 Task 11 — early-promoted)
# --------------------------------------------------------------------------- #


def _actor_user_id(request: Request) -> int | None:
    """Return the acting user's id from ``request.state.user``, or None.

    ``get_current_user``/``matrix_gate`` populate ``request.state.user``
    for both the authenticated path and the AUTH_DISABLED escape hatch.
    Returns None when the state hasn't been set (e.g. background jobs
    or unit tests that bypass auth). Used to stamp ``user_id`` onto
    audit events without crashing the request.
    """
    user = getattr(request.state, "user", None)
    if user is None:
        return None
    return getattr(user, "id", None)


# --------------------------------------------------------------------------- #
# Payer config dicts (SP36 Task 16)
# --------------------------------------------------------------------------- #
#
# Mirror cli._PAYER_FACTORIES. Kept here (not in the parse router) so
# the ``_resolve_payer`` / ``_resolve_payer_835`` helpers can import
# their backing dicts without a circular import.


PAYER_FACTORIES: dict[str, Any] = {
    "co_medicaid": PayerConfig.co_medicaid,
    "generic_837p": PayerConfig.generic_837p,
}

PAYER_FACTORIES_835: dict[str, Any] = {
    "co_medicaid_835": PayerConfig835.co_medicaid_835,
    "generic_835": PayerConfig835.generic_835,
}


# --------------------------------------------------------------------------- #
# Payer resolution (SP36 Task 16)
# --------------------------------------------------------------------------- #


def _resolve_payer(name: str) -> PayerConfig:
    if name not in PAYER_FACTORIES:
        raise HTTPException(
            status_code=400,
            detail={
                "error": "Unknown payer",
                "detail": f"Unknown payer {name!r}. Choose from: {', '.join(PAYER_FACTORIES)}",
            },
        )
    return PAYER_FACTORIES[name]()


def _resolve_payer_835(name: str) -> PayerConfig835:
    if name not in PAYER_FACTORIES_835:
        raise HTTPException(
            status_code=400,
            detail={
                "error": "Unknown payer",
                "detail": f"Unknown payer {name!r}. Choose from: {', '.join(PAYER_FACTORIES_835)}",
            },
        )
    return PAYER_FACTORIES_835[name]()


# --------------------------------------------------------------------------- #
# Envelope detection (SP36 Task 16)
# --------------------------------------------------------------------------- #


def _transaction_set_id_from_segments(segments: list[list[str]]) -> str | None:
    """Return the ST01 transaction-set id (``"837"``, ``"835"``, ``"999"``...).

    SP35 helper: scans the first few tokenized segments for the ST
    segment and returns its second element (ST01). Returns None when no
    ST is present — e.g. a TA1 file, which uses the bare TA1 segment
    and no ST envelope. The endpoint-level envelope guards treat
    ``None`` as "no ST found; let the parser decide" so TA1 files
    routed through the wrong endpoint still surface a parse error
    rather than a misleading "expected 837p, got ''" message.
    """
    for seg in segments[:5]:  # ST is always the second segment after ISA
        if seg and seg[0] == "ST" and len(seg) > 1:
            return seg[1]
    return None


# --------------------------------------------------------------------------- #
# 999 ACK builder (SP36 Task 16)
# --------------------------------------------------------------------------- #


def _build_and_persist_ack(batch_id: str) -> dict | None:
    """Build a 999 ACK for ``batch_id`` and persist the row.

    Returns the ack payload dict (matches the ``/api/parse-999``
    response shape so the JSON and NDJSON clients can share the
    schema) or None if the build failed. The build is fail-soft:
    errors are logged but never abort the 837 ingest, because the
    user-visible 837 result is still correct.
    """
    try:
        ack_result = build_ack_for_batch(batch_id)
    except Exception:
        log.exception("build_ack_for_batch failed for %s", batch_id)
        return None
    fg = ack_result.functional_group_acks[0] if ack_result.functional_group_acks else None
    if fg is None:
        return None
    raw_text = serialize_999(ack_result, interchange_control_number=ack_result.envelope.control_number)
    row = store.add_ack(
        source_batch_id=batch_id,
        accepted_count=fg.accepted_count,
        rejected_count=fg.rejected_count,
        received_count=fg.received_count,
        ack_code=fg.ack_code,
        raw_json=json.loads(ack_result.model_dump_json()),
    )
    return {
        "id": row.id,
        "accepted_count": fg.accepted_count,
        "rejected_count": fg.rejected_count,
        "received_count": fg.received_count,
        "ack_code": fg.ack_code,
        "source_batch_id": batch_id,
        "raw_999_text": raw_text,
    }


# --------------------------------------------------------------------------- #
# Reconciliation summary (SP36 Task 16)
# --------------------------------------------------------------------------- #


def _reconciliation_summary_for_batch(batch_id: str) -> dict:
    """Return ``{matched, unmatched_claims, unmatched_remittances, skipped}`` for a batch.

    Reads from the DB after ``store.add()`` has already run reconciliation
    synchronously (SP27 Task 10: ``reconcile.run(s, batch_id)`` inside the
    ingest session, before commit). Counts are observed at this moment;
    a subsequent manual match/unmatch will not be reflected until the
    next request.

    ``skipped`` is reserved for future use — the orchestrator tracks
    skipped claims internally but does not surface a queryable count.
    """
    from sqlalchemy import func, select
    from cyclone.db import Match, Remittance

    with db.SessionLocal()() as s:
        matched = s.execute(
            select(func.count(Match.id)).where(
                Match.remittance_id.in_(
                    select(Remittance.id).where(Remittance.batch_id == batch_id)
                )
            )
        ).scalar_one()

    # Pull unmatched via the store (small result set; cheap).
    unmatched = store.list_unmatched(kind="both")
    return {
        "matched": matched,
        "unmatched_claims": len(unmatched["claims"]),
        "unmatched_remittances": len(unmatched["remittances"]),
        "skipped": 0,  # reserved — T10 does not persist a skipped count
    }


# --------------------------------------------------------------------------- #
# TA1 synthetic source batch id (SP36 Task 16)
# --------------------------------------------------------------------------- #


def _ta1_synthetic_source_batch_id(interchange_control_number: str) -> str:
    """Return a synthetic ``batches.id`` for a received TA1 with no source batch.

    Mirrors :func:`_ack_synthetic_source_batch_id` (in
    ``cyclone.handlers._ack_id``). The ta1_acks.source_batch_id
    FK requires a row in batches; for received TA1s we synthesize an
    id of the form ``TA1-<ISA13>``. The row is NOT created in batches
    (same FK-is-no-op convention as the 999 path).
    """
    return f"TA1-{(interchange_control_number or '').strip() or '000000001'}"


# --------------------------------------------------------------------------- #
# TA1 serializer (SP36 Task 16)
# --------------------------------------------------------------------------- #


def _serialize_ta1(result) -> str:
    """Render a TA1 file from a ParseResultTa1 for the ``raw_ta1_text`` field.

    Mirrors what the parser consumed: ISA envelope → TA1 → IEA. We
    rebuild minimal ISA fields from the envelope plus the TA1 segment
    verbatim. The serializer is intentionally tiny — TA1 has no GS/ST,
    so there's no functional-group structure to round-trip.
    """
    ta1 = result.ta1
    parts = [
        f"TA1*{ta1.control_number}*{ta1.interchange_date.strftime('%y%m%d') if ta1.interchange_date else ''}*"
        f"{ta1.interchange_time or ''}*{ta1.ack_code}*{ta1.note_code or ''}*"
        f"{ta1.ack_generated_date.strftime('%y%m%d') if ta1.ack_generated_date else ''}",
    ]
    return "~".join(parts) + "~"