// Settings.tsx — ported verbatim from design_handoff_openless/pages.jsx::Settings.
// Section 拆分见 settings/ 子目录；本文件保留 dispatcher + RecordingSection + ProvidersSection（含其内嵌助手），
// 其他 section 已挪出。原导出 Toggle / AboutUpdateControl / SettingsSectionId 通过 re-export 维持向后兼容。

import { useCallback, useEffect, useLayoutEffect, useRef, useState, type CSSProperties, type ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { Icon } from '../components/Icon';
import { ShortcutRecorder } from '../components/ShortcutRecorder';
import { detectOS } from '../components/WindowChrome';
import { isHotkeyModeMigrationNoticeActive } from '../lib/hotkeyMigration';
import {
  getHotkeyBindingCodes,
  getHotkeyBindingLabel,
  getHotkeyCodeLabel,
} from '../lib/hotkey';
import { createHotkeyRecorderState, orderHotkeyCodes, updateHotkeyRecorderState } from '../lib/hotkeyRecorder';
import {
  isTauri,
  listMicrophoneDevices,
  openExternal,
  listProviderModels,
  readCredential,
  setActiveAsrProvider,
  setActiveLlmProvider,
  setCredential,
  setDictationHotkey,
  startMicrophoneLevelMonitor,
  stopMicrophoneLevelMonitor,
  validateProviderCredentials,
} from '../lib/ipc';
import type {
  HotkeyBinding,
  HotkeyMode,
  HotkeyTrigger,
  MicrophoneDevice,
  PasteShortcut,
} from '../lib/types';
import { emitSaved } from '../lib/savedEvent';
import { useHotkeySettings } from '../state/HotkeySettingsContext';
import { SelectLite } from '../components/ui/SelectLite';
import { Btn, Card, Collapsible, PageHeader, Pill } from './_atoms';
import {
  deleteLocalAsrModel,
  getLocalAsrSettings,
  listLocalAsrModels,
  type LocalAsrModelStatus,
  type LocalAsrSettings,
} from '../lib/localAsr';
import { SettingRow, Toggle, inputStyle, SectionTitle, SectionDesc, type AsrPresetId } from './settings/shared';
import { AdvancedSection } from './settings/AdvancedSection';
import { ShortcutsSection } from './settings/ShortcutsSection';
import { PermissionsSection } from './settings/PermissionsSection';
import { LanguageSection } from './settings/LanguageSection';

export { Toggle } from './settings/shared';
export { AboutUpdateControl } from './settings/AboutUpdateControl';

/// Settings → ASR 选了 local-qwen3 时触发跳到「模型设置」页 + 关 Settings modal。
/// FloatingShell 监听同名事件做 setCurrentTab('localAsr') + setSettingsOpen(false)。
export const NAVIGATE_LOCAL_ASR_EVENT = 'openless:navigate-local-asr';

interface SettingsProps {
  embedded?: boolean;
  initialSection?: SettingsSectionId;
}
// "关于" tab 已移除（内容并入外层 SettingsModal 的 About 页，避免设置内外重复入口）。
export type SettingsSectionId = 'recording' | 'providers' | 'shortcuts' | 'permissions' | 'language' | 'advanced';

// 「高级」放最末——本地推理 / 实验性开关都集中到这一栏，避免新手用户在主流程
// 里误开 CPU 推理（之前提案：把 local-qwen3 / foundry-local-whisper 从主 ASR
// 下拉藏进高级）。位置末尾也是「实验性」语义在 macOS 系统偏好里的惯用位置。
const SECTION_ORDER: SettingsSectionId[] = ['recording', 'providers', 'shortcuts', 'permissions', 'language', 'advanced'];

async function autostartIsEnabled(): Promise<boolean> {
  const { invoke } = await import('@tauri-apps/api/core');
  return invoke<boolean>('plugin:autostart|is_enabled');
}

async function autostartEnable(): Promise<void> {
  const { invoke } = await import('@tauri-apps/api/core');
  await invoke('plugin:autostart|enable');
}

async function autostartDisable(): Promise<void> {
  const { invoke } = await import('@tauri-apps/api/core');
  await invoke('plugin:autostart|disable');
}

export function Settings({ embedded = false, initialSection = 'recording' }: SettingsProps) {
  const { t } = useTranslation();
  const [section, setSection] = useState<SettingsSectionId>(initialSection);

  useEffect(() => {
    setSection(initialSection);
  }, [initialSection]);

  // 跟 sidebar / SettingsModal 同款滑动 pill：测当前 active section 的 offsetTop/height
  // → 用 absolute pill 平滑滑过去；--ol-motion-spring 是项目里的 Apple 风格 ease-out-quint。
  const sectionRefs = useRef<Array<HTMLButtonElement | null>>([]);
  const [pillRect, setPillRect] = useState<{ top: number; height: number } | null>(null);
  useLayoutEffect(() => {
    const idx = SECTION_ORDER.indexOf(section);
    const el = sectionRefs.current[idx];
    if (!el) return;
    setPillRect({ top: el.offsetTop, height: el.offsetHeight });
  }, [section]);

  return (
    <>
      {!embedded && (
        <PageHeader
          kicker={t('settings.kicker')}
          title={t('settings.title')}
        />
      )}
      {/* embedded（在 SettingsModal 里）模式下：mini-sidebar 固定，仅右栏 scroll。
          外层 flex:1 minHeight:0 让 grid 拿到确定高度；gridTemplateRows: minmax(0, 1fr)
          强制行高等于容器高度，否则 grid 默认 auto rows 会跟内容长，右栏 overflow:auto
          就退化成"没东西需要 scroll"，于是大家照旧一起飘。 */}
      <div
        style={{
          display: 'grid',
          gridTemplateColumns: embedded ? '120px 1fr' : '160px 1fr',
          gap: 18,
          ...(embedded ? { flex: 1, minHeight: 0, gridTemplateRows: 'minmax(0, 1fr)' } : {}),
        }}
      >
        <div style={{ position: 'relative', display: 'flex', flexDirection: 'column', gap: 2 }}>
          {pillRect && (
            <div
              aria-hidden
              style={{
                position: 'absolute',
                left: 0,
                right: 0,
                top: pillRect.top,
                height: pillRect.height,
                background: 'rgba(0,0,0,0.04)',
                borderRadius: 8,
                transition: 'top 0.36s var(--ol-motion-spring), height 0.36s var(--ol-motion-spring)',
                pointerEvents: 'none',
                zIndex: 0,
              }}
            />
          )}
          {SECTION_ORDER.map((s, i) => {
            const active = section === s;
            return (
              <button
                key={s}
                ref={el => { sectionRefs.current[i] = el; }}
                onClick={() => setSection(s)}
                className={active ? 'ol-nav-btn ol-nav-btn-active' : 'ol-nav-btn'}
                style={{
                  padding: '8px 12px', textAlign: 'left',
                  fontSize: 13,
                  background: 'transparent',
                  border: 0, borderRadius: 8, fontFamily: 'inherit',
                  cursor: 'default',
                  position: 'relative',
                  zIndex: 1,
                  transition: 'color 0.16s var(--ol-motion-quick), background 0.16s var(--ol-motion-quick)',
                }}
              >
                {t(`settings.sections.${s}`)}
              </button>
            );
          })}
        </div>
        <div
          className={embedded ? 'ol-thinscroll' : undefined}
          style={{
            display: 'flex',
            flexDirection: 'column',
            gap: 12,
            // paddingBottom: 滚到底时让最后一张 Card / Collapsible 的 border + box-shadow
            // 不被滚动容器底边吃掉。16 跟 var(--ol-shadow-sm) 的扩散距离 + Card chrome 留白
            // 匹配，视觉上跟顶部 toolbar 的呼吸感对齐。
            ...(embedded ? { minHeight: 0, overflow: 'auto', paddingRight: 4, paddingBottom: 16 } : {}),
          }}
        >
          {section === 'recording' && <RecordingSection />}
          {section === 'providers' && <ProvidersSection />}
          {section === 'shortcuts' && <ShortcutsSection />}
          {section === 'permissions' && <PermissionsSection />}
          {section === 'language' && <LanguageSection />}
          {section === 'advanced' && <AdvancedSection />}
        </div>
      </div>
    </>
  );
}

function RecordingSection() {
  const { t } = useTranslation();
  const { prefs, capability, updatePrefs: savePrefs } = useHotkeySettings();
  const [microphoneDevices, setMicrophoneDevices] = useState<MicrophoneDevice[]>([]);
  const [microphoneDevicesLoaded, setMicrophoneDevicesLoaded] = useState(false);
  const [microphoneDevicesError, setMicrophoneDevicesError] = useState<string | null>(null);
  const [microphonePickerOpen, setMicrophonePickerOpen] = useState(false);

  const loadMicrophoneDevices = useCallback(async (
    signal?: { cancelled: boolean },
    options: { showLoading?: boolean } = {},
  ) => {
    if (options.showLoading ?? true) {
      setMicrophoneDevicesLoaded(false);
    }
    setMicrophoneDevicesError(null);
    try {
      const devices = await listMicrophoneDevices();
      if (signal?.cancelled) return;
      setMicrophoneDevices(devices);
      setMicrophoneDevicesLoaded(true);
    } catch (err) {
      console.error('[settings] list microphone devices failed', err);
      if (signal?.cancelled) return;
      setMicrophoneDevices([]);
      setMicrophoneDevicesError(err instanceof Error ? err.message : String(err));
      setMicrophoneDevicesLoaded(true);
    }
  }, []);

  useEffect(() => {
    const signal = { cancelled: false };
    void loadMicrophoneDevices(signal);
    return () => {
      signal.cancelled = true;
    };
  }, [loadMicrophoneDevices]);

  useEffect(() => {
    if (!isTauri) return;
    let cancelled = false;
    let unlisten: (() => void) | undefined;
    async function listenForDeviceChanges() {
      const { listen } = await import('@tauri-apps/api/event');
      if (cancelled) return;
      const stopListening = await listen('microphone:devices-changed', () => {
        void loadMicrophoneDevices(undefined, { showLoading: false });
      });
      if (cancelled) {
        stopListening();
        return;
      }
      unlisten = stopListening;
    }
    void listenForDeviceChanges();
    return () => {
      cancelled = true;
      unlisten?.();
    };
  }, [loadMicrophoneDevices]);

  useEffect(() => {
    if (microphonePickerOpen) {
      void loadMicrophoneDevices(undefined, { showLoading: false });
    }
  }, [loadMicrophoneDevices, microphonePickerOpen]);

  if (!prefs || !capability) {
    return (
      <Card>
        <div style={{ fontSize: 12, color: 'var(--ol-ink-4)' }}>{t('common.loading')}</div>
      </Card>
    );
  }

  const onModeChange = (mode: HotkeyMode) =>
    savePrefs({ ...prefs, hotkey: { ...prefs.hotkey, mode } });
  const onShowCapsuleChange = (showCapsule: boolean) =>
    savePrefs({ ...prefs, showCapsule });
  const onMuteDuringRecordingChange = (muteDuringRecording: boolean) =>
    savePrefs({ ...prefs, muteDuringRecording });
  const onMicrophoneDeviceChange = (microphoneDeviceName: string) =>
    savePrefs({ ...prefs, microphoneDeviceName });
  const onRestoreClipboardChange = (restoreClipboardAfterPaste: boolean) =>
    savePrefs({ ...prefs, restoreClipboardAfterPaste });
  const onPasteShortcutChange = (pasteShortcut: PasteShortcut) =>
    savePrefs({ ...prefs, pasteShortcut });
  const onAllowNonTsfFallbackChange = (allowNonTsfInsertionFallback: boolean) =>
    savePrefs({ ...prefs, allowNonTsfInsertionFallback });
  // 历史保留 / 对话感知 polish 上下文窗口都用裸 number input；空字符串时回滚到默认值。
  // 范围限制：retention 0-365 天，context window 0-60 分钟（再大的值对实际对话场景没意义且白烧 token）。
  const clamp = (n: number, min: number, max: number) => Math.max(min, Math.min(max, n));
  const onHistoryRetentionChange = (raw: string) => {
    const parsed = raw === '' ? 0 : Number.parseInt(raw, 10);
    if (Number.isNaN(parsed)) return;
    void savePrefs({ ...prefs, historyRetentionDays: clamp(parsed, 0, 365) });
  };
  const onPolishContextWindowChange = (raw: string) => {
    const parsed = raw === '' ? 0 : Number.parseInt(raw, 10);
    if (Number.isNaN(parsed)) return;
    void savePrefs({ ...prefs, polishContextWindowMinutes: clamp(parsed, 0, 60) });
  };
  const onStartMinimizedChange = (startMinimized: boolean) =>
    savePrefs({ ...prefs, startMinimized });
  const onAutoUpdateCheckChange = (autoUpdateCheck: boolean) =>
    savePrefs({ ...prefs, autoUpdateCheck });
  const onMarketplaceDevLoginChange = (marketplaceDevLogin: string) =>
    savePrefs({ ...prefs, marketplaceDevLogin });
  const onRecordAudioForDebugChange = (recordAudioForDebug: boolean) =>
    savePrefs({ ...prefs, recordAudioForDebug });
  // 历史条数 200 是当前 HISTORY_CAP（persistence.rs:32），下限 5 是避免用户填 0 导致
  // 写一条就立刻被清光；空字符串视为不限制，落回 null → 后端走 200 默认。
  const onHistoryMaxEntriesChange = (raw: string) => {
    const trimmed = raw.trim();
    if (trimmed === '') {
      void savePrefs({ ...prefs, historyMaxEntries: null });
      return;
    }
    const parsed = Number.parseInt(trimmed, 10);
    if (Number.isNaN(parsed)) return;
    void savePrefs({ ...prefs, historyMaxEntries: clamp(parsed, 5, 200) });
  };
  const onAudioRecordingMaxEntriesChange = (raw: string) => {
    const trimmed = raw.trim();
    if (trimmed === '') {
      void savePrefs({ ...prefs, audioRecordingMaxEntries: null });
      return;
    }
    const parsed = Number.parseInt(trimmed, 10);
    if (Number.isNaN(parsed)) return;
    void savePrefs({ ...prefs, audioRecordingMaxEntries: clamp(parsed, 1, 200) });
  };

  const choices: Array<[HotkeyMode, string]> = [
    ['toggle', t('settings.recording.modeToggle')],
    ['hold', t('settings.recording.modeHold')],
  ];
  const hotkeyDesc = capability.requiresAccessibilityPermission
    ? t('settings.recording.hotkeyDescAcc')
    : t('settings.recording.hotkeyDescNoAcc');
  const preferredMicrophoneAvailable = Boolean(
    prefs.microphoneDeviceName
    && microphoneDevices.some(device => device.name === prefs.microphoneDeviceName),
  );
  const effectiveMicrophoneDeviceName = prefs.microphoneDeviceName
    && (!microphoneDevicesLoaded || preferredMicrophoneAvailable)
    ? prefs.microphoneDeviceName
    : '';
  const selectedMicrophoneLabel = effectiveMicrophoneDeviceName
    ? effectiveMicrophoneDeviceName
    : t('settings.recording.microphoneDefault');

  return (
    <>
    <Card>
      <SectionTitle>{t('settings.recording.title')}</SectionTitle>
      {isHotkeyModeMigrationNoticeActive() && (
        <div
          style={{
            marginTop: 10,
            marginBottom: 8,
            padding: '12px 14px',
            borderRadius: 10,
            background: 'rgba(37,99,235,0.08)',
            border: '0.5px solid rgba(37,99,235,0.18)',
          }}
        >
          <div style={{ fontSize: 12.5, fontWeight: 600, color: 'var(--ol-blue)', marginBottom: 4 }}>
            {t('settings.recording.migrationNoticeTitle')}
          </div>
          <div style={{ fontSize: 11.5, color: 'var(--ol-ink-3)', lineHeight: 1.55 }}>
                        {t('settings.recording.migrationNoticeDesc')}
          </div>
        </div>
      )}
      <SettingRow label={t('settings.recording.hotkeyLabel')}>
        <ShortcutRecorder
          value={prefs.dictationHotkey}
          onSave={async binding => {
            await setDictationHotkey(binding);
            await savePrefs({ ...prefs, dictationHotkey: binding });
          }}
        />
      </SettingRow>
      <SettingRow label={t('settings.recording.modeLabel')}>
        <div style={{ display: 'inline-flex', padding: 2, borderRadius: 8, background: 'rgba(0,0,0,0.05)' }}>
          {choices.map(([v, l]) => (
            <button
              key={v}
              onClick={() => onModeChange(v)}
              style={{
                padding: '5px 14px', fontSize: 12, fontWeight: 500,
                border: 0, borderRadius: 6, fontFamily: 'inherit',
                background: prefs.hotkey.mode === v ? '#fff' : 'transparent',
                color: prefs.hotkey.mode === v ? 'var(--ol-ink)' : 'var(--ol-ink-3)',
                boxShadow: prefs.hotkey.mode === v ? '0 1px 2px rgba(0,0,0,.08)' : 'none',
                cursor: 'default',
                transition: 'background 0.16s var(--ol-motion-quick), color 0.16s var(--ol-motion-quick), box-shadow 0.18s var(--ol-motion-soft)',
              }}
            >
              {l}
            </button>
          ))}
        </div>
      </SettingRow>
      <SettingRow label={t('settings.recording.microphoneLabel')}>
        <div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
          <button
            type="button"
            aria-label={t('settings.recording.microphoneLabel')}
            onClick={() => {
              setMicrophonePickerOpen(true);
            }}
            onKeyDown={e => {
              if (e.key === 'Enter' || e.key === ' ') {
                e.preventDefault();
                setMicrophonePickerOpen(true);
              }
            }}
            onChange={() => {}}
            style={{
              ...inputStyle,
              flex: '0 0 auto',
              width: 200,
              maxWidth: 200,
              height: 32,
              minWidth: 0,
              alignSelf: 'flex-start',
              padding: '0 9px 0 10px',
              display: 'flex',
              alignItems: 'center',
              justifyContent: 'space-between',
              gap: 8,
              textAlign: 'left',
              color: 'var(--ol-ink)',
            }}
          >
            <span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
              {selectedMicrophoneLabel}
            </span>
            <Icon name="chevRight" size={13} />
          </button>
          {!microphoneDevicesLoaded && (
            <div style={{ fontSize: 11, color: 'var(--ol-ink-4)' }}>{t('common.loading')}</div>
          )}
          {microphoneDevicesError && (
            <div style={{ fontSize: 11, color: 'var(--ol-err)', lineHeight: 1.5 }}>
              {t('settings.recording.microphoneLoadError', { message: microphoneDevicesError })}
            </div>
          )}
        </div>
      </SettingRow>
      {microphonePickerOpen && (
        <MicrophonePickerDialog
          devices={microphoneDevices}
          selectedName={effectiveMicrophoneDeviceName}
          onClose={() => setMicrophonePickerOpen(false)}
          onRefresh={() => {
            void loadMicrophoneDevices();
          }}
          loading={!microphoneDevicesLoaded}
          onSelect={(name) => {
            onMicrophoneDeviceChange(name);
          }}
        />
      )}
      <SettingRow label={t('settings.recording.capsuleLabel')}>
        <Toggle on={prefs.showCapsule} onToggle={onShowCapsuleChange} />
      </SettingRow>
      <SettingRow
        label={t('settings.recording.muteDuringRecordingLabel')}
      >
        <Toggle on={prefs.muteDuringRecording} onToggle={onMuteDuringRecordingChange} />
      </SettingRow>
    </Card>

    {/* ─── 插入与剪贴板（折叠） ──────────────────────────────────── */}
    <Collapsible title={t('settings.recording.insertGroupTitle')}>
      <SettingRow
        label={t('settings.recording.restoreClipboardLabel')}
      >
        <Toggle on={prefs.restoreClipboardAfterPaste} onToggle={onRestoreClipboardChange} />
      </SettingRow>
      {capability.adapter !== 'macEventTap' && (
        <SettingRow
          label={t('settings.recording.pasteShortcutLabel')}
        >
          <SelectLite
            value={prefs.pasteShortcut}
            onChange={next => onPasteShortcutChange(next as PasteShortcut)}
            options={[
              { value: 'ctrlV', label: t('settings.recording.pasteShortcutCtrlV') },
              { value: 'ctrlShiftV', label: t('settings.recording.pasteShortcutCtrlShiftV') },
              { value: 'shiftInsert', label: t('settings.recording.pasteShortcutShiftInsert') },
            ]}
            ariaLabel={t('settings.recording.pasteShortcutLabel')}
            style={{ ...inputStyle, maxWidth: 220 }}
          />
        </SettingRow>
      )}
      {capability.adapter === 'windowsLowLevel' && (
        <SettingRow
          label={t('settings.recording.allowNonTsfFallbackLabel')}
        >
          <Toggle
            on={prefs.allowNonTsfInsertionFallback}
            onToggle={onAllowNonTsfFallbackChange}
          />
        </SettingRow>
      )}
    </Collapsible>

    {/* ─── 历史与上下文（折叠） ────────────────────────────────── */}
    <Collapsible title={t('settings.recording.historyGroupTitle')}>
      <SettingRow
        label={t('settings.recording.historyRetentionLabel')}
      >
        <input
          type="number"
          min={0}
          max={365}
          value={prefs.historyRetentionDays}
          onChange={e => onHistoryRetentionChange(e.target.value)}
          style={{ ...inputStyle, width: 80, textAlign: 'right' }}
        />
      </SettingRow>
      <SettingRow
        label={t('settings.recording.historyMaxEntriesLabel')}
      >
        <input
          type="number"
          min={5}
          max={200}
          placeholder="200"
          value={prefs.historyMaxEntries ?? ''}
          onChange={e => onHistoryMaxEntriesChange(e.target.value)}
          style={{ ...inputStyle, width: 80, textAlign: 'right' }}
        />
      </SettingRow>
      <SettingRow
        label={t('settings.recording.polishContextWindowLabel')}
      >
        <input
          type="number"
          min={0}
          max={60}
          value={prefs.polishContextWindowMinutes}
          onChange={e => onPolishContextWindowChange(e.target.value)}
          style={{ ...inputStyle, width: 80, textAlign: 'right' }}
        />
      </SettingRow>
      <SettingRow
        label={t('settings.recording.recordAudioForDebugLabel')}
      >
        <Toggle on={prefs.recordAudioForDebug} onToggle={onRecordAudioForDebugChange} />
      </SettingRow>
      <SettingRow
        label={t('settings.recording.audioRecordingMaxEntriesLabel')}
      >
        <input
          type="number"
          min={1}
          max={200}
          placeholder="200"
          value={prefs.audioRecordingMaxEntries ?? ''}
          onChange={e => onAudioRecordingMaxEntriesChange(e.target.value)}
          style={{ ...inputStyle, width: 80, textAlign: 'right' }}
          disabled={!prefs.recordAudioForDebug}
        />
      </SettingRow>
    </Collapsible>

    {/* ─── 启动（折叠） ──────────────────────────────────────────── */}
    <Collapsible title={t('settings.recording.startupGroupTitle')}>
      <AutostartRow />
      <SettingRow
        label={t('settings.recording.startMinimizedLabel')}
      >
        <Toggle on={prefs.startMinimized} onToggle={onStartMinimizedChange} />
      </SettingRow>
      <SettingRow
        label={t('settings.recording.autoUpdateCheckLabel')}
      >
        <Toggle on={prefs.autoUpdateCheck} onToggle={onAutoUpdateCheckChange} />
      </SettingRow>
      {capability.statusHint && (
        <div style={{ marginTop: 6, fontSize: 11.5, color: 'var(--ol-ink-4)', lineHeight: 1.5 }}>
          {capability.statusHint}
        </div>
      )}
    </Collapsible>

    {/* ─── 风格市场（折叠） ────────────────────────────────────────── */}
    {/* URL 已硬编码到云端 apic.openless.top（commands.rs:MARKETPLACE_BASE_URL），
        Settings 不再提供输入，避免用户改错导致连不上。GitHub OAuth 上线后这里会接登录按钮。 */}
    <Collapsible title={t('settings.recording.marketplaceGroupTitle')}>
      <SettingRow
        label={t('settings.recording.marketplaceDevLoginLabel')}
      >
        <input
          type="text"
          placeholder="your-github-login"
          value={prefs.marketplaceDevLogin}
          onChange={e => onMarketplaceDevLoginChange(e.target.value)}
          style={{ ...inputStyle, width: 180 }}
        />
      </SettingRow>
    </Collapsible>
    </>
  );
}

function HotkeyRecorder({
  binding,
  onCommit,
}: {
  binding: HotkeyBinding;
  onCommit: (codes: string[]) => void;
}) {
  const { t } = useTranslation();
  const [recording, setRecording] = useState(false);
  const [draftCodes, setDraftCodes] = useState<string[]>([]);
  const recorderStateRef = useRef(createHotkeyRecorderState());
  const recordingRef = useRef(false);

  const resetRecording = () => {
    recordingRef.current = false;
    recorderStateRef.current = createHotkeyRecorderState();
    setDraftCodes([]);
    setRecording(false);
  };

  const commitCodes = (codes: string[]) => {
    const ordered = orderHotkeyCodes(codes);
    resetRecording();
    onCommit(ordered);
  };

  const startRecording = () => {
    recordingRef.current = true;
    recorderStateRef.current = createHotkeyRecorderState();
    setDraftCodes([]);
    setRecording(true);
  };

  useEffect(() => {
    if (!recording) return undefined;

    const stopEvent = (event: Event) => {
      event.preventDefault();
      event.stopPropagation();
    };

    const applyHotkeyCode = (code: string, pressed: boolean) => {
      if (!recordingRef.current) return;
      const next = updateHotkeyRecorderState(recorderStateRef.current, code, pressed);
      recorderStateRef.current = next.state;
      setDraftCodes(next.state.draftCodes);
      if (next.commitCodes) commitCodes(next.commitCodes);
    };

    const onKeyDown = (event: KeyboardEvent) => {
      stopEvent(event);
      if (event.key === 'Escape' || event.code === 'Escape') {
        resetRecording();
        return;
      }
      const code = normalizeKeyboardHotkeyCode(event);
      if (!code) return;
      applyHotkeyCode(code, true);
    };

    const onKeyUp = (event: KeyboardEvent) => {
      stopEvent(event);
      if (!recordingRef.current) return;
      if (event.key === 'Escape' || event.code === 'Escape') {
        resetRecording();
        return;
      }
      const code = normalizeKeyboardHotkeyCode(event);
      if (!code) return;
      applyHotkeyCode(code, false);
    };

    const onMouseDown = (event: MouseEvent) => {
      const code = mouseButtonToHotkeyCode(event.button);
      if (!code) return;
      stopEvent(event);
      applyHotkeyCode(code, true);
    };

    const onMouseUp = (event: MouseEvent) => {
      const code = mouseButtonToHotkeyCode(event.button);
      if (!code) return;
      stopEvent(event);
      applyHotkeyCode(code, false);
    };

    window.addEventListener('keydown', onKeyDown, true);
    window.addEventListener('keyup', onKeyUp, true);
    window.addEventListener('mousedown', onMouseDown, true);
    window.addEventListener('mouseup', onMouseUp, true);
    return () => {
      window.removeEventListener('keydown', onKeyDown, true);
      window.removeEventListener('keyup', onKeyUp, true);
      window.removeEventListener('mousedown', onMouseDown, true);
      window.removeEventListener('mouseup', onMouseUp, true);
    };
  }, [recording]);

  const label = recording
    ? draftCodes.length > 0
      ? draftCodes.map(getHotkeyCodeLabel).join('+')
      : t('settings.recording.hotkeyRecording')
    : getHotkeyBindingLabel(binding);
  const hasKeys = getHotkeyBindingCodes(binding).length > 0;

  return (
    <div style={{ display: 'inline-flex', alignItems: 'center', gap: 8 }}>
      <button
        type="button"
        onClick={startRecording}
        style={{
          ...hotkeyRecorderButtonStyle,
          borderColor: recording ? 'var(--ol-blue)' : 'var(--ol-line-strong)',
          color: recording ? 'var(--ol-blue)' : 'var(--ol-ink)',
        }}
      >
        <span style={hotkeyRecorderLabelStyle}>{label}</span>
        {!recording && hasKeys && (
          <span
            role="button"
            tabIndex={0}
            aria-label={t('settings.recording.hotkeyClear')}
            onClick={event => {
              event.stopPropagation();
              onCommit([]);
            }}
            onKeyDown={event => {
              if (event.key === 'Enter' || event.key === ' ') {
                event.preventDefault();
                event.stopPropagation();
                onCommit([]);
              }
            }}
            style={hotkeyClearButtonStyle}
          >
            <Icon name="x" size={11} strokeWidth={2} />
          </span>
        )}
      </button>
    </div>
  );
}

function MicrophonePickerDialog({
  devices,
  selectedName,
  onClose,
  onRefresh,
  loading,
  onSelect,
}: {
  devices: MicrophoneDevice[];
  selectedName: string;
  onClose: () => void;
  onRefresh: () => void;
  loading: boolean;
  onSelect: (name: string) => void;
}) {
  const { t } = useTranslation();
  const [pickedName, setPickedName] = useState(selectedName);
  const [previewName, setPreviewName] = useState(selectedName);
  const [level, setLevel] = useState(0);
  const [hoveredName, setHoveredName] = useState<string | null>(null);
  const [pressedName, setPressedName] = useState<string | null>(null);
  const [monitorError, setMonitorError] = useState<string | null>(null);
  const monitorQueueRef = useRef<Promise<void>>(Promise.resolve());

  const enqueueMonitorTask = useCallback((task: () => Promise<void>) => {
    const next = monitorQueueRef.current.catch(() => undefined).then(task);
    monitorQueueRef.current = next.catch(() => undefined);
    return next;
  }, []);

  useEffect(() => {
    setPickedName(selectedName);
    setPreviewName(selectedName);
  }, [selectedName]);

  useEffect(() => {
    let unlisten: (() => void) | undefined;
    let cancelled = false;
    let timer: number | undefined;
    setLevel(0);
    setMonitorError(null);

    async function start() {
      await enqueueMonitorTask(async () => {
        try {
          if (isTauri) {
            const { listen } = await import('@tauri-apps/api/event');
            if (cancelled) return;
            const stopListening = await listen<{ level: number }>('microphone:level', event => {
              setLevel(Math.max(0, Math.min(1, event.payload.level ?? 0)));
            });
            if (cancelled) {
              stopListening();
              return;
            }
            unlisten = stopListening;
            await startMicrophoneLevelMonitor(previewName);
            if (cancelled) {
              unlisten?.();
              unlisten = undefined;
              await stopMicrophoneLevelMonitor();
            }
          } else {
            const tick = window.setInterval(() => {
              setLevel(0.25 + Math.random() * 0.55);
            }, 120);
            if (cancelled) {
              window.clearInterval(tick);
              return;
            }
            unlisten = () => window.clearInterval(tick);
          }
        } catch (err) {
          console.warn('[settings] microphone level monitor failed', err);
          if (!cancelled) {
            setMonitorError(err instanceof Error ? err.message : String(err));
          }
        }
      });
    }

    timer = window.setTimeout(() => {
      void start();
    }, 140);
    return () => {
      cancelled = true;
      if (timer !== undefined) {
        window.clearTimeout(timer);
      }
      void enqueueMonitorTask(async () => {
        unlisten?.();
        unlisten = undefined;
        await stopMicrophoneLevelMonitor();
      });
    };
  }, [enqueueMonitorTask, previewName]);

  const rows = [
    {
      id: 'default',
      name: '',
      label: t('settings.recording.microphoneDefault'),
      desc: t('settings.recording.microphoneDefaultDesc'),
      isDefault: false,
    },
    ...devices.map((device, index) => ({
      id: `${device.name}-${index}`,
      name: device.name,
      label: device.name,
      desc: device.isDefault ? t('settings.recording.microphoneSystemDefault') : '',
      isDefault: device.isDefault,
    })),
  ];

  return (
    <div
      role="presentation"
      onClick={onClose}
      style={{
        position: 'fixed',
        inset: 0,
        zIndex: 40,
        display: 'grid',
        placeItems: 'center',
        background: 'rgba(0,0,0,0.32)',
        animation: 'olMicPickerFadeIn 120ms ease-out',
      }}
    >
      <div
        role="dialog"
        aria-modal="true"
        onClick={e => e.stopPropagation()}
        style={{
          width: 450,
          maxWidth: 'calc(100vw - 48px)',
          borderRadius: 16,
          background: 'rgba(255,255,255,0.96)',
          border: '0.5px solid rgba(0,0,0,0.12)',
          boxShadow: '0 24px 70px rgba(0,0,0,0.28)',
          padding: 24,
          animation: 'olMicPickerPopIn 160ms cubic-bezier(.2,.8,.2,1)',
        }}
      >
        <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, marginBottom: 10 }}>
          <div style={{ fontSize: 18, fontWeight: 650 }}>{t('settings.recording.microphoneDialogTitle')}</div>
          <div style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
            <button
              type="button"
              onClick={onRefresh}
              disabled={loading}
              style={{
                border: 0,
                borderRadius: 999,
                background: 'transparent',
                color: loading ? 'var(--ol-ink-4)' : 'var(--ol-ink-3)',
                cursor: 'default',
                display: 'inline-flex',
                alignItems: 'center',
                justifyContent: 'center',
                width: 28,
                height: 28,
                opacity: loading ? 0.65 : 1,
                transition: 'background 0.16s var(--ol-motion-quick), opacity 0.16s var(--ol-motion-quick)',
              }}
              onMouseEnter={e => {
                if (!loading) e.currentTarget.style.background = 'rgba(0,0,0,0.05)';
              }}
              onMouseLeave={e => (e.currentTarget.style.background = 'transparent')}
              title={t('common.refresh')}
            >
              <Icon
                name="refresh"
                size={14}
                style={{ animation: loading ? 'olMicPickerSpin 800ms linear infinite' : undefined }}
              />
            </button>
            <button
              type="button"
              onClick={onClose}
              style={{
                border: 0,
                borderRadius: 999,
                background: 'transparent',
                color: 'var(--ol-ink-3)',
                cursor: 'default',
                display: 'inline-flex',
                alignItems: 'center',
                justifyContent: 'center',
                width: 28,
                height: 28,
                transition: 'background 0.16s var(--ol-motion-quick)',
              }}
              onMouseEnter={e => (e.currentTarget.style.background = 'rgba(0,0,0,0.05)')}
              onMouseLeave={e => (e.currentTarget.style.background = 'transparent')}
              title={t('common.close')}
            >
              <Icon name="close" size={14} />
            </button>
          </div>
        </div>
        <div style={{ fontSize: 12.5, color: 'var(--ol-ink-3)', lineHeight: 1.55, marginBottom: 18 }}>
          {t('settings.recording.microphoneDialogDesc')}
        </div>
        {monitorError && (
          <div style={{ fontSize: 11.5, color: 'var(--ol-err)', lineHeight: 1.45, marginBottom: 12 }}>
            {t('settings.recording.microphoneMonitorError', { message: monitorError })}
          </div>
        )}
        <div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
          {rows.map(row => {
            const active = pickedName === row.name;
            const previewing = previewName === row.name;
            const hovered = hoveredName === row.name;
            const pressed = pressedName === row.name;
            return (
              <button
                key={row.id}
                type="button"
                onMouseEnter={() => {
                  setHoveredName(row.name);
                }}
                onMouseLeave={() => {
                  setHoveredName(null);
                  setPressedName(null);
                }}
                onMouseDown={() => setPressedName(row.name)}
                onMouseUp={() => setPressedName(null)}
                onFocus={() => {
                  setHoveredName(row.name);
                }}
                onBlur={() => setHoveredName(null)}
                onClick={() => {
                  setPickedName(row.name);
                  setPreviewName(row.name);
                  onSelect(row.name);
                }}
                style={{
                  display: 'grid',
                  gridTemplateColumns: '1fr auto',
                  gap: 14,
                  alignItems: 'center',
                  width: '100%',
                  padding: '14px 16px',
                  borderRadius: 10,
                  border: active ? '1px solid rgba(37,99,235,0.7)' : '0.5px solid rgba(0,0,0,0.12)',
                  background: active
                    ? 'rgba(37,99,235,0.08)'
                    : hovered
                      ? 'rgba(0,0,0,0.035)'
                      : '#fff',
                  boxShadow: active
                    ? '0 0 0 3px rgba(37,99,235,0.08)'
                    : hovered
                      ? '0 8px 18px rgba(0,0,0,0.06)'
                      : '0 1px 2px rgba(0,0,0,0.03)',
                  color: 'var(--ol-ink)',
                  cursor: 'default',
                  textAlign: 'left',
                  transform: pressed ? 'scale(0.992)' : hovered ? 'translateY(-1px)' : 'translateY(0)',
                  transition: 'background 140ms ease, border-color 140ms ease, box-shadow 160ms ease, transform 120ms ease',
                }}
              >
                <span style={{ minWidth: 0 }}>
                  <span style={{ display: 'block', fontSize: 13, fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
                    {row.label}
                  </span>
                  {row.desc && (
                    <span style={{ display: 'block', fontSize: 11.5, color: 'var(--ol-ink-4)', marginTop: 3 }}>
                      {row.desc}
                    </span>
                  )}
                </span>
                <LevelMeter level={previewing ? level : 0} />
              </button>
            );
          })}
        </div>
        <style>
          {`
            @keyframes olMicPickerFadeIn {
              from { opacity: 0; }
              to { opacity: 1; }
            }
            @keyframes olMicPickerPopIn {
              from { opacity: 0; transform: translateY(8px) scale(.985); }
              to { opacity: 1; transform: translateY(0) scale(1); }
            }
            @keyframes olMicPickerSpin {
              from { transform: rotate(0deg); }
              to { transform: rotate(360deg); }
            }
          `}
        </style>
      </div>
    </div>
  );
}

function inferLegacyTrigger(codes: string[], fallback: HotkeyTrigger): HotkeyTrigger {
  if (codes.includes('ControlRight')) return 'rightControl';
  if (codes.includes('ControlLeft')) return 'leftControl';
  if (codes.includes('AltRight')) return 'rightAlt';
  if (codes.includes('AltLeft')) return 'leftOption';
  if (codes.includes('MetaRight')) return 'rightCommand';
  if (codes.includes('Fn')) return 'fn';
  return fallback;
}

function normalizeKeyboardHotkeyCode(event: KeyboardEvent): string | null {
  if (event.key === 'Fn' || event.code === 'Fn') return 'Fn';
  if (event.key === 'FnLock' || event.code === 'FnLock') return 'FnLock';
  const code = event.code === 'OSLeft' ? 'MetaLeft' : event.code === 'OSRight' ? 'MetaRight' : event.code;
  if (SUPPORTED_HOTKEY_CODES.has(code)) return code;
  if (/^Key[A-Z]$/.test(code)) return code;
  if (/^Digit[0-9]$/.test(code)) return code;
  if (/^F([1-9]|1[0-9]|2[0-4])$/.test(code)) return code;
  if (/^Numpad[0-9]$/.test(code)) return code;
  return null;
}

function mouseButtonToHotkeyCode(button: number): string | null {
  if (button === 3) return 'Mouse4';
  if (button === 4) return 'Mouse5';
  return null;
}

const SUPPORTED_HOTKEY_CODES = new Set([
  'ControlLeft', 'ControlRight', 'AltLeft', 'AltRight', 'ShiftLeft', 'ShiftRight',
  'MetaLeft', 'MetaRight', 'CapsLock', 'ScrollLock', 'Pause', 'PrintScreen',
  'Backspace', 'Tab', 'Enter', 'Space', 'Insert', 'Delete', 'Home', 'End',
  'PageUp', 'PageDown', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight',
  'ContextMenu', 'NumpadAdd', 'NumpadSubtract', 'NumpadMultiply', 'NumpadDivide',
  'NumpadDecimal', 'NumpadEnter', 'Backquote', 'Minus', 'Equal', 'BracketLeft',
  'BracketRight', 'Backslash', 'Semicolon', 'Quote', 'Comma', 'Period', 'Slash',
  'Fn', 'FnLock',
]);

function LevelMeter({ level }: { level: number }) {
  const amplified = Math.min(1, Math.max(0, level * 4.5));
  const bars = [0.25, 0.5, 0.75, 1, 0.75, 0.5];
  return (
    <span style={{ display: 'inline-flex', alignItems: 'center', gap: 4, height: 32 }}>
      {bars.map((weight, index) => {
        const intensity = Math.min(1, amplified * (0.85 + weight * 0.35));
        const height = 6 + intensity * (20 * weight);
        return (
          <span
            key={`${weight}-${index}`}
            style={{
              width: 5,
              height,
              borderRadius: 999,
              background: intensity > 0.08 ? 'var(--ol-blue)' : 'rgba(0,0,0,0.10)',
              opacity: 0.35 + intensity * 0.65,
              transition: 'height 70ms linear, opacity 90ms ease, background 120ms ease',
            }}
          />
        );
      })}
    </span>
  );
}

// 不存进 prefs：autostart 状态由 OS 持有（mac LaunchAgent plist / linux .desktop /
// windows HKCU\Run），prefs 缓存反而会与 OS 真相不一致。issue #194。
function AutostartRow() {
  const { t } = useTranslation();
  const [enabled, setEnabled] = useState(false);
  const [loaded, setLoaded] = useState(false);
  // 切 plist / 注册表失败时给用户看的错误。null = 没有失败/上次操作已成功。
  // 不渲染等于把失败吞掉 —— Windows 写 HKCU\Run 被组策略拦、macOS 写
  // LaunchAgent plist 权限不够 都是真实可能。issue #194。
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    if (!isTauri) {
      setLoaded(true);
      return;
    }
    let cancelled = false;
    autostartIsEnabled()
      .then((v: boolean) => {
        if (!cancelled) {
          setEnabled(v);
          setLoaded(true);
        }
      })
      .catch((err: unknown) => {
        console.error('[autostart] isEnabled failed', err);
        if (!cancelled) setLoaded(true);
      });
    return () => {
      cancelled = true;
    };
  }, []);

  const onToggle = async (next: boolean) => {
    setEnabled(next);
    setError(null);
    try {
      if (!isTauri) return;
      if (next) await autostartEnable();
      else await autostartDisable();
    } catch (err) {
      console.error('[autostart] toggle failed', err);
      setEnabled(!next);
      setError(err instanceof Error ? err.message : String(err));
    }
  };

  return (
    <SettingRow
      label={t('settings.recording.startupAtBoot')}
    >
      <div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
        {loaded ? <Toggle on={enabled} onToggle={onToggle} /> : null}
        {error && (
          <div style={{ fontSize: 11, color: 'var(--ol-err)', marginTop: 4, lineHeight: 1.5 }}>
            {t('settings.recording.startupAtBootError', { message: error })}
          </div>
        )}
      </div>
    </SettingRow>
  );
}

