Source: spot.js

// SPOT PRICE FUNCTIONS
// =============================================================================

/**
 * Saves spot history to localStorage.
 * Writes the content of `spotHistory` to the `SPOT_HISTORY_KEY`.
 * @returns {void}
 */
const saveSpotHistory = () => {
  try {
    saveDataSync(SPOT_HISTORY_KEY, spotHistory);
  } catch (error) {
    console.error('Error saving spot history:', error);
  }
};

/**
 * Loads spot history from localStorage
 */
const loadSpotHistory = () => {
  try {
    const data = loadDataSync(SPOT_HISTORY_KEY, []);
    // Ensure data is an array
    spotHistory = Array.isArray(data) ? data : [];
  } catch (error) {
    console.error('Error loading spot history:', error);
    spotHistory = [];
  }
};

/**
 * One-time migration: re-tag old StakTrakr hourly backfill entries.
 * Before v3.30.02, backfill entries were stored with source:"api" (same as
 * live syncs). Identifies them by the heuristic: provider is "StakTrakr",
 * source is "api", and timestamp lands exactly on the hour (:00:00).
 * Regular syncs never produce on-the-hour timestamps.
 */
const migrateHourlySource = () => {
  const FLAG = "migration_hourlySource";
  try {
    if (localStorage.getItem(FLAG)) return;
    loadSpotHistory();
    let changed = 0;
    spotHistory.forEach(e => {
      if (
        e.provider === "StakTrakr" &&
        e.source === "api" &&
        typeof e.timestamp === "string" &&
        e.timestamp.endsWith(":00:00")
      ) {
        e.source = "api-hourly";
        changed++;
      }
    });
    if (changed > 0) {
      saveSpotHistory();
      console.log(`[Migration] Re-tagged ${changed} StakTrakr entries as api-hourly`);
    }
    localStorage.setItem(FLAG, "1");
  } catch (err) {
    console.warn("Hourly source migration failed:", err);
  }
};

// Run migration on script load
migrateHourlySource();

/**
 * Removes spot history entries older than the specified number of days
 *
 * @param {number} days - Number of days to retain
 */
const purgeSpotHistory = (days = 180) => {
  const cutoff = new Date();
  cutoff.setDate(cutoff.getDate() - days);
  spotHistory = spotHistory.filter(
    (entry) => new Date(entry.timestamp) >= cutoff,
  );
};

/**
 * Stores last cache refresh and API sync timestamps
 *
 * @param {string} source - Source of spot price ('api' or 'cached')
 * @param {string|null} provider - Provider name if available
 * @param {string} timestamp - ISO timestamp of the event
 */
const updateLastTimestamps = (source, provider, timestamp) => {
  const apiEntry = {
    provider: provider || "API",
    timestamp,
  };

  if (source === "api") {
    saveData(LAST_API_SYNC_KEY, apiEntry);
    saveData(LAST_CACHE_REFRESH_KEY, apiEntry);
  } else if (source === "cached") {
    const cacheEntry = {
      provider: provider ? `${provider} (cached)` : "Cached",
      timestamp,
    };
    saveData(LAST_CACHE_REFRESH_KEY, cacheEntry);
  }
};

/**
 * Records a new spot price entry in history
 *
 * @param {number} newSpot - New spot price value
 * @param {string} source - Source of spot price ('manual', 'api', etc.)
 * @param {string} metal - Metal type ('Silver', 'Gold', 'Platinum', or 'Palladium')
 * @param {string|null} provider - Provider name if source is API-based
 * @param {string|null} timestamp - Optional ISO timestamp for historical entries
 */
const recordSpot = (
  newSpot,
  source,
  metal,
  provider = null,
  timestamp = null,
) => {
  purgeSpotHistory();
  const entryTimestamp = timestamp
    ? new Date(timestamp).toISOString().replace("T", " ").slice(0, 19)
    : new Date().toISOString().replace("T", " ").slice(0, 19);

  // Historical backfill (explicit timestamp): full-array dedup by timestamp+metal.
  // Real-time entries (no explicit timestamp): fast O(1) tail check.
  const isDuplicate = timestamp
    ? spotHistory.some(
        (e) => e.timestamp === entryTimestamp && e.metal === metal,
      )
    : spotHistory.length > 0 &&
      spotHistory[spotHistory.length - 1].spot === newSpot &&
      spotHistory[spotHistory.length - 1].metal === metal;

  if (!isDuplicate) {
    spotHistory.push({
      spot: newSpot,
      metal,
      source,
      provider,
      timestamp: entryTimestamp,
    });
  }
  if (source === "api" || source === "cached") {
    updateLastTimestamps(source, provider, entryTimestamp);
  }
  saveSpotHistory();
};

/**
 * Returns recent spot prices for a given metal from spotHistory.
 * Used by card-view sparklines (STAK-118).
 * @param {string} metal - Metal key ('silver', 'gold', etc.)
 * @param {number} [points=30] - Number of data points to return
 * @param {boolean} [withTimestamps=false] - If true, returns {ts, spot} objects
 * @returns {Array.<number>|Array.<{ts:number,spot:number}>|null} Array of spot prices, or null if insufficient data
 */
const getSpotHistoryForMetal = (metal, points = 30, withTimestamps = false) => {
  const metalName = Object.values(METALS).find(m => m.key === metal)?.name || metal;
  const entries = spotHistory.filter(e => e.metal === metalName);
  if (entries.length < 2) return null;
  const recent = entries.slice(-points);
  if (withTimestamps) return recent.map(e => ({ ts: new Date(e.timestamp).getTime(), spot: e.spot }));
  return recent.map(e => e.spot);
};

/**
 * Updates spot card color based on price movement compared to last history entry
 *
 * @param {string} metalKey - Metal key ('silver', 'gold', etc.)
 * @param {number} newPrice - Newly set spot price
 */
