"""Background inbound MFT polling scheduler (SP16).

Turns Cyclone from a manual upload tool into a live clearinghouse:
a long-running asyncio task that periodically polls the Gainwell MFT
inbound path, downloads each new file, and runs it through the
appropriate parser. The operator no longer has to watch for inbound
files and POST them to ``/api/parse-999`` etc. by hand.

Design constraints
------------------

* **Idempotent.** A re-tick (or a process restart) must not re-parse
  the same inbound file. We persist a ``processed_inbound_files`` row
  per file and skip ones we've already seen.

* **Crash-safe.** If the parser raises or the DB write fails, the
  scheduler logs the error, records an ``error`` row, and moves on.
  The next tick continues from the next file.

* **Bounded blast radius.** A bad file must not stop the scheduler.
  Each file is wrapped in try/except so a 999 parser crash doesn't
  prevent us from processing the next inbound 835.

* **Operator-controlled.** The scheduler is OFF by default; the
  operator must explicitly start it (``POST /api/admin/scheduler/start``
  or ``CYCLONE_SCHEDULER_AUTOSTART=true``). When it's running, status
  is exposed via ``GET /api/admin/scheduler/status``.

* **No threading.** We use ``asyncio.create_task`` + ``asyncio.sleep``
  rather than APScheduler or threading because the rest of the
  codebase is asyncio-native (FastAPI). The whole polling loop runs
  in the FastAPI event loop on the main thread.

Compliance: SP16 is operational metadata only. Inbound file
processing is NOT part of the HIPAA audit chain (SP11) — an SFTP
outage shouldn't pollute the audit log with parser errors.
"""
from __future__ import annotations

import asyncio
import json
import logging
import os
import traceback
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Callable, Optional

from sqlalchemy.exc import IntegrityError

from cyclone import db
from cyclone.store import store as cycl_store
from cyclone.audit_log import AuditEvent, append_event
from cyclone.clearhouse import InboundFile, SftpClient
from cyclone.db import ProcessedInboundFile
from cyclone.edi.filenames import parse_inbound_filename
from cyclone.inbox_state import apply_999_rejections
from cyclone.inbox_state_277ca import apply_277ca_rejections
from cyclone.providers import SftpBlock

log = logging.getLogger(__name__)


# Status values for ProcessedInboundFile.status.
STATUS_OK = "ok"
STATUS_ERROR = "error"
STATUS_SKIPPED = "skipped"
STATUS_PENDING = "pending"


# File types we know how to route. The HCPF set is broader (270/271/
# 276/277/278/820/834/ENCR) but Cyclone's parser only covers the
# four below. Files with unknown types are recorded as ``skipped``
# so the operator can see them in the audit table.
ROUTED_FILE_TYPES = frozenset({"999", "835", "277", "277CA", "TA1"})


@dataclass
class TickResult:
    """Outcome of a single scheduler tick (one poll cycle)."""

    started_at: datetime
    finished_at: Optional[datetime] = None
    files_seen: int = 0
    files_processed: int = 0
    files_skipped: int = 0
    files_errored: int = 0
    errors: list[str] = field(default_factory=list)

    def as_dict(self) -> dict[str, Any]:
        return {
            "started_at": self.started_at.isoformat(),
            "finished_at": (
                self.finished_at.isoformat() if self.finished_at else None
            ),
            "files_seen": self.files_seen,
            "files_processed": self.files_processed,
            "files_skipped": self.files_skipped,
            "files_errored": self.files_errored,
            "errors": list(self.errors),
        }


