scripts/db.jsjavascript
/**
 * @module db
 * @description
 * LocalStorage persistence layer for the two face databases. Holds two parallel
 * stores — one for face-api 128-D recognition descriptors (`STORAGE_KEY`) and
 * one for the MediaPipe ImageEmbedder vectors (`STORAGE_KEY_3D`) — with IDs
 * aligned between them: when `engine.js` assigns an ID at save time, the same
 * ID is passed to `engine-3d.js` so both DBs grow in lockstep.
 *
 * Why two stores instead of one record with two descriptors: it keeps each
 * engine independent. `engine.js` never has to know that a 3D embedding
 * exists, and `engine-3d.js` never has to read or preserve 2D fields it
 * doesn't understand. Disalignment is tolerated (no rollback): if one save
 * fails after the other succeeds, the surviving record is left alone and
 * `findFace` for the missing engine simply returns no match.
 *
 * The 3D DB carries a `modelVersion` field. At load time we compare it with
 * the current `DB3D_MODEL_VERSION`; a mismatch wipes the store automatically,
 * since embeddings produced by different models are not comparable.
 */
import { els } from './dom.js';
import { state } from './state.js';
import { setLog } from './utils.js';
import { clearAllThumbnails } from './face-thumbnails.js';
import { t } from './i18n.js';

/** LocalStorage key for the 2D (face-api) face database. */
export const STORAGE_KEY = 'local-face-lab-db-v1';
/** LocalStorage key for the 3D (ImageEmbedder) face database. */
export const STORAGE_KEY_3D = 'local-face-lab-db-3d-v1';
/**
 * Version tag stamped into the 3D DB. Bumping this string invalidates the
 * stored embeddings on the next load (they will be auto-cleared). Bump it
 * whenever the embedder model or its preprocessing changes in a way that
 * makes old vectors incomparable with new ones.
 */
export const DB3D_MODEL_VERSION = 'image-embedder-v1';

/**
 * Factory for an empty 3D DB shape with the current model version stamped in.
 * Used both for the initial `loadDb3d()` path when no stored data exists and
 * for the auto-wipe path triggered by a model-version mismatch.
 *
 * @returns {{faces: Array, modelVersion: string}}
 */
function createEmptyDb3d() {
   return { faces: [], modelVersion: DB3D_MODEL_VERSION };
}

/**
 * Read the 3D face database from `localStorage`. Returns a fresh empty store
 * if nothing is stored, the JSON is malformed, or the stored model version
 * does not match `DB3D_MODEL_VERSION` (in which case the old data is wiped
 * via `clearDb3d()` because it can no longer be matched against new
 * embeddings).
 *
 * @returns {{faces: Array, modelVersion: string}}
 *   The current 3D DB state. There is no `nextId` field — 3D IDs come from
 *   the 2D DB to keep the two stores aligned.
 * @see scripts/main.js – called once during init to populate `state.db3d`.
 * @see clearDb3d – called from inside this function when the model version
 *   does not match the stored data.
 */
export function loadDb3d() {
   try {
      const raw = localStorage.getItem(STORAGE_KEY_3D);
      if (!raw) return createEmptyDb3d();
      const parsed = JSON.parse(raw);
      if (!parsed || !Array.isArray(parsed.faces)) return createEmptyDb3d();
      if (parsed.modelVersion !== DB3D_MODEL_VERSION) {
         if (parsed.faces.length > 0) {
            setLog(t('incompatible_3d_db_log'), 'db');
         }
         state.db3d = createEmptyDb3d();
         clearDb3d();
         return state.db3d;
      }
      return parsed;
   } catch {
      return createEmptyDb3d();
   }
}

/**
 * Serialise `state.db3d` to `localStorage` under `STORAGE_KEY_3D`. Called
 * after every successful 3D save so the next reload sees the new embedding.
 *
 * @see scripts/engine-3d.js – `saveFace3d()` calls this after pushing a record.
 */
export function persistDb3d() {
   localStorage.setItem(STORAGE_KEY_3D, JSON.stringify(state.db3d));
}

