import asyncio
import logging
from contextlib import asynccontextmanager
from typing import AsyncIterator

from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from sqlalchemy.exc import DBAPIError, IntegrityError

from app.core import observability
from app.core.config import settings
from app.core.scheduler import scheduler_loop
from app.core.tracing import setup_tracing

observability.configure_logging()
logger = logging.getLogger("flowfabric")
from app.routers import (
    access_policies,
    ai_assistant,
    app_model,
    approvals,
    apps,
    audit,
    auth,
    components,
    dashboards,
    data_queries,
    datasets,
    form_submissions,
    forms,
    home,
    jobs,
    masters,
    menus,
    notifications,
    pages,
    payments,
    public_data,
    public_forms,
    public_site,
    platform_admin,
    portal_users,
    releases,
    report_templates,
    reporting,
    rpa,
    roles,
    saved_views,
    scheduling,
    secrets,
    sessions,
    tasks,
    tenants,
    uploads,
    users,
    vendors,
    workflow_delegations,
    workflow_instances,
    workflows,
    workspaces,
)


@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
    # The scheduler (queues due ScheduledJobs) and a job worker (runs queued
    # jobs) — see app/core/scheduler.py and app/core/jobs.py. RUN_JOBS=false
    # leaves both to separate `python -m app.worker` processes.
    from app.core import platform_settings
    from app.core.database import AsyncSessionLocal
    from app.core.jobs import run_worker
    from app.db_migrate import warn_if_pending

    # The Super Admin's overrides of .env (app/core/platform_settings.py),
    # before the first request — and kept in step after.
    try:
        async with AsyncSessionLocal() as db:
            await platform_settings.load(db)
    except Exception:
        logging.getLogger("flowfabric").exception("platform settings couldn't be loaded; using .env")
    background = [asyncio.create_task(platform_settings.refresh_loop())]
    if settings.run_jobs:
        background += [asyncio.create_task(scheduler_loop()), asyncio.create_task(run_worker())]
    # A database behind the code is logged, not fatal (app/db_migrate.py).
    asyncio.create_task(warn_if_pending())
    try:
        yield
    finally:
        for task in background:
            task.cancel()
        await asyncio.gather(*background, return_exceptions=True)


app = FastAPI(
    title="FlowFabric API",
    description=(
        "REST API for FlowFabric — implements the modules specified in "
        "FlowFabric_SRS.docx section 4, over the schema in db/schema.sql. "
        "Auth is a self-contained JWT scheme (app/core/auth.py, "
        "app/core/security.py, POST /auth/login) — every route but "
        "/auth/login and /health requires a valid bearer token."
    ),
    version="0.1.0",
    lifespan=lifespan,
)

app.add_middleware(
    CORSMiddleware,
    allow_origins=settings.cors_origin_list,
    # The local dev frontend's port moves around (autoPort picks a new one
    # whenever the previous one is stuck — see .claude/launch.json), so
    # allow_origins' fixed list alone means a fresh port silently 403s on
    # every request until someone notices and edits it. This regex covers
    # any localhost port so that never blocks local dev; allow_origins
    # above still matters for any real, non-localhost deployment origin.
    allow_origin_regex=r"http://localhost:\d+",
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
    # The browser may read the request id off any response, to quote it.
    expose_headers=["X-Request-ID"],
)
# Outermost: every request gets its id and log context before anything else runs.
app.add_middleware(observability.RequestContextMiddleware)
setup_tracing(app)


@app.middleware("http")
async def remember_request_origin(request: Request, call_next):
    # Lets payment code build gateway callback URLs pointing back at this
    # API (app/crud/payments.py::public_api_url) from inside a Task run too.
    from app.crud.payments import request_base_url

    token = request_base_url.set(str(request.base_url).rstrip("/"))
    try:
        return await call_next(request)
    finally:
        request_base_url.reset(token)

