// ============================================================
// SETTINGS — account security, integrations, personal templates.
//
// This is the first of three commits porting the vanilla
// settings.html (13 sub-sections). Commit 1 covers the Settings
// shell + the three Security sub-sections:
//   • Change Password  — /api/auth/change-password
//   • Two-Factor Auth  — /api/auth/{setup,verify,disable}-2fa
//                        + /api/auth/2fa/backup-codes/count
//                        + /api/auth/2fa/backup-codes (regen)
//   • Active Sessions  — /api/sessions (list, revoke one, revoke all)
//
// Gating: Security sub-sections only render for users with a real
// local password (canLocalAuth === true). SSO-only users see a brief
// notice instead — password/2FA for them live in the IdP.
//
// Integrations (Nextcloud, Documents) and Voice + Content sections
// land in follow-up commits.
// ============================================================

import { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { api } from '@/lib/api';
import ConfirmModal from '@/components/ConfirmModal';
import type {
  MeOk,
  AuthUser,
  SessionsOk,
  SessionRow,
  Setup2faOk,
  Verify2faOk,
  BackupCodesCountOk,
  RegenBackupCodesOk,
  ChangePasswordOk,
  RevokeAllSessionsOk,
  NextcloudConnectOk,
  DocumentsListOk,
  DocumentUploadOk,
  DocumentDownloadOk,
  UserDocument,
  UserPreferencesOk,
  PreferencesOptionsOk,
  VoiceOption,
  SavedEncountersListOk,
  SavedEncounterRow,
  AudioBackupsListOk,
  AudioBackupRow,
  MemoriesOk,
  MemoryRow,
} from '@/shared/types';

// ── Small presentational bits ────────────────────────────────
const card = 'rounded-lg border border-border bg-card p-5 space-y-3';
const input = 'w-full rounded-md border border-input bg-background px-3 py-2 text-sm';
const btnPrimary = 'rounded-md bg-primary text-primary-foreground px-3 py-2 text-sm font-medium disabled:opacity-50';
const btnGhost = 'rounded-md border border-border px-3 py-2 text-sm disabled:opacity-50';
const btnDanger = 'rounded-md bg-destructive text-white px-3 py-2 text-sm font-medium disabled:opacity-50';

type Msg = { text: string; kind: 'ok' | 'err' | 'info' } | null;

function StatusLine({ msg }: { msg: Msg }) {
  if (!msg) return null;
  const color =
    msg.kind === 'ok' ? 'text-green-600'
    : msg.kind === 'err' ? 'text-destructive'
    : 'text-muted-foreground';
  return <div className={'text-sm ' + color}>{msg.text}</div>;
}

function timeAgo(iso: string): string {
  const s = Math.floor((Date.now() - new Date(iso).getTime()) / 1000);
  if (s < 60) return 'just now';
  if (s < 3600) return Math.floor(s / 60) + 'm ago';
  if (s < 86400) return Math.floor(s / 3600) + 'h ago';
  return Math.floor(s / 86400) + 'd ago';
}

// ── Change Password ─────────────────────────────────────────
function ChangePasswordCard() {
  const qc = useQueryClient();
  const [current, setCurrent] = useState('');
  const [newPw, setNewPw] = useState('');
  const [confirmPw, setConfirmPw] = useState('');
  const [msg, setMsg] = useState<Msg>(null);

  const mutation = useMutation({
    mutationFn: (body: { currentPassword: string; newPassword: string }) =>
      api.post<ChangePasswordOk>('/api/auth/change-password', body),
    onSuccess: (data) => {
      setMsg({ text: data.message || 'Password changed', kind: 'ok' });
      setCurrent('');
      setNewPw('');
      setConfirmPw('');
      // Server destroys all other sessions on success — refresh the list.
      qc.invalidateQueries({ queryKey: ['sessions'] });
      if (data.passwordWarning) {
        setTimeout(() => setMsg({ text: data.passwordWarning!, kind: 'info' }), 2000);
      }
    },
    onError: (e: Error) => setMsg({ text: e.message, kind: 'err' }),
  });

  function submit(e: React.FormEvent) {
    e.preventDefault();
    setMsg(null);
    if (!current || !newPw) return setMsg({ text: 'Fill in all fields', kind: 'err' });
    if (newPw.length < 8) return setMsg({ text: 'New password must be 8+ characters', kind: 'err' });
    if (newPw !== confirmPw) return setMsg({ text: 'Passwords do not match', kind: 'err' });
    mutation.mutate({ currentPassword: current, newPassword: newPw });
  }

  return (
    <section className={card} data-testid="change-password-section">
      <h3 className="text-base font-semibold">Change Password</h3>
      <form onSubmit={submit} className="space-y-2 max-w-sm">
        <input
          type="password"
          className={input}
          placeholder="Current password"
          value={current}
          onChange={(e) => setCurrent(e.target.value)}
          data-testid="pw-current"
        />
        <input
          type="password"
          className={input}
          placeholder="New password (8+ characters)"
          minLength={8}
          value={newPw}
          onChange={(e) => setNewPw(e.target.value)}
          data-testid="pw-new"
        />
        <input
          type="password"
          className={input}
          placeholder="Confirm new password"
          minLength={8}
          value={confirmPw}
          onChange={(e) => setConfirmPw(e.target.value)}
          data-testid="pw-confirm"
        />
        <div className="flex items-center gap-3">
          <button
            type="submit"
            disabled={mutation.isPending}
            className={btnPrimary}
            data-testid="btn-change-password"
          >
            {mutation.isPending ? 'Changing…' : 'Change Password'}
          </button>
          <StatusLine msg={msg} />
        </div>
      </form>
    </section>
  );
}

// ── Backup codes modal — shown once after first 2FA enable ──
function BackupCodesDisplay({ codes, onClose }: { codes: string[]; onClose: () => void }) {
  return (
    <div
      role="dialog"
      aria-modal="true"
      className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"
    >
      <div className="w-full max-w-md rounded-lg border border-border bg-background p-5 shadow-lg space-y-3">
        <h3 className="text-base font-semibold">Save your backup codes</h3>
        <p className="text-sm text-muted-foreground">
          Each code can be used once if you lose access to your authenticator. They will not be shown again.
        </p>
        <pre className="bg-muted p-3 rounded text-sm font-mono whitespace-pre-wrap break-all">
          {codes.join('\n')}
        </pre>
        <div className="flex justify-end gap-2">
          <button
            type="button"
            onClick={() => navigator.clipboard?.writeText(codes.join('\n'))}
            className={btnGhost}
          >
            Copy
          </button>
          <button type="button" onClick={onClose} className={btnPrimary}>
            I've saved them
          </button>
        </div>
      </div>
    </div>
  );
}

// ── Two-Factor Authentication ───────────────────────────────
function TwoFactorCard({ user }: { user: AuthUser }) {
  const qc = useQueryClient();
  const enabled = user.totp_enabled === true;

  const [phase, setPhase] = useState<'idle' | 'setup' | 'disabling'>('idle');
  const [qr, setQr] = useState<Setup2faOk | null>(null);
  const [verifyCode, setVerifyCode] = useState('');
  const [disablePw, setDisablePw] = useState('');
  const [shownBackupCodes, setShownBackupCodes] = useState<string[] | null>(null);
  const [regenOpen, setRegenOpen] = useState(false);
  const [msg, setMsg] = useState<Msg>(null);

  const { data: backupCount } = useQuery<BackupCodesCountOk>({
    queryKey: ['2fa-backup-count'],
    queryFn: () => api.get<BackupCodesCountOk>('/api/auth/2fa/backup-codes/count'),
    enabled,
  });

  const setupMutation = useMutation({
    mutationFn: () => api.post<Setup2faOk>('/api/auth/setup-2fa', {}),
    onSuccess: (data) => {
      setQr(data);
      setPhase('setup');
      setMsg(null);
    },
    onError: (e: Error) => setMsg({ text: e.message, kind: 'err' }),
  });

  const verifyMutation = useMutation({
    mutationFn: (code: string) =>
      api.post<Verify2faOk>('/api/auth/verify-2fa', { code }),
    onSuccess: (data) => {
      setPhase('idle');
      setVerifyCode('');
      setQr(null);
      if (data.backupCodes && data.backupCodes.length) {
        setShownBackupCodes(data.backupCodes);
      }
      qc.invalidateQueries({ queryKey: ['auth-me'] });
      qc.invalidateQueries({ queryKey: ['2fa-backup-count'] });
      setMsg({ text: '2FA enabled', kind: 'ok' });
    },
    onError: (e: Error) => setMsg({ text: e.message || 'Invalid code', kind: 'err' }),
  });

  const disableMutation = useMutation({
    mutationFn: (password: string) =>
      api.post<{ success: true }>('/api/auth/disable-2fa', { password }),
    onSuccess: () => {
      setPhase('idle');
      setDisablePw('');
      qc.invalidateQueries({ queryKey: ['auth-me'] });
      qc.invalidateQueries({ queryKey: ['2fa-backup-count'] });
      setMsg({ text: '2FA disabled', kind: 'info' });
    },
    onError: (e: Error) => setMsg({ text: e.message || 'Failed', kind: 'err' }),
  });

  const regenMutation = useMutation({
    mutationFn: (password: string) =>
      api.post<RegenBackupCodesOk>('/api/auth/2fa/backup-codes', { password }),
    onSuccess: (data) => {
      setRegenOpen(false);
      if (data.codes && data.codes.length) setShownBackupCodes(data.codes);
      qc.invalidateQueries({ queryKey: ['2fa-backup-count'] });
    },
    onError: (e: Error) => {
      setRegenOpen(false);
      setMsg({ text: e.message || 'Failed to regenerate codes', kind: 'err' });
    },
  });

  return (
    <section className={card} data-testid="2fa-section">
      <h3 className="text-base font-semibold">Two-Factor Authentication</h3>
      <p className="text-sm" data-testid="2fa-status">
        Status:{' '}
        <span className={enabled ? 'text-green-600 font-medium' : 'text-destructive font-medium'}>
          {enabled ? '✅ Enabled' : '❌ Not enabled'}
        </span>
      </p>

      {!enabled && phase === 'idle' && (
        <button
          type="button"
          className={btnPrimary}
          onClick={() => setupMutation.mutate()}
          disabled={setupMutation.isPending}
          data-testid="btn-setup-2fa"
        >
          {setupMutation.isPending ? 'Setting up…' : 'Enable 2FA'}
        </button>
      )}

      {enabled && phase === 'idle' && (
        <div className="flex items-center gap-3 flex-wrap">
          <button
            type="button"
            className={btnGhost + ' text-destructive'}
            onClick={() => setPhase('disabling')}
            data-testid="btn-disable-2fa"
          >
            Disable 2FA
          </button>
          {backupCount && (
            <span className={'text-sm ' + (backupCount.remaining <= 2 ? 'text-orange-500' : 'text-muted-foreground')}>
              {backupCount.remaining} backup codes remaining.
            </span>
          )}
          {backupCount && (
            <button
              type="button"
              className={btnGhost}
              onClick={() => setRegenOpen(true)}
              data-testid="btn-regen-backup-codes"
            >
              Regenerate
            </button>
          )}
        </div>
      )}

      {phase === 'setup' && qr && (
        <div className="space-y-3 p-3 bg-muted/40 rounded-md">
          <p className="text-sm">Scan this QR code with your authenticator app:</p>
          <img src={qr.qrCode} alt="2FA QR code" className="bg-white p-2 rounded max-w-[240px]" />
          <p className="text-sm">
            Or enter manually: <code className="bg-muted px-2 py-0.5 rounded text-xs">{qr.secret}</code>
          </p>
          <div className="flex items-center gap-2 flex-wrap">
            <input
              type="text"
              maxLength={6}
              className={input + ' max-w-[140px] font-mono tracking-widest'}
              placeholder="123456"
              value={verifyCode}
              onChange={(e) => setVerifyCode(e.target.value.replace(/\D/g, ''))}
              data-testid="2fa-verify-code"
            />
            <button
              type="button"
              className={btnPrimary}
              disabled={verifyCode.length !== 6 || verifyMutation.isPending}
              onClick={() => verifyMutation.mutate(verifyCode)}
              data-testid="btn-verify-2fa"
            >
              {verifyMutation.isPending ? 'Verifying…' : 'Verify & Enable'}
            </button>
            <button
              type="button"
              className={btnGhost}
              onClick={() => {
                setPhase('idle');
                setQr(null);
                setVerifyCode('');
              }}
            >
              Cancel
            </button>
          </div>
        </div>
      )}

      {phase === 'disabling' && (
        <div className="space-y-2 p-3 bg-muted/40 rounded-md max-w-sm">
          <label className="block text-sm font-medium">Enter your password to confirm:</label>
          <input
            type="password"
            className={input}
            value={disablePw}
            onChange={(e) => setDisablePw(e.target.value)}
            placeholder="Password"
            data-testid="2fa-disable-password"
          />
          <div className="flex gap-2">
            <button
              type="button"
              className={btnDanger}
              disabled={!disablePw || disableMutation.isPending}
              onClick={() => disableMutation.mutate(disablePw)}
              data-testid="btn-disable-2fa-confirm"
            >
              {disableMutation.isPending ? 'Disabling…' : 'Confirm Disable'}
            </button>
            <button
              type="button"
              className={btnGhost}
              onClick={() => {
                setPhase('idle');
                setDisablePw('');
              }}
              data-testid="btn-disable-2fa-cancel"
            >
              Cancel
            </button>
          </div>
        </div>
      )}

      <StatusLine msg={msg} />

      <ConfirmModal
        open={regenOpen}
        title="Regenerate backup codes?"
        body="This invalidates your existing codes. Enter your current password to confirm."
        confirmText="Regenerate"
        danger
        requirePassword
        passwordPlaceholder="Current password"
        busy={regenMutation.isPending}
        onConfirm={(pw) => pw && regenMutation.mutate(pw)}
        onCancel={() => setRegenOpen(false)}
      />

      {shownBackupCodes && (
        <BackupCodesDisplay
          codes={shownBackupCodes}
          onClose={() => setShownBackupCodes(null)}
        />
      )}
    </section>
  );
}

// ── Active Sessions ─────────────────────────────────────────
function SessionRowItem({
  s,
  isCurrent,
  onRevoke,
}: {
  s: SessionRow;
  isCurrent: boolean;
  onRevoke: () => void;
}) {
  return (
    <div
      className={
        'flex items-center justify-between gap-3 rounded-md border-2 px-3 py-2 ' +
        (isCurrent ? 'border-primary bg-primary/5' : 'border-border bg-muted/40')
      }
      data-testid={'session-row-' + s.id}
    >
      <div className="min-w-0">
        <div className="text-sm font-medium truncate">
          {s.device_label || 'Unknown device'}
          {isCurrent && <span className="ml-2 text-xs text-primary font-medium">(this device)</span>}
        </div>
        <div className="text-xs text-muted-foreground">
          {s.ip_address || '—'} · Created {new Date(s.created_at).toLocaleDateString()} · Active {timeAgo(s.last_activity)}
        </div>
      </div>
      {!isCurrent && (
        <button
          type="button"
          className={btnGhost + ' text-destructive text-xs'}
          onClick={onRevoke}
          data-testid={'btn-revoke-session-' + s.id}
        >
          Revoke
        </button>
      )}
    </div>
  );
}

function SessionsCard() {
  const qc = useQueryClient();
  const [confirm, setConfirm] = useState<
    | { kind: 'one'; id: string }
    | { kind: 'all' }
    | null
  >(null);
  const [msg, setMsg] = useState<Msg>(null);

  const { data, isLoading, error } = useQuery<SessionsOk>({
    queryKey: ['sessions'],
    queryFn: () => api.get<SessionsOk>('/api/sessions'),
  });

  const revokeOne = useMutation({
    mutationFn: (id: string) => api.delete<{ success: true }>('/api/sessions/' + id),
    onSuccess: () => {
      setMsg({ text: 'Session revoked', kind: 'info' });
      qc.invalidateQueries({ queryKey: ['sessions'] });
    },
    onError: (e: Error) => setMsg({ text: e.message, kind: 'err' }),
  });

  const revokeAll = useMutation({
    mutationFn: () => api.delete<RevokeAllSessionsOk>('/api/sessions'),
    onSuccess: (data) => {
      setMsg({ text: 'All other sessions revoked (' + (data.revoked || 0) + ' removed)', kind: 'info' });
      qc.invalidateQueries({ queryKey: ['sessions'] });
    },
    onError: (e: Error) => setMsg({ text: e.message, kind: 'err' }),
  });

  const busy = revokeOne.isPending || revokeAll.isPending;

  return (
    <section className={card} data-testid="sessions-section">
      <h3 className="text-base font-semibold">Active Sessions</h3>
      <p className="text-sm text-muted-foreground">
        Devices where you are currently logged in. Revoke any session to immediately log that device out.
      </p>
      <button
        type="button"
        className={btnGhost + ' text-destructive text-xs'}
        onClick={() => setConfirm({ kind: 'all' })}
        data-testid="btn-revoke-all-sessions"
      >
        Revoke All Other Sessions
      </button>

      {isLoading && <div className="text-sm text-muted-foreground">Loading sessions…</div>}
      {error && <div className="text-sm text-destructive">Could not load sessions.</div>}

      <div className="space-y-2">
        {data?.sessions.map((s) => (
          <SessionRowItem
            key={s.id}
            s={s}
            isCurrent={s.id === data.currentSessionId}
            onRevoke={() => setConfirm({ kind: 'one', id: s.id })}
          />
        ))}
        {data && data.sessions.length === 0 && (
          <div className="text-sm text-muted-foreground">No active sessions found.</div>
        )}
      </div>

      <StatusLine msg={msg} />

      <ConfirmModal
        open={confirm?.kind === 'one'}
        title="Revoke this session?"
        body="That device will be logged out."
        confirmText="Revoke"
        danger
        busy={busy}
        onConfirm={() => {
          if (confirm?.kind === 'one') revokeOne.mutate(confirm.id);
          setConfirm(null);
        }}
        onCancel={() => setConfirm(null)}
      />
      <ConfirmModal
        open={confirm?.kind === 'all'}
        title="Revoke all other sessions?"
        body="All other devices will be logged out."
        confirmText="Revoke All"
        danger
        busy={busy}
        onConfirm={() => {
          revokeAll.mutate();
          setConfirm(null);
        }}
        onCancel={() => setConfirm(null)}
      />
    </section>
  );
}

// ── Nextcloud Integration ───────────────────────────────────
function NextcloudCard({ user }: { user: AuthUser }) {
  const qc = useQueryClient();
  const connected = !!user.nextcloud_url;

  const [url, setUrl] = useState(user.nextcloud_url || '');
  const [username, setUsername] = useState(user.nextcloud_user || '');
  const [appPassword, setAppPassword] = useState('');
  const [webdavPath, setWebdavPath] = useState(user.webdav_learning_path || '');
  const [disconnectOpen, setDisconnectOpen] = useState(false);
  const [msg, setMsg] = useState<Msg>(null);

  const connect = useMutation({
    mutationFn: (body: { nextcloudUrl: string; username: string; appPassword: string }) =>
      api.post<NextcloudConnectOk>('/api/nextcloud/connect', body),
    onSuccess: (data) => {
      setMsg({ text: data.message || 'Connected', kind: 'ok' });
      setAppPassword('');
      qc.invalidateQueries({ queryKey: ['auth-me'] });
    },
    onError: (e: Error) => setMsg({ text: e.message || 'Connection failed', kind: 'err' }),
  });

  const disconnect = useMutation({
    mutationFn: () => api.post<{ success: true }>('/api/nextcloud/disconnect', {}),
    onSuccess: () => {
      setMsg({ text: 'Disconnected', kind: 'info' });
      setAppPassword('');
      qc.invalidateQueries({ queryKey: ['auth-me'] });
    },
    onError: (e: Error) => setMsg({ text: e.message, kind: 'err' }),
  });

  const savePath = useMutation({
    mutationFn: (path: string) =>
      api.post<{ success: true }>('/api/user/webdav-path', { path }),
    onSuccess: () => {
      setMsg({ text: 'Path saved', kind: 'ok' });
      qc.invalidateQueries({ queryKey: ['auth-me'] });
    },
    onError: (e: Error) => setMsg({ text: e.message || 'Failed to save', kind: 'err' }),
  });

  function onConnect(e: React.FormEvent) {
    e.preventDefault();
    setMsg(null);
    const cleanUrl = url.trim().replace(/\/+$/, '');
    const u = username.trim();
    const p = appPassword.trim();
    if (!cleanUrl || !u || !p) return setMsg({ text: 'Fill all Nextcloud fields', kind: 'err' });
    connect.mutate({ nextcloudUrl: cleanUrl, username: u, appPassword: p });
  }

  return (
    <section className={card} data-testid="nextcloud-section">
      <h3 className="text-base font-semibold">Nextcloud Integration</h3>
      <p className="text-sm text-muted-foreground">Export generated documents to your Nextcloud.</p>
      <div className="text-sm" data-testid="nc-status">
        {connected ? (
          <>✅ Connected to <strong>{user.nextcloud_url}</strong> as {user.nextcloud_user}</>
        ) : (
          <>Not connected</>
        )}
      </div>

      <form onSubmit={onConnect} className="space-y-2 max-w-md">
        <label className="block text-sm">
          <span className="block text-xs font-semibold text-muted-foreground mb-1">Nextcloud URL</span>
          <input
            type="url"
            className={input}
            value={url}
            onChange={(e) => setUrl(e.target.value)}
            placeholder="https://cloud.example.com"
            data-testid="nc-url"
          />
        </label>
        <label className="block text-sm">
          <span className="block text-xs font-semibold text-muted-foreground mb-1">Username</span>
          <input
            type="text"
            className={input}
            value={username}
            onChange={(e) => setUsername(e.target.value)}
            placeholder="your-username"
            data-testid="nc-user"
          />
        </label>
        <label className="block text-sm">
          <span className="block text-xs font-semibold text-muted-foreground mb-1">App Password</span>
          <input
            type="password"
            className={input}
            value={appPassword}
            onChange={(e) => setAppPassword(e.target.value)}
            placeholder="Generate in Nextcloud → Settings → Security"
            data-testid="nc-pass"
          />
          <span className="block text-xs text-muted-foreground mt-1">
            Go to Nextcloud → Settings → Security → Create new app password
          </span>
        </label>
        <div className="flex gap-2 flex-wrap">
          <button
            type="submit"
            className={btnPrimary}
            disabled={connect.isPending}
            data-testid="btn-nc-connect"
          >
            {connect.isPending ? 'Connecting…' : connected ? 'Reconnect' : 'Connect'}
          </button>
          {connected && (
            <button
              type="button"
              className={btnGhost + ' text-destructive'}
              onClick={() => setDisconnectOpen(true)}
              data-testid="btn-nc-disconnect"
            >
              Disconnect
            </button>
          )}
        </div>
      </form>

      {connected && (
        <div className="border-t border-border pt-3 space-y-2 max-w-md">
          <label className="block text-sm">
            <span className="block text-xs font-semibold text-muted-foreground mb-1">
              Learning Hub — Default Browse Path
            </span>
            <span className="block text-xs text-muted-foreground mb-2">
              Folder opened first when picking files for AI content generation (e.g. <code>/Medical-Resources</code>)
            </span>
            <div className="flex gap-2">
              <input
                type="text"
                className={input}
                value={webdavPath}
                onChange={(e) => setWebdavPath(e.target.value)}
                placeholder="/Medical-Resources"
                data-testid="nc-webdav-path"
              />
              <button
                type="button"
                className={btnPrimary}
                disabled={savePath.isPending}
                onClick={() => savePath.mutate(webdavPath.trim())}
                data-testid="btn-nc-save-path"
              >
                {savePath.isPending ? 'Saving…' : 'Save Path'}
              </button>
            </div>
          </label>
        </div>
      )}

      <StatusLine msg={msg} />

      <ConfirmModal
        open={disconnectOpen}
        title="Disconnect Nextcloud?"
        body="Future exports will fail until you reconnect. Your stored credentials will be cleared."
        confirmText="Disconnect"
        danger
        busy={disconnect.isPending}
        onConfirm={() => {
          disconnect.mutate();
          setDisconnectOpen(false);
        }}
        onCancel={() => setDisconnectOpen(false)}
      />
    </section>
  );
}

// ── Documents (S3) ──────────────────────────────────────────
function formatSize(bytes: number): string {
  if (bytes < 1024) return bytes + ' B';
  if (bytes < 1048576) return Math.round(bytes / 1024) + ' KB';
  return (bytes / 1048576).toFixed(1) + ' MB';
}

function DocumentRow({
  doc,
  onDownload,
  onDelete,
  downloading,
}: {
  doc: UserDocument;
  onDownload: () => void;
  onDelete: () => void;
  downloading: boolean;
}) {
  return (
    <div
      className="flex items-center gap-2 px-3 py-2 rounded-md bg-muted/40 border border-border"
      data-testid={'doc-row-' + doc.id}
    >
      <div className="flex-1 min-w-0">
        <div className="text-sm font-medium truncate">{doc.filename}</div>
        <div className="text-xs text-muted-foreground">
          {formatSize(doc.size_bytes)} · {new Date(doc.created_at).toLocaleDateString()}
          {doc.description ? ' · ' + doc.description : ''}
        </div>
      </div>
      <button
        type="button"
        className={btnPrimary + ' text-xs'}
        disabled={downloading}
        onClick={onDownload}
        data-testid={'btn-doc-download-' + doc.id}
      >
        {downloading ? '…' : 'Download'}
      </button>
      <button
        type="button"
        className={btnGhost + ' text-destructive text-xs'}
        onClick={onDelete}
        data-testid={'btn-doc-delete-' + doc.id}
      >
        Delete
      </button>
    </div>
  );
}

function DocumentsCard() {
  const qc = useQueryClient();
  const [file, setFile] = useState<File | null>(null);
  const [description, setDescription] = useState('');
  const [msg, setMsg] = useState<Msg>(null);
  const [deleteTarget, setDeleteTarget] = useState<UserDocument | null>(null);

  const { data, isLoading, error } = useQuery<DocumentsListOk>({
    queryKey: ['documents'],
    queryFn: () => api.get<DocumentsListOk>('/api/documents'),
  });

  const upload = useMutation({
    mutationFn: async (body: { file: File; description: string }) => {
      const form = new FormData();
      form.append('file', body.file);
      form.append('description', body.description);
      // Multipart body — bypass the JSON-only api wrapper. Cookies auto-sent.
      const resp = await fetch('/api/documents/upload', {
        method: 'POST',
        credentials: 'include',
        body: form,
      });
      const ct = resp.headers.get('content-type') || '';
      const parsed = ct.includes('application/json') ? await resp.json() : null;
      if (!resp.ok || (parsed && parsed.success === false)) {
        throw new Error((parsed && parsed.error) || resp.statusText);
      }
      return parsed as { success: true } & DocumentUploadOk;
    },
    onSuccess: (data) => {
      setMsg({ text: 'Document uploaded: ' + data.filename, kind: 'ok' });
      setFile(null);
      setDescription('');
      qc.invalidateQueries({ queryKey: ['documents'] });
    },
    onError: (e: Error) => setMsg({ text: 'Upload failed: ' + e.message, kind: 'err' }),
  });

  const del = useMutation({
    mutationFn: (id: number) => api.delete<{ success: true }>('/api/documents/' + id),
    onSuccess: () => {
      setMsg({ text: 'Document deleted', kind: 'info' });
      qc.invalidateQueries({ queryKey: ['documents'] });
    },
    onError: (e: Error) => setMsg({ text: e.message || 'Delete failed', kind: 'err' }),
  });

  const download = useMutation({
    mutationFn: (id: number) =>
      api.get<DocumentDownloadOk>('/api/documents/' + id + '/download'),
    onSuccess: (data) => {
      if (data.url) {
        // Open presigned URL in a new tab; it's short-lived (300s).
        window.open(data.url, '_blank', 'noopener,noreferrer');
      } else {
        setMsg({ text: 'Download failed', kind: 'err' });
      }
    },
    onError: (e: Error) => setMsg({ text: e.message || 'Download failed', kind: 'err' }),
  });

  function submitUpload(e: React.FormEvent) {
    e.preventDefault();
    setMsg(null);
    if (!file) return setMsg({ text: 'Select a file first', kind: 'err' });
    upload.mutate({ file, description });
  }

  return (
    <section className={card} data-testid="documents-section">
      <h3 className="text-base font-semibold">Documents</h3>
      <p className="text-sm text-muted-foreground">
        Upload and manage documents via S3 storage (PDF, images, Word docs, text files). Max 10 MB per file.
      </p>

      {isLoading && <div className="text-sm text-muted-foreground">Loading…</div>}
      {error && <div className="text-sm text-destructive">Failed to load documents.</div>}

      {data && !data.s3_configured && (
        <div className="text-sm text-muted-foreground italic">
          S3 storage not configured. Set S3_BUCKET in server environment.
        </div>
      )}

      {data && data.s3_configured && (
        <>
          <form onSubmit={submitUpload} className="flex flex-wrap gap-2 items-center" data-testid="doc-upload-area">
            <input
              type="file"
              className="text-sm"
              accept=".pdf,.jpg,.jpeg,.png,.gif,.doc,.docx,.txt,.csv"
              onChange={(e) => setFile(e.target.files?.[0] ?? null)}
              data-testid="doc-file-input"
            />
            <input
              type="text"
              className={input + ' max-w-xs'}
              placeholder="Description (optional)"
              value={description}
              onChange={(e) => setDescription(e.target.value)}
              data-testid="doc-description"
            />
            <button
              type="submit"
              className={btnPrimary}
              disabled={!file || upload.isPending}
              data-testid="btn-doc-upload"
            >
              {upload.isPending ? 'Uploading…' : 'Upload'}
            </button>
          </form>

          <div className="space-y-2">
            {data.documents.length === 0 ? (
              <div className="text-sm text-muted-foreground">No documents uploaded yet.</div>
            ) : (
              data.documents.map((d) => (
                <DocumentRow
                  key={d.id}
                  doc={d}
                  onDownload={() => download.mutate(d.id)}
                  onDelete={() => setDeleteTarget(d)}
                  downloading={download.isPending && download.variables === d.id}
                />
              ))
            )}
          </div>
        </>
      )}

      <StatusLine msg={msg} />

      <ConfirmModal
        open={!!deleteTarget}
        title="Delete this document permanently?"
        body={deleteTarget?.filename}
        confirmText="Delete"
        danger
        busy={del.isPending}
        onConfirm={() => {
          if (deleteTarget) del.mutate(deleteTarget.id);
          setDeleteTarget(null);
        }}
        onCancel={() => setDeleteTarget(null)}
      />
    </section>
  );
}

// ── Voice Preferences (STT model + TTS voice) ───────────────
function VoicePreferencesCard() {
  const qc = useQueryClient();
  const [msg, setMsg] = useState<Msg>(null);
  const [sttModel, setSttModel] = useState('');
  const [ttsVoice, setTtsVoice] = useState('');
  const [previewing, setPreviewing] = useState(false);
  const [hydrated, setHydrated] = useState(false);

  const { data: options } = useQuery<PreferencesOptionsOk>({
    queryKey: ['voice-options'],
    queryFn: () => api.get<PreferencesOptionsOk>('/api/user/preferences/options'),
  });
  const { data: prefs } = useQuery<UserPreferencesOk>({
    queryKey: ['voice-prefs'],
    queryFn: () => api.get<UserPreferencesOk>('/api/user/preferences'),
  });

  // Hydrate selection from the server once — subsequent typing is local.
  if (!hydrated && prefs) {
    setSttModel(prefs.stt_model || '');
    setTtsVoice(prefs.tts_voice || '');
    setHydrated(true);
  }

  const save = useMutation({
    mutationFn: (body: { stt_model: string | null; tts_voice: string | null }) =>
      api.post<{ success: true }>('/api/user/preferences', body),
    onSuccess: () => {
      setMsg({ text: 'Voice preferences saved', kind: 'ok' });
      qc.invalidateQueries({ queryKey: ['voice-prefs'] });
    },
    onError: (e: Error) => setMsg({ text: e.message || 'Save failed', kind: 'err' }),
  });

  async function previewVoice() {
    setMsg(null);
    setPreviewing(true);
    try {
      // First persist the selection so the server renders with the chosen voice.
      await api.post('/api/user/preferences', { tts_voice: ttsVoice || null });
      qc.invalidateQueries({ queryKey: ['voice-prefs'] });

      const text =
        'Hello, this is a preview of the ' +
        (ttsVoice || 'server default') +
        ' voice. This is how your read-aloud feature will sound.';
      const resp = await fetch('/api/text-to-speech', {
        method: 'POST',
        credentials: 'include',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ text }),
      });
      if (!resp.ok) throw new Error('Preview failed');
      const blob = await resp.blob();
      const url = URL.createObjectURL(blob);
      const audio = new Audio(url);
      audio.onended = () => URL.revokeObjectURL(url);
      await audio.play();
    } catch (e) {
      setMsg({ text: 'Preview failed: ' + (e as Error).message, kind: 'err' });
    } finally {
      setPreviewing(false);
    }
  }

  return (
    <section className={card} data-testid="voice-preferences-section">
      <h3 className="text-base font-semibold">Voice Preferences</h3>
      <p className="text-sm text-muted-foreground">
        Customize your speech-to-text model and text-to-speech voice. These settings apply to all recording and read-aloud features.
      </p>

      <div className="space-y-2 max-w-md">
        <label className="block text-sm">
          <span className="block text-xs font-semibold text-muted-foreground mb-1">
            Speech-to-Text Model (Transcription)
          </span>
          <select
            className={input}
            value={sttModel}
            onChange={(e) => setSttModel(e.target.value)}
            data-testid="stt-model-select"
          >
            <option value="">Server default{options ? ' (' + options.sttProvider + ')' : ''}</option>
            {options?.sttModels.map((m) => (
              <option key={m.value} value={m.value}>{m.label}</option>
            ))}
          </select>
        </label>

        <label className="block text-sm">
          <span className="block text-xs font-semibold text-muted-foreground mb-1">
            Text-to-Speech Voice (Read Aloud)
          </span>
          <div className="flex gap-2">
            <select
              className={input}
              value={ttsVoice}
              onChange={(e) => setTtsVoice(e.target.value)}
              data-testid="tts-voice-select"
            >
              <option value="">Server default{options ? ' (' + options.ttsProvider + ')' : ''}</option>
              {options?.ttsVoices.map((v) => (
                <option key={v.value} value={v.value}>{v.label}</option>
              ))}
            </select>
            <button
              type="button"
              className={btnGhost}
              disabled={previewing}
              onClick={previewVoice}
              data-testid="btn-preview-voice"
            >
              {previewing ? 'Loading…' : 'Preview'}
            </button>
          </div>
        </label>

        <button
          type="button"
          className={btnPrimary}
          disabled={save.isPending}
          onClick={() =>
            save.mutate({
              stt_model: sttModel || null,
              tts_voice: ttsVoice || null,
            })
          }
          data-testid="btn-save-voice-prefs"
        >
          {save.isPending ? 'Saving…' : 'Save Voice Preferences'}
        </button>
      </div>

      <StatusLine msg={msg} />
    </section>
  );
}

