Source: inventory.js

// INVENTORY FUNCTIONS

/** Blob URLs created by _enhanceTableThumbnails — revoked on each re-render */
let _thumbBlobUrls = [];
window.addEventListener('beforeunload', () => {
  for (const url of _thumbBlobUrls) {
    try { URL.revokeObjectURL(url); } catch { /* ignore */ }
  }
});
/**
 * Creates a comprehensive backup ZIP file containing all application data
 * 
 * This function generates a complete backup archive including:
 * - Current inventory data in JSON format
 * - All export formats (CSV, HTML)
 * - Application settings and configuration
 * - Spot price history
 * - README file explaining backup contents
 * 
 * The backup is packaged as a ZIP file for easy storage and portability.
 * All data is exported in multiple formats to ensure compatibility and
 * provide redundancy for data recovery scenarios.
 * 
 * @returns {void} Downloads a ZIP file containing complete backup
 * 
 * @example
 * // Called to generate a complete backup archive
 * await createBackupZip();
 */
const createBackupZip = async () => {
  try {
    // Show loading indicator
    const backupBtn = document.getElementById('backupAllBtn');
    const originalText = backupBtn ? backupBtn.textContent : '';
    if (backupBtn) {
      backupBtn.textContent = 'Creating Backup...';
      backupBtn.disabled = true;
    }

    // Create new JSZip instance
    const zip = new JSZip();
    const timestamp = new Date().toISOString().slice(0, 10).replace(/-/g, '');
    const timeFormatted = typeof formatTimestamp === 'function' ? formatTimestamp(new Date()) : new Date().toLocaleString();

    // 1. Add main inventory data (JSON)
    const inventoryData = {
      version: APP_VERSION,
      exportDate: new Date().toISOString(),
      inventory: inventory.map(item => ({
        metal: item.metal,
        composition: item.composition,
        name: item.name,
        qty: item.qty,
        type: item.type,
        weight: item.weight,
        weightUnit: item.weightUnit || 'oz',
        purity: item.purity || 1.0,
        price: item.price,
        date: item.date,
        purchaseLocation: item.purchaseLocation,
        storageLocation: item.storageLocation,
        notes: item.notes,
        spotPriceAtPurchase: item.spotPriceAtPurchase,
        premiumPerOz: item.premiumPerOz,
        totalPremium: item.totalPremium,
        marketValue: item.marketValue || 0,
        numistaId: item.numistaId,
        year: item.year || '',
        grade: item.grade || '',
        gradingAuthority: item.gradingAuthority || '',
        certNumber: item.certNumber || '',
        serialNumber: item.serialNumber || '',
        pcgsNumber: item.pcgsNumber || '',
        pcgsVerified: item.pcgsVerified || false,
        serial: item.serial,
        uuid: item.uuid,
        obverseImageUrl: item.obverseImageUrl || '',
        reverseImageUrl: item.reverseImageUrl || ''
      }))
    };
    zip.file('inventory_data.json', JSON.stringify(inventoryData, null, 2));

    // 2. Add current spot prices, settings, and catalog mappings
    const settings = {
      version: APP_VERSION,
      exportDate: new Date().toISOString(),
      spotPrices: spotPrices,
      theme: localStorage.getItem(THEME_KEY) || 'light',
      itemsPerPage: itemsPerPage,
      searchQuery: searchQuery,
      sortColumn: sortColumn,
      sortDirection: sortDirection,
      // Add catalog mappings to settings for backup
      catalogMappings: catalogManager.exportMappings(),
      // Chip grouping settings (v3.16.00+)
      chipCustomGroups: loadDataSync('chipCustomGroups', []),
      chipBlacklist: loadDataSync('chipBlacklist', []),
      chipMinCount: localStorage.getItem('chipMinCount'),
      featureFlags: localStorage.getItem(FEATURE_FLAGS_KEY),
      // Inline chip config (v3.17.00+)
      inlineChipConfig: localStorage.getItem('inlineChipConfig'),
      // Goldback denomination pricing (STACK-45)
      goldbackPrices: goldbackPrices,
      goldbackPriceHistory: goldbackPriceHistory,
      goldbackEnabled: goldbackEnabled,
      goldbackEstimateEnabled: goldbackEstimateEnabled,
      goldbackEstimateModifier: goldbackEstimateModifier,
      tableImageSides: localStorage.getItem('tableImageSides') || 'both',
      tableImagesEnabled: localStorage.getItem('tableImagesEnabled') !== 'false'
    };
    zip.file('settings.json', JSON.stringify(settings, null, 2));

    // 3. Add spot price history
    const spotHistoryData = {
      version: APP_VERSION,
      exportDate: new Date().toISOString(),
      history: spotHistory
    };
    zip.file('spot_price_history.json', JSON.stringify(spotHistoryData, null, 2));

    // 3b. Add per-item price history (STACK-43)
    const itemPriceHistoryData = {
      version: APP_VERSION,
      exportDate: new Date().toISOString(),
      history: itemPriceHistory
    };
    zip.file('item_price_history.json', JSON.stringify(itemPriceHistoryData, null, 2));

    // 3c. Add item tags (STAK-126)
    if (typeof itemTags !== 'undefined' && Object.keys(itemTags).length > 0) {
      const itemTagsData = {
        version: APP_VERSION,
        exportDate: new Date().toISOString(),
        tags: itemTags
      };
      zip.file('item_tags.json', JSON.stringify(itemTagsData, null, 2));
    }

    // 4. Generate and add CSV export (portfolio format)
    const csvHeaders = [
      "Date", "Metal", "Type", "Name", "Qty", "Weight(oz)", "Weight Unit", "Purity",
      "Purchase Price", "Melt Value", "Retail Price", "Gain/Loss",
      "Purchase Location", "N#", "PCGS #", "Serial Number", "Tags", "Notes"
    ];
    const sortedInventory = sortInventoryByDateNewestFirst();
    const csvRows = [];
    for (const item of sortedInventory) {
      const currentSpot = spotPrices[item.metal.toLowerCase()] || 0;
      const valuation = (typeof computeItemValuation === 'function')
        ? computeItemValuation(item, currentSpot)
        : null;
      const purchasePrice = valuation ? valuation.purchasePrice : (typeof item.price === 'number' ? item.price : parseFloat(item.price) || 0);
      const meltValue = valuation ? valuation.meltValue : computeMeltValue(item, currentSpot);
      const gainLoss = valuation ? valuation.gainLoss : null;

      csvRows.push([
        item.date,
        item.metal || 'Silver',
        item.type,
        item.name,
        item.qty,
        parseFloat(item.weight).toFixed(4),
        item.weightUnit || 'oz',
        parseFloat(item.purity) || 1.0,
        formatCurrency(purchasePrice),
        currentSpot > 0 ? formatCurrency(meltValue) : '—',
        formatCurrency(item.marketValue || 0),
        gainLoss !== null ? formatCurrency(gainLoss) : '—',
        item.purchaseLocation,
        item.numistaId || '',
        item.pcgsNumber || '',
        item.serialNumber || '',
        typeof getItemTags === 'function' ? getItemTags(item.uuid).join('; ') : '',
        item.notes || ''
      ]);
    }
    const csvContent = Papa.unparse([csvHeaders, ...csvRows]);
    zip.file('inventory_export.csv', csvContent);

    // 5. Generate and add HTML export (simplified version)
    const htmlContent = generateBackupHtml(sortedInventory, timeFormatted);
    zip.file('inventory_report.html', htmlContent);

    // 7. Add README file
    const readmeContent = generateReadmeContent(timeFormatted);
    zip.file('README.txt', readmeContent);

    // 8. Add sample data for reference
    if (inventory.length > 0) {
      const sampleData = inventory.slice(0, Math.min(5, inventory.length)).map(item => ({
        metal: item.metal,
        name: item.name,
        qty: item.qty,
        type: item.type,
        weight: item.weight,
        weightUnit: item.weightUnit || 'oz',
        purity: item.purity || 1.0,
        price: item.price,
        date: item.date,
        purchaseLocation: item.purchaseLocation,
        storageLocation: item.storageLocation,
        notes: item.notes,
        numistaId: item.numistaId,
        serialNumber: item.serialNumber || '',
        marketValue: item.marketValue || 0,
        serial: item.serial
      }));
      zip.file('sample_data.json', JSON.stringify(sampleData, null, 2));
    }

    // 9. Add cached coin images (STACK-88)
    if (window.imageCache?.isAvailable()) {
      const allImages = await imageCache.exportAllImages();
      if (allImages.length > 0) {
        const imgFolder = zip.folder('images');
        for (const rec of allImages) {
          if (rec.obverse) imgFolder.file(`${rec.catalogId}_obverse.jpg`, rec.obverse);
          if (rec.reverse) imgFolder.file(`${rec.catalogId}_reverse.jpg`, rec.reverse);
        }
      }
      const allMeta = await imageCache.exportAllMetadata();
      if (allMeta.length > 0) {
        zip.file('image_metadata.json', JSON.stringify({
          version: APP_VERSION,
          exportDate: new Date().toISOString(),
          count: allMeta.length,
          metadata: allMeta
        }, null, 2));
      }
    }

    // Generate and download the ZIP file
    const zipBlob = await zip.generateAsync({ type: 'blob', streamFiles: true });
    const url = URL.createObjectURL(zipBlob);
    
    const a = document.createElement('a');
    a.href = url;
    a.download = `precious_metals_backup_${timestamp}.zip`;
    document.body.appendChild(a);
    a.click();
    document.body.removeChild(a);
    URL.revokeObjectURL(url);

    // Restore button state
    if (backupBtn) {
      backupBtn.textContent = originalText;
      backupBtn.disabled = false;
    }
    
    alert('Backup created successfully!');
  } catch (error) {
    console.error('Backup creation failed:', error);
    alert('Backup creation failed: ' + error.message);
    
    // Restore button state on error
    const backupBtn = document.getElementById('backupAllBtn');
    if (backupBtn) {
      backupBtn.textContent = 'Backup All Data';
      backupBtn.disabled = false;
    }
  }
};

/**
 * Restores application data from a backup ZIP file
 *
 * @param {File} file - ZIP file created by createBackupZip
 */
const restoreBackupZip = async (file) => {
  try {
    const zip = await JSZip.loadAsync(file);

    const inventoryStr = await zip.file("inventory_data.json")?.async("string");
    if (inventoryStr) {
      const invObj = JSON.parse(inventoryStr);
      localStorage.setItem(LS_KEY, JSON.stringify(invObj.inventory || []));
    }

    const settingsStr = await zip.file("settings.json")?.async("string");
    if (settingsStr) {
      const settingsObj = JSON.parse(settingsStr);
      if (settingsObj.spotPrices) {
        Object.entries(settingsObj.spotPrices).forEach(([metal, price]) => {
          const metalConfig = METALS[metal.toUpperCase()];
          if (metalConfig) {
            localStorage.setItem(
              metalConfig.localStorageKey,
              JSON.stringify(price),
            );
          }
        });
      }
      if (settingsObj.theme) {
        localStorage.setItem(THEME_KEY, settingsObj.theme);
      }
      
      // Handle catalog mappings if present in backup
      if (settingsObj.catalogMappings) {
        // Use catalog manager to import mappings
        catalogManager.importMappings(settingsObj.catalogMappings, false);
      }

      // Restore chip grouping settings (v3.16.00+)
      if (Array.isArray(settingsObj.chipCustomGroups)) {
        saveDataSync('chipCustomGroups', settingsObj.chipCustomGroups);
      }
      if (Array.isArray(settingsObj.chipBlacklist)) {
        saveDataSync('chipBlacklist', settingsObj.chipBlacklist);
      }
      if (settingsObj.chipMinCount != null) {
        localStorage.setItem('chipMinCount', settingsObj.chipMinCount);
      }
      if (settingsObj.featureFlags != null) {
        localStorage.setItem(FEATURE_FLAGS_KEY, settingsObj.featureFlags);
      }
      // Restore inline chip config (v3.17.00+)
      if (settingsObj.inlineChipConfig != null) {
        localStorage.setItem('inlineChipConfig', settingsObj.inlineChipConfig);
      }
      // Restore Goldback denomination pricing (STACK-45)
      if (settingsObj.goldbackPrices != null) {
        saveDataSync(GOLDBACK_PRICES_KEY, settingsObj.goldbackPrices);
        goldbackPrices = settingsObj.goldbackPrices;
      }
      if (settingsObj.goldbackPriceHistory != null) {
        saveDataSync(GOLDBACK_PRICE_HISTORY_KEY, settingsObj.goldbackPriceHistory);
        goldbackPriceHistory = settingsObj.goldbackPriceHistory;
      }
      if (settingsObj.goldbackEnabled != null) {
        saveDataSync(GOLDBACK_ENABLED_KEY, settingsObj.goldbackEnabled === true);
        goldbackEnabled = settingsObj.goldbackEnabled === true;
      }
      if (settingsObj.goldbackEstimateEnabled != null) {
        saveDataSync(GOLDBACK_ESTIMATE_ENABLED_KEY, settingsObj.goldbackEstimateEnabled === true);
        goldbackEstimateEnabled = settingsObj.goldbackEstimateEnabled === true;
      }
      if (settingsObj.goldbackEstimateModifier != null) {
        const mod = parseFloat(settingsObj.goldbackEstimateModifier);
        if (!isNaN(mod) && mod > 0) {
          saveDataSync(GB_ESTIMATE_MODIFIER_KEY, mod);
          goldbackEstimateModifier = mod;
        }
      }
      // Restore display settings (backed up but previously not restored)
      if (settingsObj.itemsPerPage != null) {
        const ippRestore = settingsObj.itemsPerPage;
        localStorage.setItem(ITEMS_PER_PAGE_KEY, String(ippRestore));
        itemsPerPage = ippRestore === 'all' || ippRestore === Infinity ? Infinity : Number(ippRestore);
      }
      if (settingsObj.sortColumn != null) {
        sortColumn = settingsObj.sortColumn;
      }
      if (settingsObj.sortDirection != null) {
        sortDirection = settingsObj.sortDirection;
      }
      if (settingsObj.tableImageSides != null) {
        localStorage.setItem('tableImageSides', settingsObj.tableImageSides);
      }
      if (settingsObj.tableImagesEnabled != null) {
        localStorage.setItem('tableImagesEnabled', String(settingsObj.tableImagesEnabled));
      }
    }

    const historyStr = await zip
      .file("spot_price_history.json")
      ?.async("string");
    if (historyStr) {
      const histObj = JSON.parse(historyStr);
      localStorage.setItem(
        SPOT_HISTORY_KEY,
        JSON.stringify(histObj.history || []),
      );
    }

    loadInventory();
    renderTable();
    renderActiveFilters();
    loadSpotHistory();

    // Restore per-item price history with merge (STACK-43)
    const itemHistoryStr = await zip.file("item_price_history.json")?.async("string");
    if (itemHistoryStr) {
      const itemHistObj = JSON.parse(itemHistoryStr);
      if (typeof mergeItemPriceHistory === 'function') {
        mergeItemPriceHistory(itemHistObj.history || {});
      }
    } else if (typeof loadItemPriceHistory === 'function') {
      loadItemPriceHistory();
    }

    // Restore item tags (STAK-126)
    const itemTagsStr = await zip.file("item_tags.json")?.async("string");
    let restoredTags = null;
    if (itemTagsStr) {
      try {
        const itemTagsObj = JSON.parse(itemTagsStr);
        if (itemTagsObj.tags && typeof itemTagsObj.tags === 'object' && !Array.isArray(itemTagsObj.tags)) {
          restoredTags = itemTagsObj.tags;
        }
      } catch (e) {
        debugWarn('restoreBackupZip: item_tags.json parse error', e);
      }
    }
    itemTags = restoredTags || {};
    if (typeof saveItemTags === 'function') saveItemTags();

    // Restore cached coin images (STACK-88)
    if (window.imageCache?.isAvailable()) {
      const imgFolder = zip.folder('images');
      const imgEntries = [];
      if (imgFolder) {
        imgFolder.forEach((path, file) => { imgEntries.push({ path, file }); });
      }

      if (imgEntries.length > 0) {
        await imageCache.clearAll();
        const imageMap = new Map();

        for (const { path, file } of imgEntries) {
          const m = path.match(/^(.+)_(obverse|reverse)\.jpg$/);
          if (!m) continue;
          if (!imageMap.has(m[1])) imageMap.set(m[1], {});
          imageMap.get(m[1])[m[2]] = await file.async('blob');
        }

        for (const [catalogId, sides] of imageMap) {
          await imageCache.importImageRecord({
            catalogId,
            obverse: sides.obverse || null,
            reverse: sides.reverse || null,
            width: 400,
            height: 400,
            cachedAt: Date.now(),
            size: (sides.obverse?.size || 0) + (sides.reverse?.size || 0)
          });
        }
      }

      // Restore metadata
      const metaStr = await zip.file('image_metadata.json')?.async('string');
      if (metaStr) {
        const metaObj = JSON.parse(metaStr);
        if (Array.isArray(metaObj.metadata)) {
          for (const rec of metaObj.metadata) {
            await imageCache.importMetadataRecord(rec);
          }
        }
      }
    }

    fetchSpotPrice();
    alert("Data imported successfully. The page will now reload.");
    location.reload();
  } catch (err) {
    console.error("Restore failed", err);
    alert("Restore failed: " + err.message);
  }
};

window.restoreBackupZip = restoreBackupZip;

/**
 * Generates HTML content for backup export
 * 
 * @param {Array} sortedInventory - Sorted inventory data
 * @param {string} timeFormatted - Formatted timestamp
 * @returns {string} HTML content
 */
