import clsx from 'clsx';
import React, { useEffect, useState } from 'react';
import { MdAdd, MdDelete, MdDragIndicator, MdEdit, MdInfoOutline } from 'react-icons/md';
import { IoMdCloseCircleOutline } from 'react-icons/io';
import {
  DndContext,
  closestCenter,
  KeyboardSensor,
  PointerSensor,
  TouchSensor,
  useSensor,
  useSensors,
  type DragEndEvent,
  type DragOverEvent,
  type DragStartEvent,
  type Modifier,
} from '@dnd-kit/core';
import {
  SortableContext,
  sortableKeyboardCoordinates,
  useSortable,
  verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';

import { useEnv } from '@/context/EnvContext';
import { useTranslation } from '@/hooks/useTranslation';
import { useFileSelector } from '@/hooks/useFileSelector';
import { useCustomDictionaryStore } from '@/store/customDictionaryStore';
import { eventDispatcher } from '@/utils/event';
import { evictProvider, isSystemDictionaryEnabled } from '@/services/dictionaries/registry';
import { BUILTIN_PROVIDER_IDS } from '@/services/dictionaries/types';
import {
  clearRememberedLookupApp,
  getRememberedLookupApp,
  isSystemDictionaryAvailable,
  isSystemDictionarySupported,
  type RememberedLookupApp,
} from '@/services/dictionaries/systemDictionary';
import { queueDictionaryBinaryUpload } from '@/services/sync/replicaBinaryUpload';
import type { ImportedDictionary, WebSearchEntry } from '@/services/dictionaries/types';
import {
  getBuiltinWebSearch,
  isValidUrlTemplate,
} from '@/services/dictionaries/webSearchTemplates';
import SubPageHeader from './SubPageHeader';
import { BoxedList, SettingsRow, SettingsSelect, Tips } from './primitives';

/** Dictionary popup font-size multipliers, surfaced as percentages (#4443). */
const FONT_SCALE_OPTIONS = [
  { value: '0.85', label: '85%' },
  { value: '1', label: '100%' },
  { value: '1.15', label: '115%' },
  { value: '1.3', label: '130%' },
  { value: '1.5', label: '150%' },
  { value: '1.75', label: '175%' },
];

interface CustomDictionariesProps {
  onBack: () => void;
}

interface ProviderRow {
  id: string;
  label: string;
  kind: 'builtin' | 'stardict' | 'mdict' | 'dict' | 'slob' | 'web';
  badge: string;
  imported?: ImportedDictionary;
  /** Set on `kind: 'web'` rows. The shape distinguishes deletable custom
   *  entries (when `builtinWeb` is false) from immutable built-ins. */
  webSearch?: WebSearchEntry;
  builtinWeb?: boolean;
  disabled?: boolean;
  reason?: string;
}

// Lock drag movement to the vertical axis — the sortable list is vertical, so
// any horizontal travel is wasted motion and lets the drag preview drift out
// from under the row.
const restrictToVerticalAxis: Modifier = ({ transform }) => ({
  ...transform,
  x: 0,
});

// Clamp the drag preview to the SortableContext's container rect so users
// can't drag a row out of the dictionaries card.
const restrictToParentElement: Modifier = ({ containerNodeRect, draggingNodeRect, transform }) => {
  if (!draggingNodeRect || !containerNodeRect) return transform;
  const value = { ...transform };
  if (draggingNodeRect.top + transform.y < containerNodeRect.top) {
    value.y = containerNodeRect.top - draggingNodeRect.top;
  } else if (
    draggingNodeRect.bottom + transform.y >
    containerNodeRect.top + containerNodeRect.height
  ) {
    value.y = containerNodeRect.top + containerNodeRect.height - draggingNodeRect.bottom;
  }
  return value;
};

const dragModifiers: Modifier[] = [restrictToVerticalAxis, restrictToParentElement];

const builtinWebLabel = (id: string, _: (key: string) => string): string => {
  const tpl = getBuiltinWebSearch(id);
  if (!tpl) return id;
  return _(tpl.nameKey);
};

const builtinLabel = (id: string, _: (key: string) => string): string => {
  if (id === BUILTIN_PROVIDER_IDS.wiktionary) return _('Wiktionary');
  if (id === BUILTIN_PROVIDER_IDS.wikipedia) return _('Wikipedia');
  if (id === BUILTIN_PROVIDER_IDS.systemDictionary) return _('System Dictionary');
  return id;
};

interface SortableRowProps {
  row: ProviderRow;
  enabled: boolean;
  /** True when System Dictionary is on and this row is a non-system provider.
   * The toggle reflects the persisted enabled flag (so the user sees what
   * will come back when System is turned off) but is rendered read-only. */
  lockedBySystem: boolean;
  isDeleteMode: boolean;
  isEditMode: boolean;
  onToggle: (id: string, next: boolean) => void;
  onDelete: (row: ProviderRow) => void;
  onEditWebSearch?: (entry: WebSearchEntry) => void;
  onEditDict?: (dict: ImportedDictionary) => void;
  _: (key: string, options?: Record<string, number | string>) => string;
}

const SortableRow: React.FC<SortableRowProps> = ({
  row,
  enabled,
  lockedBySystem,
  isDeleteMode,
  isEditMode,
  onToggle,
  onDelete,
  onEditWebSearch,
  onEditDict,
  _,
}) => {
  const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
    id: row.id,
  });

  const style: React.CSSProperties = {
    transform: CSS.Transform.toString(transform),
    transition,
    // Keep the row visible while dragging; use a slight opacity dip so the
    // user can tell it's the moving one.
    opacity: isDragging ? 0.6 : 1,
  };

  return (
    <div
      ref={setNodeRef}
      style={style}
      className={clsx(
        'flex items-center gap-2 px-3 py-2 transition-colors',
        isDragging ? 'bg-base-200 z-10 shadow-md' : 'hover:bg-base-200/40',
      )}
    >
      {/* Drag handle (left). Always present so reorder works for built-ins
          and imported alike. The handle is the only element that registers
          drag listeners — clicks on the rest of the row don't initiate a
          drag, which keeps the toggle and delete buttons clickable. */}
      <button
        type='button'
        className='touch-target btn btn-ghost btn-xs h-7 w-5 cursor-grab touch-none p-0 active:cursor-grabbing'
        aria-label={_('Drag to reorder')}
        title={_('Drag to reorder')}
        {...attributes}
        {...listeners}
      >
        <MdDragIndicator className='text-base-content/60 h-4 w-4' />
      </button>

      <div className='min-w-0 flex-1'>
        <div className='flex items-center gap-2'>
          <span
            className={clsx('truncate font-medium', row.disabled && 'text-base-content/60')}
            title={row.label}
          >
            {row.label}
          </span>
        </div>
        {row.reason && (
          <div className='text-warning mt-1 flex items-start gap-1 text-xs'>
            <MdInfoOutline className='mt-0.5 h-3.5 w-3.5 shrink-0' />
            <span>{row.reason}</span>
          </div>
        )}
      </div>

      {/* End-aligned type badge. Sits just before the toggle so all
          badges form a uniform column regardless of name length, instead
          of trailing the truncated name at a ragged x position. */}
      <span className='badge badge-sm badge-ghost shrink-0'>{row.badge}</span>

      <input
        type='checkbox'
        className={clsx(
          'toggle toggle-sm shrink-0',
          lockedBySystem && 'cursor-not-allowed opacity-60',
        )}
        checked={enabled}
        onChange={() => onToggle(row.id, !enabled)}
        disabled={row.disabled || lockedBySystem}
        aria-label={enabled ? _('Disable') : _('Enable')}
        title={lockedBySystem ? _('Disable System Dictionary first to change this.') : undefined}
      />

      {/* Edit pencil — parity with the trailing delete X, but for the
          rename / re-template flow. Visible only in edit mode for rows
          backed by user-mutable metadata (imported dicts and custom web
          searches; built-ins are immutable). */}
      {(row.imported || (row.kind === 'web' && !row.builtinWeb)) && isEditMode && (
        <button
          type='button'
          onClick={() => {
            if (row.imported && onEditDict) onEditDict(row.imported);
            else if (row.kind === 'web' && row.webSearch && onEditWebSearch) {
              onEditWebSearch(row.webSearch);
            }
          }}
          className='btn btn-ghost btn-sm shrink-0 px-1'
          aria-label={_('Edit')}
          title={_('Edit')}
        >
          <MdEdit className='text-base-content/75 h-4 w-4' />
        </button>
      )}

      {/* Delete X — for imported dictionaries and custom web searches, only
          in delete mode. Built-ins (incl. built-in web searches) never show
          it; deletable rows reserve no width when not in delete mode so the
          toggles align across the list. */}
      {(row.imported || (row.kind === 'web' && !row.builtinWeb)) && isDeleteMode && (
        <button
          type='button'
          onClick={() => onDelete(row)}
          className='btn btn-ghost btn-sm shrink-0 px-1'
          aria-label={_('Delete')}
          title={_('Delete')}
        >
          <IoMdCloseCircleOutline className='text-base-content/75 h-5 w-5' />
        </button>
      )}
    </div>
  );
};

