"""FastAPI server for Gremlin Autopilot.

Provides a localhost web UI for RevOps users with:
- CSRF protection via session tokens and Origin/Referer validation
- Localhost-only binding by default
- Remote access with pairing code authentication
"""

from __future__ import annotations

import json
import logging
import os
import secrets
import shutil
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import TYPE_CHECKING, Any
from urllib.parse import urlparse

from fastapi import FastAPI, HTTPException, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from fastapi.staticfiles import StaticFiles

from .models import (
    ChatRequest,
    ChatResponse,
    ExecuteRequest,
    ExecuteResponse,
    HealthResponse,
    JobStatus,
    PairRequest,
    PairResponse,
    PlayListResponse,
    RunPlayRequest,
)
from .policy import apply_policy_overlays, compute_policy_hash, generate_default_policy, load_policy
from .receipts import ReceiptWriter
from .state import resolve_state_dir
from .approvals import ApprovalTokenStore
from .executor import PlanExecutor
from .tools import get_default_registry
from .contracts import PlanV1

if TYPE_CHECKING:
    from collections.abc import Awaitable, Callable

logger = logging.getLogger(__name__)

# Session token storage location
SESSION_KEY_PATH = Path.home() / ".g_gremlin" / "autopilot" / "session.key"
LOCALHOSTS = {"localhost", "127.0.0.1", "::1"}


def get_or_create_session_token(session_key_path: Path | None = None) -> str:
    """Get existing session token or create a new one."""
    token_path = session_key_path or SESSION_KEY_PATH
    token_path.parent.mkdir(parents=True, exist_ok=True)

    if token_path.exists():
        return token_path.read_text().strip()

    # Generate new 256-bit token
    token = secrets.token_urlsafe(32)
    token_path.write_text(token)
    # Restrict permissions (owner only)
    token_path.chmod(0o600)
    logger.info(f"Created new session token at {token_path}")
    return token


def generate_pairing_code() -> str:
    """Generate human-readable pairing code, valid for 5 minutes."""
    chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"  # No ambiguous chars
    code = "-".join("".join(secrets.choice(chars) for _ in range(4)) for _ in range(3))
    return code  # e.g., "GREM-7X2K-9PLM"


def _origin_matches_request(
    origin: str,
    request: Request,
    allow_remote: bool,
    expected_port: int,
) -> bool:
    """Validate Origin/Referer against current request."""
    if not origin:
        return True

    parsed = urlparse(origin)
    if not parsed.scheme or not parsed.hostname:
        return False

    if allow_remote:
        if parsed.hostname != request.url.hostname:
            return False
    else:
        if parsed.hostname not in LOCALHOSTS:
            return False

    if parsed.port is None:
        return expected_port in (80, 443)

    return parsed.port == expected_port