// ── Browser Whisper (WASM, local transcription) ─────────────
// UI-only port: persists enabled + model choice in localStorage under the
// same keys the vanilla BrowserWhisper module reads. The actual WASM
// model preload / transcription still lives in the vanilla
// public/js/browserWhisper.js module and will wire through when the
// recording components port to React.
const BW_ENABLED_KEY = 'ped_browser_whisper_enabled';
const BW_MODEL_KEY = 'ped_browser_whisper_model';
const BW_MODELS: VoiceOption[] = [
  { value: 'Xenova/whisper-tiny.en',  label: 'Tiny (~39MB) — fastest, ~2-3s' },
  { value: 'Xenova/whisper-base.en',  label: 'Base (~74MB) — balanced, ~3-5s' },
  { value: 'Xenova/whisper-small.en', label: 'Small (~244MB) — best quality, ~6-10s' },
];
function readLS(k: string, fallback = ''): string {
  try { return window.localStorage.getItem(k) ?? fallback; } catch { return fallback; }
}
function writeLS(k: string, v: string) {
  try { window.localStorage.setItem(k, v); } catch { /* ignore */ }
}

function BrowserWhisperCard() {
  const [enabled, setEnabled] = useState(readLS(BW_ENABLED_KEY) === 'true');
  const [model, setModel] = useState(readLS(BW_MODEL_KEY) || BW_MODELS[0].value);

  function toggle(next: boolean) {
    setEnabled(next);
    writeLS(BW_ENABLED_KEY, String(next));
  }
  function changeModel(next: string) {
    setModel(next);
    writeLS(BW_MODEL_KEY, next);
  }

  return (
    <section className={card} data-testid="browser-whisper-section">
      <h3 className="text-base font-semibold">Browser Transcription (Local Whisper)</h3>
      <p className="text-sm text-muted-foreground">
        Transcribes audio entirely in your browser — no audio sent to any server. Powered by OpenAI Whisper running in WebAssembly. Model is downloaded once and cached locally.
      </p>
      <div className="flex items-center gap-3 flex-wrap">
        <label className="text-sm font-medium">Enable browser transcription:</label>
        <label className="flex items-center gap-2 cursor-pointer">
          <input
            type="checkbox"
            className="accent-primary size-4"
            checked={enabled}
            onChange={(e) => toggle(e.target.checked)}
            data-testid="browser-whisper-enabled"
          />
          <span className="text-sm" data-testid="browser-whisper-status">
            {enabled ? 'On — audio stays on device' : 'Off'}
          </span>
        </label>
      </div>
      <div className="flex items-center gap-3 flex-wrap">
        <label className="text-sm font-medium">Model:</label>
        <select
          className={input + ' max-w-md'}
          value={model}
          onChange={(e) => changeModel(e.target.value)}
          data-testid="browser-whisper-model"
        >
          {BW_MODELS.map((m) => (
            <option key={m.value} value={m.value}>{m.label}</option>
          ))}
        </select>
      </div>
      <p className="text-xs text-muted-foreground">
        When enabled, overrides server transcription. Falls back to server if browser transcription fails. The actual WASM download runs from the recording components — pre-download will light up when those port to React.
      </p>
    </section>
  );
}

