'use strict';

/**
 * RockyCore WoWDeck - Stream Deck Plugin (standalone)
 *
 * Single process: watches WoW's SavedVariables directly (state-watcher.js),
 * fetches icons on demand from the web with a local cache (icon-store.js),
 * renders slots onto Stream Deck buttons, and simulates keypresses.
 *
 * No bridge process, no .env, no icon extraction.
 */

const WebSocket = require('ws');
const fs = require('fs');
const path = require('path');

const { StateWatcher, findRetailDir } = require('./state-watcher');
const iconStore = require('./icon-store');

// ── File logging ─────────────────────────────────────────────────────────────
// Stream Deck discards the Node runtime's stdout, so mirror all console
// output to debug.log next to the plugin for troubleshooting.
const LOG_FILE = path.join(__dirname, 'debug.log');
try { if (fs.existsSync(LOG_FILE) && fs.statSync(LOG_FILE).size > 1_000_000) fs.unlinkSync(LOG_FILE); } catch {}
function fileLog(level, args) {
  try {
    const line = args.map((a) => (a instanceof Error ? a.stack : String(a))).join(' ');
    fs.appendFileSync(LOG_FILE, `${new Date().toISOString()} [${level}] ${line}\n`);
  } catch {}
}
for (const lvl of ['log', 'warn', 'error']) {
  const orig = console[lvl].bind(console);
  console[lvl] = (...a) => { orig(...a); fileLog(lvl, a); };
}
process.on('uncaughtException', (err) => { fileLog('fatal', [err]); process.exit(1); });
process.on('unhandledRejection', (err) => { fileLog('rejection', [err]); });

// Lazy-loaded to avoid startup cost
let keyboard, Key, activeWin;
async function loadDeps() {
  if (!keyboard) {
    const nut = require('@nut-tree-fork/nut-js');
    keyboard = nut.keyboard;
    Key = nut.Key;
  }
  if (!activeWin) {
    activeWin = (await import('active-win')).default;
  }
}

// ── Constants ────────────────────────────────────────────────────────────────
const PLACEHOLDER_ICON = path.join(__dirname, 'icons', 'actionDefault.png');
const NO_KEY_ICON = path.join(__dirname, 'icons', 'noKey.png');
const BUNDLED_ADDON_DIR = path.join(__dirname, 'addon');

// ── State ────────────────────────────────────────────────────────────────────
/** @type {Map<string, {slot: number, settings: object}>} */
const buttonContexts = new Map();

/** @type {object|null} */
let wowState = null;

let globalSettings = {};

// ── Stream Deck SDK Globals ──────────────────────────────────────────────────
// Launched as: plugin.js -port <N> -pluginUUID <uuid> -registerEvent <evt> -info <json>
let sdPort, sdUUID, sdRegisterEvent, sdInfo;
let sdSocket = null;

const args = process.argv.slice(2);
for (let i = 0; i < args.length; i++) {
  if (args[i] === '-port') sdPort = args[i + 1];
  if (args[i] === '-pluginUUID') sdUUID = args[i + 1];
  if (args[i] === '-registerEvent') sdRegisterEvent = args[i + 1];
  if (args[i] === '-info') sdInfo = args[i + 1];
}

// ── Stream Deck WebSocket Connection ─────────────────────────────────────────
function connectStreamDeck() {
  sdSocket = new WebSocket(`ws://localhost:${sdPort}`);

  sdSocket.on('open', () => {
    sdSocket.send(JSON.stringify({
      event: sdRegisterEvent,
      uuid: sdUUID,
    }));
    console.log('[Plugin] Registered with Stream Deck software');
    sdSend({ event: 'getGlobalSettings', context: sdUUID });
    iconStore.warmup();
    startWatcher();
  });

  sdSocket.on('message', (data) => {
    let msg;
    try { msg = JSON.parse(data); } catch { return; }
    handleStreamDeckEvent(msg);
  });

  sdSocket.on('error', (err) => {
    console.error('[Plugin] Stream Deck socket error:', err.message);
  });
}

