Source: filters.js

// FILTERS MODULE
// =============================================================================

/**
 * Advanced filtering system
 */
/** @type {Object.<string, FilterConfig>} */
let activeFilters = {};

/**
 * Clears all active filters and resets search input and pagination.
 */
const clearAllFilters = () => {
  activeFilters = {};
  searchQuery = '';

  const searchInput = document.getElementById('searchInput');
  if (searchInput) searchInput.value = '';
  if (typeof window.updateSaveSearchButton === 'function') {
    window.updateSaveSearchButton('', false);
  }

  const typeFilter = document.getElementById('typeFilter');
  if (typeFilter) typeFilter.value = '';

  const metalFilter = document.getElementById('metalFilter');
  if (metalFilter) metalFilter.value = '';

  // Update chip UI before rerendering the table
  renderActiveFilters();
  renderTable();
};

/**
 * Removes a specific filter from active filters or search.
 *
 * @param {string} field - The field to remove filter from
 * @param {string} value - The value to remove from filter
 */
const removeFilter = (field, value) => {
  if (field === 'search') {
    // Clear search query
    searchQuery = '';
    const searchInput = document.getElementById('searchInput');
    if (searchInput) searchInput.value = '';
    if (typeof window.updateSaveSearchButton === 'function') {
      window.updateSaveSearchButton('', false);
    }
  } else if (activeFilters[field]) {
    if (activeFilters[field].values && Array.isArray(activeFilters[field].values)) {
      // Remove specific value from array
      activeFilters[field].values = activeFilters[field].values.filter(v => v !== value);
      // If no values left, remove the entire filter
      if (activeFilters[field].values.length === 0) {
        delete activeFilters[field];
      }
    } else {
      // Remove entire filter
      delete activeFilters[field];
    }
  }

  renderTable();
};

/**
 * Returns the display value for a filter chip.
 * Passes through the value as-is — hardcoded name simplifications have been
 * removed in favour of user-configurable custom grouping rules in Settings.
 *
 * @param {string} value - The original value
 * @param {string} field - The field type (e.g., 'name', 'type', etc.)
 * @returns {string} Display value
 */
const simplifyChipValue = (value, field) => {
  if (!value || typeof value !== 'string') {
    return value;
  }

  // Handle comma-separated values
  if (value.includes(', ')) {
    return value.split(', ')
      .map(v => simplifyChipValue(v.trim(), field))
      .join(', ');
  }

  return value;
};

/**
 * Generates category summary from filtered inventory.
 * Returns summary of metals, types, and item counts above minimum threshold.
 *
 * @param {Array<Object>} inventory - The filtered inventory
 * @returns {Object} Summary of metals, types, and counts
 */