function LlmThinkingToggle({ enabled, onToggle }: { enabled: boolean; onToggle: (next: boolean) => void }) {
  const { t } = useTranslation();
  return (
    <div
      title={t('settings.providers.thinkingModeHint')}
      style={{
        display: 'flex',
        alignItems: 'center',
        gap: 6,
        paddingLeft: 2,
        whiteSpace: 'nowrap',
      }}
    >
      <span style={{ fontSize: 11.5, color: 'var(--ol-ink-4)' }}>
        {t('settings.providers.thinkingModeLabel')}
      </span>
      <Toggle on={enabled} onToggle={onToggle} />
      <span style={{ fontSize: 11.5, color: enabled ? 'var(--ol-blue)' : 'var(--ol-ink-4)' }}>
        {enabled ? t('settings.providers.thinkingModeOn') : t('settings.providers.thinkingModeOff')}
      </span>
    </div>
  );
}

const LLM_PRESETS = [
  {
    id: 'ark',
    nameKey: 'ark',
    baseUrl: 'https://ark.cn-beijing.volces.com/api/v3',
    modelPlaceholder: 'deepseek-v3-2',
  },
  {
    id: 'deepseek',
    nameKey: 'deepseek',
    baseUrl: 'https://api.deepseek.com/v1',
    modelPlaceholder: 'deepseek-v4-flash',
  },
  {
    id: 'siliconflow',
    nameKey: 'siliconflow',
    baseUrl: 'https://api.siliconflow.cn/v1',
    modelPlaceholder: 'Qwen/Qwen2.5-7B-Instruct',
  },
  {
    id: 'openai',
    nameKey: 'openai',
    baseUrl: 'https://api.openai.com/v1',
    modelPlaceholder: 'gpt-4o',
  },
  {
    // 谷歌官方 Gemini API（原生 generateContent，不走 OpenAI 兼容 shim）。
    // baseUrl 末尾 /v1beta 是当前 Generally Available 的 path（ai.google.dev/api）。
    // 后端 llm_gemini.rs 会拼成 `{baseUrl}/models/{model}:generateContent`，
    // 并按 Gemini 原生通道级 thinkingConfig 关闭或压低思考，不在前端维护模型适配表。
    // 模型列表用 ProviderTools「拉取模型」按钮取，
    // 由 commands.rs::fetch_provider_models 识别 generativelanguage 域名后按 Gemini shape 解析。
    id: 'gemini',
    nameKey: 'gemini',
    baseUrl: 'https://generativelanguage.googleapis.com/v1beta',
    modelPlaceholder: 'gemini-2.5-flash',
  },
  {
    id: 'codex_oauth',
    nameKey: 'codexOAuth',
    baseUrl: '',
    modelPlaceholder: 'gpt-5.3-codex-spark',
  },
  {
    id: 'mimo',
    nameKey: 'mimo',
    baseUrl: 'https://api.xiaomimimo.com/v1',
    modelPlaceholder: 'xiaomi/mimo-v2-flash',
  },
  {
    id: 'cometapi',
    nameKey: 'cometapi',
    baseUrl: 'https://api.cometapi.com/v1',
    modelPlaceholder: 'gpt-4o',
  },
  {
    id: 'openrouterFree',
    nameKey: 'openrouterFree',
    baseUrl: 'https://openrouter.ai/api/v1',
    modelPlaceholder: 'qwen/qwen3-coder:free',
  },
  {
    id: 'alibabaCoding',
    nameKey: 'alibabaCoding',
    baseUrl: 'https://coding-intl.dashscope.aliyuncs.com/v1',
    modelPlaceholder: 'qwen3-coder-plus',
  },
  {
    id: 'codingPlanX',
    nameKey: 'codingPlanX',
    baseUrl: 'https://api.codingplanx.ai/v1',
    modelPlaceholder: 'gpt-5-mini',
  },
  {
    id: 'custom',
    nameKey: 'custom',
    baseUrl: '',
    modelPlaceholder: '',
  },
] as const;

