"""
agentpaas.api.app — FastAPI application entry point.
"""
from __future__ import annotations
import os
import threading
import time
from typing import Dict
from collections import defaultdict
import time as _time
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse
from agentpaas.config import settings
from agentpaas.api.v1 import agents, auth, admin, billing, traces, jobs, discovery, metrics, status, analyze, setup as setup_router, providers_api, templates_api, knowledge as knowledge_router, agentpacks, assistant, mcp as mcp_router, skills as skills_router
from agentpaas.observability.logging import logger

# 改进⑦ (AUDIT_2026-06-11): on_event("startup") 在 FastAPI 中已弃用，
# 改用 lifespan。_on_startup 定义在文件后部（路由注册之后），这里通过
# 模块级引用延迟解析。
from contextlib import asynccontextmanager

@asynccontextmanager
async def _lifespan(_app: FastAPI):
    await _on_startup()
    yield

app = FastAPI(
    title="AgentPaaS",
    description="Agent Platform as a Service — powered by lambdagent",
    version="1.4.2",
    docs_url="/docs",
    redoc_url="/redoc",
    lifespan=_lifespan,
)

# SEC-02: CORS whitelist — includes Vite dev server (5173) by default for local use
_cors_origins = os.getenv(
    "AGENTPAAS_CORS_ORIGINS",
    "http://localhost:3000,http://127.0.0.1:3000,http://localhost:5173,http://127.0.0.1:5173",
).split(",")
app.add_middleware(
    CORSMiddleware,
    allow_origins=_cors_origins,
    allow_credentials=True,
    allow_methods=["GET", "POST", "PUT", "DELETE"],
    allow_headers=["Content-Type", "Authorization"],
)

# SEC-06: Rate limiting middleware
_rate_store: Dict[str, list] = defaultdict(list)
# 改进① (AUDIT_2026-06-11): 多 worker 线程并发读写 _rate_store 的
# 清理/检查/追加三步不是原子的 —— 加锁保证滑动窗口计数准确。
_rate_lock = threading.Lock()

