<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, getFableMcpInfo, listApiKeys, createApiKey as apiCreateApiKey, revokeApiKey as apiRevokeApiKey, getVoiceStatus, getVoiceList, getVoiceLibrary, installVoice, uninstallVoice, synthesiseSpeech, getProfile, updateProfile, consolidateProfile, clearProfileObservations, listProfileObservations, getJournalConfig, saveJournalConfig, geocodeAddress, type ApiKeyEntry, type GroupEntry, type GroupMember, type UserSearchResult, type VoiceStatusResult, type VoiceEntry, type VoiceLibraryEntry, type UserProfile, type JournalConfig, type ProfileObservationEntry } from "@/api/client";
import { usePushStore } from "@/stores/push";
import type { User } from "@/types/auth";
import PaginationBar from "@/components/PaginationBar.vue";
import TagInput from "@/components/TagInput.vue";

const store = useSettingsStore();
const authStore = useAuthStore();
const toastStore = useToastStore();
const pushStore = usePushStore();
const assistantName = ref("");
const defaultModel = ref("");
const userTimezone = ref("");
const savingTimezone = ref(false);
const timezoneSaved = ref(false);
const autoConsolidateTasks = ref(true);
const savingAutoConsolidate = ref(false);
const thinkEnabled = ref(false);
const savingThinkEnabled = ref(false);

async function saveAutoConsolidate() {
  savingAutoConsolidate.value = true;
  try {
    await apiPut('/api/settings', {
      auto_consolidate_tasks: autoConsolidateTasks.value ? "true" : "false",
    });
  } catch {
    toastStore.show('Failed to save setting', 'error');
  } finally {
    savingAutoConsolidate.value = false;
  }
}

async function saveThinkEnabled() {
  savingThinkEnabled.value = true;
  try {
    await apiPut('/api/settings', {
      think_enabled: thinkEnabled.value ? "true" : "false",
    });
  } catch {
    toastStore.show('Failed to save setting', 'error');
  } finally {
    savingThinkEnabled.value = false;
  }
}

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;
  }
}
const backgroundModel = ref("");
const installedModels = ref<string[]>([]);
const defaultChatModel = ref("");

interface OllamaModel { name: string; size: number; loaded: boolean; modified_at: string; }
const ollamaModels = ref<OllamaModel[]>([]);
const pullModelName = ref("");
const pullProgress = ref<{ status: string; pct: number | null } | null>(null);
const pulling = ref(false);
const deletingModel = ref<string | null>(null);
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 saving = ref(false);
const saved = 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", "voice", "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 === "voice") loadVoiceTab();
  if (tab === "apikeys") { fetchApiKeys(); loadMcpInfo(); }
}

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

// API Keys
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 mcpInfo = ref<{ available: boolean; filename: string | null } | null>(null);
const mcpInfoLoading = ref(false);

const origin = window.location.origin;
const mcpClientTab = ref<'claude-code' | 'claude-desktop' | 'other'>('claude-code');
const copiedSnippetKey = ref<string | null>(null);

const effectiveApiKey = computed(() => newKeyValue.value || '<your-api-key>');

const claudeCodeCommand = computed(() => {
  return `claude mcp add --transport stdio --scope user fable \\
  --env FABLE_URL=${origin} \\
  --env FABLE_API_KEY=${effectiveApiKey.value} \\
  -- fable-mcp`;
});

const mcpConfigSnippet = computed(() => JSON.stringify({
  mcpServers: {
    fable: {
      command: "fable-mcp",
      env: {
        FABLE_URL: origin,
        FABLE_API_KEY: effectiveApiKey.value,
      },
    },
  },
}, null, 2));

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 loadMcpInfo() {
  if (mcpInfo.value !== null) return;
  mcpInfoLoading.value = true;
  try {
    mcpInfo.value = await getFableMcpInfo();
  } catch {
    mcpInfo.value = { available: false, filename: null };
  } finally {
    mcpInfoLoading.value = false;
  }
}

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);
}

function _downloadFile(filename: string, content: string, mimeType = 'text/plain') {
  const blob = new Blob([content], { type: mimeType });
  const url = URL.createObjectURL(blob);
  const a = document.createElement('a');
  a.href = url;
  a.download = filename;
  a.click();
  URL.revokeObjectURL(url);
}

function downloadEnvFile() {
  const baseUrl = window.location.origin;
  const content = `FABLE_URL=${baseUrl}\nFABLE_API_KEY=${newKeyValue.value}\n`;
  _downloadFile('fable-mcp.env', content);
}

function downloadMcpConfig() {
  const baseUrl = window.location.origin;
  const config = {
    mcpServers: {
      fable: {
        command: 'fable-mcp',
        env: {
          FABLE_URL: baseUrl,
          FABLE_API_KEY: newKeyValue.value,
        },
      },
    },
  };
  _downloadFile('fable-mcp-config.json', JSON.stringify(config, null, 2), 'application/json');
}

// 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();
}


// Chat retention
const chatRetentionDays = ref(90);
const savingRetention = ref(false);

async function saveRetention() {
  savingRetention.value = true;
  try {
    await apiPut("/api/settings", { chat_retention_days: String(chatRetentionDays.value) });
  } finally {
    savingRetention.value = false;
  }
}

// Notification preferences
const notifyTaskReminders = ref(true);
const notifySecurityAlerts = ref(true);
const savingNotifications = ref(false);
const notificationsSaved = ref(false);

// VAPID key reset (admin)
const vapidResetting = ref(false);
const vapidResetMsg = ref("");
const vapidResetError = ref(false);

async function resetVapidKeys() {
  if (!confirm("This will regenerate VAPID keys and clear all push subscriptions. You will need to re-enable notifications afterwards. Continue?")) return;
  vapidResetting.value = true;
  vapidResetMsg.value = "";
  vapidResetError.value = false;
  try {
    await apiPost("/api/push/reset-vapid", {});
    vapidResetMsg.value = "Keys regenerated. Please re-enable notifications in this browser.";
    pushStore.checkSubscription();
  } catch {
    vapidResetError.value = true;
    vapidResetMsg.value = "Reset failed — check server logs.";
  } finally {
    vapidResetting.value = false;
  }
}

// CalDAV settings (per-user)
const caldav = ref({
  caldav_url: "",
  caldav_username: "",
  caldav_password: "",
  caldav_calendar_name: "",
});
const savingCaldav = ref(false);
const caldavSaved = ref(false);
const testingCaldav = ref(false);
const caldavTestResult = ref<{ success: boolean; message?: string; error?: string; calendars?: string[] } | null>(null);

// 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);

// Voice config (admin only)
const adminVoiceEnabled = ref(false);
const adminVoiceSttModel = ref("base.en");
const savingAdminVoice = ref(false);
const adminVoiceSaved = ref(false);
const voiceLoadingModels = ref(false);

async function saveAdminVoice() {
  savingAdminVoice.value = true;
  adminVoiceSaved.value = false;
  try {
    await apiPut("/api/admin/voice", {
      voice_enabled: adminVoiceEnabled.value,
      voice_stt_model: adminVoiceSttModel.value,
    });
    adminVoiceSaved.value = true;
    setTimeout(() => { adminVoiceSaved.value = false; }, 2000);
    voiceTabLoaded.value = false;
    // Always update global voice state so mic buttons appear/disappear immediately
    await store.checkVoiceStatus();
    if (adminVoiceEnabled.value) {
      await reloadVoiceModels();
    }
  } finally {
    savingAdminVoice.value = false;
  }
}