const generateCategorySummary = (inventory) => {
  // Get minimum count setting from dropdown control or localStorage
  const chipMinCountEl = document.getElementById('chipMinCount');
  let minCount = 3;
  if (chipMinCountEl && chipMinCountEl.value) {
    minCount = parseInt(chipMinCountEl.value, 10);
  } else {
    minCount = parseInt(localStorage.getItem('chipMinCount') || '3', 10);
  }

  // When the user has active filters or a search query, drop minCount to 1
  // for descriptive categories (metal, type, year, grade, location, groups)
  // so the filtered subset's attributes are always visible.
  // Name chips keep the user's threshold (min 2) to avoid flooding the chip
  // bar with every unique item name in the filtered set.
  const hasActiveFilters = Object.keys(activeFilters).length > 0;
  const hasSearchQuery = typeof searchQuery === 'string' && searchQuery.trim().length > 0;
  const nameMinCount = Math.max(2, minCount);
  if (hasActiveFilters || hasSearchQuery) {
    minCount = 1;
  }

  const metals = {};
  const types = {};
  const purchaseLocations = {};
  const storageLocations = {};
  const names = {};
  const years = {};
  const grades = {};
  const numistaIds = {};
  const purities = {};
  const tags = {};

  inventory.forEach(item => {
    // Count metals
    const metal = getCompositionFirstWords(item.composition || item.metal || '');
    if (metal) {
      metals[metal] = (metals[metal] || 0) + 1;
    }

    // Count types
    if (item.type) {
      types[item.type] = (types[item.type] || 0) + 1;
    }

    // Count purchase locations (skip empty / "Unknown")
    const pLoc = (item.purchaseLocation || '').trim();
    if (pLoc && pLoc.toLowerCase() !== 'unknown') {
      purchaseLocations[item.purchaseLocation] = (purchaseLocations[item.purchaseLocation] || 0) + 1;
    }

    // Count storage locations (skip empty / "Unknown")
    const sLoc = (item.storageLocation || '').trim();
    if (sLoc && sLoc.toLowerCase() !== 'unknown') {
      storageLocations[item.storageLocation] = (storageLocations[item.storageLocation] || 0) + 1;
    }

    // Count normalized names (grouped name chips)
    if (window.featureFlags && window.featureFlags.isEnabled('GROUPED_NAME_CHIPS')) {
      const itemName = (item.name || '').trim();
      if (itemName) {
        let baseName = itemName;
        if (window.autocomplete && typeof window.autocomplete.normalizeItemName === 'function') {
          baseName = window.autocomplete.normalizeItemName(itemName);
        }
        names[baseName] = (names[baseName] || 0) + 1;
      }
    }

    // Count years (skip empty)
    const yr = (item.year || '').trim();
    if (yr) {
      years[yr] = (years[yr] || 0) + 1;
    }

    // Count grades (skip empty)
    const gr = (item.grade || '').trim();
    if (gr) {
      grades[gr] = (grades[gr] || 0) + 1;
    }

    // Count Numista IDs (skip empty)
    const nId = (item.numistaId || '').trim();
    if (nId) {
      numistaIds[nId] = (numistaIds[nId] || 0) + 1;
    }

    // Count purities (skip default 1.0 — only show non-pure fineness)
    const pur = parseFloat(item.purity);
    if (!isNaN(pur) && pur > 0 && pur < 1.0) {
      const purKey = String(pur);
      purities[purKey] = (purities[purKey] || 0) + 1;
    }

    // Count tags (STAK-126)
    if (typeof getItemTags === 'function' && item.uuid) {
      const itemTags = getItemTags(item.uuid);
      itemTags.forEach(tag => {
        tags[tag] = (tags[tag] || 0) + 1;
      });
    }
  });

  // Count custom groups
  let customGroups = {};
  if (typeof window.countCustomGroups === 'function') {
    customGroups = window.countCustomGroups(inventory);
  }

  // Extract dynamic chips (text from parentheses/quotes)
  let dynamicNames = {};
  if (window.featureFlags && window.featureFlags.isEnabled('DYNAMIC_NAME_CHIPS') && typeof window.extractDynamicChips === 'function') {
    dynamicNames = window.extractDynamicChips(inventory);
  }

  // Apply minCount threshold to all categories
  const filteredMetals = applyMinCountThreshold(metals, minCount);
  const filteredTypes = applyMinCountThreshold(types, minCount);
  const filteredPurchaseLocations = applyMinCountThreshold(purchaseLocations, minCount);
  const filteredStorageLocations = applyMinCountThreshold(storageLocations, minCount);
  let filteredNames = applyMinCountThreshold(names, nameMinCount);
  const filteredYears = applyMinCountThreshold(years, minCount);
  const filteredGrades = applyMinCountThreshold(grades, minCount);
  const filteredNumistaIds = applyMinCountThreshold(numistaIds, minCount);
  const filteredPurities = applyMinCountThreshold(purities, minCount);
  const filteredTags = applyMinCountThreshold(tags, minCount);
  const filteredDynamicNames = applyMinCountThreshold(dynamicNames, nameMinCount);

  // Apply blacklist filter to auto-generated name chips, dynamic chips, tag chips,
  // and custom-group labels so shift-click suppression is consistent across chip types.
  if (typeof window.isBlacklisted === 'function') {
    filteredNames = Object.fromEntries(
      Object.entries(filteredNames).filter(([key]) => !window.isBlacklisted(key))
    );
    // Filter dynamic names through blacklist too
    for (const key of Object.keys(filteredDynamicNames)) {
      if (window.isBlacklisted(key)) {
        delete filteredDynamicNames[key];
      }
    }
    for (const key of Object.keys(filteredTags)) {
      if (window.isBlacklisted(key)) {
        delete filteredTags[key];
      }
    }
    for (const groupId of Object.keys(customGroups)) {
      const info = customGroups[groupId];
      if (info && window.isBlacklisted(info.label)) {
        delete customGroups[groupId];
      }
    }
  }

  // Suppress auto-generated names that duplicate a custom group label
  const customLabelsLower = new Set(Object.values(customGroups).map(g => g.label.toLowerCase()));
  filteredNames = Object.fromEntries(
    Object.entries(filteredNames).filter(([key]) => !customLabelsLower.has(key.toLowerCase()))
  );

  // Apply minCount threshold to custom groups
  const filteredCustomGroups = Object.fromEntries(
    Object.entries(customGroups).filter(([, info]) => info.count >= minCount)
  );

  return {
    metals: filteredMetals,
    types: filteredTypes,
    purchaseLocations: filteredPurchaseLocations,
    storageLocations: filteredStorageLocations,
    names: filteredNames,
    years: filteredYears,
    grades: filteredGrades,
    numistaIds: filteredNumistaIds,
    customGroups: filteredCustomGroups,
    dynamicNames: filteredDynamicNames,
    purities: filteredPurities,
    tags: filteredTags,
    totalItems: inventory.length
  };
};

/**
 * Renders active filter chips beneath the search bar.
 * Updates the filter chip container based on current filters and inventory.
 */