def create_app(
    allow_remote: bool = False,
    port: int = 7777,
    session_key_path: Path | None = None,
    user_credentials: set[str] | None = None,
    *,
    policy_path: Path | None = None,
    state_dir: Path | None = None,
) -> FastAPI:
    """Create and configure the FastAPI application.

    Args:
        allow_remote: If True, bind to all interfaces and enable pairing.
                     Default is False (localhost only).
        port: Port to listen on (used for CORS origin checks).
        session_key_path: Optional path override for session token storage.
        user_credentials: Set of credential types the user has configured.
    """
    app = FastAPI(
        title="Gremlin Autopilot",
        description="Localhost web UI for RevOps workflows",
        version="0.1.0",
    )

    # Store configuration on app state
    app.state.allow_remote = allow_remote
    app.state.port = port
    app.state.session_token = get_or_create_session_token(session_key_path)
    app.state.session_key_path = session_key_path or SESSION_KEY_PATH
    app.state.authenticated_ips: set[str] = set()

    resolved_policy_path = _resolve_policy_path(policy_path)
    if resolved_policy_path and resolved_policy_path.exists():
        policy = load_policy(resolved_policy_path)
        app.state.policy_path = resolved_policy_path
    else:
        policy = generate_default_policy("local")
        app.state.policy_path = None
    app.state.policy = policy
    if state_dir is not None:
        app.state.state_dir = Path(state_dir).resolve()
    else:
        app.state.state_dir = resolve_state_dir(policy, create=True)

    # Initialize play loader
    from .plays import PlayLoader

    app.state.play_loader = PlayLoader(user_credentials=user_credentials)

    if allow_remote:
        app.state.pairing_code = generate_pairing_code()
        app.state.pairing_valid_until = datetime.now(timezone.utc) + timedelta(minutes=5)
    else:
        app.state.pairing_code = None
        app.state.pairing_valid_until = None

    # CORS middleware
    if allow_remote:
        allow_origins = ["*"]
        allow_credentials = False
    else:
        allow_origins = [
            f"http://localhost:{port}",
            f"http://127.0.0.1:{port}",
        ]
        allow_credentials = True

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

    # Register CSRF protection middleware
    @app.middleware("http")
    async def csrf_protection(
        request: Request,
        call_next: Callable[[Request], Awaitable[JSONResponse]],
    ) -> JSONResponse:
        """CSRF protection middleware.

        Validates:
        - Session token (always required for state-changing requests)
        - Origin/Referer (at least one must be localhost if present)
        - Remote IP authentication (if allow_remote is enabled)
        """
        # Skip protection for safe methods
        if request.method in ("GET", "HEAD", "OPTIONS"):
            return await call_next(request)

        # Get client IP
        client_ip = request.client.host if request.client else "unknown"

        # Check remote access authentication
        if app.state.allow_remote:
            # Localhost always allowed
            if client_ip not in ("127.0.0.1", "::1", "localhost"):
                # Check if this IP is authenticated
                if client_ip not in app.state.authenticated_ips:
                    # Allow pairing endpoint without authentication
                    if request.url.path == "/api/pair":
                        return await call_next(request)
                    return JSONResponse(
                        {"error": "Pairing required. Enter pairing code to authenticate."},
                        status_code=403,
                    )

        # Validate headers
        origin = request.headers.get("origin", "")
        referer = request.headers.get("referer", "")
        token = request.headers.get("x-autopilot-token", "")

        # Validate Origin if present
        if origin and not _origin_matches_request(origin, request, allow_remote, app.state.port):
            return JSONResponse({"error": "Invalid origin"}, status_code=403)

        # Validate Referer if present
        if referer and not _origin_matches_request(referer, request, allow_remote, app.state.port):
            return JSONResponse({"error": "Invalid referer"}, status_code=403)

        # Validate session token (always required except for pairing)
        if request.url.path != "/api/pair" and token != app.state.session_token:
            return JSONResponse({"error": "Invalid session token"}, status_code=403)

        return await call_next(request)

    # --- Health Check ---

    @app.get("/api/health", response_model=HealthResponse)
    async def health_check() -> HealthResponse:
        """Health check endpoint."""
        return HealthResponse()

    # --- Pairing ---

    @app.post("/api/pair", response_model=PairResponse)
    async def pair_client(request: Request, body: PairRequest) -> PairResponse:
        """Pair a remote client using the pairing code."""
        if not app.state.allow_remote:
            return PairResponse(success=False, message="Remote access is not enabled")

        # Check if pairing code has expired
        if datetime.now(timezone.utc) > app.state.pairing_valid_until:
            return PairResponse(success=False, message="Pairing code has expired")

        # Validate pairing code (normalize both sides: uppercase, remove spaces and dashes)
        user_code = body.code.upper().replace(" ", "").replace("-", "")
        expected_code = app.state.pairing_code.replace("-", "")
        if user_code != expected_code:
            return PairResponse(success=False, message="Invalid pairing code")

        # Add client IP to authenticated set
        client_ip = request.client.host if request.client else None
        if client_ip:
            app.state.authenticated_ips.add(client_ip)
            logger.info(f"Authenticated remote client: {client_ip}")
            return PairResponse(success=True, message=f"Client {client_ip} authenticated")

        return PairResponse(success=False, message="Could not determine client IP")

    # --- Plays ---

    @app.get("/api/plays", response_model=PlayListResponse)
    async def list_plays() -> PlayListResponse:
        """List available plays."""
        play_loader = app.state.play_loader
        return PlayListResponse(plays=play_loader.list_plays())

    @app.get("/api/plays/{play_name}")
    async def get_play(play_name: str) -> dict:
        """Get details of a specific play."""
        play_loader = app.state.play_loader
        play = play_loader.get_play(play_name)
        if not play:
            return {"error": "Play not found"}
        return play.model_dump()

    @app.post("/api/plays/run")
    async def run_play(body: RunPlayRequest) -> ChatResponse:
        """Run a specific play with parameters."""
        from .plays import PlayExecutor

        play_loader = app.state.play_loader
        play = play_loader.get_play(body.play_name)

        if not play:
            return ChatResponse(
                status=JobStatus.FAILED,
                message=f"Play '{body.play_name}' not found",
                job_id=None,
            )

        executor = PlayExecutor(play_loader)

        # Validate parameters
        errors = executor.validate_parameters(play, body.parameters)
        if errors:
            return ChatResponse(
                status=JobStatus.FAILED,
                message=f"Parameter validation failed: {', '.join(errors)}",
                job_id=None,
            )

        # Prepare steps
        steps = executor.prepare_steps(play, body.parameters)

        # Convert to PlanActions and create a plan
        from .models import PlanAction
        from uuid import uuid4

        actions = [
            PlanAction(
                tool=step["tool"],
                args=step["args"],
                label=step["label"],
                destructive=step.get("destructive", False),
            )
            for step in steps
        ]

        job_id = f"play_{uuid4().hex[:8]}"

        return ChatResponse(
            status=JobStatus.PLAN_READY,
            message=f"Play '{body.play_name}' ready for execution ({len(actions)} steps)",
            job_id=job_id,
            actions=actions,
        )

    # --- Admin (Receipts, Pending, Policy) ---

    @app.get("/api/admin/receipts")
    async def list_receipts() -> list[dict[str, Any]]:
        receipts_dir = Path(app.state.state_dir) / "receipts"
        items: list[dict[str, Any]] = []
        if receipts_dir.exists():
            for run_dir in receipts_dir.iterdir():
                if not run_dir.is_dir():
                    continue
                receipt_path = run_dir / "receipt.json"
                if not receipt_path.exists():
                    continue
                try:
                    data = json.loads(receipt_path.read_text(encoding="utf-8"))
                except Exception:
                    continue
                items.append(
                    {
                        "run_id": data.get("run_id") or run_dir.name,
                        "outcome": data.get("outcome"),
                        "started_at": data.get("started_at"),
                        "completed_at": data.get("completed_at"),
                        "change_count": len(data.get("changes") or []),
                    }
                )
        items.sort(key=lambda x: _parse_iso(x.get("started_at")) or datetime.min, reverse=True)
        return items[:100]

    @app.get("/api/admin/receipts/{run_id}")
    async def get_receipt_detail(run_id: str) -> dict[str, Any]:
        run_dir = Path(app.state.state_dir) / "receipts" / run_id
        receipt_path = run_dir / "receipt.json"
        plan_path = run_dir / "plan.json"
        if not receipt_path.exists():
            raise HTTPException(status_code=404, detail="Receipt not found")
        receipt = json.loads(receipt_path.read_text(encoding="utf-8"))
        plan = json.loads(plan_path.read_text(encoding="utf-8")) if plan_path.exists() else {}
        return {"receipt": receipt, "plan": plan}

    @app.get("/api/admin/pending")
    async def list_pending() -> list[dict[str, Any]]:
        pending_dir = Path(app.state.state_dir) / "pending"
        items: list[dict[str, Any]] = []
        if pending_dir.exists():
            for run_dir in pending_dir.iterdir():
                if not run_dir.is_dir():
                    continue
                plan_path = run_dir / "plan.json"
                if not plan_path.exists():
                    continue
                try:
                    data = json.loads(plan_path.read_text(encoding="utf-8"))
                except Exception:
                    continue
                created_at = datetime.fromtimestamp(plan_path.stat().st_mtime, timezone.utc).isoformat()
                items.append(
                    {
                        "run_id": data.get("run_id") or run_dir.name,
                        "summary": data.get("summary", ""),
                        "risk": data.get("risk", {}),
                        "step_count": len(data.get("steps") or []),
                        "created_at": created_at,
                    }
                )
        items.sort(key=lambda x: _parse_iso(x.get("created_at")) or datetime.min, reverse=True)
        return items

    @app.get("/api/admin/pending/{run_id}")
    async def get_pending_detail(run_id: str) -> dict[str, Any]:
        plan_path = Path(app.state.state_dir) / "pending" / run_id / "plan.json"
        if not plan_path.exists():
            raise HTTPException(status_code=404, detail="Pending plan not found")
        return json.loads(plan_path.read_text(encoding="utf-8"))

    @app.post("/api/admin/pending/{run_id}/approve")
    async def approve_pending(request: Request, run_id: str) -> dict[str, Any]:
        payload = await request.json()
        approve_token = (payload or {}).get("approve_token") if isinstance(payload, dict) else None

        plan_path = Path(app.state.state_dir) / "pending" / run_id / "plan.json"
        if not plan_path.exists():
            raise HTTPException(status_code=404, detail="Pending plan not found")
        plan = PlanV1.from_dict(json.loads(plan_path.read_text(encoding="utf-8")))

        policy = apply_policy_overlays(app.state.policy, Path(app.state.state_dir))
        current_policy_hash = compute_policy_hash(policy)
        if plan.policy_hash and plan.policy_hash != current_policy_hash:
            raise HTTPException(status_code=409, detail="Policy hash mismatch")

        from .approvals import default_approval_ttl_seconds

        store = ApprovalTokenStore(Path(app.state.state_dir) / "db" / "approval_tokens.sqlite")
        token = approve_token
        if not token:
            token = store.create_token(
                plan.plan_hash or "",
                current_policy_hash,
                plan.run_id,
                ttl_seconds=default_approval_ttl_seconds(),
            ).token
        consumed = store.consume_token(
            token,
            plan.plan_hash or "",
            current_policy_hash,
            plan.run_id,
            metadata={"action": "approve_http"},
        )
        if not consumed:
            raise HTTPException(status_code=400, detail="Invalid or expired token")

        registry = get_default_registry()
        executor = PlanExecutor(policy=policy, registry=registry, state_dir=Path(app.state.state_dir))
        receipt = executor.execute(plan, approve_token=token)
        writer = ReceiptWriter(Path(app.state.state_dir))
        writer.write(receipt, plan)
        _archive_pending(Path(app.state.state_dir), run_id, status="approved")
        return receipt.to_dict()

    @app.post("/api/admin/pending/{run_id}/reject")
    async def reject_pending(request: Request, run_id: str) -> dict[str, Any]:
        payload = await request.json()
        approve_token = (payload or {}).get("approve_token") if isinstance(payload, dict) else None

        plan_path = Path(app.state.state_dir) / "pending" / run_id / "plan.json"
        if not plan_path.exists():
            raise HTTPException(status_code=404, detail="Pending plan not found")
        plan = PlanV1.from_dict(json.loads(plan_path.read_text(encoding="utf-8")))

        policy = apply_policy_overlays(app.state.policy, Path(app.state.state_dir))
        current_policy_hash = compute_policy_hash(policy)

        from .approvals import default_approval_ttl_seconds

        store = ApprovalTokenStore(Path(app.state.state_dir) / "db" / "approval_tokens.sqlite")
        token = approve_token
        if not token:
            token = store.create_token(
                plan.plan_hash or "",
                current_policy_hash,
                plan.run_id,
                ttl_seconds=default_approval_ttl_seconds(),
            ).token
        consumed = store.consume_token(
            token,
            plan.plan_hash or "",
            current_policy_hash,
            plan.run_id,
            metadata={"action": "reject_http"},
        )
        if not consumed:
            raise HTTPException(status_code=400, detail="Invalid or expired token")
        _archive_pending(Path(app.state.state_dir), plan.run_id, status="rejected")
        return {"status": "rejected", "run_id": plan.run_id}

    @app.get("/api/admin/policy")
    async def get_policy() -> dict[str, Any]:
        return app.state.policy.model_dump(by_alias=True, exclude_none=True)

    # --- Chat ---
    # Agent chat is not yet available. These endpoints return explicit
    # "not available" responses so the UI never fakes success.

    @app.post("/api/chat", response_model=ChatResponse)
    async def chat(body: ChatRequest) -> ChatResponse:
        """Process a chat message — currently unavailable."""
        return ChatResponse(
            status=JobStatus.FAILED,
            message=(
                "Agent chat is not yet available. "
                "Use the CLI for workflow execution: "
                "g-gremlin autopilot play list / play run <name>"
            ),
            job_id=None,
        )

    # --- Jobs ---

    @app.post("/api/jobs/execute", response_model=ExecuteResponse)
    async def execute_job(body: ExecuteRequest) -> ExecuteResponse:
        """Execute an approved action plan — currently unavailable."""
        return ExecuteResponse(
            status=JobStatus.FAILED,
            job_id=body.job_id,
            results=[],
            message=(
                "Agent execution is not yet available. "
                "Use the CLI instead: g-gremlin autopilot play run <play_name>"
            ),
        )

    @app.get("/api/jobs/{job_id}")
    async def get_job_status(job_id: str) -> dict:
        """Get the status of a job."""
        return {
            "job_id": job_id,
            "status": "unavailable",
            "message": "Agent job tracking is not yet available.",
        }

    # --- Static Files (React UI) ---
    # Serve static files from the ui/dist directory if it exists
    ui_dist_path = Path(__file__).parent / "ui" / "dist"
    if ui_dist_path.exists():
        app.mount("/", StaticFiles(directory=ui_dist_path, html=True), name="static")

    return app


