import { nanoid } from 'nanoid';
import { createStore } from 'zustand/vanilla';

import type { StateChanges, StorageAdapter } from '../adapters/types';
import type { AIProvider } from '../ai/types';
import { DEFAULT_ZOOM_INDEX, GRID_ZOOM_STEPS, PET_LABELS, TRASH_RETENTION_MS } from '../constants';
import { clusterFaces } from '../lib/cluster';
import { hashPassword, verifyPassword } from '../lib/crypto';
import { blobToDataUrl, mediaFromFile } from '../lib/media';
import { defaultSystemAlbums, objectSmartAlbums } from '../lib/smartAlbums';
import type {
  Album,
  AlbumId,
  EditState,
  GalleryUser,
  LibraryScale,
  MapMode,
  MediaComment,
  MediaId,
  MediaItem,
  MediaVersion,
  Person,
  PersonId,
  ShareRecord,
  ResolvedTheme,
  ThemePreference,
  ViewId,
} from '../types';

export type GridFilter =
  | 'all'
  | 'favourites'
  | 'edited'
  | 'photos'
  | 'videos'
  | 'screenshots'
  | 'not-in-album';

export interface GalleryFeatures {
  editor: boolean;
  camera: boolean;
  ai: boolean;
  map: boolean;
  import: boolean;
  export: boolean;
  sharing: boolean;
}

export const DEFAULT_FEATURES: GalleryFeatures = {
  editor: true,
  camera: true,
  ai: true,
  map: true,
  import: true,
  export: true,
  sharing: true,
};

/**
 * Optional per-theme visual overrides. Any value may be a solid color, an rgba()
 * (for opacity), or a CSS gradient string. Light values apply in light + semi-dark
 * (content), dark values in dark mode (and the semi-dark sidebar).
 */
export interface ThemeTokens {
  /** Main app / content background. → `--apg-bg` + `--apg-bg-content` */
  bgLight?: string;
  bgDark?: string;
  /** Raised surfaces (cards, elevated panels). → `--apg-bg-elevated` */
  elevatedLight?: string;
  elevatedDark?: string;
  /** Sidebar glass backdrop. → `--apg-sidebar-bg` */
  sidebarBgLight?: string;
  sidebarBgDark?: string;
  /** Primary text color. → `--apg-text` */
  textLight?: string;
  textDark?: string;
  /** Sidebar corner radius in px. → `--apg-sidebar-radius` */
  sidebarRadius?: number;
  /** Accent color (overrides `accentColor`). → `--apg-accent` */
  accent?: string;

  // ---- extended token map (all optional; defaults keep today's look) ----

  /** Accent used behind white text (buttons, menu hover) — needs AA contrast. → `--apg-accent-strong` */
  accentStrongLight?: string;
  accentStrongDark?: string;
  /** Foreground color placed on top of the accent. → `--apg-accent-contrast` */
  accentContrast?: string;
  /** Destructive-action color. → `--apg-danger` */
  dangerLight?: string;
  dangerDark?: string;
  /** Card surface (collections / pinned cards). → `--apg-card` */
  cardLight?: string;
  cardDark?: string;
  /** Card surface on hover. → `--apg-card-hover` */
  cardHoverLight?: string;
  cardHoverDark?: string;
  /** Top toolbar glass backdrop. → `--apg-toolbar-bg` */
  toolbarBgLight?: string;
  toolbarBgDark?: string;
  /** Context-menu / popover glass fill. → `--apg-menu-bg` */
  menuBgLight?: string;
  menuBgDark?: string;
  /** Hairline separators. → `--apg-separator` */
  separatorLight?: string;
  separatorDark?: string;
  /** Stronger separators (input borders, scrollbars). → `--apg-separator-strong` */
  separatorStrongLight?: string;
  separatorStrongDark?: string;
  /** Hover wash on rows / icon buttons. → `--apg-hover` */
  hoverLight?: string;
  hoverDark?: string;
  /** Pressed/active wash. → `--apg-active` */
  activeLight?: string;
  activeDark?: string;
  /** Selected sidebar row background. → `--apg-sidebar-selected` */
  sidebarSelectedLight?: string;
  sidebarSelectedDark?: string;
  /** Secondary text (labels, captions). → `--apg-text-secondary` */
  textSecondaryLight?: string;
  textSecondaryDark?: string;
  /** Tertiary text (hints, timestamps). → `--apg-text-tertiary` */
  textTertiaryLight?: string;
  textTertiaryDark?: string;
  /** Border on glass surfaces (menus, sidebar, info panel). → `--apg-glass-border` */
  glassBorderLight?: string;
  glassBorderDark?: string;
  /** Font stack for the whole gallery. → `--apg-font` */
  fontFamily?: string;
  /** Menu / popover corner radius in px. → `--apg-radius-menu` */
  radiusMenu?: number;
  /** Small elevation shadow. → `--apg-shadow-sm` */
  shadowSm?: string;
  /** Medium elevation shadow (menus, action bar). → `--apg-shadow-md` */
  shadowMdLight?: string;
  shadowMdDark?: string;
  /** Large elevation shadow (modals). → `--apg-shadow-lg` */
  shadowLgLight?: string;
  shadowLgDark?: string;
  /** Favourite heart on a grid tile. → `--apg-tile-fav` */
  tileFav?: string;
  /** Full-screen lightbox backdrop. → `--apg-overlay-bg` */
  overlayBg?: string;
  /** Photo/video editor chrome background. → `--apg-editor-bg` */
  editorBg?: string;
  /** Active pill inside a segmented control. → `--apg-segmented-active` */
  segmentedActive?: string;
  /** Sidebar width in px. → `--apg-sidebar-w` */
  sidebarWidth?: number;
  /** Toolbar height in px. → `--apg-toolbar-h` */
  toolbarHeight?: number;
}

/**
 * A server-backed, per-user lock for the Recently Deleted view.
 *
 * When a host supplies one the gallery delegates every lock operation to it and
 * NEVER touches its device-local `localStorage` hash — so the lock follows the
 * user across devices and is enforced by the host's backend.
 */
export interface LockProvider {
  /** Whether this user currently has a lock password configured. */
  status(): Promise<{ hasPassword: boolean }>;
  /** Set (or, with `null`, clear) the password. */
  set(password: string | null): Promise<void>;
  /** Check a password. Resolve `false` for a wrong password; reject on failure. */
  verify(password: string): Promise<boolean>;
}

/** Which pieces of the gallery's own window chrome are rendered. */
export interface GalleryChrome {
  /** macOS-style traffic-light title bar. */
  titlebar: boolean;
  /** Left navigation sidebar. */
  sidebar: boolean;
  /** Top toolbar (zoom / filter / search / more). */
  toolbar: boolean;
  /** Light/Dark/Semi-Dark items in the "More" menu. Turn off when the host owns the theme. */
  themeSwitcher: boolean;
}

/** Default chrome: everything except the macOS titlebar (which stays opt-in). */
export const DEFAULT_CHROME: GalleryChrome = {
  titlebar: false,
  sidebar: true,
  toolbar: true,
  themeSwitcher: true,
};