const renderActiveFilters = () => {
  const container = document.getElementById('activeFilters');
  if (!container) return;

  container.innerHTML = '';

  // Get the current filtered inventory first
  const filteredInventory = filterInventoryAdvanced();
  
  if (filteredInventory.length === 0) {
    // Show a hint when search narrows to 0 results instead of hiding chips entirely (UX-004)
    if (searchQuery && searchQuery.trim()) {
      container.style.display = '';
      const hint = document.createElement('span');
      hint.className = 'filter-chip search-hint-chip';
      hint.style.opacity = '0.55';
      hint.style.cursor = 'default';
      hint.textContent = 'Clear search to use filter chips';
      container.appendChild(hint);
      return;
    }
    container.style.display = 'none';
    return;
  }

  // Build chips based on what's actually in the filtered inventory
  const chips = [];
  
  // Add search term chip if there's a search query
  if (searchQuery && searchQuery.trim()) {
    chips.push({ field: 'search', value: searchQuery });
  }
  
  // Generate category summary chips from filtered inventory
  const categorySummary = generateCategorySummary(filteredInventory);
  
  // Category descriptor map — maps category ID to summary key, chip field, and extra props
  const categoryDescriptors = {
    metal:            { summaryKey: 'metals',            field: 'metal' },
    type:             { summaryKey: 'types',             field: 'type' },
    name:             { summaryKey: 'names',             field: 'name',             extraProps: { isGrouped: true } },
    customGroup:      { summaryKey: 'customGroups',      field: 'customGroup' },
    dynamicName:      { summaryKey: 'dynamicNames',      field: 'dynamicName',      extraProps: { isDynamic: true } },
    purchaseLocation: { summaryKey: 'purchaseLocations', field: 'purchaseLocation' },
    storageLocation:  { summaryKey: 'storageLocations',  field: 'storageLocation' },
    year:             { summaryKey: 'years',             field: 'year' },
    grade:            { summaryKey: 'grades',            field: 'grade' },
    numistaId:        { summaryKey: 'numistaIds',        field: 'numistaId' },
    purity:           { summaryKey: 'purities',          field: 'purity' },
    tags:             { summaryKey: 'tags',              field: 'tags' },
  };

  // Read category config (order + enabled state) and sort preference
  const categoryConfig = typeof getFilterChipCategoryConfig === 'function'
    ? getFilterChipCategoryConfig()
    : [
        { id: 'metal', enabled: true }, { id: 'type', enabled: true },
        { id: 'name', enabled: true }, { id: 'customGroup', enabled: true },
        { id: 'dynamicName', enabled: true }, { id: 'purchaseLocation', enabled: true },
        { id: 'storageLocation', enabled: true }, { id: 'year', enabled: true },
        { id: 'grade', enabled: true }, { id: 'numistaId', enabled: true },
        { id: 'purity', enabled: true },
        { id: 'tags', enabled: true },
      ];

  // Read sort preference from toggle active button or localStorage (default: alpha)
  const sortEl = document.getElementById('chipSortOrder');
  const activeBtn = sortEl && sortEl.querySelector('.chip-sort-btn.active');
  const rawPref = (activeBtn && activeBtn.dataset.sort) || localStorage.getItem('chipSortOrder') || 'alpha';
  const chipSortPref = (rawPref === 'count') ? 'count' : 'alpha';

  // Helper: collect chips for a single category from the summary data
  const collectCategoryChips = (cat) => {
    const desc = categoryDescriptors[cat.id];    if (!desc) return [];
    const data = categorySummary[desc.summaryKey];    if (!data) return [];
    const result = [];
    if (cat.id === 'customGroup') {
      Object.entries(data).forEach(([groupId, info]) => {
        if (info.count > 0) {
          result.push({ field: desc.field, value: groupId, displayLabel: info.label, count: info.count, total: categorySummary.totalItems, isCustomGroup: true });
        }
      });
    } else {
      Object.entries(data).forEach(([value, count]) => {
        if (count > 0) {
          result.push({ field: desc.field, value, count, total: categorySummary.totalItems, ...(desc.extraProps || {}) });
        }
      });
    }
    return result;
  };

  // Helper: sort a chip array in place based on preference
  const sortChips = (arr) => {
    if (chipSortPref === 'alpha') {
      arr.sort((a, b) => {
        const aLabel = (a.displayLabel || a.value || '').toString();
        const bLabel = (b.displayLabel || b.value || '').toString();
        return aLabel.localeCompare(bLabel, undefined, { numeric: true, sensitivity: 'base' });
      });
    } else if (chipSortPref === 'count') {
      arr.sort((a, b) => (b.count || 0) - (a.count || 0));
    }
  };

  // Build chips — categories with the same group letter pool and sort together
  const categoryFields = new Set();
  const emittedGroups = new Set();

  for (const cat of categoryConfig) {
    if (!cat.enabled) continue;
    const desc = categoryDescriptors[cat.id];    if (!desc) continue;
    categoryFields.add(desc.field);

    if (cat.group) {
      // Grouped: first encounter collects ALL categories in this group
      if (emittedGroups.has(cat.group)) continue;
      emittedGroups.add(cat.group);

      const pooled = [];
      for (const gc of categoryConfig) {
        if (!gc.enabled || gc.group !== cat.group) continue;
        const gcDesc = categoryDescriptors[gc.id];        if (gcDesc) categoryFields.add(gcDesc.field);
        pooled.push(...collectCategoryChips(gc));
      }
      sortChips(pooled);
      chips.push(...pooled);
    } else {
      // Ungrouped: collect and sort individually
      const catChips = collectCategoryChips(cat);
      sortChips(catChips);
      chips.push(...catChips);
    }
  }

  // Add any explicitly applied filter chips (but not if they duplicate category chips)
  Object.entries(activeFilters).forEach(([field, criteria]) => {
    // Skip fields already rendered as category summary chips to avoid duplicates
    // BUT: if no summary chip was rendered for this field (all below minCount),
    // fall through so the user can still see and remove their active filter
    if (categoryFields.has(field)) {
      let hasSummaryChip = chips.some(c => c.field === field && c.count !== undefined);
      // For 'name' filters, customGroup/dynamicName chips provide visual coverage
      // so suppress the individual name fallback when those chips are present
      if (field === 'name' && !hasSummaryChip) {
        hasSummaryChip = chips.some(c => (c.field === 'customGroup' || c.field === 'dynamicName') && c.count !== undefined);
      }
      // Keep excluded filters visible even when category summary chips exist.
      const isExcludeFilter = !!(criteria && typeof criteria === 'object' && criteria.exclude);
      if (hasSummaryChip && !isExcludeFilter) return;
    }
    
    if (criteria && typeof criteria === 'object' && Array.isArray(criteria.values)) {
      criteria.values.forEach(value => {
        if (value && value.toString().trim()) {
          chips.push({ field, value, exclude: criteria.exclude });
        }
      });
    } else {
      if (criteria && criteria.toString().trim()) {
        chips.push({ field, value: criteria });
      }
    }
  });
  
  if (chips.length === 0) {
    container.style.display = 'none';
    return;
  }
  
  container.style.display = '';

  chips.forEach((f, i) => {
    const chip = document.createElement('span');
    chip.className = 'filter-chip';
    if (f.exclude) chip.classList.add('filter-chip-excluded');
    // All chip categories render visually identical — no italic/bold distinction
    const firstValue = String(f.value).split(', ')[0];
    const colorKey = f.field === 'customGroup' ? (f.displayLabel || firstValue) : firstValue;
    const { bg, text: textColor } = getChipColors(f.field, colorKey, i);
    chip.style.backgroundColor = bg;
    chip.style.color = textColor || getContrastColor(bg);

    // Determine if this chip represents a currently active filter
    let isActiveFilter = false;
    if (f.count !== undefined && f.total !== undefined) {
      // Summary chip — active only if its value is in activeFilters
      const criteria = activeFilters[f.field];
      if (criteria && Array.isArray(criteria.values) && !criteria.exclude) {
        if (f.field === 'customGroup') {
          // customGroup expands to name values — active if any non-excluded name filter exists
          const nc = activeFilters['name'];
          isActiveFilter = !!(nc && !nc.exclude && nc.values && nc.values.length > 0);
        } else if (f.field === 'dynamicName') {
          // dynamicName expands to name values — same check
          const nc = activeFilters['name'];
          isActiveFilter = !!(nc && !nc.exclude && nc.values && nc.values.length > 0);
        } else {
          isActiveFilter = criteria.values.includes(f.value);
        }
      }
    } else {
      // Fallback active-filter chips (below minCount or excluded) are always "active"
      isActiveFilter = true;
    }

    if (isActiveFilter) chip.classList.add('filter-chip-active');
    if (f.field === 'search') chip.classList.add('filter-chip-search');

    // Display simplified value for most chips, but keep full base name for name chips
    // Custom groups use their display label; dynamic chips are italic (via CSS class)
    const displayValue = f.isCustomGroup ? f.displayLabel
      : f.isDynamic ? f.value
      : f.field === 'name' ? f.value
      : f.field === 'numistaId' ? `N#${f.value}`
      : simplifyChipValue(f.value, f.field);
    let label;

    if (f.field === 'search') {
      label = displayValue;
    } else if (f.count !== undefined && f.total !== undefined) {
      // For category summary chips, show count badge if enabled
      const showQty = window.featureFlags && window.featureFlags.isEnabled('CHIP_QTY_BADGE');
      label = showQty ? `${displayValue} (${f.count})` : displayValue;
    } else {
      label = `${displayValue}${f.exclude ? ' (exclude)' : ''}`;
    }
    
    // Use safe textContent and a separate close marker span to avoid HTML injection
    chip.textContent = label + ' ';
    const close = document.createElement('span');
    close.className = 'chip-close';
    close.textContent = '×';
    close.setAttribute('aria-hidden', 'true');
    chip.appendChild(close);

    // Debug logging (opt-in)
    if (window.DEBUG_FILTERS) {
      console.debug('renderActiveFilters: adding chip', { field: f.field, value: f.value, label });
    }
    
    // Right-click context menu for name and dynamic chips (blacklist)
    if (f.field === 'name' || f.field === 'dynamicName') {
      chip.addEventListener('contextmenu', (e) => {
        e.preventDefault();
        const chipName = f.field === 'dynamicName' ? f.value : f.value;
        if (typeof window.showChipContextMenu === 'function') {
          window.showChipContextMenu(e.clientX, e.clientY, chipName);
        }
      });
    }

    // Different tooltip and click behavior for different chip types
    if (f.count !== undefined && f.total !== undefined) {
      // Category summary chips - clicking adds filter; shift+click blacklists supported chip names.
      const canBlacklist = f.field === 'name' || f.field === 'dynamicName' || f.field === 'customGroup' || f.field === 'tags';
      const chipNameForBlacklist = f.field === 'customGroup' ? (f.displayLabel || f.value) : f.value;
      chip.title = `Click to filter by ${f.field}: ${displayValue} (${f.count} items)` +
        (canBlacklist ? ' · Shift+click to ignore' : '');
      chip.addEventListener('click', (e) => {
        if (canBlacklist && e.shiftKey && typeof window.showBlacklistConfirm === 'function') {
          e.preventDefault();
          window.showBlacklistConfirm(e.clientX, e.clientY, chipNameForBlacklist);
          return;
        }
        applyQuickFilter(f.field, f.value, f.isGrouped || f.isCustomGroup || f.isDynamic || false);
      });
    } else {
      // Active filter chips - clicking removes filter
      chip.title = f.field === 'search'
        ? `Search term: ${displayValue} (click to remove)`
        : `Active ${f.exclude ? 'excluded' : 'included'} filter: ${f.field} = ${displayValue} (click to remove)`;
      chip.addEventListener('click', () => {
        removeFilter(f.field, f.value);
        renderActiveFilters();
      });
    }
    // Make the close glyph interactive and keyboard accessible (removes the filter)
    close.setAttribute('role', 'button');
    close.setAttribute('tabindex', '0');
    close.setAttribute('aria-label', `Remove filter ${displayValue}`);
    close.onclick = (e) => {
      e.stopPropagation();
      if (isActiveFilter) {
        // Active filter chip × — always removes the filter (de-activate, not exclude)
        removeFilter(f.field, f.value);
        renderActiveFilters();
      } else if (f.count !== undefined && f.total !== undefined && f.field !== 'search') {
        // Idle summary chip × — exclude this value while keeping other filters intact
        applyQuickFilter(f.field, f.value, f.isGrouped || f.isCustomGroup || f.isDynamic || false, true);
      } else {
        removeFilter(f.field, f.value);
        renderActiveFilters();
      }
    };
    close.onkeydown = (e) => {
      if (e.key === 'Enter' || e.key === ' ') {
        e.preventDefault();
        e.stopPropagation();
        if (isActiveFilter) {
          removeFilter(f.field, f.value);
          renderActiveFilters();
        } else if (f.count !== undefined && f.total !== undefined && f.field !== 'search') {
          applyQuickFilter(f.field, f.value, f.isGrouped || f.isCustomGroup || f.isDynamic || false, true);
        } else {
          removeFilter(f.field, f.value);
          renderActiveFilters();
        }
      }
    };

    container.appendChild(chip);
  });

  // Add clear button if there are any chips (check for both active and summary chips)
  if (chips.length > 0) {
    const clearButton = document.createElement('button');
    clearButton.className = 'filter-clear-btn';
    clearButton.innerHTML = 'Clear All';
    clearButton.title = 'Clear all active filters';
    clearButton.onclick = () => {
      clearAllFilters();
    };
    container.appendChild(clearButton);
  }
};

