Source: api.js

// API INTEGRATION FUNCTIONS
// =============================================================================

// Track provider connection status for settings UI
const providerStatuses = {
  STAKTRAKR: "disconnected",
  METALS_DEV: "disconnected",
  METALS_API: "disconnected",
  METAL_PRICE_API: "disconnected",
  CUSTOM: "disconnected",
};

/** Check whether a provider requires an API key */
const providerRequiresKey = (prov) => API_PROVIDERS[prov]?.requiresKey !== false;

/**
 * Fetch spot prices from StakTrakr hourly JSON files.
 * Walks back up to 6 hours from the current UTC hour to find data.
 */
const fetchStaktrakrPrices = async (selectedMetals) => {
  const baseUrl = API_PROVIDERS.STAKTRAKR.hourlyBaseUrl;
  const now = new Date();

  for (let offset = 0; offset <= 6; offset++) {
    const target = new Date(now.getTime() - offset * 3600000);
    const yyyy = target.getUTCFullYear();
    const mm = String(target.getUTCMonth() + 1).padStart(2, '0');
    const dd = String(target.getUTCDate()).padStart(2, '0');
    const hh = String(target.getUTCHours()).padStart(2, '0');

    const url = `${baseUrl}/${yyyy}/${mm}/${dd}/${hh}.json`;
    try {
      const resp = await fetch(url, { mode: 'cors' });
      if (!resp.ok) continue;
      const data = await resp.json();
      const { current } = API_PROVIDERS.STAKTRAKR.parseBatchResponse(data);
      const results = {};
      selectedMetals.forEach(metal => {
        if (current[metal] > 0) results[metal] = current[metal];
      });
      if (Object.keys(results).length > 0) {
        // Track usage for STAKTRAKR
        const cfg = loadApiConfig();
        if (cfg.usage?.STAKTRAKR) {
          cfg.usage.STAKTRAKR.used++;
          saveApiConfig(cfg);
        }
        return results;
      }
    } catch { continue; }
  }
  throw new Error('No hourly data available from StakTrakr API');
};

/**
 * Fetches hourly spot data from StakTrakr for a configurable number of hours.
 * Skips hours already present in spotHistory to avoid duplicates.
 * @param {number} hoursBack - Number of hours to look back
 * @returns {Promise<{newCount: number, fetchCount: number}>} Counts of new entries and successful fetches
 */
const fetchStaktrakrHourlyRange = async (hoursBack) => {
  const baseUrl = API_PROVIDERS.STAKTRAKR.hourlyBaseUrl;
  const now = new Date();

  // Build list of UTC hours as Date objects
  const hours = [];
  for (let i = 0; i < hoursBack; i++) {
    hours.push(new Date(now.getTime() - i * 3600000));
  }

  // Purge once, then build dedup set for batch append (avoids N×save)
  purgeSpotHistory();
  const existingKeys = new Set(
    spotHistory.map(e => `${e.timestamp}|${e.metal}`)
  );

  // Fetch hours in batches of 6
  let newCount = 0;
  let fetchCount = 0;
  const batchSize = 6;
  const providerName = API_PROVIDERS.STAKTRAKR.name;

  for (let i = 0; i < hours.length; i += batchSize) {
    const batch = hours.slice(i, i + batchSize);
    const results = await Promise.all(batch.map(async (h) => {
      const yyyy = h.getUTCFullYear();
      const mm = String(h.getUTCMonth() + 1).padStart(2, '0');
      const dd = String(h.getUTCDate()).padStart(2, '0');
      const hh = String(h.getUTCHours()).padStart(2, '0');
      const url = `${baseUrl}/${yyyy}/${mm}/${dd}/${hh}.json`;
      try {
        const resp = await fetch(url, { mode: 'cors' });
        if (!resp.ok) return null;
        const data = await resp.json();
        const { current } = API_PROVIDERS.STAKTRAKR.parseBatchResponse(data);
        // Use ISO-format UTC timestamp so recordSpot normalizes consistently
        return { current, timestamp: `${yyyy}-${mm}-${dd}T${hh}:00:00Z` };
      } catch { return null; }
    }));

    results.forEach(result => {
      if (!result) return;
      fetchCount++;
      Object.entries(result.current).forEach(([metalKey, spot]) => {
        if (spot <= 0) return;
        const metalConfig = Object.values(METALS).find(m => m.key === metalKey);
        if (!metalConfig) return;
        const entryTimestamp = result.timestamp.replace("T", " ").replace("Z", "");
        const isDuplicate = existingKeys.has(`${entryTimestamp}|${metalConfig.name}`);
        if (!isDuplicate) {
          spotHistory.push({
            spot, metal: metalConfig.name, source: "api-hourly",
            provider: providerName, timestamp: entryTimestamp,
          });
          existingKeys.add(`${entryTimestamp}|${metalConfig.name}`);
          newCount++;
        }
      });
    });
  }

  if (newCount > 0) {
    saveSpotHistory();
    console.log(`[StakTrakr] Added ${newCount} hourly entries (${fetchCount} files fetched)`);
  }

  return { newCount, fetchCount };
};

/**
 * Backfills the last 24 hours of hourly spot data from StakTrakr into spotHistory.
 * Only runs when STAKTRAKR is the primary provider (rank 1) and sync succeeded.
 * @returns {Promise<number>} Count of new entries added
 */
const backfillStaktrakrHourly = async () => {
  const { newCount, fetchCount } = await fetchStaktrakrHourlyRange(24);
  // Track usage per file fetched (each file = 1 API request)
  if (fetchCount > 0) {
    const config = loadApiConfig();
    if (config.usage?.STAKTRAKR) {
      config.usage.STAKTRAKR.used += fetchCount;
      saveApiConfig(config);
    }
  }
  return newCount;
};

/**
 * Handles user-initiated hourly history pull for STAKTRAKR.
 * Reads days from dropdown, confirms, fetches, and updates UI.
 */
const handleStaktrakrHistoryPull = async () => {
  const daysSelect = document.getElementById('historyPullDays_STAKTRAKR');
  const totalDays = daysSelect ? parseInt(daysSelect.value, 10) : 7;
  const totalHours = totalDays * 24;

  const proceed = confirm(
    `Pull ${totalDays} day${totalDays > 1 ? 's' : ''} of hourly history from StakTrakr.\n\n` +
    `This will fetch up to ${totalHours} hourly files (skipping already-fetched hours).\n\nProceed?`
  );
  if (!proceed) return;

  // Disable button during pull
  const btn = document.querySelector('.api-history-btn[data-provider="STAKTRAKR"]');
  const origText = btn ? btn.textContent : "";
  if (btn) { btn.textContent = "Pulling..."; btn.disabled = true; }

  try {
    const { newCount, fetchCount } = await fetchStaktrakrHourlyRange(totalHours);

    // Track usage
    if (fetchCount > 0) {
      const config = loadApiConfig();
      if (config.usage?.STAKTRAKR) {
        config.usage.STAKTRAKR.used += fetchCount;
        saveApiConfig(config);
      }
    }

    alert(
      `History pull complete!\n\n` +
      `Added ${newCount} new entries from ${fetchCount} hourly files.`
    );
    updateProviderHistoryTables();
    if (typeof updateAllSparklines === "function") updateAllSparklines();
  } catch (err) {
    console.error("StakTrakr history pull failed:", err);
    alert("History pull failed: " + err.message);
  } finally {
    if (btn) { btn.textContent = origText; btn.disabled = false; }
  }
};

/**
 * Renders a status summary row in the header for all configured API providers.
 * Displays connection status (connected/disconnected/cached) and last sync time.
 */
const renderApiStatusSummary = () => {
  const container = document.getElementById("apiHeaderStatusRow");
  if (!container) return;

  // Build provider list: Numista first, then metals providers
  const items = [];

  // Numista status
  let numistaStatus = "disconnected";
  try {
    if (typeof catalogConfig !== "undefined" && catalogConfig.getNumistaConfig) {
      const nc = catalogConfig.getNumistaConfig();
      numistaStatus = nc.apiKey ? "connected" : "disconnected";
    }
  } catch (e) { /* ignore */ }
  items.push({ name: "Numista", status: numistaStatus, provider: "NUMISTA" });

  // PCGS status
  let pcgsStatus = "disconnected";
  try {
    if (typeof catalogConfig !== "undefined" && catalogConfig.isPcgsEnabled) {
      pcgsStatus = catalogConfig.isPcgsEnabled() ? "connected" : "disconnected";
    }
  } catch (e) { /* ignore */ }
  items.push({ name: "PCGS", status: pcgsStatus, provider: "PCGS" });

  // Metals providers
  Object.keys(API_PROVIDERS).forEach((prov) => {
    const status = Object.hasOwn(providerStatuses, prov) ? providerStatuses[prov] : "disconnected";    const providerConfig = Object.hasOwn(API_PROVIDERS, prov) ? API_PROVIDERS[prov] : null;    if (!providerConfig) return;
    const name = providerConfig.name;
    const statusClass = status === "cached" ? "connected" : status;
    const lastSync = typeof getLastProviderSyncTime === "function" ? getLastProviderSyncTime(prov) : null;
    let tsLabel = "";
    if (lastSync) {
      const d = new Date(lastSync);
      tsLabel = typeof formatTimestamp === 'function' ? formatTimestamp(d, { year: undefined }) : d.toLocaleString();
    }
    items.push({ name, status: statusClass, tsLabel, provider: prov });
  });

  container.textContent = '';
  items.forEach(item => {
    const span = document.createElement('span');
    span.className = 'api-header-status-item ' + item.status;
    const dot = document.createElement('span');
    dot.className = 'status-dot';
    const nameEl = document.createElement('span');
    nameEl.className = 'status-name';
    nameEl.textContent = item.name;
    span.append(dot, nameEl);
    if (item.tsLabel) {
      const ts = document.createElement('span');
      ts.className = 'status-timestamp';
      ts.textContent = item.tsLabel;
      span.appendChild(ts);
    }
    container.appendChild(span);
  });
};

/** @type {Array<Object>} In-memory buffer for API history log entries */
let apiHistoryEntries = [];
/** @type {string} Current sort column for the API history table */
let apiHistorySortColumn = "";
/** @type {boolean} Sort direction for the API history table */
let apiHistorySortAsc = true;
/** @type {string} Active filter text for searching API history */
let apiHistoryFilterText = "";

/**
 * Loads Metals API configuration from localStorage
 * @returns {Object|null} Metals API configuration or null if not set
 */