// ── Web Speech Recognition (real-time streaming) ────────────
const WS_ENABLED_KEY = 'ped_web_speech_enabled';

function WebSpeechCard() {
  const [enabled, setEnabled] = useState(readLS(WS_ENABLED_KEY) === 'true');
  const [confirmOpen, setConfirmOpen] = useState(false);

  const supported =
    typeof window !== 'undefined' &&
    ('webkitSpeechRecognition' in window || 'SpeechRecognition' in window);

  function enable() {
    // Enabling Web Speech flips Browser Whisper off (the vanilla app enforces
    // the same priority: Web Speech > Browser Whisper > server).
    writeLS(BW_ENABLED_KEY, 'false');
    setEnabled(true);
    writeLS(WS_ENABLED_KEY, 'true');
  }
  function disable() {
    setEnabled(false);
    writeLS(WS_ENABLED_KEY, 'false');
  }

  return (
    <section className={card + ' border-l-4 border-l-orange-500'} data-testid="web-speech-section">
      <h3 className="text-base font-semibold">Real-Time Streaming Transcription</h3>
      <div className="bg-orange-50 dark:bg-orange-950/30 p-3 rounded-md text-sm text-orange-900 dark:text-orange-100">
        <strong>Privacy Warning:</strong> Uses your browser's built-in speech recognition, which <strong>may send audio to cloud servers</strong> (Chrome/Edge send to Google). Only enable if you accept this trade-off for real-time transcription.
      </div>
      <p className="text-sm text-muted-foreground">
        See words appear as you speak (streaming). Overrides browser and server transcription when enabled. <strong>Not HIPAA-compliant</strong> in most browsers.
      </p>
      <div className="flex items-center gap-3 flex-wrap">
        <label className="text-sm font-medium">Enable real-time streaming:</label>
        <label className="flex items-center gap-2 cursor-pointer">
          <input
            type="checkbox"
            className="accent-orange-500 size-4"
            checked={enabled}
            disabled={!supported}
            onChange={(e) => {
              if (e.target.checked) setConfirmOpen(true);
              else disable();
            }}
            data-testid="web-speech-enabled"
          />
          <span className="text-sm" data-testid="web-speech-status">
            {!supported ? 'Not supported in this browser' : enabled ? 'On — real-time streaming' : 'Off'}
          </span>
        </label>
      </div>
      {supported && (
        <p className="text-xs text-muted-foreground">
          Streaming integration lights up when the recording components port to React.
        </p>
      )}

      <ConfirmModal
        open={confirmOpen}
        title="Enable real-time streaming transcription?"
        body="Your browser's speech recognition may send audio to cloud servers (e.g. Google). This is NOT HIPAA-compliant. Only enable if you understand and accept this privacy trade-off."
        confirmText="Enable"
        danger
        onConfirm={() => {
          enable();
          setConfirmOpen(false);
        }}
        onCancel={() => setConfirmOpen(false)}
      />
    </section>
  );
}

