Source: spotLookup.js

// SPOT LOOKUP — Historical Spot Price Lookup for Add/Edit Form (STACK-49)
// =============================================================================

/**
 * Symbol mapping for API requests (metal name → ISO 4217 precious metal code)
 */
const METAL_SYMBOLS = {
  Silver: 'XAG',
  Gold: 'XAU',
  Platinum: 'XPT',
  Palladium: 'XPD',
};

/**
 * Searches local spot history for prices near a given date for a specific metal.
 * Uses progressive widening: exact → ±1d → ±3d → ±7d.
 *
 * @param {string} metalName - Metal name ('Silver', 'Gold', 'Platinum', 'Palladium')
 * @param {string} dateStr - Target date in YYYY-MM-DD format
 * @returns {Array<Object>} Matching entries with `dayOffset` appended, sorted by proximity
 */
const searchSpotByDate = (metalName, dateStr) => {
  if (!metalName || !dateStr || !Array.isArray(spotHistory)) return [];

  const targetDate = new Date(dateStr + 'T00:00:00');
  if (isNaN(targetDate.getTime())) return [];

  // Filter to matching metal
  const metalEntries = spotHistory.filter(e => e.metal === metalName);
  if (metalEntries.length === 0) return [];

  // Compute day offset for each entry
  const withOffset = metalEntries.map(entry => {
    const entryDate = new Date(entry.timestamp);
    const diffMs = entryDate.getTime() - targetDate.getTime();
    const dayOffset = Math.round(diffMs / (1000 * 60 * 60 * 24));
    return { ...entry, dayOffset };
  });

  // Progressive widening: try exact, then ±1, ±3, ±7
  const windows = [0, 1, 3, 7];
  let results = [];
  for (const window of windows) {
    results = withOffset.filter(e => Math.abs(e.dayOffset) <= window);
    if (results.length > 0) break;
  }

  // Sort by proximity (absolute offset), then by newest timestamp
  results.sort((a, b) => {
    const proxDiff = Math.abs(a.dayOffset) - Math.abs(b.dayOffset);
    if (proxDiff !== 0) return proxDiff;
    return new Date(b.timestamp) - new Date(a.timestamp);
  });

  // Deduplicate by day (keep latest entry per calendar day)
  const byDay = new Map();
  results.forEach(entry => {
    const day = entry.timestamp.slice(0, 10);
    if (!byDay.has(day)) {
      byDay.set(day, entry);
    }
  });

  return [...byDay.values()];
};

/**
 * Formats a day offset into a human-readable badge label.
 * @param {number} offset - Day offset from target date
 * @returns {string} Label like "Exact", "+1d", "-2d"
 */
const formatOffsetLabel = (offset) => {
  if (offset === 0) return 'Exact';
  return (offset > 0 ? '+' : '') + offset + 'd';
};

/**
 * Checks whether an API lookup is possible for a given date and returns availability info.
 *
 * @param {string} dateStr - Target date in YYYY-MM-DD format
 * @returns {{ available: boolean, provider: string, providerName: string, withinLimit: boolean, maxDays: number }}
 */
const getApiAvailability = (dateStr) => {
  const result = { available: false, provider: '', providerName: '', withinLimit: false, maxDays: 0 };

  const config = typeof loadApiConfig === 'function' ? loadApiConfig() : null;
  if (!config) return result;

  // Find a suitable provider: prefer active, then fall back to any with a key + batch support
  let provider = '';
  if (config.provider && config.keys[config.provider] && API_PROVIDERS[config.provider]?.batchSupported) {
    provider = config.provider;
  } else {
    for (const p of Object.keys(API_PROVIDERS)) {
      if (config.keys[p] && API_PROVIDERS[p]?.batchSupported) {
        provider = p;
        break;
      }
    }
  }

  if (!provider) return result;

  const providerConfig = API_PROVIDERS[provider];
  result.available = true;
  result.provider = provider;
  result.providerName = providerConfig.name;
  result.maxDays = providerConfig.maxHistoryDays || 30;

  // Check if date is within provider's history limit
  const targetDate = new Date(dateStr + 'T00:00:00');
  const today = new Date();
  today.setHours(0, 0, 0, 0);
  const diffDays = Math.round((today.getTime() - targetDate.getTime()) / (1000 * 60 * 60 * 24));
  result.withinLimit = diffDays <= result.maxDays;

  return result;
};

/**
 * Fetches spot price from the API for a specific date and metal.
 * Records fetched entries into local spotHistory for future lookups.
 *
 * @param {string} metalName - Metal name ('Silver', 'Gold', etc.)
 * @param {string} dateStr - Target date in YYYY-MM-DD format
 * @returns {Promise<Array>} Fetched entries with dayOffset
 */