async function reloadVoiceModels() {
  voiceLoadingModels.value = true;
  try {
    await apiPost("/api/admin/voice/reload", {});
    // Poll /api/voice/status until both STT and TTS are ready (max 3 min)
    const deadline = Date.now() + 3 * 60 * 1000;
    while (Date.now() < deadline) {
      await new Promise((r) => setTimeout(r, 2500));
      try {
        const status = await apiGet<{ enabled: boolean; stt: boolean; tts: boolean }>("/api/voice/status");
        if (status.stt && status.tts) {
          voiceStatus.value = status;
          voiceTabLoaded.value = true;
          // Propagate to global store so mic buttons appear everywhere
          store.checkVoiceStatus();
          break;
        }
      } catch { /* keep polling */ }
    }
  } catch {
    toastStore.show("Failed to trigger model reload", "error");
  } finally {
    voiceLoadingModels.value = 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("");

// Voice settings. Default voice id matches the bundled piper voice
// (en_US-amy-medium); legacy kokoro IDs in user settings were cleared
// by alembic migration 0047, so the first read after upgrade returns "".
const voiceStatus = ref<VoiceStatusResult | null>(null);
const voiceStatusLoading = ref(false);
const availableVoices = ref<VoiceEntry[]>([]);
const voiceTtsVoice = ref("en_US-amy-medium");
const voiceTtsSpeed = ref(1.0);
const voiceSpeechStyle = ref("conversational");
const savingVoice = ref(false);
const voiceSaved = ref(false);
const voiceTabLoaded = ref(false);

// Voice library (admin-only): browse + install piper voices from HF
const voiceLibrary = ref<VoiceLibraryEntry[]>([]);
const voiceLibraryLoading = ref(false);
const voiceLibraryError = ref<string | null>(null);
const voiceLibraryFilter = ref(""); // free-text language/name filter
const voiceLibraryExpanded = ref(false);
const installingVoiceIds = ref<Set<string>>(new Set());
const uninstallingVoiceIds = ref<Set<string>>(new Set());

const filteredVoiceLibrary = computed(() => {
  const q = voiceLibraryFilter.value.trim().toLowerCase();
  if (!q) return voiceLibrary.value;
  return voiceLibrary.value.filter(v =>
    v.id.toLowerCase().includes(q)
    || v.language_code.toLowerCase().includes(q)
    || v.language_name.toLowerCase().includes(q)
    || v.country.toLowerCase().includes(q)
    || v.name.toLowerCase().includes(q)
  );
});

function formatVoiceSize(bytes: number): string {
  if (!bytes) return "—";
  const mb = bytes / (1024 * 1024);
  return mb >= 10 ? `${Math.round(mb)} MB` : `${mb.toFixed(1)} MB`;
}

async function loadVoiceLibrary(refresh = false) {
  if (!authStore.isAdmin) return;
  voiceLibraryLoading.value = true;
  voiceLibraryError.value = null;
  try {
    const res = await getVoiceLibrary(refresh);
    voiceLibrary.value = res.voices;
  } catch (e) {
    voiceLibraryError.value = e instanceof Error ? e.message : "Failed to load voice library";
  } finally {
    voiceLibraryLoading.value = false;
  }
}

async function refreshInstalledVoices() {
  // Reload the active voice list (used after install/uninstall) so the
  // user's voice picker sees the change without a full page reload.
  try {
    availableVoices.value = await getVoiceList();
  } catch {
    /* voice feature may be disabled — ignore */
  }
}

async function installLibraryVoice(voiceId: string) {
  if (installingVoiceIds.value.has(voiceId)) return;
  installingVoiceIds.value.add(voiceId);
  try {
    await installVoice(voiceId);
    toastStore.show(`Installed ${voiceId}`, "success");
    await Promise.all([loadVoiceLibrary(false), refreshInstalledVoices()]);
  } catch (e) {
    const msg = e instanceof Error ? e.message : "Install failed";
    toastStore.show(`Failed to install ${voiceId}: ${msg}`, "error");
  } finally {
    installingVoiceIds.value.delete(voiceId);
  }
}

async function uninstallLibraryVoice(voiceId: string) {
  if (uninstallingVoiceIds.value.has(voiceId)) return;
  uninstallingVoiceIds.value.add(voiceId);
  try {
    await uninstallVoice(voiceId);
    toastStore.show(`Removed ${voiceId}`, "success");
    await Promise.all([loadVoiceLibrary(false), refreshInstalledVoices()]);
  } catch (e) {
    const msg = e instanceof Error ? e.message : "Uninstall failed";
    toastStore.show(`Failed to remove ${voiceId}: ${msg}`, "error");
  } finally {
    uninstallingVoiceIds.value.delete(voiceId);
  }
}

async function loadVoiceTab() {
  if (voiceTabLoaded.value) return;
  voiceTabLoaded.value = true;
  voiceStatusLoading.value = true;
  try {
    voiceStatus.value = await getVoiceStatus();
    if (voiceStatus.value.tts) {
      availableVoices.value = await getVoiceList();
    }
  } catch {
    // Voice feature may be disabled
  } finally {
    voiceStatusLoading.value = false;
  }
}

const voicePreviewing = ref(false);

async function previewVoice() {
  if (voicePreviewing.value) return;
  voicePreviewing.value = true;
  // Create AudioContext synchronously in user-gesture context to bypass autoplay policy
  const ctx = new AudioContext();
  try {
    const blob = await synthesiseSpeech(
      "Hello! I'm your assistant. How can I help you today?",
      voiceTtsVoice.value,
      voiceTtsSpeed.value,
    );
    const buf = await ctx.decodeAudioData(await blob.arrayBuffer());
    const src = ctx.createBufferSource();
    src.buffer = buf;
    src.connect(ctx.destination);
    src.start(0);
  } catch {
    toastStore.show("Preview failed — TTS may not be ready", "error");
  } finally {
    voicePreviewing.value = false;
  }
}

async function saveVoiceSettings() {
  savingVoice.value = true;
  voiceSaved.value = false;
  try {
    await apiPut("/api/settings", {
      voice_tts_voice: voiceTtsVoice.value,
      voice_tts_speed: String(voiceTtsSpeed.value),
      voice_speech_style: voiceSpeechStyle.value,
    });
    voiceSaved.value = true;
    setTimeout(() => { voiceSaved.value = false; }, 2000);
  } finally {
    savingVoice.value = false;
  }
}

// ── 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: {}, learned_summary: '',
  observations_count: 0, observations_updated_at: null,
})
const profileSaving = ref(false)
const profileSaved = ref(false)
const consolidating = ref(false)
const clearingObs = ref(false)
const observations = ref<ProfileObservationEntry[]>([])
const observationsExpanded = ref(false)
const observationsLoading = ref(false)
const observationsLoaded = ref(false)

async function toggleObservations() {
  observationsExpanded.value = !observationsExpanded.value
  if (observationsExpanded.value && !observationsLoaded.value) {
    observationsLoading.value = true
    try {
      const res = await listProfileObservations()
      observations.value = res.observations
      observationsLoaded.value = true
    } catch {
      toastStore.show('Failed to load observations', 'error')
    } finally {
      observationsLoading.value = 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 }
}

async function onToggleCloseout(enabled: boolean) {
  journalConfig.value.closeout_enabled = enabled
  try {
    await saveJournalConfig(journalConfig.value)
    toastStore.show(enabled ? 'Nightly closeout enabled' : 'Nightly closeout disabled')
  } catch {
    journalConfig.value.closeout_enabled = !enabled  // revert UI on failure
    toastStore.show('Failed to update closeout setting', 'error')
  }
}

async function runConsolidate() {
  consolidating.value = true
  try {
    const result = await consolidateProfile()
    profile.value.learned_summary = result.learned_summary
    toastStore.show('Profile updated from observations')
  } catch { toastStore.show('Consolidation failed', 'error') }
  finally { consolidating.value = false }
}

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

// ── Journal config (locations, temp unit, prep schedule) ────────────────────
const journalConfig = ref<JournalConfig>({
  prep_enabled: true,
  prep_hour: 5,
  prep_minute: 0,
  day_rollover_hour: 4,
  closeout_enabled: true,
  locations: { home: { label: 'Home', address: '' }, work: { label: 'Work', address: '' } },
  temp_unit: 'C',
})
const journalConfigSaving = ref(false)
const journalConfigSaved = ref(false)
const homeQuery = ref('')
const workQuery = ref('')
const homeGeoStatus = ref<'' | 'ok' | 'error' | 'pending'>('')
const workGeoStatus = ref<'' | 'ok' | 'error' | 'pending'>('')
const homeGeoMsg = ref('')
const workGeoMsg = ref('')

async function loadJournalConfig() {
  try {
    const cfg = await getJournalConfig()
    journalConfig.value = {
      prep_enabled: cfg.prep_enabled ?? true,
      prep_hour: cfg.prep_hour ?? 5,
      prep_minute: cfg.prep_minute ?? 0,
      day_rollover_hour: cfg.day_rollover_hour ?? 4,
      closeout_enabled: cfg.closeout_enabled ?? true,
      morning_end_hour: cfg.morning_end_hour,
      midday_end_hour: cfg.midday_end_hour,
      locations: {
        home: cfg.locations?.home ?? { label: 'Home', address: '' },
        work: cfg.locations?.work ?? { label: 'Work', address: '' },
      },
      temp_unit: cfg.temp_unit ?? 'C',
    }
    homeQuery.value = cfg.locations?.home?.address ?? ''
    workQuery.value = cfg.locations?.work?.address ?? ''
  } catch { /* non-critical */ }
}

async function geocodeFor(which: 'home' | 'work') {
  const query = (which === 'home' ? homeQuery.value : workQuery.value).trim()
  const statusRef = which === 'home' ? homeGeoStatus : workGeoStatus
  const msgRef = which === 'home' ? homeGeoMsg : workGeoMsg
  const label = which === 'home' ? 'Home' : 'Work'
  if (!query) {
    // Clear location
    if (!journalConfig.value.locations) journalConfig.value.locations = {}
    journalConfig.value.locations[which] = { label, address: '' }
    statusRef.value = ''
    msgRef.value = ''
    return
  }
  statusRef.value = 'pending'
  msgRef.value = 'Looking up…'
  try {
    const result = await geocodeAddress(query)
    if (!result) {
      statusRef.value = 'error'
      msgRef.value = `No match for "${query}"`
      return
    }
    if (!journalConfig.value.locations) journalConfig.value.locations = {}
    journalConfig.value.locations[which] = {
      label,
      address: query,
      lat: result.lat,
      lon: result.lon,
    }
    statusRef.value = 'ok'
    msgRef.value = `Found: ${result.display_name}`
  } catch {
    statusRef.value = 'error'
    msgRef.value = 'Geocoding failed'
  }
}

async function saveJournalCfg() {
  journalConfigSaving.value = true
  journalConfigSaved.value = false
  try {
    await saveJournalConfig(journalConfig.value)
    journalConfigSaved.value = true
    setTimeout(() => { journalConfigSaved.value = false }, 2000)
  } catch { toastStore.show('Failed to save journal settings', 'error') }
  finally { journalConfigSaving.value = false }
}

async function clearObservations() {
  if (!confirm('Clear all learned observations and the generated summary? This cannot be undone.')) return
  clearingObs.value = true
  try {
    await clearProfileObservations()
    profile.value.learned_summary = ''
    profile.value.observations_count = 0
    profile.value.observations_updated_at = null
    observations.value = []
    observationsLoaded.value = false
    toastStore.show('Learned data cleared')
  } catch { toastStore.show('Failed to clear observations', 'error') }
  finally { clearingObs.value = false }
}

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

  // Load installed models and configured defaults
  try {
    const modelsData = await apiGet<{ models: string[]; default_chat_model: string }>("/api/settings/models");
    installedModels.value = modelsData.models;
    defaultChatModel.value = modelsData.default_chat_model;
  } catch {
    // Ollama unreachable — dropdowns will be empty
  }
  await loadOllamaModels();

  // Load notification preferences from user settings
  const allSettings = await apiGet<Record<string, string>>("/api/settings");
  defaultModel.value = allSettings.default_model ?? "";
  backgroundModel.value = allSettings.background_model ?? "";
  userTimezone.value = allSettings.user_timezone ?? "";
  // Default true if unset; explicit "false" disables auto-consolidation.
  autoConsolidateTasks.value = (allSettings.auto_consolidate_tasks ?? "true") !== "false";
  // Default false: think mode is opt-in. Bench showed it costs 1-2 min/turn
  // for unclear benefit; qwen3 users can re-enable if tool calls regress.
  thinkEnabled.value = (allSettings.think_enabled ?? "false") === "true";
  chatRetentionDays.value = allSettings.chat_retention_days !== undefined
    ? Number(allSettings.chat_retention_days)
    : 90;
  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 schedule)
  await loadJournalConfig();

  // Load voice settings. Voice blending was removed with the kokoro→piper
  // migration (piper has no blend equivalent); legacy voice_tts_blend rows
  // were dropped in migration 0047.
  if (allSettings.voice_tts_voice) voiceTtsVoice.value = allSettings.voice_tts_voice;
  if (allSettings.voice_tts_speed) voiceTtsSpeed.value = parseFloat(allSettings.voice_tts_speed);
  if (allSettings.voice_speech_style) voiceSpeechStyle.value = allSettings.voice_speech_style;

  // Load CalDAV settings
  try {
    const caldavConfig = await apiGet<Record<string, string>>("/api/settings/caldav");
    caldav.value = { ...caldav.value, ...caldavConfig };
  } catch {
    // CalDAV not configured yet
  }

  // 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;
  }

  // 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 {
      const vc = await apiGet<Record<string, string>>("/api/admin/voice");
      adminVoiceEnabled.value = vc.voice_enabled === "true";
      adminVoiceSttModel.value = vc.voice_stt_model ?? "base.en";
    } catch {
      // voice not configured yet
    }
  }
  _loadTabContent(activeTab.value);
});

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) {
    if (e && typeof e === "object" && "body" in e) {
      const b = (e as { body?: { error?: string } }).body;
      toastStore.show(b?.error || "Failed to update email", "error");
    } else {
      toastStore.show("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) {
    if (e && typeof e === "object" && "body" in e) {
      const body = (e as { body?: { error?: string } }).body;
      toastStore.show(body?.error || "Failed to change password", "error");
    } else {
      toastStore.show("Failed to change password", "error");
    }
  } finally {
    changingPassword.value = false;
  }
}

function formatBytes(bytes: number): string {
  if (bytes === 0) return "—";
  const gb = bytes / 1e9;
  if (gb >= 1) return `${gb.toFixed(1)} GB`;
  return `${(bytes / 1e6).toFixed(0)} MB`;
}

async function loadOllamaModels() {
  try {
    const data = await apiGet<{ models: OllamaModel[] }>("/api/chat/models");
    ollamaModels.value = data.models ?? [];
    installedModels.value = ollamaModels.value.map((m) => m.name);
  } catch {
    // Ollama unreachable
  }
}

async function pullModel() {
  const name = pullModelName.value.trim();
  if (!name || pulling.value) return;
  pulling.value = true;
  pullProgress.value = { status: "Connecting…", pct: null };
  try {
    const resp = await fetch("/api/chat/models/pull", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ model: name }),
      credentials: "include",
    });
    if (!resp.ok || !resp.body) throw new Error(`HTTP ${resp.status}`);
    const reader = resp.body.getReader();
    const decoder = new TextDecoder();
    let buf = "";
    while (true) {
      const { done, value } = await reader.read();
      if (done) break;
      buf += decoder.decode(value, { stream: true });
      const lines = buf.split("\n");
      buf = lines.pop() ?? "";
      for (const line of lines) {
        const trimmed = line.replace(/^data: /, "").trim();
        if (!trimmed) continue;
        try {
          const msg = JSON.parse(trimmed);
          if (msg.error) throw new Error(msg.error);
          if (msg.status === "success") {
            pullProgress.value = { status: "Complete", pct: 100 };
            pullModelName.value = "";
          } else if (msg.total && msg.completed) {
            const pct = Math.round((msg.completed / msg.total) * 100);
            pullProgress.value = { status: msg.status || "Downloading…", pct };
          } else {
            pullProgress.value = { status: msg.status || "Working…", pct: null };
          }
        } catch (parseErr: any) {
          if (parseErr.message !== "JSON parse error") throw parseErr;
        }
      }
    }
    await loadOllamaModels();
    toastStore.show(`Model ${name} ready`);
  } catch (err: any) {
    toastStore.show(`Pull failed: ${err.message}`, "error");
  } finally {
    pulling.value = false;
    setTimeout(() => { pullProgress.value = null; }, 3000);
  }
}