// ── My Templates (Memories, non-correction entries) ─────────
const TEMPLATE_CATEGORIES: Array<{ value: string; label: string }> = [
  { value: 'physical_exam',       label: 'Physical Exam Template' },
  { value: 'ros',                 label: 'Review of Systems Template' },
  { value: 'encounter_format',    label: 'Encounter Note Format' },
  { value: 'family_history',      label: 'Family History Format' },
  { value: 'assessment_plan',     label: 'Assessment & Plan Format' },
  { value: 'template_soap',       label: 'SOAP Note Template' },
  { value: 'template_hpi',        label: 'HPI Template' },
  { value: 'template_wellvisit',  label: 'Well Visit Template' },
  { value: 'template_sickvisit',  label: 'Sick Visit Template' },
  { value: 'custom',              label: 'Custom' },
];
const CATEGORY_LABEL: Record<string, string> = Object.fromEntries(
  TEMPLATE_CATEGORIES.map((c) => [c.value, c.label.replace(/ Template$| Format$/, '')])
);

function TemplatesCard() {
  const qc = useQueryClient();
  const [msg, setMsg] = useState<Msg>(null);
  const [editingId, setEditingId] = useState<number | null>(null);
  const [category, setCategory] = useState(TEMPLATE_CATEGORIES[0].value);
  const [name, setName] = useState('');
  const [content, setContent] = useState('');
  const [deleteTarget, setDeleteTarget] = useState<MemoryRow | null>(null);

  const { data } = useQuery<MemoriesOk>({
    queryKey: ['memories'],
    queryFn: () => api.get<MemoriesOk>('/api/memories'),
  });

  const templates = (data?.memories || []).filter((m) => !m.category.startsWith('correction_'));

  const save = useMutation({
    mutationFn: (body: { id?: number; category: string; name: string; content: string }) => {
      const { id, ...rest } = body;
      return id
        ? api.put<{ success: true }>('/api/memories/' + id, rest)
        : api.post<{ success: true }>('/api/memories', rest);
    },
    onSuccess: () => {
      setMsg({ text: editingId ? 'Template updated' : 'Template saved', kind: 'ok' });
      setEditingId(null);
      setName('');
      setContent('');
      qc.invalidateQueries({ queryKey: ['memories'] });
    },
    onError: (e: Error) => setMsg({ text: e.message || 'Save failed', kind: 'err' }),
  });

  const del = useMutation({
    mutationFn: (id: number) => api.delete<{ success: true }>('/api/memories/' + id),
    onSuccess: () => {
      setMsg({ text: 'Template deleted', kind: 'info' });
      qc.invalidateQueries({ queryKey: ['memories'] });
    },
    onError: (e: Error) => setMsg({ text: e.message || 'Delete failed', kind: 'err' }),
  });

  function beginEdit(m: MemoryRow) {
    setEditingId(m.id);
    setCategory(m.category);
    setName(m.name);
    setContent(m.content);
  }
  function cancelEdit() {
    setEditingId(null);
    setName('');
    setContent('');
  }
  function submit(e: React.FormEvent) {
    e.preventDefault();
    setMsg(null);
    if (!name.trim()) return setMsg({ text: 'Enter a template name', kind: 'err' });
    if (!content.trim()) return setMsg({ text: 'Enter template content', kind: 'err' });
    save.mutate({ id: editingId ?? undefined, category, name: name.trim(), content: content.trim() });
  }

  return (
    <section className={card} data-testid="templates-section">
      <h3 className="text-base font-semibold">My Templates</h3>
      <p className="text-sm text-muted-foreground">
        Save reusable templates for physical exam, ROS, encounter format, etc. The AI will use these when generating notes.
      </p>

      <form onSubmit={submit} className="space-y-2">
        <div className="flex gap-2 flex-wrap items-center">
          <select
            className={input + ' max-w-xs'}
            value={category}
            onChange={(e) => setCategory(e.target.value)}
            data-testid="mem-category"
          >
            {TEMPLATE_CATEGORIES.map((c) => (
              <option key={c.value} value={c.value}>{c.label}</option>
            ))}
          </select>
          <input
            type="text"
            className={input + ' flex-1 min-w-[150px]'}
            placeholder="Template name (e.g. Normal PE)"
            value={name}
            onChange={(e) => setName(e.target.value)}
            data-testid="mem-name"
          />
        </div>
        <textarea
          rows={5}
          className={input + ' resize-y'}
          placeholder="Paste your template here. Example: HEENT: Normocephalic, atraumatic. Eyes: PERRL…"
          value={content}
          onChange={(e) => setContent(e.target.value)}
          data-testid="mem-content"
        />
        <div className="flex gap-2">
          <button
            type="submit"
            className={btnPrimary}
            disabled={save.isPending}
            data-testid="btn-mem-save"
          >
            {save.isPending ? 'Saving…' : editingId ? 'Update Template' : 'Add Template'}
          </button>
          {editingId !== null && (
            <button type="button" className={btnGhost} onClick={cancelEdit}>Cancel</button>
          )}
        </div>
      </form>

      <div className="space-y-2">
        {templates.length === 0 ? (
          <div className="text-sm text-muted-foreground">No templates saved yet. Add one above.</div>
        ) : (
          templates.map((m) => (
            <div
              key={m.id}
              className="flex items-center gap-2 px-3 py-2 rounded-md bg-muted/40 border border-border"
              data-testid={'mem-row-' + m.id}
            >
              <div className="flex-1 min-w-0">
                <div className="flex items-center gap-2 text-sm">
                  <span className="font-medium">{m.name}</span>
                  <span className="text-xs text-muted-foreground uppercase tracking-wide">
                    {CATEGORY_LABEL[m.category] || m.category}
                  </span>
                </div>
                <div className="text-xs text-muted-foreground truncate">
                  {(m.content || '').slice(0, 100).replace(/\n/g, ' ')}
                  {m.content && m.content.length > 100 ? '…' : ''}
                </div>
              </div>
              <button
                type="button"
                className={btnGhost + ' text-xs'}
                onClick={() => beginEdit(m)}
                data-testid={'btn-mem-edit-' + m.id}
              >
                Edit
              </button>
              <button
                type="button"
                className={btnGhost + ' text-destructive text-xs'}
                onClick={() => setDeleteTarget(m)}
                data-testid={'btn-mem-delete-' + m.id}
              >
                Delete
              </button>
            </div>
          ))
        )}
      </div>

      <StatusLine msg={msg} />

      <ConfirmModal
        open={!!deleteTarget}
        title={'Delete template "' + (deleteTarget?.name || '') + '"?'}
        body="This cannot be undone."
        confirmText="Delete"
        danger
        busy={del.isPending}
        onConfirm={() => {
          if (deleteTarget) del.mutate(deleteTarget.id);
          setDeleteTarget(null);
        }}
        onCancel={() => setDeleteTarget(null)}
      />
    </section>
  );
}

