/* =========================================================================
   Helbreath Sacrifice Launcher · V7 Battlefield Map
   Bridge to C++ host preserved + V7 strategic overview rendering.
   Phase 1: static data. Phase 2/3 will replace REALM_DATA with live fetch.
   ========================================================================= */

// --- Bridge -----------------------------------------------------------------

const bridge = {
  send(cmd, data) {
    const payload = Object.assign({ cmd }, data || {});
    if (window.chrome && window.chrome.webview) {
      window.chrome.webview.postMessage(payload);
    } else {
      console.log('[bridge]', payload);
    }
  }
};

// --- DOM refs ---------------------------------------------------------------

const $ = (sel) => document.querySelector(sel);

const el = {
  status:        $('#status-text'),
  statusIcon:    $('#status-icon'),
  progress:      $('#progress-fill'),
  btnPlay:       $('#btn-play'),
  btnCheck:      $('#btn-check'),
  btnRepair:     $('#btn-repair'),
  btnSettings:   $('#btn-settings'),
  serverStatus:  $('#server-pill'),
  serverText:    $('#server-text'),
  version:       $('#version'),
  linkDiscord:   $('#link-discord'),
  linkWeb:       $('#link-website'),
  heroA:         $('#hero-image-a'),
  heroB:         $('#hero-image-b'),
  regions:       $('#regions'),
  engagements:   $('#engagements'),
  ekFeed:        $('#ek-feed'),
  engCount:      $('#eng-count'),
  pctAresden:    $('#pct-aresden'),
  pctElvine:     $('#pct-elvine'),
  barAresden:    $('#bar-aresden'),
  barElvine:     $('#bar-elvine'),
  metaAresden:   $('#meta-aresden'),
  metaElvine:    $('#meta-elvine'),
  metaAresdenC:  $('#meta-aresden-castle'),
  metaElvineC:   $('#meta-elvine-castle'),
  settingsOverlay: $('#settings-overlay'),
  settingsClose:   $('#settings-close'),
};

const SETTING_IDS = {
  vsync:        'set-vsync',
  unlockFps:    'set-unlockFps',
  captureMouse: 'set-captureMouse',
};

let currentSettings = {};
let isUpdating = false;

// Weapon display_id → frame filename map for the EK feed icons. Built by
// Scripts/build_launcher_weapon_icons.py from ItemSpriteMetadata.json and
// shipped as web/weapon_frames.js (loaded via <script src> from index.html
// — fetch() on file:// origins is unreliable in WebView2).
function weapon_icon_html(weapon_id) {
  if (weapon_id == null || weapon_id < 0) return '';
  const map = (typeof window !== 'undefined' && window.WEAPON_FRAMES) || {};
  const fn = map[String(weapon_id)];
  if (!fn) return '';
  return `<img class="weapon" src="items/${fn}" alt="">`;
}

// --- Battle Detail overlay state -------------------------------------------
// Per-region zone parameters used by the detail sheet. The server doesn't
// expose ruleset/danger/loot — these are static config tied to the map id.
const ZONE_PARAMS = {
  aresden:    { sub: 'CITY OF KINGS',           tag: 'CAPITAL · SAFE ZONE',       terrain: 'City · Plains',  ruleset: 'Safe Zone',    level: 'any',  danger: 'NONE',     loot: 'standard',   xp: 'standard' },
  elvine:     { sub: 'EMERALD GATE',             tag: 'CAPITAL · SAFE ZONE',       terrain: 'City · Forest',  ruleset: 'Safe Zone',    level: 'any',  danger: 'NONE',     loot: 'standard',   xp: 'standard' },
  promise:    { sub: 'CONTESTED MARCHES',        tag: 'CONTESTED · FREE PVP',      terrain: 'Plains · Ruins', ruleset: 'Free PvP',     level: '40+',  danger: 'HIGH',     loot: '+20% drops', xp: '+15%' },
  procella:   { sub: 'STORM-BOUND SPIRES',       tag: 'DUNGEON · PVE',             terrain: 'Sky Citadel',    ruleset: 'PvE Dungeon',  level: '50+',  danger: 'HIGH',     loot: '+30% drops', xp: '+25%' },
  abaddon:    { sub: 'ABYSSAL HALLS',            tag: 'DUNGEON · BOSS ZONE',       terrain: 'Underworld',     ruleset: 'PvE Dungeon',  level: '70+',  danger: 'EXTREME',  loot: '+40% drops', xp: '+30%' },
  deathzone:  { sub: 'WASTELAND OF THE FALLEN',  tag: 'CONTESTED · NO RULES PVP',  terrain: 'Wasteland',      ruleset: 'Free PvP',     level: '50+',  danger: 'EXTREME',  loot: '+25% drops', xp: '+20%' },
  deathzone1: { sub: 'CRIMSON FIELDS',           tag: 'CONTESTED · NO RULES PVP',  terrain: 'Wasteland',      ruleset: 'Free PvP',     level: '60+',  danger: 'EXTREME',  loot: '+25% drops', xp: '+20%' },
  icebound:   { sub: 'FROZEN DEPTHS',            tag: 'DUNGEON · ICE',             terrain: 'Glacial Caves',  ruleset: 'PvE Dungeon',  level: '60+',  danger: 'HIGH',     loot: '+25% drops', xp: '+20%' },
};

// Tracks when each region first lit up as an engagement, so the overlay can
// display "Xm elapsed" without waiting on a server-side timestamp. Keyed by
// region id; cleared when the region drops out of the engagement list.
const engagementFirstSeen = {};
let latestRegionTally = {};
let latestRawEkFeed = [];
// Vuelta 2 — server-emitted Battle Detail data (keyed by raw m_map_name).
let latestMapStats       = {};   // { "deathzone": { aresden_ek, elvine_ek }, ... }
let latestTopCombatants  = {};   // { "deathzone": { a:[...top5], e:[...top5] }, ... }
let latestBalanceHistory = {};   // { "deathzone": [[age_s, a, e], ...], ... }
let latestNextEvent = null;      // {name, at_ms (UTC ms), running} or null
let currentBattleRegion = null;
let battleElapsedTimer = null;

// --- Phase 1: static realm data --------------------------------------------
// Phase 3 will replace this with `fetch('/realm_status.json')` polling.

