"""FastAPI dev server for Klisk Studio."""

from __future__ import annotations

import asyncio
import json
import os
import re
from contextlib import asynccontextmanager
from pathlib import Path
from typing import Any

import logging

import uvicorn
from dotenv import load_dotenv
from fastapi import FastAPI, Query, Request, WebSocket, WebSocketDisconnect
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles

from klisk.core.config import ProjectConfig
from klisk.core.discovery import discover_all_projects, discover_project
from klisk.core.paths import PROJECTS_DIR
from klisk.core.registry import AgentRegistry, ProjectSnapshot
from klisk.server.assistant_chat import check_assistant_available, handle_assistant_websocket, install_assistant_sdk
from klisk.server.chat import handle_websocket_chat
from klisk.server.file_editor import (
    update_agent_in_source,
    update_tool_in_source,
    rename_tool_references,
    get_function_source,
)
from klisk.server.watcher import start_watcher

logger = logging.getLogger(__name__)

_CURATED_MODELS: dict[str, list[str]] = {
    "openai": [
        "gpt-5.2",
        "gpt-5.2-pro",
        "gpt-5.1",
        "gpt-5",
        "gpt-5-mini",
        "gpt-5-nano",
        "gpt-5-pro",
        "gpt-4.1",
        "gpt-4.1-mini",
        "gpt-4.1-nano",
        "gpt-4.5-preview",
        "gpt-4o",
        "gpt-4o-mini",
        "gpt-4-turbo",
        "o4-mini",
        "o3",
        "o3-mini",
        "o3-pro",
        "o1",
        "o1-mini",
        "o1-pro",
        "codex-mini-latest",
    ],
    "anthropic": [
        "claude-opus-4-6",
        "claude-opus-4-5",
        "claude-opus-4-1",
        "claude-sonnet-4-5",
        "claude-haiku-4-5",
    ],
    "gemini": [
        "gemini-3-pro-preview",
        "gemini-3-flash-preview",
        "gemini-2.5-pro",
        "gemini-2.5-flash",
        "gemini-2.5-flash-lite",
        "gemini-2.0-flash",
        "gemini-2.0-flash-lite",
    ],
}


_CHAT_MODES = {"chat", "responses"}

# ---------------------------------------------------------------------------
# Model filtering: keep only canonical, current models relevant for agents
# ---------------------------------------------------------------------------

_EXCLUDE_PREFIXES = (
    "ft:", "gpt-3.5", "gpt-4-", "gpt-4-32k", "chatgpt-",
    "claude-3-", "claude-4-",
    "gemini-pro", "gemini-exp-", "gemini-gemma-", "gemma-",
    "gemini-1.5-", "learnlm-", "gemini-robotics-",
)
_EXCLUDE_SUBSTRINGS = (
    "realtime", "audio", "search-preview", "vision",
    "deep-research", "container", "-codex", "-chat",
    "-tts", "-live-", "image-generation",
    "-exp-", "thinking-exp", "computer-use",
)
_EXCLUDE_SUFFIXES = ("-latest", "-001", "-002", "-003", "-exp")

# Match: -YYYY-MM-DD, -YYYYMMDD, preview-MM-DD, preview-MM-YYYY
_DATE_SUFFIX_RE = re.compile(r"-(\d{4}-\d{2}-\d{2}|\d{8})$")
_PREVIEW_DATE_RE = re.compile(r"preview-\d{2}-\d{2,4}$")

_EXCLUDE_EXACT = {"gpt-4"}
# Models whose canonical name matches an exclude rule but should be kept
_ALWAYS_INCLUDE = {"codex-mini-latest"}


def _is_relevant_model(name: str) -> bool:
    """Filter out dated snapshots, legacy, and non-agent models."""
    if name in _ALWAYS_INCLUDE:
        return True
    if name in _EXCLUDE_EXACT:
        return False
    if _DATE_SUFFIX_RE.search(name):
        return False
    if _PREVIEW_DATE_RE.search(name):
        return False
    if any(name.startswith(p) for p in _EXCLUDE_PREFIXES):
        return False
    if any(s in name for s in _EXCLUDE_SUBSTRINGS):
        return False
    if any(name.endswith(s) for s in _EXCLUDE_SUFFIXES):
        return False
    return True