// ── AI Corrections (read-only, expandable) ──────────────────
const CORRECTION_LABEL: Record<string, string> = {
  correction_soap:       'SOAP Correction',
  correction_hpi:        'HPI Correction',
  correction_encounter:  'Encounter Correction',
  correction_wellvisit:  'Well Visit Correction',
  correction_sickvisit:  'Sick Visit Correction',
};
function parseCorrection(content: string): { original: string; corrected: string } {
  const idx = content.indexOf('\nCORRECTED TO: ');
  if (idx === -1) return { original: content.trim(), corrected: '' };
  return {
    original:  content.substring(0, idx).replace(/^ORIGINAL:\s*/i, '').trim(),
    corrected: content.substring(idx + '\nCORRECTED TO: '.length).trim(),
  };
}

function CorrectionsCard() {
  const qc = useQueryClient();
  const [expanded, setExpanded] = useState<Record<number, boolean>>({});
  const [msg, setMsg] = useState<Msg>(null);
  const [deleteTarget, setDeleteTarget] = useState<MemoryRow | null>(null);

  const { data } = useQuery<MemoriesOk>({
    queryKey: ['memories'],
    queryFn: () => api.get<MemoriesOk>('/api/memories'),
  });
  const corrections = (data?.memories || []).filter((m) => m.category.startsWith('correction_'));

  const del = useMutation({
    mutationFn: (id: number) => api.delete<{ success: true }>('/api/memories/' + id),
    onSuccess: () => {
      setMsg({ text: 'Correction deleted', kind: 'info' });
      qc.invalidateQueries({ queryKey: ['memories'] });
    },
    onError: (e: Error) => setMsg({ text: e.message || 'Delete failed', kind: 'err' }),
  });

  return (
    <section className={card} data-testid="corrections-section">
      <h3 className="text-base font-semibold">AI Learning (Corrections)</h3>
      <p className="text-sm text-muted-foreground">
        The AI automatically learns from your edits. When you modify AI-generated text and save, corrections are stored here and applied to future notes. Latest 20 per section.
      </p>

      <div className="space-y-2">
        {corrections.length === 0 ? (
          <div className="text-sm text-muted-foreground">
            No corrections yet. Edit AI-generated notes and save to start learning.
          </div>
        ) : (
          corrections.map((m) => {
            const isOpen = expanded[m.id];
            const parts = parseCorrection(m.content || '');
            const date = m.created_at ? new Date(m.created_at).toLocaleDateString() : '';
            return (
              <div
                key={m.id}
                className="rounded-md bg-muted/40 border border-border overflow-hidden"
                data-testid={'corr-row-' + m.id}
              >
                <button
                  type="button"
                  className="w-full flex items-center gap-2 px-3 py-2 text-left"
                  onClick={() => setExpanded({ ...expanded, [m.id]: !isOpen })}
                >
                  <span className="text-xs text-muted-foreground">{isOpen ? '▾' : '▸'}</span>
                  <span className="text-xs text-muted-foreground uppercase tracking-wide">
                    {CORRECTION_LABEL[m.category] || m.category}
                  </span>
                  <span className="flex-1 text-sm truncate">{m.name}</span>
                  <span className="text-xs text-muted-foreground">{date}</span>
                  <span
                    role="button"
                    className="text-destructive text-xs px-2"
                    onClick={(e) => { e.stopPropagation(); setDeleteTarget(m); }}
                    data-testid={'btn-corr-delete-' + m.id}
                  >
                    Delete
                  </span>
                </button>
                {isOpen && (
                  <div className="px-3 py-2 text-sm space-y-2 border-t border-border">
                    <div>
                      <div className="text-xs font-semibold uppercase text-destructive mb-1">Original (AI generated):</div>
                      <div className="whitespace-pre-wrap text-muted-foreground">{parts.original}</div>
                    </div>
                    {parts.corrected && (
                      <div>
                        <div className="text-xs font-semibold uppercase text-green-600 mb-1">Corrected to:</div>
                        <div className="whitespace-pre-wrap">{parts.corrected}</div>
                      </div>
                    )}
                  </div>
                )}
              </div>
            );
          })
        )}
      </div>

      <StatusLine msg={msg} />

      <ConfirmModal
        open={!!deleteTarget}
        title="Delete this correction?"
        body="The AI will no longer apply this edit in future notes."
        confirmText="Delete"
        danger
        busy={del.isPending}
        onConfirm={() => {
          if (deleteTarget) del.mutate(deleteTarget.id);
          setDeleteTarget(null);
        }}
        onCancel={() => setDeleteTarget(null)}
      />
    </section>
  );
}