const fetchSpotForDate = async (metalName, dateStr) => {
  const config = typeof loadApiConfig === 'function' ? loadApiConfig() : null;
  if (!config) throw new Error('No API configuration found.');

  const avail = getApiAvailability(dateStr);
  if (!avail.available) throw new Error('No API provider with batch support is configured.');
  if (!avail.withinLimit) throw new Error(`Date is beyond ${avail.providerName}'s ${avail.maxDays}-day limit.`);

  const provider = avail.provider;
  const providerConfig = API_PROVIDERS[provider];
  const apiKey = config.keys[provider];
  if (!apiKey) throw new Error('No API key configured for ' + avail.providerName);

  const metalSymbol = METAL_SYMBOLS[metalName];
  if (!metalSymbol) throw new Error('Unknown metal: ' + metalName);

  // Build URL from provider's batch endpoint template
  let url = providerConfig.baseUrl + providerConfig.batchEndpoint;
  url = url.replace('{API_KEY}', apiKey);
  url = url.replace('{START_DATE}', dateStr);
  url = url.replace('{END_DATE}', dateStr);
  url = url.replace('{SYMBOLS}', metalSymbol);
  url = url.replace('{CURRENCIES}', metalSymbol);

  // Metals.dev needs special header
  const headers = {};
  if (provider === 'METALS_DEV') {
    headers['Accept'] = 'application/json';
  }

  // Safe: URL constructed from hardcoded API_PROVIDERS config (baseUrl + batchEndpoint + templated dates/metals)
  const response = await fetch(url, { method: 'GET', headers, mode: 'cors' });
  if (!response.ok) throw new Error(`API request failed: HTTP ${response.status}`);

  const data = await response.json();

  // Increment usage counter
  if (config.usage && config.usage[provider]) {
    config.usage[provider].used++;
    if (typeof saveApiConfig === 'function') saveApiConfig(config);
  }

  // Parse response using provider's batch parser
  const parsed = providerConfig.parseBatchResponse(data) || {};
  const history = parsed.history || {};
  const current = parsed.current || {};

  // Collect entries for the requested metal
  const fetched = [];
  const metalKey = metalName.toLowerCase();

  // Process history entries
  if (history[metalKey] && Array.isArray(history[metalKey])) {
    history[metalKey].forEach(entry => {
      const price = entry.price;
      if (typeof price === 'number' && price > 0) {
        const ts = entry.timestamp || dateStr + ' 00:00:00';
        if (typeof recordSpot === 'function') {
          recordSpot(price, 'api', metalName, avail.providerName, ts);
        }
        fetched.push({
          spot: price,
          metal: metalName,
          source: 'api',
          provider: avail.providerName,
          timestamp: ts,
          dayOffset: 0,
        });
      }
    });
  }

  // Process current prices if no history entries
  if (fetched.length === 0 && current[metalKey]) {
    const price = current[metalKey];
    if (typeof price === 'number' && price > 0) {
      const ts = dateStr + ' 00:00:00';
      if (typeof recordSpot === 'function') {
        recordSpot(price, 'api', metalName, avail.providerName, ts);
      }
      fetched.push({
        spot: price,
        metal: metalName,
        source: 'api',
        provider: avail.providerName,
        timestamp: ts,
        dayOffset: 0,
      });
    }
  }

  return fetched;
};

/**
 * Searches historical year files for prices near a given date for a specific metal.
 * Uses the same progressive widening as searchSpotByDate: exact → ±1d → ±3d → ±7d.
 * Requires fetchYearFile() from spot.js (STACK-69).
 *
 * @param {string} metalName - Metal name ('Silver', 'Gold', 'Platinum', 'Palladium')
 * @param {string} dateStr - Target date in YYYY-MM-DD format
 * @returns {Promise<Array<Object>>} Matching entries with `dayOffset` appended, sorted by proximity
 */