def _get_provider_models() -> dict[str, list[str]]:
    """Return {provider: [model, ...]} from litellm or fallback to curated list."""
    try:
        import litellm

        result: dict[str, list[str]] = {}
        for provider in ("openai", "anthropic", "gemini"):
            raw = litellm.models_by_provider.get(provider, set())
            seen: set[str] = set()
            chat_models: list[str] = []
            for m in sorted(raw):
                info = litellm.model_cost.get(m, {})
                mode = info.get("mode")
                if mode and mode not in _CHAT_MODES:
                    continue
                # Strip provider prefix (gemini models come as "gemini/gemini-2.5-flash")
                clean = m.split("/", 1)[1] if "/" in m else m
                if clean in seen or not _is_relevant_model(clean):
                    continue
                seen.add(clean)
                chat_models.append(clean)
            result[provider] = chat_models if chat_models else _CURATED_MODELS.get(provider, [])
        return result
    except ImportError:
        return dict(_CURATED_MODELS)
    except Exception as exc:
        import traceback
        traceback.print_exc()
        print(f"[klisk] _get_provider_models failed: {exc}", flush=True)
        return dict(_CURATED_MODELS)


_project_path: Path | None = None
_workspace_mode: bool = False
_snapshot: ProjectSnapshot | None = None
_config: ProjectConfig | None = None
_reload_clients: list[WebSocket] = []
_retry_task: asyncio.Task | None = None
_retry_count: int = 0
_MAX_RETRIES = 3


def create_app(project_dir: Path | None) -> FastAPI:
    global _project_path, _workspace_mode, _snapshot, _config

    _workspace_mode = project_dir is None

    if _workspace_mode:
        _project_path = None
        _config = None
        try:
            _snapshot = discover_all_projects()
        except Exception as e:
            logger.exception("Failed to discover workspace at startup")
            _snapshot = ProjectSnapshot()
            _snapshot.config = {"error": str(e)}
    else:
        _project_path = project_dir.resolve()
        _config = ProjectConfig.load(_project_path)
        try:
            _snapshot = discover_project(_project_path)
        except Exception as e:
            logger.exception("Failed to discover project at startup")
            _snapshot = ProjectSnapshot()
            _snapshot.config = {"error": str(e)}

    @asynccontextmanager
    async def lifespan(app: FastAPI):
        watch_dir = PROJECTS_DIR if _workspace_mode else _project_path
        watcher_task = asyncio.create_task(start_watcher(watch_dir, _on_file_change))
        yield
        watcher_task.cancel()

    app = FastAPI(title="Klisk Dev Server", lifespan=lifespan)

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

    app.include_router(_build_api_router())

    @app.websocket("/ws/chat")
    async def ws_chat(websocket: WebSocket):
        await _handle_chat(websocket)

    @app.websocket("/ws/assistant")
    async def ws_assistant(websocket: WebSocket):
        project_dir = _project_path or PROJECTS_DIR
        await handle_assistant_websocket(websocket, project_dir)

    @app.websocket("/ws/reload")
    async def ws_reload(websocket: WebSocket):
        await _handle_reload(websocket)

    # Serve studio static files if the build exists
    studio_dist = _find_studio_dist()
    if studio_dist and studio_dist.exists():
        app.mount("/", StaticFiles(directory=str(studio_dist), html=True), name="studio")

    return app