const generateBackupHtml = (sortedInventory, timeFormatted) => {
  return `<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>StakTrakr Backup</title>
  <style>
    body { font-family: Arial, sans-serif; margin: 20px; }
    h1 { color: #2563eb; }
    table { width: 100%; border-collapse: collapse; margin-top: 20px; }
    th, td { border: 1px solid #ddd; padding: 8px; text-align: left; }
    th { background-color: #f2f2f2; }
    .backup-info { background: #f8f9fa; padding: 15px; border-radius: 8px; margin-bottom: 20px; }
  </style>
</head>
<body>
  <h1>StakTrakr Backup</h1>
  <div class="backup-info">
    <strong>Backup Created:</strong> ${timeFormatted}<br>
    <strong>Application Version:</strong> ${APP_VERSION}<br>
    <strong>Total Items:</strong> ${sortedInventory.length}<br>
    <strong>Archive Contents:</strong> Complete inventory data, settings, and spot price history
  </div>
  <table>
    <thead>
      <tr>
        <th>Composition</th><th>Name</th><th>Qty</th><th>Type</th><th>Weight</th>
        <th>Purchase Price</th><th>Purchase Location</th><th>Storage Location</th>
        <th>Notes</th><th>Date</th>
      </tr>
    </thead>
    <tbody>
      ${sortedInventory.map(item => `
        <tr>
          <td>${getCompositionFirstWords(item.composition || item.metal)}</td>
          <td>${item.name}</td>
          <td>${item.qty}</td>
          <td>${item.type}</td>
          <td>${formatWeight(item.weight, item.weightUnit)}</td>
          <td>${formatCurrency(item.price)}</td>
          <td>${item.purchaseLocation}</td>
          <td>${item.storageLocation || ''}</td>
          <td>${item.notes || ''}</td>
          <td>${item.date}</td>
        </tr>
      `).join('')}
    </tbody>
  </table>
</body>
</html>`;
};

/**
 * Generates README content for backup archive
 * 
 * @param {string} timeFormatted - Formatted timestamp
 * @returns {string} README content
 */
const generateReadmeContent = (timeFormatted) => {
  return `PRECIOUS METALS INVENTORY TOOL - BACKUP ARCHIVE
===============================================

Backup Created: ${timeFormatted}
Application Version: ${APP_VERSION}
Total Items: ${inventory.length}

FILE CONTENTS:
--------------

1. inventory_data.json
   - Complete inventory data in JSON format
   - Includes all item details, notes, and metadata
   - Primary data file for restoration

2. settings.json
   - Application configuration and preferences
   - Current spot prices and user settings
   - UI state (pagination, search, sorting)

3. spot_price_history.json
   - Historical spot price data and tracking
   - API sync records and manual overrides
   - Price trend information

4. inventory_export.csv
   - Spreadsheet-compatible export
   - Human-readable format for external use

5. inventory_report.html
   - Self-contained web page report
   - No external dependencies required
   - Print-friendly format

6. sample_data.json (if applicable)
   - Sample of inventory items for reference
   - Useful for testing import functionality
   - Demonstrates data structure

7. README.txt (this file)
   - Backup contents explanation
   - Restoration instructions

8. images/ (if coin images are cached)
   - Cached coin images as JPEG files
   - Named {catalogId}_obverse.jpg / {catalogId}_reverse.jpg
   - Automatically restored when importing backup

9. image_metadata.json (if coin images are cached)
   - Enriched Numista metadata for cached coins
   - Restored alongside images for offline viewing

RESTORATION INSTRUCTIONS:
------------------------

1. For complete restoration:
   - Import inventory_data.json using the application's JSON import feature
   - Manually configure spot prices from settings.json if needed

2. For partial restoration:
   - Use inventory_export.csv for spreadsheet applications
   - View inventory_report.html in any web browser

3. For data analysis:
   - All files contain the same core data in different formats
   - Choose the format best suited for your analysis tools

SUPPORT:
--------

For questions about this backup or the StakTrakr application:
- Check the application documentation
- Verify file integrity before restoration
- Test imports with sample data first

This backup contains your complete precious metals inventory as of ${timeFormatted}.
Store this archive in a secure location for data protection.

--- End of README ---`;
};

// =============================================================================

// Note: catalogMap is now managed by catalogManager class
// No need for the global catalogMap variable anymore

const getNextSerial = () => {
  const next = (parseInt(localStorage.getItem(SERIAL_KEY) || '0', 10) + 1);
  localStorage.setItem(SERIAL_KEY, next);
  return next;
};
window.getNextSerial = getNextSerial;

/**
 * Saves current inventory to localStorage
 */
const saveInventory = () => {
  saveData(LS_KEY, inventory);
  // CatalogManager handles its own saving, no need to explicitly save catalogMap
  // STACK-62: Invalidate autocomplete cache so lookup table rebuilds with current inventory
  if (typeof clearLookupCache === 'function') clearLookupCache();
  // STAK-149: Trigger debounced cloud auto-sync push (no-op if sync disabled or not connected)
  if (typeof scheduleSyncPush === 'function') scheduleSyncPush();
};

/**
 * Removes non-alphanumeric characters from inventory records.
 *
 * @returns {void}
 */
const sanitizeTablesOnLoad = () => {
  inventory = inventory.map(item => sanitizeObjectFields(item));

};

/**
 * Loads inventory from localStorage with comprehensive data migration
 * 
 * This function handles backwards compatibility by:
 * - Loading existing inventory data from localStorage
 * - Migrating legacy records that may be missing newer fields
 * - Calculating premiums for older records that lack this data
 * - Ensuring all records have required fields with sensible defaults
 * - Preserving existing user data while adding new functionality
 * 
 * @returns {void} Updates the global inventory array with migrated data
 * @throws {Error} Logs errors to console if localStorage access fails
 */
const loadInventory = () => {
  try {
    // For now, use synchronous loading to maintain compatibility
    // TODO: Convert to async when updating all callers
    const data = loadDataSync(LS_KEY, []);
    
    // Ensure data is an array
    if (!Array.isArray(data)) {
      console.warn('Inventory data is not an array, resetting to empty array');
      inventory = [];
      return;
    }
    
    // Migrate legacy data to include new fields
    inventory = data.map(item => {
    let normalized;
    // Handle legacy data that might not have all fields
    if (item.premiumPerOz === undefined) {
      // For legacy items, calculate premium if possible
      const metalConfig = Object.values(METALS).find(m => m.name === item.metal) || METALS.SILVER;
      const spotPrice = spotPrices[metalConfig.key];

      const premiumPerOz = spotPrice > 0 ? (item.price / item.weight) - spotPrice : 0;
      const totalPremium = premiumPerOz * item.qty * item.weight;

      normalized = {
        ...item,
        type: normalizeType(item.type),
        purchaseLocation: item.purchaseLocation || "",
        storageLocation: item.storageLocation || "Unknown",
        notes: item.notes || "",
        marketValue: item.marketValue || 0,
        year: item.year || item.issuedYear || "",
        grade: item.grade || '',
        gradingAuthority: item.gradingAuthority || '',
        certNumber: item.certNumber || '',
        pcgsNumber: item.pcgsNumber || '',
        pcgsVerified: item.pcgsVerified || false,
        spotPriceAtPurchase: spotPrice,
        premiumPerOz,
        totalPremium,
        composition: item.composition || item.metal || "",
        purity: parseFloat(item.purity) || 1.0
      };
    } else {
      // Ensure all items have required properties
      normalized = {
        ...item,
        type: normalizeType(item.type),
        purchaseLocation: item.purchaseLocation || "",
        storageLocation: item.storageLocation || "Unknown",
        notes: item.notes || "",
        marketValue: item.marketValue || 0,
        year: item.year || item.issuedYear || "",
        grade: item.grade || '',
        gradingAuthority: item.gradingAuthority || '',
        certNumber: item.certNumber || '',
        pcgsNumber: item.pcgsNumber || '',
        pcgsVerified: item.pcgsVerified || false,
        composition: item.composition || item.metal || "",
        purity: parseFloat(item.purity) || 1.0
      };
    }
    return sanitizeImportedItem(normalized);
  });

  let serialCounter = parseInt(localStorage.getItem(SERIAL_KEY) || '0', 10);
  
  // Process each inventory item: assign serials and sync with catalog manager
  inventory.forEach(item => {
    // Assign serial numbers to items that don't have them
    if (!item.serial) {
      serialCounter += 1;
      item.serial = serialCounter;
    }

    // Assign UUIDs to items that don't have them (migration for existing data)
    if (!item.uuid) {
      item.uuid = generateUUID();
    }

    // Use CatalogManager to synchronize numistaId
    catalogManager.syncItem(item);
  });
  
  // Save updated serial counter
  localStorage.setItem(SERIAL_KEY, serialCounter);
  
  // Clean up any orphaned catalog mappings
  if (typeof catalogManager.cleanupOrphans === 'function') {
    const removed = catalogManager.cleanupOrphans(inventory);
    if (removed > 0 && DEBUG) {
      console.log(`Removed ${removed} orphaned catalog mappings`);
    }
  }
  } catch (error) {
    console.error('Error loading inventory:', error);
    inventory = [];
  }
};

/**
 * Renders the main inventory table with all current display settings
 * 
 * This is the primary display function that:
 * - Applies current search filters to inventory data
 * - Sorts data according to user-selected column and direction
 * - Implements pagination to show only current page items
 * - Generates HTML table rows with interactive elements
 * - Updates sort indicators in column headers
 * - Refreshes pagination controls and summary totals
 * - Re-establishes column resizing functionality
 * 
 * Called whenever inventory data changes or display settings update
 * 
 * @returns {void} Updates DOM elements with fresh inventory display
 */
const METAL_COLORS = {
  Silver: 'var(--silver)',
  Gold: 'var(--gold)',
  Platinum: 'var(--platinum)',
  Palladium: 'var(--palladium)'
};

const METAL_TEXT_COLORS = {
  Silver: () => getContrastColor(getComputedStyle(document.documentElement).getPropertyValue('--silver').trim()),
  Gold: () => getContrastColor(getComputedStyle(document.documentElement).getPropertyValue('--gold').trim()),
  Platinum: () => getContrastColor(getComputedStyle(document.documentElement).getPropertyValue('--platinum').trim()),
  Palladium: () => getContrastColor(getComputedStyle(document.documentElement).getPropertyValue('--palladium').trim())
};

const typeColors = {
  Coin: 'var(--type-coin-bg)',
  Round: 'var(--type-round-bg)',
  Bar: 'var(--type-bar-bg)',
  Note: 'var(--type-note-bg)',
  Set: 'var(--type-set-bg)',
  Other: 'var(--type-other-bg)'
};
const purchaseLocationColors = {};
const storageLocationColors = {};
const nameColors = {};
const dateColors = {};

const getColor = (map, key) => {
  if (!(key in map)) {
    // Use a simple hash function based on the key itself to ensure consistent colors
    let hash = 0;
    for (let i = 0; i < key.length; i++) {
      const char = key.charCodeAt(i);
      hash = ((hash << 5) - hash) + char;
      hash = hash & hash; // Convert to 32-bit integer
    }
    map[key] = Math.abs(hash) % 360; // Use hash for hue distribution
  }
  const isDark = document.documentElement.getAttribute('data-theme') === 'dark';
  const lightness = isDark ? 65 : 35;
  return `hsl(${map[key]}, 70%, ${lightness}%)`;
};

/**
 * Escapes special characters for safe inclusion in HTML attributes
 * @param {string} text - Text to escape
 * @returns {string} Escaped text safe for attribute usage
 */