@dataclass
class SchedulerStatus:
    """Snapshot of the scheduler's runtime state."""

    running: bool
    poll_interval_seconds: int
    sftp_block_name: str
    last_poll_at: Optional[datetime]
    poll_count: int
    total_processed: int
    total_skipped: int
    total_errored: int
    last_tick: Optional[TickResult] = None

    def as_dict(self) -> dict[str, Any]:
        return {
            "running": self.running,
            "poll_interval_seconds": self.poll_interval_seconds,
            "sftp_block_name": self.sftp_block_name,
            "last_poll_at": (
                self.last_poll_at.isoformat() if self.last_poll_at else None
            ),
            "poll_count": self.poll_count,
            "total_processed": self.total_processed,
            "total_skipped": self.total_skipped,
            "total_errored": self.total_errored,
            "last_tick": self.last_tick.as_dict() if self.last_tick else None,
        }


# ---------------------------------------------------------------------------
# Per-file-type handlers. Each returns (parser_name, claim_count) and
# persists its own DB rows. The scheduler records the outcome.
# ---------------------------------------------------------------------------


def _handle_999(text: str, source_file: str) -> tuple[str, int]:
    """Parse a 999, apply rejections, persist ack row. Returns (parser, count)."""
    from cyclone.parsers.parse_999 import parse_999_text
    from cyclone.parsers.exceptions import CycloneParseError

    try:
        result = parse_999_text(text, input_file=source_file)
    except CycloneParseError as exc:
        raise ValueError(f"999 parse error: {exc}") from exc

    received, accepted, rejected, ack_code = _ack_count_summary(result)
    icn = result.envelope.control_number
    synthetic_id = _ack_synthetic_source_batch_id(icn)

    with db.SessionLocal()() as session:
        def _lookup(pcn: str):
            return (
                session.query(db.Claim)
                .filter_by(patient_control_number=pcn)
                .first()
            )
        rejection_result = apply_999_rejections(
            session, result, claim_lookup=_lookup,
        )
        if rejection_result.matched:
            for cid in rejection_result.matched:
                append_event(session, AuditEvent(
                    event_type="claim.rejected",
                    entity_type="claim",
                    entity_id=cid,
                    payload={"source_batch_id": synthetic_id},
                    actor="999-parser-scheduler",
                ))
        row = cycl_store.add_ack(
            source_batch_id=synthetic_id,
            accepted_count=accepted,
            rejected_count=rejected,
            received_count=received,
            ack_code=ack_code,
            raw_json=json.loads(result.model_dump_json()),
        )
        session.commit()
    return "parse_999", received


def _handle_835(text: str, source_file: str) -> tuple[str, int]:
    """Parse an 835, run validation, persist batch + remittances."""
    import uuid
    from cyclone.parsers.parse_835 import parse as parse_835
    from cyclone.parsers.exceptions import CycloneParseError
    from cyclone.parsers.validator_835 import validate as validate_835
    from cyclone.payers import PAYER_FACTORIES_835
    from cyclone.store import BatchRecord

    config = PAYER_FACTORIES_835["co_medicaid_835"]()
    try:
        result = parse_835(text, config, input_file=source_file)
    except CycloneParseError as exc:
        raise ValueError(f"835 parse error: {exc}") from exc

    # Validation report (mirrors the API endpoint).
    report = validate_835(result, config)
    n = len(result.claims)
    if report.passed:
        passed, failed, failed_claim_ids = n, 0, []
    else:
        passed, failed, failed_claim_ids = 0, n, [
            c.payer_claim_control_number for c in result.claims
        ]
    result = result.model_copy(update={
        "validation": report,
        "summary": result.summary.model_copy(update={
            "passed": passed,
            "failed": failed,
            "failed_claim_ids": failed_claim_ids,
        }),
    })

    rec = BatchRecord(
        id=uuid.uuid4().hex,
        kind="835",
        input_filename=source_file,
        parsed_at=datetime.now(timezone.utc),
        result=result,
    )
    cycl_store.add(rec)
    return "parse_835", len(result.claims)


