Source: image-cache.js

// IMAGE CACHE — IndexedDB storage for coin images and Numista metadata
// =============================================================================

/**
 * ImageCache provides persistent IndexedDB storage for coin images (obverse/reverse)
 * and enriched Numista metadata. Images are resized and compressed to JPEG before storage.
 *
 * Schema:
 *   DB: StakTrakrImages v3
 *   Store "coinImages"    — keyPath: catalogId (Numista N# string)
 *   Store "coinMetadata"  — keyPath: catalogId (Numista N# string)
 *   Store "userImages"    — keyPath: uuid (item UUID string)
 *   Store "patternImages" — keyPath: ruleId (pattern rule ID string)
 *
 * @class
 */
class ImageCache {
  constructor() {
    /** @type {IDBDatabase|null} */
    this._db = null;
    /** @type {boolean} */
    this._available = false;
    /** @type {number} Default storage quota in bytes (50 MB) */
    this._quotaBytes = 50 * 1024 * 1024;
    /** @type {number} Max image dimension (px) for resize */
    this._maxDim = typeof IMAGE_MAX_DIM !== 'undefined' ? IMAGE_MAX_DIM : 600;
    /** @type {number} Compression quality (0-1) */
    this._quality = typeof IMAGE_QUALITY !== 'undefined' ? IMAGE_QUALITY : 0.75;
  }

  // ---------------------------------------------------------------------------
  // Lifecycle
  // ---------------------------------------------------------------------------

  /**
   * Open (or create) the IndexedDB database. Safe to call multiple times.
   * @returns {Promise<boolean>} true if DB opened successfully
   */
  async init() {
    if (this._db) return true;

    if (typeof indexedDB === 'undefined') {
      console.warn('ImageCache: IndexedDB not available');
      return false;
    }

    try {
      this._db = await new Promise((resolve, reject) => {
        const req = indexedDB.open('StakTrakrImages', 3);

        req.onupgradeneeded = (e) => {
          const db = e.target.result;
          if (!db.objectStoreNames.contains('coinImages')) {
            db.createObjectStore('coinImages', { keyPath: 'catalogId' });
          }
          if (!db.objectStoreNames.contains('coinMetadata')) {
            db.createObjectStore('coinMetadata', { keyPath: 'catalogId' });
          }
          // v2: User-uploaded images keyed by item UUID (STACK-32)
          if (!db.objectStoreNames.contains('userImages')) {
            db.createObjectStore('userImages', { keyPath: 'uuid' });
          }
          // v3: Pattern images keyed by rule ID (user pattern image rules)
          if (e.oldVersion < 3) {
            if (!db.objectStoreNames.contains('patternImages')) {
              db.createObjectStore('patternImages', { keyPath: 'ruleId' });
            }
          }
        };

        req.onsuccess = (e) => resolve(e.target.result);
        req.onerror = (e) => reject(e.target.error);
      });

      // Detect browser-initiated connection closure
      this._db.onclose = () => {
        console.warn('ImageCache: DB connection closed by browser');
        this._db = null;
        this._available = false;
      };

      this._available = true;
      debugLog('ImageCache: initialized');
      return true;
    } catch (err) {
      console.warn('ImageCache: failed to open DB', err);
      this._available = false;
      return false;
    }
  }

  /**
   * Whether IndexedDB opened successfully.
   * @returns {boolean}
   */
  isAvailable() {
    return this._available;
  }

  /**
   * Ensure the DB connection is alive, reconnecting if stale.
   * Call this before every public operation to guard against
   * browser-initiated connection closures (storage pressure,
   * tab backgrounding, etc.).
   * @returns {Promise<boolean>}
   */
  async _ensureDb() {
    if (this._db) {
      try {
        // Lightweight probe — creating a transaction will throw if connection is dead
        this._db.transaction('coinImages', 'readonly');
        return true;
      } catch {
        console.warn('ImageCache: DB connection stale, reconnecting...');
        this._db = null;
        this._available = false;
      }
    }
    return this.init();
  }