type LlmPresetId = typeof LLM_PRESETS[number]['id'];

const ASR_DEFAULT_RESOURCE_ID = 'volc.bigasr.sauc.duration';

// `volcengine` / `bailian` 走自建流式客户端；其余走 OpenAI 兼容
// `/audio/transcriptions`（`coordinator.rs::is_whisper_compatible_provider`）。
// 新增兼容厂商：
//   1. 在这里加一项 `{ id, nameKey, baseUrl, model }`；
//   2. `coordinator.rs::is_whisper_compatible_provider` 加同名 id；
//   3. 在 i18n 的 `settings.providers.presets.<nameKey>` 加文案。
// `AsrPresetId` 定义在 settings/shared.ts，AdvancedSection / ProvidersSection 共用同一份。
const ASR_PRESETS: ReadonlyArray<{ id: AsrPresetId; nameKey: string; baseUrl: string; model: string }> = [
  { id: 'volcengine',   nameKey: 'asrVolcengine',   baseUrl: '',                                              model: ''                              },
  { id: 'bailian',      nameKey: 'asrBailian',     baseUrl: 'wss://dashscope.aliyuncs.com/api-ws/v1/inference/', model: 'fun-asr-realtime'             },
  { id: 'siliconflow',  nameKey: 'asrSiliconflow',  baseUrl: 'https://api.siliconflow.cn/v1',                  model: 'FunAudioLLM/SenseVoiceSmall' },
  { id: 'zhipu',        nameKey: 'asrZhipu',        baseUrl: 'https://open.bigmodel.cn/api/paas/v4',           model: 'glm-asr-2512'                },
  { id: 'groq',         nameKey: 'asrGroq',         baseUrl: 'https://api.groq.com/openai/v1',                 model: 'whisper-large-v3-turbo'      },
  { id: 'whisper',      nameKey: 'asrWhisper',      baseUrl: 'https://api.openai.com/v1',                      model: 'whisper-1'                   },
  { id: 'foundry-local-whisper', nameKey: 'asrFoundryLocalWhisper', baseUrl: '',                              model: ''                              },
  // 本地 Qwen3-ASR：无 baseUrl/model 配置，模型在「模型设置」页下载与切换。
  { id: 'local-qwen3',  nameKey: 'asrLocalQwen3',   baseUrl: '',                                              model: ''                              },
];