function handleStreamDeckEvent(msg) {
  const { event, context, payload, action } = msg;

  switch (event) {
    case 'willAppear': {
      const slot = parseInt(payload?.settings?.actionSlot ?? 1, 10);
      buttonContexts.set(context, { slot, settings: payload?.settings ?? {} });
      renderButton(context, slot);
      break;
    }
    case 'willDisappear': {
      buttonContexts.delete(context);
      break;
    }
    case 'didReceiveSettings': {
      const slot = parseInt(payload?.settings?.actionSlot ?? 1, 10);
      buttonContexts.set(context, { slot, settings: payload?.settings ?? {} });
      renderButton(context, slot);
      break;
    }
    case 'didReceiveGlobalSettings': {
      globalSettings = payload?.settings ?? {};
      installAddonIfNeeded();
      if (watcher) {
        watcher.setWowPathOverride(globalSettings.wowPath || null);
      }
      break;
    }
    case 'keyDown': {
      handleKeyDown(context);
      break;
    }
    case 'sendToPlugin': {
      if (payload?.event === 'requestState') {
        renderButton(context, buttonContexts.get(context)?.slot ?? 1);
        sendSlotSummary(action, context);
      }
      break;
    }
    case 'propertyInspectorDidAppear': {
      sendSlotSummary(action, context);
      break;
    }
  }
}

// ── Stream Deck API Helpers ───────────────────────────────────────────────────
// Give the Property Inspector the name/binding of every slot so its slot
// dropdown can show what's assigned where.
function sendSlotSummary(action, context) {
  const slots = {};
  for (const [key, s] of Object.entries(wowState?.slots ?? {})) {
    if (s && !s.empty) {
      slots[key] = { name: s.name || '', binding: s.binding || '' };
    }
  }
  sdSend({
    event: 'sendToPropertyInspector',
    action,
    context,
    payload: { event: 'slotSummary', slots },
  });
}

function sdSend(payload) {
  if (sdSocket?.readyState === WebSocket.OPEN) {
    sdSocket.send(JSON.stringify(payload));
  }
}

function setImage(context, base64DataUrl) {
  sdSend({
    event: 'setImage',
    context,
    payload: { image: base64DataUrl, target: 0 },
  });
}

function setTitle(context, title) {
  sdSend({
    event: 'setTitle',
    context,
    payload: { title: title ?? '', target: 0 },
  });
}

function showAlert(context) {
  sdSend({ event: 'showAlert', context });
}

// ── WoW State Watcher (in-process, replaces the bridge) ──────────────────────
let watcher = null;

function startWatcher() {
  installAddonIfNeeded();

  watcher = new StateWatcher({ wowPathOverride: globalSettings.wowPath || null });

  watcher.on('status', (message) => {
    console.log('[Plugin]', message);
  });

  watcher.on('state', (state) => {
    wowState = state;
    refreshAllButtons();
  });

  watcher.start();
}