  // ---------------------------------------------------------------------------
  // Image storage
  // ---------------------------------------------------------------------------

  /**
   * Fetch, resize, compress, and store obverse/reverse images for a coin type.
   * @param {string} catalogId - Numista N# identifier
   * @param {string} obverseUrl - CDN URL for obverse image
   * @param {string} reverseUrl - CDN URL for reverse image
   * @returns {Promise<boolean>} true if at least one image was cached
   */
  async cacheImages(catalogId, obverseUrl, reverseUrl) {
    if (!catalogId || !(await this._ensureDb())) return false;

    // Skip if already cached
    if (await this.hasImages(catalogId)) return true;

    // Check quota before proceeding
    const usage = await this.getStorageUsage();
    if (usage.totalBytes >= this._quotaBytes) {
      console.warn('ImageCache: quota exceeded, skipping cache');
      return false;
    }

    const obverseBlob = obverseUrl ? await this._fetchAndResize(obverseUrl) : null;
    const reverseBlob = reverseUrl ? await this._fetchAndResize(reverseUrl) : null;

    if (!obverseBlob && !reverseBlob) return false;

    const size = (obverseBlob?.size || 0) + (reverseBlob?.size || 0);

    const record = {
      catalogId,
      obverse: obverseBlob,
      reverse: reverseBlob,
      obverseUrl: obverseUrl || '',
      reverseUrl: reverseUrl || '',
      cachedAt: Date.now(),
      size
    };

    return this._put('coinImages', record);
  }

  /**
   * Store enriched Numista metadata for a coin type.
   * @param {string} catalogId - Numista N# identifier
   * @param {Object} numistaResult - Normalized Numista result object
   * @returns {Promise<boolean>}
   */
  async cacheMetadata(catalogId, numistaResult) {
    if (!catalogId || !numistaResult || !(await this._ensureDb())) return false;

    const record = {
      catalogId,
      title: numistaResult.name || '',
      country: numistaResult.country || '',
      denomination: numistaResult.denomination || '',
      diameter: numistaResult.diameter || numistaResult.size || 0,
      thickness: numistaResult.thickness || 0,
      weight: numistaResult.weight || 0,
      shape: numistaResult.shape || '',
      composition: numistaResult.composition || numistaResult.metal || '',
      orientation: numistaResult.orientation || '',
      commemorative: !!numistaResult.commemorative,
      commemorativeDesc: numistaResult.commemorativeDesc || '',
      rarityIndex: numistaResult.rarityIndex || 0,
      kmReferences: numistaResult.kmReferences || [],
      mintageByYear: numistaResult.mintageByYear || [],
      technique: numistaResult.technique || '',
      tags: numistaResult.tags || [],
      obverseDesc: numistaResult.obverseDesc || '',
      reverseDesc: numistaResult.reverseDesc || '',
      edgeDesc: numistaResult.edgeDesc || '',
      cachedAt: Date.now()
    };

    return this._put('coinMetadata', record);
  }

  /**
   * Retrieve the full image record for a coin type.
   * @param {string} catalogId
   * @returns {Promise<Object|null>}
   */
  async getImages(catalogId) {
    if (!catalogId || !(await this._ensureDb())) return null;
    return this._get('coinImages', catalogId);
  }

  /**
   * Retrieve the metadata record for a coin type.
   * @param {string} catalogId
   * @returns {Promise<Object|null>}
   */
  async getMetadata(catalogId) {
    if (!catalogId || !(await this._ensureDb())) return null;
    return this._get('coinMetadata', catalogId);
  }

  /**
   * Create an object URL from a cached image blob.
   * Caller is responsible for revoking via URL.revokeObjectURL().
   * @param {string} catalogId
   * @param {'obverse'|'reverse'} side
   * @returns {Promise<string|null>} Object URL or null
   */
  async getImageUrl(catalogId, side) {
    const record = await this.getImages(catalogId);
    const blob = record?.[side];
    // Reject empty blobs (e.g. opaque responses that lost data in IDB round-trip)
    if (!blob || (blob.size === 0 && !blob.type)) return null;
    return URL.createObjectURL(blob);
  }