const escapeAttribute = (text) =>
  text
    .toString()
    .replace(/&/g, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
    .replace(/'/g, '&#39;');

const filterLink = (field, value, color, displayValue = value, title, allowHtml = false) => {
  const handler = `applyColumnFilter('${field}', ${JSON.stringify(value)})`;
  // Escape characters for safe inline handler usage
  const escaped = escapeAttribute(handler);
  const displayStr = String(displayValue);
  const safe = allowHtml ? displayStr : sanitizeHtml(displayStr);
  const titleStr = title ? String(title) : `Filter by ${displayStr}`;
  const safeTitle = sanitizeHtml(titleStr);
  const isNA = displayStr === 'N/A' || displayStr === 'Numista Import' || displayStr === 'Unknown' || displayStr === '—';
  const classNames = `filter-text${isNA ? ' na-value' : ''}`;
  const styleAttr = isNA ? '' : ` style="color: ${color};"`;
  return `<span class="${classNames}"${styleAttr} onclick="${escaped}" tabindex="0" role="button" onkeydown="if(event.key==='Enter'||event.key===' ')${escaped}" title="${safeTitle}">${safe}</span>`;
};

const getTypeColor = type => typeColors[type] || 'var(--type-other-bg)';
const getPurchaseLocationColor = loc => getColor(purchaseLocationColors, loc);
const getStorageLocationColor = loc =>
  (loc === 'Unknown' || loc === '—') ? 'var(--text-muted)' : getColor(storageLocationColors, loc);

/**
 * Formats Purchase Location for table display, wrapping URLs in hyperlinks
 * while preserving filter behavior.
 *
 * @param {string} loc - Purchase location value
 * @returns {string} HTML string for table cell
 */
const formatPurchaseLocation = (loc) => {
  let value = loc || '—';

  // Convert "Numista Import" and "Unknown" to "—"
  if (value === 'Numista Import' || value === 'Unknown') {
    value = '—';
  }

  const urlPattern = /^(https?:\/\/)?[\w.-]+\.[A-Za-z]{2,}(\S*)?$/;
  const isUrl = urlPattern.test(value);

  // Strip domain suffix for display only (keep full value for filter + href)
  let displayValue = value;
  if (isUrl) {
    displayValue = value
      .replace(/^(https?:\/\/)?(www\.)?/i, '')
      .replace(/\.(com|net|org|co|io|us|uk|ca|au|de|fr|shop|store)\/?.*$/i, '');
  }

  const truncated = displayValue.length > 18 ? displayValue.substring(0, 18) + '…' : displayValue;
  const color = getPurchaseLocationColor(value);
  const filterSpan = filterLink('purchaseLocation', value, color, truncated, value !== truncated ? value : undefined);

  if (isUrl) {
    let href = value;
    if (!/^https?:\/\//i.test(href)) {
      href = `https://${href}`;
    }
    const safeHref = escapeAttribute(href);
    return `<a href="#" onclick="event.stopPropagation(); window.open('${safeHref}', '_blank', 'width=1250,height=800,scrollbars=yes,resizable=yes,toolbar=no,location=no,menubar=no,status=no'); return false;" class="purchase-link" title="${safeHref}">
      <svg class="purchase-link-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" style="width: 12px; height: 12px; fill: currentColor; margin-right: 4px;" aria-hidden="true">
        <path d="M14,3V5H17.59L7.76,14.83L9.17,16.24L19,6.41V10H21V3M19,19H5V5H12V3H5C3.89,3 3,3.9 3,5V19A2,2 0 0,0 5,21H19A2,2 0 0,0 21,19V12H19V19Z"/>
      </svg>
    </a>${filterSpan}`;
  }
  return filterSpan;
};
window.formatPurchaseLocation = formatPurchaseLocation;

/**
 * Formats Storage Location for table display with truncation
 * @param {string} loc - Storage location value
 * @returns {string} HTML string for table cell
 */
const formatStorageLocation = (loc) => {
  let value = loc || '—';
  
  // Convert "Numista Import" and "Unknown" to "—"
  if (value === 'Numista Import' || value === 'Unknown') {
    value = '—';
  }
  
  // Truncate at 25 characters
  const truncated = value.length > 25 ? value.substring(0, 25) + '…' : value;
  const color = getStorageLocationColor(value);
  return filterLink('storageLocation', value, color, truncated, value !== truncated ? value : undefined);
};

/**
 * Recalculates premium values for an inventory item
 * Legacy premiums are no longer displayed — this is now a no-op stub
 * kept to prevent runtime errors from stale references.
 * @param {Object} item - Inventory item (unused)
 */
const recalcItem = (item) => {
  // No-op: premium calculations removed in portfolio redesign
};

/**
 * Saves inventory and refreshes table display
 */
const persistInventoryAndRefresh = () => {
  saveInventory();
  renderTable();
};

/**
 * Updates the displayed inventory item count based on active filters
 *
 * @param {number} filteredCount - Items matching current filters
 * @param {number} totalCount - Total items in inventory
 */
const updateItemCount = (filteredCount, totalCount) => {
  if (!elements.itemCount) return;
  elements.itemCount.textContent =
    filteredCount === totalCount
      ? `${totalCount} items`
      : `${filteredCount} of ${totalCount} items`;
};

/**
 * Enhanced validation for inline edits with comprehensive field support
 * @param {string} field - Field being edited
 * @param {string} value - Proposed value
 * @returns {boolean} Whether value is valid
 */
const validateFieldValue = (field, value) => {
  const trimmedValue = typeof value === 'string' ? value.trim() : String(value).trim();
  
  switch (field) {
    case 'qty':
      const qty = parseInt(value, 10);
      return /^\d+$/.test(value) && qty > 0 && qty <= 999999;
      
    case 'weight':
      const weight = parseFloat(value);
      return !isNaN(weight) && weight > 0 && weight <= 999999;
      
    case 'price':
    case 'marketValue':
      const price = parseFloat(value);
      return !isNaN(price) && price >= 0 && price <= 999999999;
      
    case 'name':
      return trimmedValue.length > 0 && trimmedValue.length <= 200;
      
    case 'purchaseLocation':
    case 'storageLocation':
      return trimmedValue.length <= 100; // Allow empty for optional fields
      
    case 'notes':
      return trimmedValue.length <= 1000; // Allow long notes but with limit
      
    case 'date':
      if (!trimmedValue) return false;
      const dateRegex = /^\d{4}-\d{2}-\d{2}$/;
      if (!dateRegex.test(trimmedValue)) return false;
      const date = new Date(trimmedValue);
      const today = new Date();
      const minDate = new Date('1900-01-01');
      return date >= minDate && date <= today;
      
    case 'type':
      const validTypes = ['Coin', 'Bar', 'Round', 'Note', 'Aurum', 'Set', 'Other'];
      return validTypes.includes(trimmedValue);
      
    case 'metal':
      const validMetals = ['Silver', 'Gold', 'Platinum', 'Palladium'];
      return validMetals.includes(trimmedValue);
      
    default:
      return true;
  }
};

/**
 * Enhanced inline editing for table cells with support for multiple field types
 * @param {number} idx - Index of item to edit
 * @param {string} field - Field name to update
 * @param {HTMLElement} element - The td cell or a child element within it
 */
const startCellEdit = (idx, field, element) => {
  const td = element.tagName === 'TD' ? element : element.closest('td');
  const item = inventory[idx];
  const current = item[field] ?? '';
  const originalContent = td.innerHTML;
  
  // Close any other open editors (fix for closing all editors issue)
  const allOpenEditors = document.querySelectorAll('td.editing');
  allOpenEditors.forEach(editor => {
    if (editor !== td) {
      const cancelBtn = editor.querySelector('.cancel-inline');
      if (cancelBtn) cancelBtn.click();
    }
  });
  
  td.classList.add('editing');
  
  let input;
  
  // Create appropriate input type based on field
  if (['type', 'metal'].includes(field)) {
    input = document.createElement('select');
    input.className = 'inline-select';
    
    if (field === 'type') {
      const types = ['Coin', 'Bar', 'Round', 'Note', 'Aurum', 'Set', 'Other'];
      types.forEach(type => {
        const option = document.createElement('option');
        option.value = type;
        option.textContent = type;
        if (type === current) option.selected = true;
        input.appendChild(option);
      });
    } else if (field === 'metal') {
      const metals = ['Silver', 'Gold', 'Platinum', 'Palladium', 'Alloy/Other'];
      metals.forEach(metal => {
        const option = document.createElement('option');
        option.value = metal;
        option.textContent = metal;
        if (metal === current) option.selected = true;
        input.appendChild(option);
      });
    }
  } else {
    input = document.createElement('input');
    input.className = 'inline-input';
    
    if (field === 'qty') {
      input.type = 'number';
      input.step = '1';
      input.min = '1';
    } else if (['weight', 'price', 'marketValue'].includes(field)) {
      input.type = 'number';
      input.step = '0.01';
      input.min = '0';
    } else if (field === 'date') {
      input.type = 'date';
    } else {
      input.type = 'text';
    }
    
    // Set input value based on field type
    if (field === 'weight' && item.weight < 1) {
      input.value = oztToGrams(current).toFixed(2);
      input.dataset.unit = 'g';
    } else if (['weight', 'price', 'marketValue'].includes(field)) {
      input.value = parseFloat(current || 0).toFixed(2);
      if (field === 'weight') input.dataset.unit = 'oz';
    } else {
      input.value = current;
    }
  }
  
  td.innerHTML = '';
  td.appendChild(input);

  const cancelEdit = () => {
    td.classList.remove('editing');
    // nosemgrep: javascript.browser.security.insecure-innerhtml.insecure-innerhtml, javascript.browser.security.insecure-document-method.insecure-document-method
    td.innerHTML = originalContent;
  };

  const saveEdit = () => {
    const value = input.value;
    if (!validateFieldValue(field, value)) {
      alert(`Invalid value for ${field}`);
      cancelEdit();
      return;
    }

    let finalValue;
    if (field === 'qty') {
      finalValue = parseInt(value, 10);
    } else if (['weight', 'price', 'marketValue'].includes(field)) {
      finalValue = parseFloat(value);
      if (field === 'weight' && input.dataset.unit === 'g') {
        finalValue = gramsToOzt(finalValue);
      }
    } else {
      finalValue = value.trim();
    }

    // Store the old value for change logging
    const oldValue = item[field];
    item[field] = finalValue;

    // Log the change
    if (typeof logChange === 'function') {
      logChange(item.name || `Item ${idx + 1}`, field, oldValue, finalValue, idx);
    }

    saveInventory();

    // Record price data point for inline edits on price-related fields (STACK-43)
    if (typeof recordSingleItemPrice === 'function' &&
        ['price', 'marketValue', 'weight', 'qty'].includes(field)) {
      recordSingleItemPrice(item, 'edit');
    }

    renderTable();
  };

  // Keyboard-only: Enter saves, Escape cancels
  input.addEventListener('keydown', (e) => {
    if (e.key === 'Enter') {
      e.preventDefault();
      saveEdit();
    }
    if (e.key === 'Escape') {
      e.preventDefault();
      cancelEdit();
    }
  });

  // Cancel on blur (clicking away from the input)
  input.addEventListener('blur', () => {
    cancelEdit();
  });

  input.focus();
  if (input.select) input.select();
};

window.startCellEdit = startCellEdit;



/**
 * Hides table columns that contain no data after filtering.
 */
const hideEmptyColumns = () => {
  if (typeof document === 'undefined') return;
  const headers = document.querySelectorAll('#inventoryTable thead th[data-column]');
  headers.forEach(header => {
    const col = header.getAttribute('data-column');
    const cells = document.querySelectorAll(`#inventoryTable tbody [data-column="${col}"]`);
    const allEmpty = cells.length > 0 && Array.from(cells).every(cell => {
      // If the cell contains interactive or icon elements, consider it non-empty
      if (cell.querySelector && (cell.querySelector('svg') || cell.querySelector('button') || cell.querySelector('.action-icon') || cell.querySelector('img'))) {
        return false;
      }
      return cell.textContent.trim() === '';
    });

    document.querySelectorAll(`#inventoryTable [data-column="${col}"]`).forEach(el => {
      el.classList.toggle('hidden-empty', allEmpty);
    });
  });
};

/** IntersectionObserver instance for lazy-loading table thumbnails */
let _thumbObserver = null;

// Metal-colored SVG placeholder cache (one per metal+type combo)
const _thumbPlaceholders = {};

/**
 * Generate an inline SVG data URI for a metal-themed placeholder thumbnail.
 * Uses the metal's brand color and an icon based on item type (coin vs bar).
 * @param {string} metal - Metal name (Silver, Gold, Platinum, Palladium)
 * @param {string} type - Item type (Coin, Bar, Round, etc.)
 * @returns {string} data:image/svg+xml URI
 */
function _getThumbPlaceholder(metal, type) {
  const key = (metal || 'Silver') + ':' + (type || 'Coin');
  if (_thumbPlaceholders[key]) return _thumbPlaceholders[key];

  // Metal color palette (matches CSS custom properties)
  const colors = {
    Silver:    { fill: '#a8b5c4', stroke: '#8a9bb0', text: '#6b7d91' },
    Gold:      { fill: '#d4a74a', stroke: '#b8912e', text: '#9a7a24' },
    Platinum:  { fill: '#b8c5d6', stroke: '#95a8bd', text: '#7b8fa5' },
    Palladium: { fill: '#c2b8a3', stroke: '#a89e8a', text: '#8e846f' },
  };
  const c = colors[metal] || colors.Silver;

  // Icon path: coin (circle) for most types, rectangle for bars
  const isBar = /bar|ingot/i.test(type || '');
  const icon = isBar
    ? `<rect x="11" y="7" width="10" height="18" rx="1.5" fill="none" stroke="${c.text}" stroke-width="1.5" opacity="0.5"/><line x1="13" y1="12" x2="19" y2="12" stroke="${c.text}" stroke-width="0.8" opacity="0.4"/><line x1="13" y1="15" x2="19" y2="15" stroke="${c.text}" stroke-width="0.8" opacity="0.4"/><line x1="13" y1="18" x2="19" y2="18" stroke="${c.text}" stroke-width="0.8" opacity="0.4"/>`
    : `<circle cx="16" cy="16" r="8" fill="none" stroke="${c.text}" stroke-width="1.2" opacity="0.45"/><circle cx="16" cy="16" r="5" fill="none" stroke="${c.text}" stroke-width="0.8" opacity="0.3" stroke-dasharray="2 2"/>`;

  const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
    <circle cx="16" cy="16" r="15" fill="${c.fill}" stroke="${c.stroke}" stroke-width="1" opacity="0.25"/>
    ${icon}
  </svg>`;

  const uri = 'data:image/svg+xml,' + encodeURIComponent(svg);
  _thumbPlaceholders[key] = uri;
  return uri;
}

/**
 * Upgrades table thumbnail src attributes from IDB blob URLs using
 * IntersectionObserver for viewport-based lazy loading.
 * Pre-loads 200px before viewport for smooth scrolling.
 */
async function _enhanceTableThumbnails() {
  if (!featureFlags.isEnabled('COIN_IMAGES') || !window.imageCache?.isAvailable()) return;

  // Respect table images toggle (default ON)
  if (localStorage.getItem('tableImagesEnabled') === 'false') return;

  // Disconnect previous observer to avoid observing stale nodes
  if (_thumbObserver) _thumbObserver.disconnect();

  _thumbObserver = new IntersectionObserver((entries) => {
    for (const entry of entries) {
      if (!entry.isIntersecting) continue;
      _thumbObserver.unobserve(entry.target);
      _loadThumbImage(entry.target);
    }
  }, { rootMargin: '200px 0px' });

  document.querySelectorAll('#inventoryTable .table-thumb').forEach(img => {
    _thumbObserver.observe(img);
  });
}

/**
 * Resolve and set blob URL for a single table thumbnail image.
 * Checks IDB cache (user uploads → pattern images → Numista cache).
 * Falls back to a metal-colored SVG placeholder when no image is available.
 * @param {HTMLImageElement} img - Table thumbnail element with data attributes
 */
async function _loadThumbImage(img) {
  try {
    const item = {
      uuid: img.dataset.itemUuid || '',
      numistaId: img.dataset.catalogId || '',
      name: img.dataset.itemName || '',
      metal: img.dataset.itemMetal || '',
      type: img.dataset.itemType || '',
    };

    const side = img.dataset.side || 'obverse';

    // Resolve CDN URL from inventory item
    const row = img.closest('tr');
    const idx = row?.dataset?.idx;
    let cdnUrl = '';
    if (idx !== undefined) {
      const invItem = inventory[parseInt(idx, 10)];
      if (invItem) {
        const urlKey = side === 'reverse' ? 'reverseImageUrl' : 'obverseImageUrl';
        cdnUrl = (invItem[urlKey] && /^https?:\/\/.+\..+/i.test(invItem[urlKey])) ? invItem[urlKey] : '';
      }
    }

    // Numista override: CDN URLs (Numista source) win over user/pattern blobs
    const numistaOverride = localStorage.getItem('numistaOverridePersonal') === 'true';
    if (numistaOverride && cdnUrl) {
      img.src = cdnUrl;
      img.style.visibility = '';
      return;
    }

    const blobUrl = await imageCache.resolveImageUrlForItem(item, side);
    if (blobUrl) {
      _thumbBlobUrls.push(blobUrl);
      img.src = blobUrl;
      img.style.visibility = '';
      return;
    }

    // Fallback: CDN URL
    if (cdnUrl) {
      img.src = cdnUrl;
      img.style.visibility = '';
      return;
    }

    // No cached image, no CDN URL — show metal-themed placeholder
    img.src = _getThumbPlaceholder(item.metal, item.type);
    img.style.visibility = '';
    img.classList.add('table-thumb-placeholder');
  } catch { /* ignore — IDB unavailable or entry missing */ }
}

const renderTable = () => {
  return monitorPerformance(() => {
    // Ensure filterInventory is available (search.js may still be loading)
    const filteredInventory = typeof filterInventory === 'function' ? filterInventory() : inventory;
    updateItemCount(filteredInventory.length, inventory.length);
    const sortedInventory = sortInventory(filteredInventory);
    debugLog('renderTable start', sortedInventory.length, 'items');

    // STAK-131: Card sort bar + card view rendering branch
    const cardSortBar = document.getElementById('cardSortBar');
    const footerSelect = document.querySelector('.table-footer-controls select');
    if (typeof isCardViewActive === 'function' && isCardViewActive()) {
      const cardGrid = safeGetElement('cardViewGrid');
      const portalScroll = document.querySelector('.portal-scroll');
      if (cardGrid) {
        cardGrid.style.display = 'flex';
        if (portalScroll) portalScroll.style.display = 'none';
        // Show card sort bar and keep pagination dropdown visible
        if (cardSortBar) cardSortBar.style.display = 'flex';
        if (footerSelect) footerSelect.style.display = '';
        if (typeof initCardSortBar === 'function') initCardSortBar();
        if (typeof updateCardSortBar === 'function') updateCardSortBar();

        renderCardView(sortedInventory, cardGrid);
        bindCardClickHandler(cardGrid);

        // Defer portal height calc to next frame so cards have their layout
        requestAnimationFrame(() => updatePortalHeight());
        updateSummary();
        return;
      }
    }

    // Ensure table is visible when not in card view
    const cardGridEl = safeGetElement('cardViewGrid');
    const portalScrollEl = document.querySelector('.portal-scroll');
    if (cardGridEl) {
      cardGridEl.style.display = 'none';
      cardGridEl.style.maxHeight = '';
      cardGridEl.style.overflowY = '';
    }
    if (portalScrollEl) portalScrollEl.style.display = '';
    // Hide card sort bar and show pagination dropdown
    if (cardSortBar) cardSortBar.style.display = 'none';
    if (footerSelect) footerSelect.style.display = '';

    const rows = [];
    const chipConfig = typeof getInlineChipConfig === 'function' ? getInlineChipConfig() : [];

    for (let i = 0; i < sortedInventory.length; i++) {
      const item = sortedInventory[i];
      const originalIdx = inventory.indexOf(item);
      debugLog('renderTable row', i, item.name);

      // Portfolio computed values (all financial columns are qty-adjusted totals)
      const currentSpot = spotPrices[item.metal.toLowerCase()] || 0;
      const valuation = (typeof computeItemValuation === 'function')
        ? computeItemValuation(item, currentSpot)
        : null;
      const purchasePrice = valuation ? valuation.purchasePrice : (typeof item.price === 'number' ? item.price : parseFloat(item.price) || 0);
      const meltValue = valuation ? valuation.meltValue : computeMeltValue(item, currentSpot);
      const gbDenomPrice = valuation ? valuation.gbDenomPrice : null;
      const isManualRetail = valuation ? valuation.isManualRetail : false;
      const retailTotal = valuation ? valuation.retailTotal : meltValue;
      const gainLoss = valuation ? valuation.gainLoss : null;
      const hasRetailSignal = valuation ? valuation.hasRetailSignal : (currentSpot > 0);

      // Resolve Numista catalog ID for inline tag
      const numistaId = item.numistaId || (typeof catalogManager !== 'undefined'
        && catalogManager.getCatalogId ? catalogManager.getCatalogId(item.serial) : null);

      // Build inline chip HTML strings for config-driven rendering
      const gradeTag = item.grade ? (() => {
        const authority = item.gradingAuthority || '';
        const certNum = item.certNumber || '';
        const isClickable = !!certNum;
        let tooltip;
        if (authority === 'PCGS' && certNum && item.pcgsVerified) {
          tooltip = `${authority} Cert #${certNum} \u2014 Verified`;
        } else if (authority && certNum) {
          tooltip = `${authority} Cert #${certNum} \u2014 Click to verify`;
        } else if (authority) {
          tooltip = `Graded by ${authority}: ${item.grade}`;
        } else {
          tooltip = `Grade: ${item.grade}`;
        }
        // Show PCGS verify icon when: authority=PCGS + has cert# + PCGS API configured
        const showPcgsVerify = authority === 'PCGS' && certNum
          && typeof catalogConfig !== 'undefined' && catalogConfig.isPcgsEnabled();
        const verifyIcon = showPcgsVerify
          ? `<span class="pcgs-verify-btn${item.pcgsVerified ? ' pcgs-verified' : ''}" data-cert-number="${escapeAttribute(certNum)}" title="${item.pcgsVerified ? 'Verified \u2014 Click to re-verify' : 'Verify cert via PCGS API'}"><svg xmlns="http://www.w3.org/2000/svg" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg></span>`
          : '';
        const attrs = [
          authority ? `data-authority="${escapeAttribute(authority)}"` : '',
          isClickable ? 'data-clickable="true"' : '',
          certNum ? `data-cert-number="${escapeAttribute(certNum)}"` : '',
          `data-grade="${escapeAttribute(item.grade || '')}"`,
          isClickable ? 'tabindex="0" role="button"' : '',
        ].filter(Boolean).join(' ');
        return `<span class="grade-tag" ${attrs} title="${escapeAttribute(tooltip)}">${sanitizeHtml(item.grade)}${verifyIcon}</span>`;
      })() : '';

      const numistaTag = numistaId
        ? `<span class="numista-tag" data-numista-id="${escapeAttribute(String(numistaId))}"
               data-coin-name="${escapeAttribute(item.name)}"
               title="N#${escapeAttribute(String(numistaId))} — View on Numista"
               tabindex="0" role="button">N#${sanitizeHtml(String(numistaId))}</span>`
        : '';

      const pcgsTag = item.pcgsNumber
        ? `<span class="pcgs-tag" data-pcgs-number="${escapeAttribute(String(item.pcgsNumber))}"
               data-grade="${escapeAttribute(item.grade || '')}"
               title="PCGS #${escapeAttribute(String(item.pcgsNumber))} — View on PCGS CoinFacts"
               tabindex="0" role="button">PCGS#${sanitizeHtml(String(item.pcgsNumber))}</span>`
        : '';

      const yearTag = item.year
        ? `<span class="year-tag" title="Filter by year: ${escapeAttribute(String(item.year))}"
               onclick="applyColumnFilter('year', ${JSON.stringify(String(item.year))})"
               tabindex="0" role="button" style="cursor:pointer;">${sanitizeHtml(String(item.year))}</span>`
        : '';

      const serialTag = item.serialNumber
        ? `<span class="serial-tag" title="S/N: ${escapeAttribute(item.serialNumber)}">${sanitizeHtml(item.serialNumber)}</span>`
        : '';

      const storageTag = item.storageLocation && item.storageLocation !== 'Unknown'
        ? `<span class="storage-tag" title="${escapeAttribute(item.storageLocation)}">${sanitizeHtml(item.storageLocation)}</span>`
        : '';

      const notesIndicator = item.notes
        ? `<span class="notes-indicator" title="Click to view notes · Shift+click to edit"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8l-6-6zm-1 2l5 5h-5V4zM6 20V4h5v7h7v9H6z"/></svg></span>`
        : '';

      const purityVal = parseFloat(item.purity);
      const purityTag = (!isNaN(purityVal) && purityVal > 0 && purityVal < 1.0)
        ? `<span class="purity-tag" title="Purity: ${purityVal}" onclick="applyColumnFilter('purity', ${JSON.stringify(String(purityVal))})" tabindex="0" role="button" style="cursor:pointer;">${purityVal}</span>`
        : '';

      // Table thumbnail — obverse + reverse preview in name cell
      // Omit src attribute entirely when no URL (avoids browser requesting page URL for src="")
      // Hidden when tableImagesEnabled toggle is off
      const _tableImagesOn = localStorage.getItem('tableImagesEnabled') !== 'false';
      const _thumbType = (item.type || '').toLowerCase();
      const _isRectThumb = _thumbType === 'bar' || _thumbType === 'note' || _thumbType === 'aurum'
        || _thumbType === 'set' || item.weightUnit === 'gb';
      const _thumbShapeClass = _isRectThumb ? ' table-thumb-rect' : '';
      const _validUrl = (u) => u && /^https?:\/\/.+\..+/i.test(u);
      const obvUrl = _validUrl(item.obverseImageUrl) ? item.obverseImageUrl : '';
      const revUrl = _validUrl(item.reverseImageUrl) ? item.reverseImageUrl : '';
      const obvSrcAttr = obvUrl ? ` src="${escapeAttribute(obvUrl)}"` : '';
      const revSrcAttr = revUrl ? ` src="${escapeAttribute(revUrl)}"` : '';
      const _sharedThumbAttrs = `data-catalog-id="${escapeAttribute(item.numistaId || '')}"
               data-item-uuid="${escapeAttribute(item.uuid || '')}"
               data-item-name="${escapeAttribute(item.name || '')}"
               data-item-metal="${escapeAttribute(item.metal || '')}"
               data-item-type="${escapeAttribute(item.type || '')}"`;
      const _tableImageSides = localStorage.getItem('tableImageSides') || 'both';
      const _showObv = _tableImageSides === 'both' || _tableImageSides === 'obverse';
      const _showRev = _tableImageSides === 'both' || _tableImageSides === 'reverse';
      const thumbHtml = _tableImagesOn && featureFlags.isEnabled('COIN_IMAGES')
        ? (_showObv ? `<img class="table-thumb${_thumbShapeClass}"${obvSrcAttr}
               ${_sharedThumbAttrs} data-side="obverse"
               alt="" loading="lazy" onerror="this.style.display='none'" />` : '')
        + (_showRev ? `<img class="table-thumb${_thumbShapeClass}"${revSrcAttr}
               ${_sharedThumbAttrs} data-side="reverse"
               alt="" loading="lazy" onerror="this.style.display='none'" />` : '')
        : '';

      // STAK-126: Inline tags chip (show first 2 tags, ellipsis if more)
      const _inlineTags = typeof getItemTags === 'function' ? getItemTags(item.uuid) : [];
      const tagsChip = _inlineTags.length > 0
        ? `<span class="tags-inline-chip" title="${escapeAttribute(_inlineTags.join(', '))}">${sanitizeHtml(_inlineTags.slice(0, 2).join(', '))}${_inlineTags.length > 2 ? '\u2026' : ''}</span>`
        : '';

      // Config-driven chip ordering
      const chipMap = { grade: gradeTag, numista: numistaTag, pcgs: pcgsTag, year: yearTag, serial: serialTag, storage: storageTag, notes: notesIndicator, purity: purityTag, tags: tagsChip };
      const orderedChips = chipConfig.filter(c => c.enabled && chipMap[c.id]).map(c => chipMap[c.id]).join('');

      // Format computed displays
      const meltDisplay = currentSpot > 0 ? formatCurrency(meltValue) : '—';
      const retailDisplay = hasRetailSignal ? formatCurrency(retailTotal) : '—';
      const gainLossDisplay = gainLoss !== null && hasRetailSignal ? formatCurrency(Math.abs(gainLoss)) : '—';
      const gainLossColor = gainLoss > 0 ? 'var(--success, #4caf50)' : gainLoss < 0 ? 'var(--danger, #f44336)' : 'var(--text-primary)';
      const gainLossPrefix = gainLoss > 0 ? '+' : gainLoss < 0 ? '-' : '';

  rows.push(`
      <tr data-idx="${originalIdx}">
  <td class="shrink" data-column="date" data-label="Date">${filterLink('date', item.date, 'var(--text-primary)', item.date ? formatDisplayDate(item.date) : '—')}</td>
      <td class="shrink" data-column="metal" data-label="Metal" data-metal="${escapeAttribute(item.composition || item.metal || '')}">${filterLink('metal', item.composition || item.metal || 'Silver', METAL_COLORS[item.metal] || 'var(--primary)', getDisplayComposition(item.composition || item.metal || 'Silver'))}</td>
      <td class="shrink" data-column="type" data-label="Type">${filterLink('type', item.type, getTypeColor(item.type))}</td>
      <td class="shrink" data-column="image" data-label="Image" style="text-align: center;">${thumbHtml}</td>
      <td class="expand" data-column="name" data-label="" style="text-align: left;">
        <div class="name-cell-content">
        ${featureFlags.isEnabled('COIN_IMAGES')
          ? `<span class="filter-text" style="color: var(--text-primary); cursor: pointer;" onclick="showViewModal(${originalIdx})" tabindex="0" role="button" onkeydown="if(event.key==='Enter'||event.key===' ')showViewModal(${originalIdx})" title="View ${escapeAttribute(item.name)}">${sanitizeHtml(item.name)}</span>`
          : filterLink('name', item.name, 'var(--text-primary)', undefined, item.name)}${orderedChips}
        </div>
      </td>
      <td class="shrink" data-column="qty" data-label="Qty">${filterLink('qty', item.qty, 'var(--text-primary)')}</td>
      <td class="shrink" data-column="weight" data-label="Weight">${filterLink('weight', item.weight, 'var(--text-primary)', formatWeight(item.weight, item.weightUnit), item.weightUnit === 'gb' ? 'Goldback denomination' : item.weight < 1 ? 'Grams (g)' : 'Troy ounces (ozt)')}</td>
      <td class="shrink" data-column="purchasePrice" data-label="Purchase" title="Purchase Price (${displayCurrency}) - Click to search eBay active listings" style="color: var(--text-primary);">
        <a href="#" class="ebay-buy-link ebay-price-link" data-search="${escapeAttribute(item.metal + (item.year ? ' ' + item.year : '') + ' ' + item.name)}" title="Search eBay active listings for ${escapeAttribute(item.metal)} ${escapeAttribute(item.name)}">
          ${formatCurrency(purchasePrice)} <svg class="ebay-search-svg" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden="true"><circle cx="10.5" cy="10.5" r="6" fill="none" stroke="currentColor" stroke-width="2.5"/><line x1="15" y1="15" x2="21" y2="21" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"/></svg>
        </a>
      </td>
      <td class="shrink" data-column="meltValue" data-label="Melt" title="Melt Value (${displayCurrency})" style="color: var(--text-primary);">${meltDisplay}</td>
      <td class="shrink ${gbDenomPrice ? 'retail-confirmed' : isManualRetail ? 'retail-confirmed' : 'retail-estimated'}" data-column="retailPrice" data-label="Retail" title="${gbDenomPrice ? 'Goldback denomination price' : isManualRetail ? 'Manual retail price (confirmed)' : 'Estimated — defaults to melt value'} - Click to search eBay sold listings">
        <a href="#" class="ebay-sold-link ebay-price-link" data-search="${escapeAttribute(item.metal + (item.year ? ' ' + item.year : '') + ' ' + item.name)}" title="Search eBay sold listings for ${escapeAttribute(item.metal)} ${escapeAttribute(item.name)}">
          ${retailDisplay} <svg class="ebay-search-svg" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden="true"><circle cx="10.5" cy="10.5" r="6" fill="none" stroke="currentColor" stroke-width="2.5"/><line x1="15" y1="15" x2="21" y2="21" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"/></svg>
        </a>
      </td>
      <td class="shrink ${!isManualRetail && gainLoss !== null ? 'gainloss-estimated' : ''}" data-column="gainLoss" data-label="Gain/Loss" title="${isManualRetail ? 'Gain/Loss (confirmed retail)' : 'Gain/Loss (estimated — based on melt value)'}" style="color: ${gainLossColor}; font-weight: ${gainLoss !== null && gainLoss !== 0 && isManualRetail ? '600' : 'normal'};">${gainLoss !== null && gainLossDisplay !== '—' ? gainLossPrefix + gainLossDisplay : '—'}</td>
      <td class="shrink" data-column="purchaseLocation" data-label="Source">
        ${formatPurchaseLocation(item.purchaseLocation)}
      </td>
      <td class="icon-col actions-cell" data-column="actions" data-label=""><div class="actions-row">
        <button class="icon-btn action-icon edit-icon" role="button" tabindex="0" onclick="editItem(${originalIdx})" aria-label="Edit ${sanitizeHtml(item.name)}" title="Edit item">
          <svg class="icon-svg edit-svg" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden="true"><path d="M12,15.5A3.5,3.5 0 0,1 8.5,12A3.5,3.5 0 0,1 12,8.5A3.5,3.5 0 0,1 15.5,12A3.5,3.5 0 0,1 12,15.5M19.43,12.97C19.47,12.65 19.5,12.33 19.5,12C19.5,11.67 19.47,11.34 19.43,11L21.54,9.37C21.73,9.22 21.78,8.95 21.66,8.73L19.66,5.27C19.54,5.05 19.27,4.96 19.05,5.05L16.56,6.05C16.04,5.66 15.5,5.32 14.87,5.07L14.5,2.42C14.46,2.18 14.25,2 14,2H10C9.75,2 9.54,2.18 9.5,2.42L9.13,5.07C8.5,5.32 7.96,5.66 7.44,6.05L4.95,5.05C4.73,4.96 4.46,5.05 4.34,5.27L2.34,8.73C2.22,8.95 2.27,9.22 2.46,9.37L4.57,11C4.53,11.34 4.5,11.67 4.5,12C4.5,12.33 4.53,12.65 4.57,12.97L2.46,14.63C2.27,14.78 2.22,15.05 2.34,15.27L4.34,18.73C4.46,18.95 4.73,19.03 4.95,18.95L7.44,17.94C7.96,18.34 8.5,18.68 9.13,18.93L9.5,21.58C9.54,21.82 9.75,22 10,22H14C14.25,22 14.46,21.82 14.5,21.58L14.87,18.93C15.5,18.67 16.04,18.34 16.56,17.94L19.05,18.95C19.27,19.03 19.54,18.95 19.66,18.73L21.66,15.27C21.78,15.05 21.73,14.78 21.54,14.63L19.43,12.97Z"/></svg>
        </button>
        <button class="icon-btn action-icon" role="button" tabindex="0" onclick="duplicateItem(${originalIdx})" aria-label="Duplicate ${sanitizeHtml(item.name)}" title="Duplicate item">
          <svg class="icon-svg" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden="true"><path d="M16 1H4a2 2 0 0 0-2 2v14h2V3h12V1zm3 4H8a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h11a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2zm0 16H8V7h11v14z"/></svg>
        </button>
        <button class="icon-btn action-icon danger" role="button" tabindex="0" onclick="deleteItem(${originalIdx})" aria-label="Delete item" title="Delete item">
          <svg class="icon-svg delete-svg" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden="true"><path d="M6 7h12v13a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2V7zm3-4h6l1 1h4v2H3V4h4l1-1z"/></svg>
        </button>
      </div></td>
      </tr>
      `);
    }

    // Find tbody element directly if cached version fails
    const tbody = elements.inventoryTable || document.querySelector('#inventoryTable tbody');
    if (!tbody) {
      console.error('Could not find table tbody element');
      return;
    }

    // Revoke previous thumbnail blob URLs to prevent memory leaks
    for (const url of _thumbBlobUrls) {
      try { URL.revokeObjectURL(url); } catch { /* ignore */ }
    }
    _thumbBlobUrls = [];

    // nosemgrep: javascript.browser.security.insecure-innerhtml.insecure-innerhtml, javascript.browser.security.insecure-document-method.insecure-document-method
    tbody.innerHTML = rows.join('');

    // Upgrade table thumbnails from CDN URLs to IDB blob URLs (fire-and-forget)
    _enhanceTableThumbnails();

    // Image-cell click: open the thumb popover for upload/view
    if (!tbody._imgCellBound) {
      tbody._imgCellBound = true;
      tbody.addEventListener('click', (e) => {
        if (!featureFlags.isEnabled('COIN_IMAGES')) return;
        const cell = e.target.closest('td[data-column="image"]');
        if (!cell) return;
        e.stopPropagation();
        const row = cell.closest('tr[data-idx]');
        if (!row) return;
        const idx = parseInt(row.dataset.idx, 10);
        if (isNaN(idx)) return;
        const item = inventory[idx];
        if (!item) return;
        _openThumbPopover(cell, item);
      });
    }

    // Card-view tap: delegate click on tbody rows (≤768px only)
    // Opens view modal if COIN_IMAGES enabled, otherwise edit modal
    if (!tbody._cardTapBound) {
      tbody._cardTapBound = true;
      tbody.addEventListener('click', (e) => {
        if (window.innerWidth > 768) return;
        // Don't intercept clicks on buttons, links, or interactive elements
        if (e.target.closest('button, a, input, select, textarea, .icon-btn, .filter-text, [role="button"], .year-tag, .purity-tag, td[data-column="image"]')) return;
        const row = e.target.closest('tr[data-idx]');
        if (row) {
          const idx = Number(row.dataset.idx);
          if (featureFlags.isEnabled('COIN_IMAGES') && typeof showViewModal === 'function') {
            showViewModal(idx);
          } else {
            editItem(idx);
          }
        }
      });
    }

    hideEmptyColumns();

    debugLog('renderTable complete');

    // Update sort indicators
    const headers = document.querySelectorAll('#inventoryTable th');
    headers.forEach(header => {
      const indicator = header.querySelector('.sort-indicator');
      if (indicator) header.removeChild(indicator);
    });

    if (sortColumn !== null && sortColumn < headers.length) {
      const header = headers[sortColumn];
      const indicator = document.createElement('span');
      indicator.className = 'sort-indicator';
      indicator.textContent = sortDirection === 'asc' ? '↑' : '↓';
      header.appendChild(indicator);
    }

    updatePortalHeight();
    updateSummary();
    
    // Re-setup column resizing and responsive visibility after table re-render
    setupColumnResizing();
    updateColumnVisibility();
  }, 'renderTable');
};