// Seed values are placeholders shown for ~30 s on first launch until the
// C++ host pushes the live realm_status.json snapshot. Once any real data
// arrives the numbers (online counts, per-region populations, server pill
// total) get replaced via applyLiveRealmData.
const REALM_DATA = {
  factions: {
    aresden: { online: 0, in_combat: 0, castle: '—' },
    elvine:  { online: 0, in_combat: 0, castle: '—' },
  },
  // `match` lists the server-side map names that aggregate into this card.
  // The server reports raw m_map_name (e.g. "aresden", "deathzone1",
  // "Abaddon") and we list every variant we want grouped under one tile.
  regions: [
    { id: 'aresden',    name: 'ARESDEN CITY',  side: 'aresden',   tag: 'CAPITAL',          pop: '0', meta: 'Held · safe zone',          thumb: 'regions/aresden.png',     match: ['aresden'] },
    { id: 'elvine',     name: 'ELVINE CITY',   side: 'elvine',    tag: 'CAPITAL',          pop: '0', meta: 'Held · safe zone',          thumb: 'regions/elvine.png',      popClass: 'elvine', match: ['elvine'] },
    { id: 'promise',    name: 'PROMISELAND',   side: 'contested', tag: '⚔ PVP ZONE',       pop: '0', meta: 'Open PvP · loot boosted',   thumb: 'regions/promiseland.png', popClass: 'contested', match: ['2ndmiddle'] },
    { id: 'procella',   name: 'PROCELLA',      side: 'neutral',   tag: 'DUNGEON',          pop: '0', meta: 'Storm bound spires',        thumb: 'regions/procella.png',    match: ['procella'] },
    { id: 'abaddon',    name: 'ABADDON',       side: 'neutral',   tag: 'DUNGEON',          pop: '0', meta: 'Boss zone',                 thumb: 'regions/abaddon.png',     match: ['Abaddon', 'abaddon'] },
    { id: 'deathzone',  name: 'DEATH ZONE',    side: 'contested', tag: '⚔ PVP ZONE',       pop: '0', meta: 'Free PvP · no rules',       thumb: 'regions/deathzone.png',   popClass: 'contested', match: ['deathzone'] },
    { id: 'deathzone1', name: 'DEATH ZONE 1',  side: 'contested', tag: '⚔ PVP ZONE',       pop: '0', meta: 'Free PvP · no rules',       thumb: 'regions/deathzone1.png',  popClass: 'contested', match: ['deathzone1'] },
    { id: 'icebound',   name: 'ICE MAP',       side: 'neutral',   tag: 'DUNGEON',          pop: '0', meta: 'Frozen wastes',             thumb: 'regions/icebound.png',    match: ['icebound'] },
  ],
  // Active engagements list is built live from rebuildEngagements(); leaving
  // it empty here so the player never sees stale placeholder rows that wouldn't
  // resolve to a real region on click.
  engagements: [],
  ek_feed: [], // populated from realm_status.json `ek_feed`
};

// --- Render helpers ---------------------------------------------------------

function renderRegions() {
  if (!el.regions) return;
  el.regions.innerHTML = '';
  for (const r of REALM_DATA.regions) {
    const div = document.createElement('div');
    div.className = 'region ' + r.side;
    div.dataset.regionId = r.id;
    const popClass = r.popClass ? ' ' + r.popClass : '';
    const tagClass = r.tagClass ? r.tagClass : r.side;
    const pulse = r.pulse ? '<div class="region-pulse-overlay"></div>' : '';
    div.innerHTML = `
      <div class="region-stripe"></div>
      ${pulse}
      <img src="${r.thumb}" alt="${r.name}">
      <div class="region-tag ${tagClass}">${r.tag}</div>
      <div class="region-pop${popClass}"><span class="live"></span>${r.pop}</div>
      <div class="region-info">
        <div class="region-name">${r.name}</div>
        <div class="region-meta">${r.meta}</div>
      </div>
    `;
    el.regions.appendChild(div);
  }
}

function renderEngagements() {
  if (!el.engagements) return;
  el.engagements.innerHTML = '';
  if (!REALM_DATA.engagements.length) {
    const div = document.createElement('div');
    div.className = 'eng-empty';
    div.style.cssText = 'color:var(--text-muted);font-style:italic;font-size:11px;padding:8px 4px;text-align:center;';
    div.textContent = 'no active engagements';
    el.engagements.appendChild(div);
  }
  for (const e of REALM_DATA.engagements) {
    const div = document.createElement('div');
    div.className = 'engagement ' + e.kind;
    if (e.regionId) div.dataset.regionId = e.regionId;
    const live = e.noLive ? '' : '<div class="live">LIVE</div>';
    div.innerHTML = `
      <div class="e-thumb"><img src="${e.thumb}" alt=""></div>
      <div class="e-info">
        <div class="e-name">${e.name}</div>
        <div class="e-meta"><span class="e-tag">${e.tag}</span><span>${e.meta}</span></div>
      </div>
      <div class="e-count">
        <div>${e.count}</div>
        ${live}
      </div>
    `;
    el.engagements.appendChild(div);
  }
  if (el.engCount) el.engCount.textContent = REALM_DATA.engagements.filter(x => !x.noLive).length;
}

function renderEkFeed() {
  if (!el.ekFeed) return;
  el.ekFeed.innerHTML = '';
  if (!REALM_DATA.ek_feed.length) {
    const div = document.createElement('div');
    div.className = 'k';
    div.style.color = 'var(--text-muted)';
    div.style.fontStyle = 'italic';
    div.textContent = 'no recent kills';
    el.ekFeed.appendChild(div);
    return;
  }
  for (const k of REALM_DATA.ek_feed) {
    const div = document.createElement('div');
    div.className = 'k';
    div.innerHTML = `
      <span class="killer ${k.killer_side}">${k.killer}</span>
      <span class="verb">killed</span>
      <span class="victim ${k.victim_side}">${k.victim}</span>
      ${weapon_icon_html(k.weapon_id)}
      <span class="time">${k.t}</span>
    `;
    el.ekFeed.appendChild(div);
  }
}

function renderFactionStrength() {
  const a = REALM_DATA.factions.aresden;
  const e = REALM_DATA.factions.elvine;
  const total = a.online + e.online;
  const pctA = total > 0 ? Math.round((a.online / total) * 100) : 50;
  const pctE = 100 - pctA;
  if (el.pctAresden) el.pctAresden.textContent = `${a.online} · ${pctA}%`;
  if (el.pctElvine)  el.pctElvine.textContent  = `${e.online} · ${pctE}%`;
  if (el.barAresden) el.barAresden.style.width = pctA + '%';
  if (el.barElvine)  el.barElvine.style.width  = pctE + '%';
  if (el.metaAresden)  el.metaAresden.textContent  = `${a.in_combat} in combat`;
  if (el.metaElvine)   el.metaElvine.textContent   = `${e.in_combat} in combat`;
  if (el.metaAresdenC) el.metaAresdenC.textContent = `Castle: ${a.castle}`;
  if (el.metaElvineC)  el.metaElvineC.textContent  = `Castle: ${e.castle}`;
}

function renderAll() {
  renderRegions();
  renderEngagements();
  renderEkFeed();
  renderFactionStrength();
}

// --- Live realm status (Phase 3) -------------------------------------------
// The C++ host fetches /realm_status.json every 30 s and pushes the raw
// JSON to us as event 'realmStatus'. We can't fetch http:// directly from
// the file:// origin the WebView2 loads (cross-origin blocked), so the
// polling lives in C++ and we just consume the data here.

