"""FastAPI HTTP surface for the Cyclone 837P and 835 ERA parsers.

The frontend (Vite/React on http://localhost:5173) uploads an X12 file via
``POST /api/parse-837`` or ``POST /api/parse-835`` and receives either:

* a single ``ParseResult`` (or ``ParseResult835``) JSON object when it
  sends ``Accept: application/json``, or
* a stream of newline-delimited JSON objects (``application/x-ndjson``)
  — one envelope, one line per claim/payout, then one summary — so the
  UI can render records incrementally as they're produced.

CORS is configured to allow the Vite dev origins (``http://localhost:5173``
and ``http://127.0.0.1:5173`` — both forms are valid dev-server addresses
but CORS treats them as distinct origins) plus GET/POST with any header.
Additional origins (LAN IPs, staging hosts) can be appended via the
``CYCLONE_ALLOWED_ORIGINS`` env var as a comma-separated list.
"""

from __future__ import annotations

import asyncio
import csv
import io
import json
import logging
import os
import uuid
from datetime import datetime, timezone
from contextlib import asynccontextmanager
from typing import Any, AsyncIterator

from fastapi import Depends, FastAPI, File, HTTPException, Query, Request, UploadFile
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse, Response, StreamingResponse
from pydantic import ValidationError

from cyclone import __version__, db
from cyclone.auth.deps import matrix_gate
from cyclone.db import Batch, Claim, ClaimState, Remittance
from sqlalchemy import desc, or_
from sqlalchemy.exc import IntegrityError
from cyclone.inbox_state import apply_999_rejections
from cyclone.inbox_state_277ca import apply_277ca_rejections
from cyclone.audit_log import AuditEvent, append_event, verify_chain
from cyclone.api_routers._shared import _actor_user_id
from cyclone.claim_acks import (
    apply_277ca_acks as _apply_277ca_acks,
    apply_999_acceptances as _apply_999_acceptances,
    apply_ta1_envelope_link as _apply_ta1_envelope_link,
)


from cyclone.parsers.exceptions import CycloneParseError
from cyclone.parsers.models import BatchSummary, ClaimOutput, Envelope, ParseResult
from cyclone.parsers.models_835 import ParseResult835
from cyclone.parsers.payer import PayerConfig, PayerConfig835
from cyclone.parsers.parse_277ca import parse_277ca_text
from cyclone.parsers.parse_837 import parse
from cyclone.parsers.parse_835 import parse as parse_835
from cyclone.parsers.parse_999 import parse_999_text
from cyclone.parsers.parse_ta1 import parse_ta1_text
from cyclone.parsers.segments import tokenize as _tokenize_segments
from cyclone.parsers.serialize_999 import serialize_999
from cyclone.parsers.serialize_837 import SerializeError as SerializeError837, serialize_837, serialize_837_for_resubmit
from cyclone.parsers.batch_ack_builder import build_ack_for_batch
from cyclone.parsers.validator_835 import validate as validate_835
from cyclone.pubsub import EventBus
from cyclone.store import (
    BatchRecord,
    store,
    utcnow,
)

log = logging.getLogger(__name__)

# Cross-cutting helpers (NDJSON serialization, content negotiation, strict
# rewrites, live-tail generator). See api_helpers.py for the rationale.
from cyclone.api_helpers import (  # noqa: E402
    client_wants_json as _client_wants_json,
    drop_raw_segments_837 as _drop_raw_segments,
    drop_raw_segments_835 as _drop_raw_segments_835,
    has_claim_validation_errors as _has_claim_validation_errors,
    has_835_validation_errors as _has_835_validation_errors,
    heartbeat_seconds as _heartbeat_seconds,
    ndjson_line as _ndjson_line,
    ndjson_stream_837 as _ndjson_stream,
    ndjson_stream_835 as _ndjson_stream_835,
    ndjson_stream_list as _ndjson_stream_list,
    strict_rewrite_837 as _strict_rewrite,
    strict_rewrite_835 as _strict_rewrite_835,
    tail_events as _tail_events,
    wants_ndjson as _wants_ndjson,
)