function ProvidersSection() {
  const { t } = useTranslation();
  const { prefs, updatePrefs } = useHotkeySettings();
  // `*Provider` 立即跟随 <select> 改动（受控组件必须实时反映用户输入）；
  // `committed*Provider` 才决定 CredentialField 的 key，仅在后端 active
  // 切换 + 默认值写完后再 commit。两者拆开是为了同时满足：
  //   - <select> 立刻显示用户的选择（issue #220 P2：codex 指出受控选不应等 await）
  //   - CredentialField 不要在后端 active 切完前 remount（issue #219：避免读到旧 entry）
  // `*SwitchSeq` 是 stale-write 守卫：用户 100ms 内连点两次时，先发的请求晚到不
  // 会覆盖后发的 commit。
  const [llmProvider, setLlmProvider] = useState<LlmPresetId>('ark');
  const [asrProvider, setAsrProvider] = useState<AsrPresetId>('volcengine');
  const [committedLlmProvider, setCommittedLlmProvider] = useState<LlmPresetId>('ark');
  const [committedAsrProvider, setCommittedAsrProvider] = useState<AsrPresetId>('volcengine');
  const llmSwitchSeqRef = useRef(0);
  const asrSwitchSeqRef = useRef(0);
  const [llmModelRevision, setLlmModelRevision] = useState(0);
  const [asrModelRevision, setAsrModelRevision] = useState(0);
  const os = detectOS();
  // 主 ASR 下拉只列云端选项；本地推理（local-qwen3 / foundry-local-whisper）
  // 移到「高级」标签页，防止新手误开 CPU 推理。详见 AdvancedSection。
  const visibleAsrPresets = ASR_PRESETS.filter(
    p => p.id !== 'foundry-local-whisper' && p.id !== 'local-qwen3',
  );

  useEffect(() => {
    if (!prefs) return;
    const knownLlm = LLM_PRESETS.find(x => x.id === prefs.activeLlmProvider);
    const llmId = knownLlm ? knownLlm.id : 'custom';
    setLlmProvider(llmId);
    setCommittedLlmProvider(llmId);
    // ASR 在 ALL ASR_PRESETS 里查（不是 visibleAsrPresets）——本地选项虽然
    // 从下拉里藏起来了，但若用户曾在「高级」里启用过 local-qwen3，主 Card
    // 仍要识别出 active 是本地，并切到「正在使用本地 ASR」的 notice 渲染。
    const knownAsr = ASR_PRESETS.find(x => x.id === prefs.activeAsrProvider);
    const asrId = knownAsr ? knownAsr.id : 'volcengine';
    setAsrProvider(asrId);
    setCommittedAsrProvider(asrId);
  }, [prefs, os]);

  // issue #219 / #220 P2：
  //   1. 立刻 setLlmProvider —— 受控 <select> 必须反映用户最新选择。
  //   2. 用 seq 守卫每个 await：用户连点两次时旧请求晚到也不会盖掉新选择。
  //   3. 仅 setCommittedLlmProvider 之后 CredentialField 才 remount 读新 entry，
  //      此时后端 root.active.llm 已经是 id，lookup_account 落到正确 entry。
  //   4. endpoint/model 默认值仅在该 provider entry 该字段为空时才填，不覆盖用户自定义。
  const onLlmProviderChange = async (id: LlmPresetId) => {
    setLlmProvider(id);
    const seq = ++llmSwitchSeqRef.current;
    emitSaved('saving', t('common.saving'));
    try {
      await setActiveLlmProvider(id);
      if (seq !== llmSwitchSeqRef.current) return;
      if (prefs) {
        const next = { ...prefs, activeLlmProvider: id };
        await updatePrefs(next);
        if (seq !== llmSwitchSeqRef.current) return;
      }
      const preset = LLM_PRESETS.find(p => p.id === id);
      // 修 bug：所有 LLM provider 共用 `ark.endpoint` / `ark.model_id` 一对凭据槽
      // （persistence.rs 没做 per-provider 隔离）。旧逻辑只在槽空时填默认值，
      // 老用户切换 preset 时槽里早有旧值——dropdown 看着切了，polish 实际还是
      // 打老 endpoint。改成：切到任何非 custom 预设都强制覆盖 endpoint 与 model
      // 到该预设的默认值，让"切换"真切到位。custom 预设没有默认值，跳过。
      if (preset && preset.id !== 'custom') {
        if (preset.baseUrl) {
          await setCredential('ark.endpoint', preset.baseUrl);
          if (seq !== llmSwitchSeqRef.current) return;
        }
        if (preset.modelPlaceholder) {
          await setCredential('ark.model_id', preset.modelPlaceholder);
          if (seq !== llmSwitchSeqRef.current) return;
        }
      }
      setCommittedLlmProvider(id);
      emitSaved('saved', t('common.saved'));
    } catch (err) {
      // seq 守卫：只有当前 call 还是最新时才把 saving 翻成 failed；
      // 旧 call 早被 newer call 的 emitSaved('saving') 覆盖，再叠 failed 会
      // 把 newer 正在跑的 saving 假伪成失败。
      if (seq === llmSwitchSeqRef.current) {
        emitSaved('failed', t('common.operationFailed'));
      }
      throw err;
    }
  };

  const onLlmThinkingToggle = (enabled: boolean) => {
    if (!prefs) return;
    void updatePrefs(current => ({ ...current, llmThinkingEnabled: enabled })).catch(error => {
      console.error('[settings] failed to update LLM thinking mode', error);
      emitSaved('failed', t('common.operationFailed'));
    });
  };

  const onAsrProviderChange = async (id: AsrPresetId) => {
    setAsrProvider(id);
    const seq = ++asrSwitchSeqRef.current;
    emitSaved('saving', t('common.saving'));
    try {
      await setActiveAsrProvider(id);
      if (seq !== asrSwitchSeqRef.current) return;
      if (prefs) {
        const next = { ...prefs, activeAsrProvider: id };
        await updatePrefs(next);
        if (seq !== asrSwitchSeqRef.current) return;
      }
      // OpenAI 兼容厂商首次切换时预填 baseUrl / model 默认值，省得用户必踩
      // 「跨厂商 model 名根本不一样」的坑；但用户已自定义后就不再覆盖。
      // volcengine 走另一套凭据，跳过。
      const preset = ASR_PRESETS.find(p => p.id === id);
      if (preset && preset.baseUrl) {
        const existing = await readCredential('asr.endpoint');
        if (seq !== asrSwitchSeqRef.current) return;
        if (!existing) {
          await setCredential('asr.endpoint', preset.baseUrl);
          if (seq !== asrSwitchSeqRef.current) return;
        }
      }
      if (preset && preset.model) {
        const existing = await readCredential('asr.model');
        if (seq !== asrSwitchSeqRef.current) return;
        if (!existing) {
          await setCredential('asr.model', preset.model);
          if (seq !== asrSwitchSeqRef.current) return;
        }
      }
      setCommittedAsrProvider(id);
      emitSaved('saved', t('common.saved'));
    } catch (err) {
      // seq 守卫同上 onLlmProviderChange：旧 call 不要把 newer call 的 saving
      // 伪造成 failed。
      if (seq === asrSwitchSeqRef.current) {
        emitSaved('failed', t('common.operationFailed'));
      }
      throw err;
    }
  };

  // preset 决定 placeholder 与 default —— 必须跟着 committed*Provider 走，
  // 否则受控 <select> 立刻切到新厂商，但凭据字段还在显示旧 entry，placeholder
  // 会先于实际数据切换、视觉上对不上。
  const preset = LLM_PRESETS.find(p => p.id === committedLlmProvider) ?? LLM_PRESETS[LLM_PRESETS.length - 1];
  const codexOAuthSelected = committedLlmProvider === 'codex_oauth';
  const asrPreset = visibleAsrPresets.find(p => p.id === committedAsrProvider);
  return (
    <>
      <div style={{ fontSize: 11.5, color: 'var(--ol-ink-4)', lineHeight: 1.6, marginBottom: 10 }}>
        {t('settings.providers.credentialStorageNotice')}
      </div>
      <Card>
        <div style={{ marginBottom: 10 }}>
          <SectionTitle>{t('settings.providers.llmTitle')}</SectionTitle>
        </div>
        {/* desc 已去掉——'选择后将自动填入 Base URL 默认值' 在 180px label 列必换行成两行，
            视觉上 label 区出现"字体单独占一行"。下拉自身已经表达了"切换"含义，desc 冗余。 */}
        <SettingRow label={t('settings.providers.providerLabel')}>
          <SelectLite
            value={llmProvider}
            onChange={next => onLlmProviderChange(next as LlmPresetId)}
            options={LLM_PRESETS.map(p => ({
              value: p.id,
              label: t(`settings.providers.presets.${p.nameKey}`),
            }))}
            ariaLabel={t('settings.providers.providerLabel')}
            style={{ ...inputStyle, width: '100%', maxWidth: 200 }}
          />
        </SettingRow>
        {codexOAuthSelected ? (
          <div style={{ fontSize: 11.5, color: 'var(--ol-ink-4)', lineHeight: 1.6, margin: '2px 0 10px' }}>
            {t('settings.providers.codexOAuthNotice')}
          </div>
        ) : (
          <>
            <CredentialField key={`${committedLlmProvider}:api_key`} label={t('settings.providers.apiKeyLabel')} account="ark.api_key" mono mask />
            <CredentialField key={`${committedLlmProvider}:endpoint`} label={t('settings.providers.baseUrlLabel')} account="ark.endpoint"
              placeholder={preset.baseUrl || 'https://your-endpoint/v1'} />
          </>
        )}
        <CredentialField key={`${committedLlmProvider}:model:${llmModelRevision}`} label={t('settings.providers.modelLabel')} account="ark.model_id"
          placeholder={preset.modelPlaceholder || 'model-name'} mono
          trailing={(
            <LlmThinkingToggle
              enabled={prefs?.llmThinkingEnabled ?? false}
              onToggle={onLlmThinkingToggle}
            />
          )}
        />
        <ProviderTools key={committedLlmProvider} kind="llm" modelAccount="ark.model_id" onModelSelected={() => setLlmModelRevision(v => v + 1)} />
      </Card>

      <Card>
        <div style={{ marginBottom: 10 }}>
          <SectionTitle>{t('settings.providers.asrTitle')}</SectionTitle>
        </div>
        {/* 下拉只放云端选项；本地引擎激活时锁住 + 在下方放一行"ASR 提供商已被接管"提示，
            未激活时不显示提示。 */}
        <SettingRow label={t('settings.providers.providerLabel')}>
          {(() => {
            const isLocked =
              committedAsrProvider === 'local-qwen3' ||
              committedAsrProvider === 'foundry-local-whisper';
            const selectedValue: AsrPresetId = isLocked ? committedAsrProvider : asrProvider;
            // 跨机器同步异常兜底：committed 是本地但不在 visibleAsrPresets 里时，受控
            // select 会回退到首项造成假象 —— 补一个 disabled option 让 select 找到当前值。
            const anomalousLocal: AsrPresetId | null =
              isLocked && !visibleAsrPresets.some(p => p.id === committedAsrProvider)
                ? committedAsrProvider
                : null;
            const anomalousNameKey = anomalousLocal === 'local-qwen3'
              ? 'asrLocalQwen3'
              : anomalousLocal === 'foundry-local-whisper'
                ? 'asrFoundryLocalWhisper'
                : null;
            return (
              <div style={{ display: 'flex', flexDirection: 'column', gap: 6, alignItems: 'flex-start', minWidth: 0 }}>
                <SelectLite
                  value={selectedValue}
                  disabled={isLocked}
                  onChange={next => onAsrProviderChange(next as AsrPresetId)}
                  options={[
                    ...visibleAsrPresets.map(p => ({
                      value: p.id,
                      label: t(`settings.providers.presets.${p.nameKey}`),
                    })),
                    ...(anomalousLocal && anomalousNameKey
                      ? [{
                          value: anomalousLocal,
                          label: t(`settings.providers.presets.${anomalousNameKey}`),
                          disabled: true,
                        }]
                      : []),
                  ]}
                  ariaLabel={t('settings.providers.providerLabel')}
                  style={{ ...inputStyle, width: '100%', maxWidth: 200 }}
                />
                {isLocked && (
                  <div style={{ fontSize: 11, color: 'var(--ol-ink-4)', lineHeight: 1.5 }}>
                    {t('settings.providers.asrProviderTakenOver')}
                  </div>
                )}
              </div>
            );
          })()}
        </SettingRow>
        {committedAsrProvider === 'volcengine' ? (
          <>
            <CredentialField
              key={`${committedAsrProvider}:app_key`}
              label={t('settings.providers.volcengineAppKeyLabel')}
              account="volcengine.app_key"
              mono
              mask
            />
            <CredentialField
              key={`${committedAsrProvider}:access_key`}
              label={t('settings.providers.volcengineAccessKeyLabel')}
              account="volcengine.access_key"
              mono
              mask
            />
            <CredentialField
              key={`${committedAsrProvider}:resource_id`}
              label={t('settings.providers.volcengineResourceIdLabel')}
              account="volcengine.resource_id"
              mono
              placeholder={ASR_DEFAULT_RESOURCE_ID} defaultValue={ASR_DEFAULT_RESOURCE_ID} />
            <div style={{ marginTop: 2, fontSize: 11.5, color: 'var(--ol-ink-4)', lineHeight: 1.6 }}>
              {t('settings.providers.volcengineMappingNote')}
            </div>
          </>
        ) : committedAsrProvider === 'local-qwen3' || committedAsrProvider === 'foundry-local-whisper' ? (
          // 用户已经在用本地 ASR——dropdown 行的 localAsrActiveNotice 已经把
          // "在高级中切换或禁用"讲清楚了，body 不再重复 LocalAsrProviderHint。
          // 模型管理 UI 唯一入口在 AdvancedSection 里的 <LocalAsr embedded />。
          null
        ) : (
          <>
            <CredentialField key={`${committedAsrProvider}:api_key`} label={t('settings.providers.apiKeyLabel')} account="asr.api_key" mono mask />
            <CredentialField key={`${committedAsrProvider}:endpoint`} label={t('settings.providers.baseUrlLabel')} account="asr.endpoint"
              placeholder={asrPreset?.baseUrl || 'https://api.openai.com/v1'}
              defaultValue={asrPreset?.baseUrl || undefined} />
            <CredentialField key={`${committedAsrProvider}:model:${asrModelRevision}`} label={t('settings.providers.modelLabel')} account="asr.model"
              placeholder={asrPreset?.model || 'whisper-1'} />
            {committedAsrProvider === 'bailian' && (
              <>
                <CredentialField
                  key={`${committedAsrProvider}:vocabulary_id`}
                  label={t('settings.providers.bailianVocabularyIdLabel')}
                  account="asr.vocabulary_id"
                  mono
                  placeholder="vocab-..."
                />
                <div style={{ marginTop: 2, fontSize: 11.5, color: 'var(--ol-ink-4)', lineHeight: 1.6 }}>
                  {t('settings.providers.bailianVocabularyIdNote')}
                </div>
              </>
            )}
            <ProviderTools kind="asr" modelAccount="asr.model" onModelSelected={() => setAsrModelRevision(v => v + 1)} />
          </>
        )}
      </Card>
    </>
  );
}