def _handle_277ca(text: str, source_file: str) -> tuple[str, int]:
    """Parse a 277CA, persist ack + stamp payer-rejected claims."""
    from cyclone.parsers.parse_277ca import parse_277ca_text
    from cyclone.parsers.exceptions import CycloneParseError

    try:
        result = parse_277ca_text(text, input_file=source_file)
    except CycloneParseError as exc:
        raise ValueError(f"277CA parse error: {exc}") from exc

    icn = result.envelope.control_number
    synthetic_id = _277ca_synthetic_source_batch_id(icn)
    accepted = sum(
        1 for s in result.claim_statuses if s.classification == "accepted"
    )
    paid = sum(
        1 for s in result.claim_statuses if s.classification == "paid"
    )
    rejected = sum(
        1 for s in result.claim_statuses if s.classification == "rejected"
    )
    pended = sum(
        1 for s in result.claim_statuses if s.classification == "pended"
    )

    with db.SessionLocal()() as session:
        row = cycl_store.add_277ca_ack(
            source_batch_id=synthetic_id,
            control_number=icn,
            accepted_count=accepted,
            rejected_count=rejected,
            paid_count=paid,
            pended_count=pended,
            raw_json=json.loads(result.model_dump_json()),
        )
        def _lookup(pcn: str):
            return (
                session.query(db.Claim)
                .filter(db.Claim.patient_control_number == pcn)
                .first()
            )
        apply_result = apply_277ca_rejections(
            session, result, claim_lookup=_lookup, two77ca_id=row.id,
        )
        if apply_result.matched:
            for cid in apply_result.matched:
                append_event(session, AuditEvent(
                    event_type="claim.payer_rejected",
                    entity_type="claim",
                    entity_id=cid,
                    payload={"source_batch_id": synthetic_id, "277ca_id": row.id},
                    actor="277ca-parser-scheduler",
                ))
        session.commit()
    return "parse_277ca", len(result.claim_statuses)


def _handle_ta1(text: str, source_file: str) -> tuple[str, int]:
    """Parse a TA1, persist the interchange ack row."""
    from cyclone.parsers.parse_ta1 import parse_ta1_text
    from cyclone.parsers.exceptions import CycloneParseError

    try:
        result = parse_ta1_text(text, input_file=source_file)
    except CycloneParseError as exc:
        raise ValueError(f"TA1 parse error: {exc}") from exc

    with db.SessionLocal()() as session:
        cycl_store.add_ta1_ack(
            source_batch_id=result.source_batch_id,
            control_number=result.ta1.control_number,
            interchange_date=result.ta1.interchange_date,
            interchange_time=result.ta1.interchange_time,
            ack_code=result.ta1.ack_code,
            note_code=result.ta1.note_code,
            ack_generated_date=result.ta1.ack_generated_date,
            sender_id=result.envelope.sender_id,
            receiver_id=result.envelope.receiver_id,
            raw_json=json.loads(result.model_dump_json()),
        )
        session.commit()
    return "parse_ta1", 1


# Map file_type → handler. Mirrors ROUTED_FILE_TYPES.
HANDLERS: dict[str, Callable[[str, str], tuple[str, int]]] = {
    "999": _handle_999,
    "835": _handle_835,
    "277": _handle_277ca,   # filename uses 277; parser is the same
    "277CA": _handle_277ca,
    "TA1": _handle_ta1,
}


# ---------------------------------------------------------------------------
# Light copies of helpers the API endpoints use, so the scheduler can
# run without depending on the FastAPI module.
# ---------------------------------------------------------------------------


def _ack_count_summary(result: Any) -> tuple[int, int, int, str]:
    """Return (received, accepted, rejected, ack_code) for a 999.

    Mirrors the logic in ``cyclone.api._ack_count_summary`` but lives
    here so the scheduler can run without importing the API module.
    """
    if result.functional_group_acks:
        fg = result.functional_group_acks[0]
        return (
            fg.received_count, fg.accepted_count,
            fg.rejected_count, fg.ack_code,
        )
    sets = result.set_responses
    received = len(sets)
    accepted = sum(1 for s in sets if s.set_accept_reject.code == "A")
    rejected = received - accepted
    if rejected == 0:
        code = "A"
    elif accepted == 0:
        code = "R"
    else:
        code = "P"
    return (received, accepted, rejected, code)


