"""SP25 Task 5: ``cyclone recover-ingest`` parse + DB-write helper.

What this module is for
------------------------
On 2026-07-07 the dashboard was empty even though four 837P files
and one 835 file were sitting in ``ingest/``. The 837Ps had been
SFTP-shipped to Gainwell (the operator's SFTP client, manually),
but cyclone never recorded them in the DB — ``parse-837`` only
emits JSON, and ``submit-batch`` was never called for that run.
The 835 came back from the payer and was dropped into ``ingest/``,
but ``pull-inbound`` was never run for it.

This helper is the recovery path: parse the local file, build a
typed :class:`BatchRecord`, call :meth:`CycloneStore.add` (which
publishes events to the live-tail bus), record the file in
``processed_inbound_files`` for dedup, and stop — no SFTP, no audit
log because the file was already uploaded (or already came back).

Public surface
--------------
- :func:`recover_file` — one file. Returns a structured result dict.
- :func:`detect_kind` — ST01 helper, exposed for tests.

Why this is its own module (not a new branch in submit_file)
------------------------------------------------------------
``submit_file`` is the SFTP-write path; its invariants are
"DB write → SFTP upload → audit event" and removing the SFTP half
would mutate that contract for every caller. Recovery is a
deliberately offline path and a separate surface keeps the two
flows readable. ``recover_file`` shares the same parse + store
write code path (``CycloneStore.add``) so the live-tail pages
light up exactly the way they would for a fresh submission.
"""
from __future__ import annotations

import logging
import uuid
from datetime import datetime, timezone
from pathlib import Path
from typing import Any

from cyclone import db as db_mod
from cyclone.store import store as cycl_store
from cyclone.db import ProcessedInboundFile
from cyclone.parsers.parse_837 import parse as parse_837_text
from cyclone.parsers.parse_835 import parse as parse_835_text
from cyclone.parsers.payer import PayerConfig, PayerConfig835
from cyclone.parsers.segments import tokenize as _tokenize_segments
from cyclone.store.records import BatchRecord835, BatchRecord837

log = logging.getLogger(__name__)

# Default dedup key for the sftp_block_name column. Distinct from
# ``dzinesco`` so a real remote-pull can't accidentally mask a
# manual recovery (or vice versa).
DEFAULT_SFTP_BLOCK_NAME = "manual-recover"


def detect_kind(text: str) -> str:
    """Return the ST01 of the *first* ST segment, or ``"unknown"``.

    X12 envelopes can contain multiple transactions; the first ST is
    the canonical kind for the whole file under our parsing rules.

    Mirrors the SP35 envelope check in ``api_routers/parse.py``
    (auto-detect layer A; this is layer B for offline recovery).
    """
    try:
        segments = _tokenize_segments(text)
    except Exception:  # noqa: BLE001 — recover-context, log only
        log.warning("recover-ingest: tokenize failed", exc_info=True)
        return "unknown"
    for seg in segments:
        if seg and seg[0] == "ST" and len(seg) > 1:
            return seg[1]
    return "unknown"


def _normalize_kind(st01: str) -> str:
    """Map ``"837"`` → ``"837p"`` (parser-level kind); pass others through.

    The parser distinguishes ``837p`` (professional) from the
    837I/D variants; only 837P is supported in the recovery path
    today. Anything else is rejected with a clear error message.
    """
    if st01 == "837":
        return "837p"
    if st01 in {"835", "837p"}:
        return st01
    return st01


def _already_processed(name: str, sftp_block_name: str) -> bool:
    """Return True if ``(sftp_block_name, name)`` was already recovered.

    Mirrors the dedup index ``ux_processed_inbound_files_block_name``
    (UNIQUE on sftp_block_name, name).
    """
    s = db_mod.SessionLocal()()
    try:
        return s.query(ProcessedInboundFile).filter_by(
            sftp_block_name=sftp_block_name, name=name
        ).first() is not None
    finally:
        s.close()


def _mark_processed(
    *,
    sftp_block_name: str,
    path: Path,
    file_type: str,
    parser_used: str,
    claim_count: int,
) -> None:
    """Insert a ``processed_inbound_files`` row for the recovered file.

    Status hardcoded to ``"ok"`` because by the time we reach this
    function the parse + store add have both succeeded.
    """
    stat = path.stat()
    now = datetime.now(tz=timezone.utc)
    s = db_mod.SessionLocal()()
    try:
        s.add(ProcessedInboundFile(
            sftp_block_name=sftp_block_name,
            name=path.name,
            size=stat.st_size,
            modified_at=datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc),
            file_type=file_type,
            processed_at=now,
            parser_used=parser_used,
            claim_count=claim_count,
            status="ok",
            error_message=None,
        ))
        s.commit()
    finally:
        s.close()