@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
    """Initialize per-process resources (DB + EventBus) before the app
    serves requests. No teardown needed for Cyclone's local-only posture.
    """
    from cyclone import db, payers as payer_loader
    from cyclone import scheduler as scheduler_mod
    from cyclone import backup_service as backup_svc_mod
    from cyclone import backup_scheduler as backup_sched_mod
    from cyclone import secrets as secrets_mod
    from cyclone.logging_config import setup_logging
    import os as _os

    # SP18: structured JSON logging. Idempotent — safe even if the
    # CLI pre-configured the root logger.
    setup_logging(
        level=_os.environ.get("CYCLONE_LOG_LEVEL", "INFO"),
        log_file=_os.environ.get("CYCLONE_LOG_FILE") or None,
        json_format=_os.environ.get("CYCLONE_LOG_JSON", "true").lower() not in (
            "false", "0", "no",
        ),
    )

    db.init_db()
    app.state.event_bus = EventBus()
    # SP9: load payer config and seed singleton + 3 providers + CO_TXIX.
    try:
        payer_loader.load_payer_configs()
        store.ensure_clearhouse_seeded()
    except Exception as exc:  # noqa: BLE001
        log.exception("SP9 seed failed: %s", exc)

    # SP27 Task 11: startup audit for the Claim.matched_remittance_id ↔
    # Remittance.claim_id denormalized FK pair. Non-blocking — mismatches
    # are logged at WARNING with up to 5 examples of each case so the
    # operator can investigate without the system failing to boot.
    try:
        from cyclone.store import check_matched_pair_drift
        check_matched_pair_drift()
    except Exception as exc:  # noqa: BLE001
        log.exception("matched-pair drift check failed: %s", exc)

    # SP16: configure the inbound MFT polling scheduler. The
    # dzinesco clearhouse singleton (seeded by SP9) carries the SFTP
    # block — multi-provider polling is out of scope for v1.
    # Auto-start is opt-in via env var so operators running the API
    # for one-off parsing don't accidentally poll the MFT on startup.
    try:
        clearhouse = store.get_clearhouse()
        if clearhouse is not None:
            sched = scheduler_mod.configure_scheduler(
                clearhouse.sftp_block,
                sftp_block_name=clearhouse.name or "default",
            )
            if _os.environ.get("CYCLONE_SCHEDULER_AUTOSTART", "").lower() in (
                "1", "true", "yes",
            ):
                await sched.start()
    except Exception as exc:  # noqa: BLE001
        # Scheduler setup must never block the API from coming up.
        log.exception("SP16 scheduler config failed: %s", exc)

    # SP17: configure the encrypted backup service + scheduler.
    # Auto-start is opt-in via CYCLONE_BACKUP_AUTOSTART.
    try:
        backup_dir = _os.environ.get(
            "CYCLONE_BACKUP_DIR",
            str(db.DEFAULT_DB_PATH.parent / "backups"),
        )
        retention = int(_os.environ.get("CYCLONE_BACKUP_RETENTION_DAYS", "30"))
        passphrase = secrets_mod.get_secret(
            backup_svc_mod.KEYCHAIN_BACKUP_PASSPHRASE_ACCOUNT,
        )
        salt_hex = secrets_mod.get_secret(
            backup_svc_mod.KEYCHAIN_BACKUP_SALT_ACCOUNT,
        )
        salt = bytes.fromhex(salt_hex) if salt_hex else None
        bk_svc = backup_svc_mod.configure_backup_service(
            backup_dir=backup_dir,
            passphrase=passphrase,
            salt=salt,
            retention_days=retention,
        )
        # Always configure the scheduler so the endpoint can find it;
        # auto-start is gated on env var.
        bk_sched = backup_sched_mod.configure_backup_scheduler(
            bk_svc,
            interval_hours=float(
                _os.environ.get("CYCLONE_BACKUP_INTERVAL_HOURS", "24"),
            ),
        )
        if _os.environ.get("CYCLONE_BACKUP_AUTOSTART", "").lower() in (
            "1", "true", "yes",
        ):
            await bk_sched.start()
    except Exception as exc:  # noqa: BLE001
        # Backup setup must never block the API from coming up.
        log.exception("SP17 backup config failed: %s", exc)

    yield

    # SP17: stop the backup scheduler on shutdown.
    try:
        bk_sched = backup_sched_mod.get_backup_scheduler()
        await bk_sched.stop()
    except RuntimeError:
        pass  # never configured
    except Exception as exc:  # noqa: BLE001
        log.warning("Backup scheduler shutdown failed: %s", exc)

    # SP16: stop the scheduler on shutdown so the test harness
    # (TestClient context manager) doesn't leak background tasks.
    try:
        sched = scheduler_mod.get_scheduler()
        await sched.stop()
    except RuntimeError:
        pass  # never configured
    except Exception as exc:  # noqa: BLE001
        log.warning("Scheduler shutdown failed: %s", exc)