export interface GalleryConfig {
  features: GalleryFeatures;
  accentColor: string;
  /** Base corner radius in px (drives all rounded UI). */
  borderRadius: number;
  showWindowChrome: boolean;
  title: string;
  /** Optional theme-token overrides mapped to CSS variables at runtime. */
  themeTokens?: ThemeTokens;
  /** The host app's signed-in user. Enables identity-stamped comments. */
  currentUser?: GalleryUser;
  /** Base URL used to build share links (default: `${location.origin}/gallery`). */
  shareBaseUrl?: string;
  /** Which pieces of the gallery's own chrome to render. */
  chrome: GalleryChrome;
  /**
   * Sidebar rows + Collections sections to hide (by ViewId), e.g.
   * `['screenshots', 'sys:documents']`. Absent/`[]` = nothing hidden.
   */
  hiddenViews?: ViewId[];
  /** Whether global keyboard shortcuts are bound to `window`. */
  keyboardShortcuts: boolean;
  /** Embedded mode: lay out inside a host container instead of a full viewport. */
  embedded: boolean;
  /**
   * Server-backed per-user lock for Recently Deleted. When present it REPLACES
   * the device-local localStorage hash entirely.
   */
  lockProvider?: LockProvider;
  /** Start the gallery maximised over the host's chrome (default false). */
  defaultFullscreen?: boolean;
}

export interface GalleryState {
  ready: boolean;
  config: GalleryConfig;

  media: MediaItem[];
  albums: Album[];
  people: Person[];
  /**
   * User's permanent object-tag renames. Key = canonical lowercased detector
   * label (e.g. "car"), value = the label the user renamed it to ("excavator").
   * Persisted, applied to future uploads, and resolved wherever labels are grouped.
   */
  labelAliases: Record<string, string>;
  /** Object/material tags the user deleted — stripped from photos + hidden from future detection. */
  deletedLabels: string[];

  // Navigation / view
  view: ViewId;
  libraryScale: LibraryScale;
  mapMode: MapMode;
  gridFilter: GridFilter;
  searchQuery: string;
  /** Recently opened media ids (most-recent first; in-memory, capped). */
  recentlyViewed: MediaId[];
  /** Active quick search preset (from the search dropdown). */
  searchPreset: 'viewed' | 'edited' | 'added' | null;
  /** Semantic (CLIP) ranking for the current query — media ids best-match first.
   * Computed async by the SemanticSearch worker; null = not computed / not applicable. */
  semanticResults: MediaId[] | null;

  // Appearance
  theme: ThemePreference;
  resolvedTheme: ResolvedTheme;
  sidebarOpen: boolean;
  zoomIndex: number;

  // Selection / overlays
  selection: Set<MediaId>;
  lastSelected: MediaId | null;
  lightboxId: MediaId | null;
  editorId: MediaId | null;
  /** When set, the grid filters to items containing this object label. */
  objectFocus: string | null;
  /** When set, the grid filters to items with this tag (shown date-grouped). */
  tagFocus: string | null;
  /** When set, the grid filters to items containing this person (from People). */
  personFocus: PersonId | null;
  /** When set, the Map view centers here. */
  mapFocus: { lat: number; lng: number } | null;

  // AI analysis status
  aiAvailable: boolean;
  aiStatus: { running: boolean; done: number; total: number };

  /** Whether the Info panel (metadata + mini-map) is open. */
  infoOpen: boolean;
  /** Whether the custom camera is open. */
  cameraOpen: boolean;

  /** Whether the gallery is maximised over the host page (adds `apg--fullscreen`). */
  fullscreen: boolean;

  /**
   * Client-side lock for the Recently Deleted view (hash persisted in localStorage).
   * IGNORED when `config.lockProvider` is set — read `lockConfigured` instead.
   */
  lock: { hash: string | null };
  /**
   * Whether a lock password exists. Single source of truth for the UI: mirrors
   * `lock.hash !== null` on the localStorage path and `lockProvider.status()`
   * when a provider is configured.
   */
  lockConfigured: boolean;
  /** Whether the lock has been opened this session (in-memory, never persisted). */
  lockUnlocked: boolean;
  /**
   * Last lock failure. `'wrong-password'` = verify() answered "no";
   * `'unavailable'` = the provider threw / the network failed (a very different
   * message for the user — retry, don't re-type).
   */
  lockError: 'wrong-password' | 'unavailable' | null;
  /** Share records created on this device (persisted in localStorage). */
  shares: ShareRecord[];

  // ---- actions ----
  init: (adapter: StorageAdapter, ai: AIProvider | null) => Promise<void>;
  /**
   * Replace the live config. `<PhotoGallery>` calls this whenever its props
   * recompute, so a host changing theme tokens / user / chrome takes effect
   * without remounting the gallery.
   */
  setConfig: (config: GalleryConfig) => void;
  addMedia: (items: MediaItem[]) => void;
  /** Import File objects: builds MediaItems, uploads blobs to the adapter if it supports it. */
  importFiles: (files: FileList | File[], albumId?: AlbumId) => Promise<MediaId[]>;
  /** Upload a blob via the adapter (e.g. camera capture). Returns a durable URL or null. */
  uploadBlob: (id: MediaId, blob: Blob) => Promise<string | null>;
  updateMedia: (id: MediaId, patch: Partial<MediaItem>) => void;
  trash: (ids: MediaId[]) => void;
  restore: (ids: MediaId[]) => void;
  deletePermanently: (ids: MediaId[]) => void;
  emptyTrash: () => void;
  toggleFavorite: (ids: MediaId[]) => void;
  setHidden: (ids: MediaId[], hidden: boolean) => void;

  createAlbum: (name: string, kind?: Album['kind'], parentId?: AlbumId) => AlbumId;
  renameAlbum: (id: AlbumId, name: string) => void;
  deleteAlbum: (id: AlbumId) => void;
  addToAlbum: (albumId: AlbumId, ids: MediaId[]) => void;
  removeFromAlbum: (albumId: AlbumId, ids: MediaId[]) => void;
  moveToAlbum: (fromAlbumId: AlbumId, toAlbumId: AlbumId, ids: MediaId[]) => void;
  duplicateAlbum: (id: AlbumId) => void;
  setAlbumCover: (albumId: AlbumId, mediaId: MediaId) => void;

  applyEdit: (id: MediaId, edits: EditState) => void;
  /** Create a NEW media item cloned from `sourceId` with `patch` applied (used by
   * the editor's "Save as Copy"). Returns the new id. Pass `patch.id` to control it. */
  duplicateWithEdits: (sourceId: MediaId, patch: Partial<MediaItem>, changes?: string[]) => MediaId;
  /** Append a new version (preserving the original as v1) instead of overwriting. */
  addVersion: (id: MediaId, patch: Partial<MediaItem>, changes: string[]) => void;
  /** Make an earlier version the current one (records a "Restored" audit entry). */
  restoreVersion: (id: MediaId, versionId: string) => void;
  /** Add / remove a comment on a photo or video. */
  addComment: (id: MediaId, text: string, author?: string) => void;
  deleteComment: (id: MediaId, commentId: string) => void;