// ── Audio Backups (server-stored, 24h TTL) ──────────────────
function AudioBackupsCard() {
  const qc = useQueryClient();
  const [msg, setMsg] = useState<Msg>(null);
  const [deleteTarget, setDeleteTarget] = useState<AudioBackupRow | null>(null);

  const { data, isLoading } = useQuery<AudioBackupsListOk>({
    queryKey: ['audio-backups'],
    queryFn: () => api.get<AudioBackupsListOk>('/api/audio-backups'),
  });

  const del = useMutation({
    mutationFn: (id: number) => api.delete<{ success: true }>('/api/audio-backups/' + id),
    onSuccess: () => {
      setMsg({ text: 'Backup deleted', kind: 'info' });
      qc.invalidateQueries({ queryKey: ['audio-backups'] });
    },
    onError: (e: Error) => setMsg({ text: e.message, kind: 'err' }),
  });

  function playBackup(id: number) {
    // Server streams the decompressed audio. Open in a new tab; browser
    // picks the default audio player.
    window.open('/api/audio-backups/' + id + '/audio', '_blank', 'noopener,noreferrer');
  }

  return (
    <section className={card} data-testid="audio-backups-section">
      <h3 className="text-base font-semibold">Audio Backups</h3>
      <p className="text-sm text-muted-foreground">
        Recordings are automatically backed up on the server and kept for 24 hours. Retry flow ports with the recording components.
      </p>
      {isLoading && <div className="text-sm text-muted-foreground">Loading…</div>}
      <div className="space-y-2">
        {data && data.backups.length === 0 && (
          <div className="text-sm text-muted-foreground">No audio backups.</div>
        )}
        {data?.backups.map((b) => {
          const sizeKb = Math.round(b.size_bytes / 1024);
          const comp = b.compressed_bytes ? ' (' + Math.round(b.compressed_bytes / 1024) + ' KB compressed)' : '';
          return (
            <div
              key={b.id}
              className="flex items-center gap-2 px-3 py-2 rounded-md bg-muted/40 border border-border"
              data-testid={'audio-backup-row-' + b.id}
            >
              <div className="flex-1 min-w-0">
                <div className="text-sm font-medium">{b.module} recording</div>
                <div className="text-xs text-muted-foreground">
                  {new Date(b.created_at).toLocaleString()} · {sizeKb} KB{comp} · {timeAgo(b.created_at)}
                </div>
              </div>
              <button
                type="button"
                className={btnPrimary + ' text-xs'}
                onClick={() => playBackup(b.id)}
              >
                Play
              </button>
              <button
                type="button"
                className={btnGhost + ' text-destructive text-xs'}
                onClick={() => setDeleteTarget(b)}
                data-testid={'btn-audio-delete-' + b.id}
              >
                Delete
              </button>
            </div>
          );
        })}
      </div>

      <StatusLine msg={msg} />

      <ConfirmModal
        open={!!deleteTarget}
        title="Delete this audio backup?"
        body="This cannot be undone."
        confirmText="Delete"
        danger
        busy={del.isPending}
        onConfirm={() => {
          if (deleteTarget) del.mutate(deleteTarget.id);
          setDeleteTarget(null);
        }}
        onCancel={() => setDeleteTarget(null)}
      />
    </section>
  );
}