  /**
   * Quick existence check without loading blobs.
   * @param {string} catalogId
   * @returns {Promise<boolean>}
   */
  async hasImages(catalogId) {
    if (!catalogId || !(await this._ensureDb())) return false;

    return new Promise((resolve) => {
      try {
        const tx = this._db.transaction('coinImages', 'readonly');
        const req = tx.objectStore('coinImages').count(IDBKeyRange.only(catalogId));
        req.onsuccess = () => resolve(req.result > 0);
        req.onerror = () => resolve(false);
      } catch {
        resolve(false);
      }
    });
  }

  /**
   * List all cached catalog IDs without loading blob data.
   * Uses key-only cursor for minimal memory footprint.
   * @returns {Promise<string[]>}
   */
  async listAllCachedIds() {
    if (!(await this._ensureDb())) return [];
    return new Promise((resolve) => {
      try {
        const tx = this._db.transaction('coinImages', 'readonly');
        const req = tx.objectStore('coinImages').getAllKeys();
        req.onsuccess = () => resolve(req.result || []);
        req.onerror = () => resolve([]);
      } catch { resolve([]); }
    });
  }

  /**
   * Remove a single image record.
   * @param {string} catalogId
   * @returns {Promise<boolean>}
   */
  async deleteImages(catalogId) {
    if (!catalogId || !(await this._ensureDb())) return false;
    return this._delete('coinImages', catalogId);
  }

  /**
   * Delete cached metadata for a catalog ID.
   * @param {string} catalogId
   * @returns {Promise<boolean>}
   */
  async deleteMetadata(catalogId) {
    if (!catalogId || !(await this._ensureDb())) return false;
    return this._delete('coinMetadata', catalogId);
  }

  // ---------------------------------------------------------------------------
  // Export / Import (for ZIP backup — STACK-88)
  // ---------------------------------------------------------------------------

  /**
   * Export all image records (with blobs) for backup.
   * @returns {Promise<Array>}
   */
  async exportAllImages() {
    if (!(await this._ensureDb())) return [];
    return this._getAll('coinImages');
  }

  /**
   * Export all metadata records for backup.
   * @returns {Promise<Array>}
   */
  async exportAllMetadata() {
    if (!(await this._ensureDb())) return [];
    return this._getAll('coinMetadata');
  }

  /**
   * Import a single image record (from ZIP restore).
   * @param {Object} record - Image record with catalogId, obverse, reverse blobs
   * @returns {Promise<boolean>}
   */
  async importImageRecord(record) {
    if (!record?.catalogId || !(await this._ensureDb())) return false;
    return this._put('coinImages', record);
  }

  /**
   * Import a single metadata record (from ZIP restore).
   * @param {Object} record - Metadata record with catalogId key
   * @returns {Promise<boolean>}
   */
  async importMetadataRecord(record) {
    if (!record?.catalogId || !(await this._ensureDb())) return false;
    return this._put('coinMetadata', record);
  }

  /**
   * Clear both object stores.
   * @returns {Promise<boolean>}
   */
  async clearAll() {
    if (!(await this._ensureDb())) return false;

    try {
      const stores = ['coinImages', 'coinMetadata'];
      if (this._db.objectStoreNames.contains('userImages')) stores.push('userImages');
      if (this._db.objectStoreNames.contains('patternImages')) stores.push('patternImages');
      const tx = this._db.transaction(stores, 'readwrite');
      for (const s of stores) tx.objectStore(s).clear();
      await this._txComplete(tx);
      debugLog('ImageCache: cleared all stores');
      return true;
    } catch (err) {
      console.warn('ImageCache: clearAll failed', err);
      return false;
    }
  }