const updateSpotCardColor = (metalKey, newPrice) => {
  const metalConfig = Object.values(METALS).find((m) => m.key === metalKey);
  if (!metalConfig) return;

  const el = elements.spotPriceDisplay[metalKey];
  if (!el) return;

  // Find the most recent API/manual entry with a DIFFERENT price.
  // This ensures the direction indicator persists across page refreshes —
  // cached reads reload the same price, so comparing against them (or against
  // the most recent same-price API entry) would always show "unchanged".
  const lastEntry = [...spotHistory]
    .reverse()
    .find((e) => e.metal === metalConfig.name && e.source !== "cached" && e.spot !== newPrice);

  let arrow = "";
  const formatted = typeof formatCurrency === "function"
    ? formatCurrency(newPrice)
    : newPrice.toFixed(2);

  if (!lastEntry) {
    el.classList.remove("spot-up", "spot-down", "spot-unchanged");
    el.textContent = formatted;
    return;
  }

  if (newPrice > lastEntry.spot) {
    el.classList.add("spot-up");
    el.classList.remove("spot-down", "spot-unchanged");
    arrow = "\u25B2"; // Up arrow
  } else if (newPrice < lastEntry.spot) {
    el.classList.add("spot-down");
    el.classList.remove("spot-up", "spot-unchanged");
    arrow = "\u25BC"; // Down arrow
  } else {
    el.classList.add("spot-unchanged");
    el.classList.remove("spot-up", "spot-down");
    arrow = "=";
  }

  el.textContent = `${arrow} ${formatted}`.trim();
};

/**
 * Fetches and displays current spot prices from localStorage or defaults
 */
const fetchSpotPrice = () => {
  // Load spot prices for all metals
  Object.values(METALS).forEach((metalConfig) => {
    const storedSpot = localStorage.getItem(metalConfig.localStorageKey);
    if (storedSpot) {
      spotPrices[metalConfig.key] = parseFloat(storedSpot);
      if (
        elements.spotPriceDisplay[metalConfig.key] &&
        elements.spotPriceDisplay[metalConfig.key].textContent !== undefined
      ) {
        elements.spotPriceDisplay[metalConfig.key].textContent = formatCurrency(
          spotPrices[metalConfig.key],
        );
      }
    } else {
      // Use default price if no stored price
      const defaultPrice = metalConfig.defaultPrice;
      spotPrices[metalConfig.key] = defaultPrice;
      if (
        elements.spotPriceDisplay[metalConfig.key] &&
        elements.spotPriceDisplay[metalConfig.key].textContent !== undefined
      ) {
        elements.spotPriceDisplay[metalConfig.key].textContent = formatCurrency(
          spotPrices[metalConfig.key],
        );
      }
      // Don't record default prices in history automatically
    }

    // Update timestamp display
    const timestampElement = document.getElementById(
      `spotTimestamp${metalConfig.name}`,
    );
    if (timestampElement) {
      updateSpotTimestamp(metalConfig.name);
    }

    // Update card color based on price movement
    updateSpotCardColor(metalConfig.key, spotPrices[metalConfig.key]);
  });

  updateSummary();

  // Goldback estimation hook — fire after all spots loaded (STACK-52)
  if (typeof onGoldSpotPriceChanged === 'function') onGoldSpotPriceChanged();
};

/**
 * Updates spot price for specified metal from user input
 *
 * @param {string} metalKey - Key of metal to update ('silver', 'gold', 'platinum', 'palladium')
 */
const updateManualSpot = (metalKey) => {
  const metalConfig = Object.values(METALS).find((m) => m.key === metalKey);
  if (!metalConfig) return;

  const input = elements.userSpotPriceInput[metalKey];
  const value = input.value;

  if (!value) return;

  const num = parseFloat(value);
  if (isNaN(num) || num <= 0)
    return alert(`Invalid ${metalConfig.name.toLowerCase()} spot price.`);

  localStorage.setItem(metalConfig.localStorageKey, num);
  spotPrices[metalKey] = num;

  if (
    elements.spotPriceDisplay[metalKey] &&
    elements.spotPriceDisplay[metalKey].textContent !== undefined
  ) {
    elements.spotPriceDisplay[metalKey].textContent = formatCurrency(
      spotPrices[metalKey],
    );
  }

  updateSpotCardColor(metalKey, num);
  recordSpot(num, "manual", metalConfig.name);

  // Update timestamp display
  const timestampElement = document.getElementById(
    `spotTimestamp${metalConfig.name}`,
  );
  if (timestampElement) {
    updateSpotTimestamp(metalConfig.name);
  }

  updateSummary();

  // Snapshot item prices after manual spot change (STACK-43)
  if (typeof recordAllItemPriceSnapshots === 'function') recordAllItemPriceSnapshots();

  // Goldback estimation hook (STACK-52)
  if (metalKey === 'gold' && typeof onGoldSpotPriceChanged === 'function') onGoldSpotPriceChanged();

  // Clear the input and hide the manual input section if available
  input.value = "";
  if (typeof hideManualInput === "function") {
    hideManualInput(metalConfig.name);
  }
};

/**
 * Resets spot price for specified metal to default or API cached value
 *
 * @param {string} metalKey - Key of metal to reset ('silver', 'gold', 'platinum', 'palladium')
 */
const resetSpot = (metalKey) => {
  const metalConfig = Object.values(METALS).find((m) => m.key === metalKey);
  if (!metalConfig) return;

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

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

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

  // Update display
  if (
    elements.spotPriceDisplay[metalKey] &&
    elements.spotPriceDisplay[metalKey].textContent !== undefined
  ) {
    elements.spotPriceDisplay[metalKey].textContent = formatCurrency(
      spotPrices[metalKey],
    );
  }

  updateSpotCardColor(metalKey, resetPrice);

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

  // Update timestamp display
  const timestampElement = document.getElementById(
    `spotTimestamp${metalConfig.name}`,
  );
  if (timestampElement) {
    updateSpotTimestamp(metalConfig.name);
  }

  // Update summary
  updateSummary();

  // Snapshot item prices after spot reset (STACK-43)
  if (typeof recordAllItemPriceSnapshots === 'function') recordAllItemPriceSnapshots();

  // Goldback estimation hook (STACK-52)
  if (metalKey === 'gold' && typeof onGoldSpotPriceChanged === 'function') onGoldSpotPriceChanged();

  // Hide manual input if shown and function is available
  if (typeof hideManualInput === "function") {
    hideManualInput(metalConfig.name);
  }
};