def _build_api_router():
    from fastapi import APIRouter

    router = APIRouter(prefix="/api")

    @router.get("/project")
    async def get_project():
        return _snapshot.to_dict() if _snapshot else {}

    @router.get("/models")
    async def get_models():
        return {"providers": _get_provider_models()}

    @router.get("/agents")
    async def get_agents():
        if not _snapshot:
            return []
        return [
            {
                "name": e.name,
                "instructions": e.instructions,
                "model": e.model,
                "tools": e.tools,
                "temperature": e.temperature,
                "reasoning_effort": e.reasoning_effort,
                "source_file": e.source_file,
                "project": e.project,
            }
            for e in _snapshot.agents.values()
        ]

    @router.get("/agents/{name:path}")
    async def get_agent(name: str):
        if _snapshot and name in _snapshot.agents:
            e = _snapshot.agents[name]
            return {
                "name": e.name,
                "instructions": e.instructions,
                "model": e.model,
                "tools": e.tools,
                "temperature": e.temperature,
                "reasoning_effort": e.reasoning_effort,
                "source_file": e.source_file,
                "project": e.project,
            }
        return {"error": "Agent not found"}

    @router.get("/tools")
    async def get_tools():
        if not _snapshot:
            return []
        return [
            {
                "name": e.name,
                "description": e.description,
                "parameters": e.parameters,
                "source_file": e.source_file,
                "project": e.project,
            }
            for e in _snapshot.tools.values()
        ]

    @router.get("/tools/{name:path}/source")
    async def get_tool_source(name: str):
        if not _snapshot or name not in _snapshot.tools:
            return {"error": "Tool not found"}
        entry = _snapshot.tools[name]
        if not entry.source_file:
            return {"source_code": ""}
        # For source lookup, use the base function name (strip project prefix)
        func_name = name.split("/")[-1] if "/" in name else name
        code = get_function_source(entry.source_file, func_name)
        return {"source_code": code}

    @router.put("/agents/{name:path}")
    async def update_agent(name: str, request: Request):
        body = await request.json()
        logger.info("PUT /api/agents/%s  body=%s", name, body)

        if not _snapshot or name not in _snapshot.agents:
            logger.warning("Agent '%s' not found in snapshot", name)
            return {"error": "Agent not found"}

        entry = _snapshot.agents[name]
        if not entry.source_file:
            return {"error": "Source file unknown"}

        allowed = {"name", "instructions", "model", "temperature", "reasoning_effort"}
        nullable = {"temperature", "reasoning_effort"}
        updates = {k: v for k, v in body.items() if k in allowed and (v is not None or k in nullable)}
        if not updates:
            return {"ok": True}

        # Use the base agent name (strip project prefix) for source edits
        base_name = name.split("/")[-1] if "/" in name else name
        logger.info("Updating agent '%s' in %s: %s", base_name, entry.source_file, updates)

        try:
            update_agent_in_source(entry.source_file, base_name, updates)
        except Exception as e:
            logger.exception("Failed to update agent '%s'", name)
            return {"error": str(e)}

        return {"ok": True}

    @router.delete("/agents/{name:path}")
    async def delete_agent(name: str):
        if not _snapshot or name not in _snapshot.agents:
            logger.warning("Agent '%s' not found in snapshot", name)
            return {"error": "Agent not found"}

        entry = _snapshot.agents[name]

        # Resolve the project directory to delete
        project_dir: Path | None = None
        if _workspace_mode and entry.project:
            project_dir = PROJECTS_DIR / entry.project
        elif _workspace_mode and entry.source_file:
            project_dir = _get_project_dir_for_source(entry.source_file)
        elif _project_path:
            project_dir = _project_path

        if project_dir is None or not project_dir.is_dir():
            return {"error": "Cannot resolve project directory"}

        logger.info("Deleting project directory: %s", project_dir)

        try:
            import shutil
            shutil.rmtree(project_dir)
        except Exception as e:
            logger.exception("Failed to delete project '%s'", project_dir)
            return {"error": str(e)}

        return {"ok": True}

    @router.put("/tools/{name:path}")
    async def update_tool(name: str, request: Request):
        body = await request.json()
        logger.info("PUT /api/tools/%s  body=%s", name, body)

        if not _snapshot or name not in _snapshot.tools:
            logger.warning("Tool '%s' not found in snapshot", name)
            return {"error": "Tool not found"}

        entry = _snapshot.tools[name]
        if not entry.source_file:
            return {"error": "Source file unknown"}

        allowed = {"name", "description"}
        updates = {k: v for k, v in body.items() if k in allowed and v is not None}
        if not updates:
            return {"ok": True}

        # Use the base tool name (strip project prefix) for source edits
        base_name = name.split("/")[-1] if "/" in name else name
        old_name = base_name
        new_name = updates.get("name", old_name)

        try:
            update_tool_in_source(entry.source_file, old_name, updates)
            if new_name != old_name:
                project_dir = _get_project_dir_for_source(entry.source_file)
                if project_dir:
                    rename_tool_references(str(project_dir), old_name, new_name)
        except Exception as e:
            logger.exception("Failed to update tool '%s'", name)
            return {"error": str(e)}

        return {"ok": True}

    @router.get("/deploy-config")
    async def get_deploy_config(project: str | None = Query(None)):
        project_dir = _resolve_project_dir(project)
        if project_dir is None:
            return {"error": "Cannot resolve project path"}
        cfg = ProjectConfig.load(project_dir)
        return cfg.deploy.model_dump()

    @router.put("/deploy-config")
    async def put_deploy_config(request: Request, project: str | None = Query(None)):
        project_dir = _resolve_project_dir(project)
        if project_dir is None:
            return {"error": "Cannot resolve project path"}
        cfg = ProjectConfig.load(project_dir)
        body = await request.json()
        # Merge incoming fields into deploy config
        updated = cfg.deploy.model_dump()
        for section in ("chat", "widget", "api"):
            if section in body and isinstance(body[section], dict):
                updated[section].update(body[section])
        from klisk.core.config import DeployConfig
        cfg.deploy = DeployConfig.model_validate(updated)
        cfg.save(project_dir)
        return {"ok": True}

    @router.get("/global-config")
    async def get_global_config():
        from klisk.core.config import GlobalConfig
        cfg = GlobalConfig.load()
        return cfg.model_dump()

    @router.put("/global-config")
    async def put_global_config(request: Request):
        from klisk.core.config import GlobalConfig, GCloudConfig
        body = await request.json()
        cfg = GlobalConfig.load()
        if "gcloud" in body and isinstance(body["gcloud"], dict):
            merged = cfg.gcloud.model_dump()
            merged.update(body["gcloud"])
            cfg.gcloud = GCloudConfig.model_validate(merged)
        cfg.save()
        return {"ok": True}

    @router.get("/assistant/status")
    async def assistant_status():
        return check_assistant_available()

    @router.post("/assistant/install")
    async def assistant_install():
        return await install_assistant_sdk()

    # --- Local server endpoints ---

    @router.get("/local-server/status")
    async def local_server_status(project: str | None = Query(None)):
        project_dir = _resolve_project_dir(project)
        if project_dir is None:
            return {"running": False, "port": None, "pid": None, "url": None}
        from klisk.core.local_server import get_status
        proj_cfg = ProjectConfig.load(project_dir)
        project_name = project or (
            _config.name if _config else project_dir.name
        )
        return await asyncio.to_thread(get_status, project_name, 8080, proj_cfg.name)

    @router.post("/local-server/start")
    async def local_server_start(project: str | None = Query(None)):
        project_dir = _resolve_project_dir(project)
        if project_dir is None:
            return {"ok": False, "error": "Cannot resolve project path"}
        from klisk.core.local_server import start_server
        project_name = project or (
            _config.name if _config else project_dir.name
        )
        return await asyncio.to_thread(start_server, project_dir, project_name)

    @router.post("/local-server/stop")
    async def local_server_stop(request: Request, project: str | None = Query(None)):
        project_dir = _resolve_project_dir(project)
        if project_dir is None:
            return {"ok": True, "message": "No project to stop"}
        from klisk.core.local_server import stop_server
        proj_cfg = ProjectConfig.load(project_dir)
        project_name = project or (
            _config.name if _config else project_dir.name
        )
        return await asyncio.to_thread(stop_server, project_name, 8080, proj_cfg.name)

    # --- Cloud deploy endpoints ---

    @router.get("/deploy/cloud-status")
    async def deploy_cloud_status(project: str | None = Query(None)):
        project_dir = _resolve_project_dir(project)
        if project_dir is None:
            return {"deployed": False, "url": None, "service_name": "", "message": "Cannot resolve project path"}
        from klisk.core.config import GlobalConfig, ProjectConfig as ProjCfg
        from klisk.core.cloud_status import get_cloud_run_url, _slugify
        proj_cfg = ProjCfg.load(project_dir)
        global_cfg = GlobalConfig.load()
        service_name = _slugify(proj_cfg.name)
        gcp_project = global_cfg.gcloud.project
        region = global_cfg.gcloud.region
        return await asyncio.to_thread(get_cloud_run_url, service_name, gcp_project, region)

    # --- Env endpoints ---

    @router.get("/env")
    async def get_env(project: str | None = Query(None)):
        env_path = _resolve_env_path(project)
        projects = _list_project_names()
        if env_path is None:
            if _workspace_mode and not project:
                return {"variables": [], "projects": projects}
            return {"error": "Cannot resolve project path"}
        variables = _read_env_file(env_path)
        return {"variables": variables, "projects": projects}

    @router.put("/env")
    async def put_env(request: Request, project: str | None = Query(None)):
        env_path = _resolve_env_path(project)
        if env_path is None:
            return {"error": "Cannot resolve project path"}

        body = await request.json()
        variables: list[dict[str, str]] = body.get("variables", [])

        # Validate keys
        seen_keys: set[str] = set()
        for var in variables:
            key = var.get("key", "").strip()
            if not key:
                continue
            if not _ENV_KEY_RE.match(key):
                return {"error": f"Invalid key: {key}"}
            if key in seen_keys:
                return {"error": f"Duplicate key: {key}"}
            seen_keys.add(key)

        # Filter out entries with empty keys
        variables = [v for v in variables if v.get("key", "").strip()]

        try:
            _write_env_file(env_path, variables)
            if _workspace_mode and project:
                # Update the per-project env cache (no global os.environ pollution)
                from klisk.core.env import load_project_env
                load_project_env(PROJECTS_DIR / project)
            else:
                load_dotenv(env_path, override=True)
        except Exception as e:
            logger.exception("Failed to write .env file")
            return {"error": str(e)}

        return {"ok": True}

    # --- Security endpoints ---

    _SECURITY_KEYS = ["KLISK_API_KEY", "KLISK_CHAT_KEY", "KLISK_WIDGET_KEY"]

    @router.get("/security")
    async def get_security(project: str | None = Query(None)):
        project_dir = _resolve_project_dir(project)
        if project_dir is None:
            return {"error": "Cannot resolve project path"}
        cfg = ProjectConfig.load(project_dir)
        env_path = _resolve_env_path(project)
        env_vars = _read_env_file(env_path) if env_path and env_path.exists() else []
        env_map = {v["key"]: v["value"] for v in env_vars}
        return {
            "interfaces": {
                "chat_enabled": cfg.deploy.chat.enabled,
                "widget_enabled": cfg.deploy.widget.enabled,
            },
            "keys": {
                "api_key": env_map.get("KLISK_API_KEY", ""),
                "chat_key": env_map.get("KLISK_CHAT_KEY", ""),
                "widget_key": env_map.get("KLISK_WIDGET_KEY", ""),
            },
        }

    @router.put("/security")
    async def put_security(request: Request, project: str | None = Query(None)):
        project_dir = _resolve_project_dir(project)
        if project_dir is None:
            return {"error": "Cannot resolve project path"}

        body = await request.json()

        # Update interface toggles in config
        interfaces = body.get("interfaces", {})
        cfg = ProjectConfig.load(project_dir)
        if "chat_enabled" in interfaces:
            cfg.deploy.chat.enabled = bool(interfaces["chat_enabled"])
        if "widget_enabled" in interfaces:
            cfg.deploy.widget.enabled = bool(interfaces["widget_enabled"])
        cfg.save(project_dir)

        # Update API keys in .env
        keys = body.get("keys", {})
        env_path = _resolve_env_path(project)
        if env_path is None:
            return {"error": "Cannot resolve env path"}

        env_vars = _read_env_file(env_path) if env_path.exists() else []
        env_map = {v["key"]: v["value"] for v in env_vars}

        key_mapping = {
            "api_key": "KLISK_API_KEY",
            "chat_key": "KLISK_CHAT_KEY",
            "widget_key": "KLISK_WIDGET_KEY",
        }
        for field, env_key in key_mapping.items():
            if field in keys:
                val = keys[field].strip()
                if val:
                    env_map[env_key] = val
                else:
                    env_map.pop(env_key, None)

        # Rebuild env vars preserving order of non-security keys
        new_vars: list[dict[str, str]] = []
        seen: set[str] = set()
        for v in env_vars:
            k = v["key"]
            if k in key_mapping.values():
                if k in env_map and k not in seen:
                    new_vars.append({"key": k, "value": env_map[k]})
                    seen.add(k)
            else:
                new_vars.append(v)
                seen.add(k)
        # Append any new security keys not already in the file
        for env_key in _SECURITY_KEYS:
            if env_key in env_map and env_key not in seen:
                new_vars.append({"key": env_key, "value": env_map[env_key]})

        try:
            _write_env_file(env_path, new_vars)
            if _workspace_mode and project:
                from klisk.core.env import load_project_env
                load_project_env(PROJECTS_DIR / project)
            else:
                load_dotenv(env_path, override=True)
        except Exception as e:
            logger.exception("Failed to write .env for security config")
            return {"error": str(e)}

        return {"ok": True}

    return router


