<script setup lang="ts">
import { ref, computed, watch, onMounted } from "vue";
import { useSettingsStore } from "@/stores/settings";
import { useAuthStore } from "@/stores/auth";
import { useToastStore } from "@/stores/toast";
import { apiGet, apiPost, apiPut, apiDelete, listGroups, createGroup, deleteGroup, listGroupMembers, addGroupMember, removeGroupMember, searchUsers, listApiKeys, createApiKey as apiCreateApiKey, revokeApiKey as apiRevokeApiKey, getProfile, updateProfile, type ApiKeyEntry, type GroupEntry, type GroupMember, type UserSearchResult, type UserProfile, apiErrorMessage } from "@/api/client";
import type { User } from "@/types/auth";
import PaginationBar from "@/components/PaginationBar.vue";
import TagInput from "@/components/TagInput.vue";
import { fmtDate, fmtLogStamp } from "@/utils/dateFormat";

const store = useSettingsStore();
const authStore = useAuthStore();
const toastStore = useToastStore();
const userTimezone = ref("");
const savingTimezone = ref(false);
const timezoneSaved = ref(false);
const trashRetentionDays = ref("90");
const savingRetention = ref(false);
const retentionSaved = ref(false);
// Knowledge auto-inject (per-user). Defaults mirror the backend
// (services/plugin_context: enabled, threshold 0.55, top-k 3).
const kbInjectEnabled = ref(true);
const kbInjectThreshold = ref("0.55");
const kbInjectTopK = ref("3");
const kbWritePathEnabled = ref(true);
// The write-path arm's OWN threshold, stricter than auto-inject's 0.55 above:
// code embeddings sit on a much higher similarity floor than prose, so 0.55 let
// unrelated code through (#2223). Shares top-k, not the threshold.
const kbWritePathThreshold = ref("0.68");
// Near-duplicate report floors, one per record kind (services/dedup.py).
// Snippets are single-chunk, so their floor sits below the 0.90 write-time
// gate and catches what it lets through. Notes/tasks are scored at chunk
// grain (#280) — related families clear 0.90 easily — so their floor sits
// above the gate to keep the report pointed at genuinely-alike records.
const kbDupThresholdSnippet = ref("0.82");
const kbDupThresholdNote = ref("0.93");
const kbDupThresholdTask = ref("0.93");
const savingKbInject = ref(false);
const kbInjectSaved = ref(false);

// think_enabled setting removed 2026-05-23. The chat+curator architecture
// has tools=[] on the chat model; think on a no-tools conversational pass
// is pure latency cost. See generation_task.py:run_generation comment for
// the full reasoning.

function detectTimezone() {
  userTimezone.value = Intl.DateTimeFormat().resolvedOptions().timeZone;
}

async function saveTimezone() {
  savingTimezone.value = true;
  timezoneSaved.value = false;
  try {
    await apiPut('/api/settings', { user_timezone: userTimezone.value });
    timezoneSaved.value = true;
    setTimeout(() => (timezoneSaved.value = false), 2000);
  } catch {
    toastStore.show('Failed to save timezone', 'error');
  } finally {
    savingTimezone.value = false;
  }
}

async function saveRetention() {
  const n = Math.max(0, Math.floor(Number(trashRetentionDays.value) || 0));
  trashRetentionDays.value = String(n);
  savingRetention.value = true;
  retentionSaved.value = false;
  try {
    await apiPut('/api/settings', { trash_retention_days: String(n) });
    retentionSaved.value = true;
    setTimeout(() => (retentionSaved.value = false), 2000);
  } catch {
    toastStore.show('Failed to save retention setting', 'error');
  } finally {
    savingRetention.value = false;
  }
}

async function saveKbInject() {
  const t = Math.min(1, Math.max(0, Number(kbInjectThreshold.value) || 0));
  const k = Math.min(10, Math.max(1, Math.floor(Number(kbInjectTopK.value) || 1)));
  // `|| default` not `|| 0`: an unparseable value here should fall back to the
  // per-kind default, not to 0 — a 0 floor would report every record as a
  // duplicate of every other one.
  const dupSnip = Math.min(1, Math.max(0, Number(kbDupThresholdSnippet.value) || 0.82));
  const dupNote = Math.min(1, Math.max(0, Number(kbDupThresholdNote.value) || 0.93));
  const dupTask = Math.min(1, Math.max(0, Number(kbDupThresholdTask.value) || 0.93));
  // Same `|| default` reasoning: falling back to 0 would surface every
  // snippet in the corpus on every edit, which is the failure this knob fixes.
  const wpT = Math.min(1, Math.max(0, Number(kbWritePathThreshold.value) || 0.68));
  kbInjectThreshold.value = String(t);
  kbInjectTopK.value = String(k);
  kbDupThresholdSnippet.value = String(dupSnip);
  kbDupThresholdNote.value = String(dupNote);
  kbDupThresholdTask.value = String(dupTask);
  kbWritePathThreshold.value = String(wpT);
  savingKbInject.value = true;
  kbInjectSaved.value = false;
  try {
    await apiPut('/api/settings', {
      kb_autoinject_enabled: kbInjectEnabled.value ? 'true' : 'false',
      kb_autoinject_threshold: String(t),
      kb_autoinject_top_k: String(k),
      // Its own switch AND its own threshold (shares only the ceiling) — see
      // WRITEPATH_DEFAULT_THRESHOLD in services/plugin_context.py for the
      // measurements that split them.
      kb_writepath_enabled: kbWritePathEnabled.value ? 'true' : 'false',
      kb_writepath_threshold: String(wpT),
      kb_duplicate_threshold_snippet: String(dupSnip),
      kb_duplicate_threshold_note: String(dupNote),
      kb_duplicate_threshold_task: String(dupTask),
    });
    kbInjectSaved.value = true;
    setTimeout(() => (kbInjectSaved.value = false), 2000);
  } catch {
    toastStore.show('Failed to save auto-inject settings', 'error');
  } finally {
    savingKbInject.value = false;
  }
}
const newEmail = ref("");
const emailPassword = ref("");
const changingEmail = ref(false);
const currentPassword = ref("");
const newPassword = ref("");
const confirmNewPassword = ref("");
const changingPassword = ref(false);
const invalidatingSessions = ref(false);
const exporting = ref(false);
const restoring = ref(false);
const appVersion = ref('dev');
const restoreFileInput = ref<HTMLInputElement | null>(null);

// Migrate stored "admin" → "config"; unknown tabs fall back to "general"
const VALID_TABS = new Set(["general", "account", "profile", "notifications", "integrations", "data", "apikeys", "config", "users", "logs", "groups"]);
const _stored = localStorage.getItem("settings_tab") ?? "general";
const activeTab = ref(VALID_TABS.has(_stored) ? (_stored === "admin" ? "config" : _stored) : "general");

function _loadTabContent(tab: string) {
  if (authStore.isAdmin) {
    if (tab === "users") loadUsersPanel();
    else if (tab === "logs") loadLogsPanel();
    else if (tab === "groups") loadGroupsPanel();
  }
  if (tab === "apikeys") { fetchApiKeys(); }
}

watch(activeTab, (v) => {
  localStorage.setItem("settings_tab", v === "admin" ? "config" : v);
  _loadTabContent(v);
});

// MCP Access (API Keys are used as Bearer tokens for the in-app /mcp endpoint)
const apiKeys = ref<ApiKeyEntry[]>([]);
const newKeyName = ref('');
const newKeyScope = ref<'read' | 'write'>('write');
const newKeyValue = ref('');
const apiKeyCopied = ref(false);
const creatingApiKey = ref(false);
const revokeConfirmId = ref<number | null>(null);

const origin = window.location.origin;
const mcpUrl = computed(() => `${origin}/mcp`);
const mcpUrlCopied = ref(false);
const mcpClientTab = ref<'claude-code' | 'claude-desktop'>('claude-code');
const copiedSnippetKey = ref<string | null>(null);

// Configurable per-browser: the local name Claude uses for this server, and the
// scope of the `claude mcp add` call. Persisted to localStorage so the user's
// preferences carry across visits without needing a server-side setting.
const _MCP_NAME_KEY = 'mcp_server_name';
const _MCP_SCOPE_KEY = 'mcp_scope';
const _DEFAULT_MCP_NAME = 'scribe';
const _isValidScope = (v: unknown): v is 'user' | 'project' | 'local' =>
  v === 'user' || v === 'project' || v === 'local';

const mcpServerName = ref<string>(
  localStorage.getItem(_MCP_NAME_KEY) || _DEFAULT_MCP_NAME,
);
const _storedScope = localStorage.getItem(_MCP_SCOPE_KEY);
const mcpScope = ref<'user' | 'project' | 'local'>(
  _isValidScope(_storedScope) ? _storedScope : 'user',
);

watch(mcpServerName, (v) => {
  const clean = (v || '').trim() || _DEFAULT_MCP_NAME;
  localStorage.setItem(_MCP_NAME_KEY, clean);
});
watch(mcpScope, (v) => localStorage.setItem(_MCP_SCOPE_KEY, v));

const effectiveMcpName = computed(() => (mcpServerName.value || '').trim() || _DEFAULT_MCP_NAME);
const effectiveApiKey = computed(() => newKeyValue.value || '<your-token>');

const claudeCodeCommand = computed(() => {
  // Note: `claude mcp add` takes the URL as a positional arg, not --url.
  // Layout: claude mcp add [--transport ...] [--scope ...] <name> <url> [--header ...]
  return `claude mcp add --transport http --scope ${mcpScope.value} ${effectiveMcpName.value} \\
  ${mcpUrl.value} \\
  --header "Authorization: Bearer ${effectiveApiKey.value}"`;
});

// Plugin install — the recommended path. The Scribe plugin bundles the MCP
// connection, a session-start hook that surfaces your rules, and the Scribe
// process-skills. The marketplace is the Scribe app's own git repo; persisted
// per-browser like the MCP fields above.
const _MKT_KEY = 'plugin_marketplace_url';
const pluginMarketplaceUrl = ref<string>(localStorage.getItem(_MKT_KEY) || '');
watch(pluginMarketplaceUrl, (v) => localStorage.setItem(_MKT_KEY, (v || '').trim()));
// Instance default, set by an admin (Admin tab) and loaded on mount. Used when
// the per-browser field is blank so the install command is copyable out of the box.
const serverMarketplaceUrl = ref('');
const adminMarketplaceUrl = ref('');
const savingMarketplaceUrl = ref(false);
const marketplaceUrlSaved = ref(false);

// DB maintenance (admin) — daily targeted VACUUM (ANALYZE).
interface DbMaintTableResult { table: string; ok: boolean; elapsed_ms: number; error: string | null }
interface DbMaintRun { started_at: string; elapsed_ms: number; tables: DbMaintTableResult[] }
const dbMaintEnabled = ref(true);
const dbMaintHour = ref(4);
const dbMaintLastRun = ref<DbMaintRun | null>(null);
const savingDbMaint = ref(false);
const dbMaintSaved = ref(false);
const runningDbMaint = ref(false);
interface DbTableHealth {
  table: string; live: number; dead: number; dead_pct: number;
  total_bytes: number; mod_since_analyze: number;
  last_vacuum: string | null; last_analyze: string | null;
}
interface DbHealth { db_bytes: number; tables: DbTableHealth[] }
const dbHealth = ref<DbHealth | null>(null);
const loadingHealth = ref(false);
const DEAD_PCT_WARN = 20; // dead-tuple ratio above this = autovacuum falling behind

function formatBytes(n: number): string {
  if (n < 1024) return `${n} B`;
  const units = ["KB", "MB", "GB", "TB"];
  let v = n / 1024, i = 0;
  while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; }
  return `${v.toFixed(v >= 10 || i === 0 ? 0 : 1)} ${units[i]}`;
}
const pluginInstallCommands = computed(() => {
  const mkt = (pluginMarketplaceUrl.value || '').trim()
    || serverMarketplaceUrl.value
    || '<your-scribe-repo>.git';
  return `/plugin marketplace add ${mkt}\n/plugin install scribe@scribe-plugin`;
});

const mcpConfigSnippet = computed(() => JSON.stringify({
  mcpServers: {
    [effectiveMcpName.value]: {
      url: mcpUrl.value,
      headers: {
        Authorization: `Bearer ${effectiveApiKey.value}`,
      },
    },
  },
}, null, 2));

async function copyMcpUrl() {
  await copyToClipboard(mcpUrl.value);
  mcpUrlCopied.value = true;
  setTimeout(() => { mcpUrlCopied.value = false; }, 2000);
}

async function copyToClipboard(text: string) {
  try {
    await navigator.clipboard.writeText(text);
  } catch {
    // Fallback for http (non-secure) contexts where clipboard API is unavailable
    const ta = document.createElement('textarea');
    ta.value = text;
    ta.style.position = 'fixed';
    ta.style.opacity = '0';
    document.body.appendChild(ta);
    ta.focus();
    ta.select();
    document.execCommand('copy');
    document.body.removeChild(ta);
  }
}

async function copySnippet(text: string, key: string) {
  await copyToClipboard(text);
  copiedSnippetKey.value = key;
  setTimeout(() => {
    if (copiedSnippetKey.value === key) copiedSnippetKey.value = null;
  }, 2000);
}

async function fetchApiKeys() {
  apiKeys.value = await listApiKeys();
}

async function createApiKey() {
  if (!newKeyName.value) return;
  creatingApiKey.value = true;
  try {
    const data = await apiCreateApiKey(newKeyName.value, newKeyScope.value);
    newKeyValue.value = data.key;
    newKeyName.value = '';
    await fetchApiKeys();
  } finally {
    creatingApiKey.value = false;
  }
}

async function revokeApiKey(id: number) {
  await apiRevokeApiKey(id);
  revokeConfirmId.value = null;
  await fetchApiKeys();
}

async function copyApiKey() {
  await copyToClipboard(newKeyValue.value);
  apiKeyCopied.value = true;
  setTimeout(() => { apiKeyCopied.value = false; }, 2000);
}

// Groups management
const groups = ref<GroupEntry[]>([]);
const groupsLoading = ref(false);
const newGroupName = ref("");
const newGroupDesc = ref("");
const creatingGroup = ref(false);
const expandedGroupId = ref<number | null>(null);
const groupMembers = ref<Record<number, GroupMember[]>>({});
const groupMemberSearch = ref("");
const groupMemberResults = ref<UserSearchResult[]>([]);
let groupSearchTimer: ReturnType<typeof setTimeout> | null = null;
const groupMemberRole = ref("member");

async function loadGroupsPanel() {
  groupsLoading.value = true;
  try {
    groups.value = await listGroups();
  } finally {
    groupsLoading.value = false;
  }
}

async function createNewGroup() {
  if (!newGroupName.value.trim() || creatingGroup.value) return;
  creatingGroup.value = true;
  try {
    await createGroup(newGroupName.value.trim(), newGroupDesc.value.trim() || undefined);
    newGroupName.value = "";
    newGroupDesc.value = "";
    await loadGroupsPanel();
  } finally {
    creatingGroup.value = false;
  }
}

async function deleteGroupConfirm(g: GroupEntry) {
  if (!confirm(`Delete group "${g.name}"? This cannot be undone.`)) return;
  await deleteGroup(g.id);
  if (expandedGroupId.value === g.id) expandedGroupId.value = null;
  await loadGroupsPanel();
}

async function toggleGroupExpand(g: GroupEntry) {
  if (expandedGroupId.value === g.id) {
    expandedGroupId.value = null;
    return;
  }
  expandedGroupId.value = g.id;
  groupMemberSearch.value = "";
  groupMemberResults.value = [];
  groupMembers.value[g.id] = await listGroupMembers(g.id);
}

function debounceGroupMemberSearch() {
  if (groupSearchTimer) clearTimeout(groupSearchTimer);
  groupSearchTimer = setTimeout(async () => {
    if (groupMemberSearch.value.length < 2) { groupMemberResults.value = []; return; }
    groupMemberResults.value = await searchUsers(groupMemberSearch.value);
  }, 300);
}

async function addMemberToGroup(groupId: number, user: UserSearchResult) {
  await addGroupMember(groupId, user.id, groupMemberRole.value);
  groupMembers.value[groupId] = await listGroupMembers(groupId);
  groupMemberSearch.value = "";
  groupMemberResults.value = [];
  await loadGroupsPanel();
}