/**
 * Alternative reset function that works with metal name instead of key
 * This provides compatibility with the API.js resetSpotPrice function
 *
 * @param {string} metalName - Name of metal to reset ('Silver', 'Gold', etc.)
 */
const resetSpotByName = (metalName) => {
  const metalConfig = Object.values(METALS).find((m) => m.name === metalName);
  if (metalConfig) {
    resetSpot(metalConfig.key);
  }
};

// =============================================================================
// SPARKLINE FUNCTIONS
// =============================================================================

/**
 * Returns the theme-aware color for a metal sparkline by reading the
 * CSS custom property (--silver, --gold, etc.) from the active theme.
 * Falls back to hardcoded defaults if getComputedStyle is unavailable.
 * @param {string} metalKey - 'silver', 'gold', 'platinum', 'palladium'
 * @returns {string} CSS color string
 */
const getMetalColor = (metalKey) => {
  const prop = getComputedStyle(document.documentElement).getPropertyValue(`--${metalKey}`).trim();
  if (prop) return prop;
  const fallback = { silver: "#d1d5db", gold: "#fbbf24", platinum: "#f3f4f6", palladium: "#d8b4fe" };
  return fallback[metalKey] || "#6366f1";
};

/**
 * Loads saved trend range preferences from localStorage
 * @returns {Object} Map of metal key → days (default 30)
 */
const loadTrendRanges = () => {
  try {
    const stored = loadDataSync(SPOT_TREND_RANGE_KEY, null);
    return stored && typeof stored === "object" ? stored : {};
  } catch (e) {
    return {};
  }
};

/**
 * Saves a single metal's trend range preference
 * @param {string} metalKey - Metal key
 * @param {number} days - Number of days
 */
const saveTrendRange = (metalKey, days) => {
  const ranges = loadTrendRanges();
  ranges[metalKey] = days;
  saveDataSync(SPOT_TREND_RANGE_KEY, ranges);
};

// =============================================================================
// HISTORICAL DATA — YEAR-FILE FETCH & CACHE (STACK-69)
// =============================================================================

/** @type {Map<number, Array>} Cached year-file entries keyed by year */
const historicalDataCache = new Map();

/** @type {Map<number, Promise<Array>>} In-flight fetch promises to deduplicate concurrent requests */
const historicalFetchPromises = new Map();

/**
 * Calculates which year files are needed for a given lookback period.
 * @param {number} days - Number of days to look back
 * @returns {number[]} Array of year numbers to fetch
 */
const getRequiredYears = (days) => {
  const now = new Date();
  const cutoff = new Date(now);
  cutoff.setDate(cutoff.getDate() - days);
  const startYear = cutoff.getFullYear();
  const endYear = now.getFullYear();
  const years = [];
  for (let y = startYear; y <= endYear; y++) {
    years.push(y);
  }
  return years;
};

/** @constant {string} Remote base URL for historical data (file:// fallback) */
const HISTORICAL_DATA_REMOTE = 'https://staktrakr.com/data/';

/**
 * Loads a local JSON file via XMLHttpRequest (sync-free).
 * Broader file:// compatibility than fetch() — works in Firefox/Safari
 * and Chrome with --allow-file-access-from-files.
 * @param {string} url - Relative or absolute URL to JSON file
 * @returns {Promise<any>} Parsed JSON
 */
const xhrLoadJSON = (url) =>
  new Promise((resolve, reject) => {
    const xhr = new XMLHttpRequest();
    xhr.open('GET', url, true);
    xhr.responseType = 'json';
    xhr.onload = () => {
      if (xhr.status === 200 || (xhr.status === 0 && xhr.response)) {
        resolve(xhr.response);
      } else {
        reject(new Error(`XHR ${xhr.status}`));
      }
    };
    xhr.onerror = () => reject(new Error('XHR network error'));
    xhr.send();
  });

/**
 * Fetches a single year file from data/spot-history-{year}.json.
 * Three-tier loading: local fetch → local XHR → remote staktrakr.com.
 * Caches the result (or empty array on failure) to avoid retries.
 * Deduplicates concurrent fetches for the same year.
 * @param {number} year - Year to fetch
 * @returns {Promise<Array>} Array of spot history entries
 */
const fetchYearFile = (year) => {
  // Already cached — return immediately
  if (historicalDataCache.has(year)) {
    return Promise.resolve(historicalDataCache.get(year));
  }

  // Already in-flight — return shared promise
  if (historicalFetchPromises.has(year)) {
    return historicalFetchPromises.get(year);
  }

  const filename = `spot-history-${year}.json`;
  const localUrl = `data/${filename}`;
  const remoteUrl = `${HISTORICAL_DATA_REMOTE}${filename}`;

  const promise = fetch(localUrl)
    .then((res) => {
      if (!res.ok) throw new Error(`HTTP ${res.status}`);
      return res.json();
    })
    .catch(() => xhrLoadJSON(localUrl))       // Fallback 1: XHR for file://
    .catch(() => fetch(remoteUrl)             // Fallback 2: remote staktrakr.com
      .then((res) => {
        if (!res.ok) throw new Error(`HTTP ${res.status}`);
        return res.json();
      })
    )
    .then((entries) => {
      // Validate: must be an array of objects with spot/metal/timestamp
      if (!Array.isArray(entries)) {
        historicalDataCache.set(year, []);
        return [];
      }
      const valid = entries.filter(
        (e) => e && typeof e.spot === "number" && e.metal && e.timestamp,
      );
      historicalDataCache.set(year, valid);
      return valid;
    })
    .catch(() => {
      // All three methods failed — cache empty to avoid retries
      historicalDataCache.set(year, []);
      return [];
    })
    .finally(() => {
      historicalFetchPromises.delete(year);
    });

  historicalFetchPromises.set(year, promise);
  return promise;
};