async def _handle_chat(websocket: WebSocket) -> None:
    await handle_websocket_chat(websocket, lambda: _snapshot)


async def _handle_reload(websocket: WebSocket) -> None:
    await websocket.accept()
    _reload_clients.append(websocket)
    try:
        while True:
            await websocket.receive_text()
    except WebSocketDisconnect:
        _reload_clients.remove(websocket)


async def _on_file_change(py_changed: bool) -> None:
    global _retry_task, _retry_count
    # A real file change resets the retry counter
    _retry_count = 0
    if _retry_task is not None and not _retry_task.done():
        _retry_task.cancel()
        _retry_task = None

    if py_changed:
        await _do_discovery()
    else:
        await _do_config_reload()


async def _do_discovery() -> None:
    global _snapshot, _retry_task, _retry_count
    try:
        if _workspace_mode:
            _snapshot = discover_all_projects()
        else:
            _snapshot = discover_project(_project_path)
    except Exception as e:
        logger.exception("Failed to reload project")
        _snapshot = ProjectSnapshot()
        _snapshot.config = {"error": str(e)}

    data = json.dumps({"type": "reload", "snapshot": _snapshot.to_dict()})
    disconnected = []
    for ws in _reload_clients:
        try:
            await ws.send_text(data)
        except Exception:
            disconnected.append(ws)
    for ws in disconnected:
        _reload_clients.remove(ws)

    # Schedule a retry if some projects failed to load
    if (
        _snapshot
        and _snapshot.failed_projects
        and _retry_count < _MAX_RETRIES
    ):
        logger.info(
            "Scheduling retry %d/%d for failed projects: %s",
            _retry_count + 1,
            _MAX_RETRIES,
            _snapshot.failed_projects,
        )
        _retry_task = asyncio.create_task(_schedule_retry())