  setView: (view: ViewId) => void;
  setLibraryScale: (scale: LibraryScale) => void;
  setMapMode: (mode: MapMode) => void;
  setGridFilter: (filter: GridFilter) => void;
  setSearch: (q: string) => void;
  setSearchPreset: (preset: 'viewed' | 'edited' | 'added' | null) => void;
  setSemanticResults: (ids: MediaId[] | null) => void;
  setObjectFocus: (label: string | null) => void;
  setTagFocus: (tag: string | null) => void;
  setPersonFocus: (id: PersonId | null) => void;
  /** Re-cluster all detected faces into People + group pets (called after AI analysis). */
  rebuildPeople: () => void;
  /** Regenerate the auto "one smart album per detected object" set from current labels. */
  syncObjectAlbums: () => void;
  /** Rename a person/pet group (empty name clears it back to unnamed). */
  renamePerson: (id: PersonId, name: string) => void;
  /**
   * Permanently rename a detected object tag (e.g. "car" → "excavator"). Records a
   * persisted alias, backfills existing items' objectLabels, and rebuilds object
   * albums so the rename applies to past photos and every future upload.
   */
  renameLabel: (from: string, to: string) => void;
  /** Permanently delete an object/material tag (removes it from photos + future detections). */
  deleteLabel: (label: string) => void;

  // Recently Deleted lock
  setLockPassword: (password: string) => Promise<void>;
  removeLockPassword: () => Promise<void>;
  unlockLock: (password: string) => Promise<boolean>;
  relock: () => void;
  /** Re-read `lockProvider.status()` (no-op without a provider). */
  refreshLockStatus: () => Promise<void>;
  clearLockError: () => void;

  // Fullscreen / maximise
  setFullscreen: (next: boolean) => void;
  toggleFullscreen: () => void;

  // Sharing
  createShare: (scope: ShareRecord['scope'], mediaIds: MediaId[], albumId?: AlbumId) => ShareRecord;
  revokeShare: (id: string) => void;
  focusMap: (loc: { lat: number; lng: number } | null) => void;
  setAiStatus: (patch: Partial<GalleryState['aiStatus']>) => void;
  toggleInfo: () => void;
  setInfoOpen: (open: boolean) => void;
  openCamera: () => void;
  closeCamera: () => void;

  setTheme: (theme: ThemePreference) => void;
  setResolvedTheme: (t: ResolvedTheme) => void;
  toggleSidebar: () => void;
  setSidebar: (open: boolean) => void;
  zoomIn: () => void;
  zoomOut: () => void;
  setZoom: (index: number) => void;

  select: (id: MediaId, opts?: { additive?: boolean; range?: boolean; orderedIds?: MediaId[] }) => void;
  selectOnly: (id: MediaId) => void;
  selectMany: (ids: MediaId[]) => void;
  clearSelection: () => void;

  openLightbox: (id: MediaId) => void;
  closeLightbox: () => void;
  openEditor: (id: MediaId) => void;
  closeEditor: () => void;
}

export type GalleryStore = ReturnType<typeof createGalleryStore>;

export interface CreateStoreOptions {
  config: GalleryConfig;
  initialMedia?: MediaItem[];
  initialAlbums?: Album[];
  initialPeople?: Person[];
}

/** localStorage keys for small client-only settings (lock hash + share records).
 * These are intentionally device-local, not pushed to the shared backend. */
const LOCK_KEY = 'apg:lock-hash';
const SHARES_KEY = 'apg:shares';

function lsGet(key: string): string | null {
  try {
    return typeof localStorage !== 'undefined' ? localStorage.getItem(key) : null;
  } catch {
    return null;
  }
}
function lsSet(key: string, value: string | null): void {
  try {
    if (typeof localStorage === 'undefined') return;
    if (value === null) localStorage.removeItem(key);
    else localStorage.setItem(key, value);
  } catch {
    /* private mode / quota — non-fatal */
  }
}
function readShares(): ShareRecord[] {
  const raw = lsGet(SHARES_KEY);
  if (!raw) return [];
  try {
    const parsed = JSON.parse(raw);
    return Array.isArray(parsed) ? (parsed as ShareRecord[]) : [];
  } catch {
    return [];
  }
}

/** Index entities by id, holding the object REFERENCE (identity = "unchanged"). */
function snapshotById<T extends { id: string }>(items: readonly T[]): Map<string, object> {
  const map = new Map<string, object>();
  for (const item of items) map.set(item.id, item);
  return map;
}

function sameRecord(a: Record<string, string>, b: Record<string, string>): boolean {
  const ak = Object.keys(a);
  const bk = Object.keys(b);
  if (ak.length !== bk.length) return false;
  return ak.every((k) => a[k] === b[k]);
}

function sameList(a: readonly string[], b: readonly string[]): boolean {
  return a.length === b.length && a.every((v, i) => v === b[i]);
}