def recover_file(
    path: str | Path,
    *,
    sftp_block_name: str = DEFAULT_SFTP_BLOCK_NAME,
    actor: str = "recover-ingest",
) -> dict:
    """Recover one X12 file from a local path: parse → DB write (no SFTP).

    Args:
        path: local file. 837P / 835 only.
        sftp_block_name: dedup key in ``processed_inbound_files``.
            Defaults to ``"manual-recover"`` so the recovery doesn't
            collide with the real ``dzinesco`` SFTP block.
        actor: reserved for audit. Recovery doesn't audit-log per
            design (the file was already uploaded by whatever path
            got it into ingest/), but the parameter is kept so a
            future audit-enabled variant doesn't break callers.

    Returns:
        dict with keys:
          - ``file``: path as passed in (str)
          - ``kind``: ``"837p"`` / ``"835"`` / ``None``
          - ``status``: ``"ok"`` / ``"duplicate"`` / ``"failed"``
          - ``batch_id``: hex uuid4 when status=="ok", else None
          - ``error``: str when status=="failed", else None
    """
    p = Path(path)
    if not p.exists():
        return {"file": str(p), "kind": None, "status": "failed",
                "batch_id": None, "error": f"file does not exist: {p}"}
    if not p.is_file():
        return {"file": str(p), "kind": None, "status": "failed",
                "batch_id": None, "error": f"not a regular file: {p}"}

    # 1. Dedup — short-circuit if this exact name was already recovered.
    if _already_processed(p.name, sftp_block_name):
        return {"file": str(p), "kind": None, "status": "duplicate",
                "batch_id": None, "error": None}

    # 2. Read + detect kind.
    try:
        text = p.read_text(encoding="utf-8")
    except UnicodeDecodeError as exc:
        return {"file": str(p), "kind": None, "status": "failed",
                "batch_id": None, "error": f"encoding: {exc!r}"}

    raw_kind = detect_kind(text)
    kind = _normalize_kind(raw_kind)
    if kind not in {"837p", "835"}:
        return {"file": str(p), "kind": raw_kind, "status": "failed",
                "batch_id": None,
                "error": f"unsupported kind {raw_kind!r} (recovery supports 837p + 835)"}

    # 3. Parse through the canonical parser for the detected kind.
    parsed: Any = None
    if kind == "837p":
        try:
            parsed = parse_837_text(text, PayerConfig.co_medicaid(), input_file=p.name)
        except Exception as exc:  # noqa: BLE001
            return {"file": str(p), "kind": kind, "status": "failed",
                    "batch_id": None, "error": f"parse_837: {exc!r}"}
    else:  # "835"
        try:
            parsed = parse_835_text(text, PayerConfig835.co_medicaid_835(), input_file=p.name)
        except Exception as exc:  # noqa: BLE001
            return {"file": str(p), "kind": kind, "status": "failed",
                    "batch_id": None, "error": f"parse_835: {exc!r}"}

    # 4. Build a typed BatchRecord (mirrors api.py parse-837 happy path).
    batch_id = uuid.uuid4().hex
    now = datetime.now(tz=timezone.utc)
    if kind == "837p":
        record = BatchRecord837(
            id=batch_id, input_filename=p.name, parsed_at=now, result=parsed,
        )
    else:
        record = BatchRecord835(
            id=batch_id, input_filename=p.name, parsed_at=now, result=parsed,
        )

    # 5. DB write — canonical path. CycloneStore.add publishes events on
    # the bundled bus so live-tail subscribers see new claims/remits.
    try:
        cycl_store.add(record)
    except Exception as exc:  # noqa: BLE001
        return {"file": str(p), "kind": kind, "status": "failed",
                "batch_id": None, "error": f"db_write: {exc!r}"}

    # 6. Record success for dedup.
    claim_count = len(getattr(parsed, "claims", []) or [])
    try:
        _mark_processed(
            sftp_block_name=sftp_block_name,
            path=p,
            file_type=kind,
            parser_used=f"parse_{kind}",
            claim_count=claim_count,
        )
    except Exception as exc:  # noqa: BLE001
        # The DB write succeeded but dedup-record failed — log and
        # still report ok (re-running will hit dedup once it's
        # recorded; until then a re-run is a real duplicate insert).
        log.warning("recover-ingest: dedup-write failed for %s: %r", p, exc)

    return {"file": str(p), "kind": kind, "status": "ok",
            "batch_id": batch_id, "error": None}