async def _do_config_reload() -> None:
    """Light reload: re-read yaml configs without re-importing Python modules."""
    global _snapshot, _config
    logger.info("Config-only reload (no .py changes)")

    if _snapshot is None:
        _snapshot = ProjectSnapshot()

    if _workspace_mode:
        # Workspace-level config is static (just the marker dict).
        # Nothing to re-read; just notify the frontend so it can refetch
        # deploy-config or other per-project endpoints.
        pass
    else:
        # Single-project mode: re-read the config and update the snapshot
        _config = ProjectConfig.load(_project_path)
        _snapshot.config = {
            "name": _config.name,
            "entry": _config.entry,
            "studio": _config.studio.model_dump(),
            "api": _config.api.model_dump(),
            "deploy": _config.deploy.model_dump(),
        }

    data = json.dumps({"type": "reload", "snapshot": _snapshot.to_dict()})
    disconnected = []
    for ws in _reload_clients:
        try:
            await ws.send_text(data)
        except Exception:
            disconnected.append(ws)
    for ws in disconnected:
        _reload_clients.remove(ws)


async def _schedule_retry() -> None:
    global _retry_count
    await asyncio.sleep(5)
    _retry_count += 1
    await _do_discovery()


def _get_project_dir_for_source(source_file: str) -> Path | None:
    """Resolve the project directory that contains the given source file."""
    if _project_path:
        return _project_path
    # Workspace mode: find the project dir from the source file path
    src = Path(source_file).resolve()
    projects_str = str(PROJECTS_DIR.resolve())
    if str(src).startswith(projects_str):
        # The project dir is the first directory under PROJECTS_DIR
        rel = src.relative_to(PROJECTS_DIR.resolve())
        return PROJECTS_DIR / rel.parts[0]
    return None


