"""FastAPI application factory."""

from __future__ import annotations

import asyncio
from contextlib import asynccontextmanager
from importlib.metadata import PackageNotFoundError, version

import structlog
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware

from server.api import context, episodes, memories, subjects, timeline
from server.core.config import settings
from server.core.errors import register_exception_handlers
from server.core.logging import setup_logging
from server.core.middleware import RequestIDMiddleware
from server.core.auth import APIKeyMiddleware
from server.core.ratelimit import RateLimitMiddleware
from server.core.residency_middleware import ResidencyMiddleware
from server.core.tenant import TenantMiddleware
from server.core.tracing import setup_tracing

logger = structlog.stdlib.get_logger()


def get_app_version() -> str:
    """Return the installed package version, or a dev sentinel when not installed."""
    try:
        return version("statewave")
    except PackageNotFoundError:
        return "0.0.0-dev"


async def _cleanup_loop():
    """Periodically clean up stale ephemeral demo subjects and old compile jobs."""
    from server.db.engine import get_session_factory
    from server.services.snapshots import cleanup_ephemeral_subjects
    from server.services.compile_jobs_durable import cleanup_old_jobs
    from server.services.ratelimit import cleanup_expired_windows
    from server.services.memory_ttl import cleanup_expired_memories
    from server.services.receipts import cleanup_expired_receipts

    while True:
        await asyncio.sleep(3600)  # every hour
        try:
            count = await cleanup_ephemeral_subjects()
            if count:
                logger.info("scheduled_cleanup_done", subjects_cleaned=count)
        except Exception as exc:
            logger.warning("scheduled_cleanup_error", error=str(exc))

        # Compile job retention
        if settings.compile_job_retention_hours > 0:
            try:
                deleted = await cleanup_old_jobs(settings.compile_job_retention_hours)
                if deleted:
                    logger.info("compile_jobs_retention_done", deleted=deleted)
            except Exception as exc:
                logger.warning("compile_jobs_retention_error", error=str(exc))

        # Rate limit window cleanup
        if settings.rate_limit_rpm > 0 and settings.rate_limit_strategy == "distributed":
            try:
                await cleanup_expired_windows()
            except Exception as exc:
                logger.warning("rate_limit_cleanup_error", error=str(exc))

        # Memory TTL — backstop tombstoning of expired memories. Retrieval
        # already filters `valid_to <= now()` rows out, so this loop is
        # not on the hot path; it just transitions stale rows to the
        # `tombstoned` status that the receipts surface (issue #49) reads.
        if settings.kind_ttl_days:
            try:
                async with get_session_factory()() as session:
                    tombstoned = await cleanup_expired_memories(session)
                    await session.commit()
                if tombstoned:
                    logger.info("memory_ttl_cleanup_done", tombstoned=tombstoned)
            except Exception as exc:
                logger.warning("memory_ttl_cleanup_error", error=str(exc))

        # Receipt retention (v0.9, issue #156) — tombstones receipts past
        # their tenant's `receipt_retention_days`. Soft-delete only; rows
        # persist with `status='tombstoned'` for the audit trail of "this
        # receipt was emitted and later retired." Gated INSIDE the worker
        # by per-tenant config (no settings-level switch) so admin-set
        # retention starts taking effect on the next tick without a
        # restart.
        try:
            async with get_session_factory()() as session:
                tombstoned = await cleanup_expired_receipts(session)
                await session.commit()
            if tombstoned:
                logger.info("receipts_retention_cleanup_done", tombstoned=tombstoned)
        except Exception as exc:
            logger.warning("receipts_retention_cleanup_error", error=str(exc))


@asynccontextmanager
async def lifespan(app: FastAPI):
    # Configure tracing (no-op if opentelemetry not installed)
    setup_tracing()

    # Apply any DB-stored settings overrides BEFORE we wire up modules that
    # snapshot config (webhooks, llm singleton, …). Failures here log and
    # continue — the env-only fallback keeps the server bootable even if
    # the override layer is broken or hasn't been migrated yet.
    try:
        from server.core.dynamic_settings import apply_db_overrides_to_settings

        applied = await apply_db_overrides_to_settings()
        if applied:
            logger.info("settings_db_overrides_applied", keys=sorted(applied.keys()))
    except Exception as exc:
        logger.warning("settings_db_overrides_skipped", reason=str(exc)[:200])

    # Configure webhooks
    from server.services import webhooks

    webhooks.configure(
        url=settings.webhook_url,
        timeout=settings.webhook_timeout,
        events=settings.webhook_event_filter,
    )
    await webhooks.start_worker()

    # Background cleanup — runs once per hour, short-circuits each block
    # when its feature isn't configured. v0.9 added per-tenant receipt
    # retention (issue #156), which can be enabled/disabled at runtime
    # via the admin endpoint — gating the loop on a settings-time check
    # would mean a restart to start picking up tenant retention changes.
    # Always-on is cheap (one sleep + a handful of conditional skips per
    # hour on minimal deployments) and removes that footgun.
    cleanup_task = asyncio.create_task(_cleanup_loop())

    # Schema compatibility check
    try:
        from server.services.migrations import check_migration_status

        migration_status = await check_migration_status()
        if migration_status.error:
            logger.error("schema_check_error", error=migration_status.error)
        elif not migration_status.is_compatible:
            msg = migration_status.summary
            logger.warning("schema_mismatch", detail=msg, pending=migration_status.pending_count)
            if settings.strict_schema:
                raise RuntimeError(f"Schema mismatch (STATEWAVE_STRICT_SCHEMA=1): {msg}")
    except RuntimeError:
        raise
    except Exception as exc:
        logger.warning("schema_check_skipped", reason=str(exc)[:200])

    from server.services.llm import warn_if_llm_compiler_missing_api_key

    warn_if_llm_compiler_missing_api_key()

    logger.info("app_startup", version=get_app_version(), debug=settings.debug)
    yield
    if cleanup_task:
        cleanup_task.cancel()
    await webhooks.stop_worker()
    from server.db.engine import dispose_engine

    await dispose_engine()
    logger.info("app_shutdown")


