'use strict';

/**
 * icon-store.js
 *
 * On-demand icon fetching, resolved in priority order:
 *   1. WoW's own FileDataID -> icon name, via the local listfile (see
 *      listfile.js). This is the texture the game is actually drawing.
 *   2. Spell/item ID -> icon name, via Wowhead's tooltip endpoint.
 * The name is then downloaded from the Wowhead CDN and cached on disk as a
 * 72x72 PNG. Each icon is downloaded exactly once; after that everything is
 * served offline from icon_cache/.
 *
 * Replaces the old CASCExplorer/BLP extraction workflow entirely.
 */

const fs = require('fs');
const path = require('path');
const listfile = require('./listfile');

const ICON_CACHE_DIR = path.join(__dirname, 'icon_cache');
const TOOLTIP_BASE = 'https://nether.wowhead.com/tooltip';
const CDN_BASE = 'https://wow.zamimg.com/images/wow/icons/large';
const ICON_SIZE = 72;
const FETCH_TIMEOUT_MS = 10_000;
const FAILURE_COOLDOWN_MS = 45_000; // don't re-hit a failing ID for 45s

let Jimp = null;
async function getJimp() {
  if (!Jimp) Jimp = (await import('jimp')).Jimp;
  return Jimp;
}

/** base64 PNG per cache key, shared with disk cache */
const memoryCache = new Map();
/** cache key → in-flight promise, so concurrent renders fetch once */
const inFlight = new Map();

// Cap parallel downloads so a full action bar sync (~35 icons) doesn't
// hammer Wowhead and risk rate limiting
const MAX_CONCURRENT_FETCHES = 5;
let activeFetches = 0;
const fetchQueue = [];

function acquireFetchSlot() {
  if (activeFetches < MAX_CONCURRENT_FETCHES) {
    activeFetches++;
    return Promise.resolve();
  }
  return new Promise((resolve) => fetchQueue.push(resolve));
}

function releaseFetchSlot() {
  const next = fetchQueue.shift();
  if (next) next();
  else activeFetches--;
}
/** cache key → epoch ms of last failure */
const recentFailures = new Map();

fs.mkdirSync(ICON_CACHE_DIR, { recursive: true });

/**
 * Icon candidates for a slot, best first.
 *
 * WoW's own texture wins: it is literally what the game is drawing on that
 * button, so it is right even for custom macro icons, and the listfile
 * resolves it locally with no network call. Spell/item IDs are the fallback
 * for when the listfile has no entry or hasn't downloaded yet.
 */
function resolveIconRefs(slotData) {
  if (!slotData) return [];
  const refs = [];

  if (typeof slotData.icon === 'number') {
    refs.push({ type: 'file', id: slotData.icon });
  } else if (typeof slotData.icon === 'string' && slotData.icon.trim()) {
    // Older builds report a texture path; its basename is the icon name
    const name = slotData.icon.replace(/\\/g, '/').split('/').pop().toLowerCase();
    if (name) refs.push({ type: 'name', name: name.replace(/\.(blp|tga|png|jpg)$/, '') });
  }

  const spellID = slotData.spellID || (slotData.actionType === 'spell' ? slotData.id : null);
  if (spellID) refs.push({ type: 'spell', id: spellID });

  const itemID = slotData.itemID || (slotData.actionType === 'item' ? slotData.id : null);
  if (itemID) refs.push({ type: 'item', id: itemID });

  return refs;
}

function cacheKeyFor(ref) {
  return ref.type === 'name' ? `name-${ref.name}` : `${ref.type}-${ref.id}`;
}

/** Preferred candidate only — used to ask "is this slot resolvable at all?" */
function resolveIconRef(slotData) {
  return resolveIconRefs(slotData)[0] ?? null;
}

async function fetchWithTimeout(url) {
  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
  try {
    return await fetch(url, {
      signal: controller.signal,
      headers: { 'User-Agent': 'RockyCore/2.0' },
    });
  } finally {
    clearTimeout(timer);
  }
}