type ProviderToolStatus = 'idle' | 'loading' | 'success' | 'empty' | 'error';

function ProviderTools({ kind, modelAccount, onModelSelected }: { kind: 'llm' | 'asr'; modelAccount: string; onModelSelected: () => void }) {
  const { t } = useTranslation();
  const [models, setModels] = useState<string[]>([]);
  const [selectedModel, setSelectedModel] = useState('');
  const [status, setStatus] = useState<ProviderToolStatus>('idle');
  const [message, setMessage] = useState('');

  const setResult = (next: ProviderToolStatus, nextMessage: string) => {
    setStatus(next);
    setMessage(nextMessage);
  };

  const validate = async () => {
    setModels([]);
    setSelectedModel('');
    setResult('loading', t('settings.providers.validating'));
    try {
      const result = await validateProviderCredentials(kind);
      setResult(result.ok ? 'success' : 'error', t('settings.providers.validateSuccess'));
    } catch (error) {
      const message = error instanceof Error ? error.message : String(error);
      if ((kind === 'llm' && message === 'llmModelMissing') || (kind === 'asr' && message === 'asrModelMissing')) {
        setResult('empty', t('settings.providers.modelMissing'));
        return;
      }
      if (message === 'modelsEmpty') {
        setResult('empty', t('settings.providers.modelsEmpty'));
        return;
      }
      setResult('error', providerErrorMessage(error, t));
    }
  };

  const loadModels = async () => {
    setResult('loading', t('settings.providers.loadingModels'));
    try {
      const result = await listProviderModels(kind);
      setModels(result.models);
      if (result.models.length === 0) {
        setResult('empty', t('settings.providers.modelsEmpty'));
      } else {
        setSelectedModel('');
        setResult('success', t('settings.providers.modelsLoaded', { count: result.models.length }));
      }
    } catch (error) {
      setModels([]);
      setResult('error', providerErrorMessage(error, t));
    }
  };

  const applyModel = async (model: string) => {
    setResult('loading', t('common.saving'));
    try {
      await setCredential(modelAccount, model);
      setSelectedModel(model);
      onModelSelected();
      setResult('success', t('settings.providers.modelSaved', { model }));
    } catch (error) {
      setResult('error', providerErrorMessage(error, t));
    }
  };

  return (
    <SettingRow label={t('settings.providers.toolsLabel')}>
      <div style={{ display: 'flex', flexDirection: 'column', gap: 8, width: '100%', maxWidth: 420 }}>
        <div style={{ display: 'flex', gap: 6, alignItems: 'center', flexWrap: 'wrap' }}>
          <button onClick={validate} style={miniBtnStyle} disabled={status === 'loading'}>{t('settings.providers.validate')}</button>
          <button onClick={loadModels} style={miniBtnStyle} disabled={status === 'loading'}>{t('settings.providers.fetchModels')}</button>
          {models.length > 0 && (
            <SelectLite
              value={selectedModel}
              onChange={applyModel}
              disabled={status === 'loading'}
              options={models.map(model => ({ value: model, label: model }))}
              placeholder={t('settings.providers.selectModel')}
              ariaLabel={t('settings.providers.selectModel')}
              style={{ ...inputStyle, maxWidth: 220 }}
            />
          )}
        </div>
        {message && (
          <span style={{ fontSize: 11, color: status === 'error' ? 'var(--ol-warn)' : status === 'empty' ? 'var(--ol-ink-4)' : 'var(--ol-ok)', lineHeight: 1.4 }}>
            {message}
          </span>
        )}
      </div>
    </SettingRow>
  );
}