  /**
   * Calculate current storage usage across all stores.
   * @returns {Promise<{count: number, totalBytes: number, limitBytes: number, metadataCount: number, userImageCount: number, patternImageCount: number, numistaCount: number}>}
   */
  async getStorageUsage() {
    const result = { count: 0, totalBytes: 0, metadataCount: 0, userImageCount: 0, patternImageCount: 0, numistaCount: 0, limitBytes: this._quotaBytes };
    if (!(await this._ensureDb())) return result;

    try {
      const records = await this._getAll('coinImages');
      result.count = records.length;
      result.numistaCount = records.length;
      for (const rec of records) {
        result.totalBytes += rec.size || 0;
      }
    } catch {
      // ignore
    }

    try {
      const metaRecords = await this._getAll('coinMetadata');
      result.metadataCount = metaRecords.length;
      for (const rec of metaRecords) {
        result.totalBytes += new Blob([JSON.stringify(rec)]).size;
      }
    } catch {
      // ignore
    }

    try {
      if (this._db.objectStoreNames.contains('userImages')) {
        const userRecords = await this._getAll('userImages');
        result.userImageCount = userRecords.length;
        for (const rec of userRecords) {
          result.totalBytes += rec.size || 0;
        }
      }
    } catch {
      // ignore
    }

    try {
      if (this._db.objectStoreNames.contains('patternImages')) {
        const patternRecords = await this._getAll('patternImages');
        result.patternImageCount = patternRecords.length;
        for (const rec of patternRecords) {
          result.totalBytes += rec.size || 0;
        }
      }
    } catch {
      // ignore
    }

    return result;
  }

  // ---------------------------------------------------------------------------
  // Image resolution cascade (STACK-32)
  // ---------------------------------------------------------------------------

  /**
   * Resolve the best available image for an inventory item.
   * Default:  user upload → pattern image → numista cache → null.
   * Override: numista cache → user upload → pattern image → null.
   *
   * @param {Object} item - Inventory item with uuid, numistaId, name, metal, type fields
   * @returns {Promise<{catalogId: string, source: 'user'|'pattern'|'numista'}|null>}
   */
  async resolveImageForItem(item) {
    if (!item || !(await this._ensureDb())) return null;

    // Check Numista override toggle
    // When ON:  numista cache → user upload → pattern image
    // When OFF: user upload → pattern image → numista cache
    const numistaOverride = localStorage.getItem('numistaOverridePersonal') === 'true';

    // Helper: check user-uploaded image (by UUID in userImages store)
    const _checkUserImage = async () => {
      if (!item.uuid || !this._db.objectStoreNames.contains('userImages')) return null;
      const userRec = await this._get('userImages', item.uuid);
      if (userRec?.obverse?.size > 0) {
        return { catalogId: item.uuid, source: 'user' };
      }
      return null;
    };

    // Helper: check pattern images store for a matching rule
    const _checkPatternImage = async () => {
      if (typeof NumistaLookup === 'undefined') return null;
      const match = NumistaLookup.matchQuery(item.name || '');
      if (!match?.rule?.seedImageId) return null;
      const ruleImageId = match.rule.seedImageId;
      if (this._db.objectStoreNames.contains('patternImages')) {
        const rec = await this._get('patternImages', ruleImageId);
        if (rec?.obverse?.size > 0) {
          return { catalogId: ruleImageId, source: 'pattern' };
        }
      }
      return null;
    };

    // Helper: check Numista API cache
    const _checkNumistaCache = async () => {
      const catalogId = item.numistaId || '';
      if (catalogId && await this.hasImages(catalogId)) {
        return { catalogId, source: 'numista' };
      }
      return null;
    };

    if (numistaOverride) {
      // Override mode: Numista wins over everything
      const numista = await _checkNumistaCache();
      if (numista) return numista;
      const user = await _checkUserImage();
      if (user) return user;
      const pattern = await _checkPatternImage();
      if (pattern) return pattern;
    } else {
      // Default: user uploads → pattern rules → Numista cache
      const user = await _checkUserImage();
      if (user) return user;
      const pattern = await _checkPatternImage();
      if (pattern) return pattern;
      const numista = await _checkNumistaCache();
      if (numista) return numista;
    }

    return null;
  }