/** Wowhead tooltip JSON contains the icon's file name (e.g. "spell_fire_flamebolt"). */
async function lookupIconName(type, id) {
  const res = await fetchWithTimeout(`${TOOLTIP_BASE}/${type}/${id}?dataEnv=1&locale=0`);
  if (!res.ok) throw new Error(`tooltip ${type}/${id} → HTTP ${res.status}`);
  const data = await res.json();
  if (!data.icon) throw new Error(`tooltip ${type}/${id} has no icon field`);
  return String(data.icon).toLowerCase();
}

async function downloadIcon(iconName) {
  const res = await fetchWithTimeout(`${CDN_BASE}/${iconName}.jpg`);
  if (!res.ok) throw new Error(`icon ${iconName} → HTTP ${res.status}`);
  return Buffer.from(await res.arrayBuffer());
}

async function toPng72(buffer) {
  const J = await getJimp();
  const image = await J.read(buffer);
  image.resize({ w: ICON_SIZE, h: ICON_SIZE });
  return image.getBuffer('image/png');
}

/** The icon file name for one candidate, or null if it can't be resolved. */
async function iconNameFor(ref) {
  if (ref.type === 'file') return listfile.lookupIconName(ref.id);
  if (ref.type === 'name') return ref.name;
  return lookupIconName(ref.type, ref.id);  // Wowhead tooltip
}

function readCached(key) {
  const mem = memoryCache.get(key);
  if (mem) return mem;

  const diskPath = path.join(ICON_CACHE_DIR, `${key}.png`);
  if (fs.existsSync(diskPath)) {
    const base64 = fs.readFileSync(diskPath).toString('base64');
    memoryCache.set(key, base64);
    return base64;
  }
  return null;
}

/**
 * Get a slot's icon as base64 PNG, or null if unresolvable (caller should
 * fall back to the placeholder). Never throws.
 *
 * Candidates are tried in priority order, so a Wowhead lookup only happens
 * when the listfile can't answer.
 */
async function getIconBase64(slotData) {
  const refs = resolveIconRefs(slotData);
  if (!refs.length) return null;

  // Any candidate already on disk or in memory wins outright
  for (const ref of refs) {
    const hit = readCached(cacheKeyFor(ref));
    if (hit) return hit;
  }

  // Keyed on the preferred candidate so concurrent renders share one fetch
  const primaryKey = cacheKeyFor(refs[0]);

  const failedAt = recentFailures.get(primaryKey);
  if (failedAt && Date.now() - failedAt < FAILURE_COOLDOWN_MS) return null;

  if (inFlight.has(primaryKey)) return inFlight.get(primaryKey);

  const promise = (async () => {
    await acquireFetchSlot();
    try {
      let lastError = null;
      for (const ref of refs) {
        try {
          const iconName = await iconNameFor(ref);
          if (!iconName) {
            // e.g. listfile still downloading — let the next candidate try
            lastError = new Error(`${ref.type} unresolved`);
            continue;
          }
          const png = await toPng72(await downloadIcon(iconName));
          const key = cacheKeyFor(ref);
          fs.writeFileSync(path.join(ICON_CACHE_DIR, `${key}.png`), png);
          const base64 = png.toString('base64');
          memoryCache.set(key, base64);
          recentFailures.delete(primaryKey);
          return base64;
        } catch (err) {
          lastError = err;
        }
      }
      throw lastError ?? new Error('no candidate resolved');
    } catch (err) {
      console.warn(`[IconStore] ${primaryKey} failed: ${err.message}`);
      recentFailures.set(primaryKey, Date.now());
      return null;
    } finally {
      releaseFetchSlot();
      inFlight.delete(primaryKey);
    }
  })();

  inFlight.set(primaryKey, promise);
  return promise;
}

/** Load an arbitrary PNG (e.g. the bundled placeholder) as base64, cached. */
async function getFileBase64(filePath) {
  const cacheKey = `file:${filePath}`;
  const cached = memoryCache.get(cacheKey);
  if (cached) return cached;

  try {
    const png = await toPng72(fs.readFileSync(filePath));
    const base64 = png.toString('base64');
    memoryCache.set(cacheKey, base64);
    return base64;
  } catch {
    return null;
  }
}

/** Start the listfile download early, since it is now the primary resolver. */
function warmup() {
  listfile.ensureReady();
}

module.exports = {
  getIconBase64, getFileBase64, resolveIconRef, resolveIconRefs, warmup, ICON_CACHE_DIR,
};