/**
 * Fetches needed year files, merges with live spotHistory, filters to
 * metal + range, deduplicates by day (live data wins over seed).
 * @param {string} metalName - Metal name ('Silver', 'Gold', etc.)
 * @param {number} days - Number of days to look back
 * @returns {Promise<{ labels: string[], data: number[] }>} Arrays for Chart.js
 */
const getHistoricalSparklineData = async (metalName, days) => {
  const years = getRequiredYears(days);
  const yearArrays = await Promise.all(years.map(fetchYearFile));

  // Merge all historical entries
  const allHistorical = yearArrays.flat();

  // Cutoff date
  const cutoff = new Date();
  cutoff.setDate(cutoff.getDate() - days);

  // Combine: historical + live spotHistory
  const combined = [...allHistorical, ...spotHistory]
    .filter((e) => e.metal === metalName && new Date(e.timestamp) >= cutoff)
    .sort((a, b) => new Date(a.timestamp) - new Date(b.timestamp));

  // Deduplicate by day — live data (non-seed) wins over seed
  const byDay = new Map();
  combined.forEach((e) => {
    const day = e.timestamp.slice(0, 10);
    const existing = byDay.get(day);
    if (!existing || existing.source === "seed") {
      byDay.set(day, e);
    }
  });

  const sorted = [...byDay.values()].sort(
    (a, b) => new Date(a.timestamp) - new Date(b.timestamp),
  );

  return {
    labels: sorted.map((e) => e.timestamp.slice(0, 10)),
    data: sorted.map((e) => e.spot),
  };
};

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

/**
 * Extracts sparkline data from spotHistory for a given metal and date range
 * @param {string} metalName - Metal name ('Silver', 'Gold', etc.)
 * @param {number} days - Number of days to look back
 * @param {boolean} [intraday=false] - If true, keep all entries (no per-day dedup)
 *   and use midnight cutoff instead of current-time offset (STACK-66)
 * @returns {{ labels: string[], data: number[] }} Arrays for Chart.js
 */
const getSparklineData = (metalName, days, intraday = false) => {
  const cutoff = new Date();
  if (intraday) {
    // Use midnight of N days ago for clean calendar-day boundaries
    cutoff.setDate(cutoff.getDate() - days);
    cutoff.setHours(0, 0, 0, 0);
  } else {
    cutoff.setDate(cutoff.getDate() - days);
  }

  const entries = spotHistory
    .filter((e) => e.metal === metalName && new Date(e.timestamp) >= cutoff)
    .sort((a, b) => new Date(a.timestamp) - new Date(b.timestamp));

  if (intraday) {
    // Return individual entries for hourly resolution
    return {
      labels: entries.map((e) => e.timestamp.slice(11, 16)),  // "HH:MM"
      data: entries.map((e) => e.spot),
    };
  }

  // Track first and last entry per day for open/close comparison (STACK-92)
  const byDay = new Map();
  entries.forEach((e) => {
    const day = e.timestamp.slice(0, 10);
    if (!byDay.has(day)) {
      byDay.set(day, { first: e, last: e });
    } else {
      byDay.get(day).last = e;
    }
  });

  const sorted = [...byDay.values()].sort(
    (a, b) => new Date(a.last.timestamp) - new Date(b.last.timestamp),
  );

  return {
    labels: sorted.map((pair) => pair.last.timestamp.slice(0, 10)),
    data: sorted.map((pair) => pair.last.spot),       // close prices (backward-compatible)
    openData: sorted.map((pair) => pair.first.spot),   // open prices (STACK-92)
  };
};

/**
 * Renders or updates a sparkline chart for a single metal card.
 * For ranges >180 days, fetches historical year files asynchronously.
 * @param {string} metalKey - Metal key ('silver', 'gold', etc.)
 */
const updateSparkline = async (metalKey) => {
  const metalConfig = Object.values(METALS).find((m) => m.key === metalKey);
  if (!metalConfig) return;

  const canvasId = `sparkline${metalConfig.name}`;
  const canvas = document.getElementById(canvasId);
  if (!canvas || !canvas.getContext) return;

  // Destroy existing chart instance early to avoid stale visuals during async fetch
  if (sparklineInstances[metalKey]) {
    sparklineInstances[metalKey].destroy();
    sparklineInstances[metalKey] = null;
  }

  // Determine range from dropdown or saved preference
  const rangeSelect = document.getElementById(`spotRange${metalConfig.name}`);
  const days = rangeSelect ? parseInt(rangeSelect.value, 10) : 90;

  // 1-day view: try 1-day intraday window first, widen to 3 if too few points
  const isIntraday = (days === 1);
  let effectiveDays = isIntraday ? 1 : days;

  let labels, data;

  if (effectiveDays > 180) {
    // Historical range — async fetch year files (STACK-69)
    if (rangeSelect) rangeSelect.disabled = true;
    try {
      ({ labels, data } = await getHistoricalSparklineData(metalConfig.name, effectiveDays));
    } finally {
      if (rangeSelect) rangeSelect.disabled = false;
    }
  } else {
    ({ labels, data } = getSparklineData(metalConfig.name, effectiveDays, isIntraday));
  }

  // Adaptive fallback: if 1-day window has too few points, widen to 3 days
  if (isIntraday && data.length < 2) {
    ({ labels, data } = getSparklineData(metalConfig.name, 3, true));
  }

  // Need at least 2 data points for a meaningful line
  if (data.length < 2) {
    updateSpotChangePercent(metalKey, data);
    return;
  }

  const ctx = canvas.getContext("2d");
  const color = getMetalColor(metalKey);

  // Create gradient fill
  const gradient = ctx.createLinearGradient(0, 0, 0, canvas.clientHeight || 80);
  gradient.addColorStop(0, color);
  gradient.addColorStop(1, "transparent");

  sparklineInstances[metalKey] = new Chart(ctx, {
    type: "line",
    data: {
      datasets: [
        {
          data: data.map((val, i) => ({ x: i, y: val })),
          borderColor: color,
          backgroundColor: gradient,
          fill: true,
          borderWidth: 1.5,
          pointRadius: 0,
          tension: isIntraday ? 0 : 0.3,
        },
      ],
    },
    options: {
      responsive: true,
      maintainAspectRatio: false,
      layout: { padding: { top: 6, right: 0, bottom: 0, left: 0 } },
      plugins: {
        legend: { display: false },
        tooltip: { enabled: false },
      },
      scales: {
        x: {
          type: "linear",
          display: false,
          min: 0,
          max: data.length - 1,
        },
        y: { display: false, beginAtZero: false, grace: '50%' },
      },
      animation: { duration: 400 },
      interaction: { enabled: false },
    },
  });

  updateSpotChangePercent(metalKey, data);
};