/**
 * Reset the 3D DB to an empty store (preserving the current model version)
 * and persist the empty state. Called by `clearDb()` as part of a full
 * cross-engine wipe, and internally by `loadDb3d()` when a model-version
 * mismatch is detected.
 *
 * @see clearDb – orchestrates wiping both DBs together.
 * @see loadDb3d – triggers this on a model-version mismatch.
 */
export function clearDb3d() {
   state.db3d = createEmptyDb3d();
   persistDb3d();
}

/**
 * Read the 2D face database from `localStorage`. Returns a fresh empty store
 * (`{nextId: 0, faces: []}`) if nothing is stored or the JSON is malformed or
 * structurally invalid. `nextId` is the source of truth for the next assigned
 * ID across both engines — the 3D save path reuses it rather than tracking
 * its own counter.
 *
 * @returns {{nextId: number, faces: Array}} The current DB state.
 * @see scripts/main.js – called once during init to populate `state.db`.
 * @see scripts/engine.js – `saveFace()` increments and persists `nextId`.
 */
export function loadDb() {
   try {
      const raw = localStorage.getItem(STORAGE_KEY);
      if (!raw) return { nextId: 0, faces: [] };
      const parsed = JSON.parse(raw);
      if (!parsed || !Array.isArray(parsed.faces) || typeof parsed.nextId !== 'number') {
         return { nextId: 0, faces: [] };
      }
      return parsed;
   } catch {
      return { nextId: 0, faces: [] };
   }
}

/**
 * Serialise `state.db` to `localStorage` and emit a `dbChanged` event on the
 * shared bus so consumers (status badge, bbox overlay) can refresh. Dispatch
 * happens here so every code path that mutates the DB has consistent UI
 * feedback without having to remember to fire the event itself.
 *
 * @see scripts/engine.js – `saveFace()` calls this after pushing a record.
 * @see clearDb – calls this to persist the empty state and notify listeners.
 * @see tests/unit/db.test.js – verifies persistence and event emission.
 */
export function persistDb() {
   localStorage.setItem(STORAGE_KEY, JSON.stringify(state.db));
   state.gstmxxEvents.dispatchEvent(new CustomEvent('dbChanged', {
      detail: {
         count: state.db.faces.length,
         nextId: state.db.nextId
      }
   }));
}

/**
 * Push the current DB statistics into the side-panel UI: face count, next ID,
 * the match threshold label, and the badge in the header. Called whenever
 * the DB shape changes or the app starts up.
 *
 * @see scripts/main.js – `init()` calls this once after loading the DBs.
 * @see clearDb – calls this after wiping the data so the UI shows zero.
 */
export function renderDbStats() {
   els.dbCount.textContent = String(state.db.faces.length);
   els.nextId.textContent = String(state.db.nextId);
   els.thresholdLabel.textContent = state.MATCH_THRESHOLD.toFixed(2);

   els.dbCountBadge.textContent = String(state.db.faces.length);
   // Badge is always shown, even at zero, so users learn where the count
   // lives. Toggling its display made the layout shift on first save.
   els.dbCountBadge.style.display = 'inline-block';
}

/**
 * Wipe both the 2D and 3D face databases, persist the empty state of both,
 * and emit a `matchStateChanged` event (`source: 'clear'`) so any listening
 * UI (bbox overlay, badges) can reset its derived display. The two
 * underlying stores are cleared in lockstep because their IDs are aligned —
 * dropping one without the other would leave orphan records on the next
 * find.
 *
 * @see persistDb – persists the empty 2D state.
 * @see persistDb3d – persists the empty 3D state (called via reassignment).
 * @see renderDbStats – refreshes the on-screen counters.
 * @see tests/unit/db.test.js – covers the cross-DB wipe.
 */
export function clearDb() {
   state.db = { nextId: 0, faces: [] };
   if (state.db3d) state.db3d = createEmptyDb3d();
   persistDb();
   if (state.db3d !== null) persistDb3d();
   clearAllThumbnails();
   state.gstmxxEvents.dispatchEvent(new CustomEvent('matchStateChanged', {
      detail: { detectionState: 'unknown', source: 'clear' }
   }));
   setLog(t('local_archive_cleared_log'));
   renderDbStats();
}