function providerErrorMessage(error: unknown, t: ReturnType<typeof useTranslation>['t']): string {
  const message = error instanceof Error ? error.message : String(error);
  if (message.startsWith('providerHttpStatus:')) {
    return t('settings.providers.providerHttpStatus', { status: message.split(':')[1] || '?' });
  }
  if (message === 'endpointMustUseHttps') return t('settings.providers.endpointMustUseHttps');
  if (message === 'endpointInvalid') return t('settings.providers.endpointInvalid');
  if (message === 'providerResponseTooLarge') return t('settings.providers.responseTooLarge');
  if (message === 'asrInvalidJson') return t('settings.providers.asrInvalidJson');
  if (message === 'asrMissingTextField') return t('settings.providers.asrMissingTextField');
  if (message === 'providerNetworkError') return t('common.networkError');
  if (message === 'providerReadResponseFailed' || message === 'providerClientInitFailed') return t('common.operationFailed');
  if (message === 'providerRequestTimeout') return t('settings.providers.requestTimeout');
  if (message.includes('API Key')) return t('settings.providers.apiKeyMissing');
  if (message.includes('Endpoint')) return t('settings.providers.endpointMissing');
  if (message.includes('timeout') || message.includes('超时')) return t('settings.providers.requestTimeout');
  return t('common.operationFailed');
}