_ENV_KEY_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")


def _resolve_project_dir(project: str | None) -> Path | None:
    """Resolve the project directory for the given project name."""
    if _workspace_mode:
        if not project:
            return None
        return PROJECTS_DIR / project
    return _project_path


def _resolve_env_path(project: str | None) -> Path | None:
    """Resolve the path to the .env file for the given project."""
    if _workspace_mode:
        if not project:
            return None
        return PROJECTS_DIR / project / ".env"
    if _project_path:
        return _project_path / ".env"
    return None


def _list_project_names() -> list[str]:
    """List available project directory names (workspace mode only)."""
    if not _workspace_mode:
        return []
    if not PROJECTS_DIR.is_dir():
        return []
    return sorted(
        d.name
        for d in PROJECTS_DIR.iterdir()
        if d.is_dir() and not d.name.startswith(".")
    )


def _read_env_file(path: Path) -> list[dict[str, str]]:
    """Parse a .env file into a list of {key, value} dicts."""
    if not path.exists():
        return []
    variables: list[dict[str, str]] = []
    for line in path.read_text(encoding="utf-8").splitlines():
        line = line.strip()
        if not line or line.startswith("#"):
            continue
        if "=" not in line:
            continue
        key, _, value = line.partition("=")
        key = key.strip()
        value = value.strip()
        # Strip surrounding quotes
        if len(value) >= 2 and value[0] == value[-1] and value[0] in ("'", '"'):
            value = value[1:-1]
        variables.append({"key": key, "value": value})
    return variables