/**
 * Calculates and updates all financial summary displays across the application
 */
const updateSummary = () => {
  /**
   * Calculates portfolio metrics for specified metal type
   * Uses the three-value model: Purchase Price, Melt Value, Retail Price
   * Gain/Loss is based on retail price (which defaults to melt if not manually set)
   *
   * @param {string} metal - Metal type to calculate
   * @returns {Object} Calculated metrics
   */
  const calculateTotals = (metal) => {
    let totalItems = 0;
    let totalWeight = 0;
    let totalMeltValue = 0;
    let totalPurchased = 0;
    let totalRetailValue = 0;
    let totalGainLoss = 0;

    for (const item of inventory) {
      if (item.metal === metal) {
        const qty = Number(item.qty) || 0;
        const weight = parseFloat(item.weight) || 0;
        const price = parseFloat(item.price) || 0;

        totalItems += qty;
        // Convert gb denomination to troy oz for weight totals
        const weightOz = (item.weightUnit === 'gb') ? weight * GB_TO_OZT : weight;
        const itemWeight = qty * weightOz;
        totalWeight += itemWeight;

        // Melt value: weight x qty x current spot x purity
        const currentSpot = spotPrices[item.metal.toLowerCase()] || 0;
        const valuation = (typeof computeItemValuation === 'function')
          ? computeItemValuation(item, currentSpot)
          : null;
        const purity = parseFloat(item.purity) || 1.0;
        const meltValue = valuation ? valuation.meltValue : (currentSpot * itemWeight * purity);
        totalMeltValue += meltValue;

        // Purchase price total (price already converted)
        const purchaseTotal = valuation ? valuation.purchaseTotal : (qty * price);
        totalPurchased += purchaseTotal;

        // Retail total: (1) gb denomination price, (2) manual marketValue, (3) melt
        const retailTotal = valuation ? valuation.retailTotal : meltValue;
        totalRetailValue += retailTotal;

        // Gain/loss: retail minus purchase (both in USD; converted at display time)
        totalGainLoss += retailTotal - purchaseTotal;
      }
    }

    return {
      totalItems,
      totalWeight,
      totalMeltValue,
      totalPurchased,
      totalRetailValue,
      totalGainLoss
    };
  };

  // Calculate totals for each metal
  const metalTotals = {};
  Object.values(METALS).forEach(metalConfig => {
    metalTotals[metalConfig.key] = calculateTotals(metalConfig.name);
  });

  // Update DOM elements
  Object.values(METALS).forEach(metalConfig => {
    const totals = metalTotals[metalConfig.key];
    const metalKey = metalConfig.key;
    const els = elements.totals[metalKey];

    if (els.items) els.items.textContent = totals.totalItems;
    if (els.weight) els.weight.textContent = totals.totalWeight.toFixed(2);
    if (els.value) els.value.textContent = formatCurrency(totals.totalMeltValue || 0);
    if (els.purchased) els.purchased.textContent = formatCurrency(totals.totalPurchased || 0);
    if (els.retailValue) els.retailValue.textContent = formatCurrency(totals.totalRetailValue || 0);
    if (els.lossProfit) {
      const gl = totals.totalGainLoss || 0;
      const gainLossPct = totals.totalPurchased > 0 ? (gl / totals.totalPurchased) * 100 : 0;
      // nosemgrep: javascript.browser.security.insecure-innerhtml.insecure-innerhtml, javascript.browser.security.insecure-document-method.insecure-document-method
      els.lossProfit.innerHTML = formatLossProfit(gl, gainLossPct);
      // Dynamic label: "Gain:" green, "Loss:" red, "Gain/Loss:" neutral (STACK-50)
      const glLabel = els.lossProfit.parentElement && els.lossProfit.parentElement.querySelector('.total-label');
      if (glLabel) {
        glLabel.textContent = gl > 0 ? 'Gain:' : gl < 0 ? 'Loss:' : 'Gain/Loss:';
        glLabel.style.color = gl > 0 ? 'var(--success)' : gl < 0 ? 'var(--danger)' : '';
        glLabel.style.fontWeight = gl !== 0 ? '600' : '';
      }
    }
    if (els.avgCostPerOz) {
      const avgCost = totals.totalWeight > 0 ? totals.totalPurchased / totals.totalWeight : 0;
      els.avgCostPerOz.textContent = formatCurrency(avgCost);
    }
  });

  // Calculate combined totals for all metals
  const allTotals = {
    totalItems: 0,
    totalWeight: 0,
    totalMeltValue: 0,
    totalPurchased: 0,
    totalRetailValue: 0,
    totalGainLoss: 0
  };

  Object.values(metalTotals).forEach(totals => {
    allTotals.totalItems += totals.totalItems;
    allTotals.totalWeight += totals.totalWeight;
    allTotals.totalMeltValue += totals.totalMeltValue;
    allTotals.totalPurchased += totals.totalPurchased;
    allTotals.totalRetailValue += totals.totalRetailValue;
    allTotals.totalGainLoss += totals.totalGainLoss;
  });

  // Update "All" totals display if elements exist
  if (elements.totals.all && elements.totals.all.items) {
    elements.totals.all.items.textContent = allTotals.totalItems;
    if (elements.totals.all.weight) elements.totals.all.weight.textContent = allTotals.totalWeight.toFixed(2);
    if (elements.totals.all.value) elements.totals.all.value.textContent = formatCurrency(allTotals.totalMeltValue || 0);
    if (elements.totals.all.purchased) elements.totals.all.purchased.textContent = formatCurrency(allTotals.totalPurchased || 0);
    if (elements.totals.all.retailValue) elements.totals.all.retailValue.textContent = formatCurrency(allTotals.totalRetailValue || 0);
    if (elements.totals.all.lossProfit) {
      const allGl = allTotals.totalGainLoss || 0;
      const allGainLossPct = allTotals.totalPurchased > 0 ? (allGl / allTotals.totalPurchased) * 100 : 0;
      // nosemgrep: javascript.browser.security.insecure-innerhtml.insecure-innerhtml, javascript.browser.security.insecure-document-method.insecure-document-method
      elements.totals.all.lossProfit.innerHTML = formatLossProfit(allGl, allGainLossPct);
      const allGlLabel = elements.totals.all.lossProfit.parentElement && elements.totals.all.lossProfit.parentElement.querySelector('.total-label');
      if (allGlLabel) {
        allGlLabel.textContent = allGl > 0 ? 'Gain:' : allGl < 0 ? 'Loss:' : 'Gain/Loss:';
        allGlLabel.style.color = allGl > 0 ? 'var(--success)' : allGl < 0 ? 'var(--danger)' : '';
        allGlLabel.style.fontWeight = allGl !== 0 ? '600' : '';
      }
    }
    if (elements.totals.all.avgCostPerOz) {
      const avgCost = allTotals.totalWeight > 0 ? allTotals.totalPurchased / allTotals.totalWeight : 0;
      elements.totals.all.avgCostPerOz.textContent = formatCurrency(avgCost);
    }
  }
};