/**
 * Computes 24h % change using the user's selected comparison mode (STACK-92).
 * Modes: close-close (default), open-open, open-close.
 * Graceful degradation: when only 1 entry/day exists, first === last so all modes match.
 * @param {string} metalName - 'Silver', 'Gold', etc.
 * @returns {{ pct: number, valid: boolean }}
 */
const get24hChange = (metalName) => {
  const mode = localStorage.getItem(SPOT_COMPARE_MODE_KEY) || 'close-close';
  const { data: closeData, openData } = getSparklineData(metalName, 3, false);

  if (closeData.length < 2) return { pct: 0, valid: false };

  const yesterday = closeData.length - 2;
  const today = closeData.length - 1;
  let oldPrice, newPrice;

  switch (mode) {
    case 'open-open':
      oldPrice = openData[yesterday];
      newPrice = openData[today];
      break;
    case 'open-close':
      oldPrice = openData[yesterday];
      newPrice = closeData[today];
      break;
    default: // 'close-close'
      oldPrice = closeData[yesterday];
      newPrice = closeData[today];
      break;
  }

  if (!oldPrice || oldPrice === 0) return { pct: 0, valid: false };
  return { pct: ((newPrice - oldPrice) / oldPrice) * 100, valid: true };
};

/**
 * Computes and displays % change on a spot card based on the selected sparkline period.
 * Compares oldest vs newest data point in the selected range.
 *
 * @param {string} metalKey - Metal key ('silver', 'gold', etc.)
 * @param {number[]|null} [precomputedData=null] - Pre-fetched data array from updateSparkline
 *   (avoids redundant re-fetch for historical ranges). When null, uses sync getSparklineData().
 */
const updateSpotChangePercent = (metalKey, precomputedData = null) => {
  const metalConfig = Object.values(METALS).find((m) => m.key === metalKey);
  if (!metalConfig) return;
  const el = document.getElementById(`spotChange${metalConfig.name}`);
  if (!el) return;

  const rangeSelectEl0 = document.getElementById(`spotRange${metalConfig.name}`);
  const selectedDays0 = rangeSelectEl0 ? parseInt(rangeSelectEl0.value, 10) : 90;

  let data;
  if (selectedDays0 === 1) {
    // 1-day view: use get24hChange() which respects the user's comparison mode (STACK-92)
    ({ data } = getSparklineData(metalConfig.name, 3, false));
  } else if (precomputedData) {
    data = precomputedData;
  } else {
    ({ data } = getSparklineData(metalConfig.name, selectedDays0));
  }

  if (data.length < 2) {
    el.textContent = "";
    return;
  }

  // For 1d view, use get24hChange() for mode-aware comparison (STACK-92).
  // For all other views, compare oldest→newest across the full selected range.
  let pctChange;
  if (selectedDays0 === 1) {
    const change24h = get24hChange(metalConfig.name);
    pctChange = change24h.valid ? change24h.pct : 0;
  } else {
    const oldest = data[0];
    const newest = data[data.length - 1];
    pctChange = ((newest - oldest) / oldest) * 100;
  }
  const sign = pctChange > 0 ? "+" : "";
  const rangeClass = pctChange > 0 ? "spot-change-up" : pctChange < 0 ? "spot-change-down" : "";

  // Build DOM: range % (colored) + optional 24h % (independently colored)
  el.className = "spot-card-change";
  el.textContent = "";

  const rangeSpan = document.createElement("span");
  rangeSpan.className = rangeClass;
  rangeSpan.textContent = `${sign}${pctChange.toFixed(2)}%`;
  el.appendChild(rangeSpan);

  // Append secondary % indicator in parentheses (STACK-69, STACK-92)
  // >1d views: show 24h change (mode-aware) | 1d view: show 90d change for context
  if (selectedDays0 > 1) {
    const change24h = get24hChange(metalConfig.name);
    if (change24h.valid) {
      const dayPct = change24h.pct;
      const daySign = dayPct > 0 ? "+" : "";
      const dayClass = dayPct > 0 ? "spot-change-up" : dayPct < 0 ? "spot-change-down" : "";

      const daySpan = document.createElement("span");
      daySpan.className = dayClass;
      daySpan.textContent = ` (${daySign}${dayPct.toFixed(2)}% 24h)`;
      el.appendChild(daySpan);
    }
  } else {
    // 1d view: show 90d context
    const { data: ctx90 } = getSparklineData(metalConfig.name, 90);
    if (ctx90.length >= 2) {
      const ctxOld = ctx90[0];
      const ctxNew = ctx90[ctx90.length - 1];
      const ctxPct = ((ctxNew - ctxOld) / ctxOld) * 100;
      const ctxSign = ctxPct > 0 ? "+" : "";
      const ctxClass = ctxPct > 0 ? "spot-change-up" : ctxPct < 0 ? "spot-change-down" : "";

      const ctxSpan = document.createElement("span");
      ctxSpan.className = ctxClass;
      ctxSpan.textContent = ` (${ctxSign}${ctxPct.toFixed(2)}% 90d)`;
      el.appendChild(ctxSpan);
    }
  }

  // Override the arrow direction on the price display to match the period-based
  // trend. updateSpotCardColor() compares against the last different price in ALL
  // history, but the user expects the arrow to reflect the selected period.
  const priceEl = elements.spotPriceDisplay[metalKey];
  if (priceEl) {
    const currentPrice = spotPrices[metalKey];
    const formatted =
      typeof formatCurrency === "function"
        ? formatCurrency(currentPrice)
        : currentPrice.toFixed(2);

    if (pctChange > 0) {
      priceEl.classList.add("spot-up");
      priceEl.classList.remove("spot-down", "spot-unchanged");
      priceEl.textContent = `\u25B2 ${formatted}`;
    } else if (pctChange < 0) {
      priceEl.classList.add("spot-down");
      priceEl.classList.remove("spot-up", "spot-unchanged");
      priceEl.textContent = `\u25BC ${formatted}`;
    } else {
      priceEl.classList.add("spot-unchanged");
      priceEl.classList.remove("spot-up", "spot-down");
      priceEl.textContent = `= ${formatted}`;
    }
  }
};