// ── Saved Encounters (auto-deleted after 7 days) ────────────
function SavedEncountersCard() {
  const qc = useQueryClient();
  const [msg, setMsg] = useState<Msg>(null);
  const [deleteTarget, setDeleteTarget] = useState<SavedEncounterRow | null>(null);

  const { data, isLoading } = useQuery<SavedEncountersListOk>({
    queryKey: ['saved-encounters'],
    queryFn: () => api.get<SavedEncountersListOk>('/api/encounters/saved'),
  });

  const del = useMutation({
    mutationFn: (id: number) => api.delete<{ success: true }>('/api/encounters/saved/' + id),
    onSuccess: () => {
      setMsg({ text: 'Deleted', kind: 'info' });
      qc.invalidateQueries({ queryKey: ['saved-encounters'] });
    },
    onError: (e: Error) => setMsg({ text: e.message, kind: 'err' }),
  });

  return (
    <section className={card} data-testid="saved-encounters-section">
      <h3 className="text-base font-semibold">Saved Encounters</h3>
      <p className="text-sm text-muted-foreground">
        Encounters are automatically deleted after 7 days per site policy. Resume action ports with the encounter components.
      </p>
      {isLoading && <div className="text-sm text-muted-foreground">Loading…</div>}
      <div className="space-y-2">
        {data && data.encounters.length === 0 && (
          <div className="text-sm text-muted-foreground">No saved encounters.</div>
        )}
        {data?.encounters.map((enc) => {
          const date = new Date(enc.updated_at).toLocaleDateString();
          const expires = new Date(enc.expires_at).toLocaleDateString();
          const preview = (enc.transcript_preview || '').slice(0, 80);
          return (
            <div
              key={enc.id}
              className="flex items-center gap-2 px-3 py-2 rounded-md bg-muted/40 border border-border"
              data-testid={'enc-row-' + enc.id}
            >
              <div className="flex-1 min-w-0">
                <div className="flex items-center gap-2 text-sm">
                  <span className="font-medium truncate">{enc.label || 'Untitled'}</span>
                  <span className="text-xs text-muted-foreground uppercase tracking-wide">
                    {enc.enc_type}
                  </span>
                </div>
                <div className="text-xs text-muted-foreground truncate">
                  {date} · expires {expires}{preview ? ' · ' + preview + '…' : ''}
                </div>
              </div>
              <button
                type="button"
                className={btnGhost + ' text-destructive text-xs'}
                onClick={() => setDeleteTarget(enc)}
                data-testid={'btn-enc-delete-' + enc.id}
              >
                Delete
              </button>
            </div>
          );
        })}
      </div>

      <StatusLine msg={msg} />

      <ConfirmModal
        open={!!deleteTarget}
        title="Delete this saved encounter?"
        body={deleteTarget?.label || undefined}
        confirmText="Delete"
        danger
        busy={del.isPending}
        onConfirm={() => {
          if (deleteTarget) del.mutate(deleteTarget.id);
          setDeleteTarget(null);
        }}
        onCancel={() => setDeleteTarget(null)}
      />
    </section>
  );
}

