# backend/app/routes/settings.py
"""Settings API: read/write owner-level configuration.

`routes/__init__.py` is frozen and only mounts the module's top-
level `router` symbol. We expose a single outer router with no
prefix that composes the three concerns (owner settings, model
registry, model-picker prefs) via `include_router`. Adding a new
owner-scoped surface = define a sub-router in this file and
`outer_router.include_router(...)` it below.
"""

import logging
import shutil
import subprocess
from datetime import UTC, datetime
from pathlib import Path

from fastapi import APIRouter, Depends, Query
from fastapi.responses import Response
from sqlalchemy.orm import Session

from app import models, providers
from app.auth import encrypt_api_key
from app.config import get_settings as get_app_settings
from app.database import get_db
from app.deps import get_current_owner, reject_cross_site
from app.schemas import (
  ModelPrefsUpdate,
  ModelRegistryResponse,
  SettingsUpdate,
)

logger = logging.getLogger(__name__)

# Bound the version shell-out so a wedged CLI can never hang the
# settings request. `--version` is a near-instant probe; two seconds is
# generous headroom.
_VERSION_TIMEOUT = 2.0

# Outer composer — this is what routes/__init__.py picks up. The
# real surfaces live on the three child surfaces below.
router = APIRouter()

# Owner-level settings (Gemini key, provider preference).
settings_router = APIRouter(prefix="/api/settings", tags=["settings"])


def _cli_version(cmd: str) -> str | None:
  """Best-effort installed version of a CLI, or None if unavailable.

  Resolves the binary on PATH first so we never shell out blind, then
  runs `<cmd> --version` under a short timeout. Every failure mode — the
  binary missing, a non-zero exit, a hang, or any OS error — degrades to
  None so the settings request can't break on a flaky CLI. Returns the
  trimmed first line of stdout (the CLIs print one line).
  """
  if shutil.which(cmd) is None:
    return None
  try:
    result = subprocess.run(
      [cmd, "--version"],
      capture_output=True,
      text=True,
      timeout=_VERSION_TIMEOUT,
    )
  except (subprocess.SubprocessError, OSError) as exc:
    logger.warning("%s --version failed: %s", cmd, exc)
    return None
  if result.returncode != 0:
    return None
  first_line = result.stdout.strip().splitlines()
  return first_line[0].strip() if first_line else None


@settings_router.get("")
def get_settings_view(
  owner: models.Owner = Depends(get_current_owner),
) -> dict:
  """Returns which optional integrations are configured.

  `claude_version` / `codex_version` are probed live on each request
  (the CLIs can be upgraded in place), and are None when the CLI isn't
  installed or doesn't respond — the UI renders those read-only.
  """
  data_dir = get_app_settings().data_dir
  codex_creds = Path(data_dir) / "cli-auth" / "codex" / "auth.json"
  return {
    "gemini_configured": owner.gemini_api_key_enc is not None,
    "codex_authenticated": codex_creds.exists(),
    "provider": owner.provider or "claude",
    "skills_enabled": providers.skills_enabled(data_dir),
    "claude_version": _cli_version("claude"),
    "codex_version": _cli_version("codex"),
  }


@settings_router.post("", dependencies=[Depends(reject_cross_site)])
def update_settings(
  body: SettingsUpdate,
  owner: models.Owner = Depends(get_current_owner),
  db: Session = Depends(get_db),
) -> dict:
  """Saves updated settings. Pass empty string to clear a key."""
  if body.gemini_api_key is not None:
    if body.gemini_api_key == "":
      owner.gemini_api_key_enc = None
    else:
      owner.gemini_api_key_enc = encrypt_api_key(body.gemini_api_key)
  if body.provider is not None:
    owner.provider = body.provider
  db.commit()
  # `skills_enabled` lives in the shared agent-settings.json (the
  # Owner model is frozen / chmod 444), so it's persisted outside the
  # DB transaction. Merge into the existing file so we don't clobber
  # model/effort defaults the picker wrote there.
  if body.skills_enabled is not None:
    data_dir = get_app_settings().data_dir
    current = providers._load_agent_settings(data_dir)
    current["skills_enabled"] = bool(body.skills_enabled)
    providers.write_agent_settings(data_dir, current)
  return {"ok": True}