async function removeMemberFromGroup(groupId: number, userId: number) {
  await removeGroupMember(groupId, userId);
  groupMembers.value[groupId] = await listGroupMembers(groupId);
  await loadGroupsPanel();
}


// Notification preferences (email notifications only — push surface removed)
const notifyTaskReminders = ref(true);
const notifySecurityAlerts = ref(true);
const savingNotifications = ref(false);
const notificationsSaved = ref(false);

// SMTP settings (admin only)
const smtp = ref({
  smtp_host: "",
  smtp_port: "587",
  smtp_username: "",
  smtp_password: "",
  smtp_from_address: "",
  smtp_from_name: "Fabled Scribe",
  smtp_use_tls: "true",
});
const savingSmtp = ref(false);
const smtpSaved = ref(false);
const testRecipient = ref("");
const sendingTest = ref(false);

// Base URL setting (admin only)
const baseUrl = ref("");
const savingBaseUrl = ref(false);
const baseUrlSaved = ref(false);

// Git forge connections (#2778) — the user's keyring: one read-only
// credential per forge host, used server-side for every forge read on
// projects this user owns. The token round-trips masked; the server treats
// the mask as "unchanged".
interface ForgeConnectionEntry {
  id: number; kind: string; base_url: string; host: string;
}
const forgeConnections = ref<ForgeConnectionEntry[]>([]);
const forgeKinds = ref<string[]>(["gitea", "github"]);
const connForm = ref({ id: 0, kind: "gitea", base_url: "", token: "" });
const connFormOpen = ref(false);
const savingConn = ref(false);
const testingConnId = ref(0);
const connTestResult = ref<{ id: number; ok: boolean; message: string } | null>(null);
// The webhook secret stays admin: the push endpoint is one URL per instance
// and authenticates deliveries, not users.
const forgeWebhookSecret = ref("");
const savingForgeWebhook = ref(false);
const forgeWebhookSaved = ref(false);


// Search test (SearXNG)
const searxngConfigured = ref(false);
const searxngUrl = ref("");
const searchQuery = ref("");
const searchResults = ref<{ url: string; title: string; snippet: string }[]>([]);
const searchLoading = ref(false);
const searchError = ref("");


// ── Profile ──────────────────────────────────────────────────────────────────
const WORK_DAYS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
const profile = ref<UserProfile>({
  display_name: '', job_title: '', industry: '',
  expertise_level: 'intermediate', response_style: 'balanced', tone: 'casual',
  interests: [], work_schedule: {},
})
const profileSaving = ref(false)
const profileSaved = ref(false)

async function loadProfile() {
  try { profile.value = await getProfile() } catch { /* non-critical */ }
}

async function saveProfile() {
  profileSaving.value = true
  profileSaved.value = false
  try {
    profile.value = await updateProfile({
      display_name: profile.value.display_name,
      job_title: profile.value.job_title,
      industry: profile.value.industry,
      expertise_level: profile.value.expertise_level,
      response_style: profile.value.response_style,
      tone: profile.value.tone,
      interests: profile.value.interests,
      work_schedule: profile.value.work_schedule,
    })
    profileSaved.value = true
    setTimeout(() => { profileSaved.value = false }, 2000)
  } catch { toastStore.show('Failed to save profile', 'error') }
  finally { profileSaving.value = false }
}

function toggleProfileWorkDay(day: string) {
  const days = [...(profile.value.work_schedule.days ?? [])]
  const idx = days.indexOf(day)
  if (idx >= 0) days.splice(idx, 1)
  else days.push(day)
  profile.value.work_schedule = { ...profile.value.work_schedule, days }
}

function emptyTagsFetch(): Promise<string[]> { return Promise.resolve([]) }

onMounted(async () => {
  try {
    const v = await apiGet<{ version: string }>('/api/version')
    appVersion.value = v.version
  } catch { /* non-critical */ }
  await store.fetchSettings();
  newEmail.value = authStore.user?.email ?? "";

  // Load notification preferences from user settings
  const allSettings = await apiGet<Record<string, string>>("/api/settings");
  userTimezone.value = allSettings.user_timezone ?? "";
  trashRetentionDays.value = allSettings.trash_retention_days ?? "90";
  kbInjectEnabled.value = allSettings.kb_autoinject_enabled !== "false";
  if (allSettings.kb_autoinject_threshold !== undefined) {
    kbInjectThreshold.value = allSettings.kb_autoinject_threshold;
  }
  if (allSettings.kb_autoinject_top_k !== undefined) {
    kbInjectTopK.value = allSettings.kb_autoinject_top_k;
  }
  kbWritePathEnabled.value = allSettings.kb_writepath_enabled !== "false";
  if (allSettings.kb_writepath_threshold !== undefined) {
    kbWritePathThreshold.value = allSettings.kb_writepath_threshold;
  }
  if (allSettings.kb_duplicate_threshold_snippet !== undefined) {
    kbDupThresholdSnippet.value = allSettings.kb_duplicate_threshold_snippet;
  }
  if (allSettings.kb_duplicate_threshold_note !== undefined) {
    kbDupThresholdNote.value = allSettings.kb_duplicate_threshold_note;
  }
  if (allSettings.kb_duplicate_threshold_task !== undefined) {
    kbDupThresholdTask.value = allSettings.kb_duplicate_threshold_task;
  }
  if (allSettings.notify_task_reminders !== undefined) {
    notifyTaskReminders.value = allSettings.notify_task_reminders !== "false";
  }
  if (allSettings.notify_security_alerts !== undefined) {
    notifySecurityAlerts.value = allSettings.notify_security_alerts !== "false";
  }

  // Load user profile
  await loadProfile();

  // Load journal config (locations, temp unit; prep/closeout UI removed in Phase 7)

  // Check SearXNG status
  try {
    const sr = await apiGet<{ configured: boolean; searxng_url: string }>("/api/settings/search");
    searxngConfigured.value = sr.configured;
    searxngUrl.value = sr.searxng_url;
  } catch {
    searxngConfigured.value = false;
  }

  // Plugin marketplace URL (instance default; readable by all users so the
  // install command in MCP Access is copyable).
  try {
    const mk = await apiGet<{ marketplace_url: string }>("/api/plugin/marketplace-url");
    serverMarketplaceUrl.value = mk.marketplace_url || "";
    adminMarketplaceUrl.value = mk.marketplace_url || "";
    if (!pluginMarketplaceUrl.value) pluginMarketplaceUrl.value = mk.marketplace_url || "";
  } catch {
    // not configured yet
  }

  // DB maintenance config (admin only — endpoint is admin-gated).
  if (authStore.isAdmin) {
    try {
      const dm = await apiGet<{ enabled: boolean; hour: number; last_run: DbMaintRun | null }>("/api/admin/db-maintenance");
      dbMaintEnabled.value = dm.enabled;
      dbMaintHour.value = dm.hour;
      dbMaintLastRun.value = dm.last_run;
    } catch {
      // leave defaults
    }
    await loadDbHealth();
  }

  // Load admin settings
  if (authStore.isAdmin) {
    try {
      const smtpConfig = await apiGet<Record<string, string>>("/api/admin/smtp");
      smtp.value = { ...smtp.value, ...smtpConfig };
    } catch {
      // SMTP not configured yet
    }
    try {
      const urlConfig = await apiGet<{ base_url: string }>("/api/admin/base-url");
      baseUrl.value = urlConfig.base_url;
    } catch {
      // base URL not configured yet
    }
    try {
      await loadForgeWebhook();
    } catch {
      // webhook secret not configured yet
    }
  }
  try {
    await loadForgeConnections();
  } catch {
    // no keyring yet — the ordinary state
  }
  _loadTabContent(activeTab.value);
});

async function loadForgeConnections() {
  const res = await apiGet<{
    connections: ForgeConnectionEntry[]; kinds: string[];
  }>("/api/settings/forge-connections");
  forgeConnections.value = res.connections;
  if (res.kinds?.length) forgeKinds.value = res.kinds;
}

async function loadForgeWebhook() {
  const cfg = await apiGet<{ webhook_secret: string }>("/api/admin/forge-webhook");
  forgeWebhookSecret.value = cfg.webhook_secret;
}

async function changeEmail() {
  changingEmail.value = true;
  try {
    const body: Record<string, string> = { email: newEmail.value.trim() };
    if (authStore.user?.has_password) {
      body.password = emailPassword.value;
    }
    const updated = await apiPut<import("@/types/auth").User>("/api/auth/email", body);
    authStore.user = updated;
    emailPassword.value = "";
    toastStore.show("Email updated successfully");
  } catch (e: unknown) {
    toastStore.show(apiErrorMessage(e, "Failed to update email"), "error");
  } finally {
    changingEmail.value = false;
  }
}

async function invalidateSessions() {
  invalidatingSessions.value = true;
  try {
    await apiPost("/api/auth/invalidate-sessions", {});
    toastStore.show("All other sessions have been invalidated");
  } catch {
    toastStore.show("Failed to invalidate sessions", "error");
  } finally {
    invalidatingSessions.value = false;
  }
}

async function changePassword() {
  if (newPassword.value !== confirmNewPassword.value) {
    toastStore.show("New passwords do not match", "error");
    return;
  }
  changingPassword.value = true;
  try {
    await apiPut("/api/auth/password", {
      current_password: currentPassword.value,
      new_password: newPassword.value,
    });
    toastStore.show("Password changed successfully");
    currentPassword.value = "";
    newPassword.value = "";
    confirmNewPassword.value = "";
  } catch (e: unknown) {
    toastStore.show(apiErrorMessage(e, "Failed to change password"), "error");
  } finally {
    changingPassword.value = false;
  }
}

async function exportData(scope: "user" | "full") {
  exporting.value = true;
  try {
    const url = scope === "full" ? "/api/admin/backup" : "/api/admin/backup?scope=user";
    const res = await fetch(url);
    if (!res.ok) {
      const body = await res.json().catch(() => ({ error: `Error ${res.status}` }));
      throw new Error((body as Record<string, string>).error || `Error ${res.status}`);
    }
    const data = await res.json();
    const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
    const a = document.createElement("a");
    a.href = URL.createObjectURL(blob);
    a.download = `scribe-backup-${scope}-${new Date().toISOString().slice(0, 10)}.json`;
    a.click();
    URL.revokeObjectURL(a.href);
    toastStore.show("Backup downloaded");
  } catch (e) {
    toastStore.show("Export failed: " + (e as Error).message, "error");
  } finally {
    exporting.value = false;
  }
}

const exportingNotes = ref(false);

async function exportNotes(format: "markdown" | "json") {
  exportingNotes.value = true;
  try {
    const res = await fetch(`/api/export?format=${format}`);
    if (!res.ok) throw new Error(`Error ${res.status}`);
    const blob = await res.blob();
    const ext = format === "json" ? "json" : "zip";
    const stamp = new Date().toISOString().slice(0, 10);
    const a = document.createElement("a");
    a.href = URL.createObjectURL(blob);
    a.download = `scribe-${stamp}.${ext}`;
    a.click();
    URL.revokeObjectURL(a.href);
    toastStore.show("Export downloaded");
  } catch (e) {
    toastStore.show("Export failed: " + (e as Error).message, "error");
  } finally {
    exportingNotes.value = false;
  }
}

function triggerRestoreUpload() {
  restoreFileInput.value?.click();
}

async function saveNotifications() {
  savingNotifications.value = true;
  notificationsSaved.value = false;
  try {
    await apiPut("/api/settings", {
      notify_task_reminders: notifyTaskReminders.value ? "true" : "false",
      notify_security_alerts: notifySecurityAlerts.value ? "true" : "false",
    });
    notificationsSaved.value = true;
    setTimeout(() => (notificationsSaved.value = false), 2000);
  } catch {
    toastStore.show("Failed to save notification preferences", "error");
  } finally {
    savingNotifications.value = false;
  }
}

async function saveSmtp() {
  savingSmtp.value = true;
  smtpSaved.value = false;
  try {
    await apiPut("/api/admin/smtp", smtp.value);
    smtpSaved.value = true;
    setTimeout(() => (smtpSaved.value = false), 2000);
  } catch {
    toastStore.show("Failed to save SMTP settings", "error");
  } finally {
    savingSmtp.value = false;
  }
}

async function sendTestEmail() {
  if (!testRecipient.value.trim()) {
    toastStore.show("Enter a recipient email address", "error");
    return;
  }
  sendingTest.value = true;
  try {
    await apiPost("/api/admin/smtp/test", { recipient: testRecipient.value.trim() });
    toastStore.show("Test email sent successfully");
  } catch (e: unknown) {
    toastStore.show(apiErrorMessage(e, "Failed to send test email"), "error");
  } finally {
    sendingTest.value = false;
  }
}

function editConnection(c: ForgeConnectionEntry | null) {
  connTestResult.value = null;
  connFormOpen.value = true;
  connForm.value = c
    ? { id: c.id, kind: c.kind, base_url: c.base_url, token: "********" }
    : { id: 0, kind: forgeKinds.value[0] || "gitea", base_url: "", token: "" };
}

async function saveConnection() {
  savingConn.value = true;
  try {
    const { id, ...values } = connForm.value;
    if (id) await apiPut(`/api/settings/forge-connections/${id}`, values);
    else await apiPost("/api/settings/forge-connections", values);
    connFormOpen.value = false;
    await loadForgeConnections();
  } catch (e) {
    toastStore.show(apiErrorMessage(e, "Failed to save forge connection"), "error");
  } finally {
    savingConn.value = false;
  }
}

async function removeConnection(id: number) {
  try {
    await apiDelete(`/api/settings/forge-connections/${id}`);
    connTestResult.value = null;
    await loadForgeConnections();
  } catch (e) {
    toastStore.show(apiErrorMessage(e, "Failed to delete forge connection"), "error");
  }
}

async function testConnection(id: number) {
  testingConnId.value = id;
  connTestResult.value = null;
  try {
    const res = await apiPost<{ version: string; username: string }>(
      `/api/settings/forge-connections/${id}/test`, {},
    );
    connTestResult.value = {
      id, ok: true,
      message: `Connected — ${res.version}, authenticated as ${res.username}`,
    };
  } catch (e) {
    connTestResult.value = {
      id, ok: false,
      message: apiErrorMessage(e, "Connection test failed"),
    };
  } finally {
    testingConnId.value = 0;
  }
}

async function saveForgeWebhook() {
  savingForgeWebhook.value = true;
  try {
    await apiPut("/api/admin/forge-webhook", {
      webhook_secret: forgeWebhookSecret.value,
    });
    await loadForgeWebhook();
    forgeWebhookSaved.value = true;
    setTimeout(() => (forgeWebhookSaved.value = false), 2000);
  } catch (e) {
    toastStore.show(apiErrorMessage(e, "Failed to save webhook secret"), "error");
  } finally {
    savingForgeWebhook.value = false;
  }
}

async function saveBaseUrl() {
  savingBaseUrl.value = true;
  baseUrlSaved.value = false;
  try {
    await apiPut("/api/admin/base-url", { base_url: baseUrl.value.trim() });
    baseUrlSaved.value = true;
    setTimeout(() => (baseUrlSaved.value = false), 2000);
  } catch {
    toastStore.show("Failed to save application URL", "error");
  } finally {
    savingBaseUrl.value = false;
  }
}

async function saveMarketplaceUrl() {
  savingMarketplaceUrl.value = true;
  marketplaceUrlSaved.value = false;
  try {
    const url = adminMarketplaceUrl.value.trim();
    await apiPut("/api/plugin/marketplace-url", { marketplace_url: url });
    serverMarketplaceUrl.value = url;
    // Reflect the new instance default in the copyable field unless the user
    // set their own per-browser override.
    if (!localStorage.getItem(_MKT_KEY)) pluginMarketplaceUrl.value = url;
    marketplaceUrlSaved.value = true;
    setTimeout(() => (marketplaceUrlSaved.value = false), 2000);
  } catch (e) {
    toastStore.show(apiErrorMessage(e, "Failed to save marketplace URL"), "error");
  } finally {
    savingMarketplaceUrl.value = false;
  }
}

async function saveDbMaintenance() {
  savingDbMaint.value = true;
  dbMaintSaved.value = false;
  try {
    await apiPut("/api/admin/db-maintenance", {
      enabled: dbMaintEnabled.value,
      hour: dbMaintHour.value,
    });
    dbMaintSaved.value = true;
    setTimeout(() => (dbMaintSaved.value = false), 2000);
  } catch (e) {
    toastStore.show(apiErrorMessage(e, "Failed to save maintenance settings"), "error");
  } finally {
    savingDbMaint.value = false;
  }
}

