"""FastAPI application factory and entrypoint.

This module creates the FastAPI app, adds middleware, includes all feature
routers, and provides the CLI entrypoint.
"""

import asyncio
import logging
import os
from contextlib import asynccontextmanager

import uvicorn
from fastapi import FastAPI, HTTPException, Request
from fastapi.exceptions import RequestValidationError
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.responses import JSONResponse as StarletteJSONResponse

from peon_mcp.db import init_db
from peon_mcp.discovery import discover_routers
from peon_mcp.sessions.utils import mark_stale_sessions_timeout

logger = logging.getLogger(__name__)

_STALE_SESSION_INTERVAL = 3600  # seconds between cleanup runs
_STUCK_TASK_RECOVERY_INTERVAL = 300  # seconds between stuck task checks (5 min)

# Paths that never require authentication
_PUBLIC_PATHS = frozenset({"/api/health"})
_PUBLIC_PREFIXES = ("/api/auth/", "/api/public/")


# ---------------------------------------------------------------------------
# App setup
# ---------------------------------------------------------------------------


async def _periodic_stuck_task_recovery(db) -> None:
    """Background task: recover in_progress tasks whose host has no heartbeat.

    Runs every 5 minutes. Finds tasks that are in_progress but whose host
    loop has no active heartbeat (dead pod), and moves them back to todo
    so another peon can pick them up.
    """
    while True:
        try:
            await asyncio.sleep(_STUCK_TASK_RECOVERY_INTERVAL)
            rows = await db.pool.fetch(
                """UPDATE tasks
                   SET status = 'todo',
                       status_reason = 'auto-recovered: host loop has no active heartbeat'
                   WHERE status = 'in_progress'
                     AND started_at < NOW() - INTERVAL '10 minutes'
                     AND host != ''
                     AND host NOT IN (
                         SELECT hostname FROM loop_heartbeats
                         WHERE last_heartbeat > NOW() - INTERVAL '90 seconds'
                     )
                   RETURNING id, title, host"""
            )
            if rows:
                for r in rows:
                    logger.info(
                        "Auto-recovered stuck task #%d (%s) — host %s is dead",
                        r["id"], r["title"], r["host"],
                    )
        except asyncio.CancelledError:
            break
        except Exception:
            logger.warning("Periodic stuck task recovery failed", exc_info=True)


async def _periodic_stale_session_cleanup(db) -> None:
    """Background task: mark stale running sessions as timeout every hour."""
    while True:
        try:
            await asyncio.sleep(_STALE_SESSION_INTERVAL)
            updated = await mark_stale_sessions_timeout(db)
            if updated:
                logger.info("Periodic cleanup: marked %d stale running session(s) as timeout", updated)
        except asyncio.CancelledError:
            break
        except Exception:
            logger.warning("Periodic stale session cleanup failed", exc_info=True)


@asynccontextmanager
async def app_lifespan(application):
    """Application lifespan: create shared DB connection."""
    db = await init_db()
    application.state.db = db

    # Import subscriber modules to trigger @on() handler registration
    import peon_mcp.github_webhooks.subscribers  # noqa: F401 — registers auto-merge handler

    # Clean expired sessions on startup
    try:
        await db.execute("DELETE FROM user_sessions WHERE expires_at < NOW()")
        await db.commit()
    except Exception:
        logger.debug("user_sessions table not yet available, skipping cleanup")

    # Mark stale running sessions as timeout on startup
    try:
        updated = await mark_stale_sessions_timeout(db)
        if updated:
            logger.info("Startup cleanup: marked %d stale running session(s) as timeout", updated)
    except Exception:
        logger.debug("Stale session timeout cleanup on startup failed", exc_info=True)

    # Discord bot startup moved to peon-grukk — peon-api is a pure REST API now.

    cleanup_task = asyncio.create_task(_periodic_stale_session_cleanup(db))
    recovery_task = asyncio.create_task(_periodic_stuck_task_recovery(db))
    yield
    cleanup_task.cancel()
    recovery_task.cancel()
    await asyncio.gather(cleanup_task, recovery_task, return_exceptions=True)
    await db.close()


class AuthMiddleware(BaseHTTPMiddleware):
    """Middleware supporting API key and session cookie authentication."""

    def __init__(self, application, api_key: str | None = None, github_oauth_enabled: bool = False):
        super().__init__(application)
        self.api_key = api_key
        self.github_oauth_enabled = github_oauth_enabled

    async def dispatch(self, request: Request, call_next):
        # Skip auth for non-API routes, CORS preflight, public paths
        if not request.url.path.startswith("/api/") or request.method == "OPTIONS":
            return await call_next(request)
        if request.url.path in _PUBLIC_PATHS:
            return await call_next(request)
        if any(request.url.path.startswith(p) for p in _PUBLIC_PREFIXES):
            return await call_next(request)
        if request.url.path == "/api/github/webhook":
            return await call_next(request)

        # 1. API key auth
        if self.api_key:
            provided_key = request.headers.get("X-API-Key")
            if provided_key == self.api_key:
                request.state.auth_method = "api_key"
                return await call_next(request)

        # 2. Session cookie auth
        session_token = request.cookies.get("peon_session")
        if session_token:
            try:
                db = request.app.state.db
                row = await db.execute_fetchone(
                    "SELECT u.id as user_id, u.github_id, u.login, u.avatar_url, "
                    "u.name, u.email, s.expires_at, s.token_expires_at "
                    "FROM user_sessions s JOIN users u ON s.user_id = u.id "
                    "WHERE s.session_token = ? AND s.expires_at > NOW()",
                    (session_token,),
                )
            except Exception:
                logger.exception("Session auth DB query failed")
                row = None
            if row:
                request.state.auth_method = "session"
                request.state.user = dict(row)
                return await call_next(request)

        # 3. No valid auth found
        if self.api_key or self.github_oauth_enabled:
            return StarletteJSONResponse(
                status_code=401,
                content={"error": "Authentication required"},
            )

        # 4. No auth configured — allow through (backward compat)
        return await call_next(request)