const searchHistoricalByDate = async (metalName, dateStr) => {
  if (typeof fetchYearFile !== 'function') return [];

  const targetDate = new Date(dateStr + 'T00:00:00');
  if (isNaN(targetDate.getTime())) return [];

  // Fetch target year + adjacent years (±7d window can cross year boundary)
  const year = targetDate.getFullYear();
  const yearsToFetch = [year - 1, year, year + 1].filter(y => y >= 1968);
  const yearArrays = await Promise.all(yearsToFetch.map(fetchYearFile));
  const allEntries = yearArrays.flat().filter(e => e.metal === metalName);

  if (allEntries.length === 0) return [];

  // Compute day offset for each entry
  const withOffset = allEntries.map(entry => {
    const entryDate = new Date(entry.timestamp);
    const diffMs = entryDate.getTime() - targetDate.getTime();
    const dayOffset = Math.round(diffMs / (1000 * 60 * 60 * 24));
    return { ...entry, dayOffset };
  });

  // Progressive widening: try exact, then ±1, ±3, ±7
  const windows = [0, 1, 3, 7];
  let results = [];
  for (const window of windows) {
    results = withOffset.filter(e => Math.abs(e.dayOffset) <= window);
    if (results.length > 0) break;
  }

  // Sort by proximity (absolute offset), then by newest timestamp
  results.sort((a, b) => {
    const proxDiff = Math.abs(a.dayOffset) - Math.abs(b.dayOffset);
    if (proxDiff !== 0) return proxDiff;
    return new Date(b.timestamp) - new Date(a.timestamp);
  });

  // Deduplicate by day (keep latest entry per calendar day)
  const byDay = new Map();
  results.forEach(entry => {
    const day = entry.timestamp.slice(0, 10);
    if (!byDay.has(day)) {
      byDay.set(day, entry);
    }
  });

  return [...byDay.values()];
};

/**
 * Opens the spot lookup modal, searching local history and historical seed data
 * for the date and metal currently selected in the add/edit form.
 */
const openSpotLookupModal = async () => {
  const dateVal = elements.itemDate ? elements.itemDate.value : '';
  const metalVal = elements.itemMetal ? elements.itemMetal.value : '';

  if (!dateVal) {
    alert('Please select a purchase date first.');
    return;
  }

  // Derive metal name from composition (same logic as parseItemFormFields)
  const composition = typeof getCompositionFirstWords === 'function'
    ? getCompositionFirstWords(metalVal)
    : metalVal;
  const metalName = typeof parseNumistaMetal === 'function'
    ? parseNumistaMetal(composition)
    : composition;

  if (!metalName || metalName === 'Alloy') {
    alert('Please select a supported metal (Silver, Gold, Platinum, or Palladium).');
    return;
  }

  // Search local spotHistory first (≤180 days)
  let results = searchSpotByDate(metalName, dateVal);

  // Fallback: search historical year files (STACK-69)
  if (results.length === 0) {
    results = await searchHistoricalByDate(metalName, dateVal);
  }

  // Update modal title
  const titleEl = document.getElementById('spotLookupTitle');
  if (titleEl) {
    titleEl.textContent = `Spot Lookup — ${metalName} on ${dateVal}`;
  }

  // Render results into modal body
  const bodyEl = document.getElementById('spotLookupBody');
  if (!bodyEl) return;

  if (results.length > 0) {
    renderSpotLookupResults(bodyEl, results, metalName, dateVal);
  } else {
    renderSpotLookupEmpty(bodyEl, metalName, dateVal);
  }

  // Open the modal
  if (typeof openModalById === 'function') {
    openModalById('spotLookupModal');
  }
};

/**
 * Renders spot lookup results into the modal body.
 * @param {HTMLElement} container - The modal body element
 * @param {Array} results - Search results with dayOffset
 * @param {string} metalName - Metal name for API fallback context
 * @param {string} dateStr - Target date for API fallback context
 */
const renderSpotLookupResults = (container, results, metalName, dateStr) => {
  const formatPrice = typeof formatCurrency === 'function' ? formatCurrency : (v) => '$' + Number(v).toFixed(2);

  let html = '<table class="spot-lookup-table"><thead><tr>';
  html += '<th>Date/Time</th><th>Spot Price</th><th>Source</th><th>Offset</th><th></th>';
  html += '</tr></thead><tbody>';

  results.forEach(entry => {
    const ts = entry.timestamp ? formatTimestamp(entry.timestamp) : '';
    const price = formatPrice(entry.spot);
    const source = entry.source === 'seed' ? 'Seed' : (entry.provider || entry.source || '');
    const offsetLabel = formatOffsetLabel(entry.dayOffset);
    const exactClass = entry.dayOffset === 0 ? ' exact' : '';

    html += '<tr>';
    html += `<td>${ts}</td>`;
    html += `<td><strong>${price}</strong></td>`;
    html += `<td>${escapeHtml(source)}</td>`;
    html += `<td><span class="spot-lookup-offset${exactClass}">${offsetLabel}</span></td>`;
    html += `<td><button class="btn spot-lookup-use-btn" type="button" `
         + `data-spot="${escapeHtml(entry.spot)}" data-ts="${escapeHtml(entry.timestamp || '')}">Use</button></td>`;
    html += '</tr>';
  });

  html += '</tbody></table>';

  // nosemgrep: javascript.browser.security.insecure-innerhtml.insecure-innerhtml
  container.innerHTML = html;

  // Attach "Use" button handlers via event delegation
  container.querySelectorAll('.spot-lookup-use-btn').forEach(btn => {
    btn.addEventListener('click', () => {
      const spotPrice = parseFloat(btn.dataset.spot);
      const timestamp = btn.dataset.ts || '';
      useSpotPrice(spotPrice, timestamp);
    });
  });
};