  /**
   * Resolves and returns an object URL for the best available image side.
   * Uses resolveImageForItem() to select source, then loads the requested side.
   *
   * @param {Object} item - Inventory item with uuid/numistaId/name metadata
   * @param {'obverse'|'reverse'} [side='obverse']
   * @returns {Promise<string|null>} Object URL (caller must revoke) or null
   */
  async resolveImageUrlForItem(item, side = 'obverse') {
    const normalizedSide = side === 'reverse' ? 'reverse' : 'obverse';
    const resolved = await this.resolveImageForItem(item);
    if (!resolved) return null;

    if (resolved.source === 'user') {
      return this.getUserImageUrl(resolved.catalogId, normalizedSide);
    }
    if (resolved.source === 'pattern') {
      return this.getPatternImageUrl(resolved.catalogId, normalizedSide);
    }
    return this.getImageUrl(resolved.catalogId, normalizedSide);
  }

  /**
   * Get a user-uploaded image as an object URL.
   * Caller must revoke the URL when done.
   * @param {string} uuid - Item UUID
   * @param {'obverse'|'reverse'} [side='obverse']
   * @returns {Promise<string|null>}
   */
  async getUserImageUrl(uuid, side = 'obverse') {
    if (!uuid || !(await this._ensureDb())) return null;
    if (!this._db.objectStoreNames.contains('userImages')) return null;
    const rec = await this._get('userImages', uuid);
    const blob = rec?.[side];
    if (!blob || blob.size === 0) return null;
    return URL.createObjectURL(blob);
  }

  // ---------------------------------------------------------------------------
  // User image CRUD (STACK-32)
  // ---------------------------------------------------------------------------

  /**
   * Store a user-uploaded image for an inventory item.
   * @param {string} uuid - Item UUID
   * @param {Blob} obverse - Processed obverse image blob
   * @param {Blob} [reverse] - Optional reverse image blob
   * @returns {Promise<boolean>}
   */
  async cacheUserImage(uuid, obverse, reverse = null) {
    if (!uuid || !obverse) {
      debugLog('ImageCache.cacheUserImage: missing uuid or obverse blob');
      return false;
    }
    if (!(await this._ensureDb())) {
      debugLog('ImageCache.cacheUserImage: DB not available, attempting re-init');
      // Defensive retry: re-open DB in case v2 upgrade didn't complete
      this._db = null;
      this._available = false;
      if (!(await this.init())) {
        debugLog('ImageCache.cacheUserImage: re-init failed');
        return false;
      }
    }
    if (!this._db.objectStoreNames.contains('userImages')) {
      debugLog('ImageCache.cacheUserImage: userImages store missing — DB may need upgrade');
      return false;
    }

    const size = (obverse?.size || 0) + (reverse?.size || 0);
    const record = {
      uuid,
      obverse,
      reverse: reverse || null,
      cachedAt: Date.now(),
      size,
    };

    const result = await this._put('userImages', record);
    debugLog(`ImageCache.cacheUserImage: uuid=${uuid} size=${size} saved=${result}`);
    return result;
  }

  /**
   * Retrieve a user-uploaded image record.
   * @param {string} uuid - Item UUID
   * @returns {Promise<Object|null>}
   */
  async getUserImage(uuid) {
    if (!uuid || !(await this._ensureDb())) return null;
    if (!this._db.objectStoreNames.contains('userImages')) return null;
    return this._get('userImages', uuid);
  }

  /**
   * Delete a user-uploaded image.
   * @param {string} uuid - Item UUID
   * @returns {Promise<boolean>}
   */
  async deleteUserImage(uuid) {
    if (!uuid || !(await this._ensureDb())) return false;
    if (!this._db.objectStoreNames.contains('userImages')) return false;
    return this._delete('userImages', uuid);
  }