def _write_env_file(path: Path, variables: list[dict[str, str]]) -> None:
    """Write a list of {key, value} dicts to a .env file."""
    lines: list[str] = []
    for var in variables:
        key = var["key"].strip()
        value = var["value"]
        if not key:
            continue
        # Quote values that contain spaces, #, or quotes
        if any(c in value for c in (" ", "#", '"', "'")):
            escaped = value.replace("\\", "\\\\").replace('"', '\\"')
            lines.append(f'{key}="{escaped}"')
        else:
            lines.append(f"{key}={value}")
    path.write_text("\n".join(lines) + "\n" if lines else "", encoding="utf-8")


def _find_studio_dist() -> Path | None:
    """Locate the Studio static build directory."""
    import importlib.resources
    # 1. Inside the installed package
    pkg_dist = Path(str(importlib.resources.files("klisk"))) / "studio_dist"
    if pkg_dist.exists():
        return pkg_dist
    # 2. Development fallback (studio/dist next to the repo root)
    dev_dist = Path(__file__).resolve().parent.parent.parent.parent / "studio" / "dist"
    if dev_dist.exists():
        return dev_dist
    return None


def run_server(app: FastAPI, host: str = "0.0.0.0", port: int = 8321) -> None:
    uvicorn.run(app, host=host, port=port, log_level="info")