function applyLiveRealmData(data) {
  if (!data || typeof data !== 'object') return;

  // Faction online counts (overwrite the static seeds).
  if (data.factions) {
    if (data.factions.aresden && typeof data.factions.aresden.online === 'number') {
      REALM_DATA.factions.aresden.online = data.factions.aresden.online;
    }
    if (data.factions.elvine && typeof data.factions.elvine.online === 'number') {
      REALM_DATA.factions.elvine.online = data.factions.elvine.online;
    }
  }
  renderFactionStrength();

  // Server pill text uses real total online when available. Mark with a
  // data-live flag so subsequent setServerStatus calls (from C++ manifest
  // checks) don't overwrite our live number with a placeholder.
  if (typeof data.total_online === 'number' && el.serverText) {
    el.serverStatus.classList.add('online');
    el.serverText.textContent = `Server Online · ${data.total_online.toLocaleString()}`;
    el.serverText.dataset.live = '1';
  }

  // Per-map population: aggregate the server's raw m_map_name entries
  // into our region cards via the per-card `match` array. Server reports
  // many maps (Aresden + dungeons + halls + farms ...) — each card lumps
  // its set together so the player sees a meaningful zone count rather
  // than dozens of micro-instances.
  if (Array.isArray(data.maps)) {
    const norm = s => String(s || '').toLowerCase().replace(/[^a-z0-9]/g, '');
    const byKey = {};
    for (const m of data.maps) {
      if (!m || typeof m.name !== 'string') continue;
      byKey[norm(m.name)] = m;
    }
    let battles = 0, engaged = 0;
    // Track per-region tallies so we can also rebuild the engagements list
    // below — same data the cards use, just shaped for the sidebar entries.
    const regionTally = {};
    for (const r of REALM_DATA.regions) {
      let a = 0, e = 0, n = 0;
      const matchList = r.match || [r.id];
      for (const m of matchList) {
        const live = byKey[norm(m)];
        if (!live) continue;
        a += live.aresden || 0;
        e += live.elvine  || 0;
        n += live.neutral || 0;
      }
      regionTally[r.id] = { a, e, n, total: a + e + n, region: r };
      // Contested zones get an A v E split; single-side / dungeons stay flat.
      if (r.side === 'contested') {
        r.pop = `${a} v.s ${e}`;
        if (a > 0 && e > 0) {
          battles++;
          engaged += a + e;
        }
      } else {
        r.pop = String(a + e + n);
      }
    }
    renderRegions();
    // Header counters derive from what we just aggregated (sieges still
    // hardcoded — castle siege state isn't in the realm_status payload yet).
    const battlesEl = document.querySelector('.map-stats .stat.battles .v');
    const engagedEl = document.querySelector('.map-stats .stat.engaged .v');
    if (battlesEl) battlesEl.textContent = battles;
    if (engagedEl) engagedEl.textContent = engaged;

    rebuildEngagements(regionTally);
  }

  // Vuelta 2 — Battle Detail aggregates. We just stash the raw maps; the
  // detail-sheet render walks them keyed by the region's `match` array.
  if (data.map_stats       && typeof data.map_stats       === 'object') latestMapStats       = data.map_stats;
  if (data.top_combatants  && typeof data.top_combatants  === 'object') latestTopCombatants  = data.top_combatants;
  if (data.balance_history && typeof data.balance_history === 'object') latestBalanceHistory = data.balance_history;

  // Next-event countdown widget. `next_event` is {name, at_ms, running} or
  // null. We stash it and let the 1 s ticker render the live countdown so
  // the value keeps moving between 30 s realm_status pushes.
  if ('next_event' in data) {
    latestNextEvent = data.next_event || null;
    renderNextEvent();
  }

  // Recent enemy-kill feed — server pushes newest-first; we just trim and
  // map age_s into a compact "12s / 4m / 1h" label.
  if (Array.isArray(data.ek_feed)) {
    // Keep the raw entries (with `map`) for zone-filtered feeds in the
    // battle-detail overlay. The sidebar feed only needs the formatted view.
    latestRawEkFeed = data.ek_feed.slice();
    const out = [];
    for (const e of data.ek_feed) {
      if (!e || typeof e.killer !== 'string') continue;
      out.push({
        killer: e.killer,
        victim: e.victim || '',
        killer_side: e.killer_side === 'a' ? 'a' : (e.killer_side === 'e' ? 'e' : 'n'),
        victim_side: e.victim_side === 'a' ? 'a' : (e.victim_side === 'e' ? 'e' : 'n'),
        map: e.map || '',
        weapon_id: (typeof e.weapon_id === 'number') ? e.weapon_id : -1,
        t: formatAge(e.age_s),
      });
      if (out.length >= 8) break;
    }
    REALM_DATA.ek_feed = out;
    renderEkFeed();
  }

  if (currentBattleRegion) renderBattleDetail();
}

function formatAge(seconds) {
  const s = Math.max(0, Number(seconds) || 0);
  if (s < 60)  return s + 's';
  if (s < 3600) return Math.floor(s / 60) + 'm';
  return Math.floor(s / 3600) + 'h';
}

// Render the "NEXT EVENT" countdown widget from latestNextEvent. Called both
// when fresh realm_status data arrives and once per second by a ticker so the
// countdown keeps moving between 30 s server pushes. at_ms is UTC epoch ms;
// Date.now() is too, so the delta is the countdown straight up.
function renderNextEvent() {
  const lbl = document.getElementById('cc-label');
  const val = document.getElementById('cc-value');
  const sub = document.getElementById('cc-sub');
  if (!lbl || !val || !sub) return;

  const ev = latestNextEvent;
  if (!ev || typeof ev.at_ms !== 'number') {
    lbl.textContent = 'NEXT EVENT';
    val.textContent = '--';
    sub.textContent = 'No event scheduled';
    return;
  }

  const name = (ev.name || 'EVENT').toUpperCase();

  // Format the scheduled instant in the player's local time: "May 21 · 18:00".
  const MONTHS = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
  const dt = new Date(ev.at_ms);
  const pad = n => String(n).padStart(2, '0');
  const dateStr = MONTHS[dt.getMonth()] + ' ' + dt.getDate()
                + ' · ' + pad(dt.getHours()) + ':' + pad(dt.getMinutes());

  // "NEXT EVENT" stays small on top; the big gold line carries the event NAME
  // followed by the countdown, both in the same gold Cinzel type.
  lbl.textContent = 'NEXT EVENT';
  sub.textContent = dateStr;

  if (ev.running) {
    val.textContent = name + ' NOW';
    return;
  }

  let diff = Math.floor((ev.at_ms - Date.now()) / 1000); // seconds
  if (diff <= 0) { val.textContent = name + ' STARTING'; return; }

  const d = Math.floor(diff / 86400); diff -= d * 86400;
  const h = Math.floor(diff / 3600);  diff -= h * 3600;
  const m = Math.floor(diff / 60);
  const parts = [];
  if (d > 0) parts.push(d + 'd');
  parts.push(h + 'h');
  parts.push(m + 'm');
  val.textContent = name + ' ' + parts.join(' ');
}

// 1 s ticker keeps the countdown live between realm_status pushes.
setInterval(renderNextEvent, 1000);

// Derive the sidebar's Active Engagements list from per-region tallies. We
// classify each non-empty region into one of three rows:
//   * SIEGE  — a castle/officer region with both sides present
//   * PVP    — a contested region with both sides present
//   * DUNGEON — a neutral region with anyone inside
// The list is sorted by total engaged so the most-active entry sits on top.
function rebuildEngagements(tallies) {
  // Snapshot the latest tally so the battle-detail overlay can pull live
  // numbers without re-aggregating from the raw maps[] payload.
  latestRegionTally = tallies || {};

  const out = [];
  const seenIds = new Set();
  const now = Date.now();
  for (const id in tallies) {
    const t = tallies[id];
    const r = t.region;
    if (t.total === 0) continue;
    let entry = null;
    if (r.side === 'contested' && t.a > 0 && t.e > 0) {
      entry = {
        kind: 'skirmish',
        name: titleCase(r.name),
        tag: 'PVP',
        meta: 'contested',
        count: `${t.a} v.s ${t.e}`,
        thumb: r.thumb,
        regionId: r.id,
        sort: t.a + t.e + 500,
      };
    } else if (r.side === 'neutral' && t.total > 0) {
      entry = {
        kind: 'patrol',
        name: titleCase(r.name),
        tag: 'DUNGEON',
        meta: 'players inside',
        count: `${t.total} inside`,
        thumb: r.thumb,
        regionId: r.id,
        noLive: true,
        sort: t.total,
      };
    }
    if (entry) {
      out.push(entry);
      seenIds.add(r.id);
      // First time we see this region as an engagement — start its clock.
      if (!engagementFirstSeen[r.id]) engagementFirstSeen[r.id] = now;
    }
  }
  // Drop stale first-seen entries for regions no longer engaged so a future
  // re-flare starts fresh.
  for (const id in engagementFirstSeen) {
    if (!seenIds.has(id)) delete engagementFirstSeen[id];
  }
  out.sort((x, y) => y.sort - x.sort);
  REALM_DATA.engagements = out;
  renderEngagements();

  if (currentBattleRegion) renderBattleDetail();
}