/**
 * Renders the empty state when no local history matches.
 * Shows "Fetch from API" button if a provider is available.
 * @param {HTMLElement} container - The modal body element
 * @param {string} metalName - Metal name
 * @param {string} dateStr - Target date
 */
const renderSpotLookupEmpty = (container, metalName, dateStr) => {
  const avail = getApiAvailability(dateStr);

  let html = '<div class="spot-lookup-empty">';
  html += '<p>No spot price history found for this date.</p>';
  html += '<p class="spot-lookup-hint">Local history retains up to 180 days of price records.</p>';

  if (avail.available) {
    if (avail.withinLimit) {
      html += `<button class="btn secondary spot-lookup-fetch-btn" type="button" `
           + `data-metal="${metalName}" data-date="${dateStr}">`;
      html += `Fetch from ${avail.providerName}</button>`;
    } else {
      html += `<p class="spot-lookup-hint">Date is beyond ${avail.providerName}'s ${avail.maxDays}-day history limit.</p>`;
    }
  } else {
    html += '<p class="spot-lookup-hint">Configure an API key in Settings to fetch historical prices.</p>';
  }

  html += '</div>';

  // nosemgrep: javascript.browser.security.insecure-innerhtml.insecure-innerhtml
  container.innerHTML = html;

  // Attach fetch handler if button exists
  const fetchBtn = container.querySelector('.spot-lookup-fetch-btn');
  if (fetchBtn) {
    fetchBtn.addEventListener('click', async () => {
      const metal = fetchBtn.dataset.metal;
      const date = fetchBtn.dataset.date;

      fetchBtn.textContent = 'Fetching...';
      fetchBtn.disabled = true;

      try {
        const fetched = await fetchSpotForDate(metal, date);
        if (fetched.length > 0) {
          // Re-render with results
          renderSpotLookupResults(container, fetched, metal, date);
        } else {
          fetchBtn.textContent = 'No data returned';
          fetchBtn.disabled = true;
        }
      } catch (err) {
        console.error('Spot lookup API error:', err);
        // Show inline error
        const errEl = document.createElement('p');
        errEl.className = 'spot-lookup-hint';
        errEl.style.color = 'var(--danger, #dc3545)';
        errEl.textContent = err.message || 'Failed to fetch spot price.';
        container.appendChild(errEl);
        fetchBtn.textContent = 'Retry';
        fetchBtn.disabled = false;
      }
    });
  }
};

/**
 * Uses a selected spot price from the lookup modal.
 * Sets the hidden itemSpotPrice field and closes the modal.
 *
 * @param {number} spotPrice - The selected spot price
 * @param {string} timestamp - Timestamp of the selected entry (for reference)
 */
const useSpotPrice = (spotPrice, timestamp) => {
  // Store in hidden field for spotPriceAtPurchase (melt value calculation)
  if (elements.itemSpotPrice) {
    elements.itemSpotPrice.value = spotPrice;
  }

  // Populate visible Purchase Price field (convert USD → display currency)
  if (elements.itemPrice) {
    const fxRate = (typeof getExchangeRate === 'function') ? getExchangeRate() : 1;

    // Goldback: convert gold spot → per-unit Goldback price (STACK-68)
    let priceUSD = spotPrice;
    if (elements.itemWeightUnit && elements.itemWeightUnit.value === 'gb'
        && typeof computeGoldbackEstimatedRate === 'function') {
      const gbRate = computeGoldbackEstimatedRate(spotPrice);
      const denom = parseFloat(
        (elements.itemGbDenom && elements.itemGbDenom.value) ||
        (elements.itemWeight && elements.itemWeight.value) || 1
      );
      priceUSD = gbRate * denom;
    }

    const displayPrice = (priceUSD * fxRate).toFixed(2);
    elements.itemPrice.value = displayPrice;

    // Brief visual highlight on the price field for confirmation
    elements.itemPrice.style.transition = 'background-color 0.3s';
    elements.itemPrice.style.backgroundColor = 'var(--accent, #fbbf24)';
    setTimeout(() => {
      elements.itemPrice.style.backgroundColor = '';
    }, 800);
  }

  closeSpotLookupModal();
};

/**
 * Closes the spot lookup modal.
 */
const closeSpotLookupModal = () => {
  if (typeof closeModalById === 'function') {
    closeModalById('spotLookupModal');
  }
};

// Global exports
window.openSpotLookupModal = openSpotLookupModal;
window.closeSpotLookupModal = closeSpotLookupModal;
window.searchSpotByDate = searchSpotByDate;
window.fetchSpotForDate = fetchSpotForDate;