const loadApiConfig = () => {
  try {
    const stored = localStorage.getItem(API_KEY_STORAGE_KEY);
    if (stored) {
      const config = JSON.parse(stored);
      if (config.keys) {
        Object.keys(config.keys).forEach((p) => {
          if (config.keys[p]) {
            config.keys[p] = atob(config.keys[p]);
          }
        });
      } else if (config.apiKey && config.provider) {
        // Legacy format migration
        config.keys = { [config.provider]: atob(config.apiKey) };
      }
      const usage = config.usage || {};
      const metals = config.metals || {};
      const historyDays = config.historyDays || {};
      const historyTimes = config.historyTimes || {};
      const currentMonth = currentMonthKey();
      const savedMonth = config.usageMonth;
      Object.keys(API_PROVIDERS).forEach((p) => {
        if (!usage[p]) usage[p] = {
          quota: providerRequiresKey(p) ? DEFAULT_API_QUOTA : 5000,
          used: 0,
        };
        if (!metals[p])
          metals[p] = {
            silver: true,
            gold: true,
            platinum: true,
            palladium: true,
          };
        else {
          ["silver", "gold", "platinum", "palladium"].forEach((m) => {
            if (typeof metals[p][m] === "undefined") metals[p][m] = true;
          });
        }
        if (typeof historyDays[p] !== "number") {
          historyDays[p] = p === "METALS_DEV" ? 29 : 30;
        } else if (p === "METALS_DEV" && historyDays[p] > 30) {
          historyDays[p] = 30;
        }
        if (!Array.isArray(historyTimes[p])) historyTimes[p] = [];
      });
      let needsSave = false;
      if (savedMonth !== currentMonth) {
        Object.keys(usage).forEach((p) => (usage[p].used = 0));
        needsSave = true;
      }
      // Reconstruct per-provider cache timeouts, defaulting to global cacheHours or 24
      const cacheTimeouts = config.cacheTimeouts || {};
      const globalCache = Number.isFinite(config.cacheHours) ? config.cacheHours : 24;
      Object.keys(API_PROVIDERS).forEach((p) => {
        if (!Number.isFinite(cacheTimeouts[p]) || cacheTimeouts[p] < 0) {
          cacheTimeouts[p] = globalCache;
        }
      });

      const result = {
        provider: config.provider || "",
        // Clone keys object to prevent accidental cross-provider references
        keys: { ...(config.keys || {}) },
        cacheHours:
          typeof config.cacheHours === "number" ? config.cacheHours : 24,
        cacheTimeouts,
        customConfig: config.customConfig || {
          baseUrl: "",
          endpoint: "",
          format: "symbol",
        },
        metals,
        usage,
        historyDays,
        historyTimes,
        syncMode: config.syncMode || {},
        usageMonth: currentMonth,
      };
      if (needsSave) {
        saveApiConfig(result);
      }
      return result;
    }
  } catch (error) {
    console.error("Error loading API config:", error);
  }
  const usage = {};
  const metals = {};
  const historyDays = {};
  const historyTimes = {};
  const defaultCacheTimeouts = {};
  Object.keys(API_PROVIDERS).forEach((p) => {
    usage[p] = {
      quota: providerRequiresKey(p) ? DEFAULT_API_QUOTA : 5000,
      used: 0,
    };
    metals[p] = { silver: true, gold: true, platinum: true, palladium: true };
    historyDays[p] = p === "METALS_DEV" ? 29 : 30;
    historyTimes[p] = [];
    defaultCacheTimeouts[p] = 24;
  });
  return {
    provider: "",
    keys: {},
    cacheHours: 24,
    cacheTimeouts: defaultCacheTimeouts,
    customConfig: { baseUrl: "", endpoint: "", format: "symbol" },
    metals,
    usage,
    historyDays,
    historyTimes,
    syncMode: {},
    usageMonth: currentMonthKey(),
  };
};

/**
 * Saves Metals API configuration to localStorage
 * @param {Object} config - Metals API configuration object
 */
const saveApiConfig = (config) => {
  try {
    const configToSave = {
      provider: config.provider || "",
      keys: {},
      cacheHours:
        typeof config.cacheHours === "number" ? config.cacheHours : 24,
      cacheTimeouts: config.cacheTimeouts || {},
      customConfig: config.customConfig || {
        baseUrl: "",
        endpoint: "",
        format: "symbol",
      },
      metals: config.metals || {},
      usage: config.usage || {},
      historyDays: config.historyDays || {},
      historyTimes: config.historyTimes || {},
      syncMode: config.syncMode || {},
      usageMonth: config.usageMonth || currentMonthKey(),
    };
    Object.keys(config.keys || {}).forEach((p) => {
      if (config.keys[p]) {
        configToSave.keys[p] = btoa(config.keys[p]);
      }
    });
    localStorage.setItem(API_KEY_STORAGE_KEY, JSON.stringify(configToSave));

    // Store a cloned copy in memory to avoid shared references
    apiConfig = {
      ...config,
      keys: { ...(config.keys || {}) },
    };
    updateSyncButtonStates();
  } catch (error) {
    console.error("Error saving API config:", error);
  }
};

/**
 * Clears Metals API configuration
 */
const clearApiConfig = () => {
  localStorage.removeItem(API_KEY_STORAGE_KEY);
  localStorage.removeItem(API_CACHE_KEY);
  apiConfig = {
    provider: "",
    keys: {},
    cacheHours: 24,
    customConfig: { baseUrl: "", endpoint: "", format: "symbol" },
  };
  apiCache = null;
  Object.keys(providerStatuses).forEach((p) =>
    setProviderStatus(p, "disconnected"),
  );
  updateSyncButtonStates();
};

/**
 * Clears only the API cache, keeping the configuration
 */
const clearApiCache = () => {
  localStorage.removeItem(API_CACHE_KEY);
  apiCache = null;
  clearApiHistory(true);
  alert(
    "API cache and history cleared. Next sync will pull fresh data from the API.",
  );
};

/**
 * Gets cache duration in milliseconds
 * @returns {number} Cache duration
 */
const getCacheDurationMs = (provider) => {
  let hours;
  if (provider && Number.isFinite(apiConfig?.cacheTimeouts?.[provider])) {
    hours = apiConfig.cacheTimeouts[provider];
  } else {
    hours = apiConfig?.cacheHours ?? 24;
  }
  return (Number.isFinite(hours) && hours >= 0 ? hours : 24) * 60 * 60 * 1000;
};

/**
 * Sets connection status for a provider in the settings UI
 * @param {string} provider
 * @param {"connected"|"disconnected"|"error"|"cached"} status
*/
const setProviderStatus = (provider, status) => {
  providerStatuses[provider] = status;
  renderApiStatusSummary();
  const block = document.querySelector(
    `.api-provider[data-provider="${provider}"] .provider-status`,
  );
  if (!block) return;
  block.classList.remove(
    "status-connected",
    "status-disconnected",
    "status-error",
    "status-cached",
  );
  block.classList.add(
    status === "cached" ? "status-connected" : `status-${status}`,
  );
  const text = block.querySelector(".status-text");
  if (text) {
    text.textContent =
      status === "connected"
        ? "Connected"
        : status === "cached"
          ? "Connected (cached)"
          : status === "error"
            ? "Error"
            : "Disconnected";
  }

  // Update last-used timestamp in provider card
  const lastUsed = block.querySelector(".status-last-used");
  if (lastUsed && typeof getLastProviderSyncTime === "function") {
    const ts = getLastProviderSyncTime(provider);
    if (ts) {
      const d = new Date(ts);
      lastUsed.textContent = "Last: " + (typeof formatTimestamp === 'function' ? formatTimestamp(d, { year: undefined }) : d.toLocaleString());
    } else {
      lastUsed.textContent = "";
    }
  }
};

/**
 * Updates the visual cost indicator for a history pull from a given provider.
 * Displays total API calls or file fetches expected based on current settings.
 *
 * @param {string} provider - The unique key of the API provider
 */
const updateHistoryPullCost = (provider) => {
  const config = loadApiConfig();
  const providerConfig = API_PROVIDERS[provider];
  const costEl = document.getElementById(`historyPullCost_${provider}`);
  if (!costEl || !providerConfig) return;

  const daysSelect = document.getElementById(`historyPullDays_${provider}`);
  const totalDays = daysSelect ? parseInt(daysSelect.value, 10) : 30;

  // STAKTRAKR: show hourly file count instead of API calls
  if (provider === 'STAKTRAKR') {
    const hours = totalDays * 24;
    costEl.textContent = `${totalDays}d = ${hours} hourly files`;
    return;
  }

  const selected = config.metals?.[provider] || {};
  const selectedMetals = Object.keys(selected).filter(metal => selected[metal] !== false);

  // Check for hourly toggle (MetalPriceAPI)
  const hourlyToggle = document.getElementById(`hourlyPull_${provider}`);
  if (hourlyToggle && hourlyToggle.checked) {
    const calls = selectedMetals.length;
    costEl.textContent = `${totalDays}d \u00D7 ${selectedMetals.length} metals = ${calls} API calls (hourly)`;
    return;
  }

  const maxPerReq = providerConfig.maxHistoryDays || 30;
  const chunks = Math.ceil(totalDays / maxPerReq);

  let calls;
  if (providerConfig.symbolsPerRequest === 1) {
    calls = chunks * selectedMetals.length;
    costEl.textContent = `${totalDays}d \u00D7 ${selectedMetals.length} metals = ${calls} API calls`;
  } else {
    calls = chunks;
    costEl.textContent = `${totalDays}d = ${calls} API call${calls > 1 ? "s" : ""}`;
  }
};

/**
 * Updates persistent provider settings (like cache duration) from form inputs.
 * Persists the updated configuration to localStorage.
 *
 * @param {string} provider - The unique key of the API provider
 */
const updateProviderSettings = (provider) => {
  const config = loadApiConfig();

  // Update cache timeout
  const cacheSelect = document.getElementById(`cacheTimeout_${provider}`);
  if (cacheSelect) {
    if (!config.cacheTimeouts) config.cacheTimeouts = {};
    config.cacheTimeouts[provider] = parseFloat(cacheSelect.value);
  }

  saveApiConfig(config);
};

/**
 * Attaches DOM event listeners to the settings controls for a specific provider.
 * Handles cache changes, history pull parameters, and metal selection checkboxes.
 *
 * @param {string} provider - The unique key of the API provider
 */
const setupProviderSettingsListeners = (provider) => {
  // Cache timeout change
  const cacheSelect = document.getElementById(`cacheTimeout_${provider}`);
  if (cacheSelect) {
    cacheSelect.addEventListener('change', () => updateProviderSettings(provider));
  }

  // History pull days dropdown — update cost indicator
  const pullDaysSelect = document.getElementById(`historyPullDays_${provider}`);
  if (pullDaysSelect) {
    pullDaysSelect.addEventListener('change', () => updateHistoryPullCost(provider));
  }

  // History pull button
  const pullBtn = document.querySelector(`.api-history-btn[data-provider="${provider}"]`);
  if (pullBtn) {
    pullBtn.addEventListener('click', () => handleHistoryPull(provider));
  }

  // Hourly toggle — cap days dropdown and update cost
  const hourlyToggle = document.getElementById(`hourlyPull_${provider}`);
  if (hourlyToggle) {
    hourlyToggle.addEventListener('change', () => {
      const daysEl = document.getElementById(`historyPullDays_${provider}`);
      if (daysEl && hourlyToggle.checked) {
        const maxDays = API_PROVIDERS[provider]?.maxHourlyDays || 7;
        if (parseInt(daysEl.value, 10) > maxDays) {
          daysEl.value = String(maxDays);
        }
      }
      updateHistoryPullCost(provider);
    });
  }

  // Metal selection changes
  document.querySelectorAll(`.provider-metal[data-provider="${provider}"]`).forEach(checkbox => {
    checkbox.addEventListener('change', (e) => {
      const config = loadApiConfig();
      const metalKey = e.target.dataset.metal;
      if (!config.metals[provider]) config.metals[provider] = {};
      config.metals[provider][metalKey] = e.target.checked;
      saveApiConfig(config);
      updateHistoryPullCost(provider);
    });
  });

};

/**
 * Renders the API usage/quota visualization (progress bars) for each provider.
 * Displays usage vs quota and handles clicks for quota adjustment modals.
 */
const updateProviderHistoryTables = () => {
  const config = loadApiConfig();
  Object.keys(API_PROVIDERS).forEach((prov) => {
      const container = document.querySelector(
        `.api-provider[data-provider="${prov}"] .provider-settings .provider-history`,
      );
    if (!container) return;
    const usage = config.usage?.[prov] || {
      quota: DEFAULT_API_QUOTA,
      used: 0,
    };
    const usedPercent = Math.min((usage.used / usage.quota) * 100, 100);
    const remainingPercent = 100 - usedPercent;
    const warning = usage.used / usage.quota >= 0.9;
    const safeProv = sanitizeHtml(prov);
    const usageHtml = `<div class="api-usage" data-quota-provider="${safeProv}" style="cursor:pointer" title="Click to edit quota"><div class="usage-bar"><div class="used" style="width:${usedPercent}%"></div><div class="remaining" style="width:${remainingPercent}%"></div></div><div class="usage-text">${usage.used}/${usage.quota} calls${warning ? " 🚩" : ""}</div></div>`;
    // nosemgrep: javascript.browser.security.insecure-innerhtml.insecure-innerhtml, javascript.browser.security.insecure-document-method.insecure-document-method
    container.innerHTML = usageHtml;

    // Make quota bar clickable
    const usageEl = container.querySelector('.api-usage[data-quota-provider]');
    if (usageEl) {
      usageEl.addEventListener('click', () => {
        const modal = document.getElementById('apiQuotaModal');
        const input = document.getElementById('apiQuotaInput');
        if (modal && input) {
          const cfg = loadApiConfig();
          const u = cfg.usage?.[prov] || { quota: DEFAULT_API_QUOTA, used: 0 };
          input.value = u.quota;
          // Store provider for the save handler
          modal.dataset.quotaProvider = prov;
          if (window.openModalById) openModalById('apiQuotaModal');
          else modal.style.display = 'flex';
        }
      });
    }
  });
};