function titleCase(s) {
  return String(s).toLowerCase().replace(/(^|\s)\S/g, c => c.toUpperCase());
}

// --- Battle Detail overlay --------------------------------------------------
// The sheet that opens when the player clicks a sidebar Active Engagement.
// We render with whatever live data we have today; sections that need new
// server-side fields (top combatants, balance history) show a polite stub.

function formatElapsed(ms) {
  const s = Math.max(0, Math.floor(ms / 1000));
  if (s < 60)   return s + 's';
  if (s < 3600) return Math.floor(s / 60) + 'm';
  const h = Math.floor(s / 3600);
  const m = Math.floor((s % 3600) / 60);
  return `${h}h ${m}m`;
}

function openBattleDetail(regionId) {
  const region = REALM_DATA.regions.find(r => r.id === regionId);
  if (!region) return;
  currentBattleRegion = region;
  const overlay = document.getElementById('battle-detail');
  if (overlay) overlay.classList.remove('hidden');
  renderBattleDetail();
  // Tick the elapsed pill once a second so it doesn't only refresh on the
  // 30 s realm_status poll.
  if (battleElapsedTimer) clearInterval(battleElapsedTimer);
  battleElapsedTimer = setInterval(updateBattleElapsed, 1000);
}

function closeBattleDetail() {
  currentBattleRegion = null;
  const overlay = document.getElementById('battle-detail');
  if (overlay) overlay.classList.add('hidden');
  if (battleElapsedTimer) {
    clearInterval(battleElapsedTimer);
    battleElapsedTimer = null;
  }
}

function updateBattleElapsed() {
  if (!currentBattleRegion) return;
  const r = currentBattleRegion;
  const first = engagementFirstSeen[r.id];
  const elapsedTxt = first ? formatElapsed(Date.now() - first) : '0s';
  const pill = document.getElementById('bd-elapsed');
  const mini = document.getElementById('bd-elapsed-mini');
  if (pill) pill.textContent = `LIVE BATTLE · ${elapsedTxt} elapsed`;
  if (mini) mini.textContent = `${elapsedTxt.toUpperCase()} ELAPSED`;
}

function renderBattleDetail() {
  if (!currentBattleRegion) return;
  const r = currentBattleRegion;
  const params = ZONE_PARAMS[r.id] || {};
  const tally = latestRegionTally[r.id] || { a: 0, e: 0, n: 0, total: 0 };
  const set = (id, txt) => { const n = document.getElementById(id); if (n) n.textContent = txt; };
  const setHTML = (id, html) => { const n = document.getElementById(id); if (n) n.innerHTML = html; };

  // Hero block
  const img = document.getElementById('bd-hero-img');
  if (img) img.src = r.thumb;
  set('bd-hero-title', r.name);
  set('bd-hero-sub', params.sub || '');
  set('bd-hero-tag', params.tag || (r.side === 'contested' ? 'CONTESTED · PVP' : (r.side === 'neutral' ? 'DUNGEON' : 'CAPITAL')));
  set('bd-crumb', r.name);

  // Hero strip — UTC clock + elapsed + trend stub.
  const now = new Date();
  const hh = String(now.getUTCHours()).padStart(2, '0');
  const mm = String(now.getUTCMinutes()).padStart(2, '0');
  set('bd-now', `${hh}:${mm} UTC`);
  set('bd-trend', tally.a > 0 && tally.e > 0 ? 'Active engagement' : 'Activity in zone');
  updateBattleElapsed();

  // Live scoreboard. EK / death counters come from the server-aggregated
  // map_stats (vuelta 2). Aresden's deaths == elvine_ek and vice versa, so
  // we just sum across the region's matched server maps.
  const a = tally.a || 0;
  const e = tally.e || 0;
  const total = a + e;
  const pctA = total > 0 ? Math.round((a / total) * 1000) / 10 : 50;
  const pctE = total > 0 ? Math.round((100 - pctA) * 10) / 10 : 50;
  let aEK = 0, eEK = 0;
  for (const m of (r.match || [r.id])) {
    const ms = latestMapStats[m] || latestMapStats[m.toLowerCase()] || latestMapStats[m.toUpperCase()];
    if (ms) {
      aEK += Number(ms.aresden_ek) || 0;
      eEK += Number(ms.elvine_ek)  || 0;
    }
  }
  if (r.side === 'contested') {
    set('bd-vs-text', `${a} vs ${e}`);
    set('bd-a-stats', `${a} on field · × ${aEK} ek · † ${eEK}`);
    set('bd-e-stats', `${e} on field · × ${eEK} ek · † ${aEK}`);
  } else {
    // Neutral dungeon — stack everyone on one side as "inside".
    set('bd-vs-text', `${tally.total || 0} inside`);
    set('bd-a-stats', `${a} aresden · × ${aEK} ek · † ${eEK}`);
    set('bd-e-stats', `${e} elvine · × ${eEK} ek · † ${aEK}`);
  }
  const sa = document.getElementById('bd-strength-a');
  const se = document.getElementById('bd-strength-e');
  if (sa) sa.style.width = pctA + '%';
  if (se) se.style.width = pctE + '%';
  set('bd-pct-a', `${pctA}% strength`);
  set('bd-pct-e', `${pctE}% strength`);
  set('bd-strength-trend', total > 0 ? 'live · 30 s refresh' : 'awaiting server tick');

  // Zone parameters
  set('bd-p-terrain', params.terrain || '—');
  set('bd-p-ruleset', params.ruleset || '—');
  set('bd-p-level',   params.level   || '—');
  set('bd-p-danger',  params.danger  || '—');
  set('bd-p-loot',    params.loot    || '—');
  set('bd-p-xp',      params.xp      || '—');

  // Zone-filtered EK feed. Match against the region's `match` list (raw
  // server m_map_name values, normalized) so e.g. Promiseland card pulls
  // kills tagged "2ndmiddle".
  const norm = s => String(s || '').toLowerCase().replace(/[^a-z0-9]/g, '');
  const matchSet = new Set((r.match || [r.id]).map(norm));
  const list = document.getElementById('bd-zone-feed-list');
  if (list) {
    const zoneEntries = (latestRawEkFeed || []).filter(k => matchSet.has(norm(k.map)));
    if (!zoneEntries.length) {
      list.innerHTML = `<div class="empty">no kills in this zone yet</div>`;
    } else {
      list.innerHTML = zoneEntries.map(k => {
        const ks = k.killer_side === 'a' ? 'a' : (k.killer_side === 'e' ? 'e' : 'n');
        const vs = k.victim_side === 'a' ? 'a' : (k.victim_side === 'e' ? 'e' : 'n');
        return `<div class="k">
          <span class="killer ${ks}">${escapeHtml(k.killer || '')}</span>
          <span class="verb">killed</span>
          <span class="victim ${vs}">${escapeHtml(k.victim || '')}</span>
          ${weapon_icon_html(k.weapon_id)}
          <span class="time">${formatAge(k.age_s)}</span>
        </div>`;
      }).join('');
    }
  }

  // Top Combatants + Balance Timeline (vuelta 2 server-emitted sections)
  renderTopCombatants(r);
  renderBalanceTimeline(r);

  // Recommendation line
  let rec;
  if (r.side === 'contested' && a > 0 && e > 0) {
    rec = `${total} hostiles in zone — bring allies or rejoin from a safe map`;
  } else if (r.side === 'neutral' && tally.total > 0) {
    rec = `${tally.total} players inside — coordinate with a party`;
  } else {
    rec = 'zone is quiet — check back in a few minutes';
  }
  set('bd-rec-text', rec);
}