# Allow both common dev-server origins. ``localhost`` and ``127.0.0.1``
# resolve to the same Vite dev server but CORS treats them as distinct
# origins, so the allow-list has to list both — otherwise a tab opened
# via the IP form gets blocked even though the dev server is identical.
VITE_DEV_ORIGINS: list[str] = [
    "http://localhost:5173",
    "http://127.0.0.1:5173",
]
_extra_origins = os.environ.get("CYCLONE_ALLOWED_ORIGINS", "").strip()
if _extra_origins:
    VITE_DEV_ORIGINS.extend(o.strip() for o in _extra_origins.split(",") if o.strip())

app = FastAPI(
    title="Cyclone 837P / 835 Parser API",
    version=__version__,
    description="Upload X12 837P and 835 files and receive parsed records as JSON or NDJSON.",
    lifespan=lifespan,
)

app.add_middleware(
    CORSMiddleware,
    allow_origins=VITE_DEV_ORIGINS,
    allow_credentials=True,
    allow_methods=["GET", "POST"],
    allow_headers=["*"],
)

# SP19: security middleware chain. Starlette's ``add_middleware``
# *prepends*, so the LAST middleware we add is the OUTERMOST. We
# want SecurityHeaders outermost so it stamps the 413/429 responses
# too, then RateLimit, then BodySizeLimit closest to the app.
from cyclone.security import (  # noqa: E402
    BodySizeLimitMiddleware,
    RateLimitMiddleware,
    SecurityHeadersMiddleware,
)

app.add_middleware(BodySizeLimitMiddleware)
app.add_middleware(RateLimitMiddleware)
app.add_middleware(SecurityHeadersMiddleware)

# Resource-group routers. Each module owns its own APIRouter and is
# registered below. New resources go in `cyclone.api_routers.<name>`
# and are wired in here. (Kept as a top-level package rather than nested
# under `cyclone.api` so the existing ``cyclone.api`` module path keeps
# working — Python prefers packages over same-named modules.)
from cyclone.api_routers import routers  # noqa: E402

for _router in routers:
    app.include_router(_router)


@app.exception_handler(HTTPException)
async def _http_exc_handler(request, exc: HTTPException):
    if isinstance(exc.detail, dict):
        # Endpoints that raise HTTPException with a dict detail (e.g.
        # ``{"error": "Not found", "detail": "..."}``) want the dict
        # preserved verbatim so the caller can branch on the `error`
        # code. Wrap under `detail` for the standard envelope.
        return JSONResponse(
            status_code=exc.status_code,
            content={"detail": exc.detail},
            headers=exc.headers,
        )
    code = exc.detail if isinstance(exc.detail, str) else "error"
    return JSONResponse(
        status_code=exc.status_code,
        content={"error": code, "detail": str(exc.detail)},
        headers=exc.headers,
    )