def _ack_synthetic_source_batch_id(interchange_control_number: str) -> str:
    """Synthetic batches.id for a received 999 with no source batch."""
    return f"999-{(interchange_control_number or '').strip() or '000000001'}"


def _277ca_synthetic_source_batch_id(interchange_control_number: str) -> str:
    """Synthetic batches.id for a received 277CA with no source batch."""
    return f"277CA-{(interchange_control_number or '').strip() or '000000001'}"


# ---------------------------------------------------------------------------
# Scheduler
# ---------------------------------------------------------------------------


class Scheduler:
    """Background polling loop for inbound MFT files.

    Lifecycle:
        sched = Scheduler(sftp_block, poll_interval_seconds=60)
        await sched.start()       # begin polling
        # ... later ...
        await sched.stop()        # finish current tick, then exit
        status = sched.status()   # snapshot

    The scheduler is a single asyncio task. ``tick()`` does one full
    poll cycle and is exposed for tests + the ``/api/admin/scheduler/tick``
    endpoint so the operator can force a poll without waiting.

    Threading: NOT thread-safe. All access (start/stop/tick/status)
    must happen on the same event loop. The FastAPI app satisfies
    this trivially because endpoints run on the loop.
    """

    def __init__(
        self,
        sftp_block: SftpBlock,
        *,
        poll_interval_seconds: int = 60,
        sftp_block_name: str = "default",
        sftp_client_factory: Optional[Callable[[SftpBlock], Any]] = None,
    ) -> None:
        self._sftp_block = sftp_block
        self._poll_interval = poll_interval_seconds
        self._sftp_block_name = sftp_block_name
        # Factory indirection lets tests substitute a fake client
        # without monkey-patching the module-level SftpClient.
        self._sftp_client_factory = sftp_client_factory or SftpClient

        self._task: Optional[asyncio.Task[None]] = None
        self._stop_event = asyncio.Event()
        self._last_poll_at: Optional[datetime] = None
        self._poll_count = 0
        self._total_processed = 0
        self._total_skipped = 0
        self._total_errored = 0
        self._last_tick: Optional[TickResult] = None
        # Coalesce overlapping ticks (a slow MFT server shouldn't let
        # ticks stack up; the next tick fires only after the previous
        # one finishes).
        self._tick_in_progress = False

    # ---- Public API -------------------------------------------------------

    async def start(self) -> None:
        """Begin polling. Idempotent."""
        if self._task is not None and not self._task.done():
            log.info("Scheduler already running; start() is a no-op")
            return
        self._stop_event.clear()
        self._task = asyncio.create_task(self._run(), name="mft-scheduler")
        log.info(
            "Scheduler started",
            extra={
                "poll_interval_s": self._poll_interval,
                "sftp_block": self._sftp_block_name,
            },
        )

    async def stop(self) -> None:
        """Stop polling. Waits for the current tick to finish."""
        if self._task is None or self._task.done():
            return
        self._stop_event.set()
        try:
            await asyncio.wait_for(self._task, timeout=30)
        except asyncio.TimeoutError:
            log.warning("Scheduler did not stop within 30s; cancelling")
            self._task.cancel()
            try:
                await self._task
            except (asyncio.CancelledError, Exception):  # noqa: BLE001
                pass
        self._task = None
        log.info("Scheduler stopped")

    def status(self) -> SchedulerStatus:
        """Return a snapshot of the scheduler's state."""
        return SchedulerStatus(
            running=self.is_running(),
            poll_interval_seconds=self._poll_interval,
            sftp_block_name=self._sftp_block_name,
            last_poll_at=self._last_poll_at,
            poll_count=self._poll_count,
            total_processed=self._total_processed,
            total_skipped=self._total_skipped,
            total_errored=self._total_errored,
            last_tick=self._last_tick,
        )

    def is_running(self) -> bool:
        return self._task is not None and not self._task.done()

    async def tick(self) -> TickResult:
        """Run a single poll cycle and return the outcome.

        Concurrent ticks are coalesced: if a tick is already in
        progress, the second caller waits for it. This protects the
        SFTP server from a stampede when the operator hits
        ``/api/admin/scheduler/tick`` while a scheduled tick is
        already running.
        """
        while self._tick_in_progress:
            await asyncio.sleep(0.05)
        self._tick_in_progress = True
        try:
            result = await self._tick_impl()
            self._last_tick = result
            self._last_poll_at = result.finished_at or result.started_at
            self._poll_count += 1
            self._total_processed += result.files_processed
            self._total_skipped += result.files_skipped
            self._total_errored += result.files_errored
            return result
        finally:
            self._tick_in_progress = False

    # ---- Internals --------------------------------------------------------

    async def _run(self) -> None:
        """Main loop. Runs until ``stop()`` is called."""
        # Stagger the first tick so we don't hammer the MFT server on
        # startup if multiple operators restart Cyclone in lockstep.
        await asyncio.sleep(1)
        while not self._stop_event.is_set():
            try:
                await self.tick()
            except Exception as exc:  # noqa: BLE001
                # tick() should never raise — it catches per-file
                # exceptions. This is the safety net for SFTP outages
                # or DB connectivity issues.
                log.exception("Scheduler tick raised", extra={"error": str(exc)})
            try:
                await asyncio.wait_for(
                    self._stop_event.wait(),
                    timeout=self._poll_interval,
                )
            except asyncio.TimeoutError:
                pass  # poll interval elapsed; loop again

    async def _tick_impl(self) -> TickResult:
        """One poll cycle: list → filter already-processed → route each."""
        started = datetime.now(timezone.utc)
        result = TickResult(started_at=started)

        try:
            files = await asyncio.to_thread(self._list_inbound)
        except Exception as exc:  # noqa: BLE001
            log.exception("SFTP list_inbound failed")
            result.errors.append(f"list_inbound: {exc}")
            result.finished_at = datetime.now(timezone.utc)
            return result

        result.files_seen = len(files)
        for f in files:
            if self._stop_event.is_set():
                break
            await self._handle_one(f, result)

        result.finished_at = datetime.now(timezone.utc)
        return result

    def _list_inbound(self) -> list[InboundFile]:
        """Return files in the inbound MFT path. Runs on a thread."""
        client = self._sftp_client_factory(self._sftp_block)
        return client.list_inbound()

    async def _handle_one(self, f: InboundFile, result: TickResult) -> None:
        """Process one inbound file: skip-if-seen, classify, parse, record."""
        if await self._already_processed(f.name):
            return

        try:
            inbound = parse_inbound_filename(f.name)
            file_type = inbound.file_type
        except ValueError:
            file_type = None

        if file_type not in HANDLERS:
            await self._record(
                name=f.name, size=f.size, modified_at=f.modified_at,
                file_type=file_type, parser_used=None, claim_count=0,
                status=STATUS_SKIPPED,
                error_message=(
                    f"file_type {file_type!r} not in {sorted(HANDLERS)}"
                    if file_type else "filename does not match HCPF inbound format"
                ),
            )
            result.files_skipped += 1
            return

        try:
            _path, parser_used, claim_count = await asyncio.to_thread(
                self._download_and_parse, f, file_type,
            )
        except Exception as exc:  # noqa: BLE001
            log.exception("Failed to process inbound file", extra={"input_filename": f.name})
            await self._record(
                name=f.name, size=f.size, modified_at=f.modified_at,
                file_type=file_type, parser_used=None, claim_count=0,
                status=STATUS_ERROR,
                error_message=(
                    f"{type(exc).__name__}: {exc}\n"
                    f"{traceback.format_exc()[-500:]}"
                ),
            )
            result.files_errored += 1
            result.errors.append(f"{f.name}: {exc}")
            return

        await self._record(
            name=f.name, size=f.size, modified_at=f.modified_at,
            file_type=file_type, parser_used=parser_used, claim_count=claim_count,
            status=STATUS_OK, error_message=None,
        )
        result.files_processed += 1
        log.info(
            "Processed inbound file",
            extra={
                "input_filename": f.name,
                "parser": parser_used,
                "claims": claim_count,
            },
        )

    async def _already_processed(self, name: str) -> bool:
        with db.SessionLocal()() as session:
            row = (
                session.query(ProcessedInboundFile)
                .filter_by(sftp_block_name=self._sftp_block_name, name=name)
                .filter(ProcessedInboundFile.status != STATUS_PENDING)
                .first()
            )
            return row is not None

    async def _record(
        self,
        *,
        name: str,
        size: int,
        modified_at: datetime,
        file_type: Optional[str],
        parser_used: Optional[str],
        claim_count: int,
        status: str,
        error_message: Optional[str],
    ) -> None:
        """Persist a processed_inbound_files row. Idempotent."""
        with db.SessionLocal()() as session:
            row = ProcessedInboundFile(
                sftp_block_name=self._sftp_block_name,
                name=name,
                size=size,
                modified_at=modified_at,
                file_type=file_type,
                processed_at=datetime.now(timezone.utc),
                parser_used=parser_used,
                claim_count=claim_count,
                status=status,
                error_message=error_message,
            )
            session.add(row)
            try:
                session.commit()
            except IntegrityError:
                # A concurrent scheduler (or a retry after a partial
                # failure) already recorded this file. That's fine —
                # the latest row wins; we just skip the dup.
                session.rollback()

    def _download_and_parse(
        self, f: InboundFile, file_type: str,
    ) -> tuple[Path, str, int]:
        """Download from MFT, run the right handler. Returns (path, parser, count).

        Stub mode: ``f.local_path`` already points at the staged file
        (set by ``SftpClient._list_inbound_stub``). Real mode: the
        remote name is ``f.name`` and we round-trip through paramiko.
        """
        if self._sftp_block.stub:
            # In stub mode the InboundFile already has a local_path;
            # reading the staged bytes directly avoids the stub's
            # remote-path semantics (which expect a full inbound path).
            content = f.local_path.read_bytes()
        else:
            client = self._sftp_client_factory(self._sftp_block)
            content = client.read_file(f.name)
        text = content.decode("utf-8")
        handler = HANDLERS[file_type]
        parser_used, claim_count = handler(text, f.name)
        return f.local_path, parser_used, claim_count