const CustomDictionaries: React.FC<CustomDictionariesProps> = ({ onBack }) => {
  const _ = useTranslation();
  const { appService, envConfig } = useEnv();
  const {
    dictionaries,
    settings,
    addDictionary,
    replaceDictionaries,
    removeDictionary,
    updateDictionary,
    reorder,
    setEnabled,
    setFontScale,
    addWebSearch,
    updateWebSearch,
    removeWebSearch,
    saveCustomDictionaries,
    loadCustomDictionaries,
    markAvailableByContentId,
  } = useCustomDictionaryStore();

  useEffect(() => {
    void loadCustomDictionaries(envConfig).catch(() => {});
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  const { selectFiles } = useFileSelector(appService, _);
  const [importing, setImporting] = useState(false);
  // Android only: the dictionary app remembered for the browser-excluding
  // system-lookup chooser (issue #4559). Stays null on every other platform
  // and whenever nothing has been remembered, so the reset row below only
  // surfaces for the narrow case that can get "stuck" on one app.
  const [rememberedLookupApp, setRememberedLookupApp] = useState<RememberedLookupApp | null>(null);
  useEffect(() => {
    if (!appService?.isAndroidApp) return;
    let cancelled = false;
    void getRememberedLookupApp().then((app) => {
      if (!cancelled) setRememberedLookupApp(app);
    });
    return () => {
      cancelled = true;
    };
  }, [appService]);
  const handleFontScaleChange = async (e: React.ChangeEvent<HTMLSelectElement>) => {
    setFontScale(Number(e.target.value));
    await saveCustomDictionaries(envConfig);
  };

  const handleResetLookupApp = async () => {
    await clearRememberedLookupApp();
    setRememberedLookupApp(null);
    eventDispatcher.dispatch('toast', {
      type: 'info',
      message: _('Lookup app reset. The next lookup will ask again.'),
      timeout: 4000,
    });
  };
  // Edit and Delete are mutually-exclusive row affordances. Toggling one on
  // turns the other off so the trailing column never shows two icons at once.
  const [isDeleteMode, setIsDeleteMode] = useState(false);
  const [isEditMode, setIsEditMode] = useState(false);
  // Track the row currently under the drag cursor. Used to gate auto-scroll
  // off when the drop target is the first or last row — there's nothing
  // beyond either end, so scrolling further is just visual noise.
  const [dragOverId, setDragOverId] = useState<string | null>(null);
  const toggleDeleteMode = () =>
    setIsDeleteMode((v) => {
      const next = !v;
      if (next) setIsEditMode(false);
      return next;
    });
  const toggleEditMode = () =>
    setIsEditMode((v) => {
      const next = !v;
      if (next) setIsDeleteMode(false);
      return next;
    });

  // Add/edit web-search modal state. `editingId` is `null` for "add", a
  // custom entry's id for "edit".
  const [webModal, setWebModal] = useState<null | {
    editingId: string | null;
    name: string;
    urlTemplate: string;
  }>(null);
  const openAddWebSearch = () => setWebModal({ editingId: null, name: '', urlTemplate: '' });
  const openEditWebSearch = (entry: WebSearchEntry) =>
    setWebModal({ editingId: entry.id, name: entry.name, urlTemplate: entry.urlTemplate });
  const closeWebModal = () => setWebModal(null);
  const submitWebModal = async () => {
    if (!webModal) return;
    const name = webModal.name.trim();
    const url = webModal.urlTemplate.trim();
    if (!name || !isValidUrlTemplate(url)) {
      eventDispatcher.dispatch('toast', {
        type: 'warning',
        message: _('URL template must start with http(s):// and contain %WORD%.'),
        timeout: 4000,
      });
      return;
    }
    const isAdd = !webModal.editingId;
    if (webModal.editingId) {
      updateWebSearch(webModal.editingId, { name, urlTemplate: url });
      // Re-create the cached provider so the new template + label take effect.
      evictProvider(webModal.editingId);
    } else {
      addWebSearch(name, url);
    }
    // Adding a new web search appends to providerOrder (an explicit
    // user reorder); editing only changes name/URL, so providerOrder
    // is untouched and the auto-mutation gate stays closed.
    await saveCustomDictionaries(envConfig, { publishOrderChange: isAdd });
    setWebModal(null);
  };

  // Edit-imported-dict modal. Only the display `name` is editable; the
  // bundle on disk is untouched.
  const [dictModal, setDictModal] = useState<null | { id: string; name: string }>(null);
  const openEditDict = (dict: ImportedDictionary) => setDictModal({ id: dict.id, name: dict.name });
  const closeDictModal = () => setDictModal(null);
  const submitDictModal = async () => {
    if (!dictModal) return;
    const name = dictModal.name.trim();
    if (!name) {
      eventDispatcher.dispatch('toast', {
        type: 'warning',
        message: _('Name cannot be empty.'),
        timeout: 4000,
      });
      return;
    }
    updateDictionary(dictModal.id, { name });
    // Provider instances cache the dict's `label` from `dict.name`; evict
    // so the next lookup picks up the new name in tabs / source labels.
    evictProvider(dictModal.id);
    await saveCustomDictionaries(envConfig);
    setDictModal(null);
  };

  const buildRows = (): ProviderRow[] => {
    const dictById = new Map(dictionaries.map((d) => [d.id, d]));
    const webById = new Map((settings.webSearches ?? []).map((w) => [w.id, w]));
    const rows: ProviderRow[] = [];
    // Cache cross-row platform checks so we don't re-walk navigator
    // for every system-id encounter (and so the first iteration
    // settles before the conditional inside the loop).
    const systemSupported = isSystemDictionarySupported();
    const systemAvailable = isSystemDictionaryAvailable();
    for (const id of settings.providerOrder) {
      if (id === BUILTIN_PROVIDER_IDS.systemDictionary) {
        // On platforms that don't expose a native dictionary surface
        // (web, Linux, Windows), hide the row entirely so the user
        // never sees an option that can't work. On supported-but-not-
        // yet-wired platforms (iOS, Android in v1), surface the row
        // with the toggle disabled so it stays discoverable.
        if (!systemSupported) continue;
        const disabled = !systemAvailable;
        rows.push({
          id,
          label: builtinLabel(id, _),
          kind: 'builtin',
          badge: _('System'),
          disabled,
          reason: disabled
            ? _('System dictionary integration is coming soon on this platform.')
            : undefined,
        });
        continue;
      }
      if (id.startsWith('builtin:')) {
        rows.push({
          id,
          label: builtinLabel(id, _),
          kind: 'builtin',
          badge: _('Built-in'),
        });
        continue;
      }
      if (id.startsWith('web:builtin:')) {
        const tpl = getBuiltinWebSearch(id);
        if (!tpl) continue;
        rows.push({
          id,
          label: builtinWebLabel(id, _),
          kind: 'web',
          badge: _('Web'),
          webSearch: tpl,
          builtinWeb: true,
        });
        continue;
      }
      if (id.startsWith('web:')) {
        const w = webById.get(id);
        if (!w || w.deletedAt) continue;
        rows.push({
          id,
          label: w.name,
          kind: 'web',
          badge: _('Web'),
          webSearch: w,
          builtinWeb: false,
        });
        continue;
      }
      const dict = dictById.get(id);
      if (!dict || dict.deletedAt) continue;
      let reason: string | undefined;
      let disabled = false;
      if (dict.unavailable) {
        reason = _('Bundle is missing on this device. Re-import to use it.');
        disabled = true;
      } else if (dict.unsupported) {
        reason = dict.unsupportedReason || _('This dictionary format is not supported.');
        disabled = true;
      }
      rows.push({
        id,
        label: dict.name,
        kind: dict.kind,
        badge:
          dict.kind === 'mdict'
            ? _('MDict')
            : dict.kind === 'dict'
              ? _('DICT')
              : dict.kind === 'slob'
                ? _('Slob')
                : _('StarDict'),
        imported: dict,
        disabled,
        reason,
      });
    }
    return rows;
  };

  const rows = buildRows();
  const hasDeletable = rows.some((r) => r.imported || (r.kind === 'web' && !r.builtinWeb));

  // System-dictionary handoff is exclusive at lookup time — but only on
  // platforms where it's actually supported. `providerEnabled` is whole-field
  // synced across devices, so the flag can arrive (true) on web / Linux /
  // Windows where there's no handoff; there it's a no-op and must NOT lock the
  // other providers' toggles. `isSystemDictionaryEnabled` applies the same
  // platform gate the annotator uses, so the lock matches real lookup behavior.
  const systemDictionaryActive = isSystemDictionaryEnabled(settings);

  // dnd-kit sensors. PointerSensor with a small distance gate avoids
  // hijacking simple clicks on the drag handle. TouchSensor with a delay
  // matches mobile UX (long-press to drag). Keyboard support gives drag
  // accessibility for free.
  const sensors = useSensors(
    useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
    useSensor(TouchSensor, { activationConstraint: { delay: 200, tolerance: 5 } }),
    useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
  );

  const handleImport = async () => {
    if (importing) return;
    setImporting(true);
    try {
      const result = await selectFiles({ type: 'dictionaries', multiple: true });
      if (result.error) {
        eventDispatcher.dispatch('toast', {
          type: 'error',
          message: _('Failed to import dictionary: {{message}}', { message: result.error }),
          timeout: 4000,
        });
        return;
      }
      // User cancelled the picker — staying silent is the right call here.
      if (result.files.length === 0) return;
      const importResult = await appService?.importDictionaries(result.files, dictionaries);
      if (!importResult) {
        eventDispatcher.dispatch('toast', {
          type: 'error',
          message: _('Failed to import dictionary: {{message}}', {
            message: _('App service is not available'),
          }),
          timeout: 4000,
        });
        return;
      }
      let added = 0;
      for (const dict of importResult.imported) {
        addDictionary(dict);
        // The freshly imported bundle exists on disk now; clear any lingering
        // `unavailable` flag on an in-memory entry with the same contentId
        // (e.g. when a prior import lost its bundle dir for any reason).
        if (dict.contentId) markAvailableByContentId(dict.contentId);
        if (appService) void queueDictionaryBinaryUpload(dict, appService);
        added += 1;
      }
      let replaced = 0;
      for (const { oldIds, newDict } of importResult.replacements) {
        replaceDictionaries(oldIds, newDict);
        if (newDict.contentId) markAvailableByContentId(newDict.contentId);
        if (appService) void queueDictionaryBinaryUpload(newDict, appService);
        // Invalidate any cached provider instances for the replaced ids so
        // their next lookup picks up the new bundle's files.
        for (const oldId of oldIds) evictProvider(oldId);
        replaced += 1;
      }
      // Import / replace both mutate providerOrder (prepend or splice
      // into existing slot), so this is an explicit user reorder.
      await saveCustomDictionaries(envConfig, { publishOrderChange: added > 0 || replaced > 0 });
      if (added > 0) {
        eventDispatcher.dispatch('toast', {
          type: 'info',
          message: _('Imported {{count}} dictionary', { count: added }),
          timeout: 2500,
        });
      }
      if (replaced > 0) {
        eventDispatcher.dispatch('toast', {
          type: 'info',
          message: _('Replaced {{count}} existing dictionary', { count: replaced }),
          timeout: 2500,
        });
      }
      // Bundles that landed in the library but are marked unsupported (e.g.
      // record-block-encrypted MDX, raw .dict without DictZip). They show
      // disabled in the list — surface the first reason so the user knows
      // their "imported" toast doesn't mean it's usable.
      const unsupportedDicts = [
        ...importResult.imported,
        ...importResult.replacements.map((r) => r.newDict),
      ].filter((d) => d.unsupported);
      if (unsupportedDicts.length > 0) {
        const firstReason = unsupportedDicts.find((d) => d.unsupportedReason)?.unsupportedReason;
        eventDispatcher.dispatch('toast', {
          type: 'warning',
          message: firstReason
            ? _('Unsupported dictionary: {{reason}}', { reason: firstReason })
            : _('{{count}} dictionary is unsupported and disabled', {
                count: unsupportedDicts.length,
              }),
          timeout: 5000,
        });
      }
      if (importResult.orphanFiles.length > 0) {
        eventDispatcher.dispatch('toast', {
          type: 'warning',
          message: _('Skipped incomplete bundles: {{names}}', {
            names: importResult.orphanFiles.join(', '),
          }),
          timeout: 4000,
        });
      }
      if (added === 0 && replaced === 0 && importResult.orphanFiles.length === 0) {
        eventDispatcher.dispatch('toast', {
          type: 'info',
          message: _('No new dictionaries were imported'),
          timeout: 2500,
        });
      }
    } catch (err) {
      eventDispatcher.dispatch('toast', {
        type: 'error',
        message: _('Failed to import dictionary: {{message}}', {
          message: err instanceof Error ? err.message : String(err),
        }),
        timeout: 4000,
      });
    } finally {
      setImporting(false);
    }
  };

  const handleDelete = async (row: ProviderRow) => {
    if (row.imported) {
      const dict = row.imported;
      try {
        await appService?.deleteDictionary(dict);
      } catch (err) {
        console.warn('Failed to delete dictionary files:', err);
      }
      removeDictionary(dict.id);
      evictProvider(dict.id);
    } else if (row.kind === 'web' && !row.builtinWeb && row.webSearch) {
      removeWebSearch(row.webSearch.id);
      evictProvider(row.id);
    } else {
      return;
    }
    // Delete removes the id from providerOrder — explicit user reorder.
    await saveCustomDictionaries(envConfig, { publishOrderChange: true });
    // Auto-leave delete mode when the last deletable entry is gone — there's
    // nothing left to delete (edit mode is gated on the same row set).
    const remaining = rows.filter(
      (r) => r.id !== row.id && (r.imported || (r.kind === 'web' && !r.builtinWeb)),
    );
    if (remaining.length === 0) {
      setIsDeleteMode(false);
      setIsEditMode(false);
    }
  };

  const handleToggle = async (id: string, next: boolean) => {
    setEnabled(id, next);
    // Toggling enabled state doesn't change providerOrder; the gate
    // stays closed and providerEnabled auto-publishes through the
    // standard diff path.
    await saveCustomDictionaries(envConfig);
  };

  const handleDragStart = (event: DragStartEvent) => {
    // Seed dragOverId with the active row — at drag start the active is over
    // itself, but onDragOver only fires when the over target *changes*, so we
    // need an explicit seed to evaluate "currently at edge" on the first frame.
    setDragOverId(String(event.active.id));
  };

  const handleDragOver = (event: DragOverEvent) => {
    setDragOverId(event.over ? String(event.over.id) : null);
  };

  const handleDragEnd = async (event: DragEndEvent) => {
    setDragOverId(null);
    const { active, over } = event;
    if (!over || active.id === over.id) return;
    const order = [...settings.providerOrder];
    const fromIdx = order.indexOf(String(active.id));
    const toIdx = order.indexOf(String(over.id));
    if (fromIdx < 0 || toIdx < 0) return;
    const [moved] = order.splice(fromIdx, 1);
    if (!moved) return;
    order.splice(toIdx, 0, moved);
    reorder(order);
    // Drag-drop is the canonical user-action providerOrder change;
    // open the gate so the new order ships cross-device.
    await saveCustomDictionaries(envConfig, { publishOrderChange: true });
  };

  const handleDragCancel = () => setDragOverId(null);

  const isDragOverEdge =
    dragOverId !== null &&
    rows.length > 0 &&
    (rows[0]?.id === dragOverId || rows[rows.length - 1]?.id === dragOverId);

  return (
    <div className='w-full'>
      <SubPageHeader
        parentLabel={_('Language')}
        currentLabel={_('Dictionaries')}
        onBack={onBack}
        rightSlot={
          hasDeletable ? (
            <div className='-me-4 flex items-center gap-1'>
              <button
                onClick={toggleEditMode}
                className='btn btn-ghost btn-sm text-base-content gap-2 px-3'
                title={isEditMode ? _('Cancel Edit') : _('Edit Dictionary')}
              >
                {isEditMode ? (
                  <>{_('Cancel')}</>
                ) : (
                  <>
                    <MdEdit className='h-5 w-5 min-[800px]:h-4 min-[800px]:w-4' />
                    {/* Hide label on very narrow screens so the icon-only
                        button keeps the breadcrumb readable. */}
                    <span className='hidden min-[800px]:inline'>{_('Edit')}</span>
                  </>
                )}
              </button>
              <button
                onClick={toggleDeleteMode}
                className='btn btn-ghost btn-sm text-base-content gap-2 px-3'
                title={isDeleteMode ? _('Cancel Delete') : _('Delete Dictionary')}
              >
                {isDeleteMode ? (
                  <>{_('Cancel')}</>
                ) : (
                  <>
                    <MdDelete className='h-5 w-5 min-[800px]:h-4 min-[800px]:w-4' />
                    <span className='hidden min-[800px]:inline'>{_('Delete')}</span>
                  </>
                )}
              </button>
            </div>
          ) : undefined
        }
      />

      <div className='card border-base-200 bg-base-100 overflow-hidden border'>
        <div className='divide-base-200 divide-y'>
          {rows.length === 0 && (
            <div className='text-base-content/60 px-4 py-6 text-center text-sm'>
              {_('No dictionaries available.')}
            </div>
          )}
          <DndContext
            sensors={sensors}
            collisionDetection={closestCenter}
            modifiers={dragModifiers}
            // Auto-scroll only when the drop target is mid-list. When the
            // cursor is currently over the first or last row, there's nowhere
            // further to drop, so scrolling the dialog is just noise.
            autoScroll={!isDragOverEdge}
            onDragStart={handleDragStart}
            onDragOver={handleDragOver}
            onDragEnd={handleDragEnd}
            onDragCancel={handleDragCancel}
          >
            <SortableContext items={rows.map((r) => r.id)} strategy={verticalListSortingStrategy}>
              {rows.map((row) => (
                <SortableRow
                  key={row.id}
                  row={row}
                  enabled={settings.providerEnabled[row.id] !== false}
                  // System-dictionary handoff is exclusive at lookup time, so
                  // while it's on the other providers' toggles only express a
                  // "what to restore when System is off" choice. Render them
                  // read-only so the user can't accidentally clear that
                  // restoration state.
                  lockedBySystem={
                    systemDictionaryActive && row.id !== BUILTIN_PROVIDER_IDS.systemDictionary
                  }
                  isDeleteMode={isDeleteMode}
                  isEditMode={isEditMode}
                  onToggle={handleToggle}
                  onDelete={handleDelete}
                  onEditWebSearch={openEditWebSearch}
                  onEditDict={openEditDict}
                  _={_}
                />
              ))}
            </SortableContext>
          </DndContext>
        </div>
      </div>

      <BoxedList
        className='mt-4'
        title={_('Appearance')}
        description={_(
          'Sets the text size of dictionary results, independent of the reading view.',
        )}
      >
        <SettingsRow label={_('Font Size')}>
          <SettingsSelect
            value={String(settings.fontScale ?? 1)}
            onChange={handleFontScaleChange}
            options={FONT_SCALE_OPTIONS}
            ariaLabel={_('Font Size')}
          />
        </SettingsRow>
      </BoxedList>

      <div className='mt-4 grid grid-cols-1 gap-2 sm:grid-cols-2'>
        <button
          type='button'
          onClick={handleImport}
          disabled={importing}
          className={clsx(
            'eink-bordered group flex h-11 items-center justify-center gap-2.5',
            'border-base-200 bg-base-100 rounded-lg border px-4',
            'text-base-content text-sm font-medium',
            'transition-colors duration-150',
            'hover:border-base-300 hover:bg-base-300/40',
            'active:bg-base-200/80',
            'focus-visible:ring-base-content/15 focus-visible:outline-none focus-visible:ring-2',
            'disabled:cursor-not-allowed disabled:opacity-60',
            'disabled:hover:border-base-200 disabled:hover:bg-base-100',
          )}
        >
          <span
            className={clsx(
              // eink-inverted keeps the "+" legible on its dark badge (#4454);
              // without it the badge collapses to a solid black spot in eink.
              'eink-inverted',
              'flex h-5 w-5 items-center justify-center rounded-full',
              'bg-base-200 text-base-content/60',
              'transition-colors duration-150',
              'group-hover:bg-base-content group-hover:text-base-100',
              'group-disabled:bg-base-200 group-disabled:text-base-content/60',
            )}
          >
            <MdAdd className='h-3.5 w-3.5' />
          </span>
          <span className='line-clamp-1'>
            {importing ? _('Importing…') : _('Import Dictionary')}
          </span>
        </button>
        <button
          type='button'
          onClick={openAddWebSearch}
          className={clsx(
            'eink-bordered group flex h-11 items-center justify-center gap-2.5',
            'border-base-200 bg-base-100 rounded-lg border px-4',
            'text-base-content text-sm font-medium',
            'transition-colors duration-150',
            'hover:border-base-300 hover:bg-base-300/40',
            'active:bg-base-200/80',
            'focus-visible:ring-base-content/15 focus-visible:outline-none focus-visible:ring-2',
          )}
        >
          <span
            className={clsx(
              // eink-inverted keeps the "+" legible on its dark badge (#4454);
              // without it the badge collapses to a solid black spot in eink.
              'eink-inverted',
              'flex h-5 w-5 items-center justify-center rounded-full',
              'bg-base-200 text-base-content/60',
              'transition-colors duration-150',
              'group-hover:bg-base-content group-hover:text-base-100',
            )}
          >
            <MdAdd className='h-3.5 w-3.5' />
          </span>
          <span className='line-clamp-1'>{_('Add Web Search')}</span>
        </button>
      </div>

      {/* Reset the remembered system-lookup app. Only rendered on Android
          when a dictionary has actually been remembered from the
          browser-excluding chooser (issue #4559), so the user can switch
          to another installed dictionary without uninstalling. */}
      {rememberedLookupApp && (
        <div
          className={clsx(
            'eink-bordered mt-4 flex items-center justify-between gap-3',
            'border-base-200 bg-base-100 rounded-lg border px-4 py-3',
          )}
        >
          <div className='min-w-0'>
            <div className='text-base-content text-sm font-medium'>{_('System Lookup App')}</div>
            <div className='text-base-content/60 line-clamp-1 text-xs'>
              {rememberedLookupApp.label}
            </div>
          </div>
          <button
            type='button'
            onClick={handleResetLookupApp}
            className='btn btn-ghost btn-sm eink-bordered shrink-0'
          >
            {_('Reset')}
          </button>
        </div>
      )}

      <Tips className='mt-4'>
        <li>{_('StarDict bundles need .ifo, .idx, and .dict.dz files (.syn optional).')}</li>
        <li>{_('MDict bundles use .mdx files; companion .mdd and .css files are optional.')}</li>
        <li>{_('DICT bundles need a .index file and a .dict.dz file.')}</li>
        <li>{_('Slob bundles need a .slob file.')}</li>
        <li>{_('Select all the bundle files together when importing.')}</li>
      </Tips>

      {/* Add / edit web-search modal. Lightweight inline `<dialog>` (daisyUI
          modal classes); the heavier `Dialog.tsx` is overkill for a 2-field
          form. */}
      {webModal && (
        <div className='modal modal-open' role='dialog'>
          <div className='modal-box w-11/12 max-w-md'>
            <h3 className='text-base font-semibold'>
              {webModal.editingId ? _('Edit Web Search') : _('Add Web Search')}
            </h3>
            <div className='mt-4 space-y-3'>
              <label className='form-control w-full'>
                <span className='label-text text-sm'>{_('Name')}</span>
                <input
                  type='text'
                  className='input input-bordered input-sm w-full'
                  value={webModal.name}
                  placeholder={_('e.g. Google')}
                  onChange={(e) => setWebModal((m) => (m ? { ...m, name: e.target.value } : m))}
                />
              </label>
              <label className='form-control w-full'>
                <span className='label-text text-sm'>{_('URL Template')}</span>
                <input
                  type='url'
                  className='input input-bordered input-sm w-full'
                  value={webModal.urlTemplate}
                  placeholder='https://www.google.com/search?q=%WORD%'
                  onChange={(e) =>
                    setWebModal((m) => (m ? { ...m, urlTemplate: e.target.value } : m))
                  }
                />
                <span className='label-text-alt text-base-content/60 mt-1 text-xs'>
                  {_('Use %WORD% where the looked-up word should appear.')}
                </span>
              </label>
            </div>
            <div className='modal-action'>
              <button type='button' onClick={closeWebModal} className='btn btn-ghost btn-sm'>
                {_('Cancel')}
              </button>
              <button type='button' onClick={submitWebModal} className='btn btn-primary btn-sm'>
                {_('Save')}
              </button>
            </div>
          </div>
          {/* Click-outside dismiss. */}
          <button
            type='button'
            aria-label={_('Close')}
            className='modal-backdrop'
            onClick={closeWebModal}
          />
        </div>
      )}

      {/* Edit-imported-dict modal. Single field for the display name; the
          on-disk bundle is untouched. */}
      {dictModal && (
        <div className='modal modal-open' role='dialog'>
          <div className='modal-box w-11/12 max-w-md'>
            <h3 className='text-base font-semibold'>{_('Edit Dictionary')}</h3>
            <div className='mt-4 space-y-3'>
              <label className='form-control w-full'>
                <span className='label-text text-sm'>{_('Name')}</span>
                <input
                  type='text'
                  className='input input-bordered input-sm w-full'
                  value={dictModal.name}
                  placeholder={_('Dictionary name')}
                  onChange={(e) => setDictModal((m) => (m ? { ...m, name: e.target.value } : m))}
                />
              </label>
            </div>
            <div className='modal-action'>
              <button type='button' onClick={closeDictModal} className='btn btn-ghost btn-sm'>
                {_('Cancel')}
              </button>
              <button type='button' onClick={submitDictModal} className='btn btn-primary btn-sm'>
                {_('Save')}
              </button>
            </div>
          </div>
          <button
            type='button'
            aria-label={_('Close')}
            className='modal-backdrop'
            onClick={closeDictModal}
          />
        </div>
      )}
    </div>
  );
};

export default CustomDictionaries;