/**
 * Deletes inventory item at specified index after confirmation
 * 
 * @param {number} idx - Index of item to delete
 */
const deleteItem = async (idx) => {
  const item = inventory[idx];
  const itemLabel = item ? item.name : 'this item';
  const confirmed = typeof showAppConfirm === 'function'
    ? await showAppConfirm(`Delete ${itemLabel}?\n\nThis can be undone from the Activity Log.`, 'Delete Item')
    : confirm(`Delete ${itemLabel}?\n\nThis can be undone from the Activity Log.`);
  if (confirmed) {
    inventory.splice(idx, 1);
    saveInventory();
    renderTable();
    renderActiveFilters();
    if (item) logChange(item.name, 'Deleted', JSON.stringify(item), '', idx);

    // Clean up user images from IndexedDB (STAK-120)
    if (item?.uuid && window.imageCache?.isAvailable()) {
      window.imageCache.deleteUserImage(item.uuid).catch(err => {
        debugLog(`Failed to delete user images for deleted item: ${err}`);
      });
    }

    // Clean up item tags (STAK-126)
    if (item?.uuid && typeof deleteItemTags === 'function') {
      deleteItemTags(item.uuid);
    }
  }
};

/**
 * Opens modal to view and edit an item's notes
 *
 * @param {number} idx - Index of item whose notes to view/edit
 */
const showNotes = (idx) => {
  notesIndex = idx;
  const item = inventory[idx];
  
  // Add fallbacks and better error handling
  const textareaElement = elements.notesTextarea || document.getElementById('notesTextarea');
  const modalElement = elements.notesModal || document.getElementById('notesModal');
  
  if (textareaElement) {
    textareaElement.value = item.notes || '';
  } else {
    console.error('Notes textarea element not found');
  }
  
  if (modalElement) {
  if (window.openModalById) openModalById('notesModal');
  else modalElement.style.display = 'flex';
  } else {
    console.error('Notes modal element not found');
  }
  
  if (textareaElement && textareaElement.focus) {
    textareaElement.focus();
  }
};


/**
 * Prepares and displays edit modal for specified inventory item
 *
 * @param {number} idx - Index of item to edit
 */
const editItem = (idx, logIdx = null) => {
  editingIndex = idx;
  editingChangeLogIndex = logIdx;
  const item = inventory[idx];

  // Set modal to edit mode
  if (elements.itemModalTitle) elements.itemModalTitle.textContent = "Edit Inventory Item";
  if (elements.itemModalSubmit) elements.itemModalSubmit.textContent = "Save Changes";

  // Populate unified form fields
  elements.itemMetal.value = item.composition || item.metal;
  elements.itemName.value = item.name;
  elements.itemQty.value = item.qty;
  elements.itemType.value = item.type;

  // Weight: use real <select> instead of dataset.unit (BUG FIX)
  if (item.weightUnit === 'gb') {
    const denomSelect = elements.itemGbDenom || document.getElementById('itemGbDenom');
    elements.itemWeight.value = parseFloat(item.weight);
    elements.itemWeightUnit.value = 'gb';
    if (denomSelect) denomSelect.value = String(parseFloat(item.weight));
    if (typeof toggleGbDenomPicker === 'function') toggleGbDenomPicker();
  } else if (item.weight < 1) {
    const grams = oztToGrams(item.weight);
    // Show up to 4 decimal places for sub-gram precision, strip trailing zeros
    elements.itemWeight.value = parseFloat(grams.toFixed(4));
    elements.itemWeightUnit.value = 'g';
    if (typeof toggleGbDenomPicker === 'function') toggleGbDenomPicker();
  } else {
    elements.itemWeight.value = parseFloat(item.weight).toFixed(2);
    elements.itemWeightUnit.value = 'oz';
    if (typeof toggleGbDenomPicker === 'function') toggleGbDenomPicker();
  }

  // Convert stored USD values to display currency for the form (STACK-50)
  const fxRate = (typeof getExchangeRate === 'function') ? getExchangeRate() : 1;
  const displayPrice = item.price > 0 ? (fxRate !== 1 ? (item.price * fxRate).toFixed(2) : item.price) : '';
  const displayMv = item.marketValue > 0 ? (fxRate !== 1 ? (item.marketValue * fxRate).toFixed(2) : item.marketValue) : '';
  elements.itemPrice.value = displayPrice;
  if (elements.itemMarketValue) elements.itemMarketValue.value = displayMv;
  elements.purchaseLocation.value = item.purchaseLocation || '';
  elements.storageLocation.value = item.storageLocation && item.storageLocation !== 'Unknown' ? item.storageLocation : '';
  if (elements.itemSerialNumber) elements.itemSerialNumber.value = item.serialNumber || '';
  if (elements.itemNotes) elements.itemNotes.value = item.notes || '';
  elements.itemDate.value = item.date;
  // Reset spot lookup state for edit mode (STACK-49)
  if (elements.itemSpotPrice) elements.itemSpotPrice.value = '';
  if (elements.spotLookupBtn) elements.spotLookupBtn.disabled = !item.date;
  if (elements.itemCatalog) elements.itemCatalog.value = item.numistaId || '';
  if (elements.itemYear) elements.itemYear.value = item.year || item.issuedYear || '';
  if (elements.itemGrade) elements.itemGrade.value = item.grade || '';
  if (elements.itemGradingAuthority) elements.itemGradingAuthority.value = item.gradingAuthority || '';
  if (elements.itemCertNumber) elements.itemCertNumber.value = item.certNumber || '';
  if (elements.itemPcgsNumber) elements.itemPcgsNumber.value = item.pcgsNumber || '';
  if (elements.itemObverseImageUrl) elements.itemObverseImageUrl.value = item.obverseImageUrl || '';
  if (elements.itemReverseImageUrl) elements.itemReverseImageUrl.value = item.reverseImageUrl || '';
  if (elements.itemSerial) elements.itemSerial.value = item.serial;

  // Pre-fill purity: match a preset or show custom input
  const purityVal = parseFloat(item.purity) || 1.0;
  const puritySelect = elements.itemPuritySelect || document.getElementById('itemPuritySelect');
  const purityCustom = elements.purityCustomWrapper || document.getElementById('purityCustomWrapper');
  const purityInput = elements.itemPurity || document.getElementById('itemPurity');
  if (puritySelect) {
    const presetOption = Array.from(puritySelect.options).find(o => o.value !== 'custom' && parseFloat(o.value) === purityVal);
    if (presetOption) {
      puritySelect.value = presetOption.value;
      if (purityCustom) purityCustom.style.display = 'none';
      if (purityInput) purityInput.value = '';
    } else {
      puritySelect.value = 'custom';
      if (purityCustom) purityCustom.style.display = '';
      if (purityInput) purityInput.value = purityVal;
    }
  }

  // Show/hide PCGS verified icon next to Cert# label
  const certVerifiedIcon = document.getElementById('certVerifiedIcon');
  if (certVerifiedIcon) certVerifiedIcon.style.display = item.pcgsVerified ? 'inline-flex' : 'none';

  // Show price history link in edit mode (STAK-109)
  const retailHistoryLink = document.getElementById('retailPriceHistoryLink');
  if (retailHistoryLink) retailHistoryLink.style.display = 'inline';

  // Show/hide Undo button based on changelog context
  if (elements.undoChangeBtn) {
    elements.undoChangeBtn.style.display =
      logIdx !== null ? "inline-block" : "none";
  }

  // Update currency symbols in modal (STACK-50)
  if (typeof updateModalCurrencyUI === 'function') updateModalCurrencyUI();

  // Preload user images (obverse + reverse) into upload previews (STACK-32)
  if (typeof clearUploadState === 'function') clearUploadState();
  if (item.uuid && window.imageCache?.isAvailable()) {
    imageCache.getUserImage(item.uuid).then(rec => {
      if (!rec) return;
      // Preload obverse
      if (rec.obverse) {
        try {
          const url = URL.createObjectURL(rec.obverse);
          const previewContainer = document.getElementById('itemImagePreviewObv');
          const previewImg = document.getElementById('itemImagePreviewImgObv');
          const removeBtn = document.getElementById('itemImageRemoveBtnObv');
          if (previewImg) previewImg.src = url;
          if (previewContainer) previewContainer.style.display = 'block';
          if (removeBtn) removeBtn.style.display = '';
          if (typeof setEditPreviewUrl === 'function') setEditPreviewUrl(url, 'obverse');
        } catch { /* ignore */ }
      }
      // Preload reverse
      if (rec.reverse) {
        try {
          const url = URL.createObjectURL(rec.reverse);
          const previewContainer = document.getElementById('itemImagePreviewRev');
          const previewImg = document.getElementById('itemImagePreviewImgRev');
          const removeBtn = document.getElementById('itemImageRemoveBtnRev');
          if (previewImg) previewImg.src = url;
          if (previewContainer) previewContainer.style.display = 'block';
          if (removeBtn) removeBtn.style.display = '';
          if (typeof setEditPreviewUrl === 'function') setEditPreviewUrl(url, 'reverse');
        } catch { /* ignore */ }
      }
    }).catch(() => {});
  }

  // Open unified modal
  if (window.openModalById) openModalById('itemModal');
  else if (elements.itemModal) elements.itemModal.style.display = 'flex';
};

/**
 * Duplicates an inventory item by opening the add modal pre-filled with
 * the source item's fields. Date preserves the original purchase date, qty resets to 1.
 *
 * @param {number} idx - Index of item to duplicate
 */
const duplicateItem = (idx) => {
  const item = inventory[idx];

  // Stay in add mode — editingIndex remains null so submit creates a new record
  editingIndex = null;
  editingChangeLogIndex = null;

  // Set modal to add mode with "Duplicate" title
  if (elements.itemModalTitle) elements.itemModalTitle.textContent = "Duplicate Inventory Item";
  if (elements.itemModalSubmit) elements.itemModalSubmit.textContent = "Add to Inventory";
  if (elements.undoChangeBtn) elements.undoChangeBtn.style.display = "none";

  // Pre-fill from source item
  elements.itemMetal.value = item.composition || item.metal;
  elements.itemName.value = item.name;
  elements.itemQty.value = 1; // Reset qty to 1
  elements.itemType.value = item.type;

  // Weight: same conversion logic as editItem
  if (item.weightUnit === 'gb') {
    const denomSelect = elements.itemGbDenom || document.getElementById('itemGbDenom');
    elements.itemWeight.value = parseFloat(item.weight);
    elements.itemWeightUnit.value = 'gb';
    if (denomSelect) denomSelect.value = String(parseFloat(item.weight));
    if (typeof toggleGbDenomPicker === 'function') toggleGbDenomPicker();
  } else if (item.weight < 1) {
    const grams = oztToGrams(item.weight);
    elements.itemWeight.value = parseFloat(grams.toFixed(4));
    elements.itemWeightUnit.value = 'g';
    if (typeof toggleGbDenomPicker === 'function') toggleGbDenomPicker();
  } else {
    elements.itemWeight.value = parseFloat(item.weight).toFixed(2);
    elements.itemWeightUnit.value = 'oz';
    if (typeof toggleGbDenomPicker === 'function') toggleGbDenomPicker();
  }

  // Convert stored USD values to display currency for the form (STACK-50)
  const dupFxRate = (typeof getExchangeRate === 'function') ? getExchangeRate() : 1;
  const dupDisplayPrice = item.price > 0 ? (dupFxRate !== 1 ? (item.price * dupFxRate).toFixed(2) : item.price) : '';
  const dupDisplayMv = item.marketValue > 0 ? (dupFxRate !== 1 ? (item.marketValue * dupFxRate).toFixed(2) : item.marketValue) : '';
  elements.itemPrice.value = dupDisplayPrice;
  if (elements.itemMarketValue) elements.itemMarketValue.value = dupDisplayMv;
  elements.purchaseLocation.value = item.purchaseLocation || '';
  elements.storageLocation.value = item.storageLocation && item.storageLocation !== 'Unknown' ? item.storageLocation : '';
  if (elements.itemSerialNumber) elements.itemSerialNumber.value = item.serialNumber || '';
  if (elements.itemNotes) elements.itemNotes.value = item.notes || '';
  elements.itemDate.value = item.date || todayStr();
  if (elements.itemCatalog) elements.itemCatalog.value = item.numistaId || '';
  if (elements.itemYear) elements.itemYear.value = item.year || item.issuedYear || '';
  if (elements.itemGrade) elements.itemGrade.value = item.grade || '';
  if (elements.itemGradingAuthority) elements.itemGradingAuthority.value = item.gradingAuthority || '';
  if (elements.itemCertNumber) elements.itemCertNumber.value = item.certNumber || '';
  if (elements.itemPcgsNumber) elements.itemPcgsNumber.value = item.pcgsNumber || '';
  if (elements.itemSerial) elements.itemSerial.value = ''; // Serial should be unique per item

  // Pre-fill purity (same logic as editItem)
  const dupPurity = parseFloat(item.purity) || 1.0;
  const dupPuritySelect = elements.itemPuritySelect || document.getElementById('itemPuritySelect');
  const dupPurityCustom = elements.purityCustomWrapper || document.getElementById('purityCustomWrapper');
  const dupPurityInput = elements.itemPurity || document.getElementById('itemPurity');
  if (dupPuritySelect) {
    const presetOpt = Array.from(dupPuritySelect.options).find(o => o.value !== 'custom' && parseFloat(o.value) === dupPurity);
    if (presetOpt) {
      dupPuritySelect.value = presetOpt.value;
      if (dupPurityCustom) dupPurityCustom.style.display = 'none';
      if (dupPurityInput) dupPurityInput.value = '';
    } else {
      dupPuritySelect.value = 'custom';
      if (dupPurityCustom) dupPurityCustom.style.display = '';
      if (dupPurityInput) dupPurityInput.value = dupPurity;
    }
  }

  // Hide PCGS verified icon — duplicate is a new unverified item
  const certVerifiedIcon = document.getElementById('certVerifiedIcon');
  if (certVerifiedIcon) certVerifiedIcon.style.display = 'none';

  // Update currency symbols in modal (STACK-50)
  if (typeof updateModalCurrencyUI === 'function') updateModalCurrencyUI();

  // Open unified modal
  if (window.openModalById) openModalById('itemModal');
  else if (elements.itemModal) elements.itemModal.style.display = 'flex';
};

/**
 * Toggles price display between purchase price and market value
 * 
 * @param {number} idx - Index of item to toggle price view for
 */
/**
 * Legacy function kept for compatibility - no longer used
 * Market value now has its own dedicated column
 */
const togglePriceView = (idx) => {
  // Function kept for compatibility but no longer used
  console.warn('togglePriceView is deprecated - using separate columns now');
};

/**
 * Legacy function kept for compatibility - no longer used  
 * Market value now has its own dedicated column
 */
const toggleGlobalPriceView = () => {
  // Function kept for compatibility but no longer used
  console.warn('toggleGlobalPriceView is deprecated - using separate columns now');
};

// =============================================================================
// IMPORT/EXPORT FUNCTIONS
// =============================================================================

// Import progress utilities
const startImportProgress = (total) => {
  if (!elements.importProgress || !elements.importProgressText) return;
  elements.importProgress.max = total;
  elements.importProgress.value = 0;
  elements.importProgress.style.display = 'block';
  elements.importProgressText.style.display = 'block';
  elements.importProgressText.textContent = `0 / ${total} items imported`;
};