/**
 * Periodically refreshes connection status icons based on key presence and cache age.
 * Determines if a provider is fully connected, cached (needs sync), or disconnected.
 */
const refreshProviderStatuses = () => {
  const config = loadApiConfig();
  let cache = null;
  try {
    const stored = localStorage.getItem(API_CACHE_KEY);
    cache = stored ? JSON.parse(stored) : null;
  } catch (err) {
    console.error("Error reading API cache for status check:", err);
  }
  const now = Date.now();
  Object.keys(API_PROVIDERS).forEach((prov) => {
    const duration = getCacheDurationMs(prov);
    if (config.keys[prov] || !providerRequiresKey(prov)) {
      // API key is stored (or provider is keyless)
      if (cache && cache.provider === prov && cache.timestamp) {
        const age = now - cache.timestamp;
        if (age <= duration) {
          setProviderStatus(prov, "connected");  // Recently used with fresh data
        } else {
          setProviderStatus(prov, "cached");     // Key stored but data is old
        }
      } else if (!providerRequiresKey(prov)) {
        // Keyless provider: check last sync time instead of cache object
        const lastSync = getLastProviderSyncTime(prov);
        if (lastSync && (now - lastSync) <= duration) {
          setProviderStatus(prov, "connected");
        } else if (lastSync) {
          setProviderStatus(prov, "cached");
        } else {
          setProviderStatus(prov, "connected");  // Keyless, always available
        }
      } else {
        setProviderStatus(prov, "cached");       // Key stored but no recent usage
      }
    } else {
      setProviderStatus(prov, "disconnected");   // No API key stored
    }
  });
};

/**
 * Automatically selects the primary API provider based on priority and availability.
 * The highest-priority provider that has a stored API key is selected as default.
 */
const autoSelectDefaultProvider = () => {
  const config = loadApiConfig();
  const keys = config.keys || {};

  // Read tab order from localStorage, fall back to default order
  let order;
  try {
    const stored = localStorage.getItem("apiProviderOrder");
    order = stored ? JSON.parse(stored) : null;
  } catch (e) { order = null; }
  if (!Array.isArray(order) || order.length === 0) {
    order = Object.keys(API_PROVIDERS);
  }

  // Select first provider with a key (or keyless) as default
  const active = order.filter((p) => keys[p] || !providerRequiresKey(p));
  if (active.length > 0 && config.provider !== active[0]) {
    config.provider = active[0];
    saveApiConfig(config);
  } else if (active.length === 0 && config.provider) {
    config.provider = "";
    saveApiConfig(config);
  }
};

// Backward-compatible alias
const updateDefaultProviderButtons = autoSelectDefaultProvider;

/**
 * Returns the effective priority order for API providers.
 * Merges user-defined priority with legacy order and hardcoded defaults.
 *
 * @returns {string[]} Ordered list of provider keys
 */
const getProviderOrder = () => {
  try {
    const stored = localStorage.getItem("providerPriority");
    if (stored) {
      const priorities = JSON.parse(stored);
      if (typeof priorities === 'object' && priorities !== null) {
        return Object.entries(priorities)
          .filter(([, p]) => p > 0)
          .sort((a, b) => a[1] - b[1])
          .map(([prov]) => prov);
      }
    }
  } catch (e) { /* ignore */ }
  // Legacy fallback
  try {
    const stored = localStorage.getItem("apiProviderOrder");
    const order = stored ? JSON.parse(stored) : null;
    if (Array.isArray(order) && order.length > 0) return order;
  } catch (e) { /* ignore */ }
  return Object.keys(API_PROVIDERS);
};

/**
 * Determines the default synchronization behavior for a provider.
 * Higher priority providers default to 'always', others to 'backup'.
 *
 * @param {string} provider - The unique key of the API provider
 * @returns {"always"|"backup"} Recommended sync mode
 */
const getDefaultSyncMode = (provider) => {
  const order = getProviderOrder();
  const config = loadApiConfig();
  const firstActive = order.find(p => config.keys?.[p] || !providerRequiresKey(p));
  return provider === firstActive ? "always" : "backup";
};

/**
 * Renders API history table with filtering, sorting and pagination
 */
const renderApiHistoryTable = () => {
  const table = document.getElementById("apiHistoryTable");
  if (!table) return;
  let data = [...apiHistoryEntries];
  if (apiHistoryFilterText) {
    const f = apiHistoryFilterText.toLowerCase();
    data = data.filter((e) =>
      Object.values(e).some((v) => String(v).toLowerCase().includes(f)),
    );
  }
  if (apiHistorySortColumn) {
    data.sort((a, b) => {
      const valA = a[apiHistorySortColumn];
      const valB = b[apiHistorySortColumn];
      if (valA < valB) return apiHistorySortAsc ? -1 : 1;
      if (valA > valB) return apiHistorySortAsc ? 1 : -1;
      return 0;
    });
  }
  if (!apiHistorySortColumn) {
    data.sort((a, b) => (a.timestamp < b.timestamp ? 1 : -1));
  }

  let html =
    "<tr><th data-column=\"timestamp\">Time</th><th data-column=\"metal\">Metal</th><th data-column=\"spot\">Price</th><th data-column=\"provider\">Source</th></tr>";
  data.forEach((e) => {
    const sourceLabel = e.source === "api-hourly"
      ? `${e.provider || ""} (hourly)`
      : (e.provider || "");
    html += `<tr><td>${e.timestamp}</td><td>${e.metal}</td><td>${formatCurrency(
      e.spot,
    )}</td><td>${sourceLabel}</td></tr>`;
  });
  // nosemgrep: javascript.browser.security.insecure-innerhtml.insecure-innerhtml, javascript.browser.security.insecure-document-method.insecure-document-method
  table.innerHTML = html;

  table.querySelectorAll("th").forEach((th) => {
    th.addEventListener("click", () => {
      const col = th.dataset.column;
      if (apiHistorySortColumn === col) {
        apiHistorySortAsc = !apiHistorySortAsc;
      } else {
        apiHistorySortColumn = col;
        apiHistorySortAsc = true;
      }
      renderApiHistoryTable();
    });
  });

};

/**
 * Opens the API history modal and populates it with filtered spot history data.
 * Displays only 'api', 'api-hourly', and 'seed' entries in the log.
 */
const showApiHistoryModal = () => {
  const modal = document.getElementById("apiHistoryModal");
  if (!modal) return;
  loadSpotHistory();
  apiHistoryEntries = spotHistory.filter((e) => e.source === "api" || e.source === "api-hourly" || e.source === "seed");
  apiHistorySortColumn = "";
  apiHistorySortAsc = true;
  apiHistoryFilterText = "";
  const filterInput = document.getElementById("apiHistoryFilter");
  const clearFilterBtn = document.getElementById("apiHistoryClearFilterBtn");
  if (filterInput) {
    filterInput.value = "";
    filterInput.oninput = (e) => {
      apiHistoryFilterText = e.target.value;
      renderApiHistoryTable();
    };
  }
  if (clearFilterBtn) {
    clearFilterBtn.onclick = () => {
      apiHistoryFilterText = "";
      if (filterInput) filterInput.value = "";
      renderApiHistoryTable();
    };
  }
  renderApiHistoryTable();
  modal.style.display = "flex";
};

/**
 * Closes the API history modal.
 */
const hideApiHistoryModal = () => {
  const modal = document.getElementById("apiHistoryModal");
  if (modal) modal.style.display = "none";
};

/**
 * Opens the API provider selection modal (redirects to the API section of Settings).
 */
const showApiProvidersModal = () => {
  // Redirect to Settings modal API section
  if (typeof showSettingsModal === "function") {
    showSettingsModal('api');
  }
};

/**
 * Closes the API providers modal (legacy wrapper for hideSettingsModal).
 */
const hideApiProvidersModal = () => {
  // Legacy — Settings modal handles its own close
  if (typeof hideSettingsModal === "function") {
    hideSettingsModal();
  }
};

/**
 * Clears all stored spot price history from localStorage and re-renders UI.
 *
 * @param {boolean} [silent=false] - If true, suppresses reopening the history modal
 */
const clearApiHistory = (silent = false) => {
  spotHistory = [];
  saveSpotHistory();
  updateProviderHistoryTables();
  if (!silent) {
    showApiHistoryModal();
  }
};

/**
 * Updates the active API provider in configuration.
 * Validates that the provider has a key (if required) before switching.
 *
 * @param {string} provider - The unique key of the API provider
 */
const setDefaultProvider = (provider) => {
  const config = loadApiConfig();
  if (!config.keys[provider] && providerRequiresKey(provider)) {
    alert("Please enter your API key first");
    return;
  }
  config.provider = provider;
  saveApiConfig(config);
  updateDefaultProviderButtons();
  updateSyncButtonStates();
};

/**
 * Removes the stored API key for a given provider from configuration.
 * Also handles fallback to other available providers if necessary.
 *
 * @param {string} provider - The unique key of the API provider
 */
const clearApiKey = (provider) => {
  const config = loadApiConfig();
  delete config.keys[provider];
  if (config.provider === provider) {
    config.provider = "";
  }
  const active = Object.keys(API_PROVIDERS).filter((p) => config.keys[p] || !providerRequiresKey(p));
  if (active.length === 1) {
    config.provider = active[0];
  }
  saveApiConfig(config);
  const input = document.getElementById(`apiKey_${provider}`);
  if (input) input.value = "";
  if (provider === "CUSTOM") {
    config.customConfig = { baseUrl: "", endpoint: "", format: "symbol" };
    const base = document.getElementById("apiBase_CUSTOM");
    const endpoint = document.getElementById("apiEndpoint_CUSTOM");
    const format = document.getElementById("apiFormat_CUSTOM");
    if (base) base.value = "";
    if (endpoint) endpoint.value = "";
    if (format) format.value = "symbol";
    saveApiConfig(config);
  }
  setProviderStatus(provider, "disconnected");
  updateDefaultProviderButtons();
  updateProviderHistoryTables();
};

/**
 * Force-refreshes all spot price displays using the most recent cached data.
 * Does not make external network requests.
 *
 * @returns {boolean} True if display was successfully updated from cache
 */
const refreshFromCache = () => {
  const cache = loadApiCache();
  if (!cache || !cache.data) {
    return false;
  }

  let updatedCount = 0;
  Object.entries(cache.data).forEach(([metal, price]) => {
    const metalConfig = Object.values(METALS).find((m) => m.key === metal);
    if (metalConfig && price > 0) {
      // Save to localStorage
      localStorage.setItem(metalConfig.spotKey, price.toString());
      spotPrices[metal] = price;

      // Update display
      elements.spotPriceDisplay[metal].textContent = formatCurrency(price);

      updateSpotCardColor(metal, price);

      // Record in history as 'cached' to distinguish from fresh API calls
      recordSpot(
        price,
        "cached",
        metalConfig.name,
        API_PROVIDERS[cache.provider]?.name,
      );

      const ts = document.getElementById(`spotTimestamp${metalConfig.name}`);
      if (ts) {
        updateSpotTimestamp(metalConfig.name);
      }

      updatedCount++;
    }
  });

  if (updatedCount > 0) {
    // Update summary calculations
    updateSummary();
    if (typeof updateAllSparklines === "function") {
      updateAllSparklines();
    }
    if (typeof onGoldSpotPriceChanged === 'function') onGoldSpotPriceChanged();
    return true;
  }

  return false;
};