# ─── Model registry ─────────────────────────────────────────────────
#
# Mounted under the same router so the model surface stays adjacent
# to the rest of owner-level preferences. The picker only needs the
# combined list across providers; the upstream fetcher already does
# per-provider isolation (failure on one falls back to KNOWN_MODELS
# for that provider without affecting the other).

# Separate router instance — the model + prefs endpoints belong
# under different prefixes than /api/settings.
models_router = APIRouter(prefix="/api/models", tags=["models"])


@models_router.get("", response_model=ModelRegistryResponse)
async def list_model_registry(
  refresh: bool = Query(
    default=False,
    description="When true, bypass the 5-minute cache and re-fetch "
    "upstream. Used by the manage-models modal's explicit refresh.",
  ),
  _: models.Owner = Depends(get_current_owner),
) -> ModelRegistryResponse:
  """Returns the registry of available models per provider.

  Live data is cached for 5 minutes per provider. A fetch failure
  falls back to KNOWN_MODELS for that provider — the other
  provider's live data still flows.
  """
  data_dir = get_app_settings().data_dir
  registry = await providers.list_models(data_dir, force_refresh=refresh)
  return ModelRegistryResponse(providers=registry)


owner_router = APIRouter(prefix="/api/owner", tags=["owner"])


@owner_router.get("/model-prefs")
def get_model_prefs(
  owner: models.Owner = Depends(get_current_owner),
) -> dict:
  """Returns the owner's model-picker preferences.

  Default shape is `{"hidden_ids": []}` — absent prefs and empty
  prefs are equivalent (the picker shows every registry entry).
  """
  prefs = owner.model_prefs_json or {}
  hidden = prefs.get("hidden_ids") or []
  # Defensive normalize: any persisted non-string falls out here so
  # the client never sees a malformed entry.
  return {"hidden_ids": [s for s in hidden if isinstance(s, str)]}


@owner_router.patch("/model-prefs", dependencies=[Depends(reject_cross_site)])
def update_model_prefs(
  body: ModelPrefsUpdate,
  owner: models.Owner = Depends(get_current_owner),
  db: Session = Depends(get_db),
) -> dict:
  """Replaces the owner's model-picker preferences.

  `hidden_ids` is set verbatim — the modal always sends the full
  desired set so a partial-update merge isn't needed. Stale IDs
  (referring to models no longer in the registry) are tolerated:
  they sit in the prefs harmlessly until the next save trims them.
  """
  # Deduplicate while preserving order — the order isn't load-
  # bearing for filtering but a stable list reads better in any
  # debug surface.
  seen: set[str] = set()
  cleaned: list[str] = []
  for entry in body.hidden_ids:
    if entry in seen:
      continue
    seen.add(entry)
    cleaned.append(entry)
  owner.model_prefs_json = {"hidden_ids": cleaned}
  db.commit()
  return {"hidden_ids": cleaned}


@owner_router.get("/walkthrough")
def get_walkthrough(
  owner: models.Owner = Depends(get_current_owner),
) -> dict:
  """Reports whether the post-signup walkthrough should appear.

  The shell calls this once on mount and shows the WalkthroughOverlay
  iff `completed` is False. The timestamp is exposed so future
  analytics can read when the user got onboarded; the shell ignores
  it. Treats absence as "not completed yet" — fresh owners pre-
  migration land here with NULL and see the walkthrough exactly once.
  """
  completed_at = owner.walkthrough_completed_at
  return {
    "completed": completed_at is not None,
    "completed_at": completed_at.isoformat() if completed_at else None,
  }


@owner_router.post(
  "/walkthrough/complete",
  status_code=204,
  dependencies=[Depends(reject_cross_site)],
)
def mark_walkthrough_complete(
  owner: models.Owner = Depends(get_current_owner),
  db: Session = Depends(get_db),
) -> Response:
  """Records walkthrough completion. Idempotent and write-once: a
  second POST is a no-op rather than refreshing the timestamp, so the
  original completion time stays accurate for downstream analytics
  (the model comment commits to that). We don't require a payload —
  completion is a single bit and there's nothing to configure."""
  if owner.walkthrough_completed_at is None:
    owner.walkthrough_completed_at = datetime.now(UTC)
    db.commit()
  return Response(status_code=204)


# Compose: a single outer router so routes/__init__.py's frozen
# `_load("settings")` picks up all the surfaces.
router.include_router(settings_router)
router.include_router(models_router)
router.include_router(owner_router)