  /**
   * Export all user images for backup.
   * @returns {Promise<Array>}
   */
  async exportAllUserImages() {
    if (!(await this._ensureDb())) return [];
    if (!this._db.objectStoreNames.contains('userImages')) return [];
    return this._getAll('userImages');
  }

  /**
   * Import a single user image record (from ZIP restore).
   * @param {Object} record - User image record with uuid key
   * @returns {Promise<boolean>}
   */
  async importUserImageRecord(record) {
    if (!record?.uuid || !(await this._ensureDb())) return false;
    if (!this._db.objectStoreNames.contains('userImages')) return false;
    return this._put('userImages', record);
  }

  // ---------------------------------------------------------------------------
  // Pattern image CRUD (user pattern image rules)
  // ---------------------------------------------------------------------------

  /**
   * Store pattern rule images (obverse/reverse blobs).
   * @param {string} ruleId - Pattern rule ID
   * @param {Blob|null} obverseBlob
   * @param {Blob|null} reverseBlob
   * @returns {Promise<boolean>}
   */
  async cachePatternImage(ruleId, obverseBlob, reverseBlob) {
    if (!ruleId || !(await this._ensureDb())) return false;
    if (!this._db.objectStoreNames.contains('patternImages')) return false;

    const size = (obverseBlob?.size || 0) + (reverseBlob?.size || 0);
    const record = {
      ruleId,
      obverse: obverseBlob || null,
      reverse: reverseBlob || null,
      cachedAt: Date.now(),
      size,
    };
    return this._put('patternImages', record);
  }

  /**
   * Retrieve a pattern image record by rule ID.
   * @param {string} ruleId
   * @returns {Promise<Object|null>}
   */
  async getPatternImage(ruleId) {
    if (!ruleId || !(await this._ensureDb())) return null;
    if (!this._db.objectStoreNames.contains('patternImages')) return null;
    return this._get('patternImages', ruleId);
  }

  /**
   * Get a pattern image as an object URL.
   * Caller must revoke the URL when done.
   * @param {string} ruleId
   * @param {'obverse'|'reverse'} [side='obverse']
   * @returns {Promise<string|null>}
   */
  async getPatternImageUrl(ruleId, side = 'obverse') {
    const rec = await this.getPatternImage(ruleId);
    const blob = rec?.[side];
    if (!blob || blob.size === 0) return null;
    return URL.createObjectURL(blob);
  }

  /**
   * Delete a pattern image record.
   * @param {string} ruleId
   * @returns {Promise<boolean>}
   */
  async deletePatternImage(ruleId) {
    if (!ruleId || !(await this._ensureDb())) return false;
    if (!this._db.objectStoreNames.contains('patternImages')) return false;
    return this._delete('patternImages', ruleId);
  }

  /**
   * Export all pattern image records for backup.
   * @returns {Promise<Array>}
   */
  async exportAllPatternImages() {
    if (!(await this._ensureDb())) return [];
    if (!this._db.objectStoreNames.contains('patternImages')) return [];
    return this._getAll('patternImages');
  }

  /**
   * Export all coin (CDN) image records for backup.
   * @returns {Promise<Array>}
   */
  async exportAllCoinImages() {
    if (!(await this._ensureDb())) return [];
    if (!this._db.objectStoreNames.contains('coinImages')) return [];
    return this._getAll('coinImages');
  }

  /**
   * Import a single pattern image record (from ZIP restore).
   * @param {Object} record - Pattern image record with ruleId key
   * @returns {Promise<boolean>}
   */
  async importPatternImageRecord(record) {
    if (!record?.ruleId || !(await this._ensureDb())) return false;
    if (!this._db.objectStoreNames.contains('patternImages')) return false;
    return this._put('patternImages', record);
  }

  // ---------------------------------------------------------------------------
  // Validation
  // ---------------------------------------------------------------------------