async function loadDbHealth() {
  loadingHealth.value = true;
  try {
    dbHealth.value = await apiGet<DbHealth>("/api/admin/db-maintenance/health");
  } catch {
    // leave previous value
  } finally {
    loadingHealth.value = false;
  }
}

async function runDbMaintenanceNow() {
  runningDbMaint.value = true;
  try {
    const summary = await apiPost<DbMaintRun>("/api/admin/db-maintenance/run", {});
    dbMaintLastRun.value = summary;
    const failed = summary.tables.filter((t) => !t.ok).length;
    toastStore.show(
      failed ? `Maintenance ran with ${failed} error(s)` : "Maintenance complete",
      failed ? "error" : "success",
    );
    await loadDbHealth(); // reflect the dead-tuple drop
  } catch (e) {
    toastStore.show(apiErrorMessage(e, "Maintenance run failed"), "error");
  } finally {
    runningDbMaint.value = false;
  }
}

async function handleRestoreFile(event: Event) {
  const file = (event.target as HTMLInputElement).files?.[0];
  if (!file) return;
  restoring.value = true;
  try {
    const text = await file.text();
    const data = JSON.parse(text);
    const res = await fetch("/api/admin/restore", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(data),
    });
    if (!res.ok) {
      const body = await res.json().catch(() => ({ error: `Error ${res.status}` }));
      throw new Error((body as Record<string, string>).error || `Error ${res.status}`);
    }
    const result = await res.json();
    toastStore.show(
      `Restored ${result.stats?.users ?? 0} users, ${result.stats?.notes ?? 0} notes, ${result.stats?.conversations ?? 0} conversations`
    );
  } catch (e) {
    toastStore.show("Restore failed: " + (e as Error).message, "error");
  } finally {
    restoring.value = false;
    if (restoreFileInput.value) restoreFileInput.value.value = "";
  }
}

async function testSearch() {
  const q = searchQuery.value.trim();
  if (!q) return;
  searchLoading.value = true;
  searchError.value = "";
  searchResults.value = [];
  try {
    const data = await apiGet<{ configured: boolean; results: { url: string; title: string; snippet: string }[]; error?: string }>(
      `/api/settings/search?q=${encodeURIComponent(q)}`
    );
    if (!data.configured) {
      searchError.value = "SearXNG is not configured — set SEARXNG_URL in docker-compose.";
    } else {
      searchResults.value = data.results;
      if (!data.results.length) searchError.value = "No results found.";
    }
  } catch {
    searchError.value = "Search request failed.";
  } finally {
    searchLoading.value = false;
  }
}

function onSearchKeydown(e: KeyboardEvent) {
  if (e.key === "Enter") testSearch();
}

function hostname(url: string): string {
  try { return new URL(url).hostname; } catch { return url; }
}

// ── Users panel ──

interface Invitation {
  id: number;
  email: string;
  created_at: string;
  expires_at: string;
}

const users = ref<User[]>([]);
const registrationOpen = ref(false);
const usersLoading = ref(false);
const toggling = ref(false);
const confirmDeleteId = ref<number | null>(null);
const deleting = ref<number | null>(null);
const inviteEmail = ref("");
const sendingInvite = ref(false);
const invitations = ref<Invitation[]>([]);
const revokingId = ref<number | null>(null);

async function fetchUsers() {
  try {
    const data = await apiGet<{ users: User[] }>("/api/admin/users");
    users.value = data.users;
  } catch {
    toastStore.show("Failed to load users", "error");
  }
}

async function fetchRegistration() {
  try {
    const data = await apiGet<{ open: boolean }>("/api/admin/registration");
    registrationOpen.value = data.open;
  } catch { /* ignore */ }
}

async function fetchInvitations() {
  try {
    const data = await apiGet<{ invitations: Invitation[] }>("/api/admin/invitations");
    invitations.value = data.invitations;
  } catch { /* ignore */ }
}

async function loadUsersPanel() {
  if (users.value.length > 0) return; // already loaded
  usersLoading.value = true;
  await Promise.all([fetchUsers(), fetchRegistration(), fetchInvitations()]);
  usersLoading.value = false;
}

// ── Logs panel ──
interface LogEntry {
  id: number;
  category: string;
  user_id: number | null;
  username: string | null;
  action: string | null;
  endpoint: string | null;
  method: string | null;
  status_code: number | null;
  duration_ms: number | null;
  ip_address: string | null;
  details: string | null;
  created_at: string;
}

interface LogStats {
  audit: number;
  usage: number;
  error: number;
  total: number;
}

const logs = ref<LogEntry[]>([]);
const logStats = ref<LogStats>({ audit: 0, usage: 0, error: 0, total: 0 });
const logTotal = ref(0);
const logsLoading = ref(false);
const logsLoaded = ref(false);
const expandedLogId = ref<number | null>(null);
const logCategory = ref("");
const logSearch = ref("");
const logDateFrom = ref("");
const logDateTo = ref("");
const logLimit = 50;
const logOffset = ref(0);
let logSearchTimeout: ReturnType<typeof setTimeout> | null = null;

watch([logCategory, logDateFrom, logDateTo], () => {
  logOffset.value = 0;
  if (logsLoaded.value) fetchLogs();
});
watch(logSearch, () => {
  if (logSearchTimeout) clearTimeout(logSearchTimeout);
  logSearchTimeout = setTimeout(() => {
    logOffset.value = 0;
    if (logsLoaded.value) fetchLogs();
  }, 300);
});
watch(logOffset, () => {
  if (logsLoaded.value) fetchLogs();
});

async function fetchLogs() {
  try {
    const params = new URLSearchParams();
    if (logCategory.value) params.set("category", logCategory.value);
    if (logSearch.value) params.set("search", logSearch.value);
    if (logDateFrom.value) params.set("date_from", logDateFrom.value);
    if (logDateTo.value) params.set("date_to", logDateTo.value);
    params.set("limit", String(logLimit));
    params.set("offset", String(logOffset.value));
    const data = await apiGet<{ logs: LogEntry[]; total: number }>(`/api/admin/logs?${params}`);
    logs.value = data.logs;
    logTotal.value = data.total;
  } catch {
    toastStore.show("Failed to load logs", "error");
  }
}

async function fetchLogStats() {
  try {
    logStats.value = await apiGet<LogStats>("/api/admin/logs/stats");
  } catch { /* ignore */ }
}

async function loadLogsPanel() {
  if (logsLoaded.value) return;
  logsLoading.value = true;
  await Promise.all([fetchLogs(), fetchLogStats()]);
  logsLoaded.value = true;
  logsLoading.value = false;
}

function toggleLogExpand(id: number) {
  expandedLogId.value = expandedLogId.value === id ? null : id;
}

function formatLogDetails(details: string | null): string {
  if (!details) return "";
  try { return JSON.stringify(JSON.parse(details), null, 2); } catch { return details; }
}

function logDisplayLabel(entry: LogEntry): string {
  if (entry.category === "audit" && entry.action) return entry.action;
  if (entry.endpoint) return entry.endpoint;
  return "—";
}

function clearLogFilters() {
  logCategory.value = "";
  logSearch.value = "";
  logDateFrom.value = "";
  logDateTo.value = "";
  logOffset.value = 0;
}

async function sendInvite() {
  const email = inviteEmail.value.trim().toLowerCase();
  if (!email) return;
  sendingInvite.value = true;
  try {
    await apiPost("/api/admin/invitations", { email });
    toastStore.show(`Invitation sent to ${email}`);
    inviteEmail.value = "";
    await fetchInvitations();
  } catch (e: unknown) {
    toastStore.show(apiErrorMessage(e, "Failed to send invitation"), "error");
  } finally {
    sendingInvite.value = false;
  }
}

async function revokeInvitation(id: number) {
  revokingId.value = id;
  try {
    await apiDelete(`/api/admin/invitations/${id}`);
    invitations.value = invitations.value.filter((inv) => inv.id !== id);
    toastStore.show("Invitation revoked");
  } catch {
    toastStore.show("Failed to revoke invitation", "error");
  } finally {
    revokingId.value = null;
  }
}

async function toggleRegistration() {
  toggling.value = true;
  try {
    const data = await apiPut<{ open: boolean }>("/api/admin/registration", {
      open: !registrationOpen.value,
    });
    registrationOpen.value = data.open;
    toastStore.show(data.open ? "Registration opened" : "Registration closed");
  } catch {
    toastStore.show("Failed to update registration setting", "error");
  } finally {
    toggling.value = false;
  }
}

function confirmDelete(userId: number) {
  if (confirmDeleteId.value === userId) {
    deleteUser(userId);
  } else {
    confirmDeleteId.value = userId;
  }
}
function cancelDelete() { confirmDeleteId.value = null; }

async function deleteUser(userId: number) {
  confirmDeleteId.value = null;
  deleting.value = userId;
  try {
    await apiDelete(`/api/admin/users/${userId}`);
    users.value = users.value.filter((u) => u.id !== userId);
    toastStore.show("User deleted");
  } catch (e: unknown) {
    toastStore.show(apiErrorMessage(e, "Failed to delete user"), "error");
  } finally {
    deleting.value = null;
  }
}
</script>