const updateImportProgress = (processed, imported, total) => {
  if (!elements.importProgress || !elements.importProgressText) return;
  elements.importProgress.value = processed;
  elements.importProgressText.textContent = `${imported} / ${total} items imported`;
};

const endImportProgress = () => {
  if (!elements.importProgress || !elements.importProgressText) return;
  elements.importProgress.style.display = 'none';
  elements.importProgressText.style.display = 'none';
};

/**
 * Imports inventory data from CSV file with comprehensive validation and error handling
 * 
 * @param {File} file - CSV file selected by user through file input
 * @param {boolean} [override=false] - Replace existing inventory instead of merging
 */
const importCsv = (file, override = false) => {
  if (typeof Papa === 'undefined') {
    alert('CSV library (PapaParse) failed to load. Please check your internet connection and reload the page.');
    return;
  }
  try {
    debugLog('importCsv start', file.name);
    Papa.parse(file, {
      header: true,
      skipEmptyLines: true,
      complete: function(results) {
        const imported = [];
        const totalRows = results.data.length;
        startImportProgress(totalRows);
        let processed = 0;
        let importedCount = 0;

        const supportedMetals = ['Silver', 'Gold', 'Platinum', 'Palladium'];
        const skippedNonPM = [];

        for (const row of results.data) {
          processed++;
          debugLog('importCsv row', processed, JSON.stringify(row));
          const compositionRaw = row['Composition'] || row['Metal'] || 'Silver';
          const composition = getCompositionFirstWords(compositionRaw);
          const metal = parseNumistaMetal(composition);

          // Skip non-precious-metal items
          if (!supportedMetals.includes(metal)) {
            const rowName = row['Name'] || row['name'] || `Row ${processed}`;
            skippedNonPM.push(`${rowName} (${compositionRaw})`);
            updateImportProgress(processed, importedCount, totalRows);
            continue;
          }

          const name = row['Name'] || row['name'];
          const qty = row['Qty'] || row['qty'] || 1;
          const type = normalizeType(row['Type'] || row['type']);
          const weight = row['Weight(oz)'] || row['weight'];
          const weightUnit = row['Weight Unit'] || row['weightUnit'] || 'oz';
          const priceStr = row['Purchase Price'] || row['price'];
          let price = typeof priceStr === 'string'
            ? parseFloat(priceStr.replace(/[^\d.-]+/g, ''))
            : parseFloat(priceStr);
          if (price < 0) price = 0;
          const purchaseLocation = row['Purchase Location'] || '';
          const storageLocation = row['Storage Location'] || '';
          const notes = row['Notes'] || '';
          const year = row['Year'] || row['year'] || row['issuedYear'] || '';
          const grade = row['Grade'] || row['grade'] || '';
          const gradingAuthority = row['Grading Authority'] || row['gradingAuthority'] || row['Authority'] || '';
          const certNumber = (row['Cert #'] || row['certNumber'] || row['Cert Number'] || '').toString();
          const date = parseDate(row['Date']);

          // Parse retail price from CSV (backward-compatible with legacy columns)
          const retailStr = row['Retail Price'] || row['Market Value'] || row['marketValue'] || '0';
          const marketValue = typeof retailStr === 'string'
            ? parseFloat(retailStr.replace(/[^\d.-]+/g, '')) || 0
            : parseFloat(retailStr) || 0;

          let spotPriceAtPurchase;
          if (row['Spot Price ($/oz)']) {
            const spotStr = row['Spot Price ($/oz)'].toString();
            spotPriceAtPurchase = parseFloat(spotStr.replace(/[^0-9.-]+/g, ''));
          } else if (row['spotPriceAtPurchase']) {
            spotPriceAtPurchase = parseFloat(row['spotPriceAtPurchase']);
          } else {
            spotPriceAtPurchase = 0;
          }

          const premiumPerOz = 0;
          const totalPremium = 0;

          const numistaRaw = (row['N#'] || row['Numista #'] || row['numistaId'] || '').toString();
          const numistaMatch = numistaRaw.match(/\d+/);
          const numistaId = numistaMatch ? numistaMatch[0] : '';
          const pcgsNumber = (row['PCGS #'] || row['PCGS Number'] || row['pcgsNumber'] || '').toString().trim();
          const purityRaw = row['Purity'] || row['Fineness'] || row['purity'] || '';
          const purity = parseFloat(purityRaw) || 1.0;
          const serialNumber = row['Serial Number'] || row['serialNumber'] || '';
          const serial = row['Serial'] || row['serial'] || getNextSerial();
          const uuid = row['UUID'] || row['uuid'] || generateUUID();
          const csvTags = (row['Tags'] || row['tags'] || '').trim();
          const obverseImageUrl = row['Obverse Image URL'] || row['obverseImageUrl'] || '';
          const reverseImageUrl = row['Reverse Image URL'] || row['reverseImageUrl'] || '';

          addCompositionOption(composition);

          const item = sanitizeImportedItem({
            metal,
            composition,
            name,
            qty,
            type,
            weight,
            weightUnit,
            price,
            marketValue,
            date,
            purchaseLocation,
            storageLocation,
            notes,
            year,
            grade,
            gradingAuthority,
            certNumber,
            pcgsNumber,
            purity,
            spotPriceAtPurchase,
            premiumPerOz,
            totalPremium,
            numistaId,
            serialNumber,
            serial,
            uuid,
            obverseImageUrl,
            reverseImageUrl
          });

          imported.push(item);

          // STAK-126: Import tags from CSV
          if (csvTags && typeof addItemTag === 'function') {
            csvTags.split(';').map(t => t.trim()).filter(Boolean).forEach(tag => {
              addItemTag(item.uuid, tag, false);
            });
          }

          importedCount++;
          updateImportProgress(processed, importedCount, totalRows);
        }

        // STAK-126: Persist any imported tags
        if (typeof saveItemTags === 'function') saveItemTags();

        endImportProgress();

        // Report skipped non-precious-metal items
        if (skippedNonPM.length > 0) {
          alert(`${skippedNonPM.length} item(s) skipped: no precious metal content\n\n${skippedNonPM.join('\n')}`);
        }

        if (imported.length === 0) return alert('No items to import.');

        const existingSerials = new Set(override ? [] : inventory.map(item => item.serial));
        const existingKeys = new Set(
          (override ? [] : inventory)
            .filter(item => item.numistaId)
            .map(item => `${item.numistaId}|${item.name}|${item.date}`)
        );
        const deduped = [];
        let duplicateCount = 0;

        for (const item of imported) {
          const key = item.numistaId ? `${item.numistaId}|${item.name}|${item.date}` : null;
          if (existingSerials.has(item.serial) || (key && existingKeys.has(key))) {
            duplicateCount++;
            continue;
          }
          existingSerials.add(item.serial);
          if (key) existingKeys.add(key);
          deduped.push(item);
        }

        if (duplicateCount > 0) {
          console.info(`${duplicateCount} duplicate items skipped during import.`);
        }

        if (deduped.length === 0) return alert('No items to import.');

        for (const item of deduped) {
          if (typeof registerName === "function") {
            registerName(item.name);
          }
        }

        if (override) {
          inventory = deduped;
        } else {
          inventory = inventory.concat(deduped);
        }

        // Synchronize all items with catalog manager
        inventory = catalogManager.syncInventory(inventory);

        saveInventory();
        renderTable();
        if (typeof renderActiveFilters === 'function') {
          renderActiveFilters();
        }
        if (typeof updateStorageStats === 'function') {
          updateStorageStats();
        }
        debugLog('importCsv complete', deduped.length, 'items added');
        if (localStorage.getItem('staktrakr.debug') && typeof window.showDebugModal === 'function') {
          showDebugModal();
        }
      },
      error: function(error) {
        endImportProgress();
        handleError(error, 'CSV import');
      }
    });
  } catch (error) {
    endImportProgress();
    handleError(error, 'CSV import initialization');
  }
};

/**
 * Imports inventory data from a Numista CSV export
 *
 * @param {File} file - CSV file from Numista
 * @param {boolean} [override=false] - Replace existing inventory instead of merging
 */
const importNumistaCsv = (file, override = false) => {
  if (typeof Papa === 'undefined') {
    alert('CSV library (PapaParse) failed to load. Please check your internet connection and reload the page.');
    return;
  }
  try {
    const reader = new FileReader();
    reader.onload = function(e) {
      try {
        const csvText = e.target.result;
        const results = Papa.parse(csvText, {
          header: true,
          skipEmptyLines: true,
          transformHeader: (h) => h.trim(), // Handle Numista headers with trailing spaces
        });
        const rawTable = results.data;
        const imported = [];
        const supportedMetals = ['Silver', 'Gold', 'Platinum', 'Palladium'];
        const skippedNonPM = [];
        const totalRows = rawTable.length;
        startImportProgress(totalRows);
        let processed = 0;
        let importedCount = 0;

        const getValue = (row, keys) => {
          for (const key of keys) {
            const foundKey = Object.keys(row).find(k => k.toLowerCase() === key.toLowerCase());
            if (foundKey) return row[foundKey];
          }
          return "";
        };

        for (const row of rawTable) {
          processed++;

          const numistaRaw = (getValue(row, ['N# number', 'N# number (with link)', 'Numista #', 'Numista number', 'Numista id']) || '').toString();
          const numistaMatch = numistaRaw.match(/\d+/);
          const numistaId = numistaMatch ? numistaMatch[0] : '';
          const title = (getValue(row, ['Title', 'Name']) || '').trim();
          const year = (getValue(row, ['Year', 'Date']) || '').trim();
          const name = year.length >= 4 ? `${title} ${year}`.trim() : title;
          const issuedYear = year.length >= 4 ? year : '';
          const compositionRaw = getValue(row, ['Composition', 'Metal']) || '';
          const composition = getCompositionFirstWords(compositionRaw);

          addCompositionOption(composition);

          let metal = parseNumistaMetal(composition);

          // Skip non-precious-metal items (Paper, Alloy, Copper, Nickel, etc.)
          if (!supportedMetals.includes(metal)) {
            skippedNonPM.push(`${name || `Row ${processed}`} (${compositionRaw || 'unknown'})`);
            updateImportProgress(processed, importedCount, totalRows);
            continue;
          }

          const qty = parseInt(getValue(row, ['Quantity', 'Qty', 'Quantity owned']) || 1, 10);

          let type = normalizeType(mapNumistaType(getValue(row, ['Type']) || ''));

          const weightCols = Object.keys(row).filter(k => { const key = k.toLowerCase(); return key.includes('weight') || key.includes('mass'); });
          let weightGrams = 0;
          for (const col of weightCols) {
            const val = parseFloat(String(row[col]).replace(/[^0-9.]/g, ''));
            if (!isNaN(val)) weightGrams = Math.max(weightGrams, val);
          }
          const weight = parseFloat(gramsToOzt(weightGrams).toFixed(6));

          const priceKey = Object.keys(row).find(k => /^(buying price|purchase price|price paid)/i.test(k));
          const estimateKey = Object.keys(row).find(k => /^estimate/i.test(k));
          const parsePriceField = (key) => {
            const rawVal = String(row[key] ?? '').trim();
            const valueCurrency = detectCurrency(rawVal);
            const headerCurrencyMatch = key.match(/\(([^)]+)\)/);
            const headerCurrency = headerCurrencyMatch ? headerCurrencyMatch[1] : displayCurrency;
            const currency = valueCurrency || headerCurrency;
            const amount = parseFloat(rawVal.replace(/[^0-9.\-]/g, ''));
            return isNaN(amount) ? 0 : convertToUsd(amount, currency);
          };
          
          let purchasePrice = 0;
          let marketValue = 0;
          
          // Set purchase price from buying price
          if (priceKey) {
            purchasePrice = parsePriceField(priceKey);
          }
          
          // Set market value from estimate price
          if (estimateKey) {
            marketValue = parsePriceField(estimateKey);
          }
          
          // If no market value but we have buying price, use buying price for both
          if (marketValue === 0 && purchasePrice > 0) {
            marketValue = purchasePrice;
          }
          
          // If no purchase price but we have estimate, use estimate for both
          if (purchasePrice === 0 && marketValue > 0) {
            purchasePrice = marketValue;
          }

          const purchaseLocRaw = getValue(row, ['Acquisition place', 'Acquired from', 'Purchase place']);
          const purchaseLocation = purchaseLocRaw && purchaseLocRaw.trim() ? purchaseLocRaw.trim() : '—';
          const storageLocRaw = getValue(row, ['Storage location', 'Stored at', 'Storage place']);
          const storageLocation = storageLocRaw && storageLocRaw.trim() ? storageLocRaw.trim() : '—';

          const dateStrRaw = getValue(row, ['Acquisition date', 'Date acquired', 'Date']);
          const dateStr = dateStrRaw && dateStrRaw.trim() ? dateStrRaw.trim() : '—';
          const date = parseDate(dateStr);

          const baseNote = (getValue(row, ['Note', 'Notes']) || '').trim();
          const privateComment = (getValue(row, ['Private comment']) || '').trim();
          const publicComment = (getValue(row, ['Public comment']) || '').trim();
          const otherComment = (getValue(row, ['Comment']) || '').trim();
          const noteParts = [];
          if (baseNote) noteParts.push(baseNote);
          if (privateComment) noteParts.push(`Private Comment: ${privateComment}`);
          if (publicComment) noteParts.push(`Public Comment: ${publicComment}`);
          if (otherComment) noteParts.push(`Comment: ${otherComment}`);
          const notes = noteParts.join('\n');

          const markdownLines = Object.entries(row)
            .filter(([, v]) => v && String(v).trim())
            .map(([k, v]) => `- **${k.trim()}**: ${String(v).trim()}`);
          const markdownNote = markdownLines.length
            ? `### Numista Import Data\n${markdownLines.join('\n')}`
            : '';
          const finalNotes = markdownNote
            ? notes ? `${notes}\n\n${markdownNote}` : markdownNote
            : notes;

          const spotPriceAtPurchase = 0;
          const premiumPerOz = 0;
          const totalPremium = 0;
          const serial = getNextSerial();
          const uuid = generateUUID();

          const item = sanitizeImportedItem({
            metal,
            composition,
            name,
            qty,
            type,
            weight,
            price: purchasePrice,
            purchasePrice,
            marketValue,
            date,
            purchaseLocation,
            storageLocation,
            notes: finalNotes,
            spotPriceAtPurchase,
            premiumPerOz,
            totalPremium,
            numistaId,
            year: issuedYear,
            grade: '',
            gradingAuthority: '',
            certNumber: '',
            pcgsNumber: '',
            serial,
            uuid
          });

          imported.push(item);
          importedCount++;
          updateImportProgress(processed, importedCount, totalRows);
        }

        endImportProgress();

        // Report skipped non-precious-metal items
        if (skippedNonPM.length > 0) {
          alert(`${skippedNonPM.length} item(s) skipped: no precious metal content\n\n${skippedNonPM.join('\n')}`);
        }

        if (imported.length === 0) return alert('No items to import.');

        const existingSerials = new Set(override ? [] : inventory.map(item => item.serial));
        const existingKeys = new Set(
          (override ? [] : inventory)
            .filter(item => item.numistaId)
            .map(item => `${item.numistaId}|${item.name}|${item.date}`)
        );
        const deduped = [];
        let duplicateCount = 0;

        for (const item of imported) {
          const key = item.numistaId ? `${item.numistaId}|${item.name}|${item.date}` : null;
          if (existingSerials.has(item.serial) || (key && existingKeys.has(key))) {
            duplicateCount++;
            continue;
          }
          existingSerials.add(item.serial);
          if (key) existingKeys.add(key);
          deduped.push(item);
        }

        if (duplicateCount > 0) {
          console.info(`${duplicateCount} duplicate items skipped during import.`);
        }

        if (deduped.length === 0) return alert('No items to import.');

        for (const item of deduped) {
          if (typeof registerName === "function") {
            registerName(item.name);
          }
        }

        if (override) {
          inventory = deduped;
        } else {
          inventory = inventory.concat(deduped);
        }

        // Synchronize all items with catalog manager
        inventory = catalogManager.syncInventory(inventory);

        saveInventory();
        renderTable();
        if (typeof renderActiveFilters === 'function') {
          renderActiveFilters();
        }
        if (typeof updateStorageStats === 'function') {
          updateStorageStats();
        }
      } catch (error) {
        endImportProgress();
        handleError(error, 'Numista CSV import');
      }
    };
    reader.onerror = (error) => {
      endImportProgress();
      handleError(error, 'Numista CSV import');
    };
    reader.readAsText(file);
  } catch (error) {
    endImportProgress();
    handleError(error, 'Numista CSV import initialization');
  }
};

/**
 * Exports inventory using Numista-compatible column layout
 */
const exportNumistaCsv = () => {
  const timestamp = new Date().toISOString().slice(0,10).replace(/-/g,'');
  const headers = [
    "N# number",
    "Title",
    "Year",
    "Metal",
    "Quantity",
    "Type",
    "Weight (g)",
    `Buying price (${displayCurrency})`,
    "Acquisition place",
    "Storage location",
    "Acquisition date",
    "Note",
    "Private comment",
    "Public comment",
    "Comment",
  ];

  const sortedInventory = sortInventoryByDateNewestFirst();
  const rows = [];

  for (const item of sortedInventory) {
    const year = item.year || item.issuedYear || '';
    let title = item.name || '';
    if (year) {
      const yearRegex = new RegExp(`\\s*${String(year).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`);
      title = title.replace(yearRegex, '').trim();
    }

    const weightGrams = parseFloat(item.weight)
      ? parseFloat(item.weight) * 31.1034768
      : 0;
    const purchasePrice = item.purchasePrice ?? item.price;

    let baseNote = '';
    let privateComment = '';
    let publicComment = '';
    let otherComment = '';
    if (item.notes) {
      const lines = String(item.notes).split(/\n/);
      for (const line of lines) {
        if (/^\s*Private Comment:/i.test(line)) {
          privateComment = line.replace(/^\s*Private Comment:\s*/i, '').trim();
        } else if (/^\s*Public Comment:/i.test(line)) {
          publicComment = line.replace(/^\s*Public Comment:\s*/i, '').trim();
        } else if (/^\s*Comment:/i.test(line)) {
          otherComment = line.replace(/^\s*Comment:\s*/i, '').trim();
        } else {
          baseNote = baseNote ? `${baseNote}\n${line}` : line;
        }
      }
    }

    rows.push([
      item.numistaId || '',
      title,
      year,
      item.metal || '',
      item.qty || '',
      item.type || '',
      weightGrams ? weightGrams.toFixed(2) : '',
      purchasePrice != null ? Number(purchasePrice).toFixed(2) : '',
      item.purchaseLocation || '',
      item.storageLocation || '',
      item.date || '',
      baseNote,
      privateComment,
      publicComment,
      otherComment,
    ]);
  }

  const csv = Papa.unparse([headers, ...rows]);
  const blob = new Blob([csv], { type: 'text/csv' });
  const url = URL.createObjectURL(blob);

  const a = document.createElement('a');
  a.href = url;
  a.download = `numista_export_${timestamp}.csv`;
  document.body.appendChild(a);
  a.click();
  document.body.removeChild(a);
  URL.revokeObjectURL(url);
};