def run_server(port: int = 7777, allow_remote: bool = False) -> None:
    """Run the Autopilot server.

    Args:
        port: Port to listen on (default 7777)
        allow_remote: If True, bind to all interfaces and show pairing code.
                     Default is False (localhost only).
    """
    import uvicorn

    app = create_app(allow_remote=allow_remote, port=port)
    host = "0.0.0.0" if allow_remote else "127.0.0.1"

    if allow_remote:
        logger.warning(
            "\n"
            "  ======================================================\n"
            "  ⚠️  SECURITY WARNING: Server bound to all interfaces.\n"
            "     Only use --allow-remote on trusted networks.\n"
            "  ======================================================\n"
            f"\n  🔐 Pairing code: {app.state.pairing_code}\n"
            "     Enter this code in the browser to authenticate.\n"
            f"     Valid for 5 minutes (until {app.state.pairing_valid_until.strftime('%H:%M:%S')})\n"
        )
    else:
        logger.info(f"Starting Gremlin Autopilot on http://localhost:{port}")
        logger.info(f"Session token stored at: {app.state.session_key_path}")

    uvicorn.run(app, host=host, port=port, log_level="info")


def _parse_iso(value: str | None) -> datetime | None:
    if not value:
        return None
    try:
        return datetime.fromisoformat(value.replace("Z", "+00:00"))
    except Exception:
        return None


def _resolve_policy_path(policy_path: Path | None) -> Path | None:
    if policy_path:
        return Path(policy_path)
    env_path = os.getenv("G_GREMLIN_POLICY_PATH")
    if env_path:
        return Path(env_path)
    default_path = Path("autopilot_policy.yaml")
    if default_path.exists():
        return default_path
    return None


def _archive_pending(state_dir: Path, run_id: str, *, status: str) -> None:
    pending_dir = Path(state_dir) / "pending" / run_id
    if not pending_dir.exists():
        return
    target_root = Path(state_dir) / "pending_archive" / status
    target_root.mkdir(parents=True, exist_ok=True)
    target_dir = target_root / run_id
    if target_dir.exists():
        shutil.rmtree(target_dir, ignore_errors=True)
    try:
        shutil.move(str(pending_dir), str(target_dir))
    except Exception:
        shutil.rmtree(pending_dir, ignore_errors=True)


if __name__ == "__main__":
    # For development: run directly
    logging.basicConfig(level=logging.INFO)
    run_server()