// ── Compliance (static info card) ───────────────────────────
function ComplianceCard() {
  return (
    <section className={card} data-testid="compliance-section">
      <h3 className="text-base font-semibold">Compliance & Usage</h3>
      <div className="text-sm space-y-2">
        <p>
          <strong>AWS Bedrock</strong> is available with a Business Associate Agreement (BAA) for HIPAA-eligible workloads.
        </p>
        <ul className="list-disc pl-5 space-y-1 text-muted-foreground">
          <li>All connections use HTTPS/TLS encryption</li>
          <li>Authentication with optional 2FA</li>
          <li>No patient data stored on server beyond session</li>
          <li>AWS Bedrock supports BAA for HIPAA compliance</li>
          <li>Azure OpenAI supports BAA for HIPAA compliance</li>
        </ul>
        <p>
          <strong>Important:</strong> Check with your institution's guidelines and policies before use. This tool is not intended for production clinical use without proper organizational authorization and provider BAAs in place. Use with caution.
        </p>
      </div>
    </section>
  );
}

// ── Page shell ───────────────────────────────────────────────
export default function Settings() {
  const { data: me, isLoading, error } = useQuery<MeOk>({
    queryKey: ['auth-me'],
    queryFn: () => api.get<MeOk>('/api/auth/me'),
  });

  // Default to showing local-auth UIs unless the server explicitly says
  // canLocalAuth === false. Matches vanilla behavior: if the flag is
  // absent (older server, transient hiccup), we still render the section.
  const canLocal = me?.user.canLocalAuth !== false;

  return (
    <div className="max-w-3xl mx-auto p-6 space-y-4">
      <header>
        <h1 className="text-2xl font-semibold">Settings</h1>
        <p className="text-sm text-muted-foreground">
          Account security, integrations, and personal templates. More sub-sections port over in follow-up commits.
        </p>
      </header>

      {isLoading && <div className="text-sm text-muted-foreground">Loading…</div>}
      {error && (
        <div className="text-sm text-destructive">Could not load your account: {(error as Error).message}</div>
      )}

      {me && canLocal && (
        <>
          <ChangePasswordCard />
          <TwoFactorCard user={me.user} />
          <SessionsCard />
        </>
      )}

      {me && !canLocal && (
        <section className={card}>
          <h3 className="text-base font-semibold">Account managed by single sign-on</h3>
          <p className="text-sm text-muted-foreground">
            Your password and two-factor authentication are managed by your identity provider.
          </p>
        </section>
      )}

      {/* Integrations — shown to all users, not gated by canLocalAuth. */}
      {me && <NextcloudCard user={me.user} />}
      {me && <DocumentsCard />}

      {/* Voice + transcription */}
      {me && <VoicePreferencesCard />}
      {me && <BrowserWhisperCard />}
      {me && <WebSpeechCard />}

      {/* Personal content */}
      {me && <TemplatesCard />}
      {me && <CorrectionsCard />}
      {me && <AudioBackupsCard />}
      {me && <SavedEncountersCard />}

      <ComplianceCard />
    </div>
  );
}