// Aggregate top combatants from all maps that match this region. Each
// region card may roll up several server-side maps (e.g. Promiseland is
// just "2ndmiddle" but a future card could span variants), so we union
// the per-map top-5 then re-sort and trim. Names are unique per server.
function renderTopCombatants(region) {
  const host = document.getElementById('bd-tc-content');
  if (!host) return;

  const merged = { a: [], e: [] };
  for (const m of (region.match || [region.id])) {
    const tc = latestTopCombatants[m] || latestTopCombatants[m.toLowerCase()] || latestTopCombatants[m.toUpperCase()];
    if (!tc) continue;
    if (Array.isArray(tc.a)) merged.a.push(...tc.a);
    if (Array.isArray(tc.e)) merged.e.push(...tc.e);
  }
  // De-dupe by name (a player who switched maps inside the region group
  // shows up twice — keep the higher-kill row).
  const dedupe = arr => {
    const byName = new Map();
    for (const c of arr) {
      const cur = byName.get(c.name);
      if (!cur || (Number(c.kills) || 0) > (Number(cur.kills) || 0)) byName.set(c.name, c);
    }
    return Array.from(byName.values()).sort((x, y) => (y.kills || 0) - (x.kills || 0)).slice(0, 5);
  };
  const topA = dedupe(merged.a);
  const topE = dedupe(merged.e);

  if (topA.length === 0 && topE.length === 0) {
    host.innerHTML = `<div class="bd-stub">no kills yet in this zone</div>`;
    return;
  }

  const renderCol = (rows, sideClass, sideName) => {
    if (!rows.length) {
      return `<div class="bd-tc-col"><div class="bd-tc-col-head ${sideClass}">${sideName}</div><div class="bd-tc-empty">— no kills —</div></div>`;
    }
    const items = rows.map((c, i) => {
      const guild = c.guild && c.guild !== 'NONE' ? ` · ${escapeHtml(c.guild)}` : '';
      return `<div class="bd-tc-row">
        <span class="rank">${i + 1}</span>
        <span class="name">${escapeHtml(c.name || '')}</span>
        <span class="meta">Lv ${Number(c.level) || 0}${guild}</span>
        <span class="kills">×${Number(c.kills) || 0}</span>
      </div>`;
    }).join('');
    return `<div class="bd-tc-col"><div class="bd-tc-col-head ${sideClass}">${sideName}</div>${items}</div>`;
  };

  host.innerHTML = `<div class="bd-tc-grid">${renderCol(topA, 'a', 'ARESDEN')}${renderCol(topE, 'e', 'ELVINE')}</div>`;
}

// Render the balance histogram. Each bar = one snapshot tick (10 s); height
// reflects the side ratio at that moment. Bars stack newest-on-the-right.
function renderBalanceTimeline(region) {
  const host = document.getElementById('bd-tl-content');
  const window_label = document.getElementById('bd-tl-window');
  if (!host) return;

  // Union samples from all matched server maps; if multiple maps roll up
  // into one card we sum their populations per timestamp (bucketed by age_s).
  const buckets = new Map();
  for (const m of (region.match || [region.id])) {
    const samples = latestBalanceHistory[m]
      || latestBalanceHistory[m.toLowerCase()]
      || latestBalanceHistory[m.toUpperCase()];
    if (!Array.isArray(samples)) continue;
    for (const s of samples) {
      if (!Array.isArray(s) || s.length < 3) continue;
      const age = s[0] | 0;
      const cur = buckets.get(age) || [0, 0];
      cur[0] += s[1] | 0;
      cur[1] += s[2] | 0;
      buckets.set(age, cur);
    }
  }

  if (buckets.size === 0) {
    host.innerHTML = `<div class="bd-stub">no samples yet — the timeline starts populating once the server takes its first 10 s tick</div>`;
    if (window_label) window_label.textContent = 'last — min';
    return;
  }

  // Keep the 60 newest samples (10 min @ 10 s tick), then reverse so the
  // render walks oldest → newest left to right.
  const sorted = Array.from(buckets.entries())
    .sort((x, y) => x[0] - y[0])   // ascending age → newest (age=0) first
    .slice(0, 60)
    .reverse();                      // now oldest (largest age) first = leftmost bar

  const ageOldest = sorted[0][0];
  const ageNewest = sorted[sorted.length - 1][0];
  const minutes = Math.max(1, Math.round((ageOldest - ageNewest) / 60));
  if (window_label) window_label.textContent = `last ${minutes} min`;

  // Each bar: container 100% tall, Aresden bottom, Elvine top, split by ratio.
  const bars = sorted.map(([_, [a, e]]) => {
    const total = a + e;
    if (total === 0) return `<div class="bd-tl-bar"></div>`;
    const aPct = (a / total) * 100;
    const ePct = 100 - aPct;
    return `<div class="bd-tl-bar"><div class="e" style="height:${ePct.toFixed(1)}%"></div><div class="a" style="height:${aPct.toFixed(1)}%"></div></div>`;
  }).join('');

  // Axis labels. UTC clock at each end so it lines up with the in-game day.
  const now = new Date();
  const leftEdge = new Date(now.getTime() - ageOldest * 1000);
  const fmt = d => `${String(d.getUTCHours()).padStart(2,'0')}:${String(d.getUTCMinutes()).padStart(2,'0')}`;

  host.innerHTML = `
    <div class="bd-tl-bars">${bars}</div>
    <div class="bd-tl-axis">
      <span>${fmt(leftEdge)}</span>
      <span>↑ NOW ${fmt(now)}</span>
    </div>`;
}

function escapeHtml(s) {
  return String(s).replace(/[&<>"']/g, c => ({
    '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
  })[c]);
}

// --- Status / progress (preserved from legacy) ------------------------------

function setStatus(text, state, percent) {
  if (!el.status) return;
  el.status.textContent = text || '';
  el.status.className = '';
  el.statusIcon.className = 'status-icon';
  if (state) {
    el.status.classList.add('status-' + state);
    el.statusIcon.classList.add('status-' + state);
  } else {
    el.statusIcon.classList.add('status-idle');
  }
  if (typeof percent === 'number') {
    const pct = Math.max(0, Math.min(1, percent));
    el.progress.style.width = (pct * 100).toFixed(1) + '%';
  }
}

function setUpdating(updating) {
  isUpdating = updating;
  if (el.btnPlay)   el.btnPlay.disabled   = updating;
  if (el.btnCheck)  el.btnCheck.disabled  = updating;
  if (el.btnRepair) el.btnRepair.disabled = updating;
}

function setServerStatus(online) {
  if (!el.serverStatus) return;
  el.serverStatus.classList.remove('online', 'offline');
  // Only own the dot color + base text. The player count is owned by
  // applyLiveRealmData (realm_status.json polling) so the C++ manifest
  // check doesn't stomp the live number with a stale placeholder when
  // both fire on startup.
  if (online === true) {
    el.serverStatus.classList.add('online');
    if (!el.serverText.dataset.live) el.serverText.textContent = 'Server Online';
  } else if (online === false) {
    el.serverStatus.classList.add('offline');
    el.serverText.textContent = 'Server Offline';
    delete el.serverText.dataset.live;
  } else {
    el.serverText.textContent = 'Checking…';
    delete el.serverText.dataset.live;
  }
}