/**
 * Refreshes sparklines on all 4 metal cards concurrently
 */
const updateAllSparklines = async () => {
  await Promise.all(Object.values(METALS).map((mc) => updateSparkline(mc.key)));
};

/**
 * Destroys all sparkline Chart.js instances (cleanup)
 */
const destroySparklines = () => {
  Object.keys(sparklineInstances).forEach((key) => {
    if (sparklineInstances[key]) {
      sparklineInstances[key].destroy();
      sparklineInstances[key] = null;
    }
  });
};

/**
 * Opens an inline input on a spot card price for manual editing (shift+click)
 * @param {HTMLElement} valueEl - The .spot-card-value element that was clicked
 * @param {string} metalKey - Metal key ('silver', 'gold', etc.)
 */
const startSpotInlineEdit = (valueEl, metalKey) => {
  if (valueEl.querySelector(".spot-inline-input")) return; // already editing

  const metalConfig = Object.values(METALS).find((m) => m.key === metalKey);
  if (!metalConfig) return;

  const currentPrice = spotPrices[metalKey] || 0;
  const originalHTML = valueEl.innerHTML;

  const input = document.createElement("input");
  input.type = "number";
  input.className = "spot-inline-input";
  input.value = currentPrice > 0 ? currentPrice.toFixed(2) : "";
  input.step = "0.01";
  input.min = "0";

  valueEl.textContent = "";
  valueEl.appendChild(input);
  input.focus();
  input.select();

  const cancel = () => {
    // nosemgrep: javascript.browser.security.insecure-innerhtml.insecure-innerhtml, javascript.browser.security.insecure-document-method.insecure-document-method
    valueEl.innerHTML = originalHTML;
  };

  const save = () => {
    const num = parseFloat(input.value);
    if (isNaN(num) || num <= 0) {
      cancel();
      return;
    }

    localStorage.setItem(metalConfig.localStorageKey, num);
    spotPrices[metalKey] = num;
    valueEl.textContent = formatCurrency(num);
    updateSpotCardColor(metalKey, num);
    recordSpot(num, "manual", metalConfig.name);
    updateSpotTimestamp(metalConfig.name);
    updateSummary();
    updateSparkline(metalKey);

    // Snapshot item prices after inline spot edit (STACK-43)
    if (typeof recordAllItemPriceSnapshots === 'function') recordAllItemPriceSnapshots();

    // Goldback estimation hook (STACK-52)
    if (metalKey === 'gold' && typeof onGoldSpotPriceChanged === 'function') onGoldSpotPriceChanged();
  };

  input.addEventListener("keydown", (e) => {
    if (e.key === "Enter") {
      e.preventDefault();
      save();
    } else if (e.key === "Escape") {
      e.preventDefault();
      cancel();
    }
  });

  input.addEventListener("blur", cancel);
};

// =============================================================================
// SPOT HISTORY — SETTINGS LOG TABLE
// =============================================================================

/** @type {string} Current sort column for settings spot history table */
let settingsSpotSortColumn = '';
/** @type {boolean} Sort ascending for settings spot history table */
let settingsSpotSortAsc = true;

/**
 * Renders the spot price history table in the Settings > Activity Log > Metals sub-tab.
 * Reads from the global spotHistory array and sorts by timestamp descending by default.
 */