app.include_router(auth.router)
app.include_router(platform_admin.router)
app.include_router(tenants.router)
app.include_router(vendors.router)
app.include_router(users.router)
app.include_router(sessions.router)
app.include_router(roles.router)
app.include_router(masters.router)
app.include_router(access_policies.router)
app.include_router(components.router)
app.include_router(workflows.router)
app.include_router(workflow_instances.router)
app.include_router(forms.router)
app.include_router(form_submissions.router)
app.include_router(menus.router)
app.include_router(dashboards.router)
app.include_router(pages.router)
app.include_router(payments.router)
app.include_router(rpa.router)
app.include_router(rpa.providers_router)
app.include_router(tasks.router)
app.include_router(datasets.router)
app.include_router(uploads.router)
app.include_router(releases.router)
app.include_router(app_model.router)
app.include_router(apps.router)
app.include_router(workspaces.router)
app.include_router(reporting.router)
app.include_router(secrets.router)
app.include_router(data_queries.router)
app.include_router(report_templates.router)
app.include_router(scheduling.router)
app.include_router(jobs.router)
app.include_router(notifications.router)
app.include_router(portal_users.router)
app.include_router(audit.router)
app.include_router(home.router)
app.include_router(saved_views.router)
app.include_router(approvals.router)
app.include_router(workflow_delegations.router)
app.include_router(public_site.router)
app.include_router(public_forms.router)
app.include_router(public_data.router)
app.include_router(ai_assistant.router)
app.include_router(ai_assistant.config_router)


async def _entity_violation(exc: DBAPIError) -> JSONResponse | None:
    """A datasource stored as a table refused a write (app/crud/entity_storage.py)
    — said in words the person can act on, as a 422 (a value) or 409 (a
    record still linked from elsewhere)."""
    from app.core.jobs import tenant_session
    from app.crud.entity_storage import explain_violation

    # A fresh session (the request's has failed), in the request's tenant —
    # get_auth_context put it in the log context.
    tenant = observability.current("tenant_id")
    if tenant is None:
        return None
    violation = await explain_violation(lambda: tenant_session(tenant), exc)
    if violation is None:
        return None
    return JSONResponse(status_code=violation.status, content={"detail": {"code": "storage", "message": violation.message, "field": violation.field}})


@app.exception_handler(DBAPIError)
async def database_error_handler(request: Request, exc: DBAPIError) -> JSONResponse:
    if getattr(getattr(exc.orig, "__cause__", None), "sqlstate", None) == "57014":  # query_canceled: statement_timeout
        return JSONResponse(status_code=503, content={"detail": "This took too long and was stopped — narrow it with a filter or search, then try again."})
    response = await _entity_violation(exc)
    if response is None:
        raise exc
    return response


@app.exception_handler(IntegrityError)
async def integrity_error_handler(request: Request, exc: IntegrityError) -> JSONResponse:
    """
    A constraint violation (bad FK reference, a unique-name collision, a
    NOT NULL gap) is a client-input problem, not a server fault — without
    this handler it reaches the caller as a bare 500 with a stack trace.
    Every router already handles the specific violations it can predict in
    advance (e.g. FR-MST-007's own check before DELETE); this is the
    catch-all for the rest of Postgres's own constraints.
    """
    response = await _entity_violation(exc)
    if response is not None:
        return response
    pg_error_type = type(exc.orig.__cause__).__name__ if exc.orig is not None else "IntegrityError"
    logger.warning("IntegrityError on %s %s: %s", request.method, request.url.path, exc.orig.__cause__ if exc.orig else exc)
    return JSONResponse(
        status_code=409,
        content={"detail": f"{pg_error_type}: the request conflicts with an existing record or reference."},
    )


@app.get("/health", tags=["health"])
async def health() -> dict[str, str]:
    """Liveness: the process is up and serving."""
    return {"status": "ok"}


@app.get("/ready", tags=["health"])
async def ready() -> JSONResponse:
    """Readiness: the database answers (503 when it doesn't). Pending
    migrations are reported, not fatal — the app warns about them at startup."""
    from sqlalchemy import text

    from app.core.database import engine
    from app.db_migrate import migrations

    try:
        async with asyncio.timeout(3):
            async with engine.connect() as conn:
                await conn.execute(text("SELECT 1"))
                tracked = await conn.execute(text("SELECT name FROM schema_migrations")) if (await conn.execute(text("SELECT to_regclass('schema_migrations')"))).scalar() else None
                applied = {r[0] for r in tracked} if tracked is not None else None
    except Exception as exc:  # noqa: BLE001 - any failure here means "not ready"
        logger.warning("Readiness check failed: %s", exc)
        return JSONResponse(status_code=503, content={"status": "unavailable", "database": "unreachable"})
    pending = None if applied is None else [m.name for m in migrations() if m.name not in applied]
    return JSONResponse(content={"status": "ok", "database": "ok", "pending_migrations": pending})


@app.get("/metrics", tags=["health"], include_in_schema=False)
async def metrics(request: Request):
    """Prometheus metrics (app/core/observability.py)."""
    from fastapi.responses import Response

    if not observability.metrics_allowed(request.headers.get("authorization")):
        return JSONResponse(status_code=404, content={"detail": "Not Found"})
    body, content_type = await observability.metrics_text()
    return Response(content=body, media_type=content_type)