// --- Banner carousel (preserved) -------------------------------------------

let bannerList = [];
let bannerIdx = 0;
let bannerActive = 'a';
let bannerTimer = null;

// Default launcher wallpapers — crossfaded when the server hasn't pushed any
// promotional banners. Both live in the web folder next to this file.
const DEFAULT_BANNERS = ['banner.png', 'banner2.png'];

function setBanners(list) {
  bannerList = (list && list.length) ? list.slice() : DEFAULT_BANNERS.slice();
  bannerIdx = 0;
  if (bannerTimer) { clearInterval(bannerTimer); bannerTimer = null; }
  if (!bannerList.length) return;

  el.heroA.style.backgroundImage = `url("${bannerList[0]}")`;
  el.heroA.style.opacity = 1;
  el.heroB.style.opacity = 0;
  bannerActive = 'a';

  if (bannerList.length > 1) {
    bannerTimer = setInterval(nextBanner, 5000);
  }
}

function nextBanner() {
  if (bannerList.length < 2) return;
  bannerIdx = (bannerIdx + 1) % bannerList.length;
  const url = `url("${bannerList[bannerIdx]}")`;
  if (bannerActive === 'a') {
    el.heroB.style.backgroundImage = url;
    el.heroB.style.opacity = 1;
    el.heroA.style.opacity = 0;
    bannerActive = 'b';
  } else {
    el.heroA.style.backgroundImage = url;
    el.heroA.style.opacity = 1;
    el.heroB.style.opacity = 0;
    bannerActive = 'a';
  }
}

// --- Rotating hero tips -----------------------------------------------------

const ART_TIPS = [
  { title: 'Movement',   body: 'Press G to greet a nearby player and gain reputation.' },
  { title: 'Ascension',  body: 'Reach level 475 to Ascend into one of 9 branches and earn a Soul Crystal.' },
  { title: 'Elite Gear', body: 'Elite armor grants bonus stats — check the item tooltip and your Character (F5) panel.' },
  { title: 'Magic',      body: 'Magic Hit Ratio now scales with MAG — raise MAG to land more spells.' },
  { title: 'Events',     body: 'Join Team vs Team and DeathMatch events to rack up Enemy Kills.' },
  { title: 'Rings',      body: 'Enhancer rings boost your damage — match the ring tier to your build.' },
  { title: 'Safe Zones', body: 'Aresden and Elvine cities are safe zones — no PvP inside the walls.' },
];

let artTipIdx = 0;
let artTipTimer = null;

function showArtTip(i) {
  const box = $('#art-tips');
  const t = $('#art-tip-title');
  const b = $('#art-tip-body');
  if (!box || !t || !b || !ART_TIPS.length) return;
  const tip = ART_TIPS[i % ART_TIPS.length];
  box.style.opacity = 0;
  setTimeout(() => {
    t.textContent = tip.title;
    b.textContent = tip.body;
    box.style.opacity = 1;
  }, 600); // matches the .art-tips CSS fade
}

function startArtTips() {
  if (!ART_TIPS.length) return;
  artTipIdx = 0;
  showArtTip(artTipIdx);
  if (artTipTimer) clearInterval(artTipTimer);
  artTipTimer = setInterval(() => {
    artTipIdx = (artTipIdx + 1) % ART_TIPS.length;
    showArtTip(artTipIdx);
  }, 7000); // ~7s per tip
}

// --- Settings (preserved) ---------------------------------------------------

function applySettingsToUI(s) {
  currentSettings = Object.assign({}, s);
  for (const key in SETTING_IDS) {
    const chk = document.getElementById(SETTING_IDS[key]);
    if (chk) chk.checked = !!s[key];
  }
  const mode = s.mode || (s.fullscreen ? 'fullscreen' : (s.borderless ? 'borderless' : 'windowed'));
  const radio = document.querySelector(`input[name="displayMode"][value="${mode}"]`);
  if (radio) radio.checked = true;
}

function collectSettingsFromUI() {
  const s = Object.assign({}, currentSettings);
  for (const key in SETTING_IDS) {
    const chk = document.getElementById(SETTING_IDS[key]);
    if (chk) s[key] = chk.checked;
  }
  const modeEl = document.querySelector('input[name="displayMode"]:checked');
  s.mode = modeEl ? modeEl.value : 'windowed';
  return s;
}

function saveSettings() {
  const s = collectSettingsFromUI();
  currentSettings = s;
  bridge.send('saveSettings', { data: s });
}

function openSettings() {
  if (el.settingsOverlay) el.settingsOverlay.classList.remove('hidden');
}

function closeSettings() {
  if (el.settingsOverlay) el.settingsOverlay.classList.add('hidden');
}

// --- Event wiring -----------------------------------------------------------

// Launch security-check modal — shown while the Play button re-verifies files
// before entering the game. Its progress bar mirrors the host's 'progress' events.
let launchCheckActive = false;
function showLaunchCheck() {
  const fill = document.getElementById('launch-check-fill');
  if (fill) fill.style.width = '0%';
  const ov = document.getElementById('launch-check-overlay');
  if (ov) ov.classList.remove('hidden');
  launchCheckActive = true;
}
function hideLaunchCheck() {
  const ov = document.getElementById('launch-check-overlay');
  if (ov) ov.classList.add('hidden');
  launchCheckActive = false;
}
function setLaunchCheckProgress(pct) {
  const fill = document.getElementById('launch-check-fill');
  if (!fill) return;
  const p = Math.max(0, Math.min(1, pct || 0));
  fill.style.width = (p * 100).toFixed(1) + '%';
}

if (el.btnPlay) {
  el.btnPlay.addEventListener('click', () => {
    if (isUpdating) return;
    saveSettings();
    showLaunchCheck();
    bridge.send('launch');
  });
}

// 2026-06-29: Beta Mode flow.
// Default UI: PLAY SACRIFICE (prod) + small "Login" button. PLAY BETA hidden.
// After successful login → small button becomes "Logout" and PLAY BETA appears
// right next to PLAY SACRIFICE (same style). Click PLAY BETA → game with
// --server=<beta IP>. Click PLAY SACRIFICE → game on prod (always).
let betaLoggedInUser = null;
let betaLoginTimeoutId = null;

function setBetaLoggedIn(loggedIn, username) {
  betaLoggedInUser = loggedIn ? username : null;
  const loginLabel  = document.getElementById('btn-beta-login-label');
  const btnPlayBeta = document.getElementById('btn-play-beta');
  if (loginLabel)  loginLabel.textContent = loggedIn ? 'Logout' : 'Login';
  if (btnPlayBeta) btnPlayBeta.style.display = loggedIn ? '' : 'none';
}

function handleBetaLoginResult(m) {
  if (betaLoginTimeoutId) { clearTimeout(betaLoginTimeoutId); betaLoginTimeoutId = null; }
  const submitBtn = document.getElementById('beta-login-submit');
  const userInput = document.getElementById('beta-login-user');
  const pwInput   = document.getElementById('beta-login-pw');
  const errorBox  = document.getElementById('beta-login-error');
  const overlay   = document.getElementById('beta-login-overlay');
  if (submitBtn) { submitBtn.disabled = false; submitBtn.textContent = 'Login'; }
  if (m && m.ok) {
    setBetaLoggedIn(true, (userInput?.value || '').trim());
    if (overlay)  overlay.style.display = 'none';
    if (pwInput)  pwInput.value = '';
    if (errorBox) errorBox.style.display = 'none';
  } else if (errorBox) {
    errorBox.textContent = (m && m.reason) || 'Login failed';
    errorBox.style.display = '';
  }
}