/**
 * Checks whether a two-word search like "Silver Eagle" should match an item,
 * preventing cross-metal false positives (e.g. "Silver Eagle" matching "American Gold Eagle").
 *
 * @param {string} searchMetal - First word of the two-word search (lowercase)
 * @param {string} coinType - Second word of the two-word search (lowercase)
 * @param {string} itemText - Concatenated item fields (lowercase)
 * @param {string} exactPhrase - Full two-word search string (lowercase)
 * @returns {boolean|null} true/false for definitive match result, null to fall through to default logic
 */
const matchCoinSeries = (searchMetal, coinType, itemText, exactPhrase) => {
  const metalWords = ['gold', 'silver', 'platinum', 'palladium'];

  const checkNationalPrefix = (prefix) => {
    const hasMetalBetween = metalWords.some(metal =>
      itemText.includes(`${prefix} ${metal} ${coinType}`) && !exactPhrase.includes(metal)
    );
    return !hasMetalBetween;
  };

  // Eagle series
  if (coinType === 'eagle') {
    if (searchMetal === 'american') return checkNationalPrefix('american');
    if (metalWords.includes(searchMetal)) return itemText.includes(exactPhrase);
  }
  // Maple series
  if (coinType === 'maple') {
    if (metalWords.includes(searchMetal)) {
      return itemText.includes(exactPhrase) || itemText.includes(`${searchMetal} maple leaf`);
    }
    if (searchMetal === 'canadian') return checkNationalPrefix('canadian');
  }
  // Britannia series
  if (coinType === 'britannia') {
    if (metalWords.includes(searchMetal)) return itemText.includes(exactPhrase);
    if (searchMetal === 'british') {
      const hasMetalBetween = metalWords.some(metal =>
        itemText.includes(`british ${metal} britannia`) && !exactPhrase.includes(metal)
      );
      return !hasMetalBetween;
    }
  }
  // Krugerrand series
  if (coinType === 'krugerrand') {
    if (metalWords.includes(searchMetal)) return itemText.includes(exactPhrase);
    if (searchMetal === 'south' || searchMetal === 'african') {
      const hasMetalBetween = metalWords.some(metal =>
        (itemText.includes(`south african ${metal} krugerrand`) ||
         itemText.includes(`${metal} krugerrand`)) && !exactPhrase.includes(metal)
      );
      return !hasMetalBetween;
    }
  }
  // Buffalo series
  if (coinType === 'buffalo') {
    if (metalWords.includes(searchMetal)) return itemText.includes(exactPhrase);
    if (searchMetal === 'american') return checkNationalPrefix('american');
  }
  // Panda series
  if (coinType === 'panda') {
    if (metalWords.includes(searchMetal)) return itemText.includes(exactPhrase);
    if (searchMetal === 'chinese') return checkNationalPrefix('chinese');
  }
  // Kangaroo series
  if (coinType === 'kangaroo') {
    if (metalWords.includes(searchMetal)) return itemText.includes(exactPhrase);
    if (searchMetal === 'australian') {
      const hasMetalBetween = metalWords.some(metal =>
        itemText.includes(`australian ${metal} kangaroo`) && !exactPhrase.includes(metal)
      );
      return !hasMetalBetween;
    }
  }

  return null; // No series match — fall through to default logic
};