  /**
   * Check whether a string looks like a usable image URL.
   * Rejects corrupted URLs that have had special characters stripped.
   * @param {string} url
   * @returns {boolean}
   */
  static isValidImageUrl(url) {
    if (!url || typeof url !== 'string') return false;
    return /^https?:\/\/.+\..+/i.test(url);
  }

  // ---------------------------------------------------------------------------
  // Image pipeline (private)
  // ---------------------------------------------------------------------------

  /**
   * Fetch an image URL using a multi-strategy cascade that gracefully
   * degrades when CORS headers are unavailable (e.g. Numista CDN).
   *
   * Strategy A: fetch(CORS) → createImageBitmap → canvas resize → JPEG blob
   * Strategy B: fetch(CORS) succeeded but canvas tainted → store raw blob
   * Strategy C: Image element (no crossOrigin) → canvas → JPEG blob
   * Strategy D: fetch(no-cors) → non-opaque blob if available (opaque blobs rejected — lose data in IDB)
   *
   * @param {string} url
   * @returns {Promise<Blob|null>}
   */
  async _fetchAndResize(url) {
    if (!url || !ImageCache.isValidImageUrl(url)) return null;

    // --- Strategy A: CORS fetch → ImageProcessor pipeline ---
    try {
      const resp = await fetch(url, { mode: 'cors' });
      if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
      const blob = await resp.blob();

      // Prefer ImageProcessor (WebP + byte budget) when available
      if (typeof imageProcessor !== 'undefined') {
        const result = await imageProcessor.processFile(blob, {
          maxDim: this._maxDim,
          quality: this._quality,
        });
        if (result?.blob) return result.blob;
      }

      // Fallback: bitmap → canvas resize
      const imgBitmap = await createImageBitmap(blob);
      const resized = await this._resizeAndCompress(imgBitmap);
      if (resized) return resized;

      // Strategy B: fetch succeeded but canvas failed — use raw blob
      console.warn('ImageCache: Strategy A canvas failed, using raw blob for', url);
      return blob;
    } catch (errA) {
      console.warn('ImageCache: Strategy A (CORS fetch) failed for', url, errA.message);
    }

    // --- Strategy C: Image element without crossOrigin → canvas ---
    try {
      const img = await this._loadImageElement(url, false);
      const resized = await this._resizeAndCompress(img);
      if (resized) return resized;
      console.warn('ImageCache: Strategy C canvas tainted for', url);
    } catch (errC) {
      console.warn('ImageCache: Strategy C (Image element) failed for', url, errC.message);
    }

    // --- Strategy D: no-cors fetch → opaque blob (displayable via object URL) ---
    try {
      const blob = await this._fetchBlobDirect(url);
      if (blob) {
        console.warn('ImageCache: Strategy D (no-cors blob) succeeded for', url);
        return blob;
      }
    } catch (errD) {
      console.warn('ImageCache: Strategy D (no-cors) failed for', url, errD.message);
    }

    console.warn('ImageCache: all strategies failed for', url);
    return null;
  }

  /**
   * Resize and compress an image source using ImageProcessor (STACK-95).
   * Falls back to inline Canvas JPEG if ImageProcessor is unavailable.
   * @param {ImageBitmap|HTMLImageElement} source
   * @returns {Promise<Blob|null>}
   */
  async _resizeAndCompress(source) {
    // Delegate to ImageProcessor when available
    if (typeof imageProcessor !== 'undefined') {
      try {
        // Convert source to blob first so ImageProcessor can handle it
        const canvas = document.createElement('canvas');
        canvas.width = source.width;
        canvas.height = source.height;
        canvas.getContext('2d').drawImage(source, 0, 0);
        const srcBlob = await new Promise((resolve) => {
          canvas.toBlob((b) => resolve(b), 'image/png');
        });
        if (!srcBlob) return null;
        const result = await imageProcessor.processFile(srcBlob, {
          maxDim: this._maxDim,
          quality: this._quality,
        });
        return result?.blob || null;
      } catch {
        // Fall through to legacy path
      }
    }

    // Legacy fallback: inline Canvas JPEG resize
    let width = source.width;
    let height = source.height;
    const scale = Math.min(this._maxDim / width, this._maxDim / height, 1);
    width = Math.round(width * scale);
    height = Math.round(height * scale);

    const canvas = document.createElement('canvas');
    canvas.width = width;
    canvas.height = height;
    canvas.getContext('2d').drawImage(source, 0, 0, width, height);

    try {
      return await new Promise((resolve, reject) => {
        canvas.toBlob(
          (blob) => blob ? resolve(blob) : reject(new Error('toBlob returned null')),
          'image/jpeg',
          this._quality
        );
      });
    } catch {
      return null;
    }
  }