/**
 * Exports current inventory to CSV format
 */
const exportCsv = () => {
  if (typeof Papa === 'undefined') {
    alert('CSV library (PapaParse) failed to load. Please check your internet connection and reload the page.');
    return;
  }
  debugLog('exportCsv start', inventory.length, 'items');
  const timestamp = new Date().toISOString().slice(0,10).replace(/-/g,'');
  const headers = [
    "Date","Metal","Type","Name","Year","Qty","Weight(oz)","Weight Unit","Purity",
    "Purchase Price","Melt Value","Retail Price","Gain/Loss",
    "Purchase Location","N#","PCGS #","Grade","Grading Authority","Cert #","Serial Number","Notes","UUID",
    "Obverse Image URL","Reverse Image URL"
  ];

  const sortedInventory = sortInventoryByDateNewestFirst();
  const rows = [];

  for (const i of sortedInventory) {
    const currentSpot = spotPrices[i.metal.toLowerCase()] || 0;
    const valuation = (typeof computeItemValuation === 'function')
      ? computeItemValuation(i, currentSpot)
      : null;
    const purchasePrice = valuation ? valuation.purchasePrice : (typeof i.price === 'number' ? i.price : parseFloat(i.price) || 0);
    const meltValue = valuation ? valuation.meltValue : computeMeltValue(i, currentSpot);
    const gainLoss = valuation ? valuation.gainLoss : null;

    rows.push([
      i.date,
      i.metal || 'Silver',
      i.type,
      i.name,
      i.year || '',
      i.qty,
      parseFloat(i.weight).toFixed(4),
      i.weightUnit || 'oz',
      parseFloat(i.purity) || 1.0,
      formatCurrency(purchasePrice),
      currentSpot > 0 ? formatCurrency(meltValue) : '—',
      formatCurrency(i.marketValue || 0),
      gainLoss !== null ? formatCurrency(gainLoss) : '—',
      i.purchaseLocation,
      i.numistaId || '',
      i.pcgsNumber || '',
      i.grade || '',
      i.gradingAuthority || '',
      i.certNumber || '',
      i.serialNumber || '',
      i.notes || '',
      i.uuid || '',
      i.obverseImageUrl || '',
      i.reverseImageUrl || ''
    ]);
  }

  const csv = Papa.unparse([headers, ...rows]);
  const blob = new Blob([csv], { type: "text/csv" });
  const url = URL.createObjectURL(blob);

  const a = document.createElement('a');
  a.href = url;
  a.download = `metal_inventory_${timestamp}.csv`;
  document.body.appendChild(a);
  a.click();
  a.remove();
  URL.revokeObjectURL(url);
  debugLog('exportCsv complete');
};

/**
 * Imports inventory data from JSON file
 *
 * @param {File} file - JSON file to import
 * @param {boolean} [override=false] - Replace existing inventory instead of merging
 */
const importJson = (file, override = false) => {
  const reader = new FileReader();
  debugLog('importJson start', file.name);

  reader.onload = function(e) {
    try {
      const data = JSON.parse(e.target.result);

      // Validate data structure
      if (!Array.isArray(data)) {
        return alert("Invalid JSON format. Expected an array of inventory items.");
      }

      // Process each item
      const imported = [];
      const skippedDetails = [];
      const skippedNonPM = [];
      const supportedMetals = ['Silver', 'Gold', 'Platinum', 'Palladium'];
      const totalItems = data.length;
      startImportProgress(totalItems);
      let processed = 0;
      let importedCount = 0;

      const pendingTagsByUuid = new Map();

      for (const [index, raw] of data.entries()) {
        processed++;
        debugLog('importJson item', index + 1, JSON.stringify(raw));

        const compositionRaw = raw.composition || raw.metal || 'Silver';
        const composition = getCompositionFirstWords(compositionRaw);
        const metal = parseNumistaMetal(composition);

        // Skip non-precious-metal items
        if (!supportedMetals.includes(metal)) {
          const itemName = raw.name || `Item ${index + 1}`;
          skippedNonPM.push(`${itemName} (${compositionRaw})`);
          updateImportProgress(processed, importedCount, totalItems);
          continue;
        }

        const name = raw.name || '';
        const qty = parseInt(raw.qty ?? raw.quantity ?? 1, 10);
        const type = normalizeType(raw.type || raw.itemType || 'Other');
        const weight = parseFloat(raw.weight ?? raw.weightOz ?? 0);
        const weightUnit = raw.weightUnit || raw['Weight Unit'] || 'oz';
        const purity = parseFloat(raw.purity ?? raw['Purity'] ?? raw['Fineness'] ?? 1.0) || 1.0;
        const priceStr = raw.price ?? raw.purchasePrice ?? 0;
        let price = typeof priceStr === 'string'
          ? parseFloat(priceStr.replace(/[^\d.-]+/g, ''))
          : parseFloat(priceStr);
        if (price < 0) price = 0;
        const purchaseLocation = raw.purchaseLocation || '';
        const storageLocation = raw.storageLocation || 'Unknown';
        const notes = raw.notes || '';
        const year = (raw.year || raw.issuedYear || '').toString().trim();
        const grade = (raw.grade || '').toString().trim();
        const gradingAuthority = (raw.gradingAuthority || raw.authority || '').toString().trim();
        const certNumber = (raw.certNumber || '').toString().trim();
        const pcgsNumber = (raw.pcgsNumber || raw['PCGS #'] || raw['PCGS Number'] || '').toString().trim();
        const pcgsVerified = raw.pcgsVerified || false;
        const date = parseDate(raw.date);

        // Parse marketValue (retail price), backward-compatible with legacy fields
        const marketValue = parseFloat(raw.marketValue ?? raw.retailPrice ?? 0) || 0;

        // Legacy field support for backward compatibility
        let spotPriceAtPurchase;
        if (raw.spotPriceAtPurchase) {
          spotPriceAtPurchase = parseFloat(raw.spotPriceAtPurchase);
        } else if (raw.spotPrice || raw.spot) {
          spotPriceAtPurchase = parseFloat(raw.spotPrice || raw.spot);
        } else {
          spotPriceAtPurchase = 0;
        }

        const premiumPerOz = 0;
        const totalPremium = 0;

        const numistaRaw = (raw.numistaId || raw.numista || raw['N#'] || '').toString();
        const numistaMatch = numistaRaw.match(/\d+/);
        const numistaId = numistaMatch ? numistaMatch[0] : '';
        const serial = raw.serial || getNextSerial();
        const uuid = raw.uuid || generateUUID();
        const obverseImageUrl = raw.obverseImageUrl || raw['Obverse Image URL'] || '';
        const reverseImageUrl = raw.reverseImageUrl || raw['Reverse Image URL'] || '';

        const processedItem = sanitizeImportedItem({
          metal,
          composition,
          name,
          qty,
          type,
          weight,
          weightUnit,
          price,
          marketValue,
          date,
          purchaseLocation,
          storageLocation,
          notes,
          spotPriceAtPurchase,
          premiumPerOz,
          totalPremium,
          numistaId,
          year,
          grade,
          gradingAuthority,
          certNumber,
          pcgsNumber,
          pcgsVerified,
          purity,
          serial,
          uuid,
          obverseImageUrl,
          reverseImageUrl
        });

        const validation = validateInventoryItem(processedItem);
        if (!validation.isValid) {
          const reason = validation.errors.join(', ');
          skippedDetails.push(`Item ${index + 1}: ${reason}`);
          updateImportProgress(processed, importedCount, totalItems);
          continue;
        }

        addCompositionOption(composition);
        imported.push(processedItem);

        // STAK-126: Import tags from JSON if present
        if (typeof addItemTag === 'function') {
          const jsonTags = raw.tags;
          let pendingTags = [];
          if (Array.isArray(jsonTags)) {
            pendingTags = jsonTags.map(tag => String(tag).trim()).filter(Boolean);
          } else if (typeof jsonTags === 'string' && jsonTags.trim()) {
            pendingTags = jsonTags.split(';').map(t => t.trim()).filter(Boolean);
          }
          if (pendingTags.length > 0) {
            const existing = pendingTagsByUuid.get(processedItem.uuid) || [];
            pendingTagsByUuid.set(processedItem.uuid, [...new Set([...existing, ...pendingTags])]);
          }
        }

        importedCount++;
        updateImportProgress(processed, importedCount, totalItems);
      }

      endImportProgress();

      // Report skipped non-precious-metal items
      if (skippedNonPM.length > 0) {
        alert(`${skippedNonPM.length} item(s) skipped: no precious metal content\n\n${skippedNonPM.join('\n')}`);
      }

      if (skippedDetails.length > 0) {
        alert('Skipped entries:\n' + skippedDetails.join('\n'));
      }

      if (imported.length === 0) {
        return alert("No valid items found in JSON file.");
      }

      const existingSerials = new Set(override ? [] : inventory.map(item => item.serial));
      const existingKeys = new Set(
        (override ? [] : inventory)
          .filter(item => item.numistaId)
          .map(item => `${item.numistaId}|${item.name}|${item.date}`)
      );
      const deduped = [];
      let duplicateCount = 0;

      for (const item of imported) {
        const key = item.numistaId ? `${item.numistaId}|${item.name}|${item.date}` : null;
        if (existingSerials.has(item.serial) || (key && existingKeys.has(key))) {
          duplicateCount++;
          continue;
        }
        existingSerials.add(item.serial);
        if (key) existingKeys.add(key);
        deduped.push(item);
      }

      if (duplicateCount > 0) {
        console.info(`${duplicateCount} duplicate items skipped during import.`);
      }

      if (deduped.length === 0) {
        return alert('No items to import.');
      }

      if (typeof addItemTag === 'function') {
        for (const item of deduped) {
          const pendingTags = pendingTagsByUuid.get(item.uuid);
          if (pendingTags && pendingTags.length) {
            pendingTags.forEach(tag => addItemTag(item.uuid, tag, false));
          }
        }
        if (typeof saveItemTags === 'function') saveItemTags();
      }

      for (const item of deduped) {
        if (typeof registerName === "function") {
          registerName(item.name);
        }
      }

      if (override) {
        inventory = deduped;
      } else {
        inventory = inventory.concat(deduped);
      }

      // Synchronize all items with catalog manager
      inventory = catalogManager.syncInventory(inventory);

      saveInventory();
      renderTable();
      if (typeof renderActiveFilters === 'function') {
        renderActiveFilters();
      }
      if (typeof updateStorageStats === "function") {
        updateStorageStats();
      }
      debugLog('importJson complete', deduped.length, 'items added');
      if (localStorage.getItem('staktrakr.debug') && typeof window.showDebugModal === 'function') {
        showDebugModal();
      }
    } catch (error) {
      endImportProgress();
      alert("Error parsing JSON file: " + error.message);
    }
  };

  reader.readAsText(file);
};

/**
 * Exports current inventory to JSON format
 */
const exportJson = () => {
  debugLog('exportJson start', inventory.length, 'items');
  const timestamp = new Date().toISOString().slice(0,10).replace(/-/g,'');

  const sortedInventory = sortInventoryByDateNewestFirst();

  const exportData = sortedInventory.map(item => ({
    date: item.date,
    metal: item.metal,
    type: item.type,
    name: item.name,
    year: item.year || '',
    qty: item.qty,
    weight: item.weight,
    weightUnit: item.weightUnit || 'oz',
    purity: parseFloat(item.purity) || 1.0,
    price: item.price,
    marketValue: item.marketValue || 0,
    purchaseLocation: item.purchaseLocation,
    storageLocation: item.storageLocation,
    notes: item.notes,
    numistaId: item.numistaId,
    grade: item.grade || '',
    gradingAuthority: item.gradingAuthority || '',
    certNumber: item.certNumber || '',
    serialNumber: item.serialNumber || '',
    pcgsNumber: item.pcgsNumber || '',
    pcgsVerified: item.pcgsVerified || false,
    serial: item.serial,
    uuid: item.uuid,
    obverseImageUrl: item.obverseImageUrl || '',
    reverseImageUrl: item.reverseImageUrl || '',
    // Legacy fields preserved for backward compatibility
    spotPriceAtPurchase: item.spotPriceAtPurchase,
    composition: item.composition
  }));

  const json = JSON.stringify(exportData, null, 2);
  const blob = new Blob([json], { type: "application/json" });
  const url = URL.createObjectURL(blob);

  const a = document.createElement('a');
  a.href = url;
  a.download = `metal_inventory_${timestamp}.json`;
  document.body.appendChild(a);
  a.click();
  a.remove();
  debugLog('exportJson complete');
};

/**
 * Exports current inventory to PDF format
 */
const exportPdf = () => {
  if (!window.jspdf || !window.jspdf.jsPDF) {
    alert('PDF library (jsPDF) failed to load. Please check your internet connection and reload the page.');
    return;
  }
  const { jsPDF } = window.jspdf;
  const doc = new jsPDF('landscape');

  // Sort inventory by date (newest first) for export
  const sortedInventory = sortInventoryByDateNewestFirst();

  // Add title
  doc.setFontSize(16);
  doc.text("StakTrakr", 14, 15);

  // Add date
  doc.setFontSize(10);
  doc.text(`Exported: ${typeof formatTimestamp === 'function' ? formatTimestamp(new Date()) : new Date().toLocaleString()}`, 14, 22);

  // Prepare table data with computed portfolio columns
  const tableData = sortedInventory.map(item => {
    const currentSpot = spotPrices[item.metal.toLowerCase()] || 0;
    const valuation = (typeof computeItemValuation === 'function')
      ? computeItemValuation(item, currentSpot)
      : null;
    const purchasePrice = valuation ? valuation.purchasePrice : (typeof item.price === 'number' ? item.price : parseFloat(item.price) || 0);
    const meltValue = valuation ? valuation.meltValue : computeMeltValue(item, currentSpot);
    const retailTotal = valuation ? valuation.retailTotal : meltValue;
    const gainLoss = valuation ? valuation.gainLoss : null;

    return [
      item.date,
      item.metal,
      item.type,
      item.name,
      item.qty,
      formatWeight(item.weight, item.weightUnit),
      parseFloat(item.purity) || 1.0,
      formatCurrency(purchasePrice),
      currentSpot > 0 ? formatCurrency(meltValue) : '—',
      formatCurrency(retailTotal),
      gainLoss !== null ? formatCurrency(gainLoss) : '—',
      item.purchaseLocation,
      item.numistaId || '',
      item.pcgsNumber || '',
      item.grade || '',
      item.gradingAuthority || '',
      item.certNumber || '',
      item.notes || '',
      (item.uuid || '').slice(0, 8)
    ];
  });

  // Add table
  doc.autoTable({
    head: [['Date', 'Metal', 'Type', 'Name', 'Qty', 'Weight', 'Purity', 'Purchase',
            'Melt Value', 'Retail', 'Gain/Loss', 'Location', 'N#', 'PCGS#', 'Grade', 'Auth', 'Cert#', 'Notes', 'UUID']],
    body: tableData,
    startY: 30,
    theme: 'striped',
    styles: { fontSize: 7 },
    headStyles: { fillColor: [25, 118, 210] }
  });

  // Add totals
  const finalY = doc.lastAutoTable.finalY || 30;

  // Helper to safely read element text
  const txt = (el) => (el && el.textContent) || '—';

  // Add totals section
  doc.setFontSize(12);
  doc.text("Portfolio Summary", 14, finalY + 10);

  // Silver Totals
  doc.setFontSize(10);
  doc.text("Silver:", 14, finalY + 16);
  doc.text(`Items: ${txt(elements.totals.silver.items)}`, 25, finalY + 22);
  doc.text(`Weight: ${txt(elements.totals.silver.weight)} oz`, 25, finalY + 28);
  doc.text(`Purchase: ${txt(elements.totals.silver.purchased)}`, 25, finalY + 34);
  doc.text(`Melt Value: ${txt(elements.totals.silver.value)}`, 25, finalY + 40);
  doc.text(`Retail: ${txt(elements.totals.silver.retailValue)}`, 25, finalY + 46);
  doc.text(`Gain/Loss: ${txt(elements.totals.silver.lossProfit)}`, 25, finalY + 52);

  // Gold Totals
  doc.text("Gold:", 100, finalY + 16);
  doc.text(`Items: ${txt(elements.totals.gold.items)}`, 111, finalY + 22);
  doc.text(`Weight: ${txt(elements.totals.gold.weight)} oz`, 111, finalY + 28);
  doc.text(`Purchase: ${txt(elements.totals.gold.purchased)}`, 111, finalY + 34);
  doc.text(`Melt Value: ${txt(elements.totals.gold.value)}`, 111, finalY + 40);
  doc.text(`Retail: ${txt(elements.totals.gold.retailValue)}`, 111, finalY + 46);
  doc.text(`Gain/Loss: ${txt(elements.totals.gold.lossProfit)}`, 111, finalY + 52);

  // Platinum Totals
  doc.text("Platinum:", 186, finalY + 16);
  doc.text(`Items: ${txt(elements.totals.platinum.items)}`, 197, finalY + 22);
  doc.text(`Weight: ${txt(elements.totals.platinum.weight)} oz`, 197, finalY + 28);
  doc.text(`Purchase: ${txt(elements.totals.platinum.purchased)}`, 197, finalY + 34);
  doc.text(`Melt Value: ${txt(elements.totals.platinum.value)}`, 197, finalY + 40);
  doc.text(`Retail: ${txt(elements.totals.platinum.retailValue)}`, 197, finalY + 46);
  doc.text(`Gain/Loss: ${txt(elements.totals.platinum.lossProfit)}`, 197, finalY + 52);

  // Palladium Totals
  doc.text("Palladium:", 14, finalY + 60);
  doc.text(`Items: ${txt(elements.totals.palladium.items)}`, 25, finalY + 66);
  doc.text(`Weight: ${txt(elements.totals.palladium.weight)} oz`, 25, finalY + 72);
  doc.text(`Purchase: ${txt(elements.totals.palladium.purchased)}`, 25, finalY + 78);
  doc.text(`Melt Value: ${txt(elements.totals.palladium.value)}`, 25, finalY + 84);
  doc.text(`Retail: ${txt(elements.totals.palladium.retailValue)}`, 25, finalY + 90);
  doc.text(`Gain/Loss: ${txt(elements.totals.palladium.lossProfit)}`, 25, finalY + 96);

  // Save PDF
  doc.save(`metal_inventory_${new Date().toISOString().slice(0,10).replace(/-/g,'')}.pdf`);
};
// =============================================================================
// Expose inventory actions globally for inline event handlers
window.importCsv = importCsv;
window.exportCsv = exportCsv;
window.importJson = importJson;
window.exportJson = exportJson;
window.exportPdf = exportPdf;
window.updateSummary = updateSummary;
window.togglePriceView = togglePriceView;
window.toggleGlobalPriceView = toggleGlobalPriceView;
window.editItem = editItem;
window.duplicateItem = duplicateItem;
window.deleteItem = deleteItem;
window.showNotes = showNotes;