(function setupBetaLogin() {
  const btnLogin    = document.getElementById('btn-beta-login');
  const overlay     = document.getElementById('beta-login-overlay');
  const closeBtn    = document.getElementById('beta-login-close');
  const submitBtn   = document.getElementById('beta-login-submit');
  const userInput   = document.getElementById('beta-login-user');
  const pwInput     = document.getElementById('beta-login-pw');
  const errorBox    = document.getElementById('beta-login-error');
  const btnPlayBeta = document.getElementById('btn-play-beta');
  if (!btnLogin || !overlay) return;

  function openModal() {
    if (errorBox) errorBox.style.display = 'none';
    overlay.style.display = '';
    if (userInput) setTimeout(() => userInput.focus(), 50);
  }
  function closeModal() {
    overlay.style.display = 'none';
    if (pwInput)  pwInput.value = '';
    if (errorBox) errorBox.style.display = 'none';
    if (submitBtn) { submitBtn.disabled = false; submitBtn.textContent = 'Login'; }
  }

  btnLogin.addEventListener('click', () => {
    if (betaLoggedInUser) {
      setBetaLoggedIn(false, null);
      bridge.send('clearBetaCreds');           // tell C++ to delete cache/beta_creds.json
    } else {
      openModal();
    }
  });
  if (closeBtn) closeBtn.addEventListener('click', closeModal);
  overlay.addEventListener('click', (e) => { if (e.target === overlay) closeModal(); });

  function submitLogin() {
    const u = (userInput?.value || '').trim();
    const p = pwInput?.value || '';
    if (!u || !p) {
      if (errorBox) { errorBox.textContent = 'Username and password required'; errorBox.style.display = ''; }
      return;
    }
    if (errorBox) errorBox.style.display = 'none';
    if (submitBtn) { submitBtn.disabled = true; submitBtn.textContent = 'Connecting...'; }
    const rememberChk = document.getElementById('beta-login-remember');
    const remember = !!(rememberChk && rememberChk.checked);
    bridge.send('loginBeta', { username: u, password: p, remember });
    // Surface a clear error if C++ never responds (stale binary, network hang).
    if (betaLoginTimeoutId) clearTimeout(betaLoginTimeoutId);
    betaLoginTimeoutId = setTimeout(() => {
      handleBetaLoginResult({ ok: false, reason: 'No response from launcher binary. Close + reopen the Launcher (auto-update may be pending).' });
    }, 8000);
  }
  if (submitBtn) submitBtn.addEventListener('click', submitLogin);
  [userInput, pwInput].forEach(inp => {
    if (inp) inp.addEventListener('keydown', (e) => { if (e.key === 'Enter') submitLogin(); });
  });

  if (btnPlayBeta) {
    btnPlayBeta.addEventListener('click', () => {
      if (isUpdating) return;
      if (!betaLoggedInUser) return;
      saveSettings();
      showLaunchCheck();
      bridge.send('launch', { betaMode: true });
    });
  }
})();

if (el.btnCheck) {
  el.btnCheck.addEventListener('click', () => {
    if (isUpdating) return;
    bridge.send('checkUpdates');
  });
}

if (el.btnRepair) {
  el.btnRepair.addEventListener('click', () => {
    if (isUpdating) return;
    if (confirm('Re-verify and re-download all game files? This may take a while.')) {
      bridge.send('repair');
    }
  });
}

if (el.btnSettings)  el.btnSettings.addEventListener('click', openSettings);
if (el.settingsClose) el.settingsClose.addEventListener('click', closeSettings);
if (el.settingsOverlay) {
  el.settingsOverlay.addEventListener('click', (e) => {
    if (e.target === el.settingsOverlay) closeSettings();
  });
}

// --- News & Patch Notes: click a card → open the full detail modal ----------
const newsOverlay = document.getElementById('news-overlay');
function openNewsDetail(card) {
  if (!newsOverlay || !card) return;
  const date = card.getAttribute('data-date') || '';
  const title = card.getAttribute('data-title') || '';
  const full = card.querySelector('.nc-full');
  const ndDate = document.getElementById('nd-date');
  const ndTitle = document.getElementById('nd-title');
  const ndBody = document.getElementById('nd-body');
  if (ndDate)  ndDate.textContent = date;
  if (ndTitle) ndTitle.innerHTML = title;          // titles may contain &amp;
  if (ndBody)  ndBody.innerHTML = full ? full.innerHTML : '';
  newsOverlay.classList.remove('hidden');
}
function closeNewsDetail() { if (newsOverlay) newsOverlay.classList.add('hidden'); }
const newsFeedEl = document.getElementById('news-feed');
if (newsFeedEl) {
  newsFeedEl.addEventListener('click', (e) => {
    const card = e.target.closest('.news-card');
    if (card) openNewsDetail(card);
  });
}
const newsClose = document.getElementById('news-close');
if (newsClose) newsClose.addEventListener('click', closeNewsDetail);
if (newsOverlay) {
  newsOverlay.addEventListener('click', (e) => { if (e.target === newsOverlay) closeNewsDetail(); });
}

if (el.linkDiscord) el.linkDiscord.addEventListener('click', () => bridge.send('openURL', { url: 'https://discord.gg/helbreathsacrifice' }));
if (el.linkWeb)     el.linkWeb.addEventListener('click',     () => bridge.send('openURL', { url: 'https://www.helbreathsacrifice.com' }));

// Custom titlebar window controls — the borderless WS_POPUP window has no
// native min/close buttons, so the bridge handles these in launcher_main.cpp.
const elBtnMin = document.getElementById('btn-min');
const elBtnClose = document.getElementById('btn-close');
if (elBtnMin)   elBtnMin.addEventListener('click',   () => bridge.send('minimize'));
if (elBtnClose) elBtnClose.addEventListener('click', () => bridge.send('close'));

// Drag the borderless window when the user clicks on empty titlebar space.
// WebView2 captures mouse events so the host's WM_NCHITTEST can't see them;
// we forward via the bridge which calls SendMessage(WM_NCLBUTTONDOWN, HTCAPTION).
const elTitlebar = document.getElementById('titlebar');
if (elTitlebar) {
  elTitlebar.addEventListener('mousedown', (e) => {
    if (e.button !== 0) return;
    // Skip clicks on interactive children (buttons, inputs, slider, pills).
    if (e.target.closest('button, input, a, #server-pill, #music-ctrl')) return;
    bridge.send('dragWindow');
  });
}

document.querySelectorAll('.side-tabs .t').forEach(tab => {
  tab.addEventListener('click', () => {
    document.querySelectorAll('.side-tabs .t').forEach(t => t.classList.remove('active'));
    tab.classList.add('active');
  });
});

document.addEventListener('click', (e) => {
  // Sidebar engagement card → open the full battle-detail sheet.
  const eng = e.target.closest('#engagements .engagement');
  if (eng && eng.dataset.regionId) {
    openBattleDetail(eng.dataset.regionId);
    return;
  }
  // Main region card click → same detail sheet, so ENTER BATTLE works as
  // fast-travel even when no live engagement is in the sidebar yet.
  const r = e.target.closest('.region');
  if (r && r.dataset.regionId) {
    openBattleDetail(r.dataset.regionId);
    return;
  }
});

// Battle detail action wiring
const bdBack = document.getElementById('bd-back');
if (bdBack) bdBack.addEventListener('click', closeBattleDetail);