const renderSpotHistoryTable = () => {
  const table = document.getElementById('settingsSpotHistoryTable');
  if (!table) return;

  loadSpotHistory();
  let data = [...spotHistory];

  // Sort
  if (settingsSpotSortColumn) {
    data.sort((a, b) => {
      const valA = a[settingsSpotSortColumn];
      const valB = b[settingsSpotSortColumn];
      if (valA < valB) return settingsSpotSortAsc ? -1 : 1;
      if (valA > valB) return settingsSpotSortAsc ? 1 : -1;
      return 0;
    });
  } else {
    data.sort((a, b) => (a.timestamp < b.timestamp ? 1 : -1));
  }

  const tbody = table.querySelector('tbody');
  if (!tbody) return;

  if (data.length === 0) {
    // nosemgrep: javascript.browser.security.insecure-innerhtml.insecure-innerhtml
    tbody.innerHTML = '<tr class="settings-log-empty"><td colspan="5">No spot price history recorded yet.</td></tr>';
    return;
  }

  const rows = data.map(e => {
    const ts = e.timestamp ? (typeof formatTimestamp === 'function' ? formatTimestamp(e.timestamp) : new Date(e.timestamp).toLocaleString()) : '';
    const metal = e.metal || '';
    const price = typeof formatCurrency === 'function' ? formatCurrency(e.spot) : `$${Number(e.spot).toFixed(2)}`;
    const source = e.source || '';
    const provider = e.source === 'seed' ? 'Seed' : (e.provider || '');
    return `<tr><td>${ts}</td><td>${escapeHtml(metal)}</td><td>${price}</td><td>${escapeHtml(source)}</td><td>${escapeHtml(provider)}</td></tr>`;
  });

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

  // Sortable headers
  table.querySelectorAll('th').forEach(th => {
    th.style.cursor = 'pointer';
    th.onclick = () => {
      const cols = ['timestamp', 'metal', 'spot', 'source', 'provider'];
      const idx = Array.from(th.parentNode.children).indexOf(th);
      const col = cols[idx];
      if (settingsSpotSortColumn === col) {
        settingsSpotSortAsc = !settingsSpotSortAsc;
      } else {
        settingsSpotSortColumn = col;
        settingsSpotSortAsc = true;
      }
      renderSpotHistoryTable();
    };
  });
};

/** @type {string} Current sort column for settings LBMA reference table */
let settingsLbmaSortColumn = '';
/** @type {boolean} Sort ascending for settings LBMA reference table */
let settingsLbmaSortAsc = true;
/** @type {Array<Object>|null} Cached LBMA reference entries */
let settingsLbmaReferenceCache = null;
/** @type {Promise<Array<Object>>|null} In-flight LBMA load promise */
let settingsLbmaLoadPromise = null;
/** @type {boolean} Prevent duplicate LBMA control binding */
let settingsLbmaControlsBound = false;

/**
 * Returns the seed years configured for bundled LBMA reference data.
 * Falls back to current year when the seed config is unavailable.
 *
 * @returns {number[]} Sorted list of years
 */
const getLbmaReferenceYears = () => {
  if (typeof SEED_DATA_YEARS !== 'undefined' && Array.isArray(SEED_DATA_YEARS) && SEED_DATA_YEARS.length > 0) {
    return [...new Set(
      SEED_DATA_YEARS
        .map((y) => parseInt(y, 10))
        .filter((y) => Number.isFinite(y))
    )].sort((a, b) => a - b);
  }
  return [new Date().getFullYear()];
};

/**
 * Loads and caches LBMA reference entries from yearly seed files.
 * Uses fetchYearFile() for file:// and HTTP compatibility.
 *
 * @returns {Promise<Array<Object>>} Flattened LBMA reference entries
 */
const loadLbmaReferenceEntries = async () => {
  if (Array.isArray(settingsLbmaReferenceCache)) return settingsLbmaReferenceCache;
  if (settingsLbmaLoadPromise) return settingsLbmaLoadPromise;

  settingsLbmaLoadPromise = Promise.all(getLbmaReferenceYears().map(fetchYearFile))
    .then((yearArrays) => {
      let entries = yearArrays
        .flat()
        .filter((e) => e && typeof e.spot === 'number' && e.metal && e.timestamp)
        .filter((e) => e.source === 'seed' || String(e.provider || '').toUpperCase() === 'LBMA');

      // Final fallback for unusual file:// cases where year files fail entirely.
      if (entries.length === 0 && typeof getEmbeddedSeedData === 'function') {
        entries = getEmbeddedSeedData().filter(
          (e) => e && typeof e.spot === 'number' && e.metal && e.timestamp
        );
      }

      settingsLbmaReferenceCache = entries.map((e) => ({
        ...e,
        provider: e.provider || 'LBMA',
      }));
      return settingsLbmaReferenceCache;
    })
    .catch(() => {
      settingsLbmaReferenceCache = [];
      return settingsLbmaReferenceCache;
    })
    .finally(() => {
      settingsLbmaLoadPromise = null;
    });

  return settingsLbmaLoadPromise;
};

/**
 * Binds LBMA history filter controls once.
 */
const bindLbmaHistoryControls = () => {
  if (settingsLbmaControlsBound) return;

  const metalFilter = document.getElementById('settingsLbmaMetalFilter');
  const startDate = document.getElementById('settingsLbmaStartDate');
  const endDate = document.getElementById('settingsLbmaEndDate');
  const resetBtn = document.getElementById('settingsLbmaResetBtn');
  if (!metalFilter || !startDate || !endDate || !resetBtn) return;

  const rerender = () => {
    renderLbmaHistoryTable();
  };

  metalFilter.addEventListener('change', rerender);
  startDate.addEventListener('change', rerender);
  endDate.addEventListener('change', rerender);
  resetBtn.addEventListener('click', () => {
    metalFilter.value = 'all';
    startDate.value = '';
    endDate.value = '';
    renderLbmaHistoryTable();
  });

  settingsLbmaControlsBound = true;
};

/**
 * Renders the Settings > Activity Log > LBMA History reference table.
 * Data source is bundled year files (seed reference data), not user spotHistory.
 */