async function deleteModel(name: string) {
  deletingModel.value = name;
  try {
    await apiPost("/api/chat/models/delete", { model: name });
    await loadOllamaModels();
    if (defaultModel.value === name) defaultModel.value = "";
    toastStore.show(`Deleted ${name}`);
  } catch {
    toastStore.show(`Failed to delete ${name}`, "error");
  } finally {
    deletingModel.value = null;
  }
}

async function saveAssistant() {
  saving.value = true;
  saved.value = false;
  try {
    await store.updateSettings({
      assistant_name: assistantName.value.trim() || "Fable",
      default_model: defaultModel.value,
      background_model: backgroundModel.value,
    });
    saved.value = true;
    setTimeout(() => (saved.value = false), 2000);
  } finally {
    saving.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 = `fabledassistant-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 = `fabledassistant-${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 saveCaldav() {
  savingCaldav.value = true;
  caldavSaved.value = false;
  try {
    await apiPut("/api/settings/caldav", caldav.value);
    caldavSaved.value = true;
    setTimeout(() => (caldavSaved.value = false), 2000);
  } catch {
    toastStore.show("Failed to save CalDAV settings", "error");
  } finally {
    savingCaldav.value = false;
  }
}

async function testCaldav() {
  testingCaldav.value = true;
  caldavTestResult.value = null;
  try {
    const result = await apiPost<{ success: boolean; message?: string; error?: string; calendars?: string[] }>("/api/settings/caldav/test", {});
    caldavTestResult.value = result;
  } catch (e: unknown) {
    if (e && typeof e === "object" && "body" in e) {
      const body = (e as { body?: { error?: string } }).body;
      caldavTestResult.value = { success: false, error: body?.error || "Connection test failed" };
    } else {
      caldavTestResult.value = { success: false, error: "Connection test failed" };
    }
  } finally {
    testingCaldav.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) {
    if (e && typeof e === "object" && "body" in e) {
      const body = (e as { body?: { error?: string } }).body;
      toastStore.show(body?.error || "Failed to send test email", "error");
    } else {
      toastStore.show("Failed to send test email", "error");
    }
  } finally {
    sendingTest.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 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 formatLogTime(iso: string): string {
  const d = new Date(iso);
  return d.toLocaleString(undefined, {
    month: "short", day: "numeric",
    hour: "2-digit", minute: "2-digit", second: "2-digit",
  });
}

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) {
    const body = (e as { body?: { error?: string } })?.body;
    toastStore.show(body?.error || "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) {
    const body = (e as { body?: { error?: string } })?.body;
    toastStore.show(body?.error || "Failed to delete user", "error");
  } finally {
    deleting.value = null;
  }
}

function formatUserDate(iso: string): string {
  return new Date(iso).toLocaleDateString(undefined, {
    year: "numeric", month: "short", day: "numeric",
  });
}
</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', 'voice', 'apikeys']"
          :key="tab"
          :class="['sidebar-item', { active: activeTab === tab }]"
          @click="activeTab = tab"
        >
          {{ tab === 'apikeys' ? 'API Keys' : 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">
      <section class="settings-section full-width">
        <h2>Assistant</h2>
        <div class="assistant-grid">
          <div class="field">
            <label for="assistant-name">Assistant Name</label>
            <input
              id="assistant-name"
              v-model="assistantName"
              type="text"
              placeholder="Fable"
              class="input"
            />
            <p class="field-hint">The name used in chat messages and LLM context.</p>
          </div>
          <div class="field">
            <label for="default-model">Chat Model</label>
            <select id="default-model" v-model="defaultModel" class="input">
              <option value="">Default ({{ defaultChatModel || "qwen3:latest" }})</option>
              <option v-for="m in installedModels" :key="m" :value="m">{{ m }}</option>
            </select>
            <p class="field-hint">Model used for new conversations.</p>
          </div>
          <div class="field">
            <label for="background-model">Background Model</label>
            <select id="background-model" v-model="backgroundModel" class="input">
              <option value="">Default (qwen2.5:0.5b)</option>
              <option v-for="m in installedModels" :key="m" :value="m">{{ m }}</option>
            </select>
            <p class="field-hint">
              Model used for background tasks: title generation, tag suggestions, and project summaries.
              Using a small dedicated model (e.g. qwen2.5:0.5b) keeps the chat model's KV cache warm between messages, significantly reducing response time.
              <span v-if="backgroundModel && backgroundModel === (defaultModel || defaultChatModel)" class="field-hint-warn">
                ⚠ Setting this to the same model as Chat Model will wipe the KV cache after every background task, increasing response latency.
              </span>
            </p>
          </div>
          <div class="field">
            <label class="checkbox-label">
              <input
                type="checkbox"
                v-model="thinkEnabled"
                :disabled="savingThinkEnabled"
                @change="saveThinkEnabled"
              />
              Enable model thinking
            </label>
            <p class="field-hint">
              When on, the chat model runs in reasoning mode before responding.
              Default off — bench data showed thinking adds 1–2 minutes per turn for unclear quality benefit.
              Turn on for qwen3-family models if tool-call reliability drops without it.
            </p>
          </div>
        </div>
        <div class="actions">
          <button class="btn-save" @click="saveAssistant" :disabled="saving">
            {{ saving ? "Saving..." : "Save" }}
          </button>
          <span v-if="saved" class="saved-msg">Saved!</span>
        </div>
      </section>

      <!-- Tasks -->
      <section class="settings-section full-width">
        <h2>Tasks</h2>
        <p class="section-desc">
          Task bodies are auto-summarized from accumulated work logs. The summary runs every few logs, plus on task close.
        </p>
        <div class="field">
          <label class="checkbox-label">
            <input
              type="checkbox"
              v-model="autoConsolidateTasks"
              :disabled="savingAutoConsolidate"
              @change="saveAutoConsolidate"
            />
            Auto-consolidate task bodies
          </label>
          <p class="field-hint">
            When off, the task body is only refreshed when you click "Re-consolidate"
            on a task. Existing summaries remain in place.
          </p>
        </div>
      </section>

      <!-- 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-save" @click="saveTimezone" :disabled="savingTimezone">
            {{ savingTimezone ? 'Saving…' : 'Save' }}
          </button>
          <span v-if="timezoneSaved" class="saved-msg">Saved!</span>
        </div>
      </section>

      <!-- Model Management -->
      <section class="settings-section full-width">
        <div class="model-mgmt-header">
          <div>
            <h2>Model Management</h2>
            <p class="section-desc">Install and remove Ollama models without leaving the app. Search <a href="https://ollama.com/library" target="_blank" rel="noopener">ollama.com/library</a> for available models.</p>
          </div>
          <button class="btn-secondary" @click="loadOllamaModels" title="Refresh list">↺ Refresh</button>
        </div>

        <!-- Installed models -->
        <div v-if="ollamaModels.length" class="model-list">
          <div v-for="m in ollamaModels" :key="m.name" class="model-row">
            <div class="model-row-info">
              <span class="model-name">{{ m.name }}</span>
              <span v-if="m.loaded" class="model-badge model-badge--loaded">in VRAM</span>
              <span v-if="m.name === (defaultModel || defaultChatModel)" class="model-badge model-badge--default">default</span>
            </div>
            <div class="model-row-right">
              <span class="model-size">{{ formatBytes(m.size) }}</span>
              <button
                class="model-delete-btn"
                :disabled="deletingModel === m.name"
                @click="deleteModel(m.name)"
                :title="`Remove ${m.name}`"
              >{{ deletingModel === m.name ? '…' : '✕' }}</button>
            </div>
          </div>
        </div>
        <p v-else class="field-hint">No models installed, or Ollama is unreachable.</p>

        <!-- Pull a model -->
        <div class="model-pull-form">
          <input
            v-model="pullModelName"
            class="input"
            placeholder="e.g. qwen3:7b"
            :disabled="pulling"
            @keydown.enter="pullModel"
          />
          <button class="btn-secondary" @click="pullModel" :disabled="pulling || !pullModelName.trim()">
            {{ pulling ? 'Pulling…' : 'Pull' }}
          </button>
        </div>
        <div class="model-suggestions">
          <span class="suggestions-label">Suggestions:</span>
          <button v-for="s in ['qwen3:7b','qwen3:14b','qwen3:4b','llama3.1:8b','nomic-embed-text']"
            :key="s" class="suggestion-chip"
            :disabled="pulling || ollamaModels.some(m => m.name === s)"
            @click="pullModelName = s"
          >{{ s }}</button>
        </div>

        <!-- Pull progress -->
        <div v-if="pullProgress" class="model-pull-progress">
          <div class="pull-status">{{ pullProgress.status }}</div>
          <div class="pull-bar-track" v-if="pullProgress.pct !== null">
            <div class="pull-bar-fill" :style="{ width: pullProgress.pct + '%' }"></div>
          </div>
          <div v-else class="pull-bar-indeterminate"></div>
        </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-save"
              @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-save"
              @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-save" @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-save" @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-save" @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-save" @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>Locations</h2>
        <p class="section-desc">Home and work locations are used by the journal's daily prep to fetch local weather. Place names are geocoded once on save.</p>
        <div class="field">
          <label>Home</label>
          <div class="location-row">
            <input
              v-model="homeQuery"
              type="text"
              class="input"
              placeholder="e.g. Brooklyn, NY"
              @blur="geocodeFor('home')"
              @keydown.enter.prevent="geocodeFor('home')"
            />
          </div>
          <p v-if="homeGeoStatus === 'ok'" class="geo-msg geo-ok">{{ homeGeoMsg }}</p>
          <p v-else-if="homeGeoStatus === 'error'" class="geo-msg geo-error">{{ homeGeoMsg }}</p>
          <p v-else-if="homeGeoStatus === 'pending'" class="geo-msg geo-pending">{{ homeGeoMsg }}</p>
        </div>
        <div class="field">
          <label>Work</label>
          <div class="location-row">
            <input
              v-model="workQuery"
              type="text"
              class="input"
              placeholder="e.g. Manhattan, NY"
              @blur="geocodeFor('work')"
              @keydown.enter.prevent="geocodeFor('work')"
            />
          </div>
          <p v-if="workGeoStatus === 'ok'" class="geo-msg geo-ok">{{ workGeoMsg }}</p>
          <p v-else-if="workGeoStatus === 'error'" class="geo-msg geo-error">{{ workGeoMsg }}</p>
          <p v-else-if="workGeoStatus === 'pending'" class="geo-msg geo-pending">{{ workGeoMsg }}</p>
        </div>
        <div class="field">
          <label>Temperature unit</label>
          <div class="unit-toggle">
            <button
              type="button"
              class="unit-btn"
              :class="{ active: journalConfig.temp_unit === 'C' }"
              @click="journalConfig.temp_unit = 'C'"
            >Celsius</button>
            <button
              type="button"
              class="unit-btn"
              :class="{ active: journalConfig.temp_unit === 'F' }"
              @click="journalConfig.temp_unit = 'F'"
            >Fahrenheit</button>
          </div>
        </div>
        <div class="actions">
          <button class="btn-save" @click="saveJournalCfg" :disabled="journalConfigSaving">{{ journalConfigSaving ? 'Saving…' : 'Save' }}</button>
          <span v-if="journalConfigSaved" class="saved-msg">Saved!</span>
        </div>
      </section>

      <section class="settings-section full-width">
        <h2>Journal</h2>
        <p class="section-desc">Controls when the daily journal prep generates and how the day is framed.</p>
        <div class="field">
          <label class="checkbox-label">
            <input type="checkbox" v-model="journalConfig.prep_enabled" />
            Generate daily prep automatically
          </label>
          <p class="field-hint">When enabled, the journal generates a morning briefing at the time below. When off, the journal still works — just without the auto-generated daily prep.</p>
        </div>
        <div class="assistant-grid">
          <div class="field">
            <label>Prep generates at</label>
            <div class="time-row">
              <input
                type="number"
                min="0"
                max="23"
                v-model.number="journalConfig.prep_hour"
                class="input time-input"
                :disabled="!journalConfig.prep_enabled"
              />
              <span class="time-sep">:</span>
              <input
                type="number"
                min="0"
                max="59"
                step="5"
                v-model.number="journalConfig.prep_minute"
                class="input time-input"
                :disabled="!journalConfig.prep_enabled"
              />
            </div>
            <p class="field-hint">24-hour. Generates each morning at this local time.</p>
          </div>
          <div class="field">
            <label>Day rolls over at</label>
            <div class="time-row">
              <input
                type="number"
                min="0"
                max="23"
                v-model.number="journalConfig.day_rollover_hour"
                class="input time-input"
              />
              <span class="time-sep time-sep--quiet">:00</span>
            </div>
            <p class="field-hint">Hour when the journal switches to a new day. Default 4am — late-night entries (1–3am) still count as the previous day.</p>
          </div>
        </div>
        <div class="actions">
          <button class="btn-save" @click="saveJournalCfg" :disabled="journalConfigSaving">{{ journalConfigSaving ? 'Saving…' : 'Save' }}</button>
          <span v-if="journalConfigSaved" class="saved-msg">Saved!</span>
        </div>
      </section>

      <section class="settings-section full-width">
        <h2>What the Assistant Has Learned</h2>
        <p class="section-desc">
          The assistant observes patterns from your journal and chat conversations and builds a summary over time. The summary is included in the journal's system prompt so the daily prep can reference what it knows about you.
          <span v-if="profile.observations_count > 0"> {{ profile.observations_count }} raw observation{{ profile.observations_count !== 1 ? 's' : '' }} stored.</span>
        </p>

        <label class="toggle-row" style="display:flex;align-items:center;gap:0.6rem;margin:0.5rem 0 0.75rem">
          <input
            type="checkbox"
            :checked="journalConfig.closeout_enabled !== false"
            @change="onToggleCloseout(($event.target as HTMLInputElement).checked)"
          />
          <span>
            <strong>Nightly closeout</strong>
            <small style="display:block;color:var(--color-text-muted)">Extracts patterns from yesterday's journal at your day-rollover hour.</small>
          </span>
        </label>

        <div v-if="profile.learned_summary" class="learned-summary">{{ profile.learned_summary }}</div>
        <div v-else class="learned-empty">No learned summary yet. Observations accumulate from journal and chat conversations.</div>

        <div class="observations-panel" style="margin-top:0.75rem">
          <button class="btn-secondary" @click="toggleObservations" :disabled="profile.observations_count === 0">
            {{ observationsExpanded ? '▾' : '▸' }} Recent observations ({{ profile.observations_count }})
          </button>
          <div v-if="observationsExpanded" class="observations-list" style="margin-top:0.5rem;padding:0.5rem 0.75rem;border:1px solid var(--color-border);border-radius:var(--radius-md);background:var(--color-bg-elev-1)">
            <div v-if="observationsLoading">Loading…</div>
            <div v-else-if="observations.length === 0">No observations yet.</div>
            <div v-else>
              <div v-for="entry in observations" :key="entry.date" style="margin-bottom:0.75rem">
                <div style="font-weight:600;font-size:0.875rem;color:var(--color-text-muted)">{{ entry.date }}</div>
                <div style="white-space:pre-wrap;font-size:0.9rem">{{ entry.bullets }}</div>
              </div>
            </div>
          </div>
        </div>

        <div class="actions" style="gap:0.5rem;flex-wrap:wrap">
          <button class="btn-secondary" @click="runConsolidate" :disabled="consolidating || profile.observations_count === 0">
            {{ consolidating ? 'Consolidating…' : 'Consolidate Now' }}
          </button>
          <button class="btn-danger-outline" @click="clearObservations" :disabled="clearingObs">
            {{ clearingObs ? 'Clearing…' : 'Reset Learned Data' }}
          </button>
        </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-save" @click="saveNotifications" :disabled="savingNotifications">
            {{ savingNotifications ? "Saving..." : "Save" }}
          </button>
          <span v-if="notificationsSaved" class="saved-msg">Saved!</span>
        </div>
      </section>

      <section class="settings-section">
        <h2>Push Notifications</h2>
        <p class="section-desc">
          Receive browser push notifications when a response is ready. Requires HTTPS.
        </p>
        <template v-if="!pushStore.isSupported">
          <p class="push-unsupported">Push notifications are not supported in this browser or connection (requires HTTPS).</p>
        </template>
        <template v-else>
          <div class="push-status-row">
            <span class="push-status-label">Permission:</span>
            <span
              :class="['push-permission-badge', {
                'perm-granted': pushStore.permission === 'granted',
                'perm-denied': pushStore.permission === 'denied',
                'perm-default': pushStore.permission === 'default',
              }]"
            >
              {{ pushStore.permission === 'granted' ? 'Granted' : pushStore.permission === 'denied' ? 'Denied' : 'Not asked' }}
            </span>
          </div>
          <div class="push-status-row">
            <span class="push-status-label">Status:</span>
            <span :class="['push-sub-badge', { 'sub-active': pushStore.isSubscribed }]">
              {{ pushStore.isSubscribed ? 'Subscribed' : 'Not subscribed' }}
            </span>
          </div>
          <p v-if="pushStore.error" class="push-error">{{ pushStore.error }}</p>
          <div class="actions" style="margin-top: 0.75rem;">
            <button
              v-if="!pushStore.isSubscribed"
              class="btn-save"
              @click="pushStore.subscribe()"
              :disabled="pushStore.loading || pushStore.permission === 'denied'"
            >
              {{ pushStore.loading ? 'Enabling...' : 'Enable Notifications' }}
            </button>
            <button
              v-else
              class="btn-secondary"
              @click="pushStore.unsubscribe()"
              :disabled="pushStore.loading"
            >
              {{ pushStore.loading ? 'Disabling...' : 'Disable Notifications' }}
            </button>
          </div>
          <p v-if="pushStore.permission === 'denied'" class="field-hint" style="margin-top: 0.5rem;">
            Notifications are blocked. Allow them in your browser site settings to re-enable.
          </p>
        </template>
        <template v-if="authStore.isAdmin">
          <div class="field-divider" style="margin: 1rem 0;" />
          <p class="section-desc">
            If push notifications fail due to a corrupted or misformatted VAPID key, regenerate
            the key pair here. All existing subscriptions will be cleared — you will need to
            re-enable notifications in this browser afterwards.
          </p>
          <div class="actions" style="margin-top: 0.5rem;">
            <button class="btn-danger" :disabled="vapidResetting" @click="resetVapidKeys">
              {{ vapidResetting ? 'Regenerating…' : 'Regenerate VAPID Keys' }}
            </button>
          </div>
          <p v-if="vapidResetMsg" :class="vapidResetError ? 'field-error' : 'field-hint'" style="margin-top: 0.5rem;">
            {{ vapidResetMsg }}
          </p>
        </template>
      </section>

      <section class="settings-section">
        <h2>Chat History</h2>
        <p class="section-desc">
          Conversations older than this many days are automatically deleted when you load the chat.
          Set to <strong>0</strong> to keep conversations forever.
        </p>
        <div class="field retention-field">
          <label class="field-label">Retention period (days)</label>
          <div class="retention-row">
            <input
              v-model.number="chatRetentionDays"
              type="number"
              min="0"
              max="3650"
              class="input retention-input"
            />
            <button class="btn-primary" :disabled="savingRetention" @click="saveRetention">
              {{ savingRetention ? 'Saving...' : 'Save' }}
            </button>
          </div>
        </div>
      </section>

      <section class="settings-section full-width">
        <h2>About</h2>
        <p class="version-line">Fabled Scribe <span class="version-badge">{{ appVersion }}</span></p>
      </section>

    </div>

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

      <section class="settings-section full-width">
        <h2>Calendar (CalDAV)</h2>
        <p class="section-desc">
          Connect to a CalDAV server (Nextcloud, iCloud, Radicale, Baikal) to create and view calendar events from chat.
        </p>
        <div class="caldav-grid">
          <div class="field">
            <label for="caldav-url">CalDAV URL</label>
            <input id="caldav-url" v-model="caldav.caldav_url" type="url" placeholder="https://cloud.example.com/remote.php/dav" class="input" />
          </div>
          <div class="field">
            <label for="caldav-username">Username</label>
            <input id="caldav-username" v-model="caldav.caldav_username" type="text" class="input" />
          </div>
          <div class="field">
            <label for="caldav-password">Password</label>
            <input id="caldav-password" v-model="caldav.caldav_password" type="password" class="input" />
          </div>
          <div class="field">
            <label for="caldav-calendar-name">Calendar Name</label>
            <input id="caldav-calendar-name" v-model="caldav.caldav_calendar_name" type="text" placeholder="Leave empty to use first available" class="input" />
            <p class="field-hint">Optional. Exact calendar name to use.</p>
          </div>
        </div>
        <div class="actions" style="margin-bottom: 1rem;">
          <button class="btn-save" @click="saveCaldav" :disabled="savingCaldav">
            {{ savingCaldav ? "Saving..." : "Save" }}
          </button>
          <span v-if="caldavSaved" class="saved-msg">Saved!</span>
          <button class="btn-secondary" @click="testCaldav" :disabled="testingCaldav">
            {{ testingCaldav ? "Testing..." : "Test Connection" }}
          </button>
        </div>
        <div v-if="caldavTestResult" class="test-result" :class="{ success: caldavTestResult.success, error: !caldavTestResult.success }">
          <template v-if="caldavTestResult.success">
            {{ caldavTestResult.message }}
            <div v-if="caldavTestResult.calendars?.length" class="test-calendars">
              Calendars: {{ caldavTestResult.calendars.join(", ") }}
            </div>
          </template>
          <template v-else>{{ caldavTestResult.error }}</template>
        </div>
      </section>

      <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-save" @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>

    </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>

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

      <section class="settings-section full-width">
        <h2>Voice</h2>
        <p class="section-desc">
          Configure text-to-speech and speech-to-text for voice conversations.
          Requires <code>VOICE_ENABLED=true</code> on the server.
        </p>

        <!-- Status indicator -->
        <div v-if="voiceStatusLoading" class="field-hint">Loading voice status…</div>
        <div v-else-if="voiceStatus === null" class="field-hint">Voice status unavailable.</div>
        <div v-else class="voice-status-row">
          <span :class="['status-badge', voiceStatus.enabled ? 'status-on' : 'status-off']">
            {{ voiceStatus.enabled ? 'Enabled' : 'Disabled' }}
          </span>
          <span v-if="voiceStatus.enabled" class="status-badge" :class="voiceStatus.stt ? 'status-on' : 'status-off'">
            STT {{ voiceStatus.stt ? 'ready' : 'loading…' }}
          </span>
          <span v-if="voiceStatus.enabled" class="status-badge" :class="voiceStatus.tts ? 'status-on' : 'status-off'">
            TTS {{ voiceStatus.tts ? 'ready' : 'loading…' }}
          </span>
          <span v-if="voiceStatus.stt_model" class="field-hint" style="margin-left:0.5rem;">
            Model: {{ voiceStatus.stt_model }}
          </span>
        </div>

        <div v-if="!voiceStatus?.enabled" class="field-hint" style="margin-top:0.75rem;">
          Voice is disabled. An administrator can enable it under
          <strong>Admin → Config → Voice</strong>.
        </div>
      </section>

      <section v-if="voiceStatus?.enabled" class="settings-section full-width">
        <h2>Speech Style</h2>
        <p class="section-desc">Controls how the assistant phrases spoken responses.</p>

        <div class="field-group">
          <label class="field-label">Style</label>
          <div class="radio-group">
            <label v-for="opt in [
              { value: 'conversational', label: 'Conversational', hint: 'Warm and natural, like speaking to a friend' },
              { value: 'concise', label: 'Concise', hint: 'Short and to the point' },
              { value: 'detailed', label: 'Detailed', hint: 'Thorough explanations spoken aloud' },
            ]" :key="opt.value" class="radio-option">
              <input type="radio" v-model="voiceSpeechStyle" :value="opt.value" />
              <span>
                <strong>{{ opt.label }}</strong>
                <span class="field-hint">{{ opt.hint }}</span>
              </span>
            </label>
          </div>
        </div>
      </section>

      <section v-if="voiceStatus?.enabled" class="settings-section full-width">
        <h2>Voice &amp; Speed</h2>
        <p class="section-desc">Choose the TTS voice and playback speed.</p>

        <div class="field-group">
          <label class="field-label" for="voice-select">Voice</label>
          <select id="voice-select" class="input" v-model="voiceTtsVoice" :disabled="availableVoices.length === 0">
            <option v-if="availableVoices.length === 0" value="af_heart">af_heart (default)</option>
            <option v-for="v in availableVoices" :key="v.id" :value="v.id">{{ v.label }}</option>
          </select>
          <p class="field-hint">Voice character and accent. Applies to all voice conversations.</p>
        </div>

        <div class="field-group">
          <label class="field-label" for="voice-speed">
            Speed: {{ voiceTtsSpeed.toFixed(1) }}×
          </label>
          <input
            id="voice-speed"
            type="range"
            min="0.7"
            max="1.3"
            step="0.05"
            v-model.number="voiceTtsSpeed"
            class="range-input"
          />
          <div class="range-labels">
            <span>0.7× (slow)</span>
            <span>1.0× (normal)</span>
            <span>1.3× (fast)</span>
          </div>
        </div>

        <div class="form-actions">
          <button class="btn-secondary" @click="previewVoice" :disabled="voicePreviewing || !voiceStatus?.tts">
            {{ voicePreviewing ? 'Generating…' : '▶ Preview' }}
          </button>
          <button class="btn-save" @click="saveVoiceSettings" :disabled="savingVoice">
            {{ voiceSaved ? 'Saved ✓' : savingVoice ? 'Saving…' : 'Save Voice Settings' }}
          </button>
        </div>
      </section>

      <!-- Voice Blend section removed with the kokoro → piper migration
           (2026-05-22). Piper has no voice-blending equivalent. -->

      <!-- Voice Library (admin only) — browse + install piper voices.
           Voices land in /data/voices and are immediately available to
           every user's voice picker. Bundled voices in /opt/piper-voices
           are read-only and show a "bundled" badge instead of a delete. -->
      <section v-if="authStore.isAdmin && voiceStatus?.enabled" class="settings-section full-width">
        <div class="voice-library-header">
          <h2>Voice Library</h2>
          <button
            class="btn-secondary"
            type="button"
            @click="voiceLibraryExpanded = !voiceLibraryExpanded; if (voiceLibraryExpanded && voiceLibrary.length === 0) loadVoiceLibrary()"
          >
            {{ voiceLibraryExpanded ? 'Hide' : 'Browse voices' }}
          </button>
        </div>
        <p class="section-desc">
          Download additional piper voices from the
          <a href="https://huggingface.co/rhasspy/piper-voices" target="_blank" rel="noopener">piper-voices catalog</a>.
          Installed voices appear in every user's voice picker after a page reload.
        </p>

        <template v-if="voiceLibraryExpanded">
          <div class="voice-library-toolbar">
            <input
              v-model="voiceLibraryFilter"
              type="search"
              class="input"
              placeholder="Filter by language, country, or name (e.g. 'en', 'fr_FR', 'amy')…"
            />
            <button
              class="btn-secondary"
              type="button"
              :disabled="voiceLibraryLoading"
              @click="loadVoiceLibrary(true)"
              title="Re-fetch catalog from HuggingFace"
            >
              {{ voiceLibraryLoading ? 'Loading…' : 'Refresh' }}
            </button>
          </div>

          <p v-if="voiceLibraryError" class="field-hint-warn">
            {{ voiceLibraryError }}
          </p>

          <div v-if="voiceLibraryLoading && voiceLibrary.length === 0" class="voice-library-empty">
            Loading catalog…
          </div>

          <ul v-else-if="filteredVoiceLibrary.length > 0" class="voice-library-list">
            <li v-for="v in filteredVoiceLibrary" :key="v.id" class="voice-library-row">
              <div class="voice-library-meta">
                <div class="voice-library-id">{{ v.id }}</div>
                <div class="voice-library-sub">
                  {{ v.language_name || v.language_code }}
                  <span v-if="v.country"> · {{ v.country }}</span>
                  · {{ v.quality }}
                  <span v-if="v.num_speakers > 1"> · {{ v.num_speakers }} speakers</span>
                  · {{ formatVoiceSize(v.size_bytes) }}
                </div>
              </div>
              <div class="voice-library-actions">
                <span v-if="v.installed && v.installed_source === 'bundled'" class="voice-badge voice-badge--bundled">
                  bundled
                </span>
                <button
                  v-else-if="v.installed && v.installed_source === 'user'"
                  class="btn-danger btn-sm"
                  :disabled="uninstallingVoiceIds.has(v.id)"
                  @click="uninstallLibraryVoice(v.id)"
                >
                  {{ uninstallingVoiceIds.has(v.id) ? 'Removing…' : 'Remove' }}
                </button>
                <button
                  v-else
                  class="btn-secondary btn-sm"
                  :disabled="installingVoiceIds.has(v.id)"
                  @click="installLibraryVoice(v.id)"
                >
                  {{ installingVoiceIds.has(v.id) ? 'Installing…' : 'Install' }}
                </button>
              </div>
            </li>
          </ul>

          <div v-else-if="!voiceLibraryLoading" class="voice-library-empty">
            No voices match the filter.
          </div>
        </template>
      </section>

    </div>

    <!-- ── API Keys ── -->
    <div v-show="activeTab === 'apikeys'" class="settings-grid">
      <section class="settings-section full-width">
        <h2>API Keys</h2>
        <p class="settings-description">
          API keys let external tools (like the Fable MCP server) access your data without a browser session.
          Write-scoped keys can create and update content; read-only keys can only query.
        </p>

        <!-- Create form -->
        <div class="api-key-create-form">
          <input v-model="newKeyName" placeholder="Key name (e.g. Claude MCP)" 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 Key' }}
          </button>
        </div>

        <!-- One-time key reveal -->
        <div v-if="newKeyValue" class="api-key-reveal">
          <p><strong>Copy this key 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>
          <div style="display:flex; gap:0.5rem; margin-top: 0.5rem;">
            <button @click="downloadEnvFile" class="btn btn-secondary btn-sm">Download .env</button>
            <button @click="downloadMcpConfig" class="btn btn-secondary btn-sm">Download Claude config</button>
            <button @click="newKeyValue = ''" class="btn btn-secondary">Done</button>
          </div>
        </div>

        <!-- Keys 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 API keys yet.</p>
      </section>

      <!-- Fable MCP install section -->
      <section class="settings-section full-width">
        <h2>Fable MCP</h2>
        <p class="settings-description">
          The Fable MCP server lets Claude (and other MCP clients) read and write your notes, tasks, and projects.
          Install it locally, then point it at this Fable instance with an API key above.
        </p>

        <div v-if="mcpInfoLoading" class="mcp-status">Checking package availability…</div>
        <template v-else-if="mcpInfo">
          <div v-if="mcpInfo.available" class="mcp-available">
            <div class="mcp-pkg-row">
              <span class="mcp-pkg-name">{{ mcpInfo.filename }}</span>
              <a href="/api/fable-mcp/download" class="btn btn-primary btn-sm" download>Download</a>
            </div>

            <div class="mcp-install-steps">
              <h3>Installation</h3>

              <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>
                <button
                  type="button"
                  role="tab"
                  :aria-selected="mcpClientTab === 'other'"
                  :class="['mcp-client-tab', { active: mcpClientTab === 'other' }]"
                  @click="mcpClientTab = 'other'"
                >Other</button>
              </div>

              <!-- Claude Code tab -->
              <ol v-if="mcpClientTab === 'claude-code'">
                <li>
                  Download the wheel above and install it with <a href="https://docs.astral.sh/uv/" target="_blank" rel="noopener">uv</a> or <a href="https://pipx.pypa.io/" target="_blank" rel="noopener">pipx</a> — either one works:
                  <div class="mcp-code-row">
                    <pre class="mcp-code">uv tool install ./{{ mcpInfo.filename }}</pre>
                    <button class="btn btn-secondary btn-sm" @click="copySnippet(`uv tool install ./${mcpInfo.filename}`, 'cc-install-uv')">
                      {{ copiedSnippetKey === 'cc-install-uv' ? 'Copied' : 'Copy' }}
                    </button>
                  </div>
                  <div class="mcp-code-row">
                    <pre class="mcp-code">pipx install ./{{ mcpInfo.filename }}</pre>
                    <button class="btn btn-secondary btn-sm" @click="copySnippet(`pipx install ./${mcpInfo.filename}`, 'cc-install-pipx')">
                      {{ copiedSnippetKey === 'cc-install-pipx' ? 'Copied' : 'Copy' }}
                    </button>
                  </div>
                  <p class="mcp-hint">This puts <code>fable-mcp</code> on your PATH so Claude Code can launch it.</p>
                </li>
                <li>
                  Register the server with Claude Code:
                  <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">
                    <code>--scope user</code> makes <code>fable</code> available across all your projects. Use <code>--scope project</code> to write it into the current repo's <code>.mcp.json</code> instead, or <code>--scope local</code> for this machine and repo only.
                    <span v-if="!newKeyValue"> Generate an API key above to pre-fill the command.</span>
                  </p>
                </li>
                <li>
                  Verify the connection by running <code>/mcp</code> inside Claude Code — <code>fable</code> should appear as connected.
                </li>
              </ol>

              <!-- Claude Desktop tab -->
              <ol v-else-if="mcpClientTab === 'claude-desktop'">
                <li>
                  Download the wheel above and install it with uv or pipx:
                  <div class="mcp-code-row">
                    <pre class="mcp-code">uv tool install ./{{ mcpInfo.filename }}</pre>
                    <button class="btn btn-secondary btn-sm" @click="copySnippet(`uv tool install ./${mcpInfo.filename}`, 'cd-install')">
                      {{ copiedSnippetKey === 'cd-install' ? 'Copied' : 'Copy' }}
                    </button>
                  </div>
                </li>
                <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 Fable tools should appear in the available tools list.
                </li>
              </ol>

              <!-- Other tab -->
              <div v-else class="mcp-other">
                <p>
                  Any MCP-compatible client can launch <code>fable-mcp</code> over stdio. The exact setup depends on the client, but in general you'll need:
                </p>
                <ul class="mcp-plain-list">
                  <li>Install the wheel: <code>uv tool install ./{{ mcpInfo.filename }}</code> or <code>pipx install ./{{ mcpInfo.filename }}</code></li>
                  <li>Command: <code>fable-mcp</code></li>
                  <li>Transport: <code>stdio</code></li>
                  <li>Environment variables:
                    <div class="mcp-code-row">
                      <pre class="mcp-code">FABLE_URL={{ origin }}
FABLE_API_KEY={{ effectiveApiKey }}</pre>
                      <button class="btn btn-secondary btn-sm" @click="copySnippet(`FABLE_URL=${origin}\nFABLE_API_KEY=${effectiveApiKey}`, 'other-env')">
                        {{ copiedSnippetKey === 'other-env' ? 'Copied' : 'Copy' }}
                      </button>
                    </div>
                  </li>
                </ul>
                <p class="mcp-hint">Consult your MCP client's documentation for how to register a stdio server. These instructions have only been tested with Claude Code.</p>
              </div>
            </div>
          </div>
          <div v-else class="mcp-unavailable">
            <p>The Fable MCP package is not bundled in this image build. It will be available after the next image rebuild.</p>
          </div>
        </template>
      </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-save" @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>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-save" @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-save" @click="sendTestEmail" :disabled="sendingTest || !testRecipient.trim()">
              {{ sendingTest ? "Sending..." : "Send Test" }}
            </button>
          </div>
        </div>
      </section>

      <section class="settings-section full-width">
        <h2>Voice (Speech-to-Speech)</h2>
        <p class="section-desc">
          Enable self-hosted voice using faster-whisper (STT) and Kokoro (TTS).
          Save settings then click "Reload models" to apply without restarting the server.
          Install dependencies first: <code>pip install faster-whisper kokoro soundfile</code>
        </p>
        <div class="checkbox-field">
          <label>
            <input type="checkbox" v-model="adminVoiceEnabled" />
            Enable Voice
          </label>
          <p class="field-hint">Shows the PTT button and voice settings to all users.</p>
        </div>
        <div v-if="adminVoiceEnabled" class="field" style="max-width:280px; margin-top:0.75rem;">
          <label for="stt-model">STT Model</label>
          <select id="stt-model" v-model="adminVoiceSttModel" class="input">
            <option value="tiny.en">tiny.en — fastest, lowest accuracy (~75 MB)</option>
            <option value="base.en">base.en — balanced (recommended, ~145 MB)</option>
            <option value="small.en">small.en — better accuracy (~465 MB)</option>
            <option value="medium.en">medium.en — best accuracy (~1.5 GB)</option>
          </select>
          <p class="field-hint">Larger models are more accurate but use more RAM and take longer to load.</p>
        </div>
        <div class="actions" style="margin-top:0.85rem;">
          <button class="btn-save" @click="saveAdminVoice" :disabled="savingAdminVoice || voiceLoadingModels">
            {{ savingAdminVoice ? 'Saving…' : adminVoiceSaved ? 'Saved ✓' : 'Save' }}
          </button>
          <button
            v-if="!voiceLoadingModels && adminVoiceEnabled"
            class="btn-secondary"
            @click="reloadVoiceModels"
            :disabled="voiceLoadingModels"
            title="Reload models without restarting the server"
          >Reload models</button>
          <span v-if="voiceLoadingModels" class="field-hint">
            Loading models… this may take a minute.
            <span class="voice-admin-spinner">
              <span></span><span></span><span></span>
            </span>
          </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-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-save" :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">{{ formatUserDate(inv.created_at) }}</td>
                <td class="hide-mobile cell-date">{{ formatUserDate(inv.expires_at) }}</td>
                <td class="cell-actions">
                  <button class="btn-delete" @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">{{ formatUserDate(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-confirm-delete" @click="confirmDelete(u.id)" :disabled="deleting !== null">
                    {{ deleting === u.id ? "Deleting..." : "Confirm" }}
                  </button>
                  <button class="btn-cancel-delete" @click="cancelDelete">Cancel</button>
                </template>
                <template v-else>
                  <button class="btn-delete" @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">{{ formatLogTime(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-sm" @click="toggleGroupExpand(g)">
                  {{ expandedGroupId === g.id ? 'Collapse' : 'Manage' }}
                </button>
                <button class="btn-sm btn-danger-sm" @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-sm btn-danger-sm" @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(--color-text-muted);
  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(--color-text-secondary);
  border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
  transition: color 0.15s, background 0.15s, border-color 0.15s;
  font-family: inherit;
}
.sidebar-item:hover {
  color: var(--color-text);
  background: var(--color-bg-secondary);
}
.sidebar-item.active {
  color: var(--color-primary);
  background: rgba(91, 74, 138, 0.08);
  border-left-color: var(--color-primary);
  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(--color-bg-card);
  border: 1px solid var(--color-border);
  border-radius: var(--radius-md);
  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(--color-text-muted);
}
.section-desc {
  margin: 0 0 1rem;
  font-size: 0.875rem;
  color: var(--color-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(--color-bg-secondary);
  border: 1px solid var(--color-border);
  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(--color-primary) 18%, transparent); color: var(--color-primary); }
.model-row-right { display: flex; align-items: center; gap: 0.5rem; flex-shrink: 0; }
.model-size { font-size: 0.78rem; color: var(--color-text-muted); }
.model-delete-btn {
  background: none; border: none; cursor: pointer; color: var(--color-text-muted);
  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(--color-action-destructive); background: color-mix(in srgb, var(--color-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(--color-text-muted); white-space: nowrap; }
.suggestion-chip {
  font-size: 0.72rem; padding: 0.15rem 0.5rem; border-radius: 4px;
  border: 1px solid var(--color-border); background: var(--color-bg-card);
  cursor: pointer; font-family: monospace; color: var(--color-text);
  transition: border-color 0.12s, background 0.12s;
}
.suggestion-chip:hover:not(:disabled) { border-color: var(--color-primary); background: color-mix(in srgb, var(--color-primary) 8%, var(--color-bg-card)); }
.suggestion-chip:disabled { opacity: 0.4; cursor: default; }
.model-pull-progress { margin-top: 0.6rem; }
.pull-status { font-size: 0.8rem; color: var(--color-text-muted); margin-bottom: 0.25rem; }
.pull-bar-track {
  height: 4px; background: var(--color-border); border-radius: 2px; overflow: hidden;
}
.pull-bar-fill {
  height: 100%; background: var(--color-primary); border-radius: 2px;
  transition: width 0.3s ease;
}
.pull-bar-indeterminate {
  height: 4px; background: var(--color-border); border-radius: 2px;
  position: relative; overflow: hidden;
}
.pull-bar-indeterminate::after {
  content: ""; position: absolute; top: 0; left: -40%;
  width: 40%; height: 100%; background: var(--color-primary); 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(--color-text);
}
.input {
  width: 100%;
  padding: 0.45rem 0.7rem;
  border: 1px solid var(--color-border);
  border-radius: var(--radius-sm);
  font-size: 0.9rem;
  background: var(--color-bg);
  color: var(--color-text);
  box-sizing: border-box;
  font-family: inherit;
}
.input:focus {
  outline: none;
  border-color: var(--color-primary);
}
.field-hint {
  margin: 0.3rem 0 0;
  font-size: 0.78rem;
  color: var(--color-text-muted);
}
.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 */
.btn-save {
  padding: 0.4rem 0.9rem;
  background: var(--color-action-primary);
  color: #fff;
  border: none;
  border-radius: var(--radius-sm);
  cursor: pointer;
  font-size: 0.875rem;
  font-family: inherit;
  white-space: nowrap;
  transition: background 0.15s;
}
.btn-save:disabled { opacity: 0.6; cursor: default; }
.btn-save:hover:not(:disabled) { background: var(--color-action-primary-hover); }

/* Danger outline (Invalidate sessions, Clear observations, etc.):
   Oxblood action-destructive ghost — fills on hover */
.btn-danger-outline {
  padding: 0.4rem 0.9rem;
  background: none;
  color: var(--color-action-destructive);
  border: 1px solid var(--color-action-destructive);
  border-radius: var(--radius-sm);
  cursor: pointer;
  font-size: 0.875rem;
  font-family: inherit;
  white-space: nowrap;
  transition: background 0.15s, color 0.15s;
}
.btn-danger-outline:hover:not(:disabled) {
  background: var(--color-action-destructive);
  color: #fff;
}
.btn-danger-outline:disabled { opacity: 0.5; cursor: default; }

/* Filled destructive: Oxblood action-destructive */
.btn-danger {
  padding: 0.4rem 0.9rem;
  background: var(--color-action-destructive);
  color: #fff;
  border: none;
  border-radius: var(--radius-sm);
  cursor: pointer;
  font-size: 0.875rem;
  font-family: inherit;
  white-space: nowrap;
  transition: background 0.15s;
}
.btn-danger:hover:not(:disabled) { background: var(--color-action-destructive-hover); }
.btn-danger:disabled { opacity: 0.5; cursor: default; }

/* Secondary: Bronze action-secondary — alternate paths (Detect, Test,
   Refresh, Add slot, etc.). Outline form for visual lightness. */
.btn-secondary {
  padding: 0.4rem 0.9rem;
  background: var(--color-action-secondary);
  color: #fff;
  border: none;
  border-radius: var(--radius-sm);
  cursor: pointer;
  font-size: 0.875rem;
  font-family: inherit;
  white-space: nowrap;
  transition: background 0.15s;
}
.btn-secondary:hover:not(:disabled) { background: var(--color-action-secondary-hover); }
.btn-secondary:disabled { opacity: 0.6; cursor: default; }
.btn-warn:hover:not(:disabled) {
  background: var(--color-warning);
  color: #fff;
}

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

.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(--color-text);
  cursor: pointer;
}
.checkbox-field input[type="checkbox"] {
  width: 16px;
  height: 16px;
  accent-color: var(--color-primary);
}

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

.test-result {
  padding: 0.65rem 0.9rem;
  border-radius: var(--radius-sm);
  font-size: 0.875rem;
  line-height: 1.4;
}
.test-result.success {
  background: color-mix(in srgb, var(--color-success) 10%, var(--color-bg));
  border: 1px solid var(--color-success);
  color: var(--color-success);
}
.test-result.error {
  background: color-mix(in srgb, var(--color-danger) 10%, var(--color-bg));
  border: 1px solid var(--color-danger);
  color: var(--color-danger);
}
.test-calendars {
  margin-top: 0.3rem;
  font-size: 0.82rem;
  opacity: 0.85;
}

/* Search test */
.url-chip {
  font-size: 0.8rem;
  padding: 0.1rem 0.4rem;
  background: var(--color-bg-secondary);
  border: 1px solid var(--color-border);
  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(--color-danger);
  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(--color-bg-secondary);
  border: 1px solid var(--color-border);
  border-radius: var(--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(--color-primary);
  text-decoration: none;
  word-break: break-word;
}
.result-title:hover { text-decoration: underline; }
.result-host {
  font-size: 0.75rem;
  color: var(--color-text-muted);
  white-space: nowrap;
}
.result-snippet {
  margin: 0;
  font-size: 0.82rem;
  color: var(--color-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(--color-text-secondary);
}
.test-email-section {
  border-top: 1px solid var(--color-border);
  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(--color-text-muted);
}
.push-status-row {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  margin-bottom: 0.4rem;
  font-size: 0.875rem;
}
.push-status-label {
  color: var(--color-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(--color-text-muted) 15%, transparent);
  color: var(--color-text-muted);
}
.perm-granted { background: color-mix(in srgb, var(--color-success) 15%, transparent); color: var(--color-success); }
.perm-denied  { background: color-mix(in srgb, var(--color-danger, #e74c3c) 15%, transparent); color: var(--color-danger, #e74c3c); }
.sub-active   { background: color-mix(in srgb, var(--color-success) 15%, transparent); color: var(--color-success); }
.push-error {
  font-size: 0.82rem;
  color: var(--color-danger, #e74c3c);
  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(--color-border);
    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(--radius-sm) var(--radius-sm) 0 0;
    font-size: 0.82rem;
    padding: 0.35rem 0.7rem;
  }
  .sidebar-item.active {
    border-bottom-color: var(--color-primary);
    background: var(--color-primary-tint);
  }
  .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;
  }
  .caldav-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(--color-success); }
.text-muted { color: var(--color-text-muted); }
.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(--color-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(--color-text-muted);
  padding: 0.5rem 0.75rem;
  border-bottom: 1px solid var(--color-border);
}
.users-table td {
  padding: 0.65rem 0.75rem;
  border-bottom: 1px solid var(--color-border);
  font-size: 0.9rem;
}
.users-table tbody tr:last-child td { border-bottom: none; }
.cell-username { font-weight: 500; }
.cell-email { color: var(--color-text-secondary); }
.cell-date { color: var(--color-text-muted); 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(--radius-sm);
}
.role-admin {
  color: var(--color-primary);
  background: color-mix(in srgb, var(--color-primary) 15%, transparent);
}
.role-user {
  color: var(--color-text-muted);
  background: var(--color-bg-secondary);
}
.you-label { font-size: 0.8rem; color: var(--color-text-muted); }
/* Per-row delete (users / invitations / etc.): ghost → Oxblood on hover */
.btn-delete {
  padding: 0.25rem 0.6rem;
  background: transparent;
  color: var(--color-text-muted);
  border: 1px solid var(--color-border);
  border-radius: var(--radius-sm);
  cursor: pointer;
  font-size: 0.8rem;
}
.btn-delete:hover:not(:disabled) { border-color: var(--color-action-destructive); color: var(--color-action-destructive); }
.btn-delete:disabled { opacity: 0.4; cursor: default; }
/* Two-stage destructive: Confirm = Oxblood filled, Cancel = Bronze ghost */
.btn-confirm-delete {
  padding: 0.25rem 0.6rem;
  background: var(--color-action-destructive);
  color: #fff;
  border: none;
  border-radius: var(--radius-sm);
  cursor: pointer;
  font-size: 0.8rem;
  font-weight: 500;
  margin-right: 0.25rem;
  transition: background 0.15s;
}
.btn-confirm-delete:hover:not(:disabled) { background: var(--color-action-destructive-hover); }
.btn-confirm-delete:disabled { opacity: 0.6; cursor: default; }
.btn-cancel-delete {
  padding: 0.25rem 0.6rem;
  background: transparent;
  color: var(--color-text-muted);
  border: 1px solid var(--color-border);
  border-radius: var(--radius-sm);
  cursor: pointer;
  font-size: 0.8rem;
}
.btn-cancel-delete:hover { color: var(--color-text); border-color: var(--color-text-muted); }
/* Toggle (Open/Close registration, etc.): Open = Moss, Close = Pewter ghost */
.btn-toggle {
  padding: 0.45rem 1rem;
  border: none;
  border-radius: var(--radius-sm);
  cursor: pointer;
  font-size: 0.9rem;
  font-weight: 500;
  white-space: nowrap;
  transition: background 0.15s;
}
.btn-toggle:disabled { opacity: 0.6; cursor: default; }
.btn-toggle-open { background: var(--color-action-primary); color: #fff; }
.btn-toggle-open:hover:not(:disabled) { background: var(--color-action-primary-hover); }
.btn-toggle-close {
  background: var(--color-bg-secondary);
  color: var(--color-text);
  border: 1px solid var(--color-border);
}
.btn-toggle-close:hover:not(:disabled) { border-color: var(--color-warning); color: var(--color-warning); }
.loading-msg, .empty-msg {
  text-align: center;
  color: var(--color-text-muted);
  font-size: 0.9rem;
  padding: 1rem 0;
}

/* 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(--color-text); }
.stat-label {
  font-size: 0.75rem;
  font-weight: 500;
  text-transform: uppercase;
  letter-spacing: 0.05em;
  color: var(--color-text-muted);
}
.stat-audit { color: var(--color-primary); }
.stat-usage { color: var(--color-success); }
.stat-error { color: var(--color-danger); }

.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(--color-text-muted);
  padding: 0.5rem 0.75rem;
  border-bottom: 1px solid var(--color-border);
}
.logs-table td {
  padding: 0.5rem 0.75rem;
  border-bottom: 1px solid var(--color-border);
  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(--color-bg-secondary); }
.row-expanded { background: var(--color-bg-secondary); }
.cell-time { white-space: nowrap; color: var(--color-text-muted); font-size: 0.8rem; }
.cell-user { color: var(--color-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(--color-text-muted); font-size: 0.8rem; white-space: nowrap; }
.text-error { color: var(--color-danger); }
.detail-row td { padding: 0 0.75rem 0.75rem; border-bottom: 1px solid var(--color-border); }
.detail-ip { font-family: monospace; font-size: 0.8rem; color: var(--color-text-muted); margin-bottom: 0.4rem; }
.detail-json {
  margin: 0; padding: 0.75rem;
  background: var(--color-bg); border: 1px solid var(--color-border);
  border-radius: var(--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(--radius-sm);
}
.cat-audit { color: var(--color-primary); background: color-mix(in srgb, var(--color-primary) 15%, transparent); }
.cat-usage { color: var(--color-success); background: color-mix(in srgb, var(--color-success) 15%, transparent); }
.cat-error { color: var(--color-danger); background: color-mix(in srgb, var(--color-danger) 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(--color-bg-secondary); color: var(--color-text-muted);
  margin-right: 0.25rem;
}

/* ── About / version ─────────────────────────────────────────── */
.version-line {
  margin: 0;
  font-size: 0.9rem;
  color: var(--color-text);
  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(--color-bg-secondary);
  border: 1px solid var(--color-primary);
  border-radius: 4px;
  color: var(--color-primary);
}

/* ── Groups tab ──────────────────────────────────────────────── */
/* Moss action-primary per Hybrid */
.btn-primary {
  padding: 0.4rem 0.9rem;
  background: var(--color-action-primary);
  color: #fff;
  border: none;
  border-radius: var(--radius-sm);
  cursor: pointer;
  font-size: 0.875rem;
  font-family: inherit;
  white-space: nowrap;
  transition: background 0.15s;
}
.btn-primary:disabled { opacity: 0.6; cursor: default; }
.btn-primary:hover:not(:disabled) { background: var(--color-action-primary-hover); }

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

.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(--color-text-muted);
  font-size: 0.88rem;
  padding: 0.5rem 0;
}

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

.group-card {
  border: 1px solid var(--color-border);
  border-radius: var(--radius-md, 8px);
  overflow: hidden;
}

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

.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(--color-text);
}
.group-meta {
  font-size: 0.78rem;
  color: var(--color-text-muted);
  white-space: nowrap;
}
.group-desc {
  font-size: 0.82rem;
  color: var(--color-text-muted);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

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

.btn-sm {
  padding: 0.25rem 0.6rem;
  background: none;
  border: 1px solid var(--color-border);
  border-radius: 4px;
  font-size: 0.78rem;
  color: var(--color-text-secondary);
  cursor: pointer;
  font-family: inherit;
  transition: border-color 0.15s, color 0.15s;
}
.btn-sm:hover { border-color: var(--color-primary); color: var(--color-primary); }
.btn-danger-sm:hover { border-color: var(--color-action-destructive); color: var(--color-action-destructive); }

.group-members-panel {
  padding: 0.75rem 1rem 1rem;
  border-top: 1px solid var(--color-border);
  background: var(--color-surface);
}

.members-search {
  display: flex;
  gap: 0.5rem;
  align-items: flex-start;
  margin-bottom: 0.75rem;
}

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

.member-results {
  position: absolute;
  top: 100%;
  left: 0;
  right: 0;
  background: var(--color-surface);
  border: 1px solid var(--color-border);
  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(--color-hover); }
.member-result-name { font-weight: 500; font-size: 0.85rem; }
.member-result-email { color: var(--color-text-muted); font-size: 0.78rem; }

.role-select {
  padding: 0.4rem 0.5rem;
  border: 1px solid var(--color-border);
  border-radius: 6px;
  background: var(--color-surface);
  color: var(--color-text);
  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(--color-hover);
}

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

.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(--color-warning, #f59e0b) 15%, transparent); color: var(--color-warning, #f59e0b); }
.role-member { background: color-mix(in srgb, var(--color-muted) 15%, transparent); color: var(--color-muted); }

.members-empty {
  color: var(--color-text-muted);
  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(--color-success); }
.geo-error { color: var(--color-danger); }
.geo-pending { color: var(--color-text-muted); }

.unit-toggle {
  display: flex;
  gap: 0;
  border: 1px solid var(--color-border);
  border-radius: var(--radius-sm);
  overflow: hidden;
  width: fit-content;
  margin-top: 0.25rem;
}
.unit-btn {
  padding: 0.4rem 1rem;
  background: var(--color-bg-card);
  color: var(--color-text-muted);
  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(--color-border);
}
.unit-btn.active {
  background: var(--color-action-primary);
  color: #fff;
}
.unit-btn:hover:not(.active) {
  color: var(--color-text);
}

.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(--color-text-muted);
}
.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(--color-primary) 8%, var(--color-surface));
  border: 1px solid color-mix(in srgb, var(--color-primary) 30%, transparent);
  border-radius: var(--radius-md);
  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(--color-surface-2, var(--color-surface));
  padding: 0.4rem 0.6rem;
  border-radius: var(--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(--color-border);
  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; }

/* Fable 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(--color-primary) 8%, transparent);
  border: 1px solid color-mix(in srgb, var(--color-primary) 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(--color-text) 6%, transparent);
  border: 1px solid var(--color-border);
  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(--color-border);
}
.mcp-client-tab {
  background: transparent;
  border: none;
  padding: 0.5rem 0.9rem;
  font-size: 0.85rem;
  color: var(--color-text-muted);
  cursor: pointer;
  border-bottom: 2px solid transparent;
  margin-bottom: -1px;
  transition: color 0.15s, border-color 0.15s;
}
.mcp-client-tab:hover { color: var(--color-text); }
.mcp-client-tab.active {
  color: var(--color-primary);
  border-bottom-color: var(--color-primary);
  font-weight: 500;
}
.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-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(--color-border);
  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(--color-border);
}
.voice-library-meta {
  min-width: 0;
  flex: 1;
}
.voice-library-id {
  font-family: var(--font-mono, monospace);
  font-size: 0.9rem;
  font-weight: 500;
}
.voice-library-sub {
  color: var(--color-text-muted);
  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(--color-text-muted);
  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(--color-text-muted) 10%, transparent);
  color: var(--color-text-muted);
  border: 1px solid var(--color-border);
}
.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(--color-success, #22c55e) 15%, transparent);
  color: var(--color-success, #22c55e);
  border: 1px solid color-mix(in srgb, var(--color-success, #22c55e) 40%, transparent);
}
.status-off {
  background: color-mix(in srgb, var(--color-text-muted) 10%, transparent);
  color: var(--color-text-muted);
  border: 1px solid var(--color-border);
}
.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(--color-primary);
  margin: 0.35rem 0 0.2rem;
}
.range-labels {
  display: flex;
  justify-content: space-between;
  max-width: 360px;
  font-size: 0.75rem;
  color: var(--color-text-muted);
}
.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(--color-text-muted);
  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(--color-surface) 60%, transparent);
  border: 1px solid var(--color-border);
  border-radius: var(--radius-md);
  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(--color-text-secondary);
}
.btn-remove-slot {
  background: none;
  border: 1px solid var(--color-border);
  border-radius: var(--radius-sm);
  color: var(--color-text-muted);
  cursor: pointer;
  font-size: 0.75rem;
  padding: 0.2rem 0.45rem;
  line-height: 1;
  transition: color 0.15s, border-color 0.15s;
}
.btn-remove-slot:hover {
  color: var(--color-danger, #e05555);
  border-color: var(--color-danger, #e05555);
}
.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(--color-border);
  border-radius: 6px;
  background: var(--color-bg-card);
  color: var(--color-text-muted);
  font-size: 0.82rem;
  cursor: pointer;
  transition: all 0.15s;
  font-family: inherit;
}
.day-btn:hover { border-color: var(--color-primary); color: var(--color-primary); }
.day-btn.active {
  background: color-mix(in srgb, var(--color-primary) 15%, transparent);
  border-color: var(--color-primary);
  color: var(--color-primary);
  font-weight: 500;
}
.learned-summary {
  background: var(--color-bg-secondary);
  border: 1px solid var(--color-border);
  border-left: 3px solid var(--color-primary);
  border-radius: 8px;
  padding: 0.85rem 1rem;
  font-size: 0.9rem;
  line-height: 1.55;
  color: var(--color-text);
  white-space: pre-wrap;
  margin-bottom: 0.75rem;
}
.learned-empty {
  font-size: 0.85rem;
  color: var(--color-text-muted);
  margin-bottom: 0.75rem;
}
.btn-danger-outline {
  padding: 0.45rem 1rem;
  background: none;
  border: 1px solid var(--color-action-destructive);
  border-radius: var(--radius-sm);
  color: var(--color-action-destructive);
  font-size: 0.9rem;
  cursor: pointer;
  font-family: inherit;
  transition: background 0.15s, color 0.15s;
}
.btn-danger-outline:hover:not(:disabled) {
  background: var(--color-action-destructive);
  color: #fff;
}
.btn-danger-outline:disabled { opacity: 0.5; cursor: default; }
@keyframes va-dot-bounce {
  0%, 80%, 100% { transform: scale(0.6); opacity: 0.4; }
  40% { transform: scale(1); opacity: 1; }
}
</style>