# --------------------------------------------------------------------------- #
# Catch-all exception handler
# --------------------------------------------------------------------------- #
#
# Without this, any exception that escapes a route handler is rendered by
# Uvicorn as a bare ``500 Internal Server Error`` text/plain response with
# NO CORS headers. Browsers reading such a response report it as a CORS
# error (``Origin ... is not allowed by Access-Control-Allow-Origin``)
# because they cannot read the body without the CORS allow-origin header
# — even though the actual failure was on the server. We catch everything
# here, log it, and return a JSON error with the request's Origin echoed
# back so the browser can surface the real message.
@app.exception_handler(Exception)
async def _unhandled_exception_handler(request: Request, exc: Exception) -> JSONResponse:
    log.exception(
        "Unhandled exception in %s %s", request.method, request.url.path
    )
    headers: dict[str, str] = {}
    origin = request.headers.get("origin", "")
    if origin:
        headers["Access-Control-Allow-Origin"] = origin
        headers["Vary"] = "Origin"
    return JSONResponse(
        status_code=500,
        content={"error": "Internal server error", "detail": str(exc)},
        headers=headers,
    )


from cyclone.auth.routes import router as auth_router
from cyclone.auth.admin import router as admin_users_router

app.include_router(auth_router)
app.include_router(admin_users_router, dependencies=[Depends(matrix_gate)])

# SP36 Task 6 backward-compat: ``_clear_summary_cache`` moved to
# ``cyclone.api_routers.payers`` (it's a single-router helper per
# spec D4). The test fixture in ``tests/test_payer_summary.py``
# calls ``api_mod._clear_summary_cache()`` between requests to wipe
# the in-process cache. Per the SP-N invariant that we don't
# rewrite tests for a structural refactor, expose the helper on
# this module's namespace via a one-line re-import. Delete this
# line once the test is updated to import from the new home.
from cyclone.api_routers.payers import _clear_summary_cache  # noqa: F401  # SP36 backward-compat shim

# SP36 Task 15 backward-compat: ``claims_stream`` moved into
# ``cyclone.api_routers.claims`` (it's a single-router handler per
# spec D4). The live-tail integration test in
# ``tests/test_api_stream_live.py`` imports it by name from this
# module to drive ``_call_endpoint(stream_fn, path)``. Per the
# SP-N invariant that we don't rewrite tests for a structural
# refactor, expose it on this module's namespace via a one-line
# re-import. Delete this line once the test is updated to
# import from the new home.
from cyclone.api_routers.claims import claims_stream  # noqa: F401  # SP36 backward-compat shim

# SP36 Tasks 9+10 backward-compat: ``remittances_stream`` and
# ``activity_stream`` moved into ``cyclone.api_routers.remittances``
# and ``cyclone.api_routers.activity`` respectively (they are
# single-router handlers per spec D4). The live-tail integration
# test in ``tests/test_api_stream_live.py`` imports them by name
# from this module to drive ``_call_endpoint(stream_fn, path)``.
# Per the SP-N invariant that we don't rewrite tests for a
# structural refactor, expose them on this module's namespace via
# one-line re-imports. Delete these lines once the test is updated
# to import from the new homes.
from cyclone.api_routers.remittances import remittances_stream  # noqa: F401  # SP36 backward-compat shim
from cyclone.api_routers.activity import activity_stream  # noqa: F401  # SP36 backward-compat shim

# SP36 Task 16 backward-compat: the 3 ack-helper aliases
# (``_ack_count_summary``, ``_ack_synthetic_source_batch_id``,
# ``_277ca_synthetic_source_batch_id``) used to be aliased imports
# at the top of api.py so the parse-999 / parse-277ca routes could
# call them by their underscore-prefixed names. After Task 16
# extracted the parse routes into ``cyclone.api_routers.parse``,
# those alias imports moved with the routes. The dedup test in
# ``tests/test_api_dedup.py`` asserts ``hasattr(api, name)`` for
# each of these 3 names — it's a guard against the inline copy
# ever creeping back in. Re-import them here so the test keeps
# passing without modification.
from cyclone.handlers._ack_id import (  # noqa: E402,F401  # SP36 backward-compat shim
    ack_count_summary as _ack_count_summary,
    ack_synthetic_source_batch_id as _ack_synthetic_source_batch_id,
    two77ca_synthetic_source_batch_id as _277ca_synthetic_source_batch_id,
)


__all__ = ["app"]