  /**
   * Try to get a raw blob for a URL without canvas processing.
   * Falls back to no-cors fetch (opaque blob — still displayable via object URL).
   * @param {string} url
   * @returns {Promise<Blob|null>}
   */
  async _fetchBlobDirect(url) {
    // Try standard fetch first
    try {
      const resp = await fetch(url);
      if (resp.ok) return await resp.blob();
    } catch { /* fall through */ }

    // Try no-cors fetch — only accept non-opaque blobs (opaque blobs report
    // size === 0 and lose their data during IDB structured clone round-trips)
    try {
      const resp = await fetch(url, { mode: 'no-cors' });
      const blob = await resp.blob();
      if (blob && blob.size > 0) return blob;
    } catch { /* fall through */ }

    return null;
  }

  /**
   * Load an image via HTMLImageElement.
   * @param {string} url
   * @param {boolean} [useCors=false] - Whether to set crossOrigin='anonymous'
   * @returns {Promise<ImageBitmap>}
   */
  _loadImageElement(url, useCors = false) {
    return new Promise((resolve, reject) => {
      const img = new Image();
      if (useCors) img.crossOrigin = 'anonymous';
      img.onload = () => {
        createImageBitmap(img).then(resolve).catch(reject);
      };
      img.onerror = () => reject(new Error('Image load failed'));
      img.src = url;
    });
  }

  // ---------------------------------------------------------------------------
  // IndexedDB helpers (private)
  // ---------------------------------------------------------------------------

  /** @returns {Promise<boolean>} */
  async _put(storeName, record) {
    try {
      const tx = this._db.transaction(storeName, 'readwrite');
      tx.objectStore(storeName).put(record);
      await this._txComplete(tx);
      return true;
    } catch (err) {
      console.warn(`ImageCache: put to ${storeName} failed`, err);
      return false;
    }
  }

  /** @returns {Promise<Object|null>} */
  _get(storeName, key) {
    return new Promise((resolve) => {
      try {
        const tx = this._db.transaction(storeName, 'readonly');
        const req = tx.objectStore(storeName).get(key);
        req.onsuccess = () => resolve(req.result || null);
        req.onerror = () => resolve(null);
      } catch {
        resolve(null);
      }
    });
  }

  /** @returns {Promise<Array>} */
  _getAll(storeName) {
    return new Promise((resolve) => {
      try {
        const tx = this._db.transaction(storeName, 'readonly');
        const req = tx.objectStore(storeName).getAll();
        req.onsuccess = () => resolve(req.result || []);
        req.onerror = () => resolve([]);
      } catch {
        resolve([]);
      }
    });
  }

  /** @returns {Promise<boolean>} */
  async _delete(storeName, key) {
    try {
      const tx = this._db.transaction(storeName, 'readwrite');
      tx.objectStore(storeName).delete(key);
      await this._txComplete(tx);
      return true;
    } catch {
      return false;
    }
  }

  /** Wait for a transaction to complete. */
  _txComplete(tx) {
    return new Promise((resolve, reject) => {
      tx.oncomplete = () => resolve();
      tx.onerror = () => reject(tx.error);
      tx.onabort = () => reject(tx.error);
    });
  }
}

// Singleton instance exposed globally
const imageCache = new ImageCache();
if (typeof window !== 'undefined') {
  window.imageCache = imageCache;
}