type CredentialFieldStatus = 'idle' | 'saving' | 'saved' | 'readError' | 'saveError' | 'copied' | 'copyError';

interface CredentialFieldProps {
  label: string;
  account: string;
  placeholder?: string;
  mono?: boolean;
  mask?: boolean;
  defaultValue?: string;
  trailing?: ReactNode;
}

function CredentialField({ label, account, placeholder, mono, mask, defaultValue, trailing }: CredentialFieldProps) {
  const { t } = useTranslation();
  const [value, setValue] = useState('');
  const [revealed, setRevealed] = useState(false);
  const [loaded, setLoaded] = useState(false);
  const [dirty, setDirty] = useState(false);
  const [status, setStatus] = useState<CredentialFieldStatus>('idle');
  const debounceRef = useRef<number | null>(null);
  const statusRef = useRef<number | null>(null);

  useEffect(() => {
    let cancelled = false;
    setLoaded(false);
    setDirty(false);
    setStatus('idle');
    setValue('');
    if (debounceRef.current) {
      clearTimeout(debounceRef.current);
      debounceRef.current = null;
    }
    readCredential(account)
      .then(v => {
        if (cancelled) return;
        setValue(v ?? '');
        setLoaded(true);
      })
      .catch(error => {
        if (cancelled) return;
        console.error('[settings] failed to read credential', account, error);
        setLoaded(true);
        setStatus('readError');
      });
    return () => {
      cancelled = true;
    };
  }, [account]);

  useEffect(() => {
    return () => {
      if (debounceRef.current) clearTimeout(debounceRef.current);
      if (statusRef.current) clearTimeout(statusRef.current);
    };
  }, []);

  // 改造：除 readError（持续错误，留在输入旁标识字段不可用）外，所有 saving / saved /
  //   saveError / copied / copyError 一律发到右上角 SavedToast。原内联文案太挤、跟其它
  //   页面 toast 风格不统一。
  const showTemporaryStatus = (next: CredentialFieldStatus) => {
    if (next === 'saving') {
      emitSaved('saving', t('common.saving'));
    } else if (next === 'saved') {
      emitSaved('saved', t('common.saved'));
    } else if (next === 'saveError') {
      emitSaved('failed', t('common.operationFailed'));
    } else if (next === 'copied') {
      emitSaved('saved', t('common.copied'));
    } else if (next === 'copyError') {
      emitSaved('failed', t('common.operationFailed'));
    }
    setStatus(next);
    if (statusRef.current) clearTimeout(statusRef.current);
    statusRef.current = window.setTimeout(() => setStatus('idle'), 1600);
  };

  const save = async (v: string, force = false) => {
    if (!loaded || (!dirty && !force)) return;
    setStatus('saving');
    emitSaved('saving', t('common.saving'));
    try {
      await setCredential(account, v);
      setDirty(false);
      showTemporaryStatus('saved');
    } catch (error) {
      console.error('[settings] failed to save credential', account, error);
      showTemporaryStatus('saveError');
    }
  };

  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    const v = e.target.value;
    setValue(v);
    if (!loaded) return;
    setDirty(true);
    if (debounceRef.current) clearTimeout(debounceRef.current);
    debounceRef.current = window.setTimeout(() => save(v, true), 300);
  };

  const onBlur = () => {
    if (!loaded || !dirty) return;
    if (debounceRef.current) {
      clearTimeout(debounceRef.current);
      debounceRef.current = null;
    }
    save(value, true);
  };

  const fillDefault = async () => {
    if (!loaded || !defaultValue) return;
    setValue(defaultValue);
    setDirty(true);
    await save(defaultValue, true);
  };

  const onCopy = async () => {
    if (!value || !loaded) return;
    try {
      if (!navigator.clipboard?.writeText) {
        throw new Error('Clipboard API unavailable');
      }
      await navigator.clipboard.writeText(value);
      showTemporaryStatus('copied');
    } catch (error) {
      console.error('[settings] failed to copy credential', account, error);
      showTemporaryStatus('copyError');
    }
  };

  const inputType = mask && !revealed ? 'password' : 'text';
  const disabled = !loaded;

  return (
    <SettingRow label={label}>
      <div style={{ display: 'flex', gap: 6, alignItems: 'center', width: '100%', maxWidth: 420 }}>
        <input
          type={inputType}
          value={value}
          placeholder={loaded ? placeholder : t('common.loading')}
          onChange={handleChange}
          onBlur={onBlur}
          disabled={disabled}
          style={{ ...inputStyle, fontFamily: mono ? 'var(--ol-font-mono)' : 'inherit' }}
        />
        {defaultValue && !value && loaded && (
          <button onClick={fillDefault} title={t('settings.providers.fillDefault')} style={iconBtnStyle} disabled={!loaded}>
            <Icon name="check" size={13} />
          </button>
        )}
        {trailing}
        {mask && (
          <button
            onClick={() => setRevealed(r => !r)}
            title={revealed ? t('common.hide') : t('common.show')}
            style={iconBtnStyle}
            disabled={disabled}
          >
            <Icon name="eye" size={14} />
          </button>
        )}
        <button
          onClick={onCopy}
          title={t('common.copy')}
          style={iconBtnStyle}
          disabled={!value || disabled}
        >
          <Icon name="copy" size={14} />
        </button>
        {/* readError 是字段无法读取的持续错误，留在原位提示用户该字段不可用；
            其它瞬态状态（saving / saved / saveError / copied / copyError）都通过
            emitSaved 发到右上角统一 toast，不再内联占位。 */}
        {status === 'readError' && (
          <span
            style={{
              fontSize: 11,
              color: 'var(--ol-warn)',
              whiteSpace: 'nowrap',
            }}
          >
            {t('settings.providers.readFailed')}
          </span>
        )}
      </div>
    </SettingRow>
  );
}