<template>
  <main class="settings-root">
    <aside class="settings-sidebar" role="navigation" aria-label="Settings navigation">
      <div class="sidebar-group">
        <div class="sidebar-group-label">User</div>
        <button
          v-for="tab in ['general', 'account', 'profile', 'notifications', 'integrations', 'data', 'apikeys']"
          :key="tab"
          :class="['sidebar-item', { active: activeTab === tab }]"
          @click="activeTab = tab"
        >
          {{ tab === 'apikeys' ? 'MCP Access' : tab.charAt(0).toUpperCase() + tab.slice(1) }}
        </button>
      </div>
      <div v-if="authStore.isAdmin" class="sidebar-group">
        <div class="sidebar-group-label">Admin</div>
        <button
          v-for="tab in ['config', 'users', 'groups', 'logs']"
          :key="tab"
          :class="['sidebar-item', { active: activeTab === tab }]"
          @click="activeTab = tab"
        >
          {{ tab.charAt(0).toUpperCase() + tab.slice(1) }}
        </button>
      </div>
    </aside>
    <div class="settings-content">

    <!-- ── General ── -->
    <div v-show="activeTab === 'general'" class="settings-grid">
      <!-- Timezone -->
      <section class="settings-section full-width">
        <h2>Timezone</h2>
        <p class="section-desc">Used to schedule the daily journal prep and format times in chat. Set this to your local IANA timezone (e.g. America/New_York, Europe/London).</p>
        <div class="field">
          <label for="user-timezone">Your timezone</label>
          <div style="display:flex; gap:0.5rem; align-items:center">
            <input
              id="user-timezone"
              v-model="userTimezone"
              type="text"
              class="input"
              placeholder="e.g. America/New_York"
            />
            <button class="btn-secondary" type="button" @click="detectTimezone">Detect</button>
          </div>
          <p class="field-hint">Click Detect to auto-fill from your browser.</p>
        </div>
        <div class="actions">
          <button class="btn-primary" @click="saveTimezone" :disabled="savingTimezone">
            {{ savingTimezone ? 'Saving…' : 'Save' }}
          </button>
          <span v-if="timezoneSaved" class="saved-msg">Saved!</span>
        </div>
      </section>

      <!-- Trash retention -->
      <section class="settings-section full-width">
        <h2>Trash retention</h2>
        <p class="section-desc">Deleted items move to <router-link to="/trash">Trash</router-link> and can be restored. They're permanently purged after this many days.</p>
        <div class="field">
          <label for="trash-retention">Retention period (days)</label>
          <input
            id="trash-retention"
            v-model="trashRetentionDays"
            type="number"
            min="0"
            step="1"
            class="input"
            style="max-width: 8rem"
          />
          <p class="field-hint">Set to <strong>0</strong> to keep deleted items forever (never auto-purge).</p>
        </div>
        <div class="actions">
          <button class="btn-primary" @click="saveRetention" :disabled="savingRetention">
            {{ savingRetention ? 'Saving…' : 'Save' }}
          </button>
          <span v-if="retentionSaved" class="saved-msg">Saved!</span>
        </div>
      </section>

      <section class="settings-section full-width">
        <h2>Knowledge auto-inject</h2>
        <p class="section-desc">
          When enabled, the Scribe plugin quietly surfaces the titles of your most
          relevant notes on each prompt — never their full text — so Claude can pull
          one in with <code>get_note(id)</code> only when it helps. Titles only, each
          note at most once per session, and nothing is shown unless it clears the
          confidence bar below.
        </p>
        <div class="checkbox-field">
          <label>
            <input type="checkbox" v-model="kbInjectEnabled" />
            Surface relevant note titles each prompt
          </label>
          <p class="field-hint">Off = notes reach context only when Claude searches for them.</p>
        </div>
        <div class="field">
          <label for="kb-inject-threshold">Confidence threshold (0–1)</label>
          <input
            id="kb-inject-threshold"
            v-model="kbInjectThreshold"
            type="number"
            min="0"
            max="1"
            step="0.05"
            class="input"
            style="max-width: 8rem"
          />
          <p class="field-hint">Minimum similarity to surface a note. Higher = stricter (fewer, more certain). Deliberately above the 0.45 used for searches you trigger yourself.</p>
        </div>
        <div class="field">
          <label for="kb-inject-topk">Max notes per prompt</label>
          <input
            id="kb-inject-topk"
            v-model="kbInjectTopK"
            type="number"
            min="1"
            max="10"
            step="1"
            class="input"
            style="max-width: 8rem"
          />
          <p class="field-hint">Ceiling on titles surfaced at once (1–10).</p>
        </div>
        <div class="checkbox-field">
          <label>
            <input type="checkbox" v-model="kbWritePathEnabled" />
            Also surface prior art when Claude writes code
          </label>
          <p class="field-hint">
            Checks the file Claude is about to write or edit against your recorded
            snippets — what's already kept at that path, and what resembles the code
            being written — so a helper you already have is offered before it's
            rewritten. When the file being edited is itself a recorded snippet's
            location, the hint instead asks Claude to update or re-verify that
            record as part of the edit — how records stay current without any forge
            connection. Uses the ceiling above with its own threshold below, and never
            blocks the edit. Off = prior art surfaces only on your own prompts.
          </p>
        </div>
        <div class="field">
          <label for="kb-writepath-threshold">Prior-art confidence threshold (0–1)</label>
          <input
            id="kb-writepath-threshold"
            v-model="kbWritePathThreshold"
            type="number"
            min="0"
            max="1"
            step="0.01"
            class="input"
            style="max-width: 8rem"
          />
          <p class="field-hint">
            Stricter than the prompt threshold above on purpose. Any two pieces of
            code look somewhat alike — shared keywords, indentation, structure — so
            resemblance scores start higher for code than for prose, and a bar tuned
            for prompts flags unrelated code as prior art. Lower this if genuine
            duplicates go unnoticed; raise it if you're being offered snippets that
            have nothing to do with what's being written. Snippets recorded at the
            exact file are always shown regardless — those are prior art by
            location, not by resemblance.
          </p>
        </div>
        <!-- A design system belongs to a PROJECT, and the picker for it lives on
             the project. There was a setting here that designated the system
             this install's own interface was built from; it only ever described
             the app you were already looking at, which is not what the feature
             is for (#274). -->

        <div class="field">
          <label for="kb-duplicate-threshold-snippet">Near-duplicate report threshold — snippets</label>
          <input
            id="kb-duplicate-threshold-snippet"
            v-model="kbDupThresholdSnippet"
            type="number"
            min="0"
            max="1"
            step="0.01"
            class="input"
            style="max-width: 8rem"
          />
          <p class="field-hint">
            How alike two snippets must be before the Snippets page suggests merging
            them. Lower = more suggestions, more false pairs. Looser than the 0.90
            used to block a duplicate at creation, because this only proposes a merge
            you review — it never acts on its own.
          </p>
        </div>

        <div class="field">
          <label for="kb-duplicate-threshold-note">Near-duplicate report threshold — notes</label>
          <input
            id="kb-duplicate-threshold-note"
            v-model="kbDupThresholdNote"
            type="number"
            min="0"
            max="1"
            step="0.01"
            class="input"
            style="max-width: 8rem"
          />
          <p class="field-hint">
            The floor for the Knowledge page's note report. Notes are compared
            section by section, so related records — a run of dev-logs, notes on
            one topic — score high without being duplicates. Stricter than the
            snippet floor on purpose; lower it to browse related families rather
            than hunt true duplicates.
          </p>
        </div>

        <div class="field">
          <label for="kb-duplicate-threshold-task">Near-duplicate report threshold — tasks</label>
          <input
            id="kb-duplicate-threshold-task"
            v-model="kbDupThresholdTask"
            type="number"
            min="0"
            max="1"
            step="0.01"
            class="input"
            style="max-width: 8rem"
          />
          <p class="field-hint">
            Same as the note floor, for the task report. Step tasks from different
            milestones ("Verify on CI") legitimately resemble each other, so this
            stays strict to keep the report pointed at work opened twice.
          </p>
        </div>
        <div class="actions">
          <button class="btn-primary" @click="saveKbInject" :disabled="savingKbInject">
            {{ savingKbInject ? 'Saving…' : 'Save' }}
          </button>
          <span v-if="kbInjectSaved" class="saved-msg">Saved!</span>
        </div>
      </section>

    </div>

    <!-- ── Account ── -->
    <div v-show="activeTab === 'account'" class="settings-grid">

      <!-- SSO accounts: no local credential management -->
      <section v-if="!authStore.user?.has_password" class="settings-section">
        <h2>Account</h2>
        <p class="section-desc">
          Your account is managed by an external identity provider.
          Email and password changes are made through your provider, not here.
        </p>
      </section>

      <template v-if="authStore.user?.has_password">
        <section class="settings-section">
          <h2>Email Address</h2>
          <p class="section-desc">Used for password resets and notifications.</p>
          <div class="field">
            <label for="new-email">Email</label>
            <input
              id="new-email"
              v-model="newEmail"
              type="email"
              placeholder="you@example.com"
              class="input"
            />
          </div>
          <div class="field">
            <label for="email-password">Current Password</label>
            <input
              id="email-password"
              v-model="emailPassword"
              type="password"
              autocomplete="current-password"
              class="input"
            />
            <p class="field-hint">Required to confirm the change.</p>
          </div>
          <div class="actions">
            <button
              class="btn-primary"
              @click="changeEmail"
              :disabled="changingEmail || !emailPassword"
            >
              {{ changingEmail ? "Saving..." : "Save Email" }}
            </button>
          </div>
        </section>

        <section class="settings-section">
          <h2>Change Password</h2>
          <div class="field">
            <label for="current-password">Current Password</label>
            <input
              id="current-password"
              v-model="currentPassword"
              type="password"
              autocomplete="current-password"
              class="input"
            />
          </div>
          <div class="field">
            <label for="new-password">New Password</label>
            <input
              id="new-password"
              v-model="newPassword"
              type="password"
              autocomplete="new-password"
              class="input"
            />
            <p class="field-hint">Must be at least 8 characters</p>
          </div>
          <div class="field">
            <label for="confirm-new-password">Confirm New Password</label>
            <input
              id="confirm-new-password"
              v-model="confirmNewPassword"
              type="password"
              autocomplete="new-password"
              class="input"
              :class="{ 'input-error': confirmNewPassword && newPassword !== confirmNewPassword }"
            />
            <p v-if="confirmNewPassword && newPassword !== confirmNewPassword" class="error-hint">
              Passwords do not match
            </p>
          </div>
          <div class="actions">
            <button
              class="btn-primary"
              @click="changePassword"
              :disabled="changingPassword || !currentPassword || newPassword.length < 8 || newPassword !== confirmNewPassword"
            >
              {{ changingPassword ? "Changing..." : "Change Password" }}
            </button>
          </div>
        </section>
      </template>

      <section class="settings-section">
        <h2>Active Sessions</h2>
        <p class="section-desc">
          Sign out all other devices and sessions. Use this after a password change
          to ensure stale sessions are revoked.
        </p>
        <div class="actions">
          <button class="btn-danger-outline" @click="invalidateSessions" :disabled="invalidatingSessions">
            {{ invalidatingSessions ? "Invalidating..." : "Invalidate All Other Sessions" }}
          </button>
        </div>
      </section>

    </div>

    <!-- ── Notifications ── -->
    <!-- ── Profile ── -->
    <div v-show="activeTab === 'profile'" class="settings-grid">
      <section class="settings-section full-width">
        <h2>About You</h2>
        <p class="section-desc">This information is used by the assistant to personalise responses in chat and the daily journal.</p>
        <div class="assistant-grid">
          <div class="field">
            <label>Display Name</label>
            <input v-model="profile.display_name" type="text" class="input" placeholder="e.g. Alex" />
            <p class="field-hint">How the assistant addresses you.</p>
          </div>
          <div class="field">
            <label>Job Title</label>
            <input v-model="profile.job_title" type="text" class="input" placeholder="e.g. Product Manager" />
          </div>
          <div class="field">
            <label>Industry</label>
            <input v-model="profile.industry" type="text" class="input" placeholder="e.g. Technology" />
          </div>
          <div class="field">
            <label>Expertise Level</label>
            <select v-model="profile.expertise_level" class="input">
              <option value="novice">Novice — explain things simply</option>
              <option value="intermediate">Intermediate — balanced explanations</option>
              <option value="expert">Expert — assume deep knowledge</option>
            </select>
            <p class="field-hint">Calibrates how the assistant explains concepts.</p>
          </div>
        </div>
        <div class="actions">
          <button class="btn-primary" @click="saveProfile" :disabled="profileSaving">{{ profileSaving ? 'Saving…' : 'Save' }}</button>
          <span v-if="profileSaved" class="saved-msg">Saved!</span>
        </div>
      </section>

      <section class="settings-section full-width">
        <h2>Response Preferences</h2>
        <div class="assistant-grid">
          <div class="field">
            <label>Response Style</label>
            <select v-model="profile.response_style" class="input">
              <option value="concise">Concise — short and direct</option>
              <option value="balanced">Balanced — default</option>
              <option value="detailed">Detailed — thorough explanations</option>
            </select>
          </div>
          <div class="field">
            <label>Tone</label>
            <select v-model="profile.tone" class="input">
              <option value="casual">Casual — friendly and relaxed</option>
              <option value="professional">Professional — formal and precise</option>
              <option value="technical">Technical — jargon-friendly</option>
            </select>
          </div>
        </div>
        <div class="actions">
          <button class="btn-primary" @click="saveProfile" :disabled="profileSaving">{{ profileSaving ? 'Saving…' : 'Save' }}</button>
          <span v-if="profileSaved" class="saved-msg">Saved!</span>
        </div>
      </section>

      <section class="settings-section full-width">
        <h2>Interests</h2>
        <p class="section-desc">Topics you care about — used to personalise the journal's daily prep and chat responses.</p>
        <TagInput v-model="profile.interests" placeholder="Add an interest…" :fetchTags="emptyTagsFetch" />
        <div class="actions">
          <button class="btn-primary" @click="saveProfile" :disabled="profileSaving">{{ profileSaving ? 'Saving…' : 'Save' }}</button>
          <span v-if="profileSaved" class="saved-msg">Saved!</span>
        </div>
      </section>

      <section class="settings-section full-width">
        <h2>Work Schedule</h2>
        <p class="section-desc">Helps the journal understand when you're working and what's relevant each morning.</p>
        <div class="field">
          <label>Work Days</label>
          <div class="day-picker">
            <button
              v-for="day in WORK_DAYS"
              :key="day"
              class="day-btn"
              :class="{ active: (profile.work_schedule.days ?? []).includes(day) }"
              @click="toggleProfileWorkDay(day)"
              type="button"
            >{{ day }}</button>
          </div>
        </div>
        <div class="assistant-grid" style="margin-top:0.75rem">
          <div class="field">
            <label>Start Time</label>
            <input v-model="profile.work_schedule.start" type="time" class="input" />
          </div>
          <div class="field">
            <label>End Time</label>
            <input v-model="profile.work_schedule.end" type="time" class="input" />
          </div>
        </div>
        <div class="actions">
          <button class="btn-primary" @click="saveProfile" :disabled="profileSaving">{{ profileSaving ? 'Saving…' : 'Save' }}</button>
          <span v-if="profileSaved" class="saved-msg">Saved!</span>
        </div>
      </section>

    </div>

    <div v-show="activeTab === 'notifications'" class="settings-grid">

      <section class="settings-section">
        <h2>Email Notifications</h2>
        <p class="section-desc">
          Email notifications when SMTP is configured by an admin.
        </p>
        <div class="checkbox-field">
          <label>
            <input type="checkbox" v-model="notifyTaskReminders" />
            Task due date reminders
          </label>
          <p class="field-hint">Daily email for tasks due or overdue.</p>
        </div>
        <div class="checkbox-field">
          <label>
            <input type="checkbox" v-model="notifySecurityAlerts" />
            Security alerts
          </label>
          <p class="field-hint">Emails for logins, logouts, and password changes.</p>
        </div>
        <div class="actions">
          <button class="btn-primary" @click="saveNotifications" :disabled="savingNotifications">
            {{ savingNotifications ? "Saving..." : "Save" }}
          </button>
          <span v-if="notificationsSaved" class="saved-msg">Saved!</span>
        </div>
      </section>

    </div>

    <!-- ── Integrations ── -->
    <div v-show="activeTab === 'integrations'" class="settings-grid">

      <section class="settings-section full-width">
        <h2>Web Search (SearXNG)</h2>
        <template v-if="searxngConfigured">
          <p class="section-desc">
            Connected to <code class="url-chip">{{ searxngUrl }}</code>.
            Test a query below to verify results and rate limiting.
          </p>
          <div class="search-row">
            <input
              v-model="searchQuery"
              type="text"
              class="input"
              placeholder="Enter a search query..."
              @keydown="onSearchKeydown"
            />
            <button class="btn-primary" @click="testSearch" :disabled="searchLoading || !searchQuery.trim()">
              {{ searchLoading ? "Searching..." : "Search" }}
            </button>
          </div>
          <p v-if="searchError" class="search-error">{{ searchError }}</p>
          <ul v-if="searchResults.length" class="search-results">
            <li v-for="(r, i) in searchResults" :key="i" class="search-result">
              <div class="result-header">
                <a :href="r.url" target="_blank" rel="noopener noreferrer" class="result-title">{{ r.title || r.url }}</a>
                <span class="result-host">{{ hostname(r.url) }}</span>
              </div>
              <p v-if="r.snippet" class="result-snippet">{{ r.snippet }}</p>
            </li>
          </ul>
        </template>
        <template v-else>
          <p class="section-desc not-configured">
            Not configured. Set <code>SEARXNG_URL</code> in docker-compose to enable web research from chat.
          </p>
        </template>
      </section>

      <section class="settings-section full-width">
        <h2>Git Forges</h2>
        <p class="section-desc">
          Read-only connections to your git forges, one per host. Projects you
          own use them server-side — resolved by repo host — to fetch and
          drift-check recorded snippets and measure pattern coverage. A
          read-scope token is enough. Gitea: an access token with read scope
          on repositories. GitHub: a fine-grained PAT with Contents:
          Read-only, base URL <code>https://github.com</code> (or your GitHub
          Enterprise URL).
        </p>
        <table v-if="forgeConnections.length" class="api-keys-table">
          <thead>
            <tr><th>Host</th><th>Kind</th><th>Base URL</th><th></th></tr>
          </thead>
          <tbody>
            <tr v-for="c in forgeConnections" :key="c.id">
              <td>{{ c.host }}</td>
              <td>{{ c.kind }}</td>
              <td><code>{{ c.base_url }}</code></td>
              <td>
                <button class="btn btn-secondary btn-sm" :disabled="testingConnId === c.id" @click="testConnection(c.id)">
                  {{ testingConnId === c.id ? "Testing…" : "Test" }}
                </button>
                <button class="btn btn-secondary btn-sm" @click="editConnection(c)">Edit</button>
                <button class="btn btn-danger btn-sm" @click="removeConnection(c.id)">Delete</button>
              </td>
            </tr>
          </tbody>
        </table>
        <p v-else class="section-desc not-configured">No forge connections yet.</p>
        <p
          v-if="connTestResult"
          :class="connTestResult.ok ? 'text-success' : 'text-error'"
        >
          {{ connTestResult.message }}
        </p>
        <div v-if="connFormOpen" class="smtp-grid">
          <div class="field">
            <label for="conn-kind">Forge</label>
            <select id="conn-kind" v-model="connForm.kind" class="input">
              <option v-for="k in forgeKinds" :key="k" :value="k">{{ k }}</option>
            </select>
          </div>
          <div class="field">
            <label for="conn-base-url">Base URL</label>
            <input id="conn-base-url" v-model="connForm.base_url" type="text" placeholder="https://git.example.com" class="input" />
          </div>
          <div class="field">
            <label for="conn-token">API Token (read scope)</label>
            <input id="conn-token" v-model="connForm.token" type="password" class="input" />
          </div>
        </div>
        <div class="actions">
          <template v-if="connFormOpen">
            <button class="btn-primary" @click="saveConnection" :disabled="savingConn">
              {{ savingConn ? "Saving..." : connForm.id ? "Save Connection" : "Add Connection" }}
            </button>
            <button class="btn-ghost" @click="connFormOpen = false">Cancel</button>
          </template>
          <button v-else class="btn-primary" @click="editConnection(null)">Add Connection</button>
        </div>
      </section>

    </div>

    <!-- ── Data ── -->
    <div v-show="activeTab === 'data'" class="settings-grid">

      <section class="settings-section full-width">
        <h2>Export</h2>
        <p class="section-desc">Download your notes and tasks in portable formats.</p>
        <div class="data-actions">
          <button class="btn-secondary" @click="exportNotes('markdown')" :disabled="exportingNotes">
            {{ exportingNotes ? "Exporting..." : "Export as Markdown" }}
          </button>
          <button class="btn-secondary" @click="exportNotes('json')" :disabled="exportingNotes">
            {{ exportingNotes ? "Exporting..." : "Export as JSON" }}
          </button>
          <button class="btn-secondary" @click="exportData('user')" :disabled="exporting">
            {{ exporting ? "Exporting..." : "Export My Data" }}
          </button>
        </div>
      </section>

      <template v-if="authStore.isAdmin">
        <section class="settings-section full-width">
          <h2>Backup &amp; Restore</h2>
          <p class="section-desc">Full application backup includes all users and their data.</p>
          <div class="data-actions">
            <button class="btn-secondary" @click="exportData('full')" :disabled="exporting">
              {{ exporting ? "Exporting..." : "Full Backup" }}
            </button>
            <button class="btn-secondary btn-warn" @click="triggerRestoreUpload" :disabled="restoring">
              {{ restoring ? "Restoring..." : "Restore from Backup" }}
            </button>
            <input
              ref="restoreFileInput"
              type="file"
              accept=".json"
              class="hidden-file-input"
              @change="handleRestoreFile"
            />
          </div>
        </section>
      </template>

    </div>


    <!-- ── MCP Access ── -->
    <div v-show="activeTab === 'apikeys'" class="settings-grid">
      <!-- Endpoint URL -->
      <section class="settings-section full-width">
        <h2>MCP Access</h2>
        <p class="settings-description">
          Connect Claude (Code or Desktop) to this Scribe instance. Claude reads and writes your notes,
          tasks, projects, events, and people via the built-in MCP endpoint below.
        </p>

        <div class="mcp-url-block">
          <label class="mcp-url-label">MCP endpoint</label>
          <div class="mcp-code-row">
            <pre class="mcp-code">{{ mcpUrl }}</pre>
            <button class="btn btn-secondary btn-sm" @click="copyMcpUrl">
              {{ mcpUrlCopied ? 'Copied' : 'Copy URL' }}
            </button>
          </div>
        </div>
      </section>

      <!-- Tokens -->
      <section class="settings-section full-width">
        <h2>Personal access tokens</h2>
        <p class="settings-description">
          Tokens are sent as <code>Authorization: Bearer …</code> on every MCP request and resolve to
          your user account. Write-scoped tokens can create and update content; read-only tokens can
          only query. A token is shown <strong>once</strong> at creation — keep it safe.
        </p>

        <!-- Create form -->
        <div class="api-key-create-form">
          <input v-model="newKeyName" placeholder="Token name (e.g. claude-laptop)" class="settings-input" />
          <div class="api-key-scope-select">
            <label><input type="radio" v-model="newKeyScope" value="read" /> Read-only</label>
            <label><input type="radio" v-model="newKeyScope" value="write" /> Read + Write</label>
          </div>
          <button @click="createApiKey" :disabled="!newKeyName || creatingApiKey" class="btn btn-primary">
            {{ creatingApiKey ? 'Generating…' : 'Generate token' }}
          </button>
        </div>

        <!-- One-time token reveal -->
        <div v-if="newKeyValue" class="api-key-reveal">
          <p><strong>Copy this token now — it will not be shown again.</strong></p>
          <div class="api-key-value-row">
            <code class="api-key-value">{{ newKeyValue }}</code>
            <button @click="copyApiKey" class="btn btn-secondary btn-sm">{{ apiKeyCopied ? 'Copied!' : 'Copy' }}</button>
          </div>
          <p class="mcp-hint" style="margin-top:0.5rem;">
            The snippets in <strong>Connect Claude</strong> below are pre-filled with this token until you click Done.
          </p>
          <div style="display:flex; gap:0.5rem; margin-top: 0.5rem;">
            <button @click="newKeyValue = ''" class="btn btn-secondary">Done</button>
          </div>
        </div>

        <!-- Tokens table -->
        <table v-if="apiKeys.length > 0" class="api-keys-table">
          <thead>
            <tr><th>Name</th><th>Scope</th><th>Prefix</th><th>Last Used</th><th></th></tr>
          </thead>
          <tbody>
            <tr v-for="key in apiKeys" :key="key.id">
              <td>{{ key.name }}</td>
              <td><span :class="['scope-badge', key.scope]">{{ key.scope }}</span></td>
              <td><code>{{ key.key_prefix }}…</code></td>
              <td>{{ key.last_used_at ? new Date(key.last_used_at).toLocaleDateString() : 'Never' }}</td>
              <td>
                <span v-if="revokeConfirmId !== key.id">
                  <button @click="revokeConfirmId = key.id" class="btn btn-secondary btn-sm">Revoke</button>
                </span>
                <span v-else>
                  Sure?
                  <button @click="revokeApiKey(key.id)" class="btn btn-danger btn-sm">Yes</button>
                  <button @click="revokeConfirmId = null" class="btn btn-secondary btn-sm">No</button>
                </span>
              </td>
            </tr>
          </tbody>
        </table>
        <p v-else-if="apiKeys.length === 0 && activeTab === 'apikeys'" class="settings-empty">No tokens yet.</p>
      </section>

      <!-- Connect Claude -->
      <section class="settings-section full-width">
        <h2>Connect Claude</h2>
        <p class="settings-description">
          Run the appropriate snippet for your client. Generate a token above first to pre-fill the
          token value, or copy the snippet now and paste your own where it says <code>&lt;your-token&gt;</code>.
        </p>

        <div class="mcp-client-tabs" role="tablist">
          <button
            type="button"
            role="tab"
            :aria-selected="mcpClientTab === 'claude-code'"
            :class="['mcp-client-tab', { active: mcpClientTab === 'claude-code' }]"
            @click="mcpClientTab = 'claude-code'"
          >Claude Code</button>
          <button
            type="button"
            role="tab"
            :aria-selected="mcpClientTab === 'claude-desktop'"
            :class="['mcp-client-tab', { active: mcpClientTab === 'claude-desktop' }]"
            @click="mcpClientTab = 'claude-desktop'"
          >Claude Desktop</button>
        </div>

        <!-- Claude Code tab -->
        <div v-if="mcpClientTab === 'claude-code'">
          <p class="settings-description">
            <strong>Recommended — install the Scribe plugin.</strong> One install wires up the MCP
            connection, a session-start hook that surfaces your rules, and the Scribe process-skills.
          </p>
          <ol>
            <li>
              Add the marketplace and install the plugin:
              <div class="mcp-code-row">
                <pre class="mcp-code">{{ pluginInstallCommands }}</pre>
                <button class="btn btn-secondary btn-sm" @click="copySnippet(pluginInstallCommands, 'plugin-install')">
                  {{ copiedSnippetKey === 'plugin-install' ? 'Copied' : 'Copy' }}
                </button>
              </div>
            </li>
            <li>
              When prompted, enter your <strong>Scribe base URL</strong> (<code>{{ origin }}</code>),
              an <strong>API key</strong> (generate one above), and optionally a
              <strong>project id</strong> to scope the session-start context.
            </li>
            <li>
              Restart Claude Code — <code>/mcp</code> shows <code>scribe</code> connected and your
              standing rules load at session start.
            </li>
          </ol>

          <details class="mcp-advanced">
            <summary>Customize</summary>

            <label class="mcp-config-field">
              <span class="mcp-config-label">Plugin marketplace (git URL)</span>
              <input
                v-model="pluginMarketplaceUrl"
                type="text"
                :placeholder="serverMarketplaceUrl || 'https://git.example.com/you/Scribe.git'"
                class="settings-input"
                spellcheck="false"
              />
              <span class="mcp-hint">
                Pre-filled with this instance's repo. Override only if you host the plugin elsewhere.
              </span>
            </label>

            <div class="mcp-client-config">
              <label class="mcp-config-field">
                <span class="mcp-config-label">Server name</span>
                <input v-model="mcpServerName" type="text" placeholder="scribe" class="settings-input" spellcheck="false" />
                <span class="mcp-hint">
                  Local label for the MCP-only path below. Examples: <code>scribe</code>, <code>scribe-dev</code>.
                </span>
              </label>
              <label class="mcp-config-field">
                <span class="mcp-config-label">Scope</span>
                <select v-model="mcpScope" class="settings-input">
                  <option value="user">user — available across all projects</option>
                  <option value="project">project — write to current repo's .mcp.json</option>
                  <option value="local">local — this machine + repo only</option>
                </select>
                <span class="mcp-hint">Where Claude Code stores the MCP-only registration.</span>
              </label>
            </div>

            <p class="mcp-hint" style="margin-top: 0.75rem;">
              <strong>Connect the MCP only (no plugin).</strong> You won't get the session-start rule
              push or the Scribe skills this way.
            </p>
            <div class="mcp-code-row">
              <pre class="mcp-code">{{ claudeCodeCommand }}</pre>
              <button class="btn btn-secondary btn-sm" @click="copySnippet(claudeCodeCommand, 'cc-add')">
                {{ copiedSnippetKey === 'cc-add' ? 'Copied' : 'Copy' }}
              </button>
            </div>
            <p class="mcp-hint">
              Verify with <code>/mcp</code> — <code>{{ effectiveMcpName }}</code> should appear as connected.
            </p>
          </details>
        </div>

        <!-- Claude Desktop tab -->
        <div v-else-if="mcpClientTab === 'claude-desktop'">
          <label class="mcp-config-field">
            <span class="mcp-config-label">Server name</span>
            <input v-model="mcpServerName" type="text" placeholder="scribe" class="settings-input" spellcheck="false" />
            <span class="mcp-hint">The key used for this server in the config JSON below.</span>
          </label>
          <ol>
            <li>
              Add this block to your Claude Desktop MCP config file
              (<code>~/Library/Application Support/Claude/claude_desktop_config.json</code> on macOS,
              <code>%APPDATA%\Claude\claude_desktop_config.json</code> on Windows):
              <div class="mcp-code-row">
                <pre class="mcp-code">{{ mcpConfigSnippet }}</pre>
                <button class="btn btn-secondary btn-sm" @click="copySnippet(mcpConfigSnippet, 'cd-config')">
                  {{ copiedSnippetKey === 'cd-config' ? 'Copied' : 'Copy' }}
                </button>
              </div>
            </li>
            <li>
              Restart Claude Desktop. The Scribe tools should appear in the available tools list.
            </li>
          </ol>
        </div>
      </section>
    </div>

    <!-- ── Admin ── -->
    <div v-if="authStore.isAdmin" v-show="activeTab === 'config'" class="settings-grid">

      <section class="settings-section full-width">
        <h2>Application URL</h2>
        <p class="section-desc">
          Public URL used in email links (invitations, password resets). Example: https://notes.example.com
        </p>
        <div class="field url-field">
          <label for="base-url">Base URL</label>
          <input
            id="base-url"
            v-model="baseUrl"
            type="url"
            placeholder="https://notes.example.com"
            class="input"
          />
        </div>
        <div class="actions">
          <button class="btn-primary" @click="saveBaseUrl" :disabled="savingBaseUrl">
            {{ savingBaseUrl ? "Saving..." : "Save" }}
          </button>
          <span v-if="baseUrlSaved" class="saved-msg">Saved!</span>
        </div>
      </section>

      <section class="settings-section full-width">
        <h2>Plugin marketplace</h2>
        <p class="section-desc">
          Git URL of the repo that ships this Scribe plugin (usually this app's own repo).
          Shown to every user in <strong>MCP Access</strong> so the install command is
          copyable. Example: https://git.example.com/you/Scribe.git
        </p>
        <div class="field url-field">
          <label for="marketplace-url">Marketplace git URL</label>
          <input
            id="marketplace-url"
            v-model="adminMarketplaceUrl"
            type="url"
            placeholder="https://git.example.com/you/Scribe.git"
            class="input"
          />
        </div>
        <div class="actions">
          <button class="btn-primary" @click="saveMarketplaceUrl" :disabled="savingMarketplaceUrl">
            {{ savingMarketplaceUrl ? "Saving..." : "Save" }}
          </button>
          <span v-if="marketplaceUrlSaved" class="saved-msg">Saved!</span>
        </div>
      </section>

      <section class="settings-section full-width">
        <h2>Database maintenance</h2>
        <p class="section-desc">
          A daily <code>VACUUM (ANALYZE)</code> over the high-churn tables (logs, notifications,
          tokens, notes, version history) — on top of Postgres autovacuum — to reclaim space left
          by the nightly cleanup sweeps and keep query plans fresh. Runs at the hour below (UTC),
          just after trash purge.
        </p>
        <div class="checkbox-field">
          <label>
            <input type="checkbox" v-model="dbMaintEnabled" />
            Run scheduled maintenance daily
          </label>
        </div>
        <div class="field url-field">
          <label for="db-maint-hour">Run hour (UTC)</label>
          <select id="db-maint-hour" v-model.number="dbMaintHour" class="input">
            <option v-for="h in 24" :key="h - 1" :value="h - 1">
              {{ String(h - 1).padStart(2, '0') }}:00
            </option>
          </select>
        </div>
        <div class="actions">
          <button class="btn-primary" @click="saveDbMaintenance" :disabled="savingDbMaint">
            {{ savingDbMaint ? "Saving..." : "Save" }}
          </button>
          <button class="btn-secondary" @click="runDbMaintenanceNow" :disabled="runningDbMaint">
            {{ runningDbMaint ? "Running..." : "Run now" }}
          </button>
          <span v-if="dbMaintSaved" class="saved-msg">Saved!</span>
        </div>
        <div v-if="dbMaintLastRun" class="db-maint-last">
          <span class="db-maint-last-label">
            Last run {{ new Date(dbMaintLastRun.started_at).toLocaleString() }}
            · {{ dbMaintLastRun.elapsed_ms }}ms
          </span>
          <ul class="db-maint-table-list">
            <li v-for="t in dbMaintLastRun.tables" :key="t.table" :class="{ 'dm-failed': !t.ok }">
              <code>{{ t.table }}</code>
              <span class="dm-status">{{ t.ok ? `✓ ${t.elapsed_ms}ms` : `✗ ${t.error}` }}</span>
            </li>
          </ul>
        </div>

        <div class="db-health">
          <h3 class="subsection-label">
            Table health
            <span v-if="dbHealth" class="db-health-total">· database {{ formatBytes(dbHealth.db_bytes) }}</span>
          </h3>
          <p class="field-hint">
            Dead-tuple ratio is bloat — rows left by updates/deletes not yet reclaimed.
            Above {{ DEAD_PCT_WARN }}% on a large table means autovacuum is falling behind;
            consider adding it to the maintenance set.
          </p>
          <p v-if="loadingHealth && !dbHealth" class="field-hint">Loading…</p>
          <div v-else-if="dbHealth" class="db-health-scroll">
            <table class="db-health-table">
              <thead>
                <tr>
                  <th>Table</th><th class="num">Size</th><th class="num">Live</th>
                  <th class="num">Dead</th><th class="num">Dead %</th>
                  <th>Last vacuum</th><th>Last analyze</th>
                </tr>
              </thead>
              <tbody>
                <tr v-for="t in dbHealth.tables" :key="t.table" :class="{ 'dh-warn': t.dead_pct >= DEAD_PCT_WARN }">
                  <td><code>{{ t.table }}</code></td>
                  <td class="num">{{ formatBytes(t.total_bytes) }}</td>
                  <td class="num">{{ t.live.toLocaleString() }}</td>
                  <td class="num">{{ t.dead.toLocaleString() }}</td>
                  <td class="num">{{ t.dead_pct }}%</td>
                  <td>{{ t.last_vacuum ? new Date(t.last_vacuum).toLocaleString() : "—" }}</td>
                  <td>{{ t.last_analyze ? new Date(t.last_analyze).toLocaleString() : "—" }}</td>
                </tr>
              </tbody>
            </table>
          </div>
        </div>
      </section>

      <section class="settings-section full-width">
        <h2>Email / SMTP</h2>
        <p class="section-desc">Configure SMTP to enable email notifications for all users.</p>
        <div class="smtp-grid">
          <div class="field">
            <label for="smtp-host">SMTP Host</label>
            <input id="smtp-host" v-model="smtp.smtp_host" type="text" placeholder="smtp.example.com" class="input" />
          </div>
          <div class="field">
            <label for="smtp-port">Port</label>
            <input id="smtp-port" v-model="smtp.smtp_port" type="text" placeholder="587" class="input" />
          </div>
          <div class="field">
            <label for="smtp-username">Username</label>
            <input id="smtp-username" v-model="smtp.smtp_username" type="text" class="input" />
          </div>
          <div class="field">
            <label for="smtp-password">Password</label>
            <input id="smtp-password" v-model="smtp.smtp_password" type="password" class="input" />
          </div>
          <div class="field">
            <label for="smtp-from-address">From Address</label>
            <input id="smtp-from-address" v-model="smtp.smtp_from_address" type="email" placeholder="noreply@example.com" class="input" />
          </div>
          <div class="field">
            <label for="smtp-from-name">From Name</label>
            <input id="smtp-from-name" v-model="smtp.smtp_from_name" type="text" placeholder="Fabled Scribe" class="input" />
          </div>
        </div>
        <div class="checkbox-field">
          <label>
            <input type="checkbox" :checked="smtp.smtp_use_tls === 'true'" @change="smtp.smtp_use_tls = ($event.target as HTMLInputElement).checked ? 'true' : 'false'" />
            Use STARTTLS
          </label>
          <p class="field-hint">Recommended for port 587. Implicit TLS is used automatically for port 465.</p>
        </div>
        <div class="actions" style="margin-bottom: 1.25rem;">
          <button class="btn-primary" @click="saveSmtp" :disabled="savingSmtp">
            {{ savingSmtp ? "Saving..." : "Save SMTP Settings" }}
          </button>
          <span v-if="smtpSaved" class="saved-msg">Saved!</span>
        </div>
        <div class="test-email-section">
          <h3 class="subsection-label">Test Email</h3>
          <div class="test-email-row">
            <input
              v-model="testRecipient"
              type="email"
              placeholder="test@example.com"
              class="input"
            />
            <button class="btn-primary" @click="sendTestEmail" :disabled="sendingTest || !testRecipient.trim()">
              {{ sendingTest ? "Sending..." : "Send Test" }}
            </button>
          </div>
        </div>
      </section>

      <section class="settings-section full-width">
        <h2>Forge Webhook</h2>
        <p class="section-desc">
          Forge connections are per-user (Settings → Integrations → Git
          Forges). What stays instance-wide is the push webhook: create one on
          the forge pointing at <code>/api/webhooks/forge</code> with this
          secret, and snippets whose recorded files change get flagged for
          re-verification.
        </p>
        <div class="smtp-grid">
          <div class="field">
            <label for="forge-webhook-secret">Webhook Secret</label>
            <input id="forge-webhook-secret" v-model="forgeWebhookSecret" type="password" class="input" />
          </div>
        </div>
        <div class="actions">
          <button class="btn-primary" @click="saveForgeWebhook" :disabled="savingForgeWebhook">
            {{ savingForgeWebhook ? "Saving..." : "Save Webhook Secret" }}
          </button>
          <span v-if="forgeWebhookSaved" class="saved-msg">Saved!</span>
        </div>
      </section>

    </div>

    <!-- ── Users ── -->
    <div v-if="authStore.isAdmin" v-show="activeTab === 'users'" class="settings-grid">

      <section class="settings-section full-width">
        <h2>Registration</h2>
        <div class="registration-row">
          <div class="registration-info">
            <p class="registration-status">
              Registration is currently
              <strong :class="registrationOpen ? 'text-success' : 'text-muted'">
                {{ registrationOpen ? "open" : "closed" }}
              </strong>
            </p>
            <p class="field-hint">When closed, new users can only be added by an administrator.</p>
          </div>
          <button
            class="btn-primary btn-toggle"
            :class="registrationOpen ? 'btn-toggle-close' : 'btn-toggle-open'"
            @click="toggleRegistration"
            :disabled="toggling"
          >
            {{ toggling ? "Updating..." : registrationOpen ? "Close Registration" : "Open Registration" }}
          </button>
        </div>
      </section>

      <section class="settings-section full-width">
        <h2>Invite User</h2>
        <form class="invite-form" @submit.prevent="sendInvite">
          <input
            v-model="inviteEmail"
            type="email"
            placeholder="Email address"
            class="input invite-input"
            required
            :disabled="sendingInvite"
          />
          <button type="submit" class="btn-primary" :disabled="sendingInvite || !inviteEmail.trim()">
            {{ sendingInvite ? "Sending..." : "Send Invite" }}
          </button>
        </form>
        <p class="field-hint">Send an invitation link to allow someone to register, even when public registration is closed.</p>
        <div v-if="invitations.length > 0" class="invite-list">
          <h3 class="subsection-label">Pending Invitations</h3>
          <table class="users-table">
            <thead>
              <tr>
                <th>Email</th>
                <th class="hide-mobile">Sent</th>
                <th class="hide-mobile">Expires</th>
                <th>Actions</th>
              </tr>
            </thead>
            <tbody>
              <tr v-for="inv in invitations" :key="inv.id">
                <td class="cell-email">{{ inv.email }}</td>
                <td class="hide-mobile cell-date">{{ fmtDate(inv.created_at) }}</td>
                <td class="hide-mobile cell-date">{{ fmtDate(inv.expires_at) }}</td>
                <td class="cell-actions">
                  <button class="btn-ghost btn-compact" @click="revokeInvitation(inv.id)" :disabled="revokingId !== null">
                    {{ revokingId === inv.id ? "Revoking..." : "Revoke" }}
                  </button>
                </td>
              </tr>
            </tbody>
          </table>
        </div>
      </section>

      <section class="settings-section full-width">
        <h2>Users</h2>
        <div v-if="usersLoading" class="loading-msg">Loading users...</div>
        <div v-else-if="users.length === 0" class="empty-msg">No users found.</div>
        <table v-else class="users-table">
          <thead>
            <tr>
              <th>Username</th>
              <th class="hide-mobile">Email</th>
              <th>Role</th>
              <th class="hide-mobile">Joined</th>
              <th>Actions</th>
            </tr>
          </thead>
          <tbody>
            <tr v-for="u in users" :key="u.id">
              <td class="cell-username">{{ u.username }}</td>
              <td class="hide-mobile cell-email">{{ u.email || "—" }}</td>
              <td>
                <span class="role-badge" :class="u.role === 'admin' ? 'role-admin' : 'role-user'">
                  {{ u.role }}
                </span>
              </td>
              <td class="hide-mobile cell-date">{{ fmtDate(u.created_at) }}</td>
              <td class="cell-actions">
                <template v-if="u.id === authStore.user?.id">
                  <span class="you-label">You</span>
                </template>
                <template v-else-if="confirmDeleteId === u.id">
                  <button class="btn-danger btn-compact" @click="confirmDelete(u.id)" :disabled="deleting !== null">
                    {{ deleting === u.id ? "Deleting..." : "Confirm" }}
                  </button>
                  <button class="btn-ghost btn-compact" @click="cancelDelete">Cancel</button>
                </template>
                <template v-else>
                  <button class="btn-ghost btn-compact" @click="confirmDelete(u.id)" :disabled="deleting !== null">Delete</button>
                </template>
              </td>
            </tr>
          </tbody>
        </table>
      </section>

    </div>

    <!-- ── Logs ── -->
    <div v-if="authStore.isAdmin" v-show="activeTab === 'logs'" class="settings-grid">

      <section class="settings-section full-width stats-section">
        <h2>Overview</h2>
        <div class="stats-grid">
          <div class="stat-card">
            <span class="stat-count">{{ logStats.total.toLocaleString() }}</span>
            <span class="stat-label">Total</span>
          </div>
          <div class="stat-card">
            <span class="stat-count stat-audit">{{ logStats.audit.toLocaleString() }}</span>
            <span class="stat-label">Audit</span>
          </div>
          <div class="stat-card">
            <span class="stat-count stat-usage">{{ logStats.usage.toLocaleString() }}</span>
            <span class="stat-label">Usage</span>
          </div>
          <div class="stat-card">
            <span class="stat-count stat-error">{{ logStats.error.toLocaleString() }}</span>
            <span class="stat-label">Error</span>
          </div>
        </div>
      </section>

      <section class="settings-section full-width">
        <h2>Log Entries</h2>
        <div class="filter-bar">
          <select v-model="logCategory" class="filter-select input">
            <option value="">All categories</option>
            <option value="audit">Audit</option>
            <option value="usage">Usage</option>
            <option value="error">Error</option>
          </select>
          <input v-model="logSearch" type="text" placeholder="Search logs..." class="filter-input input" />
          <input v-model="logDateFrom" type="date" class="filter-date input" title="From date" />
          <input v-model="logDateTo" type="date" class="filter-date input" title="To date" />
          <button
            v-if="logCategory || logSearch || logDateFrom || logDateTo"
            class="btn-secondary"
            @click="clearLogFilters"
          >Clear</button>
        </div>

        <div v-if="logsLoading" class="loading-msg">Loading logs...</div>
        <div v-else-if="logs.length === 0" class="empty-msg">No log entries found.</div>
        <template v-else>
          <table class="users-table logs-table">
            <thead>
              <tr>
                <th>Time</th>
                <th>Category</th>
                <th class="hide-mobile">User</th>
                <th>Action / Endpoint</th>
                <th class="hide-mobile">Status</th>
                <th class="hide-mobile">Duration</th>
              </tr>
            </thead>
            <tbody>
              <template v-for="entry in logs" :key="entry.id">
                <tr class="log-row" :class="{ 'row-expanded': expandedLogId === entry.id }" @click="toggleLogExpand(entry.id)">
                  <td class="cell-time">{{ fmtLogStamp(entry.created_at) }}</td>
                  <td>
                    <span class="category-badge" :class="'cat-' + entry.category">{{ entry.category }}</span>
                  </td>
                  <td class="hide-mobile cell-user">{{ entry.username || "—" }}</td>
                  <td class="cell-action">
                    <span v-if="entry.method" class="method-tag">{{ entry.method }}</span>
                    {{ logDisplayLabel(entry) }}
                  </td>
                  <td class="hide-mobile cell-status">
                    <span v-if="entry.status_code" :class="entry.status_code >= 400 ? 'text-error' : ''">
                      {{ entry.status_code }}
                    </span>
                    <span v-else>—</span>
                  </td>
                  <td class="hide-mobile cell-duration">
                    {{ entry.duration_ms != null ? entry.duration_ms + "ms" : "—" }}
                  </td>
                </tr>
                <tr v-if="expandedLogId === entry.id && (entry.details || entry.ip_address)" class="detail-row">
                  <td colspan="6">
                    <div v-if="entry.ip_address" class="detail-ip">IP: {{ entry.ip_address }}</div>
                    <pre v-if="entry.details" class="detail-json">{{ formatLogDetails(entry.details) }}</pre>
                  </td>
                </tr>
              </template>
            </tbody>
          </table>
          <PaginationBar
            :total="logTotal"
            :limit="logLimit"
            :offset="logOffset"
            @update:offset="logOffset = $event"
          />
        </template>
      </section>

    </div>

    <!-- ── Groups ── -->
    <div v-if="authStore.isAdmin" v-show="activeTab === 'groups'" class="settings-grid">

      <section class="settings-section full-width">
        <h2>Groups</h2>
        <p class="section-desc">Manage platform-wide groups for sharing projects and notes.</p>

        <!-- Create group form -->
        <div class="group-create-form">
          <input v-model="newGroupName" class="input-field" placeholder="Group name" maxlength="100" @keydown.enter="createNewGroup" />
          <input v-model="newGroupDesc" class="input-field" placeholder="Description (optional)" maxlength="255" @keydown.enter="createNewGroup" />
          <button class="btn-primary" @click="createNewGroup" :disabled="creatingGroup || !newGroupName.trim()">
            {{ creatingGroup ? 'Creating…' : 'Create Group' }}
          </button>
        </div>

        <!-- Groups list -->
        <div v-if="groupsLoading" class="loading-msg">Loading groups…</div>
        <div v-else-if="!groups.length" class="empty-msg">No groups yet.</div>
        <div v-else class="groups-list">
          <div v-for="g in groups" :key="g.id" class="group-card">
            <div class="group-card-header">
              <div class="group-card-info">
                <span class="group-name">{{ g.name }}</span>
                <span class="group-meta">{{ g.member_count }} member{{ g.member_count !== 1 ? 's' : '' }}</span>
                <span v-if="g.description" class="group-desc">{{ g.description }}</span>
              </div>
              <div class="group-card-actions">
                <button class="btn-ghost btn-compact" @click="toggleGroupExpand(g)">
                  {{ expandedGroupId === g.id ? 'Collapse' : 'Manage' }}
                </button>
                <button class="btn-danger-outline btn-compact" @click="deleteGroupConfirm(g)">Delete</button>
              </div>
            </div>

            <!-- Members panel -->
            <div v-if="expandedGroupId === g.id" class="group-members-panel">
              <div class="members-search">
                <div class="member-search-wrap">
                  <input
                    v-model="groupMemberSearch"
                    class="input-field"
                    placeholder="Search user to add…"
                    @input="debounceGroupMemberSearch"
                    autocomplete="off"
                  />
                  <ul v-if="groupMemberResults.length" class="member-results">
                    <li v-for="u in groupMemberResults" :key="u.id" class="member-result-item" @click="addMemberToGroup(g.id, u)">
                      <span class="member-result-name">{{ u.username }}</span>
                    </li>
                  </ul>
                </div>
                <select v-model="groupMemberRole" class="role-select">
                  <option value="member">Member</option>
                  <option value="owner">Owner</option>
                </select>
              </div>
              <ul class="members-list">
                <li v-for="m in (groupMembers[g.id] || [])" :key="m.user_id" class="member-row">
                  <span class="member-name">{{ m.username }}</span>
                  <span class="member-role-badge" :class="`role-${m.role}`">{{ m.role }}</span>
                  <button class="btn-danger-outline btn-compact" @click="removeMemberFromGroup(g.id, m.user_id)">Remove</button>
                </li>
                <li v-if="!(groupMembers[g.id]?.length)" class="members-empty">No members yet.</li>
              </ul>
            </div>
          </div>
        </div>
      </section>
    </div>

    </div><!-- end .settings-content -->
  </main>
</template>

<style scoped>
/* Settings root layout */
.settings-root {
  display: flex;
  gap: 0;
  max-width: 1100px;
  margin: 2rem auto;
  padding: 0 1.5rem;
  align-items: flex-start;
  min-height: 0;
}

/* Sidebar */
.settings-sidebar {
  width: 175px;
  flex-shrink: 0;
  position: sticky;
  top: 1.5rem;
  padding-right: 1rem;
}
.sidebar-group {
  margin-bottom: 1rem;
}
.sidebar-group-label {
  font-size: 0.65rem;
  font-weight: 500;
  text-transform: uppercase;
  letter-spacing: 0.07em;
  color: var(--fs-text-tertiary);
  padding: 0.25rem 0.75rem 0.2rem;
}
.sidebar-item {
  display: block;
  width: 100%;
  text-align: left;
  padding: 0.4rem 0.75rem;
  border: none;
  border-left: 2px solid transparent;
  background: none;
  cursor: pointer;
  font-size: 0.875rem;
  color: var(--fs-text-secondary);
  border-radius: 0 var(--fs-radius-sm) var(--fs-radius-sm) 0;
  transition: color 0.15s, background 0.15s, border-color 0.15s;
  font-family: inherit;
}
.sidebar-item:hover {
  color: var(--fs-text-primary);
  background: var(--fs-surface-raised);
}
.sidebar-item.active {
  color: var(--fs-accent);
  background: color-mix(in srgb, var(--fs-accent) 8%, transparent);
  border-left-color: var(--fs-accent);
  font-weight: 500;
}

/* Content panel */
.settings-content {
  flex: 1;
  min-width: 0;
}

/* Two-column grid — small sections pair up, full-width sections span both */
.settings-grid {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 1.25rem;
  align-items: start;
}
.settings-section {
  background: var(--fs-surface-raised);
  border: 1px solid var(--fs-border-color);
  border-radius: var(--fs-radius-lg);
  padding: 1.25rem;
}
.settings-section.full-width {
  grid-column: 1 / -1;
}
.settings-section h2 {
  margin: 0 0 0.75rem;
  font-size: 0.7rem;
  font-weight: 500;
  text-transform: uppercase;
  letter-spacing: 0.07em;
  color: var(--fs-text-tertiary);
}
.section-desc {
  margin: 0 0 1rem;
  font-size: 0.875rem;
  color: var(--fs-text-secondary);
  line-height: 1.5;
}

/* ── Model Management ───────────────────────────────────────── */
.model-mgmt-header {
  display: flex; align-items: flex-start; justify-content: space-between; gap: 1rem; margin-bottom: 0.75rem;
}
.model-mgmt-header h2 { margin: 0; }
.model-mgmt-header .section-desc { margin: 0.25rem 0 0; }
.model-list { display: flex; flex-direction: column; gap: 0.25rem; margin-bottom: 0.75rem; }
.model-row {
  display: flex; align-items: center; justify-content: space-between; gap: 0.5rem;
  padding: 0.45rem 0.6rem;
  background: var(--fs-surface-raised);
  border: 1px solid var(--fs-border-color);
  border-radius: 6px;
}
.model-row-info { display: flex; align-items: center; gap: 0.4rem; min-width: 0; flex: 1; }
.model-name { font-size: 0.88rem; font-weight: 500; font-family: monospace; }
.model-badge {
  font-size: 0.68rem; padding: 0.1rem 0.4rem; border-radius: 3px; font-weight: 500; white-space: nowrap;
}
.model-badge--loaded { background: color-mix(in srgb, #22c55e 18%, transparent); color: #22c55e; }
.model-badge--default { background: color-mix(in srgb, var(--fs-accent) 18%, transparent); color: var(--fs-accent); }
.model-row-right { display: flex; align-items: center; gap: 0.5rem; flex-shrink: 0; }
.model-size { font-size: 0.78rem; color: var(--fs-text-tertiary); }
.model-delete-btn {
  background: none; border: none; cursor: pointer; color: var(--fs-text-tertiary);
  font-size: 0.8rem; padding: 0.2rem 0.35rem; border-radius: 3px; line-height: 1;
  transition: color 0.15s, background 0.15s;
}
.model-delete-btn:hover:not(:disabled) { color: var(--fs-action-destructive); background: color-mix(in srgb, var(--fs-action-destructive) 10%, transparent); }
.model-delete-btn:disabled { opacity: 0.4; cursor: default; }
.model-pull-form { display: flex; gap: 0.5rem; margin-top: 0.5rem; }
.model-pull-form .input { flex: 1; }
.model-suggestions { display: flex; align-items: center; gap: 0.35rem; flex-wrap: wrap; margin-top: 0.4rem; }
.suggestions-label { font-size: 0.75rem; color: var(--fs-text-tertiary); white-space: nowrap; }
.suggestion-chip {
  font-size: 0.72rem; padding: 0.15rem 0.5rem; border-radius: 4px;
  border: 1px solid var(--fs-border-color); background: var(--fs-surface-raised);
  cursor: pointer; font-family: monospace; color: var(--fs-text-primary);
  transition: border-color 0.12s, background 0.12s;
}
.suggestion-chip:hover:not(:disabled) { border-color: var(--fs-accent); background: color-mix(in srgb, var(--fs-accent) 8%, var(--fs-surface-raised)); }
.suggestion-chip:disabled { opacity: 0.4; cursor: default; }
.model-pull-progress { margin-top: 0.6rem; }
.pull-status { font-size: 0.8rem; color: var(--fs-text-tertiary); margin-bottom: 0.25rem; }
.pull-bar-track {
  height: 4px; background: var(--fs-border-color); border-radius: 2px; overflow: hidden;
}
.pull-bar-fill {
  height: 100%; background: var(--fs-accent); border-radius: 2px;
  transition: width 0.3s ease;
}
.pull-bar-indeterminate {
  height: 4px; background: var(--fs-border-color); border-radius: 2px;
  position: relative; overflow: hidden;
}
.pull-bar-indeterminate::after {
  content: ""; position: absolute; top: 0; left: -40%;
  width: 40%; height: 100%; background: var(--fs-accent); border-radius: 2px;
  animation: indeterminate 1.2s ease-in-out infinite;
}
@keyframes indeterminate {
  0% { left: -40%; } 100% { left: 100%; }
}

/* Assistant — 2-col internal grid */
.assistant-grid {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 0 1rem;
}
@media (max-width: 700px) {
  .assistant-grid {
    grid-template-columns: 1fr;
  }
}

.field {
  margin-bottom: 1rem;
}
.field label {
  display: block;
  font-size: 0.875rem;
  font-weight: 500;
  margin-bottom: 0.35rem;
  color: var(--fs-text-primary);
}
.input {
  width: 100%;
  padding: 0.45rem 0.7rem;
  border: 1px solid var(--fs-border-color);
  border-radius: var(--fs-radius-sm);
  font-size: 0.9rem;
  background: var(--fs-surface-page);
  color: var(--fs-text-primary);
  box-sizing: border-box;
  font-family: inherit;
}
.input:focus {
  outline: none;
  border-color: var(--fs-accent);
}
.field-hint {
  margin: 0.3rem 0 0;
  font-size: 0.78rem;
  color: var(--fs-text-tertiary);
}
.field-hint-warn {
  display: block;
  margin-top: 0.3rem;
  color: #f59e0b;
}
.actions {
  display: flex;
  align-items: center;
  gap: 0.65rem;
  flex-wrap: wrap;
}
/* Save: Moss action-primary per Hybrid */
.db-maint-table-list {
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: 0.2rem;
}
.db-maint-table-list li {
  display: flex;
  justify-content: space-between;
  gap: 1rem;
  font-size: 0.82rem;
  padding: 0.2rem 0;
}
.db-maint-table-list .dm-status { color: var(--fs-text-tertiary); }
.db-maint-table-list li.dm-failed .dm-status { color: var(--fs-error); }

/* DB table-health readout */
.db-health { margin-top: 1.5rem; }
.db-health-total { color: var(--fs-text-tertiary); font-weight: 400; }
.db-health-scroll { overflow-x: auto; margin-top: 0.5rem; }
.db-health-table {
  width: 100%;
  border-collapse: collapse;
  font-size: 0.82rem;
}
.db-health-table th, .db-health-table td {
  text-align: left;
  padding: 0.35rem 0.6rem;
  border-bottom: 1px solid var(--fs-border-color);
  white-space: nowrap;
}
.db-health-table th {
  font-size: 0.72rem;
  text-transform: uppercase;
  letter-spacing: 0.04em;
  color: var(--fs-text-tertiary);
  font-weight: 500;
}
.db-health-table td.num, .db-health-table th.num { text-align: right; font-variant-numeric: tabular-nums; }
.db-health-table tr.dh-warn td { color: var(--fs-warning); }
.db-health-table tr.dh-warn td:first-child code { color: var(--fs-warning); }
.btn-warn:hover:not(:disabled) {
  background: var(--fs-warning);
  color: var(--fs-text-on-action);
}

.saved-msg {
  color: var(--fs-success);
  font-size: 0.875rem;
  font-weight: 500;
}
.input-error { border-color: var(--fs-error); }
.input-error:focus { border-color: var(--fs-error); }
.error-hint {
  margin: 0.3rem 0 0;
  font-size: 0.78rem;
  color: var(--fs-error);
}

.retention-row {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  margin-top: 0.4rem;
}
.retention-input {
  width: 6rem;
}

/* Data buttons */
.data-actions {
  display: flex;
  flex-wrap: wrap;
  gap: 0.5rem;
}
.hidden-file-input { display: none; }

/* Checkboxes */
.checkbox-field { margin-bottom: 1rem; }
.checkbox-field label {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  font-size: 0.9rem;
  color: var(--fs-text-primary);
  cursor: pointer;
}
.checkbox-field input[type="checkbox"] {
  width: 16px;
  height: 16px;
  accent-color: var(--fs-accent);
}

/* Search test */
.url-chip {
  font-size: 0.8rem;
  padding: 0.1rem 0.4rem;
  background: var(--fs-surface-raised);
  border: 1px solid var(--fs-border-color);
  border-radius: 4px;
}
.not-configured {
  opacity: 0.75;
}
.search-row {
  display: flex;
  gap: 0.5rem;
  align-items: center;
  margin-bottom: 0.75rem;
}
.search-row .input { flex: 1; }
.search-error {
  font-size: 0.875rem;
  color: var(--fs-error);
  margin: 0 0 0.5rem;
}
.search-results {
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: 0.75rem;
}
.search-result {
  padding: 0.65rem 0.85rem;
  background: var(--fs-surface-raised);
  border: 1px solid var(--fs-border-color);
  border-radius: var(--fs-radius-sm);
}
.result-header {
  display: flex;
  align-items: baseline;
  gap: 0.5rem;
  flex-wrap: wrap;
  margin-bottom: 0.25rem;
}
.result-title {
  font-size: 0.9rem;
  font-weight: 500;
  color: var(--fs-accent);
  text-decoration: none;
  word-break: break-word;
}
.result-title:hover { text-decoration: underline; }
.result-host {
  font-size: 0.75rem;
  color: var(--fs-text-tertiary);
  white-space: nowrap;
}
.result-snippet {
  margin: 0;
  font-size: 0.82rem;
  color: var(--fs-text-secondary);
  line-height: 1.45;
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
}

/* Application URL — constrain input width */
.url-field { max-width: 480px; }

/* SMTP grid */
.smtp-grid {
  display: grid;
  grid-template-columns: 1fr 1fr 1fr;
  gap: 0 1rem;
}
@media (max-width: 700px) {
  .smtp-grid { grid-template-columns: 1fr 1fr; }
}
@media (max-width: 480px) {
  .smtp-grid { grid-template-columns: 1fr; }
}

/* Test email */
.subsection-label {
  margin: 0 0 0.5rem;
  font-size: 0.875rem;
  font-weight: 500;
  color: var(--fs-text-secondary);
}
.test-email-section {
  border-top: 1px solid var(--fs-border-color);
  padding-top: 1rem;
}
.test-email-row {
  display: flex;
  gap: 0.5rem;
  align-items: center;
  max-width: 480px;
}

/* Push notifications */
.push-unsupported {
  font-size: 0.875rem;
  color: var(--fs-text-tertiary);
}
.push-status-row {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  margin-bottom: 0.4rem;
  font-size: 0.875rem;
}
.push-status-label {
  color: var(--fs-text-secondary);
  font-weight: 500;
}
.push-permission-badge,
.push-sub-badge {
  font-size: 0.72rem;
  font-weight: 500;
  text-transform: uppercase;
  letter-spacing: 0.04em;
  padding: 0.1rem 0.4rem;
  border-radius: 999px;
  background: color-mix(in srgb, var(--fs-text-tertiary) 15%, transparent);
  color: var(--fs-text-tertiary);
}
.perm-granted { background: color-mix(in srgb, var(--fs-success) 15%, transparent); color: var(--fs-success); }
.perm-denied  { background: color-mix(in srgb, var(--fs-error) 15%, transparent); color: var(--fs-error); }
.sub-active   { background: color-mix(in srgb, var(--fs-success) 15%, transparent); color: var(--fs-success); }
.push-error {
  font-size: 0.82rem;
  color: var(--fs-error);
  margin: 0.25rem 0 0;
}

@media (max-width: 768px) {
  .settings-root {
    flex-direction: column;
    padding: 0 1rem;
  }
  .settings-sidebar {
    width: 100%;
    position: static;
    padding-right: 0;
    padding-bottom: 0.5rem;
    border-bottom: 1px solid var(--fs-border-color);
    margin-bottom: 1rem;
  }
  .sidebar-group {
    display: flex;
    flex-wrap: wrap;
    gap: 0.25rem;
    margin-bottom: 0.25rem;
  }
  .sidebar-group-label {
    width: 100%;
  }
  .sidebar-item {
    width: auto;
    border-left: none;
    border-bottom: 2px solid transparent;
    border-radius: var(--fs-radius-sm) var(--fs-radius-sm) 0 0;
    font-size: 0.82rem;
    padding: 0.35rem 0.7rem;
  }
  .sidebar-item.active {
    border-bottom-color: var(--fs-accent);
    background: var(--fs-accent-soft);
  }
  .settings-grid {
    grid-template-columns: 1fr;
  }
  .settings-section.full-width {
    grid-column: auto;
  }
  .assistant-grid {
    grid-template-columns: 1fr;
  }
  .smtp-grid {
    grid-template-columns: 1fr;
  }
}

/* Users panel */
.registration-row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 1rem;
}
.registration-info { flex: 1; }
.registration-status { margin: 0; font-size: 0.95rem; }
.text-success { color: var(--fs-success); }
.text-muted { color: var(--fs-text-tertiary); }
.invite-form {
  display: flex;
  gap: 0.5rem;
  margin-bottom: 0.5rem;
}
.invite-input { flex: 1; }
.invite-list { margin-top: 1rem; }
.subsection-label {
  margin: 0 0 0.5rem;
  font-size: 0.85rem;
  font-weight: 500;
  color: var(--fs-text-secondary);
}
.users-table { width: 100%; border-collapse: collapse; }
.users-table th {
  text-align: left;
  font-size: 0.8rem;
  font-weight: 500;
  text-transform: uppercase;
  letter-spacing: 0.05em;
  color: var(--fs-text-tertiary);
  padding: 0.5rem 0.75rem;
  border-bottom: 1px solid var(--fs-border-color);
}
.users-table td {
  padding: 0.65rem 0.75rem;
  border-bottom: 1px solid var(--fs-border-color);
  font-size: 0.9rem;
}
.users-table tbody tr:last-child td { border-bottom: none; }
.cell-username { font-weight: 500; }
.cell-email { color: var(--fs-text-secondary); }
.cell-date { color: var(--fs-text-tertiary); font-size: 0.85rem; }
.cell-actions { white-space: nowrap; }
.role-badge {
  display: inline-block;
  font-size: 0.7rem;
  font-weight: 500;
  text-transform: uppercase;
  letter-spacing: 0.05em;
  padding: 0.15rem 0.4rem;
  border-radius: var(--fs-radius-sm);
}
.role-admin {
  color: var(--fs-accent);
  background: color-mix(in srgb, var(--fs-accent) 15%, transparent);
}
.role-user {
  color: var(--fs-text-tertiary);
  background: var(--fs-surface-raised);
}
.you-label { font-size: 0.8rem; color: var(--fs-text-tertiary); }
/* Per-row delete (users / invitations / etc.): ghost → Oxblood on hover */

/* Logs panel */
.stats-section { padding: 1rem 1.25rem; }
.stats-grid { display: flex; gap: 1rem; flex-wrap: wrap; }
.stat-card {
  flex: 1;
  min-width: 80px;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 0.15rem;
}
.stat-count { font-size: 1.5rem; font-weight: 500; color: var(--fs-text-primary); }
.stat-label {
  font-size: 0.75rem;
  font-weight: 500;
  text-transform: uppercase;
  letter-spacing: 0.05em;
  color: var(--fs-text-tertiary);
}
.stat-audit { color: var(--fs-accent); }
.stat-usage { color: var(--fs-success); }
.stat-error { color: var(--fs-error); }

.filter-bar { display: flex; gap: 0.5rem; flex-wrap: wrap; margin-bottom: 0.75rem; }
.filter-select { min-width: 140px; }
.filter-input { flex: 1; min-width: 150px; }
.filter-date { width: 140px; }

.logs-table { width: 100%; border-collapse: collapse; margin-top: 0.5rem; }
.logs-table th {
  text-align: left;
  font-size: 0.8rem;
  font-weight: 500;
  text-transform: uppercase;
  letter-spacing: 0.05em;
  color: var(--fs-text-tertiary);
  padding: 0.5rem 0.75rem;
  border-bottom: 1px solid var(--fs-border-color);
}
.logs-table td {
  padding: 0.5rem 0.75rem;
  border-bottom: 1px solid var(--fs-border-color);
  font-size: 0.85rem;
}
.logs-table tbody tr:last-child td { border-bottom: none; }
.log-row { cursor: pointer; transition: background 0.1s; }
.log-row:hover { background: var(--fs-surface-raised); }
.row-expanded { background: var(--fs-surface-raised); }
.cell-time { white-space: nowrap; color: var(--fs-text-tertiary); font-size: 0.8rem; }
.cell-user { color: var(--fs-text-secondary); }
.cell-action { max-width: 260px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.cell-status { font-family: monospace; font-size: 0.85rem; }
.cell-duration { color: var(--fs-text-tertiary); font-size: 0.8rem; white-space: nowrap; }
.text-error { color: var(--fs-error); }
/* Bare by design: a `<tr>` has nothing to style
   that its cells don't carry (#2444). */
.detail-row td { padding: 0 0.75rem 0.75rem; border-bottom: 1px solid var(--fs-border-color); }
.detail-ip { font-family: monospace; font-size: 0.8rem; color: var(--fs-text-tertiary); margin-bottom: 0.4rem; }
.detail-json {
  margin: 0; padding: 0.75rem;
  background: var(--fs-surface-page); border: 1px solid var(--fs-border-color);
  border-radius: var(--fs-radius-sm); font-size: 0.8rem;
  overflow-x: auto; white-space: pre-wrap; word-break: break-all; max-height: 300px;
}
.category-badge {
  display: inline-block;
  font-size: 0.65rem; font-weight: 500;
  text-transform: uppercase; letter-spacing: 0.05em;
  padding: 0.1rem 0.35rem; border-radius: var(--fs-radius-sm);
}
.cat-audit { color: var(--fs-accent); background: color-mix(in srgb, var(--fs-accent) 15%, transparent); }
.cat-usage { color: var(--fs-success); background: color-mix(in srgb, var(--fs-success) 15%, transparent); }
.cat-error { color: var(--fs-error); background: color-mix(in srgb, var(--fs-error) 15%, transparent); }
.method-tag {
  display: inline-block;
  font-size: 0.65rem; font-weight: 500; font-family: monospace;
  padding: 0.05rem 0.25rem; border-radius: 3px;
  background: var(--fs-surface-raised); color: var(--fs-text-tertiary);
  margin-right: 0.25rem;
}

/* ── About / version ─────────────────────────────────────────── */
.version-line {
  margin: 0;
  font-size: 0.9rem;
  color: var(--fs-text-primary);
  display: flex;
  align-items: center;
  gap: 0.6rem;
}
.version-badge {
  font-family: ui-monospace, monospace;
  font-size: 0.8rem;
  padding: 0.15rem 0.5rem;
  background: var(--fs-surface-raised);
  border: 1px solid var(--fs-accent);
  border-radius: 4px;
  color: var(--fs-accent);
}

/* ── Groups tab ──────────────────────────────────────────────── */
/* Moss action-primary per Hybrid */

.input-field {
  width: 100%;
  padding: 0.4rem 0.6rem;
  border: 1px solid var(--fs-border-color);
  border-radius: var(--fs-radius-sm);
  background: var(--fs-surface-hover);
  color: var(--fs-text-primary);
  font-size: 0.875rem;
  font-family: inherit;
  outline: none;
  transition: border-color 0.15s;
}
.input-field:focus { border-color: var(--fs-accent); }

.group-create-form {
  display: flex;
  gap: 0.5rem;
  flex-wrap: wrap;
  margin-bottom: 1.5rem;
  align-items: center;
}
.group-create-form .input-field { flex: 1; min-width: 160px; }

.loading-msg, .empty-msg {
  color: var(--fs-text-tertiary);
  font-size: 0.88rem;
  padding: 0.5rem 0;
}

.groups-list {
  display: flex;
  flex-direction: column;
  gap: 0.75rem;
}

.group-card {
  border: 1px solid var(--fs-border-color);
  border-radius: var(--fs-radius-lg);
  overflow: hidden;
}

.group-card-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 0.75rem 1rem;
  background: var(--fs-surface-raised);
}

.group-card-info {
  display: flex;
  align-items: center;
  gap: 0.75rem;
  flex: 1;
  min-width: 0;
}

.group-name {
  font-weight: 500;
  font-size: 0.9rem;
  color: var(--fs-text-primary);
}
.group-meta {
  font-size: 0.78rem;
  color: var(--fs-text-tertiary);
  white-space: nowrap;
}
.group-desc {
  font-size: 0.82rem;
  color: var(--fs-text-tertiary);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.group-card-actions {
  display: flex;
  gap: 0.35rem;
  flex-shrink: 0;
}


.member-search-wrap {
  flex: 1;
  position: relative;
}

.member-results {
  position: absolute;
  top: 100%;
  left: 0;
  right: 0;
  background: var(--fs-surface-hover);
  border: 1px solid var(--fs-border-color);
  border-radius: 6px;
  margin-top: 2px;
  list-style: none;
  padding: 0.25rem 0;
  z-index: 10;
  box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
  max-height: 160px;
  overflow-y: auto;
}

.member-result-item {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  padding: 0.45rem 0.8rem;
  cursor: pointer;
  transition: background 0.1s;
}
.member-result-item:hover { background: var(--fs-surface-hover); }
.member-result-name { font-weight: 500; font-size: 0.85rem; }
.member-result-email { color: var(--fs-text-tertiary); font-size: 0.78rem; }

.role-select {
  padding: 0.4rem 0.5rem;
  border: 1px solid var(--fs-border-color);
  border-radius: 6px;
  background: var(--fs-surface-hover);
  color: var(--fs-text-primary);
  font-size: 0.85rem;
  cursor: pointer;
  font-family: inherit;
}

.members-list {
  list-style: none;
  padding: 0;
  margin: 0;
  display: flex;
  flex-direction: column;
  gap: 0.35rem;
}

.member-row {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  padding: 0.4rem 0.5rem;
  border-radius: 6px;
  background: var(--fs-surface-hover);
}

.member-name { flex: 1; font-size: 0.88rem; font-weight: 500; color: var(--fs-text-primary); }

.member-role-badge {
  font-size: 0.7rem;
  font-weight: 500;
  text-transform: uppercase;
  letter-spacing: 0.04em;
  padding: 0.15rem 0.4rem;
  border-radius: 4px;
}
.role-owner { background: color-mix(in srgb, var(--fs-warning) 15%, transparent); color: var(--fs-warning); }
.role-member { background: color-mix(in srgb, var(--fs-text-tertiary) 15%, transparent); color: var(--fs-text-tertiary); }

.members-empty {
  color: var(--fs-text-tertiary);
  font-size: 0.82rem;
  padding: 0.25rem 0.5rem;
}

/* Profile — Locations + Journal sections */
.location-row {
  display: flex;
  gap: 0.5rem;
  margin-top: 0.25rem;
}
.geo-msg {
  font-size: 0.78rem;
  margin: 0.2rem 0 0;
}
.geo-ok { color: var(--fs-success); }
.geo-error { color: var(--fs-error); }
.geo-pending { color: var(--fs-text-tertiary); }

.unit-toggle {
  display: flex;
  gap: 0;
  border: 1px solid var(--fs-border-color);
  border-radius: var(--fs-radius-sm);
  overflow: hidden;
  width: fit-content;
  margin-top: 0.25rem;
}
.unit-btn {
  padding: 0.4rem 1rem;
  background: var(--fs-surface-raised);
  color: var(--fs-text-tertiary);
  font-size: 0.85rem;
  cursor: pointer;
  border: none;
  font-family: inherit;
  transition: background 0.15s, color 0.15s;
}
.unit-btn:not(:last-child) {
  border-right: 1px solid var(--fs-border-color);
}
.unit-btn.active {
  background: var(--fs-action-primary);
  color: var(--fs-text-on-action);
}
.unit-btn:hover:not(.active) {
  color: var(--fs-text-primary);
}

.checkbox-label {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  cursor: pointer;
  font-size: 0.9rem;
}
.checkbox-label input[type="checkbox"] {
  cursor: pointer;
}

.time-row {
  display: flex;
  align-items: center;
  gap: 0.4rem;
}
.time-input {
  width: 4rem;
  text-align: center;
}
.time-sep {
  font-size: 1.1rem;
  color: var(--fs-text-tertiary);
}
.time-sep--quiet { font-size: 0.9rem; }

/* API Keys tab */
.api-key-create-form {
  display: flex;
  gap: 0.75rem;
  align-items: center;
  flex-wrap: wrap;
  margin-bottom: 1.5rem;
}
.api-key-scope-select {
  display: flex;
  gap: 1rem;
}
.api-key-scope-select label {
  display: flex;
  align-items: center;
  gap: 0.3rem;
  cursor: pointer;
}
.api-key-reveal {
  background: color-mix(in srgb, var(--fs-accent) 8%, var(--fs-surface-hover));
  border: 1px solid color-mix(in srgb, var(--fs-accent) 30%, transparent);
  border-radius: var(--fs-radius-lg);
  padding: 1rem;
  margin-bottom: 1.5rem;
}
.api-key-value-row {
  display: flex;
  gap: 0.5rem;
  align-items: center;
  margin-top: 0.5rem;
}
.api-key-value {
  flex: 1;
  background: var(--fs-surface-hover);
  padding: 0.4rem 0.6rem;
  border-radius: var(--fs-radius-sm);
  font-size: 0.85rem;
  word-break: break-all;
}
.api-keys-table {
  width: 100%;
  border-collapse: collapse;
  margin-top: 1rem;
}
.api-keys-table th, .api-keys-table td {
  text-align: left;
  padding: 0.5rem 0.75rem;
  border-bottom: 1px solid var(--fs-border-color);
  font-size: 0.9rem;
}
.api-keys-table th { font-weight: 500; opacity: 0.7; }
.scope-badge {
  display: inline-block;
  padding: 0.1rem 0.5rem;
  border-radius: 9999px;
  font-size: 0.78rem;
  font-weight: 500;
}
.scope-badge.read { background: color-mix(in srgb, #3b82f6 15%, transparent); color: #3b82f6; }
.scope-badge.write { background: color-mix(in srgb, #10b981 15%, transparent); color: #10b981; }
.settings-empty { opacity: 0.5; margin-top: 1rem; }
.settings-description { opacity: 0.7; margin-bottom: 1rem; line-height: 1.5; }

/* Scribe MCP section */
.mcp-status { opacity: 0.6; font-size: 0.9rem; }
.mcp-unavailable p { opacity: 0.7; }
.mcp-available { display: flex; flex-direction: column; gap: 1.25rem; }
.mcp-pkg-row {
  display: flex;
  align-items: center;
  gap: 1rem;
  padding: 0.65rem 0.9rem;
  background: color-mix(in srgb, var(--fs-accent) 8%, transparent);
  border: 1px solid color-mix(in srgb, var(--fs-accent) 25%, transparent);
  border-radius: 8px;
}
.mcp-pkg-name { font-family: monospace; font-size: 0.9rem; flex: 1; }
.mcp-install-steps h3 { font-size: 0.95rem; font-weight: 500; margin-bottom: 0.75rem; }
.mcp-install-steps ol { padding-left: 1.25rem; display: flex; flex-direction: column; gap: 0.75rem; }
.mcp-install-steps li { line-height: 1.6; font-size: 0.9rem; }
.mcp-code {
  margin-top: 0.4rem;
  padding: 0.55rem 0.75rem;
  background: color-mix(in srgb, var(--fs-text-primary) 6%, transparent);
  border: 1px solid var(--fs-border-color);
  border-radius: 6px;
  font-size: 0.82rem;
  font-family: monospace;
  white-space: pre-wrap;
  word-break: break-all;
  overflow-x: auto;
  flex: 1;
}
.mcp-client-tabs {
  display: flex;
  gap: 0.25rem;
  margin-bottom: 1rem;
  border-bottom: 1px solid var(--fs-border-color);
}
.mcp-client-tab {
  background: transparent;
  border: none;
  padding: 0.5rem 0.9rem;
  font-size: 0.85rem;
  color: var(--fs-text-tertiary);
  cursor: pointer;
  border-bottom: 2px solid transparent;
  margin-bottom: -1px;
  transition: color 0.15s, border-color 0.15s;
}
.mcp-client-tab:hover { color: var(--fs-text-primary); }
.mcp-client-tab.active {
  color: var(--fs-accent);
  border-bottom-color: var(--fs-accent);
  font-weight: 500;
}
.mcp-url-block { margin-top: 0.25rem; }
.mcp-url-label {
  display: block;
  font-size: 0.85rem;
  opacity: 0.7;
  margin-bottom: 0.25rem;
}
.mcp-client-config {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 1rem;
  margin: 1rem 0 1.5rem;
}
@media (max-width: 640px) {
  .mcp-client-config { grid-template-columns: 1fr; }
}
.mcp-config-field {
  display: flex;
  flex-direction: column;
  gap: 0.25rem;
}
.mcp-config-label {
  font-size: 0.85rem;
  opacity: 0.7;
}
.mcp-code-row {
  display: flex;
  align-items: stretch;
  gap: 0.4rem;
  margin-top: 0.4rem;
}
.mcp-code-row .mcp-code { margin-top: 0; }
.mcp-code-row .btn-sm { white-space: nowrap; }
.mcp-advanced {
  margin-top: 1.25rem;
  border-top: 1px solid var(--fs-border-color);
  padding-top: 0.75rem;
}
.mcp-advanced summary {
  cursor: pointer;
  font-size: 0.85rem;
  opacity: 0.75;
  user-select: none;
}
.mcp-advanced summary:hover { opacity: 1; }
.mcp-advanced[open] summary { margin-bottom: 0.5rem; }
.mcp-hint {
  margin-top: 0.5rem;
  font-size: 0.8rem;
  opacity: 0.7;
  line-height: 1.5;
}
.mcp-other p { font-size: 0.9rem; line-height: 1.6; }
.mcp-plain-list {
  list-style: disc;
  padding-left: 1.25rem;
  display: flex;
  flex-direction: column;
  gap: 0.5rem;
  font-size: 0.9rem;
  line-height: 1.6;
}

/* Voice tab */
.voice-status-row {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  flex-wrap: wrap;
  margin-top: 0.5rem;
}

/* Voice Library (admin) */
.voice-library-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 1rem;
}
.voice-library-toolbar {
  display: flex;
  gap: 0.5rem;
  align-items: center;
  margin: 1rem 0;
}
.voice-library-toolbar .input {
  flex: 1;
}
.voice-library-list {
  list-style: none;
  margin: 0;
  padding: 0;
  border-top: 1px solid var(--fs-border-color);
  max-height: 480px;
  overflow-y: auto;
}
.voice-library-row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 1rem;
  padding: 0.65rem 0.25rem;
  border-bottom: 1px solid var(--fs-border-color);
}
.voice-library-meta {
  min-width: 0;
  flex: 1;
}
.voice-library-id {
  font-family: var(--fs-font-mono);
  font-size: 0.9rem;
  font-weight: 500;
}
.voice-library-sub {
  color: var(--fs-text-tertiary);
  font-size: 0.8rem;
  margin-top: 0.15rem;
}
.voice-library-actions {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  flex-shrink: 0;
}
.voice-library-empty {
  padding: 1rem 0;
  color: var(--fs-text-tertiary);
  text-align: center;
  font-style: italic;
}
.voice-badge {
  font-size: 0.7rem;
  font-weight: 500;
  padding: 0.15rem 0.5rem;
  border-radius: 999px;
  text-transform: uppercase;
  letter-spacing: 0.04em;
}
.voice-badge--bundled {
  background: color-mix(in srgb, var(--fs-text-tertiary) 10%, transparent);
  color: var(--fs-text-tertiary);
  border: 1px solid var(--fs-border-color);
}
.status-badge {
  font-size: 0.75rem;
  font-weight: 500;
  padding: 0.15rem 0.55rem;
  border-radius: 999px;
}
.status-on {
  background: color-mix(in srgb, var(--fs-success) 15%, transparent);
  color: var(--fs-success);
  border: 1px solid color-mix(in srgb, var(--fs-success) 40%, transparent);
}
.status-off {
  background: color-mix(in srgb, var(--fs-text-tertiary) 10%, transparent);
  color: var(--fs-text-tertiary);
  border: 1px solid var(--fs-border-color);
}
.radio-group {
  display: flex;
  flex-direction: column;
  gap: 0.65rem;
  margin-top: 0.35rem;
}
.radio-option {
  display: flex;
  align-items: flex-start;
  gap: 0.55rem;
  cursor: pointer;
}
.radio-option input[type="radio"] { margin-top: 0.2rem; flex-shrink: 0; }
.radio-option span { display: flex; flex-direction: column; gap: 0.15rem; }
.radio-option strong { font-size: 0.875rem; }
.range-input {
  width: 100%;
  max-width: 360px;
  accent-color: var(--fs-accent);
  margin: 0.35rem 0 0.2rem;
}
.range-labels {
  display: flex;
  justify-content: space-between;
  max-width: 360px;
  font-size: 0.75rem;
  color: var(--fs-text-tertiary);
}
.form-actions {
  margin-top: 1rem;
}

.voice-admin-spinner {
  display: inline-flex;
  gap: 3px;
  align-items: center;
  margin-left: 0.4rem;
  vertical-align: middle;
}
.voice-admin-spinner span {
  display: inline-block;
  width: 4px;
  height: 4px;
  border-radius: 50%;
  background: var(--fs-text-tertiary);
  animation: va-dot-bounce 1.2s ease-in-out infinite;
}
.voice-admin-spinner span:nth-child(2) { animation-delay: 0.2s; }
.voice-admin-spinner span:nth-child(3) { animation-delay: 0.4s; }

/* ── Voice blend ────────────────────────────────────────────────────────── */
.blend-slots {
  display: flex;
  flex-direction: column;
  gap: 0.75rem;
  margin-bottom: 0.5rem;
}
.blend-slot {
  background: color-mix(in srgb, var(--fs-surface-hover) 60%, transparent);
  border: 1px solid var(--fs-border-color);
  border-radius: var(--fs-radius-lg);
  padding: 0.75rem 1rem;
  display: flex;
  flex-direction: column;
  gap: 0.5rem;
}
.blend-voice-select {
  width: 100%;
}
.blend-weight-row {
  display: flex;
  align-items: center;
  gap: 0.75rem;
}
.blend-weight-slider {
  flex: 1;
}
.blend-weight-label {
  font-size: 0.85rem;
  font-variant-numeric: tabular-nums;
  min-width: 2.5rem;
  text-align: right;
  color: var(--fs-text-secondary);
}
.blend-actions {
  margin-top: 0.25rem;
}
.toggle-label {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  cursor: pointer;
  font-size: 0.9rem;
}

/* ── Profile tab ─────────────────────────────────────────────────────────── */
.day-picker {
  display: flex;
  gap: 0.35rem;
  flex-wrap: wrap;
  margin-top: 0.35rem;
}
.day-btn {
  padding: 0.3rem 0.65rem;
  border: 1px solid var(--fs-border-color);
  border-radius: 6px;
  background: var(--fs-surface-raised);
  color: var(--fs-text-tertiary);
  font-size: 0.82rem;
  cursor: pointer;
  transition: all 0.15s;
  font-family: inherit;
}
.day-btn:hover { border-color: var(--fs-accent); color: var(--fs-accent); }
.day-btn.active {
  background: color-mix(in srgb, var(--fs-accent) 15%, transparent);
  border-color: var(--fs-accent);
  color: var(--fs-accent);
  font-weight: 500;
}
.learned-summary {
  background: var(--fs-surface-raised);
  border: 1px solid var(--fs-border-color);
  border-left: 3px solid var(--fs-accent);
  border-radius: 8px;
  padding: 0.85rem 1rem;
  font-size: 0.9rem;
  line-height: 1.55;
  color: var(--fs-text-primary);
  white-space: pre-wrap;
  margin-bottom: 0.75rem;
}
.learned-empty {
  font-size: 0.85rem;
  color: var(--fs-text-tertiary);
  margin-bottom: 0.75rem;
}
</style>