export function createGalleryStore(options: CreateStoreOptions) {
  // Non-reactive references kept out of state.
  let adapter: StorageAdapter | null = null;
  let persistTimer: ReturnType<typeof setTimeout> | null = null;

  // ---- Incremental-persistence bookkeeping (per store instance) ----
  // Snapshots of the LAST SUCCESSFULLY PERSISTED entities, by id, holding the
  // object reference so a `!==` check is a complete change detector (the store
  // never mutates entities in place — every update produces a new object).
  let lastMedia = new Map<string, object>();
  let lastAlbums = new Map<string, object>();
  let lastPeople = new Map<string, object>();
  let lastAliases: Record<string, string> = {};
  let lastDeletedLabels: string[] = [];
  /** Guards against overlapping in-flight writes reordering on a slow backend. */
  let persisting = false;
  let persistDirty = false;
  /** storageRefs resolved by `uploadBlob` before the media item existed. */
  const pendingStorageRefs = new Map<MediaId, string>();

  const store = createStore<GalleryState>((set, get) => {
    /** Seed the snapshots from a just-loaded backend state so a fresh load
     *  does not re-upload the entire library on the first persist. */
    const seedSnapshots = (
      media: readonly MediaItem[],
      albums: readonly Album[],
      people: readonly Person[],
      labelAliases: Record<string, string>,
      deletedLabels: string[],
    ) => {
      lastMedia = snapshotById(media);
      lastAlbums = snapshotById(albums.filter((a) => !a.system));
      lastPeople = snapshotById(people);
      lastAliases = { ...labelAliases };
      lastDeletedLabels = [...deletedLabels];
    };

    const runPersist = async (): Promise<void> => {
      if (!adapter) return;
      // A slow backend must not be able to reorder writes: queue instead.
      if (persisting) {
        persistDirty = true;
        return;
      }
      persisting = true;
      try {
        const { media, albums, people, labelAliases, deletedLabels } = get();
        // Only persist user-owned albums; smart/system albums are regenerated.
        const userAlbums = albums.filter((a) => !a.system);

        if (!adapter.applyChanges) {
          await adapter.save({
            media,
            albums: userAlbums,
            people,
            labelAliases,
            deletedLabels,
            version: 1,
          });
          // Keep the snapshots current so an adapter that gains applyChanges
          // later (or a diff-based retry) starts from the persisted truth.
          seedSnapshots(media, userAlbums, people, labelAliases, deletedLabels);
          return;
        }

        const changes: StateChanges = {};
        const nextMedia = snapshotById(media);
        const nextAlbums = snapshotById(userAlbums);
        const nextPeople = snapshotById(people);

        const upsertMedia = media.filter((m) => lastMedia.get(m.id) !== m);
        const removeMedia = [...lastMedia.keys()].filter((id) => !nextMedia.has(id));
        const upsertAlbums = userAlbums.filter((a) => lastAlbums.get(a.id) !== a);
        const removeAlbums = [...lastAlbums.keys()].filter((id) => !nextAlbums.has(id));
        const upsertPeople = people.filter((p) => lastPeople.get(p.id) !== p);
        const removePeople = [...lastPeople.keys()].filter((id) => !nextPeople.has(id));

        if (upsertMedia.length) changes.upsertMedia = upsertMedia;
        if (removeMedia.length) changes.removeMedia = removeMedia;
        if (upsertAlbums.length) changes.upsertAlbums = upsertAlbums;
        if (removeAlbums.length) changes.removeAlbums = removeAlbums;
        if (upsertPeople.length) changes.upsertPeople = upsertPeople;
        if (removePeople.length) changes.removePeople = removePeople;
        if (!sameRecord(lastAliases, labelAliases)) changes.labelAliases = labelAliases;
        if (!sameList(lastDeletedLabels, deletedLabels)) changes.deletedLabels = deletedLabels;

        if (Object.keys(changes).length === 0) return;

        // Optimistically advance the snapshots, then roll back on rejection so
        // the very next persist retries exactly the same diff.
        const prev = {
          media: lastMedia,
          albums: lastAlbums,
          people: lastPeople,
          aliases: lastAliases,
          deletedLabels: lastDeletedLabels,
        };
        lastMedia = nextMedia;
        lastAlbums = nextAlbums;
        lastPeople = nextPeople;
        lastAliases = { ...labelAliases };
        lastDeletedLabels = [...deletedLabels];
        try {
          await adapter.applyChanges(changes);
        } catch (err) {
          lastMedia = prev.media;
          lastAlbums = prev.albums;
          lastPeople = prev.people;
          lastAliases = prev.aliases;
          lastDeletedLabels = prev.deletedLabels;
          throw err;
        }
      } catch {
        /* Persistence is best-effort — the app keeps working in memory. */
      } finally {
        persisting = false;
        if (persistDirty) {
          persistDirty = false;
          persist();
        }
      }
    };

    const persist = () => {
      if (!adapter) return;
      if (persistTimer) clearTimeout(persistTimer);
      persistTimer = setTimeout(() => {
        persistTimer = null;
        void runPersist();
      }, 400);
    };

    const now = Date.now();

    return {
      ready: false,
      config: options.config,
      media: options.initialMedia ?? [],
      albums: [...defaultSystemAlbums(now), ...(options.initialAlbums ?? [])],
      people: options.initialPeople ?? [],
      labelAliases: {},
      deletedLabels: [],

      view: 'library',
      libraryScale: 'all',
      mapMode: 'satellite',
      gridFilter: 'all',
      searchQuery: '',
      recentlyViewed: [],
      searchPreset: null,
      semanticResults: null,

      theme: 'system',
      resolvedTheme: 'light',
      sidebarOpen: true,
      zoomIndex: DEFAULT_ZOOM_INDEX,

      selection: new Set<MediaId>(),
      lastSelected: null,
      lightboxId: null,
      editorId: null,
      objectFocus: null,
      tagFocus: null,
      personFocus: null,
      mapFocus: null,

      aiAvailable: false,
      aiStatus: { running: false, done: 0, total: 0 },
      infoOpen: false,
      cameraOpen: false,
      fullscreen: options.config.defaultFullscreen ?? false,
      // With a provider the localStorage hash is never read or written; the real
      // answer arrives from `refreshLockStatus()` during init().
      lock: { hash: options.config.lockProvider ? null : lsGet(LOCK_KEY) },
      lockConfigured: options.config.lockProvider ? false : lsGet(LOCK_KEY) !== null,
      lockUnlocked: false,
      lockError: null,
      shares: readShares(),

      async init(a, ai) {
        adapter = a;
        const loaded = await a.load();
        const ts = Date.now();
        if (loaded) {
          // Backend is the SINGLE SOURCE OF TRUTH: show exactly what it returns
          // (fixes "some images missing / stale on refresh"). The only exception is
          // a genuinely-empty backend on first run, which we seed once from the
          // demo `initialMedia` so a fresh install isn't blank.
          const seed = get().media;
          const seedEmptyBackend = loaded.media.length === 0 && seed.length > 0;
          // Seed the diff snapshots from what the backend just returned, so the
          // first persist only sends what actually changed afterwards.
          seedSnapshots(
            loaded.media,
            loaded.albums,
            loaded.people ?? [],
            loaded.labelAliases ?? {},
            loaded.deletedLabels ?? [],
          );
          set({
            media: seedEmptyBackend ? seed : loaded.media,
            albums: [...defaultSystemAlbums(ts), ...loaded.albums.filter((al) => !al.system)],
            people: loaded.people ?? [],
            labelAliases: loaded.labelAliases ?? {},
            deletedLabels: loaded.deletedLabels ?? [],
            ready: true,
            aiAvailable: Boolean(ai),
          });
          if (seedEmptyBackend) persist(); // write the seed into the empty backend once
        } else {
          set({ ready: true, aiAvailable: Boolean(ai) });
          persist();
        }
        // Recycle-bin retention: permanently remove items trashed > 30 days ago.
        const cutoff = Date.now() - TRASH_RETENTION_MS;
        const expired = get()
          .media.filter((m) => m.deletedAt && m.deletedAt < cutoff)
          .map((m) => m.id);
        if (expired.length) get().deletePermanently(expired);
        // Build object smart albums for an already-analyzed library (works even when
        // AI is disabled — labels persisted from a prior session still group).
        get().syncObjectAlbums();
        // A server-backed lock owns the truth about whether a password exists.
        await get().refreshLockStatus();
      },

      setConfig(config) {
        // A host that only NOW supplies a lock provider (async auth, late mount)
        // must get its real lock status. Keyed on presence, not identity, so an
        // inline `lockProvider={{…}}` literal doesn't refetch on every render.
        const had = Boolean(get().config.lockProvider);
        const has = Boolean(config.lockProvider);
        set({ config });
        if (has && !had) void get().refreshLockStatus();
        else if (!has && had) {
          // Provider removed → fall back to the device-local hash.
          const hash = lsGet(LOCK_KEY);
          set({ lock: { hash }, lockConfigured: hash !== null, lockUnlocked: false });
        }
      },

      addMedia(items) {
        if (!items.length) return;
        // Apply any storageRef resolved by uploadBlob() before the item existed.
        const withRefs = items.map((item) => {
          const ref = pendingStorageRefs.get(item.id);
          if (!ref) return item;
          pendingStorageRefs.delete(item.id);
          return item.storageRef ? item : { ...item, storageRef: ref };
        });
        items = withRefs;
        set((s) => ({ media: [...items, ...s.media] }));
        // Auto-file captures/imports into source albums (created on first use).
        const categories: Array<[string, string]> = [
          ['Camera', 'camera'],
          ['Screenshots', 'screenshot'],
        ];
        for (const [name, src] of categories) {
          const ids = items.filter((i) => i.source === src).map((i) => i.id);
          if (!ids.length) continue;
          const existing = get().albums.find((a) => a.kind === 'user' && a.name === name);
          const albumId = existing ? existing.id : get().createAlbum(name);
          get().addToAlbum(albumId, ids);
        }
        persist();
      },

      async importFiles(files, albumId) {
        const arr = Array.from(files as ArrayLike<File>);
        const items: MediaItem[] = [];
        // Stamp the importing user (display identity) when the host supplies one.
        const currentUser = get().config.currentUser;
        for (const f of arr) {
          // mediaFromFile validates the type (image/video allow-list) and returns
          // null for anything else — the backend validation for uploads.
          const item = await mediaFromFile(f);
          if (!item) continue;
          // Record who imported it — but never overwrite an existing value on re-import.
          if (currentUser && !item.uploadedBy) item.uploadedBy = currentUser;
          const objectUrl = item.src; // blob: URL from mediaFromFile (dies on reload)
          // Preferred path: a durable byte store that hands back BOTH a renderable
          // URL and a permanent ref we can re-sign after a reload.
          if (adapter?.putMedia) {
            try {
              const stored = await adapter.putMedia(item.id, f, {
                name: item.name,
                mime: item.mime,
              });
              if (stored?.url) {
                item.src = stored.url;
                item.storageRef = stored.ref;
                item.thumbnail = undefined;
              }
            } catch {
              /* fall through to putBlob / the data-URL fallback below */
            }
          }
          // Upload to the backend (if the adapter supports blobs) for a durable URL.
          if (!item.storageRef && adapter?.putBlob) {
            try {
              const url = await adapter.putBlob(item.id, f);
              if (url) {
                item.src = url;
                item.thumbnail = undefined;
              }
            } catch {
              /* fall through to the data-URL fallback below */
            }
          }
          // Never PERSIST a bare blob: URL — it's invalid after a page reload. If we
          // didn't get a durable backend URL (no adapter, upload failed, or the adapter
          // itself returns a blob: URL), inline the bytes as a durable data: URL so the
          // imported media survives a reload. (Supabase returns a short https URL here.)
          if (item.src.startsWith('blob:')) {
            try {
              item.src = await blobToDataUrl(f);
              item.thumbnail = undefined;
              if (objectUrl.startsWith('blob:')) URL.revokeObjectURL(objectUrl);
            } catch {
              /* keep the object URL as an absolute last resort */
            }
          }
          items.push(item);
        }
        if (items.length) {
          // addMedia auto-files by source (Camera / Screenshots); AIAnalyzer then
          // tags objects/faces/OCR/embeddings in the background.
          get().addMedia(items);
          if (albumId) get().addToAlbum(albumId, items.map((i) => i.id));
          // Surface the just-uploaded photo with its Info panel open, so the user
          // watches the analysis run + sees its results without hunting for it.
          set({ infoOpen: true, selection: new Set([items[0]!.id]) });
        }
        return items.map((i) => i.id);
      },

      async uploadBlob(id, blob) {
        if (adapter?.putMedia) {
          try {
            const existing = get().media.find((m) => m.id === id);
            const stored = await adapter.putMedia(id, blob, {
              name: existing?.name ?? id,
              mime: existing?.mime ?? blob.type ?? 'application/octet-stream',
            });
            if (stored?.url) {
              // Record the durable ref on the item. When the item doesn't exist
              // yet (camera capture) it is stashed and applied by addMedia().
              if (existing) {
                set((s) => ({
                  media: s.media.map((m) => (m.id === id ? { ...m, storageRef: stored.ref } : m)),
                }));
                persist();
              } else {
                pendingStorageRefs.set(id, stored.ref);
              }
              return stored.url;
            }
          } catch {
            /* fall through to putBlob below */
          }
        }
        if (adapter?.putBlob) {
          try {
            return await adapter.putBlob(id, blob);
          } catch {
            return null;
          }
        }
        return null;
      },

      updateMedia(id, patch) {
        set((s) => ({ media: s.media.map((m) => (m.id === id ? { ...m, ...patch } : m)) }));
        persist();
      },

      trash(ids) {
        const set_ = new Set(ids);
        const ts = Date.now();
        set((s) => ({
          media: s.media.map((m) => (set_.has(m.id) ? { ...m, deletedAt: ts } : m)),
          selection: new Set(),
        }));
        persist();
      },

      restore(ids) {
        const set_ = new Set(ids);
        set((s) => ({
          media: s.media.map((m) =>
            set_.has(m.id) ? { ...m, deletedAt: undefined } : m,
          ),
          selection: new Set(),
        }));
        persist();
      },

      deletePermanently(ids) {
        const set_ = new Set(ids);
        set((s) => ({
          media: s.media.filter((m) => !set_.has(m.id)),
          albums: s.albums.map((a) => ({
            ...a,
            mediaIds: a.mediaIds.filter((mid) => !set_.has(mid)),
          })),
          selection: new Set(),
        }));
        persist();
      },

      emptyTrash() {
        set((s) => ({ media: s.media.filter((m) => !m.deletedAt), selection: new Set() }));
        persist();
      },

      toggleFavorite(ids) {
        const set_ = new Set(ids);
        // If any selected item is not a favourite, favourite all; else unfavourite.
        const anyOff = get().media.some((m) => set_.has(m.id) && !m.favorite);
        set((s) => ({
          media: s.media.map((m) => (set_.has(m.id) ? { ...m, favorite: anyOff } : m)),
        }));
        persist();
      },

      setHidden(ids, hidden) {
        const set_ = new Set(ids);
        set((s) => ({ media: s.media.map((m) => (set_.has(m.id) ? { ...m, hidden } : m)) }));
        persist();
      },

      createAlbum(name, kind = 'user', parentId) {
        const id = `album:${nanoid(8)}`;
        const album: Album = {
          id,
          name: name.trim() || 'Untitled Album',
          kind,
          parentId,
          mediaIds: [],
          createdAt: Date.now(),
        };
        set((s) => ({ albums: [...s.albums, album] }));
        persist();
        return id;
      },

      addVersion(id, patch, changes) {
        const now = Date.now();
        const user = get().config.currentUser;
        set((s) => ({
          media: s.media.map((m) => {
            if (m.id !== id) return m;
            // Seed v1 = the ORIGINAL (the current pre-edit snapshot) on first edit.
            const base: MediaVersion[] =
              m.versions && m.versions.length
                ? m.versions
                : [
                    {
                      id: nanoid(8),
                      version: 1,
                      createdAt: m.importedAt ?? now,
                      src: m.src,
                      thumbnail: m.thumbnail,
                      width: m.width,
                      height: m.height,
                      edits: m.edits,
                      changes: ['Original'],
                    },
                  ];
            const nextNum = base[base.length - 1]!.version + 1;
            const newVersion: MediaVersion = {
              id: nanoid(8),
              version: nextNum,
              createdAt: now,
              src: patch.src ?? m.src,
              thumbnail: patch.thumbnail,
              width: patch.width ?? m.width,
              height: patch.height ?? m.height,
              edits: patch.edits,
              changes: changes.length ? changes : ['Edited'],
              // Who made this edit (display identity), when the host supplies a user.
              ...(user
                ? { authorId: user.id, author: user.name, authorAvatar: user.avatarUrl }
                : {}),
            };
            return { ...m, ...patch, versions: [...base, newVersion], editedAt: now };
          }),
        }));
        persist();
      },

      restoreVersion(id, versionId) {
        const now = Date.now();
        const user = get().config.currentUser;
        set((s) => ({
          media: s.media.map((m) => {
            if (m.id !== id || !m.versions) return m;
            const target = m.versions.find((v) => v.id === versionId);
            if (!target) return m;
            const nextNum = m.versions[m.versions.length - 1]!.version + 1;
            const restored: MediaVersion = {
              id: nanoid(8),
              version: nextNum,
              createdAt: now,
              src: target.src,
              thumbnail: target.thumbnail,
              width: target.width,
              height: target.height,
              edits: target.edits,
              changes: [`Restored version ${target.version}`],
              // Who performed the restore (display identity), when a user is set.
              ...(user
                ? { authorId: user.id, author: user.name, authorAvatar: user.avatarUrl }
                : {}),
            };
            return {
              ...m,
              src: target.src,
              thumbnail: target.thumbnail,
              width: target.width ?? m.width,
              height: target.height ?? m.height,
              edits: target.edits,
              editedAt: now,
              versions: [...m.versions, restored],
            };
          }),
        }));
        persist();
      },

      addComment(id, text, author) {
        const trimmed = text.trim();
        if (!trimmed) return;
        // With a host-provided user, comments carry a real identity instead of a
        // free-text name (the backend is still the authority on `authorId`).
        const user = get().config.currentUser;
        const comment: MediaComment = user
          ? {
              id: nanoid(8),
              text: trimmed,
              createdAt: Date.now(),
              authorId: user.id,
              author: author ?? user.name,
              authorAvatar: user.avatarUrl,
            }
          : { id: nanoid(8), author, text: trimmed, createdAt: Date.now() };
        set((s) => ({
          media: s.media.map((m) =>
            m.id === id ? { ...m, comments: [...(m.comments ?? []), comment] } : m,
          ),
        }));
        persist();
      },
      deleteComment(id, commentId) {
        // Client-side mirror of the server rule: with a configured user you may
        // only delete your own comments (legacy comments with no authorId stay
        // deletable so pre-identity libraries keep working).
        const user = get().config.currentUser;
        if (user) {
          const target = get()
            .media.find((m) => m.id === id)
            ?.comments?.find((c) => c.id === commentId);
          if (target && target.authorId !== undefined && target.authorId !== user.id) return;
        }
        set((s) => ({
          media: s.media.map((m) =>
            m.id === id ? { ...m, comments: (m.comments ?? []).filter((c) => c.id !== commentId) } : m,
          ),
        }));
        persist();
      },

      renameAlbum(id, name) {
        set((s) => ({
          albums: s.albums.map((a) =>
            a.id === id && !a.system ? { ...a, name: name.trim() || a.name } : a,
          ),
        }));
        persist();
      },

      deleteAlbum(id) {
        set((s) => ({ albums: s.albums.filter((a) => a.id !== id || a.system) }));
        persist();
      },

      addToAlbum(albumId, ids) {
        set((s) => ({
          albums: s.albums.map((a) =>
            a.id === albumId
              ? { ...a, mediaIds: [...new Set([...a.mediaIds, ...ids])] }
              : a,
          ),
          media: s.media.map((m) =>
            ids.includes(m.id) ? { ...m, albumIds: [...new Set([...m.albumIds, albumId])] } : m,
          ),
        }));
        persist();
      },

      removeFromAlbum(albumId, ids) {
        const set_ = new Set(ids);
        set((s) => ({
          albums: s.albums.map((a) =>
            a.id === albumId ? { ...a, mediaIds: a.mediaIds.filter((m) => !set_.has(m)) } : a,
          ),
          media: s.media.map((m) =>
            set_.has(m.id) ? { ...m, albumIds: m.albumIds.filter((aid) => aid !== albumId) } : m,
          ),
        }));
        persist();
      },

      moveToAlbum(fromAlbumId, toAlbumId, ids) {
        get().removeFromAlbum(fromAlbumId, ids);
        get().addToAlbum(toAlbumId, ids);
      },

      duplicateAlbum(id) {
        const album = get().albums.find((a) => a.id === id);
        if (!album) return;
        const copy: Album = {
          ...album,
          id: `album:${nanoid(8)}`,
          name: `${album.name} copy`,
          system: false,
          kind: 'user',
          createdAt: Date.now(),
        };
        set((s) => ({ albums: [...s.albums, copy] }));
        persist();
      },

      setAlbumCover(albumId, mediaId) {
        set((s) => ({
          albums: s.albums.map((a) => (a.id === albumId ? { ...a, coverId: mediaId } : a)),
        }));
        persist();
      },

      applyEdit(id, edits) {
        set((s) => ({
          media: s.media.map((m) =>
            m.id === id ? { ...m, edits, editedAt: Date.now() } : m,
          ),
        }));
        persist();
      },

      duplicateWithEdits(sourceId, patch, changes) {
        const src = get().media.find((m) => m.id === sourceId);
        const now = Date.now();
        const id = (patch.id as MediaId) ?? (nanoid(10) as MediaId);
        if (!src) return id;
        const dot = src.name.lastIndexOf('.');
        const base = dot > 0 ? src.name.slice(0, dot) : src.name;
        const ext = dot > 0 ? src.name.slice(dot) : '';
        // The copy gets its OWN fresh 2-entry history: v1 = the source as it was
        // (the original), v2 = this edit. Never inherit the parent's versions/comments.
        const original: MediaVersion = {
          id: nanoid(8),
          version: 1,
          createdAt: src.importedAt ?? now,
          src: src.src,
          thumbnail: src.thumbnail,
          width: src.width,
          height: src.height,
          edits: src.edits,
          changes: ['Original'],
        };
        const edited: MediaVersion = {
          id: nanoid(8),
          version: 2,
          createdAt: now,
          src: patch.src ?? src.src,
          thumbnail: patch.thumbnail,
          width: patch.width ?? src.width,
          height: patch.height ?? src.height,
          edits: patch.edits,
          changes: changes && changes.length ? changes : ['Edited'],
        };
        const copy: MediaItem = {
          ...src,
          ...patch,
          id,
          name: `${base} copy${ext}`,
          albumIds: [], // the user files the copy via the album picker
          favorite: false,
          deletedAt: undefined,
          importedAt: now,
          editedAt: now,
          personIds: [],
          versions: [original, edited],
          comments: [],
        };
        // addMedia prepends + auto-files by source; persist() runs inside it.
        get().addMedia([copy]);
        return id;
      },

      setView(view) {
        set({
          view,
          objectFocus: null,
          tagFocus: null,
          personFocus: null,
          searchPreset: null,
          selection: new Set(),
        });
      },
      setLibraryScale(scale) {
        set({ libraryScale: scale });
      },
      setMapMode(mode) {
        set({ mapMode: mode });
      },
      setGridFilter(filter) {
        set({ gridFilter: filter });
      },
      setSearch(q) {
        // Clear stale semantic ranking; the SemanticSearch worker recomputes it.
        set({ searchQuery: q, searchPreset: null, semanticResults: null });
      },
      setSemanticResults(ids) {
        set({ semanticResults: ids });
      },
      setSearchPreset(preset) {
        set({
          searchPreset: preset,
          view: preset ? 'search' : 'library',
          searchQuery: '',
          objectFocus: null,
          tagFocus: null,
          personFocus: null,
        });
      },
      setObjectFocus(label) {
        set({
          objectFocus: label,
          tagFocus: null,
          personFocus: null,
          view: label ? 'library' : get().view,
        });
      },
      setTagFocus(tag) {
        set((s) => ({
          tagFocus: tag,
          objectFocus: null,
          personFocus: null,
          view: tag ? 'library' : s.view,
          libraryScale: tag ? 'days' : s.libraryScale, // tag view is grouped date-wise
        }));
      },
      setPersonFocus(id) {
        set((s) => ({
          personFocus: id,
          objectFocus: null,
          tagFocus: null,
          view: id ? 'library' : s.view,
        }));
      },
      rebuildPeople() {
        const { media, people: prev } = get();
        const clusters = clusterFaces(media).filter((c) => c.mediaIds.length > 0);

        // Reuse an existing person's id/name when a new cluster overlaps it the
        // most — so user-assigned names survive re-clustering as the library grows.
        const usedPrev = new Set<PersonId>();
        const people: Person[] = clusters
          .map((c) => {
            let match: Person | undefined;
            let bestOverlap = 0;
            for (const p of prev) {
              if (usedPrev.has(p.id)) continue;
              const overlap = p.mediaIds.reduce(
                (n, mid) => (c.mediaIds.includes(mid) ? n + 1 : n),
                0,
              );
              if (overlap > bestOverlap) {
                bestOverlap = overlap;
                match = p;
              }
            }
            const reuse = match && bestOverlap > 0 ? match : undefined;
            if (reuse) usedPrev.add(reuse.id);
            return {
              id: reuse?.id ?? `person:${nanoid(8)}`,
              name: reuse?.name,
              isPet: false,
              coverId: c.coverId,
              mediaIds: c.mediaIds,
            } satisfies Person;
          })
          .sort((a, b) => b.mediaIds.length - a.mediaIds.length);

        // Pet groups: object detection can't tell individual animals apart, so we
        // group by detected animal type (Dogs/Cats/…). Stable id `pet:<label>` keeps
        // a user-assigned name across rebuilds.
        const live = media.filter((m) => !m.deletedAt && !m.hidden);
        const petGroups: Person[] = [];
        for (const label of PET_LABELS) {
          const items = live.filter((m) => m.objectLabels.includes(label));
          if (items.length === 0) continue;
          const id = `pet:${label}` as PersonId;
          const prevPet = prev.find((p) => p.id === id);
          petGroups.push({
            id,
            name: prevPet?.name ?? `${label[0]!.toUpperCase()}${label.slice(1)}s`,
            isPet: true,
            coverId: items[0]!.id,
            mediaIds: items.map((m) => m.id),
          });
        }

        const all = [...people, ...petGroups].sort((a, b) => b.mediaIds.length - a.mediaIds.length);

        // Denormalize person/pet membership back onto each item (powers person focus).
        const byMedia = new Map<MediaId, PersonId[]>();
        for (const p of all) {
          for (const mid of p.mediaIds) {
            const arr = byMedia.get(mid);
            if (arr) arr.push(p.id);
            else byMedia.set(mid, [p.id]);
          }
        }
        set((s) => ({
          people: all,
          media: s.media.map((m) => {
            const ids = byMedia.get(m.id) ?? [];
            if (ids.length === 0 && m.personIds.length === 0) return m;
            const same =
              ids.length === m.personIds.length && ids.every((id, i) => id === m.personIds[i]);
            return same ? m : { ...m, personIds: ids };
          }),
        }));
        persist();
      },
      syncObjectAlbums() {
        // Replace ONLY the sys:obj:* set; keep default system albums + user albums.
        // Membership stays live via resolveSmartAlbum, so no per-item bookkeeping and
        // no persist() needed (system albums are stripped on save + regenerated on load).
        const generated = objectSmartAlbums(get().media, Date.now(), get().labelAliases);
        set((s) => {
          const others = s.albums.filter((a) => !a.id.startsWith('sys:obj:'));
          // Skip the state write if the label SET is unchanged (avoids render churn).
          const prevIds = s.albums.filter((a) => a.id.startsWith('sys:obj:')).map((a) => a.id);
          const nextIds = generated.map((a) => a.id);
          const same =
            prevIds.length === nextIds.length && prevIds.every((id, i) => id === nextIds[i]);
          if (same) return {};
          return { albums: [...others, ...generated] };
        });
      },
      renamePerson(id, name) {
        const trimmed = name.trim();
        set((s) => ({
          people: s.people.map((p) => (p.id === id ? { ...p, name: trimmed || undefined } : p)),
        }));
        persist();
      },
      renameLabel(from, to) {
        const key = from.trim().toLowerCase();
        const value = to.trim().toLowerCase();
        if (!key) return;
        set((s) => {
          const labelAliases: Record<string, string> = { ...s.labelAliases };
          // Empty / unchanged name clears the alias (renames the tag back to itself).
          const clearing = !value || value === key;
          if (clearing) {
            delete labelAliases[key];
          } else {
            labelAliases[key] = value;
            // Re-point any earlier aliases that resolved to `key` so renaming an
            // already-renamed tag keeps mapping the original detector label(s).
            for (const k of Object.keys(labelAliases)) {
              if (k !== key && labelAliases[k] === key) labelAliases[k] = value;
            }
          }
          // Backfill existing items so past photos regroup under the new label and
          // stay searchable by it (search reads objectLabels directly).
          const target = clearing ? key : value;
          const media = s.media.map((m) => {
            if (!m.objectLabels.some((l) => l.trim().toLowerCase() === key)) return m;
            const mapped = [
              ...new Set(
                m.objectLabels.map((l) => (l.trim().toLowerCase() === key ? target : l)),
              ),
            ];
            return { ...m, objectLabels: mapped };
          });
          return { labelAliases, media };
        });
        // Rebuild the sys:obj:* albums so the sidebar/Objects re-title live.
        get().syncObjectAlbums();
        persist();
      },

      deleteLabel(label) {
        const key = label.trim().toLowerCase();
        if (!key) return;
        set((s) => {
          const deletedLabels = s.deletedLabels.includes(key)
            ? s.deletedLabels
            : [...s.deletedLabels, key];
          // Drop any aliases pointing at this label so it can't reappear via a rename.
          const labelAliases: Record<string, string> = { ...s.labelAliases };
          for (const k of Object.keys(labelAliases)) {
            if (k === key || labelAliases[k] === key) delete labelAliases[k];
          }
          // Strip the label from every photo so its album empties and it stops matching.
          const media = s.media.map((m) =>
            m.objectLabels.some((l) => l.trim().toLowerCase() === key)
              ? { ...m, objectLabels: m.objectLabels.filter((l) => l.trim().toLowerCase() !== key) }
              : m,
          );
          return { deletedLabels, labelAliases, media };
        });
        get().syncObjectAlbums();
        persist();
      },

      async setLockPassword(password) {
        const provider = get().config.lockProvider;
        if (provider) {
          try {
            await provider.set(password);
          } catch {
            set({ lockError: 'unavailable' });
            return;
          }
          set({ lockConfigured: true, lockUnlocked: true, lockError: null });
          return;
        }
        const hash = await hashPassword(password);
        if (!hash) return;
        lsSet(LOCK_KEY, hash);
        set({ lock: { hash }, lockConfigured: true, lockUnlocked: true, lockError: null });
      },
      async removeLockPassword() {
        const provider = get().config.lockProvider;
        if (provider) {
          try {
            await provider.set(null);
          } catch {
            set({ lockError: 'unavailable' });
            return;
          }
          set({ lockConfigured: false, lockUnlocked: false, lockError: null });
          return;
        }
        lsSet(LOCK_KEY, null);
        set({ lock: { hash: null }, lockConfigured: false, lockUnlocked: false, lockError: null });
      },
      async unlockLock(password) {
        const provider = get().config.lockProvider;
        if (provider) {
          let ok: boolean;
          try {
            ok = await provider.verify(password);
          } catch {
            // A rejected verify is NOT a wrong password — say so.
            set({ lockError: 'unavailable' });
            return false;
          }
          set(ok ? { lockUnlocked: true, lockError: null } : { lockError: 'wrong-password' });
          return ok;
        }
        const ok = await verifyPassword(password, get().lock.hash);
        set(ok ? { lockUnlocked: true, lockError: null } : { lockError: 'wrong-password' });
        return ok;
      },
      relock() {
        set({ lockUnlocked: false, lockError: null });
      },
      async refreshLockStatus() {
        const provider = get().config.lockProvider;
        if (!provider) return;
        try {
          const { hasPassword } = await provider.status();
          set((s) => ({
            lockConfigured: hasPassword,
            // Losing the password server-side must not leave a stale unlock.
            lockUnlocked: hasPassword ? s.lockUnlocked : false,
          }));
        } catch {
          set({ lockError: 'unavailable' });
        }
      },
      clearLockError() {
        set({ lockError: null });
      },

      setFullscreen(next) {
        set({ fullscreen: next });
      },
      toggleFullscreen() {
        set((s) => ({ fullscreen: !s.fullscreen }));
      },

      createShare(scope, mediaIds, albumId) {
        const token = nanoid(10);
        const album = albumId ? get().albums.find((a) => a.id === albumId) : undefined;
        const title =
          scope === 'album'
            ? (album?.name ?? 'Shared Album')
            : mediaIds.length === 1
              ? (get().media.find((m) => m.id === mediaIds[0])?.name ?? 'Shared Photo')
              : `${mediaIds.length} Photos`;
        const origin =
          typeof location !== 'undefined' && location.origin ? location.origin : 'https://photos.app';
        // The host owns where a share link points (its own route, not ours).
        const base = get().config.shareBaseUrl ?? `${origin}/gallery`;
        const share: ShareRecord = {
          id: nanoid(12),
          token,
          scope,
          mediaIds,
          albumId,
          title,
          url: `${base}?shared=${token}`,
          createdAt: Date.now(),
        };
        const next = [share, ...get().shares];
        lsSet(SHARES_KEY, JSON.stringify(next));
        set({ shares: next });
        return share;
      },
      revokeShare(id) {
        const next = get().shares.filter((s) => s.id !== id);
        lsSet(SHARES_KEY, JSON.stringify(next));
        set({ shares: next });
      },
      focusMap(loc) {
        set({ mapFocus: loc, view: 'map', infoOpen: false, lightboxId: null });
      },
      setAiStatus(patch) {
        set((s) => ({ aiStatus: { ...s.aiStatus, ...patch } }));
      },
      toggleInfo() {
        set((s) => ({ infoOpen: !s.infoOpen }));
      },
      setInfoOpen(open) {
        set({ infoOpen: open });
      },
      openCamera() {
        set({ cameraOpen: true });
      },
      closeCamera() {
        set({ cameraOpen: false });
      },

      setTheme(theme) {
        set({ theme });
      },
      setResolvedTheme(t) {
        set({ resolvedTheme: t });
      },
      toggleSidebar() {
        set((s) => ({ sidebarOpen: !s.sidebarOpen }));
      },
      setSidebar(open) {
        set({ sidebarOpen: open });
      },
      zoomIn() {
        // Larger tiles, fewer columns.
        set((s) => ({ zoomIndex: Math.min(GRID_ZOOM_STEPS.length - 1, s.zoomIndex + 1) }));
      },
      zoomOut() {
        // Smaller tiles, more columns.
        set((s) => ({ zoomIndex: Math.max(0, s.zoomIndex - 1) }));
      },
      setZoom(index) {
        set({ zoomIndex: Math.max(0, Math.min(GRID_ZOOM_STEPS.length - 1, index)) });
      },

      select(id, opts) {
        const { additive, range, orderedIds } = opts ?? {};
        set((s) => {
          const next = new Set(s.selection);
          if (range && s.lastSelected && orderedIds) {
            const a = orderedIds.indexOf(s.lastSelected);
            const b = orderedIds.indexOf(id);
            if (a !== -1 && b !== -1) {
              const [lo, hi] = a < b ? [a, b] : [b, a];
              // A plain shift-click replaces the prior selection with the fresh
              // contiguous range; shift+cmd unions the range onto it.
              const sel = additive ? next : new Set<MediaId>();
              for (let i = lo; i <= hi; i++) sel.add(orderedIds[i]!);
              return { selection: sel };
            }
          }
          if (additive) {
            if (next.has(id)) next.delete(id);
            else next.add(id);
            return { selection: next, lastSelected: id };
          }
          return { selection: new Set([id]), lastSelected: id };
        });
      },
      selectOnly(id) {
        set({ selection: new Set([id]), lastSelected: id });
      },
      selectMany(ids) {
        set({ selection: new Set(ids), lastSelected: ids[ids.length - 1] ?? null });
      },
      clearSelection() {
        set({ selection: new Set() });
      },

      openLightbox(id) {
        set((s) => ({
          lightboxId: id,
          recentlyViewed: [id, ...s.recentlyViewed.filter((x) => x !== id)].slice(0, 50),
        }));
      },
      closeLightbox() {
        set({ lightboxId: null });
      },
      openEditor(id) {
        set({ editorId: id, lightboxId: null });
      },
      closeEditor() {
        set({ editorId: null });
      },
    };
  });

  return store;
}