const miniBtnStyle: CSSProperties = {
  height: 32, padding: '0 12px',
  border: '0.5px solid var(--ol-line-strong)',
  borderRadius: 8, background: 'var(--ol-surface)',
  boxShadow: '0 1px 2px rgba(0,0,0,0.04), 0 0 0 0.5px rgba(255,255,255,0.2) inset',
  color: 'var(--ol-ink-2)', cursor: 'default', flexShrink: 0,
  fontSize: 12.5, fontWeight: 500, letterSpacing: '0.01em',
  transition: 'background 0.16s var(--ol-motion-quick), border-color 0.16s var(--ol-motion-quick), color 0.16s var(--ol-motion-quick), box-shadow 0.16s var(--ol-motion-quick)',
};

const recordingHotkeyControlWidth = 178;

const hotkeyRecorderButtonStyle: CSSProperties = {
  width: recordingHotkeyControlWidth,
  height: 32,
  padding: '0 8px 0 12px',
  border: '0.5px solid var(--ol-line-strong)',
  borderRadius: 8,
  background: 'var(--ol-surface)',
  boxShadow: '0 1px 2px rgba(0,0,0,0.04), 0 0 0 0.5px rgba(255,255,255,0.2) inset',
  display: 'inline-flex',
  alignItems: 'center',
  justifyContent: 'space-between',
  gap: 8,
  fontFamily: 'var(--ol-font-mono)',
  fontSize: 12.5,
  cursor: 'default',
  transition: 'background 0.16s var(--ol-motion-quick), border-color 0.16s var(--ol-motion-quick), color 0.16s var(--ol-motion-quick)',
};

const recordingHotkeySegmentedStyle: CSSProperties = {
  width: recordingHotkeyControlWidth,
  display: 'inline-flex',
  padding: 3,
  borderRadius: 8,
  background: 'rgba(0,0,0,0.06)',
  boxShadow: 'inset 0 1px 2px rgba(0,0,0,0.04)',
};

const recordingHotkeyGroupStyle: CSSProperties = {
  display: 'grid',
  gridTemplateColumns: 'auto',
  rowGap: 12,
  justifyItems: 'start',
};

const recordingHotkeyLineStyle: CSSProperties = {
  display: 'grid',
  gridTemplateColumns: '64px auto',
  alignItems: 'center',
  columnGap: 12,
};

const recordingHotkeyFieldLabelStyle: CSSProperties = {
  fontSize: 12.5,
  color: 'var(--ol-ink-4)',
  textAlign: 'right',
  whiteSpace: 'nowrap',
};

const recordingHotkeyStatusStyle: CSSProperties = {
  marginLeft: 76,
  fontSize: 12,
  lineHeight: 1.4,
  color: 'var(--ol-ink-3)',
};

const hotkeyRecorderLabelStyle: CSSProperties = {
  minWidth: 0,
  overflow: 'hidden',
  textOverflow: 'ellipsis',
  whiteSpace: 'nowrap',
};

const hotkeyClearButtonStyle: CSSProperties = {
  width: 18,
  height: 18,
  borderRadius: 999,
  display: 'inline-flex',
  alignItems: 'center',
  justifyContent: 'center',
  flexShrink: 0,
  background: 'rgba(0,0,0,0.15)',
  color: '#fff',
  transition: 'background 0.16s ease',
};

const iconBtnStyle: CSSProperties = {
  width: 32, height: 32,
  border: '0.5px solid var(--ol-line-strong)',
  borderRadius: 8, background: 'var(--ol-surface)',
  boxShadow: '0 1px 2px rgba(0,0,0,0.04), 0 0 0 0.5px rgba(255,255,255,0.2) inset',
  display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
  color: 'var(--ol-ink-3)', cursor: 'default', flexShrink: 0,
  transition: 'background 0.16s var(--ol-motion-quick), border-color 0.16s var(--ol-motion-quick), color 0.16s var(--ol-motion-quick), transform 0.12s var(--ol-motion-quick)',
};






/// 本地 Qwen3-ASR 在 Settings → 服务商区里**不**让用户填空——展示当前激活模型
/// 是否已下载、列出所有已下载模型 + 删除按钮，并提示性能/质量预期，引导跳到
/// 「模型设置」页做下载。
function LocalAsrProviderHint({
  provider,
  selectedProvider,
}: {
  provider: 'local-qwen3' | 'foundry-local-whisper';
  selectedProvider: AsrPresetId;
}) {
  const { t } = useTranslation();
  const [settings, setSettings] = useState<LocalAsrSettings | null>(null);
  const [models, setModels] = useState<LocalAsrModelStatus[]>([]);
  const [loading, setLoading] = useState(true);
  const [deletingId, setDeletingId] = useState<string | null>(null);
  const refreshSeqRef = useRef(0);
  const providerStateRef = useRef({ provider, selectedProvider });
  providerStateRef.current = { provider, selectedProvider };

  const qwenReadyForFetch = () => {
    const state = providerStateRef.current;
    return state.provider === 'local-qwen3' && state.selectedProvider === 'local-qwen3';
  };

  const refresh = async (seq: number) => {
    try {
      const [s, list] = await Promise.all([getLocalAsrSettings(), listLocalAsrModels()]);
      if (seq !== refreshSeqRef.current) {
        return;
      }
      setSettings(s);
      setModels(list);
    } catch (err) {
      if (seq !== refreshSeqRef.current) {
        return;
      }
      console.warn('[settings] load local asr status failed', err);
    } finally {
      if (seq === refreshSeqRef.current) {
        setLoading(false);
      }
    }
  };

  const beginRefresh = () => {
    const seq = ++refreshSeqRef.current;
    setSettings(null);
    setModels([]);
    setDeletingId(null);
    if (provider !== selectedProvider) {
      setLoading(true);
      return;
    }
    if (provider === 'foundry-local-whisper') {
      setLoading(false);
      return;
    }
    setLoading(true);
    void refresh(seq);
  };

  useEffect(() => {
    beginRefresh();
    return () => {
      refreshSeqRef.current += 1;
    };
  }, [provider, selectedProvider]);

  const goToLocalAsr = () => {
    window.dispatchEvent(new CustomEvent(NAVIGATE_LOCAL_ASR_EVENT));
  };

  const handleDelete = async (modelId: string) => {
    const seq = refreshSeqRef.current;
    if (!qwenReadyForFetch()) {
      return;
    }
    setDeletingId(modelId);
    try {
      await deleteLocalAsrModel(modelId);
      if (seq !== refreshSeqRef.current || !qwenReadyForFetch()) {
        return;
      }
      beginRefresh();
    } catch (err) {
      console.warn('[settings] delete local model failed', err);
    } finally {
      if (seq === refreshSeqRef.current && provider === 'local-qwen3') {
        setDeletingId(null);
      }
    }
  };

  const hintKey = provider === 'foundry-local-whisper'
    ? 'settings.providers.foundryLocalAsrHint'
    : 'settings.providers.localAsrHint';

  if (loading) {
    return (
      <div style={{ padding: '12px 0', fontSize: 12.5, color: 'var(--ol-ink-4)' }}>
        {t('common.loading')}
      </div>
    );
  }

  const active = models.find(m => m.id === settings?.activeModel);
  const isReady = active?.isDownloaded ?? false;
  const downloaded = models.filter(m => m.isDownloaded);

  if (provider === 'foundry-local-whisper') {
    return (
      <div style={{ padding: '8px 0 4px', display: 'flex', flexDirection: 'column', gap: 12 }}>
        <div style={{ fontSize: 12.5, color: 'var(--ol-ink-3)', lineHeight: 1.6 }}>
          {t(hintKey)}
        </div>
        <div>
          <Btn variant="ghost" size="sm" onClick={goToLocalAsr}>
            {t('settings.providers.localAsrManage')}
          </Btn>
        </div>
      </div>
    );
  }

  return (
    <div style={{ padding: '8px 0 4px', display: 'flex', flexDirection: 'column', gap: 12 }}>
      {/* 性能/质量预期警告 —— 用户硬要求要写清楚 */}
      <div
        style={{
          padding: '10px 12px',
          background: 'rgba(255, 215, 130, 0.18)',
          borderRadius: 8,
          fontSize: 12.5,
          color: 'var(--ol-ink-2)',
          lineHeight: 1.6,
        }}>
        ⚠️ {t('settings.providers.localAsrPerformanceWarning')}
      </div>

      <div style={{ fontSize: 12.5, color: 'var(--ol-ink-3)', lineHeight: 1.6 }}>
        {t(hintKey)}
      </div>

      {/* 当前激活模型状态 + 跳转按钮 */}
      <div style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
        <Pill tone={isReady ? 'ok' : 'outline'} size="sm">
          {isReady
            ? t('settings.providers.localAsrReady', { model: active?.id ?? '' })
            : t('settings.providers.localAsrNotReady', { model: settings?.activeModel ?? '' })}
        </Pill>
        <Btn variant={isReady ? 'ghost' : 'primary'} size="sm" onClick={goToLocalAsr}>
          {isReady
            ? t('settings.providers.localAsrManage')
            : t('settings.providers.localAsrGoDownload')}
        </Btn>
      </div>

      {/* 已下载模型列表 + 删除按钮（用户：已下载的项目要在旁边显示 + 提供删除） */}
      {downloaded.length > 0 && (
        <div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
          <div style={{ fontSize: 11.5, fontWeight: 600, color: 'var(--ol-ink-4)', letterSpacing: '.04em', textTransform: 'uppercase' }}>
            {t('settings.providers.localAsrDownloadedTitle')}
          </div>
          {downloaded.map(m => (
            <div
              key={m.id}
              style={{
                display: 'flex',
                alignItems: 'center',
                justifyContent: 'space-between',
                padding: '6px 10px',
                borderRadius: 6,
                background: 'rgba(0,0,0,0.03)',
                fontSize: 12.5,
                color: 'var(--ol-ink-2)',
              }}>
              <div style={{ display: 'flex', alignItems: 'center', gap: 8, minWidth: 0 }}>
                <span style={{ fontWeight: 500 }}>{m.id}</span>
                <span style={{ fontSize: 11, color: 'var(--ol-ink-4)' }}>
                  {formatBytes(m.downloadedBytes)}
                </span>
              </div>
              <Btn
                variant="ghost"
                size="sm"
                disabled={deletingId === m.id}
                onClick={() => void handleDelete(m.id)}>
                {t('settings.providers.localAsrDelete')}
              </Btn>
            </div>
          ))}
        </div>
      )}
    </div>
  );
}

function formatBytes(n: number): string {
  if (n < 1024) return `${n} B`;
  if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
  if (n < 1024 * 1024 * 1024) return `${(n / 1024 / 1024).toFixed(0)} MB`;
  return `${(n / 1024 / 1024 / 1024).toFixed(2)} GB`;
}