const renderLbmaHistoryTable = async () => {
  const table = document.getElementById('settingsLbmaHistoryTable');
  if (!table) return;
  bindLbmaHistoryControls();

  const tbody = table.querySelector('tbody');
  if (!tbody) return;

  const resultCountEl = document.getElementById('settingsLbmaResultCount');
  // nosemgrep: javascript.browser.security.insecure-innerhtml.insecure-innerhtml
  tbody.innerHTML = '<tr class="settings-log-empty"><td colspan="4">Loading LBMA reference history…</td></tr>';

  const allEntries = await loadLbmaReferenceEntries();
  const metalFilter = (document.getElementById('settingsLbmaMetalFilter')?.value || 'all').toLowerCase();
  const startDate = document.getElementById('settingsLbmaStartDate')?.value || '';
  const endDate = document.getElementById('settingsLbmaEndDate')?.value || '';

  let data = [...allEntries];

  if (metalFilter !== 'all') {
    data = data.filter((e) => String(e.metal || '').toLowerCase() === metalFilter);
  }
  if (startDate) {
    data = data.filter((e) => String(e.timestamp || '').slice(0, 10) >= startDate);
  }
  if (endDate) {
    data = data.filter((e) => String(e.timestamp || '').slice(0, 10) <= endDate);
  }

  if (settingsLbmaSortColumn) {
    data.sort((a, b) => {
      const getSortVal = (entry) => {
        if (settingsLbmaSortColumn === 'spot') return Number(entry.spot) || 0;
        if (settingsLbmaSortColumn === 'metal') return String(entry.metal || '').toLowerCase();
        if (settingsLbmaSortColumn === 'provider') return String(entry.provider || '').toLowerCase();
        return String(entry.timestamp || '');
      };
      const valA = getSortVal(a);
      const valB = getSortVal(b);
      if (valA < valB) return settingsLbmaSortAsc ? -1 : 1;
      if (valA > valB) return settingsLbmaSortAsc ? 1 : -1;
      return 0;
    });
  } else {
    data.sort((a, b) => (a.timestamp < b.timestamp ? 1 : -1));
  }

  if (resultCountEl) {
    resultCountEl.textContent = `${data.length.toLocaleString()} rows`;
  }

  if (data.length === 0) {
    // nosemgrep: javascript.browser.security.insecure-innerhtml.insecure-innerhtml
    tbody.innerHTML = '<tr class="settings-log-empty"><td colspan="4">No LBMA reference rows match the current filters.</td></tr>';
  } else {
    const rows = data.map((entry) => {
      const rawDate = String(entry.timestamp || '').slice(0, 10);
      const dateLabel = rawDate
        ? (typeof formatDisplayDate === 'function' ? formatDisplayDate(rawDate) : rawDate)
        : '';
      const metal = escapeHtml(entry.metal || '');
      const spot = typeof formatCurrency === 'function'
        ? formatCurrency(entry.spot)
        : `$${Number(entry.spot).toFixed(2)}`;
      const provider = escapeHtml(entry.provider || 'LBMA');
      return `<tr><td>${dateLabel}</td><td>${metal}</td><td>${spot}</td><td>${provider}</td></tr>`;
    });
    // nosemgrep: javascript.browser.security.insecure-innerhtml.insecure-innerhtml
    tbody.innerHTML = rows.join('');
  }

  table.querySelectorAll('th').forEach((th, idx) => {
    th.style.cursor = 'pointer';
    th.onclick = () => {
      const cols = ['date', 'metal', 'spot', 'provider'];
      const nextCol = cols[idx];
      if (settingsLbmaSortColumn === nextCol) {
        settingsLbmaSortAsc = !settingsLbmaSortAsc;
      } else {
        settingsLbmaSortColumn = nextCol;
        settingsLbmaSortAsc = true;
      }
      renderLbmaHistoryTable();
    };
  });
};

/**
 * Clears all spot price history after user confirmation.
 */
const clearSpotHistory = () => {
  if (!confirm('Clear all spot price history? This cannot be undone.')) return;
  spotHistory = [];
  saveSpotHistory();
  // Reset rendered flag so it re-renders fresh
  const panel = document.getElementById('logPanel_metals');
  if (panel) delete panel.dataset.rendered;
  renderSpotHistoryTable();
};

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

// ---------------------------------------------------------------------------
// Seed bundle loader (file:// protocol support)
// ---------------------------------------------------------------------------
// data/spot-history-bundle.js calls this function on load.
// Compact format: { year: { metal: [[MM-DD, price], ...] } }
// Expands into full historicalDataCache entries so fetchYearFile() finds
// cached data immediately without network requests.
/**
 * Loads the spot history seed bundle into the cache.
 * @param {Object} bundle - The spot history seed bundle.
 */
window._loadSpotSeedBundle = function(bundle) {
  let loaded = 0;
  for (const yearStr of Object.keys(bundle)) {
    const year = parseInt(yearStr, 10);
    if (historicalDataCache.has(year) && historicalDataCache.get(year).length > 0) continue;
    const metals = bundle[yearStr];
    const entries = [];
    for (const metal of Object.keys(metals)) {
      for (const pair of metals[metal]) {
        entries.push({
          spot: pair[1],
          metal: metal,
          source: 'seed',
          provider: 'LBMA',
          timestamp: yearStr + '-' + pair[0] + ' 12:00:00'
        });
      }
    }
    historicalDataCache.set(year, entries);
    loaded += entries.length;
  }
  if (loaded > 0) {
    console.log('[SpotSeed] Loaded ' + loaded + ' entries from bundle (' + Object.keys(bundle).length + ' years)');
  }
};

// Ensure global availability
window.fetchSpotPrice = fetchSpotPrice;
window.updateSpotCardColor = updateSpotCardColor;
window.updateSparkline = updateSparkline;
window.updateAllSparklines = updateAllSparklines;
window.destroySparklines = destroySparklines;
window.updateSpotChangePercent = updateSpotChangePercent;
window.startSpotInlineEdit = startSpotInlineEdit;
window.getMetalColor = getMetalColor;
window.loadTrendRanges = loadTrendRanges;
window.saveTrendRange = saveTrendRange;
window.renderSpotHistoryTable = renderSpotHistoryTable;
window.renderLbmaHistoryTable = renderLbmaHistoryTable;
window.clearSpotHistory = clearSpotHistory;
window.getHistoricalSparklineData = getHistoricalSparklineData;
window.getRequiredYears = getRequiredYears;
window.fetchYearFile = fetchYearFile;
window.historicalDataCache = historicalDataCache;