# ---------------------------------------------------------------------------
# Module-level singleton — only one scheduler per process.
# ---------------------------------------------------------------------------


_scheduler: Optional[Scheduler] = None


def configure_scheduler(
    sftp_block: SftpBlock,
    *,
    poll_interval_seconds: int = 60,
    sftp_block_name: str = "default",
    force: bool = False,
) -> Scheduler:
    """Create the module-level scheduler singleton (or return the existing one).

    Called from the FastAPI lifespan handler. Tests pre-configure the
    scheduler before the TestClient opens the lifespan; in that case
    we leave the existing singleton alone (``force=False``). Pass
    ``force=True`` to replace unconditionally.
    """
    global _scheduler
    if _scheduler is not None and not force:
        return _scheduler
    poll = int(
        os.environ.get("CYCLONE_SCHEDULER_POLL_SECONDS", poll_interval_seconds),
    )
    _scheduler = Scheduler(
        sftp_block,
        poll_interval_seconds=poll,
        sftp_block_name=sftp_block_name,
    )
    return _scheduler


def get_scheduler() -> Scheduler:
    """Return the module-level scheduler.

    Raises:
        RuntimeError: if ``configure_scheduler`` hasn't been called.
    """
    if _scheduler is None:
        raise RuntimeError(
            "scheduler not configured; call configure_scheduler() first",
        )
    return _scheduler


def reset_scheduler_for_tests() -> None:
    """Clear the module-level scheduler. Test-only."""
    global _scheduler
    _scheduler = None