@app.middleware("http")
async def rate_limit(request: Request, call_next):
    """Per-API-key (or per-IP for unauth) rate limiting (sliding window).

    audit/AUDIT_2026-06-05.md #25 fix: previously `if not api_key:
    return await call_next(request)` meant every unauthenticated request
    bypassed rate limiting entirely — a single anonymous IP could
    hammer /api/v1/setup/health, /docs, or any 401-returning endpoint
    at unlimited bandwidth. Now: unauth traffic falls back to a stricter
    per-IP bucket so the bypass is closed but doc/health latency-spike
    paths don't disrupt legitimate API-key users.
    """
    # Exempt static / discovery / health from any rate limit (these are
    # cheap and we want them always responsive).
    if request.url.path in ("/health", "/", "/docs", "/redoc", "/openapi.json"):
        return await call_next(request)

    # Tests fire 100+ requests per pytest run against the synthetic
    # `testclient` host; that exhausts the unauth-per-IP bucket (default
    # 10/min) and 429s legitimate test traffic. AGENTPAAS_TESTING=1
    # exempts test runs without weakening the production posture (which
    # is the whole point of audit #25 — to defend the public surface,
    # not the integration harness).
    from agentpaas.config import is_testing
    if is_testing():
        return await call_next(request)

    # Safety-critical: a run-cancel POST must NEVER be rate-limited. We hit
    # this exact wall during run_e48f99 — the chat UI's 5s polling burned
    # through the 60/min bucket, and when the user finally clicked Stop the
    # /cancel POST itself returned 429. The user then can't kill a runaway
    # iterate run that's piling up claude subprocesses and eating RAM, which
    # is the worst possible time to throttle them.
    p = request.url.path
    if p.endswith("/cancel") and request.method == "POST":
        return await call_next(request)

    api_key = request.headers.get("authorization", "")[:20]  # prefix as bucket key
    if api_key:
        rate_key = f"key:{api_key}"
        max_requests = settings.default_tenant_rate_limit
    else:
        # Unauthenticated: bucket by client IP. Lower ceiling — common
        # workloads here are setup wizard / probing / health UI. The
        # legitimate single-user case stays well under it; anonymous
        # scanners get throttled.
        client_ip = (request.client.host if request.client else "") or "unknown"
        rate_key = f"ip:{client_ip}"
        max_requests = max(10, settings.default_tenant_rate_limit // 6)

    now = _time.time()
    window = 60  # 1 minute window

    with _rate_lock:
        # Clean old entries
        _rate_store[rate_key] = [t for t in _rate_store[rate_key] if now - t < window]

        if len(_rate_store[rate_key]) >= max_requests:
            from fastapi.responses import JSONResponse
            return JSONResponse(
                status_code=429,
                content={"error": {"code": "RATE_LIMITED", "message": f"Rate limit exceeded ({max_requests}/min)"}},
                headers={"Retry-After": "60"},
            )

        _rate_store[rate_key].append(now)
    return await call_next(request)

# SEC-10: HTTPS enforcement in production
if not settings.debug:
    @app.middleware("http")
    async def enforce_https(request: Request, call_next):
        if request.headers.get("x-forwarded-proto") == "http":
            from fastapi.responses import JSONResponse
            return JSONResponse(
                status_code=421,
                content={"error": {"code": "HTTPS_REQUIRED", "message": "HTTPS is required in production"}},
            )
        return await call_next(request)

@app.middleware("http")
async def add_timing(request: Request, call_next):
    t0 = time.time()
    response = await call_next(request)
    ms = int((time.time() - t0) * 1000)
    response.headers["X-Response-Time-Ms"] = str(ms)
    logger.info(f"{request.method} {request.url.path} -> {response.status_code} ({ms}ms)")
    return response

# SEC-23: Security response headers
@app.middleware("http")
async def security_headers(request: Request, call_next):
    """SEC-23: Add security response headers."""
    response = await call_next(request)
    response.headers["X-Content-Type-Options"] = "nosniff"
    response.headers["X-Frame-Options"] = "DENY"
    response.headers["X-XSS-Protection"] = "1; mode=block"
    response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
    if not settings.debug:
        response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
        response.headers["Content-Security-Policy"] = "default-src 'self'"
    return response

# SEC-24: Request ID tracking
import uuid as _uuid

@app.middleware("http")
async def request_id(request: Request, call_next):
    """SEC-24: Unique request ID for log correlation."""
    req_id = request.headers.get("X-Request-ID", str(_uuid.uuid4()))
    response = await call_next(request)
    response.headers["X-Request-ID"] = req_id
    return response


# ── Deployment-mode route gating (CR-20260607-001 rev.2, §10.2 / Q9) ──
# In `desktop` mode, /admin and /billing routes return 404 — they're not
# applicable to a single-user installer and exposing them would just
# confuse end users. 404 (not 403) follows the same oracle-defense
# pattern as audit critical #1's loopback-only /bootstrap: a remote
# scanner cannot distinguish desktop / lab / paas deployments from the
# outside, because the response is identical to "route does not exist".
# `lab` and `paas` modes leave these routes fully reachable; the
# existing audit fixes (#14 #28 #30 + the security batch) handle
# tenant scoping there.
_DESKTOP_BLOCKED_PREFIXES = (
    "/api/v1/admin",
    "/api/v1/billing",
)


@app.middleware("http")
async def deployment_mode_gate(request: Request, call_next):
    if settings.deployment_mode == "desktop":
        path = request.url.path
        for prefix in _DESKTOP_BLOCKED_PREFIXES:
            if path.startswith(prefix):
                logger.debug(
                    f"Mode-blocked: {path} (deployment_mode=desktop, "
                    f"expected lab|paas to expose)"
                )
                from fastapi.responses import JSONResponse
                return JSONResponse(
                    status_code=404,
                    content={"detail": "Not Found"},
                )
    return await call_next(request)


app.include_router(agents.router, prefix="/api/v1")
app.include_router(jobs.router, prefix="/api/v1")
app.include_router(auth.router, prefix="/api/v1")
app.include_router(admin.router, prefix="/api/v1")
app.include_router(billing.router, prefix="/api/v1")
app.include_router(traces.router, prefix="/api/v1")
app.include_router(metrics.router, prefix="/api/v1")
app.include_router(discovery.router)
app.include_router(status.router, prefix="/api/v1")
app.include_router(analyze.router, prefix="/api/v1")
# Web UI support endpoints (no auth required for setup bootstrap)
app.include_router(setup_router.router, prefix="/api/v1")
app.include_router(providers_api.router, prefix="/api/v1")
app.include_router(templates_api.router, prefix="/api/v1")
app.include_router(knowledge_router.router, prefix="/api/v1")
app.include_router(agentpacks.router, prefix="/api/v1")
app.include_router(assistant.router, prefix="/api/v1")
app.include_router(mcp_router.router, prefix="/api/v1")
app.include_router(skills_router.router, prefix="/api/v1")


async def _on_startup():
    """Start background services on server startup (called by _lifespan)."""
    # Eagerly construct the singleton Database so the zombie-reaper migration
    # (Database.__init__) runs NOW instead of on the first authenticated
    # request. Otherwise a 'running' kb_job from a prior crashed backend
    # keeps showing as in-flight in the UI until someone happens to hit a
    # DB-touching endpoint.
    from agentpaas.db.session import get_db
    _db = get_db()

    # Reap orphaned 'running' runs left by a prior crashed/restarted backend.
    # In-process (stream) runs do not survive a process restart, so any row
    # still marked 'running' at startup is dead — finalize it so the UI stops
    # showing an eternal spinner. cancelled if the user had pressed Stop, else
    # failed. Mirrors the kb_jobs zombie-reaper already in Database.__init__.
    try:
        import json as _json
        from agentpaas.db.models import now_utc as _now
        _n = _db.execute(
            "UPDATE runs SET "
            "status = CASE WHEN cancel_requested = 1 THEN 'cancelled' ELSE 'failed' END, "
            "error = COALESCE(error, ?), completed_at = ? "
            "WHERE status = 'running'",
            (_json.dumps({"code": "ORPHANED",
                          "message": "backend restarted while run was in-flight"}),
             _now()),
        ).rowcount
        _db.commit()
        if _n:
            logger.info(f"reaped {_n} orphaned 'running' run(s) at startup")
    except Exception as e:  # pragma: no cover (startup hook must not crash)
        logger.warning(f"run orphan reaper skipped: {e}")

    knowledge_router._start_schedule_checker()

    # Desktop mode auto-bootstrap (CR-20260607-001 rev.2, Q11).
    # If we're a fresh desktop installer with no tenant configured yet,
    # create one + an admin API key + write to ~/.agentpaas/config.json.
    # User then opens http://127.0.0.1:8000 and is logged in.
    # No-op if any active tenant already exists.
    #
    # Safety: AGENTPAAS_TESTING=1 disables the auto-bootstrap so test
    # fixtures don't clobber the user's real ~/.agentpaas/config.json on
    # every TestClient(app) invocation. Tests that want to exercise the
    # bootstrap path call `auto_bootstrap_desktop()` explicitly (with
    # CONFIG_FILE patched to a tmp path).
    from agentpaas.config import is_testing as _is_testing
    # 注意：用 is_testing() 而非 os.getenv 真值判断——AGENTPAAS_TESTING=0
    # 曾被当成"测试模式开启"，导致 desktop 启动跳过 bootstrap 和内置包安装。
    if settings.deployment_mode == "desktop" and not _is_testing():
        try:
            from agentpaas.api.v1.setup import auto_bootstrap_desktop
            auto_bootstrap_desktop()
        except Exception as e:  # pragma: no cover (startup hook must not crash)
            logger.warning(f"desktop auto-bootstrap skipped: {e}")

        # Auto-install built-in AgentPacks (idempotent — skips already-installed).
        # Runs after bootstrap so agentpacks_dir is guaranteed to resolve
        # correctly (data_dir is locked in by this point).
        try:
            from agentpaas.engine.builtin_packs import ensure_builtin_packs_installed
            ensure_builtin_packs_installed(settings.agentpacks_dir)
        except Exception as e:  # pragma: no cover (startup hook must not crash)
            logger.warning(f"builtin pack auto-install skipped: {e}")

        # Auto-register built-in prompt-skills（能力插件，如「作业批改」）。
        # 与内置包同构、幂等：已注册则跳过。
        try:
            from agentpaas.engine.builtin_skills import ensure_builtin_skills_installed
            ensure_builtin_skills_installed()
        except Exception as e:  # pragma: no cover (startup hook must not crash)
            logger.warning(f"builtin skill auto-register skipped: {e}")

        # 示例知识库（幂等）：让「知识库」页开箱就有一个已建索引、
        # 可搜索、可挂接的演示库，帮助老师理解知识库怎么用。
        # 在 bootstrap 之后跑 — 需要租户已存在。
        try:
            from agentpaas.engine.sample_kb import ensure_sample_kb
            ensure_sample_kb(settings.data_dir)
        except Exception as e:  # pragma: no cover (startup hook must not crash)
            logger.warning(f"sample KB setup skipped: {e}")


@app.get("/health")
async def health():
    return {"status": "ok", "version": "1.4.2"}

# Serve built Web UI static files when webui-dist/ exists.
#
# Path resolution broke during the Phase 1 src-layout migration (this file
# moved from agentpaas/api/app.py to agentpaas/src/agentpaas/api/app.py,
# adding one directory level — the old `../../webui-dist` started pointing
# at agentpaas/src/webui-dist, which doesn't exist; caught by dogfooding,
# not unit tests, since the import test only checks routes load). Resolve
# robustly by probing a list of candidate locations:
#   1. AGENTPAAS_WEBUI_DIST env (explicit override)
#   2. /app/webui-dist (Docker layout — Dockerfile COPYs it there)
#   3. <repo_root>/webui-dist (dev: 4 levels up from this file's dir)
#   4. <cwd>/webui-dist (running from repo root)
def _resolve_webui_dist() -> str:
    import sys
    explicit = os.getenv("AGENTPAAS_WEBUI_DIST", "")
    here = os.path.dirname(os.path.abspath(__file__))
    # Nuitka standalone/onefile: this module compiles to
    # <bundle_root>/agentpaas/api/app.py (no `src` level), and the bundled
    # webui-dir sits at <bundle_root>/webui-dist — i.e. up 2, not up 4.
    # Also probe next to the executable so the binary serves the UI
    # regardless of cwd.
    exe_dir = os.path.dirname(os.path.abspath(sys.argv[0])) if sys.argv and sys.argv[0] else here
    candidates = [
        explicit,
        "/app/webui-dist",
        # agentpaas/src/agentpaas/api -> up 4 -> repo root (dev/source layout)
        os.path.normpath(os.path.join(here, "..", "..", "..", "..", "webui-dist")),
        # bundled layout (Nuitka): agentpaas/api -> up 2 -> bundle root
        os.path.normpath(os.path.join(here, "..", "..", "webui-dist")),
        # alongside the (onefile/standalone) executable
        os.path.join(exe_dir, "webui-dist"),
        os.path.normpath(os.path.join(os.getcwd(), "webui-dist")),
    ]
    for c in candidates:
        if c and os.path.isdir(c):
            return c
    # Return the dev default even if missing, so the `else` branch logs it.
    return os.path.normpath(os.path.join(here, "..", "..", "..", "..", "webui-dist"))


_WEBUI_DIST = _resolve_webui_dist()

if os.path.isdir(_WEBUI_DIST):
    app.mount("/assets", StaticFiles(directory=os.path.join(_WEBUI_DIST, "assets")), name="assets")

    from fastapi import Request as _Request
    from fastapi.exceptions import HTTPException as _HTTPException

    @app.exception_handler(404)
    async def spa_fallback(_req: _Request, _exc: _HTTPException):
        if _req.url.path.startswith("/api/") or _req.url.path in ("/docs", "/redoc", "/health"):
            from fastapi.responses import JSONResponse
            return JSONResponse(status_code=404, content={"detail": "Not Found"})
        return FileResponse(os.path.join(_WEBUI_DIST, "index.html"))

    @app.get("/", include_in_schema=False)
    async def serve_root():
        return FileResponse(os.path.join(_WEBUI_DIST, "index.html"))

else:
    @app.get("/")
    async def root():
        return {
            "service": "AgentPaaS",
            "version": "1.4.2",
            "docs": "/docs",
            "ui": "Run `cd webui && npm install && npm run build` to enable the Web UI.",
            "description": "Agent Platform as a Service — Every agent is a Lambda term.",
        }