/**
 * Retrieves valid cached API response data from localStorage.
 * Checks against the provider's specific cache duration before returning.
 *
 * @returns {Object|null} Cached response or null if expired/not found
 */
const loadApiCache = () => {
  try {
    const stored = localStorage.getItem(API_CACHE_KEY);
    if (stored) {
      const cache = JSON.parse(stored);
      const now = new Date().getTime();

      const duration = getCacheDurationMs(cache.provider);
      if (cache.timestamp && now - cache.timestamp < duration) {
        return cache;
      } else {
        // Cache expired, remove it
        localStorage.removeItem(API_CACHE_KEY);
      }
    }
  } catch (error) {
    console.error("Error loading API cache:", error);
  }
  return null;
};

/**
 * Persists API response data to the local browser cache.
 * Uses provider-specific cache duration settings.
 *
 * @param {Object} data - Standardized price data object
 * @param {string} provider - Key of the data provider
 */
const saveApiCache = (data, provider) => {
  try {
    const duration = getCacheDurationMs(provider);
    if (duration === 0) {
      localStorage.removeItem(API_CACHE_KEY);
      apiCache = null;
      return;
    }
    const cacheObject = {
      timestamp: new Date().getTime(),
      data: data,
      provider,
    };
    localStorage.setItem(API_CACHE_KEY, JSON.stringify(cacheObject));
    apiCache = cacheObject;
  } catch (error) {
    console.error("Error saving API cache:", error);
  }
};

/**
 * Triggers an automatic background spot price synchronization.
 * Only runs if API keys are configured and local data is stale.
 *
 * @returns {Promise<void>} Resolves when background sync process ends
 */
const autoSyncSpotPrices = async () => {
  const config = loadApiConfig();
  const hasAnyKey = Object.values(config.keys || {}).some(k => k);
  const hasKeylessProvider = Object.keys(API_PROVIDERS).some(p => !providerRequiresKey(p));
  if (!hasAnyKey && !hasKeylessProvider) return;

  await syncProviderChain({ showProgress: false, forceSync: false });
  updateSyncButtonStates();
};

/**
 * Scans the spot history log to find the most recent successful sync for a provider.
 *
 * @param {string} provider - The unique key of the API provider
 * @returns {number|null} Millisecond timestamp of last sync, or null
 */
const getLastProviderSyncTime = (provider) => {
  try {
    const providerName = API_PROVIDERS[provider]?.name;
    if (!providerName || !spotHistory || !spotHistory.length) return null;
    // Find most recent API entry from this provider
    for (let i = spotHistory.length - 1; i >= 0; i--) {
      const entry = spotHistory[i];
      if ((entry.source === "api" || entry.source === "api-hourly") && entry.provider === providerName) {
        // Parse timestamp string "YYYY-MM-DD HH:MM:SS" to ms
        const ts = new Date(entry.timestamp).getTime();
        if (!isNaN(ts)) return ts;
      }
    }
  } catch (e) {
    console.warn("Error checking provider sync time:", e);
  }
  return null;
};

/**
 * Calculates the expected API usage (call count) for a given sync operation.
 * Accounts for batch support and historical data backfill.
 *
 * @param {string[]} selectedMetals - Array of metal keys to fetch
 * @param {number} [historyDays=0] - Number of days of history to include
 * @param {boolean} [batchSupported=false] - Whether the provider supports batch calls
 * @returns {Object} Usage breakdown including calls, type, and potential savings
 */
const calculateApiUsage = (selectedMetals, historyDays = 0, batchSupported = false) => {
  if (batchSupported && selectedMetals.length > 1) {
    return {
      calls: 1,
      type: 'batch',
      metals: selectedMetals.length,
      days: historyDays,
      saved: selectedMetals.length - 1 + (historyDays > 0 ? selectedMetals.length * historyDays : 0)
    };
  } else {
    const currentPriceCalls = selectedMetals.length;
    const historicalCalls = historyDays > 0 ? selectedMetals.length * historyDays : 0;
    return {
      calls: currentPriceCalls + historicalCalls,
      type: 'individual',
      metals: selectedMetals.length,
      days: historyDays,
      saved: 0
    };
  }
};

/**
 * Fetches the most recent spot prices for selected metals using individual endpoints.
 * Optimized for low-cost, real-time updates without full history backfill.
 *
 * @param {string} provider - The unique key of the API provider
 * @param {string} apiKey - The API key for the provider
 * @param {string[]} selectedMetals - Array of metal keys to fetch
 * @returns {Promise<Object<string, number>>} Map of metal keys to spot prices
 */