// ENTER BATTLE — launches the game with `--enter-map=<server map name>`. The
// region's `match[0]` is the canonical server-side name (e.g. Promiseland → 2ndmiddle).
const bdEnter = document.getElementById('bd-btn-enter');
if (bdEnter) bdEnter.addEventListener('click', () => {
  if (isUpdating) return;
  if (!currentBattleRegion) return;
  const mapName = (currentBattleRegion.match || [])[0] || currentBattleRegion.id;
  bridge.send('launchWithMap', { map: mapName });
});

// WATCH SPECTATOR — same flow as ENTER BATTLE but the launcher passes
// `--spectate-map=<name>` instead so the client requests observer mode.
const bdWatch = document.getElementById('bd-btn-watch');
if (bdWatch) bdWatch.addEventListener('click', () => {
  if (isUpdating) return;
  if (!currentBattleRegion) return;
  const mapName = (currentBattleRegion.match || [])[0] || currentBattleRegion.id;
  bridge.send('watchSpectator', { map: mapName });
});

document.addEventListener('change', (e) => {
  if (e.target.matches('input[type="checkbox"], input[type="radio"]')) {
    saveSettings();
  }
});

document.addEventListener('keydown', (e) => {
  if (e.key === 'Escape') {
    if (newsOverlay && !newsOverlay.classList.contains('hidden')) closeNewsDetail();
    else if (currentBattleRegion) closeBattleDetail();
    else                     closeSettings();
  }
});

// --- C++ → JS event receiver (preserved) -----------------------------------

function onHostMessage(e) {
  let msg = e.data;
  if (typeof msg === 'string') {
    try { msg = JSON.parse(msg); } catch (_) { return; }
  }
  if (!msg || !msg.event) return;

  switch (msg.event) {
    case 'progress':
      setStatus(msg.status, msg.state, msg.percent);
      if (launchCheckActive) setLaunchCheckProgress(msg.percent);
      break;
    case 'settings':
      applySettingsToUI(msg.data || {});
      break;
    case 'version':
      el.version.textContent = 'v' + (msg.value || '1.0.0');
      break;
    case 'updating':
      setUpdating(!!msg.isUpdating);
      break;
    case 'serverStatus':
      setServerStatus(msg.online);
      break;
    case 'betaLoginResult':
      // 2026-06-29: handled in setupBetaLogin (Beta Mode toggle reveal).
      handleBetaLoginResult(msg);
      break;
    case 'betaSavedCreds':
      // 2026-06-30: C++ leyó cache/beta_creds.json al arrancar. Si hay creds
      // guardadas, hacemos auto-login silencioso (sin abrir modal) para que el
      // boton PLAY BETA aparezca solo. Si el login falla por whitelist expirada
      // o lo que sea, se ignora y el usuario hace el flow normal.
      if (msg.username && msg.password) {
        bridge.send('loginBeta', { username: msg.username, password: msg.password, remember: true });
      }
      break;
    case 'banners':
      setBanners(msg.list || []);
      break;
    case 'realmStatus':
      applyLiveRealmData(msg.data || {});
      break;
    case 'updateComplete':
      if (msg.restartNeeded) {
        setStatus('Update complete. Restart required.', 'success', 1);
      } else if (msg.downloaded > 0) {
        setStatus(`Update complete. ${msg.downloaded} file(s) updated.`, 'success', 1);
      } else {
        setStatus('All files up to date.', 'success', 1);
      }
      // El check final del Play termino: llenar la barra y cerrar el modal (el juego lanza).
      if (launchCheckActive) { setLaunchCheckProgress(1); setTimeout(hideLaunchCheck, 350); }
      break;
    case 'restartPrompt': {
      if (launchCheckActive) hideLaunchCheck();
      const overlay = document.getElementById('restart-overlay');
      if (overlay) overlay.classList.remove('hidden');
      break;
    }
  }
}

// Restart modal — Accept button kicks off the spawn+quit on the host side.
const elRestartOk = document.getElementById('restart-ok');
if (elRestartOk) elRestartOk.addEventListener('click', () => bridge.send('restartNow'));

if (window.chrome && window.chrome.webview) {
  window.chrome.webview.addEventListener('message', onHostMessage);
}

// --- Music control ---------------------------------------------------------
// Plays the loading-screen video's audio track in the launcher. Volume + mute
// state persist across runs via localStorage. The widget hides itself if the
// audio asset can't be loaded (e.g. fresh install before the launcher pulls
// the game files).

function initMusicControl() {
  const audio   = document.getElementById('bg-music');
  const ctrl    = document.getElementById('music-ctrl');
  const btn     = document.getElementById('music-toggle');
  const slider  = document.getElementById('music-vol');
  const iconOn  = document.getElementById('music-icon-on');
  const iconOff = document.getElementById('music-icon-off');
  if (!audio || !ctrl) return;

  const KEY_VOL   = 'hb_launcher_volume';
  const KEY_MUTED = 'hb_launcher_muted';

  let savedVol = parseInt(localStorage.getItem(KEY_VOL) ?? '30', 10);
  if (!Number.isFinite(savedVol)) savedVol = 30;
  savedVol = Math.max(0, Math.min(100, savedVol));
  let muted = localStorage.getItem(KEY_MUTED) === '1';

  const applyState = () => {
    audio.muted  = muted;
    audio.volume = savedVol / 100;
    slider.value = muted ? 0 : savedVol;
    iconOn.style.display  = muted ? 'none'  : 'block';
    iconOff.style.display = muted ? 'block' : 'none';
    btn.classList.toggle('muted', muted);
  };

  // Hide widget if the audio asset can't be loaded.
  audio.addEventListener('error', () => { ctrl.classList.add('hidden'); });

  // Try to start playback as soon as enough data is buffered. The launcher
  // passes --autoplay-policy=no-user-gesture-required so this should succeed
  // on its own; the click fallback is only here for older WebView2 runtimes.
  const tryPlay = () => {
    if (muted) return;
    const p = audio.play();
    if (p && typeof p.catch === 'function') p.catch(() => {});
  };

  audio.addEventListener('canplay', tryPlay, { once: true });
  // First click/keydown anywhere triggers playback if autoplay was blocked.
  const kickstart = () => {
    tryPlay();
    document.removeEventListener('click',   kickstart);
    document.removeEventListener('keydown', kickstart);
  };
  document.addEventListener('click',   kickstart);
  document.addEventListener('keydown', kickstart);

  applyState();
  audio.load();

  btn.addEventListener('click', () => {
    muted = !muted;
    localStorage.setItem(KEY_MUTED, muted ? '1' : '0');
    if (!muted) {
      if (savedVol === 0) { savedVol = 30; localStorage.setItem(KEY_VOL, '30'); }
      tryPlay();
    }
    applyState();
  });

  slider.addEventListener('input', () => {
    const v = parseInt(slider.value, 10) || 0;
    savedVol = v;
    localStorage.setItem(KEY_VOL, String(v));

    // Auto-mute when slider reaches 0; auto-unmute when leaving 0.
    const shouldMute = (v === 0);
    if (shouldMute !== muted) {
      muted = shouldMute;
      localStorage.setItem(KEY_MUTED, muted ? '1' : '0');
      if (!muted) tryPlay();
    }
    applyState();
  });
}

// --- Init -------------------------------------------------------------------

window.addEventListener('DOMContentLoaded', () => {
  renderAll();
  initMusicControl();
  // Start the default wallpaper crossfade immediately. If the server later
  // pushes promotional banners, the 'banners' message replaces this set.
  setBanners([]);
  startArtTips();
  bridge.send('ready');
  // The C++ host owns the realm_status.json polling (see
  // start_realm_status_polling in launcher_main.cpp). We just receive
  // 'realmStatus' events and apply them via applyLiveRealmData.
});