def create_app() -> FastAPI:
    setup_logging(debug=settings.debug)

    app = FastAPI(
        title="Statewave",
        summary="Memory OS — trusted context runtime for AI agents",
        description=(
            "Statewave lets AI applications record raw interaction history, "
            "compile durable typed memories, retrieve ranked context within "
            "token budgets, and govern data by subject."
        ),
        version=get_app_version(),
        docs_url="/docs",
        redoc_url="/redoc",
        lifespan=lifespan,
    )

    # -- Middleware -----------------------------------------------------------
    # Starlette executes add_middleware in REVERSE order (last-added = outermost).
    # Desired execution: CORS → RequestID → Auth → RateLimit → Tenant → Residency → App
    # So we register innermost first:
    # Residency runs AFTER Tenant resolves the id so the check has
    # something to compare against. Single-region deployments
    # (settings.region is None) make the middleware a zero-cost
    # pass-through. See server/services/residency.py.
    app.add_middleware(ResidencyMiddleware)
    app.add_middleware(
        TenantMiddleware, header=settings.tenant_header, require=settings.require_tenant
    )
    app.add_middleware(
        RateLimitMiddleware, rpm=settings.rate_limit_rpm, strategy=settings.rate_limit_strategy
    )
    app.add_middleware(APIKeyMiddleware, api_key=settings.api_key)
    app.add_middleware(RequestIDMiddleware)
    app.add_middleware(
        CORSMiddleware,
        allow_origins=settings.cors_origins,
        allow_credentials=True,
        allow_methods=["*"],
        allow_headers=["*"],
    )

    # -- Exception handlers --------------------------------------------------
    register_exception_handlers(app)

    # -- Routes --------------------------------------------------------------
    app.include_router(episodes.router)
    app.include_router(memories.router)
    app.include_router(context.router)
    app.include_router(timeline.router)
    app.include_router(subjects.router)

    from server.api.resolutions import router as resolutions_router

    app.include_router(resolutions_router)

    from server.api.handoff import router as handoff_router

    app.include_router(handoff_router)

    from server.api.admin import router as admin_router

    app.include_router(admin_router)

    from server.api.health import router as health_router

    app.include_router(health_router)

    from server.api.sla import router as sla_router

    app.include_router(sla_router)

    from server.api.llm import router as llm_router

    app.include_router(llm_router)

    from server.api.receipts import router as receipts_router

    app.include_router(receipts_router)

    from server.api.templates import router as templates_router

    app.include_router(templates_router)

    # -- Ops endpoints -------------------------------------------------------
    @app.get("/healthz", tags=["ops"], summary="Liveness check")
    @app.get("/health", tags=["ops"], summary="Liveness check (alias)", include_in_schema=False)
    async def healthz():
        """Returns 200 if the process is alive."""
        return {"status": "ok"}

    @app.get("/v1/version", tags=["ops"], summary="Server version discovery")
    async def version():
        """Public, unauthenticated version discovery.

        Returns the running server version and the API contract id — useful
        for smoke tests, support, and Docker users verifying which image is
        live. No auth, tenant, or rate-limit applies (see the middleware
        public-path sets).
        """
        from importlib.metadata import PackageNotFoundError
        from importlib.metadata import version as pkg_version

        try:
            ver = pkg_version("statewave")
        except PackageNotFoundError:
            ver = "unknown"
        return {"version": ver, "api_contract": "v1"}

    @app.get("/readyz", tags=["ops"], summary="Deep readiness check")
    @app.get("/ready", tags=["ops"], summary="Readiness check (alias)", include_in_schema=False)
    async def readyz():
        """Deep readiness check: DB, queue health, LLM reachability."""
        from fastapi.responses import JSONResponse

        from server.db.engine import get_engine
        from server.services.readiness import database_url_status, run_readiness_checks

        # Classify the DB URL before touching the engine so a first-time
        # deployer sees "not set" / "couldn't be parsed" / "unreachable"
        # as distinct, actionable states instead of one generic error (#66).
        url_state, url_detail = database_url_status()
        if url_state != "ok":
            result = await run_readiness_checks(None, db_unavailable_detail=url_detail)
        else:
            try:
                async with get_engine().connect() as conn:
                    result = await run_readiness_checks(conn)
            except Exception as exc:
                result = await run_readiness_checks(
                    None,
                    db_unavailable_detail=f"Postgres unreachable: {str(exc)[:200]}",
                )

        body = {
            "status": result.status,
            "checks": [
                {
                    "name": c.name,
                    "status": c.status,
                    **({"detail": c.detail} if c.detail else {}),
                    **({"latency_ms": c.latency_ms} if c.latency_ms else {}),
                }
                for c in result.checks
            ],
        }
        return JSONResponse(content=body, status_code=result.http_status)

    @app.get("/ops/migrations", tags=["ops"], summary="Migration status")
    async def migration_status():
        """Return current schema revision, expected head, and pending migrations."""
        from server.services.migrations import check_migration_status

        status = await check_migration_status()
        return {
            "current_revision": status.current_revision,
            "expected_head": status.expected_head,
            "is_compatible": status.is_compatible,
            "pending_count": status.pending_count,
            "pending_revisions": status.pending_revisions,
            "error": status.error,
            "summary": status.summary,
        }

    return app


app = create_app()