// ── Addon auto-install ───────────────────────────────────────────────────────
// Copies the bundled addon into WoW's AddOns folder when missing or outdated,
// so installing the plugin is the only manual step.
function addonVersion(tocPath) {
  try {
    const m = fs.readFileSync(tocPath, 'utf8').match(/^## Version:\s*(.+)$/m);
    return m ? m[1].trim() : null;
  } catch {
    return null;
  }
}

function installAddonIfNeeded() {
  try {
    const retailDir = findRetailDir(globalSettings.wowPath || null);
    if (!retailDir || !fs.existsSync(BUNDLED_ADDON_DIR)) return;

    const targetDir = path.join(retailDir, 'Interface', 'AddOns', 'RockyCoreWoWDeckHelper');
    const bundledVer = addonVersion(path.join(BUNDLED_ADDON_DIR, 'RockyCoreWoWDeckHelper.toc'));
    const installedVer = addonVersion(path.join(targetDir, 'RockyCoreWoWDeckHelper.toc'));

    if (installedVer && installedVer === bundledVer) return;

    fs.mkdirSync(targetDir, { recursive: true });
    for (const file of fs.readdirSync(BUNDLED_ADDON_DIR)) {
      fs.copyFileSync(path.join(BUNDLED_ADDON_DIR, file), path.join(targetDir, file));
    }
    console.log(
      `[Plugin] Addon ${installedVer ? 'updated' : 'installed'} to ${targetDir} ` +
      `(version ${bundledVer})`
    );
  } catch (err) {
    console.warn('[Plugin] Addon auto-install failed:', err.message);
  }
}

// ── Rendering ─────────────────────────────────────────────────────────────────
// If any button is stuck on the placeholder because its icon fetch failed
// (offline, CDN hiccup), retry the render once a minute until it resolves.
let iconRetryTimer = null;
function scheduleIconRetry() {
  if (iconRetryTimer) return;
  iconRetryTimer = setTimeout(() => {
    iconRetryTimer = null;
    refreshAllButtons();
  }, 60_000);
}

function refreshAllButtons() {
  const renders = [];
  for (const [context, { slot }] of buttonContexts.entries()) {
    renders.push(renderButton(context, slot));
  }
  return Promise.all(renders);
}

// Contexts showing a temporary message. Keyed by context so repeated presses
// don't stack timers and a state refresh doesn't wipe the message early.
const flashTimers = new Map();

// Shows the warning-triangle image in place of the slot icon, then puts the
// normal icon back. The image carries its own "no key" caption, so the title
// is cleared to avoid Stream Deck drawing text over it.
async function flashNoKey(context, ms = 3000) {
  const existing = flashTimers.get(context);
  if (existing) clearTimeout(existing);

  // Register the timer before awaiting so a concurrent render stands down
  flashTimers.set(context, setTimeout(() => {
    flashTimers.delete(context);
    const slot = buttonContexts.get(context)?.slot;
    if (slot !== undefined) renderButton(context, slot);
  }, ms));

  setTitle(context, '');
  const image = await iconStore.getFileBase64(NO_KEY_ICON);
  if (image) setImage(context, `data:image/png;base64,${image}`);
}

// Both labels are off by default — a bare icon, like a real action bar button.
function buildTitle(slotData, settings) {
  const parts = [];
  if (settings.showBinding === true && slotData.binding) parts.push(slotData.binding);
  if (settings.showName === true && slotData.name) {
    const name = slotData.name.length > 9
      ? slotData.name.slice(0, 8) + '…'
      : slotData.name;
    parts.push(name);
  }
  return parts.join('\n');
}

async function renderButton(context, slot) {
  // Mid-flash: leave the warning on screen, it restores itself when it expires
  if (flashTimers.has(context)) return;

  const settings = buttonContexts.get(context)?.settings ?? {};
  const slotData = wowState?.slots?.[String(slot)] ?? wowState?.slots?.[slot];

  const unassigned = !slotData || slotData.empty ||
    (settings.hideUnassigned && !slotData.binding);

  if (unassigned) {
    setImage(context, '');
    setTitle(context, '');
    return;
  }

  try {
    let iconBase64 = await iconStore.getIconBase64(slotData);
    if (!iconBase64) {
      // Resolvable but not fetched yet → transient failure, retry later
      if (iconStore.resolveIconRef(slotData)) scheduleIconRetry();
      iconBase64 = await iconStore.getFileBase64(PLACEHOLDER_ICON);
    }
    if (iconBase64) {
      setImage(context, `data:image/png;base64,${iconBase64}`);
    }
    setTitle(context, buildTitle(slotData, settings));
  } catch (err) {
    console.error(`[Plugin] renderButton error (slot ${slot}):`, err.message);
  }
}

// ── Keypress Handling ─────────────────────────────────────────────────────────
// WoW must be the foreground window for keypresses to register.
async function handleKeyDown(context) {
  const buttonData = buttonContexts.get(context);
  if (!buttonData || !wowState) {
    console.warn(`[Plugin] keyDown ignored — button known: ${!!buttonData}, state loaded: ${!!wowState}`);
    return;
  }

  const slotData = wowState.slots?.[String(buttonData.slot)] ?? wowState.slots?.[buttonData.slot];
  if (!slotData || slotData.empty) {
    console.warn(`[Plugin] keyDown slot ${buttonData.slot} — slot is empty in WoW`);
    return;
  }
  if (!slotData.binding) {
    console.warn(`[Plugin] keyDown slot ${buttonData.slot} ("${slotData.name}") — no WoW keybinding, nothing to press`);
    await flashNoKey(context);
    return;
  }

  console.log(`[Plugin] keyDown slot ${buttonData.slot} → binding "${slotData.binding}"`);
  try {
    await loadDeps();
  } catch (err) {
    console.error('[Plugin] Failed to load keypress dependencies:', err);
    showAlert(context);
    return;
  }

  // Guard: only send keypress if WoW is focused
  try {
    const win = await activeWin();
    const title = win?.title ?? '';
    if (!title.toLowerCase().includes('world of warcraft')) {
      console.warn('[Plugin] WoW is not focused, skipping keypress');
      showAlert(context);
      return;
    }
  } catch {
    // active-win failed; proceed anyway
  }

  await sendKeypressForBinding(slotData.binding);
}

// Built once, after nut-js is loaded. Maps WoW's key names to nut-js Key values.
let keyMap = null;
function buildKeyMap() {
  const map = {
    'BACKSPACE': Key.Backspace, 'TAB': Key.Tab, 'ENTER': Key.Return,
    'RETURN': Key.Return, 'ESCAPE': Key.Escape, 'SPACE': Key.Space,
    'DELETE': Key.Delete, 'HOME': Key.Home, 'END': Key.End, 'INSERT': Key.Insert,
    'PAGEUP': Key.PageUp, 'PAGEDOWN': Key.PageDown,
    'UP': Key.Up, 'DOWN': Key.Down, 'LEFT': Key.Left, 'RIGHT': Key.Right,
    // Punctuation — WoW reports these as the literal character
    '`': Key.Grave, '-': Key.Minus, '=': Key.Equal,
    '[': Key.LeftBracket, ']': Key.RightBracket, '\\': Key.Backslash,
    ';': Key.Semicolon, "'": Key.Quote,
    ',': Key.Comma, '.': Key.Period, '/': Key.Slash,
    // Numpad operators use WoW's own names
    'NUMPADDIVIDE': Key.Divide, 'NUMPADMULTIPLY': Key.Multiply,
    'NUMPADMINUS': Key.Subtract, 'NUMPADPLUS': Key.Add,
    'NUMPADDECIMAL': Key.Decimal,
  };
  for (let i = 0; i <= 9; i++) {
    map[String(i)] = Key[`Num${i}`];        // top-row digits
    map[`NUMPAD${i}`] = Key[`NumPad${i}`];
  }
  for (let i = 1; i <= 12; i++) map[`F${i}`] = Key[`F${i}`];
  return map;
}

// Splits "CTRL-ALT-1" into modifiers + main key. Strips modifier prefixes from
// the front rather than splitting on "-", so the main key can itself be "-"
// (WoW writes shift+minus as "SHIFT--").
function splitBinding(binding) {
  let rest = binding.toUpperCase();
  const modifiers = [];
  let m;
  while ((m = /^(SHIFT|CTRL|ALT)-/.exec(rest))) {
    modifiers.push(m[1]);
    rest = rest.slice(m[0].length);
  }
  return { modifiers, mainKey: rest };
}

// WoW binding format examples: "1", "F1", "SHIFT-F", "CTRL-ALT-1", "NUMPAD0", "SHIFT--"
async function sendKeypressForBinding(binding) {
  await loadDeps();
  if (!keyMap) keyMap = buildKeyMap();

  const { modifiers, mainKey } = splitBinding(binding);

  if (/^(BUTTON\d+|MOUSEWHEELUP|MOUSEWHEELDOWN)$/.test(mainKey)) {
    console.warn(`[Plugin] Binding "${binding}" is a mouse button — cannot be sent as a keystroke`);
    return;
  }

  const keysToPress = [];
  for (const mod of modifiers) {
    if (mod === 'SHIFT') keysToPress.push(Key.LeftShift);
    else if (mod === 'CTRL') keysToPress.push(Key.LeftControl);
    else if (mod === 'ALT') keysToPress.push(Key.LeftAlt);
  }

  // Only numeric enum values are real keys. Key is a TypeScript enum, so
  // Key["1"] is the reverse mapping to the *name* "F1" — accepting that would
  // press the wrong key.
  let nutKey = typeof keyMap[mainKey] === 'number' ? keyMap[mainKey] : null;
  if (nutKey === null && /^[A-Z]$/.test(mainKey) && typeof Key[mainKey] === 'number') {
    nutKey = Key[mainKey];
  }
  if (nutKey === null) {
    console.warn(`[Plugin] No key mapping for: "${mainKey}" (from binding "${binding}")`);
    return;
  }

  keysToPress.push(nutKey);

  try {
    await keyboard.pressKey(...keysToPress);
    await keyboard.releaseKey(...keysToPress);
    console.log(`[Plugin] Keypress sent for binding "${binding}"`);
  } catch (err) {
    console.error('[Plugin] Keypress failed:', err);
  }
}

// ── Entry Point ───────────────────────────────────────────────────────────────
if (!sdPort || !sdUUID) {
  console.error('[Plugin] Missing required Stream Deck launch arguments (-port, -pluginUUID)');
  console.error('[Plugin] This plugin must be launched by the Stream Deck software.');
  process.exit(1);
}

console.log(`[Plugin] RockyCore WoWDeck starting (UUID: ${sdUUID})`);
connectStreamDeck();

process.on('SIGINT', () => {
  watcher?.stop();
  process.exit(0);
});