/**
 * Applies a minimum count threshold to a category data object, filtering out entries below the threshold.
 * @param {Object} data - Map of { key: count }
 * @param {number} minCount - Minimum count to include
 * @returns {Object} Filtered map
 */
const applyMinCountThreshold = (data, minCount) => {
  return Object.fromEntries(
    Object.entries(data).filter(([, count]) => count >= minCount)
  );
};

/**
 * Returns chip color and text color for a given field/value combination.
 * @param {string} field - Chip field type
 * @param {string} value - Chip value
 * @param {number} index - Chip index for fallback color cycling
 * @returns {{ bg: string, text: string|undefined }} Background and optional text color
 */
const getChipColors = (field, value, index) => {
  const fallbackColors = ['var(--primary)', 'var(--secondary)', 'var(--success)', 'var(--warning)', 'var(--danger)', 'var(--info)'];
  let color;
  let textColor;

  switch (field) {
    case 'type':
      color = getTypeColor(value);
      break;
    case 'metal':
      if (!METAL_COLORS[value]) {
        color = getColor(nameColors, value);
      } else {
        color = METAL_COLORS[value];
        textColor = METAL_TEXT_COLORS[value] ? METAL_TEXT_COLORS[value]() : undefined;
      }
      break;
    case 'name':
    case 'dynamicName':
      color = getColor(nameColors, value);
      break;
    case 'customGroup':
      color = getColor(nameColors, value);
      break;
    case 'tags':
      color = getColor(nameColors, value);
      break;
    case 'purchaseLocation':
      color = getPurchaseLocationColor(value);
      break;
    case 'storageLocation':
      color = getStorageLocationColor(value);
      break;
    default:
      color = fallbackColors[index % fallbackColors.length];
  }

  return { bg: color || fallbackColors[index % fallbackColors.length], text: textColor };
};