/**
 * Opens a read-only notes viewer for the given inventory index.
 * @param {number} idx - Inventory array index
 */
const showNotesView = (idx) => {
  const item = inventory[idx];
  if (!item) return;
  const titleEl = document.getElementById('notesViewTitle');
  const contentEl = document.getElementById('notesViewContent');
  const editBtn = document.getElementById('notesViewEditBtn');
  if (!contentEl) return;

  if (titleEl) titleEl.textContent = item.name ? `Notes — ${item.name}` : 'Notes';
  contentEl.textContent = item.notes || '(no notes)';

  // Wire edit button to open the full item edit modal
  if (editBtn) {
    editBtn.onclick = () => {
      closeModalById('notesViewModal');
      editItem(idx);
    };
  }

  openModalById('notesViewModal');
};
window.showNotesView = showNotesView;

/**
 * Delegated click handler for inline tag interactions.
 * Uses data attributes and closest() to prevent XSS
 * when item names contain quotes or special characters.
 */
document.addEventListener('click', (e) => {
  // Notes indicator click → view notes (shift+click → edit item)
  const notesInd = e.target.closest('.notes-indicator');
  if (notesInd) {
    e.preventDefault();
    e.stopPropagation();
    const tr = notesInd.closest('tr[data-idx]');
    if (!tr) return;
    const idx = parseInt(tr.dataset.idx, 10);
    if (isNaN(idx)) return;
    if (e.shiftKey) {
      editItem(idx);
    } else {
      showNotesView(idx);
    }
    return;
  }

  // PCGS verify button click → call PCGS API for cert verification
  const verifyBtn = e.target.closest('.pcgs-verify-btn');
  if (verifyBtn) {
    e.preventDefault();
    e.stopPropagation();
    const certNum = verifyBtn.dataset.certNumber || '';
    if (!certNum || typeof verifyPcgsCert !== 'function') return;

    const tr = verifyBtn.closest('tr[data-idx]');
    const idx = tr ? parseInt(tr.dataset.idx, 10) : -1;

    verifyBtn.classList.add('pcgs-verifying');
    verifyBtn.title = 'Verifying...';

    verifyPcgsCert(certNum).then(result => {
      verifyBtn.classList.remove('pcgs-verifying');
      if (result.verified) {
        verifyBtn.classList.add('pcgs-verified');
        if (idx >= 0 && inventory[idx]) {
          inventory[idx].pcgsVerified = true;
          saveInventory();
        }
        const parts = [];
        if (result.grade) parts.push(`Grade: ${result.grade}`);
        if (result.population) parts.push(`Pop: ${result.population}`);
        if (result.popHigher) parts.push(`Pop Higher: ${result.popHigher}`);
        if (result.priceGuide) parts.push(`Price Guide: $${Number(result.priceGuide).toLocaleString()}`);
        verifyBtn.title = `Verified — ${parts.join(' | ')}`;
      } else {
        verifyBtn.title = result.error || 'Verification failed';
        verifyBtn.classList.add('pcgs-verify-failed');
        setTimeout(() => verifyBtn.classList.remove('pcgs-verify-failed'), 3000);
      }
    });
    return;
  }

  // Numista N# tag click → open Numista in popup window
  const numistaTag = e.target.closest('.numista-tag');
  if (numistaTag) {
    e.preventDefault();
    e.stopPropagation();
    const nId = numistaTag.dataset.numistaId;
    const coinName = numistaTag.dataset.coinName || '';
    if (nId && typeof openNumistaModal === 'function') {
      openNumistaModal(nId, coinName);
    }
    return;
  }

  // PCGS# tag click → open PCGS CoinFacts in popup window
  const pcgsTagEl = e.target.closest('.pcgs-tag');
  if (pcgsTagEl) {
    e.preventDefault();
    e.stopPropagation();
    const pcgsNo = pcgsTagEl.dataset.pcgsNumber || '';
    const gradeNum = (pcgsTagEl.dataset.grade || '').match(/\d+/)?.[0] || '';
    if (pcgsNo) {
      const url = `https://www.pcgs.com/coinfacts/coin/detail/${encodeURIComponent(pcgsNo)}/${encodeURIComponent(gradeNum)}`;
      const popup = window.open(url, `pcgs_${pcgsNo}`,
        'width=1250,height=800,scrollbars=yes,resizable=yes,toolbar=no,location=no,menubar=no,status=no');
      if (!popup) {
        alert(`Popup blocked! Please allow popups or manually visit:\n${url}`);
      } else {
        popup.focus();
      }
    }
    return;
  }

  // Grade tag click → open cert verification URL
  const gradeTag = e.target.closest('.grade-tag[data-clickable="true"]');
  if (gradeTag) {
    e.preventDefault();
    e.stopPropagation();
    const authority = gradeTag.dataset.authority || '';
    const certNum = gradeTag.dataset.certNumber || '';
    if (authority && typeof CERT_LOOKUP_URLS !== 'undefined' && CERT_LOOKUP_URLS[authority]) {
      let url = CERT_LOOKUP_URLS[authority].replaceAll('{certNumber}', encodeURIComponent(certNum));
      const gradeNum = (gradeTag.dataset.grade || '').match(/\d+/)?.[0] || '';
      url = url.replace('{grade}', encodeURIComponent(gradeNum));
      const popup = window.open(url, `cert_${authority}_${certNum || Date.now()}`,
        'width=1250,height=800,scrollbars=yes,resizable=yes,toolbar=no,location=no,menubar=no,status=no');
      if (!popup) {
        alert(`Popup blocked! Please allow popups or manually visit:\n${url}`);
      } else {
        popup.focus();
      }
    }
    return;
  }

  const buyLink = e.target.closest('.ebay-buy-link');
  if (buyLink) {
    e.preventDefault();
    e.stopPropagation();
    openEbayBuySearch(buyLink.dataset.search);
    return;
  }
  const soldLink = e.target.closest('.ebay-sold-link');
  if (soldLink) {
    e.preventDefault();
    e.stopPropagation();
    openEbaySoldSearch(soldLink.dataset.search);
    return;
  }
});

/**
 * Shift+click inline editing — power user shortcut for editable cells.
 * Capture-phase listener intercepts shift+clicks before inline onclick
 * handlers (filterLink) and bubble-phase eBay handlers can fire.
 */
document.addEventListener('click', (e) => {
  if (!e.shiftKey) return;
  const td = e.target.closest('#inventoryTable td[data-column]');
  if (!td) return;
  const EDITABLE = {
    name: 'name',
    qty: 'qty',
    weight: 'weight',
    purchasePrice: 'price',
    retailPrice: 'marketValue',
    purchaseLocation: 'purchaseLocation'
  };
  const field = EDITABLE[td.dataset.column];
  if (!field) return;
  const tr = td.closest('tr[data-idx]');
  if (!tr) return;
  const idx = parseInt(tr.dataset.idx, 10);
  if (isNaN(idx)) return;
  e.preventDefault();
  e.stopPropagation();
  startCellEdit(idx, field, td);
}, true); // capture phase

// =============================================================================
// THUMBNAIL POPOVER  (image view + upload for main table)
// =============================================================================

/**
 * Opens a fixed-position popover anchored below (or above) the image cell.
 * Shows a large preview of the resolved image for each visible side, with
 * Upload, Camera (mobile/HTTPS only), and Remove buttons.
 * Saves directly to imageCache and refreshes the row's thumbnails.
 *
 * @param {HTMLTableDataCellElement} cell  - the td[data-column="image"] element
 * @param {Object} item                   - the full inventory item object
 */
function _openThumbPopover(cell, item) {
  // Toggle off if same cell clicked again
  const existing = document.getElementById('thumbPopover');
  if (existing) {
    existing.remove();
    if (existing.dataset.forUuid === (item.uuid || '')) return;
  }

  const isSecure = location.protocol === 'https:' || location.hostname === 'localhost';
  const isMobile = /Mobi|Android/i.test(navigator.userAgent);
  const showCamera = isMobile && isSecure;

  const { showObv, showRev } = (() => {
    const s = localStorage.getItem('tableImageSides') || 'both';
    return { showObv: s === 'both' || s === 'obverse', showRev: s === 'both' || s === 'reverse' };
  })();

  // Build side HTML helper
  const sideHtml = (sideKey, label) => `
    <div class="bulk-img-popover-side">
      <span class="bulk-img-popover-label">${label}</span>
      <div class="bulk-img-popover-preview thumb-popover-preview" id="thumbPop_${sideKey}_preview"></div>
      <div class="bulk-img-popover-actions">
        <input type="file" id="thumbPop_${sideKey}_file" accept="image/jpeg,image/png,image/webp" style="display:none" />
        <button class="btn btn-sm" id="thumbPop_${sideKey}_upload" type="button">Upload</button>
        ${showCamera ? `<button class="btn btn-sm" id="thumbPop_${sideKey}_camera" type="button">📷</button>` : ''}
        <button class="btn btn-sm btn-danger" id="thumbPop_${sideKey}_remove" type="button" style="display:none">Remove</button>
      </div>
    </div>`;

  const pop = document.createElement('div');
  pop.id = 'thumbPopover';
  pop.className = 'bulk-img-popover thumb-popover';
  pop.dataset.forUuid = item.uuid || '';

  pop.innerHTML = `
    <div class="bulk-img-popover-header">
      <span class="bulk-img-popover-title">${item.name ? item.name.slice(0, 28) + (item.name.length > 28 ? '…' : '') : 'Photos'}</span>
      <button class="bulk-img-popover-close" type="button" aria-label="Close">×</button>
    </div>
    <div class="bulk-img-popover-sides">
      ${showObv ? sideHtml('obv', 'Obverse') : ''}
      ${showRev ? sideHtml('rev', 'Reverse') : ''}
    </div>`;

  document.body.appendChild(pop);

  // Position: below cell, flip above if near viewport bottom
  const rect = cell.getBoundingClientRect();
  const popW = 300;
  let left = rect.left;
  if (left + popW > window.innerWidth - 8) left = window.innerWidth - popW - 8;
  let top = rect.bottom + 4;
  if (top + 340 > window.innerHeight) top = rect.top - 344;
  pop.style.left = Math.max(4, left) + 'px';
  pop.style.top  = Math.max(4, top) + 'px';

  // Close handlers
  const closePopover = () => pop.remove();
  pop.querySelector('.bulk-img-popover-close').addEventListener('click', closePopover);
  const _onOutside = (e) => {
    if (!pop.contains(e.target) && e.target !== cell) {
      closePopover();
      document.removeEventListener('click', _onOutside, true);
    }
  };
  setTimeout(() => document.addEventListener('click', _onOutside, true), 10);

  // Track blob URLs created here so they're revoked with the main pool
  const _popBlobUrls = [];
  const _track = (url) => { if (url) { _thumbBlobUrls.push(url); _popBlobUrls.push(url); } return url; };

  // Load existing images into previews
  const _loadPreview = async (sideKey, side) => {
    const previewEl = document.getElementById(`thumbPop_${sideKey}_preview`);
    const removeBtn = document.getElementById(`thumbPop_${sideKey}_remove`);
    if (!previewEl) return;

    let url = null;
    if (window.imageCache?.isAvailable()) {
      url = _track(await imageCache.resolveImageUrlForItem(item, side));
    }
    // Fallback to CDN URL strings
    if (!url) {
      url = side === 'obverse' ? (item.obverseImageUrl || null) : (item.reverseImageUrl || null);
      if (url && !/^https?:\/\//i.test(url)) url = null;
    }

    if (url) {
      previewEl.innerHTML = `<img src="${url}" alt="${side}" class="bulk-img-popover-img" />`;
      if (removeBtn) removeBtn.style.display = '';
    } else {
      previewEl.innerHTML = `<span class="thumb-popover-empty">No image</span>`;
    }
  };

  if (showObv) _loadPreview('obv', 'obverse');
  if (showRev) _loadPreview('rev', 'reverse');

  // Refresh the row thumbnails after a change
  const _refreshRowThumbs = () => {
    if (!featureFlags.isEnabled('COIN_IMAGES') || !window.imageCache?.isAvailable()) return;
    const row = document.querySelector(`#inventoryTable tr[data-idx]`);
    // Find by uuid via data attribute on the img
    const thumbImg = document.querySelector(`#inventoryTable .table-thumb[data-item-uuid="${CSS.escape(item.uuid || '')}"]`);
    if (thumbImg) {
      // Revoke old blob URL for this specific image
      if (thumbImg.src && thumbImg.src.startsWith('blob:')) {
        try { URL.revokeObjectURL(thumbImg.src); } catch { /* ignore */ }
      }
      thumbImg.src = '';
      thumbImg.style.visibility = 'hidden';
      thumbImg.removeAttribute('src');
      _loadThumbImage(thumbImg);
    }
    // Refresh popover previews too
    if (showObv) _loadPreview('obv', 'obverse');
    if (showRev) _loadPreview('rev', 'reverse');
  };

  // Handle upload for one side
  const _handleUpload = async (file, side) => {
    if (!file || typeof imageProcessor === 'undefined') return;
    const result = await imageProcessor.processFile(file, {
      maxDim:   typeof IMAGE_MAX_DIM   !== 'undefined' ? IMAGE_MAX_DIM   : 600,
      maxBytes: typeof IMAGE_MAX_BYTES !== 'undefined' ? IMAGE_MAX_BYTES : 512000,
    });
    if (!result?.blob) return;

    let obvBlob = side === 'obverse' ? result.blob : null;
    let revBlob = side === 'reverse' ? result.blob : null;
    // Merge: keep the other side if it exists
    try {
      const existing = await imageCache.getUserImage(item.uuid);
      if (existing) {
        if (!obvBlob && existing.obverse) obvBlob = existing.obverse;
        if (!revBlob && existing.reverse) revBlob = existing.reverse;
      }
    } catch { /* ignore */ }
    if (!obvBlob && revBlob) { obvBlob = revBlob; revBlob = null; }

    await imageCache.cacheUserImage(item.uuid, obvBlob, revBlob);
    _refreshRowThumbs();
  };

  // Wire Upload + Camera buttons for each visible side
  const _wireSide = (sideKey, side) => {
    const fileInput = document.getElementById(`thumbPop_${sideKey}_file`);
    const uploadBtn = document.getElementById(`thumbPop_${sideKey}_upload`);
    const cameraBtn = document.getElementById(`thumbPop_${sideKey}_camera`);
    const removeBtn = document.getElementById(`thumbPop_${sideKey}_remove`);
    if (!fileInput) return;

    if (uploadBtn) {
      uploadBtn.addEventListener('click', () => {
        fileInput.removeAttribute('capture');
        fileInput.click();
      });
    }
    if (cameraBtn) {
      cameraBtn.addEventListener('click', () => {
        fileInput.setAttribute('capture', 'environment');
        fileInput.click();
      });
    }
    fileInput.addEventListener('change', () => {
      if (fileInput.files[0]) _handleUpload(fileInput.files[0], side);
    });

    if (removeBtn) {
      removeBtn.addEventListener('click', async () => {
        if (!window.imageCache?.isAvailable()) return;
        const existing = await imageCache.getUserImage(item.uuid);
        if (!existing) return;
        const keepObv = side === 'reverse' ? existing.obverse : null;
        const keepRev = side === 'obverse' ? existing.reverse : null;
        if (!keepObv && !keepRev) {
          await imageCache.deleteUserImage(item.uuid);
        } else {
          const o = keepObv || keepRev;
          const r = keepObv ? keepRev : null;
          await imageCache.cacheUserImage(item.uuid, o, r);
        }
        _refreshRowThumbs();
      });
    }
  };

  if (showObv) _wireSide('obv', 'obverse');
  if (showRev) _wireSide('rev', 'reverse');
}

/**
 * Phase 1C: Storage optimization and housekeeping
 */
function optimizeStoragePhase1C(){
  try{
    if (typeof catalogManager !== 'undefined' && catalogManager && typeof catalogManager.removeOrphanedMappings === 'function'){
      catalogManager.removeOrphanedMappings();
    }
    if (typeof generateStorageReport === 'function'){
      const report = generateStorageReport();
      debugLog('Storage Optimization: Total localStorage ~', report.totalKB, 'KB');
      if (typeof initializeStorageChart === 'function'){
        try { initializeStorageChart(report); } catch (e) { debugWarn('Storage chart init failed', e); }
      }
    }
  } catch(e){
    debugWarn('optimizeStoragePhase1C error', e);
  }
}
if (typeof window !== 'undefined'){ window.optimizeStoragePhase1C = optimizeStoragePhase1C; }