def create_app() -> FastAPI:
    api_key = os.environ.get("PEON_API_KEY")
    github_oauth_enabled = bool(os.environ.get("GITHUB_APP_CLIENT_ID"))

    swagger_params: dict = {}
    if api_key or github_oauth_enabled:
        swagger_params["swagger_ui_init_oauth"] = {}
        swagger_params["swagger_ui_parameters"] = {
            "persistAuthorization": True,
        }

    application = FastAPI(
        title="Peon MCP",
        version="0.9.66",
        lifespan=app_lifespan,
        **swagger_params,
    )

    # Auth middleware (inner — runs after CORS headers are handled)
    application.add_middleware(
        AuthMiddleware,
        api_key=api_key,
        github_oauth_enabled=github_oauth_enabled,
    )

    # CORS middleware (outer — wraps everything so 401s get CORS headers)
    application.add_middleware(
        CORSMiddleware,
        allow_origin_regex=r".*",
        allow_credentials=True,
        allow_methods=["*"],
        allow_headers=["*"],
    )

    # Inject security schemes into OpenAPI spec
    if api_key or github_oauth_enabled:
        def custom_openapi():
            if application.openapi_schema:
                return application.openapi_schema
            schema = _original_openapi()
            schemes = schema.setdefault("components", {}).setdefault("securitySchemes", {})
            if api_key:
                schemes["ApiKeyAuth"] = {"type": "apiKey", "in": "header", "name": "X-API-Key"}
            if github_oauth_enabled:
                schemes["CookieAuth"] = {"type": "apiKey", "in": "cookie", "name": "peon_session"}
            security = []
            if api_key:
                security.append({"ApiKeyAuth": []})
            if github_oauth_enabled:
                security.append({"CookieAuth": []})
            schema["security"] = security
            application.openapi_schema = schema
            return schema

        _original_openapi = application.openapi
        application.openapi = custom_openapi

    @application.exception_handler(HTTPException)
    async def http_exception_handler(request: Request, exc: HTTPException):
        """Return errors in {"error": "..."} format for backward compatibility."""
        return JSONResponse(
            status_code=exc.status_code,
            content={"error": exc.detail},
        )

    @application.exception_handler(RequestValidationError)
    async def validation_exception_handler(request: Request, exc: RequestValidationError):
        """Return validation errors in {"error": "..."} format."""
        errors = exc.errors()
        messages = []
        for err in errors:
            loc = " -> ".join(str(part) for part in err["loc"])
            messages.append(f"{loc}: {err['msg']}")
        return JSONResponse(
            status_code=422,
            content={"error": "; ".join(messages)},
        )

    # Auto-discover and include feature routers
    for router in discover_routers():
        application.include_router(router)

    return application


app = create_app()


# ---------------------------------------------------------------------------
# Entrypoint
# ---------------------------------------------------------------------------


def find_open_port(host: str, preferred: int) -> int:
    """Return *preferred* if available, otherwise ask the OS for a free port."""
    import socket

    sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    try:
        sock.bind((host, preferred))
        sock.close()
        return preferred
    except OSError:
        pass

    sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    sock.bind((host, 0))
    port = sock.getsockname()[1]
    sock.close()
    return port


def _parse_port(value: str, default: int = 8420) -> int:
    """Parse a port number, ignoring Kubernetes service discovery URLs.

    K8s sets SERVICE_PORT to values like 'tcp://10.x.x.x:80' which aren't
    valid integer ports.
    """
    try:
        return int(value)
    except ValueError:
        return default


def main():
    log_level = os.environ.get("PEON_LOG_LEVEL", "INFO").upper()
    logging.basicConfig(
        level=logging.WARNING,
        format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
        datefmt="%Y-%m-%d %H:%M:%S",
    )
    logging.getLogger("peon_mcp").setLevel(getattr(logging, log_level, logging.INFO))

    host = os.environ.get("PEON_UI_HOST", "127.0.0.1")
    preferred_port = _parse_port(os.environ.get("PEON_UI_PORT", "8420"))
    port = find_open_port(host, preferred_port)
    if port != preferred_port:
        print(f"Port {preferred_port} in use, using {port} instead")
    url = f"http://{host}:{port}"
    link = f"\033]8;;{url}\033\\{url}\033]8;;\033\\"
    print(f"peon-ui starting on {link}")
    uvicorn.run(app, host=host, port=port)


if __name__ == "__main__":
    main()