const fetchLatestPrices = async (provider, apiKey, selectedMetals) => {
  const providerConfig = API_PROVIDERS[provider];
  if (!providerConfig) throw new Error("Invalid API provider");

  const config = loadApiConfig();
  const usage = config.usage?.[provider] || { quota: DEFAULT_API_QUOTA, used: 0 };
  const results = {};

  // metals.dev supports a batch /latest endpoint returning all metals in one call
  if (provider === "METALS_DEV" && providerConfig.latestBatchEndpoint) {
    try {
      const url = providerConfig.baseUrl + providerConfig.latestBatchEndpoint.replace("{API_KEY}", apiKey);
      const headers = { "Content-Type": "application/json" };
      if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`;

      // Safe: URL constructed from hardcoded API_PROVIDERS config (latestBatchEndpoint)
      const response = await fetch(url, { method: "GET", headers, mode: "cors" });
      if (!response.ok) throw new Error(`HTTP ${response.status}: ${response.statusText}`);

      const data = await response.json();
      usage.used++;

      const parsed = providerConfig.parseLatestBatchResponse(data);
      selectedMetals.forEach((metal) => {
        if (parsed[metal] && parsed[metal] > 0) results[metal] = parsed[metal];
      });
    } catch (err) {
      console.warn("Batch latest failed for METALS_DEV, falling back to individual:", err.message);
      // Fall through to individual requests below
    }
  }

  // Individual requests for remaining metals (or all metals for non-batch providers)
  if (Object.keys(results).length < selectedMetals.length) {
    const remaining = selectedMetals.filter((m) => !results[m]);

    if (provider === "CUSTOM") {
      const custom = config.customConfig || {};
      const base = custom.baseUrl || "";
      const pattern = custom.endpoint || "";
      const format = custom.format || "symbol";

      // Validate custom API base URL before use
      try {
        const validated = new URL(base);
        if (validated.protocol !== 'https:') {
          throw new Error('Custom API base must use HTTPS');
        }
      } catch (urlErr) {
        console.warn('Invalid custom API base URL:', base, urlErr.message);
        return results;
      }
      const metalCodes = {
        silver: format === "symbol" ? "XAG" : "silver",
        gold: format === "symbol" ? "XAU" : "gold",
        platinum: format === "symbol" ? "XPT" : "platinum",
        palladium: format === "symbol" ? "XPD" : "palladium",
      };
      for (const metal of remaining) {
        try {
          const endpoint = pattern.replace("{API_KEY}", apiKey).replace("{METAL}", metalCodes[metal]);
          const url = `${base}${endpoint}`;
          const response = await fetch(url, { method: "GET", headers: { "Content-Type": "application/json" }, mode: "cors" });
          if (!response.ok) throw new Error(`HTTP ${response.status}: ${response.statusText}`);
          const data = await response.json();
          usage.used++;
          const price = providerConfig.parseResponse(data, metal);
          if (price && price > 0) results[metal] = price;
        } catch (err) {
          console.warn(`Latest fetch failed for ${metal}:`, err.message);
        }
      }
    } else {
      for (const metal of remaining) {
        const endpoint = providerConfig.endpoints[metal];
        if (!endpoint) continue;
        try {
          // Safe: URL constructed from hardcoded API_PROVIDERS config (baseUrl + endpoints)
          const url = `${providerConfig.baseUrl}${endpoint.replace("{API_KEY}", apiKey)}`;
          const headers = { "Content-Type": "application/json" };
          if (provider === "METALS_DEV" && apiKey) headers["Authorization"] = `Bearer ${apiKey}`;
          const response = await fetch(url, { method: "GET", headers, mode: "cors" });
          if (!response.ok) throw new Error(`HTTP ${response.status}: ${response.statusText}`);
          const data = await response.json();
          usage.used++;
          const price = providerConfig.parseResponse(data, metal);
          if (price && price > 0) results[metal] = price;
        } catch (err) {
          console.warn(`Latest fetch failed for ${metal}:`, err.message);
        }
      }
    }
  }

  if (Object.keys(results).length === 0) {
    throw new Error("No valid prices retrieved from latest endpoints");
  }

  config.usage[provider] = usage;
  saveApiConfig(config);
  return results;
};

/**
 * Executes a batch API request to retrieve spot prices for multiple metals simultaneously.
 * Supports historical data range requests if provided by the underlying API.
 *
 * @param {string} provider - The unique key of the API provider
 * @param {string} apiKey - The API key for the provider
 * @param {string[]} selectedMetals - Array of metal keys to fetch
 * @param {number} [historyDays=0] - Number of days of history to include
 * @param {string[]} [historyTimes=[]] - Array of HH:MM times for granular history
 * @returns {Promise<Object<string, number>>} Map of metal keys to spot prices
 */
const fetchBatchSpotPrices = async (provider, apiKey, selectedMetals, historyDays = 0, historyTimes = []) => {
  const providerConfig = API_PROVIDERS[provider];
  if (!providerConfig || !providerConfig.batchSupported) {
    throw new Error("Provider does not support batch requests");
  }

  if (provider === "METALS_DEV" && historyDays > 30) historyDays = 30;

  const config = loadApiConfig();
  const usage = config.usage?.[provider] || { quota: DEFAULT_API_QUOTA, used: 0 };

  try {
    let url = providerConfig.baseUrl + providerConfig.batchEndpoint;

    // Replace placeholders based on provider specifics
    if (provider === 'METALS_DEV') {
      url = url.replace('{API_KEY}', apiKey);
    } else if (provider === 'METALS_API') {
      const symbolMap = { silver: 'XAG', gold: 'XAU', platinum: 'XPT', palladium: 'XPD' };
      const symbols = selectedMetals.map(metal => symbolMap[metal]).join(',');
      url = url.replace('{API_KEY}', apiKey)
              .replace('{SYMBOLS}', symbols);
    } else if (provider === 'METAL_PRICE_API') {
      const symbolMap = { silver: 'XAG', gold: 'XAU', platinum: 'XPT', palladium: 'XPD' };
      const currencies = selectedMetals.map(metal => symbolMap[metal]).join(',');
      url = url.replace('{API_KEY}', apiKey)
              .replace('{CURRENCIES}', currencies);
    }

    // Compute start/end dates for timeseries endpoints (all providers)
    if (url.includes('{START_DATE}') || url.includes('{END_DATE}')) {
      const end = new Date();
      const start = new Date();
      start.setDate(start.getDate() - (historyDays || 29));
      const fmt = (d) => d.toISOString().slice(0, 10);
      url = url.replace('{START_DATE}', fmt(start))
              .replace('{END_DATE}', fmt(end));
    }

    // Apply historical parameters if supported
    if (url.includes('{DAYS}')) {
      url = url.replace('{DAYS}', historyDays);
      if (Array.isArray(historyTimes) && historyTimes.length) {
        const timesParam = historyTimes.map(t => encodeURIComponent(t)).join(',');
        if (url.includes('{TIMES}')) {
          url = url.replace('{TIMES}', timesParam);
        } else {
          url += `&times=${timesParam}`;
        }
      }
    }

    const headers = {
      "Content-Type": "application/json",
    };

    if (provider === "METALS_DEV" && apiKey) {
      headers["Authorization"] = `Bearer ${apiKey}`;
    }

    const response = await fetch(url, {
      method: "GET",
      headers: headers,
      mode: "cors",
    });

    if (!response.ok) {
      throw new Error(`HTTP ${response.status}: ${response.statusText}`);
    }

    const data = await response.json();
    usage.used++; // Only increment by 1 for batch request

    const { current = {}, history = {} } =
      providerConfig.parseBatchResponse(data) || {};

    // Filter results to only include selected metals
    const filteredResults = {};
    selectedMetals.forEach((metal) => {
      if (current[metal] && current[metal] > 0) {
        filteredResults[metal] = current[metal];
      }
    });

    if (Object.keys(filteredResults).length === 0) {
      throw new Error("No valid prices retrieved from batch request");
    }

    // Record historical data if provided
    const providerName = providerConfig.name;
    Object.entries(history).forEach(([metal, entries]) => {
      const metalConfig = Object.values(METALS).find((m) => m.key === metal);
      const metalName = metalConfig?.name || metal;
      entries.forEach(({ timestamp, price }) => {
        recordSpot(price, "api", metalName, providerName, timestamp);
      });
    });
    if (Object.keys(history).length) {
      renderApiHistoryTable();
    }

    // Update usage
    config.usage[provider] = usage;
    saveApiConfig(config);

    return filteredResults;
  } catch (error) {
    throw new Error(`Batch request failed: ${error.message}`);
  }
};

/**
 * Standard interface for fetching spot prices from any supported API provider.
 * Automatically chooses between individual latest endpoints or batch calls.
 *
 * @param {string} provider - The unique key of the API provider
 * @param {string} apiKey - The API key for the provider
 * @returns {Promise<Object<string, number>>} Map of metal keys to spot prices
 */
const fetchSpotPricesFromApi = async (provider, apiKey) => {
  const providerConfig = API_PROVIDERS[provider];
  if (!providerConfig) {
    throw new Error("Invalid API provider");
  }

  const config = loadApiConfig();
  const selected = config.metals?.[provider] || {};

  // Get selected metals
  const selectedMetals = Object.keys(selected).filter(
    (metal) => selected[metal] !== false,
  );

  if (selectedMetals.length === 0) {
    throw new Error("No metals selected for sync");
  }

  // StakTrakr uses its own hourly JSON fetch instead of generic provider logic
  if (provider === 'STAKTRAKR') {
    return await fetchStaktrakrPrices(selectedMetals);
  }

  // Latest-only: no history backfill on regular sync
  return await fetchLatestPrices(provider, apiKey, selectedMetals);
};

// =============================================================================
// BATCHED HISTORY PULL
// =============================================================================

/**
 * Splits a requested historical time range into smaller date chunks.
 * Ensures each request stays within the provider's maximum allowed days per call.
 *
 * @param {number} totalDays - Total number of days to fetch
 * @param {number} maxPerRequest - Maximum days allowed per API request
 * @returns {Array<{start: Date, end: Date}>} Array of date range objects, newest first
 */
const getDateChunks = (totalDays, maxPerRequest) => {
  const chunks = [];
  const today = new Date();
  let remaining = totalDays;
  let endDate = new Date(today);
  while (remaining > 0) {
    const chunkSize = Math.min(remaining, maxPerRequest);
    const startDate = new Date(endDate);
    startDate.setDate(startDate.getDate() - chunkSize);
    chunks.push({ start: new Date(startDate), end: new Date(endDate) });
    endDate = new Date(startDate);
    remaining -= chunkSize;
  }
  return chunks;
};

/**
 * Orchestrates a series of batched API requests to backfill historical spot price data.
 * Automates the chunking process and parses results for multiple metals.
 *
 * @param {string} provider - The unique key of the API provider
 * @param {string} apiKey - The API key for the provider
 * @param {string[]} selectedMetals - Array of metal keys to fetch
 * @param {number} totalDays - Total number of days of history to pull
 * @returns {Promise<{totalEntries: number, callsMade: number}>} Summary of the batch operation
 */
const fetchHistoryBatched = async (provider, apiKey, selectedMetals, totalDays) => {
  const providerConfig = API_PROVIDERS[provider];
  if (!providerConfig || !providerConfig.batchSupported) {
    throw new Error("Provider does not support history requests");
  }

  const maxPerReq = providerConfig.maxHistoryDays || 30;
  const chunks = getDateChunks(totalDays, maxPerReq);
  const config = loadApiConfig();
  const usage = config.usage?.[provider] || { quota: DEFAULT_API_QUOTA, used: 0 };
  const providerName = providerConfig.name;
  const fmt = (d) => d.toISOString().slice(0, 10);

  // Build symbol groups based on provider capability
  let symbolGroups;
  if (providerConfig.symbolsPerRequest === 1) {
    // One metal per request (e.g., metals-api)
    symbolGroups = selectedMetals.map((m) => [m]);
  } else {
    // All metals in one request
    symbolGroups = [selectedMetals];
  }

  let totalEntries = 0;
  let callsMade = 0;

  for (const chunk of chunks) {
    for (const metals of symbolGroups) {
      let url = providerConfig.baseUrl + providerConfig.batchEndpoint;

      // Replace API key and currency placeholders
      url = url.replace("{API_KEY}", apiKey);

      // Replace date placeholders
      url = url.replace("{START_DATE}", fmt(chunk.start)).replace("{END_DATE}", fmt(chunk.end));

      // Replace symbol/currency placeholders
      if (provider === "METALS_API") {
        const symbolMap = { silver: "XAG", gold: "XAU", platinum: "XPT", palladium: "XPD" };
        const symbols = metals.map((m) => symbolMap[m]).join(",");
        url = url.replace("{SYMBOLS}", symbols);
      } else if (provider === "METAL_PRICE_API") {
        const symbolMap = { silver: "XAG", gold: "XAU", platinum: "XPT", palladium: "XPD" };
        const currencies = metals.map((m) => symbolMap[m]).join(",");
        url = url.replace("{CURRENCIES}", currencies);
      }

      const headers = { "Content-Type": "application/json" };
      if (provider === "METALS_DEV" && apiKey) {
        headers["Authorization"] = `Bearer ${apiKey}`;
      }

      try {
        // 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(`HTTP ${response.status}: ${response.statusText}`);

        const data = await response.json();
        callsMade++;
        usage.used++;

        const { history = {} } = providerConfig.parseBatchResponse(data) || {};

        Object.entries(history).forEach(([metal, entries]) => {
          if (!selectedMetals.includes(metal)) return;
          const metalConfig = Object.values(METALS).find((m) => m.key === metal);
          const metalName = metalConfig?.name || metal;
          entries.forEach(({ timestamp, price }) => {
            recordSpot(price, "api", metalName, providerName, timestamp);
            totalEntries++;
          });
        });
      } catch (err) {
        console.error(`History batch failed (${fmt(chunk.start)}..${fmt(chunk.end)}):`, err.message);
      }
    }
  }

  // Save updated usage
  config.usage[provider] = usage;
  saveApiConfig(config);

  return { totalEntries, callsMade };
};

/**
 * Specialized history fetcher for MetalPriceAPI's hourly endpoint.
 * Requests granular hourly data for a specific date range.
 *
 * @param {string} apiKey - The API key for MetalPriceAPI
 * @param {string[]} selectedMetals - Array of metal keys to fetch
 * @param {number} totalDays - Number of days of history to pull
 * @returns {Promise<{totalEntries: number, callsMade: number}>} Summary of the operation
 */
const fetchMetalPriceApiHourly = async (apiKey, selectedMetals, totalDays) => {
  const baseUrl = API_PROVIDERS.METAL_PRICE_API.baseUrl;
  const symbolMap = { silver: 'XAG', gold: 'XAU', platinum: 'XPT', palladium: 'XPD' };
  const config = loadApiConfig();
  const usage = config.usage?.METAL_PRICE_API || { quota: DEFAULT_API_QUOTA, used: 0 };
  const providerName = API_PROVIDERS.METAL_PRICE_API.name;

  const end = new Date();
  const start = new Date();
  start.setDate(start.getDate() - totalDays);
  const fmt = (d) => d.toISOString().slice(0, 10);

  // Purge once, then build dedup set for batch append (avoids N×save)
  purgeSpotHistory();
  const existingKeys = new Set(
    spotHistory.map(e => `${e.timestamp}|${e.metal}`)
  );

  let totalEntries = 0;
  let callsMade = 0;

  for (const metal of selectedMetals) {
    const currency = symbolMap[metal];
    if (!currency) continue;
    const url = (baseUrl + API_PROVIDERS.METAL_PRICE_API.hourlyEndpoint)
      .replace("{API_KEY}", encodeURIComponent(apiKey))
      .replace("{CURRENCY}", currency)
      .replace("{START_DATE}", fmt(start))
      .replace("{END_DATE}", fmt(end));
    try {
      const resp = await fetch(url, { method: 'GET', headers: { 'Content-Type': 'application/json' }, mode: 'cors' });
      if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
      const data = await resp.json();
      callsMade++;
      usage.used++;

      const metalConfig = Object.values(METALS).find(m => m.key === metal);
      const metalName = metalConfig?.name || metal;
      (data.rates || []).forEach(entry => {
        const ts = new Date(entry.timestamp * 1000);
        const entryTimestamp = ts.toISOString().replace('T', ' ').slice(0, 19);
        const rate = entry.rates?.[currency];
        if (!Number.isFinite(rate) || rate === 0) return;
        const price = 1 / rate;
        const key = `${entryTimestamp}|${metalName}`;
        if (!existingKeys.has(key)) {
          spotHistory.push({
            spot: price, metal: metalName, source: 'api-hourly',
            provider: providerName, timestamp: entryTimestamp,
          });
          existingKeys.add(key);
          totalEntries++;
        }
      });
    } catch (err) {
      console.warn(`Hourly fetch failed for ${metal}:`, err.message);
    }
  }

  if (totalEntries > 0) {
    saveSpotHistory();
  }

  config.usage.METAL_PRICE_API = usage;
  saveApiConfig(config);
  return { totalEntries, callsMade };
};

/**
 * UI entry point for initiating a historical data pull for a provider.
 * Validates requirements, shows cost/quota confirmation, and executes pull.
 *
 * @param {string} provider - The unique key of the API provider
 */
const handleHistoryPull = async (provider) => {
  // STAKTRAKR has its own hourly pull logic (no API key needed)
  if (provider === 'STAKTRAKR') {
    return handleStaktrakrHistoryPull();
  }

  const config = loadApiConfig();
  const apiKey = config.keys?.[provider];
  if (!apiKey) {
    alert("No API key configured for this provider. Please save your key first.");
    return;
  }

  const providerConfig = API_PROVIDERS[provider];
  if (!providerConfig || !providerConfig.batchSupported) {
    alert("This provider does not support history pulls.");
    return;
  }

  const selected = config.metals?.[provider] || {};
  const selectedMetals = Object.keys(selected).filter((m) => selected[m] !== false);
  if (selectedMetals.length === 0) {
    alert("No metals selected. Please select at least one metal to track.");
    return;
  }

  const daysSelect = document.getElementById(`historyPullDays_${provider}`);
  let totalDays = daysSelect ? parseInt(daysSelect.value, 10) : 30;

  // Check for hourly mode (MetalPriceAPI)
  const hourlyToggle = document.getElementById(`hourlyPull_${provider}`);
  const isHourly = hourlyToggle && hourlyToggle.checked;
  if (isHourly) {
    const maxHourly = providerConfig.maxHourlyDays || 7;
    totalDays = Math.min(totalDays, maxHourly);
  }

  // Calculate cost — one request per metal for hourly, chunked batches for daily
  const totalCalls = isHourly
    ? selectedMetals.length
    : Math.ceil(totalDays / (providerConfig.maxHistoryDays || 30))
      * (providerConfig.symbolsPerRequest === 1 ? selectedMetals.length : 1);

  const usage = config.usage?.[provider] || { quota: DEFAULT_API_QUOTA, used: 0 };
  const remaining = Math.max(0, usage.quota - usage.used);

  const modeLabel = isHourly ? "hourly" : "daily";
  const proceed = confirm(
    `Pull ${totalDays} days of ${modeLabel} history from ${providerConfig.name}.\n\n` +
    `This will use ${totalCalls} API call${totalCalls > 1 ? "s" : ""} ` +
    `(${remaining} remaining this month).\n\nProceed?`
  );
  if (!proceed) return;

  // Disable button during pull
  const btn = document.querySelector(`.api-history-btn[data-provider="${provider}"]`);
  const origText = btn ? btn.textContent : "";
  if (btn) { btn.textContent = "Pulling..."; btn.disabled = true; }

  try {
    let result;
    if (isHourly && provider === 'METAL_PRICE_API') {
      result = await fetchMetalPriceApiHourly(apiKey, selectedMetals, totalDays);
    } else {
      result = await fetchHistoryBatched(provider, apiKey, selectedMetals, totalDays);
    }
    alert(
      `History pull complete!\n\n` +
      `Pulled ${result.totalEntries} data points using ${result.callsMade} API call${result.callsMade > 1 ? "s" : ""}.`
    );
    updateProviderHistoryTables();
    if (typeof updateAllSparklines === "function") updateAllSparklines();
  } catch (err) {
    console.error("History pull failed:", err);
    alert("History pull failed: " + err.message);
  } finally {
    if (btn) { btn.textContent = origText; btn.disabled = false; }
  }
};

/**
 * Initiates the spot price synchronization process across all configured providers.
 * Handles user interaction, cache validation prompts, and UI status updates.
 *
 * @param {boolean} [showProgress=true] - Whether to display alerts and progress UI
 * @param {boolean} [forceSync=false] - If true, ignores the local cache and forces API calls
 * @returns {Promise<boolean>} True if at least one provider successfully synced prices
 */
const syncSpotPricesFromApi = async (
  showProgress = true,
  forceSync = false,
) => {
  const config = loadApiConfig();
  const hasAnyKey = Object.values(config.keys || {}).some(k => k);
  const hasKeylessProvider = Object.keys(API_PROVIDERS).some(p => !providerRequiresKey(p));

  if (!hasAnyKey && !hasKeylessProvider) {
    if (showProgress) {
      alert(
        "No Metals API configuration found. Please configure an API provider first.",
      );
    }
    return false;
  }

  // Interactive cache prompt (only when user-initiated with visible UI)
  if (showProgress && !forceSync) {
    const cache = loadApiCache();
    if (cache && cache.data && cache.timestamp) {
      const now = Date.now();
      const cacheAge = now - cache.timestamp;
      const duration = getCacheDurationMs(apiConfig?.provider || config.provider);

      if (cacheAge < duration) {
        const hoursAgo = Math.floor(cacheAge / (1000 * 60 * 60));
        const minutesAgo = Math.floor(cacheAge / (1000 * 60));
        const timeText =
          hoursAgo > 0
            ? `${hoursAgo} hours ago`
            : `${minutesAgo} minutes ago`;

        const override = confirm(
          `Cached prices from ${timeText}.\n\nFetch fresh prices from the API?`,
        );
        if (!override) {
          return refreshFromCache();
        }
      }
    }
  }

  // Delegate to provider chain
  const { updatedCount, anySucceeded, results } = await syncProviderChain({
    showProgress,
    forceSync: forceSync || showProgress, // User-initiated always forces
  });

  if (showProgress && updatedCount > 0) {
    const summary = Object.entries(results)
      .filter(([_, status]) => status !== "skipped")
      .map(([prov, status]) => `${API_PROVIDERS[prov]?.name || prov}: ${status}`)
      .join("\n");
    alert(`Synced ${updatedCount} prices.\n\n${summary}`);
  } else if (showProgress && !anySucceeded) {
    alert("Failed to sync prices from any provider.");
  }

  return anySucceeded;
};

/**
 * Validates an API provider's connectivity by making a lightweight test request.
 * Usually attempts to fetch a single metal's price (e.g., silver) to verify the key.
 *
 * @param {string} provider - The unique key of the API provider
 * @param {string} apiKey - The API key to be tested
 * @returns {Promise<boolean>} True if the connection test was successful
 */
const testApiConnection = async (provider, apiKey) => {
  try {
    // Just test one metal (silver) to verify connection
    const providerConfig = API_PROVIDERS[provider];
    if (!providerConfig) {
      throw new Error("Invalid provider");
    }

    if (provider === 'STAKTRAKR') {
      const result = await fetchStaktrakrPrices(['silver']);
      return result.silver > 0;
    }

    let url = "";
    const headers = {
      "Content-Type": "application/json",
    };
    if (provider === "CUSTOM") {
      const config = loadApiConfig();
      const custom = config.customConfig || {};
      const metal = custom.format === "word" ? "silver" : "XAG";
      url = `${custom.baseUrl || ""}${(custom.endpoint || "")
        .replace("{API_KEY}", apiKey)
        .replace("{METAL}", metal)}`;
    } else {
      const endpoint = providerConfig.endpoints.silver;
      url = `${providerConfig.baseUrl}${endpoint.replace("{API_KEY}", apiKey)}`;
      if (provider === "METALS_DEV" && apiKey) {
        headers["Authorization"] = `Bearer ${apiKey}`;
      }
    }

    const response = await fetch(url, {
      method: "GET",
      headers: headers,
      mode: "cors",
    });

    if (!response.ok) {
      throw new Error(`HTTP ${response.status}: ${response.statusText}`);
    }

    const data = await response.json();
    const price = providerConfig.parseResponse(data, "silver");

    return price && price > 0;
  } catch (error) {
    console.error("API connection test failed:", error);
    return false;
  }
};

/**
 * Handles the UI-triggered synchronization of a single specific provider.
 * Useful for per-provider settings cards and troubleshooting.
 *
 * @param {string} provider - The unique key of the API provider
 * @returns {Promise<void>} Resolves when the provider sync attempt completes
 */
const handleProviderSync = async (provider) => {
  let apiKey = '';
  if (providerRequiresKey(provider)) {
    const keyInput = document.getElementById(`apiKey_${provider}`);
    if (!keyInput) return;
    apiKey = keyInput.value.trim();
    if (!apiKey) {
      alert("Please enter your API key");
      return;
    }
  }

  const config = loadApiConfig();
  // Ensure keys object exists and clone to avoid mutating shared references
  config.keys = { ...(config.keys || {}) };
  if (apiKey) config.keys[provider] = apiKey;
  if (provider === "CUSTOM") {
    const base = document.getElementById("apiBase_CUSTOM")?.value.trim() || "";
    const endpoint =
      document.getElementById("apiEndpoint_CUSTOM")?.value.trim() || "";
    const format =
      document.getElementById("apiFormat_CUSTOM")?.value || "symbol";
    if (!base || !endpoint) {
      alert("Please enter base URL and endpoint");
      return;
    }
    config.customConfig = { baseUrl: base, endpoint, format };
  }
  config.timestamp = new Date().getTime();
  saveApiConfig(config);
  updateDefaultProviderButtons();
  updateSyncButtonStates();
  setProviderStatus(provider, "disconnected");

  // Test connection
  const ok = await testApiConnection(provider, apiKey);
  if (!ok) {
    alert("API connection test failed.");
    setProviderStatus(provider, "error");
    return;
  }

  try {
    const data = await fetchSpotPricesFromApi(provider, apiKey);
    let updatedCount = 0;
    Object.entries(data).forEach(([metal, price]) => {
      const metalConfig = Object.values(METALS).find((m) => m.key === metal);
      if (metalConfig && price > 0) {
        localStorage.setItem(metalConfig.spotKey, price.toString());
        spotPrices[metal] = price;
        elements.spotPriceDisplay[metal].textContent = formatCurrency(price);
        updateSpotCardColor(metal, price);
        recordSpot(
          price,
          "api",
          metalConfig.name,
          API_PROVIDERS[provider].name,
        );
        const ts = document.getElementById(`spotTimestamp${metalConfig.name}`);
        if (ts) {
          updateSpotTimestamp(metalConfig.name);
        }
        updatedCount++;
      }
    });

    if (updatedCount > 0) {
      saveApiCache(data, provider);
      updateSummary();
      // Update Goldback denomination prices BEFORE snapshotting item prices (STAK-108)
      if (typeof onGoldSpotPriceChanged === 'function') onGoldSpotPriceChanged();
      if (typeof recordAllItemPriceSnapshots === 'function') recordAllItemPriceSnapshots();
      if (typeof updateAllSparklines === "function") {
        updateAllSparklines();
      }
      setProviderStatus(provider, "connected");
      updateProviderHistoryTables();
      alert(
        `Successfully synced ${updatedCount} metal prices from ${API_PROVIDERS[provider].name}`,
      );
    } else {
      setProviderStatus(provider, "error");
      alert("No valid prices retrieved from API");
    }
  } catch (error) {
    console.error("API sync error:", error);
    setProviderStatus(provider, "error");
    alert("Failed to sync prices: " + error.message);
  }
};

/**
 * Triggers a background sync across all providers.
 *
 * @returns {Promise<number>} Total number of prices updated
 */
const syncAllProviders = async () => {
  const { updatedCount } = await syncProviderChain({ showProgress: false, forceSync: true });
  updateProviderHistoryTables();
  return updatedCount;
};

/**
 * Core engine that iterates through configured API providers in priority order.
 * Respects sync modes ('always' vs 'backup') and handles cascading failover.
 *
 * @param {Object} options
 * @param {boolean} [options.showProgress=false] - If true, updates sync button UI states
 * @param {boolean} [options.forceSync=false] - If true, ignores provider-specific cache durations
 * @returns {Promise<{results: Object, updatedCount: number, anySucceeded: boolean}>} Sync operation summary
 */
const syncProviderChain = async ({ showProgress = false, forceSync = false } = {}) => {
  const config = loadApiConfig();
  const order = getProviderOrder();
  const results = {};
  let updatedCount = 0;
  let anySucceeded = false;

  if (showProgress) {
    updateSyncButtonStates(true);
  }

  // Load priorities once outside loop (STACK-90)
  const priorities = typeof loadProviderPriorities === 'function'
    ? loadProviderPriorities() : {};

  try {
    for (const prov of order) {
      const apiKey = config.keys?.[prov];
      if (!apiKey && providerRequiresKey(prov)) continue;

      // Priority-based sync: priority > 1 are backups, skip if primary succeeded (STACK-90)
      if (priorities[prov] === 0) { results[prov] = "disabled"; continue; }
      if (priorities[prov] > 1 && anySucceeded) {
        results[prov] = "skipped";
        continue;
      }

      // Check per-provider cache unless forcing
      if (!forceSync) {
        const provDuration = getCacheDurationMs(prov);
        const lastSync = getLastProviderSyncTime(prov);
        if (lastSync && Date.now() - lastSync < provDuration) {
          results[prov] = "cached";
          anySucceeded = true;
          continue;
        }
      }

      // Attempt fetch
      try {
        const data = await fetchSpotPricesFromApi(prov, apiKey);
        let provUpdated = 0;

        Object.entries(data).forEach(([metal, price]) => {
          const metalConfig = Object.values(METALS).find((m) => m.key === metal);
          if (metalConfig && price > 0) {
            localStorage.setItem(metalConfig.spotKey, price.toString());
            spotPrices[metal] = price;
            elements.spotPriceDisplay[metal].textContent = formatCurrency(price);
            updateSpotCardColor(metal, price);
            recordSpot(price, "api", metalConfig.name, API_PROVIDERS[prov].name);
            const ts = document.getElementById(`spotTimestamp${metalConfig.name}`);
            if (ts) updateSpotTimestamp(metalConfig.name);
            provUpdated++;
          }
        });

        if (provUpdated > 0) {
          saveApiCache(data, prov);
          updatedCount += provUpdated;
          anySucceeded = true;
          results[prov] = "success";
          setProviderStatus(prov, "connected");
        } else {
          results[prov] = "no data";
          setProviderStatus(prov, "error");
        }
      } catch (err) {
        console.warn(`Chain sync failed for ${prov}:`, err.message);
        results[prov] = "error";
        setProviderStatus(prov, "error");
      }
    }

    // Post-sync updates if anything changed
    if (updatedCount > 0) {
      // Refresh exchange rates alongside spot prices (STACK-50)
      if (typeof fetchExchangeRates === 'function') {
        fetchExchangeRates().catch(() => {});
      }
      updateSummary();
      // Update Goldback denomination prices BEFORE snapshotting item prices,
      // so the retail hierarchy reflects the new gold spot (STAK-108)
      if (typeof onGoldSpotPriceChanged === 'function') onGoldSpotPriceChanged();
      if (typeof recordAllItemPriceSnapshots === 'function') recordAllItemPriceSnapshots();
      if (typeof updateStorageStats === "function") updateStorageStats();
      // Backfill hourly data when StakTrakr is rank 1 and sync was fresh
      if (results.STAKTRAKR === "success" && priorities.STAKTRAKR === 1) {
        try { await backfillStaktrakrHourly(); }
        catch (err) { console.warn("Hourly backfill failed:", err.message); }
      }
      if (typeof updateAllSparklines === "function") updateAllSparklines();
    }
  } finally {
    if (showProgress) {
      updateSyncButtonStates(false);
    }
  }

  return { results, updatedCount, anySucceeded };
};

/**
 * Updates sync button states based on API availability
 * @param {boolean} syncing - Whether sync is in progress
 */
const updateSyncButtonStates = (syncing = false) => {
  const hasApi =
    apiConfig && apiConfig.provider &&
    (apiConfig.keys[apiConfig.provider] || !providerRequiresKey(apiConfig.provider));

  Object.values(METALS).forEach((metalConfig) => {
    // New sparkline card sync icon
    const syncIcon = document.getElementById(`syncIcon${metalConfig.name}`);
    if (syncIcon) {
      syncIcon.disabled = !hasApi || syncing;
      syncIcon.title = hasApi
        ? syncing
          ? "Syncing..."
          : "Sync from API"
        : "Configure API first";
      if (syncing) {
        syncIcon.classList.add("syncing");
      } else {
        syncIcon.classList.remove("syncing");
      }
    }
  });
};

/**
 * Updates API status display in modal
 */
/**
 * Populates the API section fields with current configuration.
 * Called when switching to the API section in the Settings modal.
 */
const populateApiSection = () => {
  let currentConfig = loadApiConfig() || {
    provider: "",
    keys: {},
    cacheHours: 24,
    customConfig: { baseUrl: "", endpoint: "", format: "symbol" },
  };
  if (!currentConfig.provider) {
    currentConfig.provider = Object.keys(API_PROVIDERS)[0];
    saveApiConfig(currentConfig);
  }

  // Populate Numista tab
  if (typeof catalogConfig !== "undefined" && catalogConfig.getNumistaConfig) {
    const nc = catalogConfig.getNumistaConfig();
    const numistaKeyInput = document.getElementById("numistaApiKey");
    if (numistaKeyInput) numistaKeyInput.value = nc.apiKey || "";
    if (typeof renderNumistaUsageBar === "function") renderNumistaUsageBar();
    // Update Numista status indicator
    const numistaStatusEl = document.getElementById("numistaProviderStatus");
    if (numistaStatusEl) {
      numistaStatusEl.classList.remove("status-connected", "status-disconnected");
      if (nc.apiKey) {
        numistaStatusEl.classList.add("status-connected");
        const dot = numistaStatusEl.querySelector(".status-dot");
        const text = numistaStatusEl.querySelector(".status-text");
        if (dot) dot.style.background = "";
        if (text) text.textContent = "Connected";
      } else {
        numistaStatusEl.classList.add("status-disconnected");
        const text = numistaStatusEl.querySelector(".status-text");
        if (text) text.textContent = "Disconnected";
      }
    }
  }

  // Populate PCGS tab status
  if (typeof catalogConfig !== "undefined" && catalogConfig.isPcgsEnabled) {
    const pcgsStatusEl = document.getElementById("pcgsProviderStatus");
    if (pcgsStatusEl) {
      pcgsStatusEl.classList.remove("status-connected", "status-disconnected");
      if (catalogConfig.isPcgsEnabled()) {
        pcgsStatusEl.classList.add("status-connected");
        const dot = pcgsStatusEl.querySelector(".status-dot");
        const text = pcgsStatusEl.querySelector(".status-text");
        if (dot) dot.style.background = "";
        if (text) text.textContent = "Connected";
      } else {
        pcgsStatusEl.classList.add("status-disconnected");
        const text = pcgsStatusEl.querySelector(".status-text");
        if (text) text.textContent = "Disconnected";
      }
    }
  }

  // Populate metals provider tabs
  Object.keys(API_PROVIDERS).forEach((prov) => {
    const input = document.getElementById(`apiKey_${prov}`);
    if (input) input.value = currentConfig.keys?.[prov] || "";
    setProviderStatus(prov, providerStatuses[prov] || "disconnected");
  });
  renderApiStatusSummary();

  const baseInput = document.getElementById("apiBase_CUSTOM");
  if (baseInput) baseInput.value = currentConfig.customConfig?.baseUrl || "";
  const endpointInput = document.getElementById("apiEndpoint_CUSTOM");
  if (endpointInput)
    endpointInput.value = currentConfig.customConfig?.endpoint || "";
  const formatSelect = document.getElementById("apiFormat_CUSTOM");
  if (formatSelect)
    formatSelect.value = currentConfig.customConfig?.format || "symbol";

  autoSelectDefaultProvider();
  updateProviderHistoryTables();

  // Initialize provider settings listeners and load saved values
  const cfg = loadApiConfig();
  Object.keys(API_PROVIDERS).forEach(provider => {
    if (typeof setupProviderSettingsListeners === 'function') {
      setupProviderSettingsListeners(provider);
    }

    // Load saved cache timeout
    const cacheSelect = document.getElementById(`cacheTimeout_${provider}`);
    if (cacheSelect) {
      cacheSelect.value = cfg.cacheTimeouts?.[provider] ?? 24;
    }

    // Initialize history pull cost indicator
    if (typeof updateHistoryPullCost === 'function') {
      updateHistoryPullCost(provider);
    }

    // Load saved metal selections
    const metals = cfg.metals?.[provider] || {};
    ['silver', 'gold', 'platinum', 'palladium'].forEach(metal => {
      const checkbox = document.querySelector(`.provider-metal[data-provider="${provider}"][data-metal="${metal}"]`);
      if (checkbox) {
        checkbox.checked = metals[metal] !== false;
      }
    });

  });

  // Restore provider priority UI (STACK-90)
  if (typeof loadProviderPriorities === 'function' && typeof syncProviderPriorityUI === 'function') {
    syncProviderPriorityUI(loadProviderPriorities());
  }

  if (typeof refreshProviderStatuses === 'function') {
    refreshProviderStatuses();
  }

  // Wire up spot history export/import buttons
  if (typeof initSpotHistoryButtons === 'function') {
    initSpotHistoryButtons();
  }
};

/**
 * Legacy showApiModal — redirects to Settings modal API section
 */
const showApiModal = () => {
  if (typeof showSettingsModal === "function") {
    showSettingsModal('api');
  }
};

/**
 * Legacy hideApiModal — redirects to hideSettingsModal
 */
const hideApiModal = () => {
  if (typeof hideSettingsModal === "function") {
    hideSettingsModal();
  }
};

/**
 * Legacy showFilesModal — redirects to Settings modal Files section
 */
const showFilesModal = () => {
  if (typeof showSettingsModal === "function") {
    showSettingsModal('system');
  }
};

/**
 * Legacy hideFilesModal — redirects to hideSettingsModal
 */
const hideFilesModal = () => {
  if (typeof hideSettingsModal === "function") {
    hideSettingsModal();
  } else {
    try {
      document.body.style.overflow = '';
    } catch (e) {
      console.warn('Failed to reset body overflow:', e);
    }
  }
};


/**
 * Shows provider information modal
 * @param {string} providerKey
 */
const showProviderInfo = (providerKey) => {
  const modal = document.getElementById("apiInfoModal");
  if (!modal || !API_PROVIDERS[providerKey]) return;

  const provider = API_PROVIDERS[providerKey];
  const title = document.getElementById("apiInfoTitle");
  const body = document.getElementById("apiInfoBody");

  if (title) title.textContent = "Provider Information";
  if (body) {
    // nosemgrep: javascript.browser.security.insecure-innerhtml.insecure-innerhtml, javascript.browser.security.insecure-document-method.insecure-document-method
    body.innerHTML = `
      <div class="info-provider-name">${provider.name}</div>
      <div>Base URL: ${provider.baseUrl}</div>
      <div>Metals: Silver, Gold, Platinum, Palladium</div>
      <div class="api-key-info">
        <div>📋 <strong>API Key Management:</strong></div>
        <ul>
          <li>Visit the documentation link below to manage your API key</li>
          <li>You can view usage, reset, or regenerate your key there</li>
          <li>Keep your API key secure and never share it publicly</li>
        </ul>
      </div>
      <a class="btn info-docs-btn" href="${provider.documentation}" target="_blank" rel="noopener">
        📄 ${provider.name} Documentation & Key Management
      </a>
    `;
  }

  modal.style.display = "flex";
};

/**
 * Hides provider information modal
 */
const hideProviderInfo = () => {
  const modal = document.getElementById("apiInfoModal");
  if (modal) {
    modal.style.display = "none";
  }
};

// Make modal controls available globally
  window.showApiModal = showApiModal;
  window.hideApiModal = hideApiModal;
  window.showFilesModal = showFilesModal;
  window.hideFilesModal = hideFilesModal;
  window.populateApiSection = populateApiSection;
  window.showProviderInfo = showProviderInfo;
  window.hideProviderInfo = hideProviderInfo;

/**
 * Saves provider settings (key, cache timeout, history days) without testing or fetching
 * @param {string} provider - Provider key
 */
const handleProviderSave = (provider) => {
  const keyInput = document.getElementById(`apiKey_${provider}`);
  if (!keyInput) return;

  const apiKey = keyInput.value.trim();
  const config = loadApiConfig();
  config.keys = { ...(config.keys || {}) };

  if (apiKey) {
    config.keys[provider] = apiKey;
  }

  if (provider === "CUSTOM") {
    const base = document.getElementById("apiBase_CUSTOM")?.value.trim() || "";
    const endpoint = document.getElementById("apiEndpoint_CUSTOM")?.value.trim() || "";
    const format = document.getElementById("apiFormat_CUSTOM")?.value || "symbol";
    config.customConfig = { baseUrl: base, endpoint, format };
  }

  // Persist per-provider settings (cache timeout)
  updateProviderSettings(provider);

  // Re-load after updateProviderSettings saved, then layer key + CUSTOM config on top
  const updated = loadApiConfig();
  updated.keys = { ...(updated.keys || {}) };
  if (apiKey) updated.keys[provider] = apiKey;
  if (provider === "CUSTOM") {
    updated.customConfig = {
      baseUrl: document.getElementById("apiBase_CUSTOM")?.value.trim() || "",
      endpoint: document.getElementById("apiEndpoint_CUSTOM")?.value.trim() || "",
      format: document.getElementById("apiFormat_CUSTOM")?.value || "symbol",
    };
  }
  saveApiConfig(updated);

  updateDefaultProviderButtons();
  updateSyncButtonStates();

  // Brief visual confirmation via status indicator
  const btn = document.querySelector(`.api-save-btn[data-provider="${provider}"]`);
  if (btn) {
    const origText = btn.textContent;
    btn.textContent = "Saved!";
    btn.disabled = true;
    setTimeout(() => {
      btn.textContent = origText;
      btn.disabled = false;
    }, 1200);
  }
};

window.handleProviderSave = handleProviderSave;
window.handleProviderSync = handleProviderSync;
window.clearApiKey = clearApiKey;
window.clearApiCache = clearApiCache;
window.setDefaultProvider = setDefaultProvider;
window.showApiHistoryModal = showApiHistoryModal;
window.hideApiHistoryModal = hideApiHistoryModal;
window.clearApiHistory = clearApiHistory;
window.syncAllProviders = syncAllProviders;
window.syncProviderChain = syncProviderChain;
window.autoSyncSpotPrices = autoSyncSpotPrices;
window.handleHistoryPull = handleHistoryPull;
window.updateHistoryPullCost = updateHistoryPullCost;
window.fetchHistoryBatched = fetchHistoryBatched;

/**
 * Shows manual price input for a specific metal
 * @param {string} metal - Metal name (Silver, Gold, etc.)
 */
const showManualInput = (metal) => {
  const manualInput = document.getElementById(`manualInput${metal}`);
  if (manualInput) {
    manualInput.style.display = "block";

    // Focus the input field
    const input = document.getElementById(`userSpotPrice${metal}`);
    if (input) {
      input.focus();
    }
  }
};

/**
 * Hides manual price input for a specific metal
 * @param {string} metal - Metal name (Silver, Gold, etc.)
 */
const hideManualInput = (metal) => {
  const manualInput = document.getElementById(`manualInput${metal}`);
  if (manualInput) {
    manualInput.style.display = "none";

    // Clear the input
    const input = document.getElementById(`userSpotPrice${metal}`);
    if (input) {
      input.value = "";
    }
  }
};

/**
 * Resets spot price to default or API cached value
 * @param {string} metal - Metal name (Silver, Gold, etc.)
 */
const resetSpotPrice = (metal) => {
  const metalConfig = Object.values(METALS).find((m) => m.name === metal);
  if (!metalConfig) return;

  let resetPrice = metalConfig.defaultPrice;
  let source = "default";
  let providerName = null;

  // If we have cached API data, use that instead
  if (apiCache && apiCache.data && apiCache.data[metalConfig.key]) {
    resetPrice = apiCache.data[metalConfig.key];
    source = "api";
    providerName = API_PROVIDERS[apiCache.provider]?.name || null;
  }

  // Update price
  localStorage.setItem(metalConfig.spotKey, resetPrice.toString());
  spotPrices[metalConfig.key] = resetPrice;

  // Update display
  elements.spotPriceDisplay[metalConfig.key].textContent =
    formatCurrency(resetPrice);

  updateSpotCardColor(metalConfig.key, resetPrice);

  // Record in history
  recordSpot(resetPrice, source, metalConfig.name, providerName);

  // Update summary
  updateSummary();

  // Hide manual input if shown
  hideManualInput(metal);
};

/**
 * Exports backup data including Metals API configuration
 * @returns {Object} Complete backup data object
 */
const createBackupData = () => {
  const backupData = {
    version: APP_VERSION,
    timestamp: new Date().toISOString(),
    inventory: loadData(LS_KEY, []),
    spotHistory: loadData(SPOT_HISTORY_KEY, []),
    apiConfig:
      apiConfig && apiConfig.provider
        ? {
            provider: apiConfig.provider,
            providerName: API_PROVIDERS[apiConfig.provider]?.name || "Unknown",
            keyLength: apiConfig.keys[apiConfig.provider]
              ? apiConfig.keys[apiConfig.provider].length
              : 0,
            hasKey: !!apiConfig.keys[apiConfig.provider],
            timestamp: apiConfig.timestamp,
          }
        : null,
    spotPrices: { ...spotPrices },
  };

  return backupData;
};

/**
 * Downloads complete backup files including inventory and Metals API configuration
 */
const downloadCompleteBackup = async () => {
  try {
    const timestamp = new Date()
      .toISOString()
      .slice(0, 19)
      .replace(/[T:]/g, "-");

    // 1. Create inventory CSV using existing export logic
    const inventory = loadDataSync(LS_KEY, []);
    if (inventory.length > 0) {
      // Create CSV manually for backup instead of calling exportCsv()
      const headers = [
        "Metal",
        "Name",
        "Qty",
        "Type",
        "Weight(oz)",
        "Purchase Price",
        "Spot Price ($/oz)",
        "Premium ($/oz)",
        "Total Premium",
        "Purchase Location",
        "Storage Location",
        "Notes",
        "Date",
      ];
      const sortedInventory = [...inventory].sort(
        (a, b) => new Date(b.date) - new Date(a.date),
      );

      const rows = sortedInventory.map((item) => [
        item.metal || "Silver",
        item.name,
        item.qty,
        item.type,
        parseFloat(item.weight).toFixed(4),
        formatCurrency(item.price),
        formatCurrency(item.spotPriceAtPurchase),
        formatCurrency(item.premiumPerOz),
        formatCurrency(item.totalPremium),
        item.purchaseLocation,
        item.storageLocation || "Unknown",
        item.notes || "",
        item.date,
      ]);

      const inventoryCsv = Papa.unparse([headers, ...rows]);
      downloadFile(
        `inventory-backup-${timestamp}.csv`,
        inventoryCsv,
        "text/csv",
      );
    }

    // 2. Create spot history CSV
    const spotHistory = loadDataSync(SPOT_HISTORY_KEY, []);
    if (spotHistory.length > 0) {
      const historyData = [
        ["Timestamp", "Metal", "Price", "Source"],
        ...spotHistory.map((entry) => [
          entry.timestamp,
          entry.metal,
          entry.spot,
          entry.source,
        ]),
      ];

      const historyCsv = Papa.unparse(historyData);
      downloadFile(
        `spot-price-history-${timestamp}.csv`,
        historyCsv,
        "text/csv",
      );
    }

    // 3. Create complete JSON backup
    const completeBackup = {
      version: APP_VERSION,
      timestamp: new Date().toISOString(),
      data: {
        inventory: inventory,
        spotHistory: spotHistory,
        spotPrices: { ...spotPrices },
        apiConfig:
          apiConfig && apiConfig.provider
            ? {
                provider: apiConfig.provider,
                providerName:
                  API_PROVIDERS[apiConfig.provider]?.name || "Unknown",
                hasKey: !!apiConfig.keys[apiConfig.provider],
                keyLength: apiConfig.keys[apiConfig.provider]
                  ? apiConfig.keys[apiConfig.provider].length
                  : 0,
                timestamp: apiConfig.timestamp,
              }
            : null,
      },
    };

    const backupJson = JSON.stringify(completeBackup, null, 2);
    downloadFile(
      `complete-backup-${timestamp}.json`,
      backupJson,
      "application/json",
    );

    // 4. Create API documentation and restoration guide
    const backupData = createBackupData();
    const apiInfo = `# StakTrakr - Complete Backup

Generated: ${typeof formatTimestamp === 'function' ? formatTimestamp(new Date()) : new Date().toLocaleString()}
Application Version: ${APP_VERSION}

## Backup Contents

1. **inventory-backup-${timestamp}.csv** - Complete inventory data
2. **spot-price-history-${timestamp}.csv** - Historical spot price data
3. **complete-backup-${timestamp}.json** - Full application backup
4. **backup-info-${timestamp}.md** - This documentation file

## Metals API Configuration
${
  backupData.apiConfig
    ? `
- Provider: ${backupData.apiConfig.providerName}
- Has API Key: ${backupData.apiConfig.hasKey}
- Key Length: ${backupData.apiConfig.keyLength} characters
- Configured: ${typeof formatTimestamp === 'function' ? formatTimestamp(backupData.apiConfig.timestamp) : new Date(backupData.apiConfig.timestamp).toLocaleString()}

**⚠️ Security Note:** API keys are not included in backups for security.
After restoring, reconfigure your API key in the API settings.

### API Key Management
${
  API_PROVIDERS[apiConfig?.provider]
    ? `
**${API_PROVIDERS[apiConfig.provider].name}**
- Documentation: ${API_PROVIDERS[apiConfig.provider].documentation}
- If you need to reset your API key, visit the documentation link above
`
    : ""
}
`
    : "No Metals API configuration found."
}

## Current Data Summary
- Inventory Items: ${inventory.length}
- Spot Price History: ${spotHistory.length} entries
- Silver Price: ${spotPrices.silver || "Not set"}
- Gold Price: ${spotPrices.gold || "Not set"}
- Platinum Price: ${spotPrices.platinum || "Not set"}
- Palladium Price: ${spotPrices.palladium || "Not set"}

## Restoration Instructions

1. Import **inventory-backup-${timestamp}.csv** using the CSV import feature
2. Reconfigure API settings if needed (keys not backed up for security)
3. Use **complete-backup-${timestamp}.json** for full data restoration if needed

*This backup was created by StakTrakr v${APP_VERSION}*
`;

    downloadFile(`backup-info-${timestamp}.md`, apiInfo, "text/markdown");

    alert(
      `Complete backup created! Downloaded files:\n\n✓ Inventory CSV (${inventory.length} items)\n✓ Spot price history (${spotHistory.length} entries)\n✓ Complete JSON backup\n✓ Documentation & restoration guide\n\nCheck your Downloads folder.`,
    );
  } catch (error) {
    console.error("Backup error:", error);
    alert("Error creating backup: " + error.message);
  }
};

// =============================================================================
// SPOT HISTORY EXPORT/IMPORT
// =============================================================================

/**
 * Exports all spot history data as a CSV file
 */
const exportSpotHistory = () => {
  loadSpotHistory();
  if (!spotHistory.length) {
    alert("No spot history to export.");
    return;
  }

  const csv = Papa.unparse([
    ["Timestamp", "Metal", "Price", "Source", "Provider"],
    ...spotHistory.map((e) => [
      e.timestamp,
      e.metal,
      e.spot,
      e.source,
      e.provider || "",
    ]),
  ]);
  downloadFile(
    `spot-history-${new Date().toISOString().slice(0, 10)}.csv`,
    csv,
    "text/csv",
  );
};

/**
 * Imports spot history data from a CSV or JSON file
 * @param {File} file - File to import
 */
const importSpotHistory = (file) => {
  const reader = new FileReader();
  reader.onload = (e) => {
    let entries = [];
    try {
      if (file.name.endsWith(".json")) {
        const parsed = JSON.parse(e.target.result);
        // Support both flat array and { history: [...] } wrapper
        entries = Array.isArray(parsed) ? parsed : parsed.history || [];
      } else {
        const parsed = Papa.parse(e.target.result, { header: true });
        entries = parsed.data
          .map((row) => ({
            timestamp: row.Timestamp,
            metal: row.Metal,
            spot: parseFloat(row.Price),
            source: row.Source || "import",
            provider: row.Provider || "import",
          }))
          .filter((entry) => entry.timestamp && entry.metal && entry.spot > 0);
      }
    } catch (err) {
      alert("Failed to parse file: " + err.message);
      return;
    }

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

    loadSpotHistory();
    let imported = 0;
    entries.forEach((entry) => {
      recordSpot(
        entry.spot,
        entry.source || "import",
        entry.metal,
        entry.provider || "import",
        entry.timestamp,
      );
      imported++;
    });

    alert(`Imported ${imported} spot history entries.`);
    if (typeof updateAllSparklines === "function") updateAllSparklines();

    // Refresh the visible history table after import
    apiHistoryEntries = spotHistory.filter((e) => e.source === "api" || e.source === "api-hourly" || e.source === "seed");
    renderApiHistoryTable();
  };
  reader.readAsText(file);
};

/**
 * Wires up spot history export/import button event listeners.
 * Called during populateApiSection() or init.
 */
const initSpotHistoryButtons = () => {
  const exportBtn = document.getElementById("exportSpotHistoryBtn");
  if (exportBtn) exportBtn.addEventListener("click", exportSpotHistory);

  const importBtn = document.getElementById("importSpotHistoryBtn");
  const importFile = document.getElementById("importSpotHistoryFile");
  if (importBtn && importFile) {
    importBtn.addEventListener("click", () => importFile.click());
    importFile.addEventListener("change", (e) => {
      if (e.target.files.length > 0) {
        importSpotHistory(e.target.files[0]);
        e.target.value = ""; // Reset so same file can be re-imported
      }
    });
  }
};

window.exportSpotHistory = exportSpotHistory;
window.importSpotHistory = importSpotHistory;
window.initSpotHistoryButtons = initSpotHistoryButtons;

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