/**
 * Enhanced filter inventory function that includes advanced filters.
 * Applies all active filters in `activeFilters` to the inventory.
 *
 * @returns {Array<InventoryItem>} Filtered inventory items
 */
const filterInventoryAdvanced = () => {
  let result = inventory;

  // Apply advanced filters
  Object.entries(activeFilters).forEach(([field, criteria]) => {
    if (criteria && typeof criteria === 'object' && Array.isArray(criteria.values)) {
      const { values, exclude } = criteria;
      switch (field) {
        case 'name': {
          const simplifiedValues = values.map(v => simplifyChipValue(v, field));
          result = result.filter(item => {
            const itemName = simplifyChipValue(item.name || '', field);
            const match = simplifiedValues.includes(itemName);
            return exclude ? !match : match;
          });
          break;
        }
        case 'metal': {
          const lowerVals = values.map(v => v.toLowerCase());
          result = result.filter(item => {
            const itemMetal = getCompositionFirstWords(item.composition || item.metal || '').toLowerCase();
            const match = lowerVals.includes(itemMetal);
            return exclude ? !match : match;
          });
          break;
        }
        case 'type':
          result = result.filter(item => {
            const match = values.includes(item.type);
            return exclude ? !match : match;
          });
          break;
        case 'purchaseLocation':
          result = result.filter(item => {
            const loc = item.purchaseLocation;
            const normalized = (!loc || loc === 'Unknown' || loc === 'Numista Import') ? '—' : loc;
            const match = values.includes(normalized);
            return exclude ? !match : match;
          });
          break;
        case 'storageLocation':
          result = result.filter(item => {
            const loc = item.storageLocation;
            const normalized = (!loc || loc === 'Unknown' || loc === 'Numista Import') ? '—' : loc;
            const match = values.includes(normalized);
            return exclude ? !match : match;
          });
          break;
        case 'tags': {
          // STAK-126: Filter by item tags
          if (typeof getItemTags === 'function') {
            const lowerVals = values.map(v => v.toLowerCase());
            result = result.filter(item => {
              const tags = getItemTags(item.uuid);
              const match = tags.some(t => lowerVals.includes(t.toLowerCase()));
              return exclude ? !match : match;
            });
          }
          break;
        }
        default: {
          const lowerVals = values.map(v => String(v).toLowerCase());
          result = result.filter(item => {
            const fieldVal = String(item[field] ?? '').toLowerCase();
            const match = lowerVals.includes(fieldVal);
            return exclude ? !match : match;
          });
          break;
        }
      }
    } else {
      const value = criteria;
      switch (field) {
        case 'dateFrom':
          result = result.filter(item => item.date >= value);
          break;
        case 'dateTo':
          result = result.filter(item => item.date <= value);
          break;
      }
    }
  });

  // Apply text search
  if (!searchQuery.trim()) return result;

  let query = searchQuery.toLowerCase().trim();

  const terms = query.split(',').map(t => t.trim()).filter(t => t);

  return result.filter(item => {
    if (!terms.length) return true;

    const formattedDate = formatDisplayDate(item.date).toLowerCase();
    
    // Handle comma-separated terms (OR logic between comma terms)
    return terms.some(q => {
      // Split each comma term into individual words for AND logic
      const words = q.split(/\s+/).filter(w => w.length > 0);
      
      // Special handling for multi-word searches to prevent partial matches
      // If searching for "American Eagle", it should only match items that have both words
      // but NOT match "American Gold Eagle" (which has an extra word in between)
      if (words.length >= 2) {
        // STACK-62: Expand abbreviations in the query words for multi-word searches
        const abbrevs = typeof METAL_ABBREVIATIONS !== 'undefined' ? METAL_ABBREVIATIONS : {};
        const expandedWords = words.map(w => abbrevs[w.toLowerCase()] || w);
        const expandedPhrase = expandedWords.join(' ').toLowerCase();

        // For multi-word searches, check if the exact phrase exists or
        // if all words exist as separate word boundaries without conflicting words
        const exactPhrase = q.toLowerCase();
        // STAK-126: include tags in searchable text
        const _searchTags = typeof getItemTags === 'function' ? getItemTags(item.uuid).join(' ') : '';
        const itemText = [
          item.metal,
          item.composition || '',
          item.name,
          item.type,
          item.purchaseLocation,
          item.storageLocation || '',
          item.notes || '',
          String(item.year || ''),
          item.grade || '',
          item.gradingAuthority || '',
          String(item.certNumber || ''),
          String(item.numistaId || ''),
          item.serialNumber || '',
          _searchTags
        ].join(' ').toLowerCase();

        // Check for exact phrase match first
        if (itemText.includes(exactPhrase)) {
          return true;
        }

        // STACK-62: Check expanded abbreviation phrase (e.g. "ase 2024" → "american silver eagle 2024")
        if (expandedPhrase !== exactPhrase && itemText.includes(expandedPhrase)) {
          return true;
        }

        // STACK-23: Check custom chip group label matching for multi-word searches
        if (typeof window.itemMatchesCustomGroupLabel === 'function' &&
            window.itemMatchesCustomGroupLabel(item, q)) {
          return true;
        }

        // For phrase searches like "American Eagle", be more restrictive
        // Check that all words are present as word boundaries
        const allWordsPresent = words.every(word => {
          // nosemgrep: javascript.dos.rule-non-literal-regexp
          const wordRegex = new RegExp(`\\b${word.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, 'i');
          return wordRegex.test(itemText);
        });

        if (!allWordsPresent) {
          return false;
        }
        
        // Prevent cross-metal matching for common coin series
        if (words.length === 2) {
          const seriesResult = matchCoinSeries(words[0], words[1], itemText, exactPhrase);
          if (seriesResult !== null) return seriesResult;
        }
        
        // Handle three-word searches with special patterns
        if (words.length === 3) {
          // Handle "American Gold Eagle" type searches - these should be exact
          const firstWord = words[0];
          const middleWord = words[1];
          const lastWord = words[2];
          
          if (['american', 'canadian', 'british', 'chinese', 'australian', 'south'].includes(firstWord) &&
              ['gold', 'silver', 'platinum', 'palladium'].includes(middleWord) &&
              ['eagle', 'maple', 'britannia', 'krugerrand', 'buffalo', 'panda', 'kangaroo'].includes(lastWord)) {
            // For "American Gold Eagle" type searches, require exact phrase or very close match
            return itemText.includes(exactPhrase) || 
                   (lastWord === 'maple' && itemText.includes(`${firstWord} ${middleWord} maple leaf`));
          }
        }
        
        // Handle fractional weight searches to be more specific
        // "1/4 oz" should be distinct from "1/2 oz" and "1 oz"
        if (words.length >= 2) {
          const hasFraction = words.some(word => word.includes('/'));
          const hasOz = words.some(word => word === 'oz' || word === 'ounce');
          
          if (hasFraction && hasOz) {
            // For fractional searches, require exact phrase match
            return itemText.includes(exactPhrase);
          }
        }
        
        // Prevent overly broad country/origin searches
        const broadTerms = ['american', 'canadian', 'australian', 'british', 'chinese', 'south', 'mexican'];
        if (words.length === 1 && broadTerms.includes(words[0])) {
          // Single broad geographic terms should require additional context
          // Return false to prevent matching everything from that country
          return false;
        }
        
        return true;
      }
      
      // For single words, use word boundary matching with abbreviation expansion
      const fieldMatch = words.every(word => {
        // STACK-62: Build regex with original word + abbreviation expansion
        const escaped = word.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
        const patterns = [escaped];
        const abbrevs = typeof METAL_ABBREVIATIONS !== 'undefined' ? METAL_ABBREVIATIONS : {};
        const expansion = abbrevs[word.toLowerCase()];
        if (expansion) {
          patterns.push(expansion.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
        }
        const combined = patterns.join('|');
        // nosemgrep: javascript.dos.rule-non-literal-regexp
        const wordRegex = new RegExp(`\\b(?:${combined})`, 'i');

        return (
          wordRegex.test(item.metal) ||
          (item.composition && wordRegex.test(item.composition)) ||
          wordRegex.test(item.name) ||
          wordRegex.test(item.type) ||
          wordRegex.test(item.purchaseLocation) ||
          (item.storageLocation && wordRegex.test(item.storageLocation)) ||
          (item.notes && wordRegex.test(item.notes)) ||
          item.date.includes(word) ||
          formattedDate.includes(word) ||
          String(Number.isFinite(Number(item.qty)) ? Number(item.qty) : '').includes(word) ||
          String(Number.isFinite(Number(item.weight)) ? Number(item.weight) : '').includes(word) ||
          String(Number.isFinite(Number(item.price)) ? Number(item.price) : '').includes(word) ||
          (item.year && wordRegex.test(String(item.year))) ||
          (item.grade && wordRegex.test(item.grade)) ||
          (item.gradingAuthority && wordRegex.test(item.gradingAuthority)) ||
          (item.certNumber && wordRegex.test(String(item.certNumber))) ||
          (item.numistaId && wordRegex.test(String(item.numistaId))) ||
          (item.serialNumber && wordRegex.test(item.serialNumber)) ||
          (typeof getItemTags === 'function' && getItemTags(item.uuid).some(t => wordRegex.test(t)))
        );
      });
      if (fieldMatch) return true;

      // STACK-23: Fall back to custom chip group label matching
      if (typeof window.itemMatchesCustomGroupLabel === 'function') {
        return window.itemMatchesCustomGroupLabel(item, q);
      }

      // STACK-62: Fuzzy fallback — score item fields when exact matching fails
      if (typeof window.featureFlags !== 'undefined' &&
          window.featureFlags.isEnabled('FUZZY_AUTOCOMPLETE') &&
          typeof fuzzyMatch === 'function') {
        const fuzzyThreshold = typeof AUTOCOMPLETE_CONFIG !== 'undefined'
          ? AUTOCOMPLETE_CONFIG.threshold : 0.3;
        const fieldsToCheck = [item.name, item.purchaseLocation, item.storageLocation || '', item.notes || ''];
        for (const field of fieldsToCheck) {
          if (field && fuzzyMatch(q, field, { threshold: fuzzyThreshold }) > 0) {
            if (!window._fuzzyMatchUsed) window._fuzzyMatchUsed = true;
            return true;
          }
        }
      }

      return false;
    });
  });
};

/**
 * Applies a quick filter for a specific field value (when clicking on table values)
 * Supports 3-level deep filtering - clicking same filter removes it, clicking different filters stacks them
 * @param {string} field - The field to filter by
 * @param {string} value - The value to filter for
 * @param {boolean} [isGrouped=false] - Whether this is a grouped/special filter (uses 'include' logic)
 * @param {boolean} [exclude=false] - Whether to apply the filter in exclusion mode
 */
const applyQuickFilter = (field, value, isGrouped = false, exclude = false) => {
  // Fields that support OR-logic multi-select (filter engine already handles these natively)
  const isMultiSelect = field === 'tags' || field === 'metal' || field === 'type';

  // Handle custom group chip click
  if (field === 'customGroup') {
    const groups = typeof window.loadCustomGroups === 'function' ? window.loadCustomGroups() : [];
    const group = groups.find(g => g.id === value);
    if (group) {
      const matchingNames = [];
      inventory.forEach(item => {
        const itemName = (item.name || '').toLowerCase();
        if (group.patterns.some(p => {
          try {
            // nosemgrep: javascript.dos.rule-non-literal-regexp
            return new RegExp('\\b' + p.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\b', 'i').test(itemName);
          } catch (e) { return itemName.includes(p.toLowerCase()); }
        })) {
          matchingNames.push(item.name);
        }
      });
      const uniqueNames = [...new Set(matchingNames)];

      // Toggle behavior: if same custom group filter is active, remove it
      const currentValues = activeFilters['name']?.values || [];
      const currentExclude = !!activeFilters['name']?.exclude;
      const isCurrentlyActive = uniqueNames.length > 0 &&
        uniqueNames.every(n => currentValues.includes(n)) &&
        currentValues.length === uniqueNames.length &&
        currentExclude === exclude;

      if (isCurrentlyActive) {
        delete activeFilters['name'];
      } else if (uniqueNames.length > 0) {
        activeFilters['name'] = { values: uniqueNames, exclude };
      }
    }
    renderTable();
    renderActiveFilters();
    return;
  }

  // Handle dynamic name chip click
  if (field === 'dynamicName') {
    const matchingNames = [];
    inventory.forEach(item => {
      const name = item.name || '';
      if (name.includes('(' + value + ')') || name.includes('"' + value + '"')) {
        matchingNames.push(name);
      }
    });
    const uniqueNames = [...new Set(matchingNames)];

    // Toggle behavior
    const currentValues = activeFilters['name']?.values || [];
    const currentExclude = !!activeFilters['name']?.exclude;
    const isCurrentlyActive = uniqueNames.length > 0 &&
      uniqueNames.every(n => currentValues.includes(n)) &&
      currentValues.length === uniqueNames.length &&
      currentExclude === exclude;

    if (isCurrentlyActive) {
      delete activeFilters['name'];
    } else if (uniqueNames.length > 0) {
      activeFilters['name'] = { values: uniqueNames, exclude };
    }
    renderTable();
    renderActiveFilters();
    return;
  }

  // If this exact filter is already active, remove it (toggle behavior — single-select fields only)
  if (!isMultiSelect && activeFilters[field]?.values?.[0] === value && activeFilters[field]?.exclude === exclude && !isGrouped) {
    delete activeFilters[field];
  } else if (field === 'name' && isGrouped && window.featureFlags && window.featureFlags.isEnabled('GROUPED_NAME_CHIPS')) {
    // Handle grouped name filtering
    if (window.autocomplete && window.autocomplete.normalizeItemName) {
      // Find all item names that normalize to this base name
      const matchingNames = [];
      inventory.forEach(item => {
        if (item.name) {
          const baseName = window.autocomplete.normalizeItemName(item.name);
          if (baseName === value) {
            matchingNames.push(item.name);
          }
        }
      });
      
      // Remove duplicates
      const uniqueNames = [...new Set(matchingNames)];
      
      if (uniqueNames.length > 0) {
        // Check if this grouped filter is already active
        const currentValues = activeFilters[field]?.values || [];
        const currentExclude = !!activeFilters[field]?.exclude;
        const isCurrentlyActive = uniqueNames.every(name => currentValues.includes(name)) &&
                                 currentValues.length === uniqueNames.length &&
                                 currentExclude === exclude;
        
        if (isCurrentlyActive) {
          // Toggle off - remove the filter
          delete activeFilters[field];
        } else {
          // Apply the grouped filter
          activeFilters[field] = { values: uniqueNames, exclude };
        }
      }
    } else {
      // Fallback to regular filtering if normalization is not available
      activeFilters[field] = { values: [value], exclude };
    }
  } else if (isMultiSelect && activeFilters[field] && activeFilters[field].exclude === exclude) {
    // Accumulate: toggle individual values in/out of the active set
    const existing = activeFilters[field].values;
    const idx = existing.indexOf(value);
    if (idx !== -1) {
      const updated = existing.filter(v => v !== value);
      if (updated.length === 0) {
        delete activeFilters[field];
      } else {
        activeFilters[field] = { values: updated, exclude };
      }
    } else {
      activeFilters[field] = { values: [...existing, value], exclude };
    }
  } else {
    // Single-select fields, first click, or switching exclude mode: replace
    activeFilters[field] = { values: [value], exclude };
  }

  // Don't clear search query - allow search + filters to work together
  renderTable();
  renderActiveFilters();
};

/**
 * Legacy function for backward compatibility with table click handlers
 * @param {string} field - The field to filter by
 * @param {string} value - The value to filter for
 */
const applyColumnFilter = (field, value) => {
  applyQuickFilter(field, value);
};

// Export functions for global access
window.clearAllFilters = clearAllFilters;
window.applyQuickFilter = applyQuickFilter;
window.applyColumnFilter = applyColumnFilter;
window.filterInventoryAdvanced = filterInventoryAdvanced;
window.renderActiveFilters = renderActiveFilters;

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