import { fnv1a32, hash2u32, mulberry32 } from "./rng.js";
import { formatPlanet, generatePlanet } from "./planetGeneration.js";
import { apiFetchConfig, apiFetchGrid, apiRevealCell, apiFetchEconScores, apiTickEconScores, apiFetchElementBonus, apiTickElementBonus, apiFetchDbInfo, apiFetchVictoryPoints, apiFetchActivePlayers, apiFetchActivePlayerNames, apiFetchMilitaryDeductions, apiMilitaryAttack, apiCaptureCell } from "./api.js";
import { computeTeamIncome, computeTeamElementBonus, computeTeamElementBonusDetailed, renderResourceTable, renderElementBonusTable, setEconSort, getEconSort, setElemSort, getElemSort, computeTeamMilitaryDetailed, renderMilitaryTable, setMilSort, getMilSort } from "./economy.js";

// ── Constants ─────────────────────────────────────────────────────────────────

const GRID_W = 100;
const GRID_H = 100;
const PLANET_CHANCE = 0.1;

// SVG playfield dimensions (must match /graphism/playground.svg viewBox)
const SVG_W = 1000;
const SVG_H = 1000;

const COLOR_RING_IDLE       = "rgba(113,199,255,0.08)";
const COLOR_BLUE_DISCOVERED = "rgba(90, 200, 255, 0.38)";
const COLOR_RED_DISCOVERED  = "rgba(220, 75, 85, 0.42)";
const COLOR_OPPONENT_GREY   = "rgba(95, 98, 110, 0.72)";

// ── Shared game state ─────────────────────────────────────────────────────────

export const GAME_CONFIG = {
  dailyActionQuota: 100,
  databaseWipeoutIntervalSeconds: 21600,
  configReloadIntervalSeconds: 30,
  worldSeed: "",
  seedPeriodEndsAtUtc: "",
  elementWorth: {},
  resourceWorth: { common: {}, rare: {} },
  militaryPower: {},
  teamActionsRemaining: null,
};
window.GAME_CONFIG = GAME_CONFIG;

/** @type {Map<string, { controlledBy: string|null, hasPlanet: boolean, planet: object|null }>} */
export const cells = new Map();

export let currentTeam = "blue";
export function setCurrentTeam(t) { currentTeam = t; }

export let seedStr = "";
export let seedU32 = 0;
export function applySeed(str) {
  seedStr = str;
  seedU32 = fnv1a32(str || "fallback");
}

/** Remaining actions for the current user in today's quota window. */
export let actionsRemaining = 0;
/** Remaining actions for the current team in today's team quota window. */
export let teamActionsRemaining = null;
let lastPointerEvent = null;

// ── Tile fade-in animation ────────────────────────────────────────────────────

/** key → timestamp (ms) when the tile was first revealed */
const tileRevealTime = new Map();
const TILE_FADE_MS = 500;
let fadeRafId = 0;

function markTileReveal(key) {
  tileRevealTime.set(key, Date.now());
  if (!fadeRafId) scheduleFadeFrame();
}

function scheduleFadeFrame() {
  fadeRafId = requestAnimationFrame(() => {
    fadeRafId = 0;
    draw();
    // Keep looping while any tile is still fading
    const now = Date.now();
    const stillFading = [...tileRevealTime.values()].some(t => now - t < TILE_FADE_MS);
    if (stillFading) scheduleFadeFrame();
  });
}

/** Returns the fill opacity multiplier for a tile (0–1). */
function tileAlpha(key) {
  const t = tileRevealTime.get(key);
  if (t === undefined) return 1;
  const progress = (Date.now() - t) / TILE_FADE_MS;
  if (progress >= 1) { tileRevealTime.delete(key); return 1; }
  return progress;
}

// ── Zoom / Pan state ──────────────────────────────────────────────────────────

const MIN_ZOOM = 1;
const MAX_ZOOM = 12;
const ZOOM_SPEED = 0.001;          // wheel delta multiplier

/** Current zoom level (1 = whole map fits in canvas). */
let zoom = 1;
/** Top-left offset in *map-space* coordinates (0..1000). */
let panX = 0;
let panY = 0;

/** Clamp panX/panY so the viewport stays inside the map. */
function clampPan() {
  const vw = SVG_W / zoom;
  const vh = SVG_H / zoom;
  panX = Math.max(0, Math.min(panX, SVG_W - vw));
  panY = Math.max(0, Math.min(panY, SVG_H - vh));
}

export function resetZoom() { zoom = 1; panX = 0; panY = 0; }

// ── Playfield mask ────────────────────────────────────────────────────────────

/** Set of "x,y" keys for exploitable tiles (populated by loadPlayfieldMask). */
let exploitableTiles = null;

/** Preloaded SVG image for background rendering. */
let playfieldImg = null;

/**
 * Loads /graphism/playground.svg, rasterises it to an offscreen canvas,
 * then classifies each 100×100 grid tile as exploitable if ≥80 % of its
 * sampled pixels are the black playable zone (R≤20, G≤20, B≤20).
 */
export function loadPlayfieldMask() {
  return new Promise((resolve) => {
    const img = new Image();
    img.onload = () => {
      playfieldImg = img;

      // ── Rasterise SVG at native size onto an offscreen canvas ──
      const oc   = document.createElement("canvas");
      oc.width   = SVG_W;
      oc.height  = SVG_H;
      const octx = oc.getContext("2d");

      // White fill so transparent SVG areas appear white (not black).
      octx.fillStyle = "#ffffff";
      octx.fillRect(0, 0, SVG_W, SVG_H);
      octx.drawImage(img, 0, 0, SVG_W, SVG_H);

      let imageData;
      try {
        imageData = octx.getImageData(0, 0, SVG_W, SVG_H);
      } catch (e) {
        console.warn("[playfield] canvas tainted – no exploitable tiles:", e);
        exploitableTiles = new Set();
        resolve();
        return;
      }

      const data = imageData.data; // RGBA flat array

      const SAMPLES_X = 10;
      const SAMPLES_Y = 10;
      const cellW     = SVG_W / GRID_W; // 7.5 px
      const cellH     = SVG_H / GRID_H; // 10 px
      const tiles     = new Set();

      for (let gy = 0; gy < GRID_H; gy++) {
        for (let gx = 0; gx < GRID_W; gx++) {
          let blackCount = 0;

          for (let sy = 0; sy < SAMPLES_Y; sy++) {
            for (let sx = 0; sx < SAMPLES_X; sx++) {
              const px = Math.min(
                SVG_W - 1,
                Math.floor(gx * cellW + (sx + 0.5) * cellW / SAMPLES_X)
              );
              const py = Math.min(
                SVG_H - 1,
                Math.floor(gy * cellH + (sy + 0.5) * cellH / SAMPLES_Y)
              );
              const idx = (py * SVG_W + px) * 4;
              const r = data[idx], g = data[idx + 1], b = data[idx + 2];
              // Pure black = playable zone (path2, fill:#000000)
              if (r <= 20 && g <= 20 && b <= 20) blackCount++;
            }
          }

          const coverage = blackCount / (SAMPLES_X * SAMPLES_Y);
          if (coverage >= 0.8) tiles.add(cellKey(gx, gy));
        }
      }

      exploitableTiles = tiles;
      console.log(`[playfield] ${tiles.size} exploitable tiles loaded.`);
      resolve();
    };
    img.onerror = () => {
      console.warn("[playfield] failed to load playground.svg");
      exploitableTiles = new Set();
      resolve();
    };
    img.src = "/graphism/playground.svg";
  });
}

// ── DOM refs ──────────────────────────────────────────────────────────────────

const canvas        = document.getElementById("canvas");
const ctx           = canvas.getContext("2d", { alpha: false });
const details       = document.getElementById("details");
const hint          = document.getElementById("hint");
const countdownEl   = document.getElementById("countdown");
const countdownWrap = document.getElementById("countdownWrap");
const cooldownCfgEl = document.getElementById("cooldownConfig");
const seedDisplayEl = document.getElementById("worldSeedDisplay");
const nextPeriodEl  = document.getElementById("nextPeriodUtc");
const resetCountEl  = document.getElementById("refreshCountdown");
const vpBlueEl           = document.getElementById("vpBlue");
const vpRedEl            = document.getElementById("vpRed");
const dbCreatedAtEl      = document.getElementById("dbCreatedAt");
const elemBonusBlueEl    = document.getElementById("elemBonusBlue");
const elemBonusRedEl     = document.getElementById("elemBonusRed");
const effectiveCooldownEl = document.getElementById("effectiveCooldown");
const incomeBlueEl     = document.getElementById("incomeBlue");
const incomeRedEl      = document.getElementById("incomeRed");
const resourceTableEl  = document.getElementById("resourceTableBody");
const econScoreBlueEl  = document.getElementById("econScoreBlue");
const econScoreRedEl   = document.getElementById("econScoreRed");
const econDeltaBlueEl  = document.getElementById("econDeltaBlue");
const econDeltaRedEl   = document.getElementById("econDeltaRed");
const elemBonusTableEl = document.getElementById("elementBonusTableBody");
const activeCountBlueEl = document.getElementById("activeCountBlue");
const activeCountRedEl  = document.getElementById("activeCountRed");
const milTableEl      = document.getElementById("militaryTableBody");
const milTotalBlueEl  = document.getElementById("milPowerBlue");
const milTotalRedEl   = document.getElementById("milPowerRed");
const attackOverlayEl  = document.getElementById("attackOverlay");
const attackModalBodyEl = document.getElementById("attackModalBody");
const attackModalYesEl  = document.getElementById("attackModalYes");
const attackModalNoEl   = document.getElementById("attackModalNo");
const captureOverlayEl  = document.getElementById("captureOverlay");
const captureModalBodyEl = document.getElementById("captureModalBody");
const captureModalYesEl  = document.getElementById("captureModalYes");
const captureModalNoEl   = document.getElementById("captureModalNo");
const teamQuotaEl      = document.getElementById("teamActionsRemaining");
const captorInfoEl     = document.getElementById("captorInfo");
const playerListPopupEl = document.getElementById("playerListPopup");
const playerListContentEl = document.getElementById("playerListContent");
const mapAnimEl        = document.getElementById("mapAnim");
// ── Cell helpers ──────────────────────────────────────────────────────────────

export function cellKey(x, y) { return `${x},${y}`; }

export function isExploitable(x, y) {
  if (!exploitableTiles) return false;
  return exploitableTiles.has(cellKey(x, y));
}

function hasPlanetAt(x, y) {
  const h = hash2u32(x, y, seedU32);
  return h / 4294967296 < PLANET_CHANCE;
}

export function cellMeta(key)        { return cells.get(key) ?? null; }
export function isOpponentTile(key)  { const m = cellMeta(key); return m !== null && m.controlledBy !== null && m.controlledBy !== currentTeam; }
export function isOwnTile(key)       { const m = cellMeta(key); return m !== null && m.controlledBy === currentTeam; }

// ── Config display ────────────────────────────────────────────────────────────

export function applyConfigPayload(data) {
  GAME_CONFIG.dailyActionQuota              = Number(data.dailyActionQuota) || 100;
  GAME_CONFIG.databaseWipeoutIntervalSeconds = Number(data.databaseWipeoutIntervalSeconds) || 21600;
  GAME_CONFIG.configReloadIntervalSeconds   = Math.max(5, Number(data.configReloadIntervalSeconds) || 30);
  GAME_CONFIG.worldSeed                     = String(data.worldSeed ?? "");
  GAME_CONFIG.seedPeriodEndsAtUtc           = String(data.seedPeriodEndsAtUtc ?? "");
  if (data.elementWorth && typeof data.elementWorth === "object") {
    GAME_CONFIG.elementWorth = data.elementWorth;
  }
  if (data.resourceWorth && typeof data.resourceWorth === "object") {
    GAME_CONFIG.resourceWorth = data.resourceWorth;
  }
  if (data.militaryPower && typeof data.militaryPower === "object") {
    GAME_CONFIG.militaryPower = data.militaryPower;
  }
  if (data.actionsRemaining !== null && data.actionsRemaining !== undefined) {
    actionsRemaining = Number(data.actionsRemaining);
    updateActionsDisplay();
  }
  if (data.teamActionsRemaining !== null && data.teamActionsRemaining !== undefined) {
    teamActionsRemaining = Number(data.teamActionsRemaining);
    updateTeamQuotaDisplay();
  }

  cooldownCfgEl.textContent = String(GAME_CONFIG.dailyActionQuota);
  seedDisplayEl.textContent = GAME_CONFIG.worldSeed || "—";
  nextPeriodEl.textContent  = GAME_CONFIG.seedPeriodEndsAtUtc
    ? new Date(GAME_CONFIG.seedPeriodEndsAtUtc).toISOString().replace("T", " ").slice(0, 19) + "Z"
    : "—";

  updateResetCountdown();

}

export function updateResetCountdown() {
  if (!GAME_CONFIG.seedPeriodEndsAtUtc) { resetCountEl.textContent = "--d --:--:--"; return; }
  const diff = new Date(GAME_CONFIG.seedPeriodEndsAtUtc).getTime() - Date.now();
  if (diff <= 0) { resetCountEl.textContent = "00d 00:00:00"; return; }
  const s = Math.floor(diff / 1000);
  const days = Math.floor(s / 86400);
  const hh   = Math.floor((s % 86400) / 3600);
  const mm   = Math.floor((s % 3600) / 60);
  const ss   = s % 60;
  resetCountEl.textContent =
    String(days).padStart(2, "0") + "d " +
    String(hh).padStart(2, "0") + ":" +
    String(mm).padStart(2, "0") + ":" +
    String(ss).padStart(2, "0");
}


// ── Scores ────────────────────────────────────────────────────────────────────

export async function loadVictoryPoints() {
  try {
    const { blue, red } = await apiFetchVictoryPoints();
    setVictoryPointsDisplay(blue ?? 0, red ?? 0);
  } catch { /* ignore */ }
}

export async function fetchAndApplyActivePlayers() {
  try {
    const { blue, red } = await apiFetchActivePlayers();
    setActivePlayersDisplay(blue ?? 0, red ?? 0);
  } catch { /* ignore */ }
}

function setVictoryPointsDisplay(blue, red) {
  if (vpBlueEl) vpBlueEl.textContent = String(blue ?? 0);
  if (vpRedEl)  vpRedEl.textContent  = String(red ?? 0);
}

function setActivePlayersDisplay(blue, red) {
  const fmt = (n) => `${n} joueur${n > 1 ? "s" : ""}`;
  if (activeCountBlueEl) activeCountBlueEl.textContent = fmt(blue ?? 0);
  if (activeCountRedEl)  activeCountRedEl.textContent  = fmt(red  ?? 0);
}

// ── Player list popup (click on joueur count) ─────────────────────────────────

function closePlayerListPopup() {
  if (playerListPopupEl) playerListPopupEl.classList.add("hidden");
}

async function openPlayerListPopup(anchorEl, team) {
  if (!playerListPopupEl || !playerListContentEl) return;
  try {
    const names = await apiFetchActivePlayerNames();
    const list = names[team] ?? [];
    if (!list.length) {
      playerListContentEl.innerHTML = `<span class="playerListEmpty">Aucun joueur actif</span>`;
    } else {
      const teamClass = `playerListName--${team}`;
      playerListContentEl.innerHTML =
        list.map(u => `<div class="${escHtml(teamClass)}">${escHtml(u)}</div>`).join("");
    }
    // Position popup below the anchor
    const rect = anchorEl.getBoundingClientRect();
    const parentRect = anchorEl.closest(".infoColumn, aside")?.getBoundingClientRect() ?? { left: 0, top: 0 };
    playerListPopupEl.style.left = `${rect.left - parentRect.left}px`;
    playerListPopupEl.style.top  = `${rect.bottom - parentRect.top + 4}px`;
    playerListPopupEl.classList.remove("hidden");
    playerListPopupEl.dataset.team = team;
  } catch { /* ignore */ }
}

if (activeCountBlueEl) {
  activeCountBlueEl.style.cursor = "pointer";
  activeCountBlueEl.addEventListener("click", (ev) => {
    ev.stopPropagation();
    if (playerListPopupEl && !playerListPopupEl.classList.contains("hidden") && playerListPopupEl.dataset.team === "blue") {
      closePlayerListPopup();
    } else {
      openPlayerListPopup(activeCountBlueEl, "blue");
    }
  });
}

if (activeCountRedEl) {
  activeCountRedEl.style.cursor = "pointer";
  activeCountRedEl.addEventListener("click", (ev) => {
    ev.stopPropagation();
    if (playerListPopupEl && !playerListPopupEl.classList.contains("hidden") && playerListPopupEl.dataset.team === "red") {
      closePlayerListPopup();
    } else {
      openPlayerListPopup(activeCountRedEl, "red");
    }
  });
}

document.addEventListener("click", (ev) => {
  if (playerListPopupEl && !playerListPopupEl.contains(ev.target)) {
    closePlayerListPopup();
  }
});

// ── Element bonus ─────────────────────────────────────────────────────────────

let elemBonusBlue = 0;
let elemBonusRed  = 0;

// ── Military deductions (spent billions) ──────────────────────────────────────

/** Billions permanently deducted per team via military attacks. */
let milDeductBlue = 0;
let milDeductRed  = 0;

export async function loadMilitaryDeductions() {
  try {
    const { blue, red } = await apiFetchMilitaryDeductions();
    applyMilitaryDeductionsUpdate({ blue, red });
  } catch { /* ignore */ }
}

function updateEffectiveCooldownDisplay() {
  const base = GAME_CONFIG.dailyActionQuota;
  const bonus = currentTeam === "blue" ? elemBonusBlue : elemBonusRed;
  const effective = Math.floor(base * (1 + bonus / 100));
  if (elemBonusBlueEl) elemBonusBlueEl.textContent = elemBonusBlue.toFixed(2);
  if (elemBonusRedEl)  elemBonusRedEl.textContent  = elemBonusRed.toFixed(2);
  if (effectiveCooldownEl) {
    effectiveCooldownEl.textContent = String(effective);
  }
}

export async function loadElementBonus() {
  try {
    const { blue, red } = await apiFetchElementBonus();
    elemBonusBlue = blue ?? 0;
    elemBonusRed  = red  ?? 0;
    updateEffectiveCooldownDisplay();
  } catch { /* ignore */ }
}

export async function tickElementBonus() {
  const worth = GAME_CONFIG.elementWorth;
  const blueBonus = computeTeamElementBonus("blue", cells, worth);
  const redBonus  = computeTeamElementBonus("red",  cells, worth);
  try {
    const result = await apiTickElementBonus(seedStr, blueBonus, redBonus);
    elemBonusBlue = result.blue ?? blueBonus;
    elemBonusRed  = result.red  ?? redBonus;
  } catch {
    elemBonusBlue = blueBonus;
    elemBonusRed  = redBonus;
  }
  updateEffectiveCooldownDisplay();
}

export function getEffectiveQuota() {
  const base  = GAME_CONFIG.dailyActionQuota;
  const bonus = currentTeam === "blue" ? elemBonusBlue : elemBonusRed;
  return Math.floor(base * (1 + bonus / 100));
}

export async function loadDbInfo() {
  try {
    const { createdAt } = await apiFetchDbInfo();
    if (dbCreatedAtEl && createdAt) {
      const d = new Date(createdAt);
      dbCreatedAtEl.textContent =
        d.toLocaleDateString("fr-FR", { day: "2-digit", month: "2-digit", year: "numeric" }) +
        " " +
        d.toLocaleTimeString("fr-FR", { hour: "2-digit", minute: "2-digit" });
    }
  } catch { /* ignore */ }
}

// ── Economy display ───────────────────────────────────────────────────────────

/** Recalculates and renders team income and resource table. */
export function updateEconomyDisplay() {
  const worth = GAME_CONFIG.resourceWorth;

  const blueIncome = computeTeamIncome("blue", cells, worth);
  const redIncome  = computeTeamIncome("red",  cells, worth);

  if (incomeBlueEl) incomeBlueEl.textContent = `+${blueIncome.total.toFixed(3)}/s`;
  if (incomeRedEl)  incomeRedEl.textContent  = `+${redIncome.total.toFixed(3)}/s`;

  // Resource table shows own-team breakdown
  const teamIncome = currentTeam === "blue" ? blueIncome : redIncome;
  if (resourceTableEl) {
    resourceTableEl.innerHTML = renderResourceTable(worth, teamIncome.byResource);
  }

  const elemWorth = GAME_CONFIG.elementWorth;
  const teamElemBonus = currentTeam === "blue"
    ? computeTeamElementBonusDetailed("blue", cells, elemWorth)
    : computeTeamElementBonusDetailed("red",  cells, elemWorth);
  if (elemBonusTableEl) {
    elemBonusTableEl.innerHTML = renderElementBonusTable(elemWorth, teamElemBonus.byElement);
  }

  // ── Military power ──────────────────────────────────────────────────────────
  const milPower = GAME_CONFIG.militaryPower;
  const blueMil = computeTeamMilitaryDetailed("blue", cells, milPower);
  const redMil  = computeTeamMilitaryDetailed("red",  cells, milPower);
  const blueMilNet = blueMil.total - milDeductBlue;
  const redMilNet  = redMil.total  - milDeductRed;
  if (milTotalBlueEl) milTotalBlueEl.textContent = (blueMilNet * 1000).toFixed(1);
  if (milTotalRedEl)  milTotalRedEl.textContent  = (redMilNet  * 1000).toFixed(1);
  const teamMil = currentTeam === "blue" ? blueMil : redMil;
  if (milTableEl) {
    milTableEl.innerHTML = renderMilitaryTable(milPower, teamMil.byType);
  }
}

// ── Economic score ────────────────────────────────────────────────────────────

let econScoreBlue = 0;
let econScoreRed  = 0;

export function applyMilitaryDeductionsUpdate(deductions) {
  if (!deductions || typeof deductions !== "object") return;
  if (deductions.blue !== undefined && deductions.blue !== null) {
    milDeductBlue = Number(deductions.blue);
  }
  if (deductions.red !== undefined && deductions.red !== null) {
    milDeductRed = Number(deductions.red);
  }
  updateEconomyDisplay();
}

export function applyTeamQuotaUpdate(team, actionsRemainingValue) {
  if (team !== currentTeam) return;
  if (actionsRemainingValue === null || actionsRemainingValue === undefined) return;
  teamActionsRemaining = Number(actionsRemainingValue);
  updateTeamQuotaDisplay();
}

export function applyCellUpdate(cell) {
  if (!cell || typeof cell !== "object") return;
  const x = Number(cell.x);
  const y = Number(cell.y);
  if (!Number.isInteger(x) || !Number.isInteger(y)) return;

  const key = cellKey(x, y);
  const hadCell = cells.has(key);
  cells.set(key, {
    controlledBy: cell.discoveredBy ?? cell.discovered_by ?? null,
    hasPlanet: Boolean(cell.hasPlanet ?? cell.has_planet),
    planet: cell.planet ?? cell.planet_json ?? null,
    capturedBy: cell.capturedBy ?? cell.captured_by ?? null,
  });

  if (!hadCell) {
    markTileReveal(key);
  }

  updateEconomyDisplay();
  draw();
  refreshCursorFromLast();
}

export function applyRealtimeSnapshot(snapshot) {
  if (!snapshot || typeof snapshot !== "object") return;
  let shouldUpdateEconomy = false;

  if (snapshot.scores && typeof snapshot.scores === "object") {
    econScoreBlue = Number(snapshot.scores.blue ?? econScoreBlue);
    econScoreRed  = Number(snapshot.scores.red  ?? econScoreRed);
    if (econScoreBlueEl) econScoreBlueEl.textContent = econScoreBlue.toFixed(3);
    if (econScoreRedEl)  econScoreRedEl.textContent  = econScoreRed.toFixed(3);
  }

  if (snapshot.elementBonus && typeof snapshot.elementBonus === "object") {
    elemBonusBlue = Number(snapshot.elementBonus.blue ?? elemBonusBlue);
    elemBonusRed  = Number(snapshot.elementBonus.red  ?? elemBonusRed);
    updateEffectiveCooldownDisplay();
    shouldUpdateEconomy = true;
  }

  if (snapshot.militaryDeductions && typeof snapshot.militaryDeductions === "object") {
    milDeductBlue = Number(snapshot.militaryDeductions.blue ?? milDeductBlue);
    milDeductRed  = Number(snapshot.militaryDeductions.red  ?? milDeductRed);
    shouldUpdateEconomy = true;
  }

  if (snapshot.activePlayers && typeof snapshot.activePlayers === "object") {
    setActivePlayersDisplay(snapshot.activePlayers.blue ?? 0, snapshot.activePlayers.red ?? 0);
  }

  if (snapshot.victoryPoints && typeof snapshot.victoryPoints === "object") {
    setVictoryPointsDisplay(snapshot.victoryPoints.blue ?? 0, snapshot.victoryPoints.red ?? 0);
  }

  if (shouldUpdateEconomy) {
    updateEconomyDisplay();
  }
}

/** Trigger the delta fade animation on an element. */
function showEconDelta(el, delta) {
  if (!el || delta <= 0) return;
  el.textContent = `+${delta.toFixed(3)}`;
  el.classList.remove("econDelta--active");
  // Force reflow so removing/re-adding the class restarts the animation
  void el.offsetWidth;
  el.classList.add("econDelta--active");
}

/**
 * Called every ECON_TICK_MS milliseconds.
 * Adds income × (interval in seconds) to each team's cumulative score,
 * updates the score display, and triggers the delta animation.
 *
 * @param {number} intervalSeconds - how many seconds elapsed since last tick
 */
export async function tickEconScore(intervalSeconds) {
  const worth = GAME_CONFIG.resourceWorth;

  const blueIncome = computeTeamIncome("blue", cells, worth);
  const redIncome  = computeTeamIncome("red",  cells, worth);

  const blueDelta = blueIncome.total * intervalSeconds;
  const redDelta  = redIncome.total  * intervalSeconds;

  try {
    const scores = await apiTickEconScores(seedStr, blueDelta, redDelta);
    econScoreBlue = scores.blue;
    econScoreRed  = scores.red;
  } catch {
    // fallback: update locally if server unreachable
    econScoreBlue += blueDelta;
    econScoreRed  += redDelta;
  }

  if (econScoreBlueEl) econScoreBlueEl.textContent = econScoreBlue.toFixed(3);
  if (econScoreRedEl)  econScoreRedEl.textContent  = econScoreRed.toFixed(3);

  showEconDelta(econDeltaBlueEl, blueDelta);
  showEconDelta(econDeltaRedEl,  redDelta);
}

/** Load economic scores from server (called on boot / after seed change). */
export async function loadEconScores() {
  try {
    const scores = await apiFetchEconScores();
    econScoreBlue = scores.blue ?? 0;
    econScoreRed  = scores.red  ?? 0;
    if (econScoreBlueEl) econScoreBlueEl.textContent = econScoreBlue.toFixed(3);
    if (econScoreRedEl)  econScoreRedEl.textContent  = econScoreRed.toFixed(3);
  } catch { /* ignore */ }
}

/** Reset economic scores (called when world period changes). */
export function resetEconScores() {
  econScoreBlue = 0;
  econScoreRed  = 0;
  if (econScoreBlueEl) econScoreBlueEl.textContent = "0.000";
  if (econScoreRedEl)  econScoreRedEl.textContent  = "0.000";
}

// ── Config fetch + apply ──────────────────────────────────────────────────────

/** Fetches /api/config, updates GAME_CONFIG, returns true if the world seed changed. */
export async function fetchConfig() {
  const data = await apiFetchConfig(currentTeam);
  const prevSeed = GAME_CONFIG.worldSeed;
  applyConfigPayload(data);
  applySeed(GAME_CONFIG.worldSeed);
  return prevSeed !== "" && prevSeed !== GAME_CONFIG.worldSeed;
}

// ── Grid fetch ────────────────────────────────────────────────────────────────

export async function fetchGridForSeed(seed) {
  let currentSeed = seed;
  const seenSeeds = new Set();

  for (let attempt = 0; attempt < 5; attempt++) {
    const res = await apiFetchGrid(currentSeed);

    if (res.status === 410) {
      let nextSeed = "";
      try {
        const stale = await res.json();
        if (typeof stale?.worldSeed === "string" && stale.worldSeed) {
          nextSeed = stale.worldSeed;
        }
      } catch {
        // ignore parse errors, fallback to /api/config
      }

      seenSeeds.add(currentSeed);

      if (nextSeed) {
        if (seenSeeds.has(nextSeed)) break;
        GAME_CONFIG.worldSeed = nextSeed;
        applySeed(nextSeed);
        currentSeed = nextSeed;
        continue;
      }

      await fetchConfig();
      currentSeed = seedStr;
      if (seenSeeds.has(currentSeed)) break;
      continue;
    }

    if (!res.ok) throw new Error("grid");

    const data = await res.json();
    cells.clear();
    for (const row of data.cells || []) {
      cells.set(cellKey(row.x, row.y), {
        controlledBy: row.discovered_by ?? row.discoveredBy ?? null,
        hasPlanet:    Boolean(row.has_planet),
        planet:       row.planet_json ?? null,
        capturedBy:   row.captured_by ?? row.capturedBy ?? null,
      });
    }
    return;
  }

  throw new Error("grid_stale");
}

// ── Action quota ──────────────────────────────────────────────────────────────

/** Updates the actions remaining display in the sidebar. */
function updateActionsDisplay() {
  countdownEl.textContent = String(Math.max(0, actionsRemaining));
  refreshCursorFromLast();
}

/** Updates the team quota display in the sidebar. */
function updateTeamQuotaDisplay() {
  if (teamQuotaEl) {
    teamQuotaEl.textContent = teamActionsRemaining !== null ? String(Math.max(0, teamActionsRemaining)) : "—";
  }
  refreshCursorFromLast();
}

/** Returns true when the user has no actions left for today's quota window. */
export function cooldownActive() {
  return actionsRemaining <= 0;
}

/** Decrements the local actions counter by 1 after a successful reveal. */
export function startCooldown() {
  actionsRemaining = Math.max(0, actionsRemaining - 1);
  updateActionsDisplay();
  refreshCursorFromLast();
}

/** Resets local action state (called on world seed change or re-login). */
export function clearCooldown() {
  actionsRemaining = 0;
  teamActionsRemaining = null;
  updateActionsDisplay();
  updateTeamQuotaDisplay();
}

// ── Draw ──────────────────────────────────────────────────────────────────────

export function draw() {
  const w = canvas.width;
  const h = canvas.height;

  // viewport in map-space
  const vw = SVG_W / zoom;
  const vh = SVG_H / zoom;

  ctx.save();

  // 1. Dark base fill
  ctx.fillStyle = "#050810";
  ctx.fillRect(0, 0, w, h);

  // 2. Apply zoom/pan transform: map panX..panX+vw  →  0..w
  ctx.scale(zoom, zoom);
  ctx.translate(-panX, -panY);

  // 3. Draw the SVG playfield as the background layer
  if (playfieldImg) {
    ctx.drawImage(playfieldImg, 0, 0, SVG_W, SVG_H);
  }

  const cw = SVG_W / GRID_W;
  const ch = SVG_H / GRID_H;

  // Visible cell range (with 1-cell margin for partially visible tiles)
  const xMin = Math.max(0, Math.floor(panX / cw) - 1);
  const xMax = Math.min(GRID_W - 1, Math.ceil((panX + vw) / cw) + 1);
  const yMin = Math.max(0, Math.floor(panY / ch) - 1);
  const yMax = Math.min(GRID_H - 1, Math.ceil((panY + vh) / ch) + 1);

  // 4. Draw game tile overlays (only visible exploitable tiles)
  for (let y = yMin; y <= yMax; y++) {
    for (let x = xMin; x <= xMax; x++) {
      if (!isExploitable(x, y)) continue;
      const k = cellKey(x, y);
      const meta = cellMeta(k);
      const alpha = tileAlpha(k);
      ctx.globalAlpha = alpha;
      if (!meta)                                         ctx.fillStyle = COLOR_RING_IDLE;
      else if (!meta.hasPlanet)                          ctx.fillStyle = COLOR_OPPONENT_GREY;  // empty or unexploitable
      else if (meta.controlledBy === null)               ctx.fillStyle = COLOR_OPPONENT_GREY;  // neutral planet
      else if (meta.controlledBy === currentTeam)        ctx.fillStyle = currentTeam === "blue" ? COLOR_BLUE_DISCOVERED : COLOR_RED_DISCOVERED;
      else ctx.fillStyle = currentTeam === "blue" ? COLOR_RED_DISCOVERED : COLOR_BLUE_DISCOVERED; // opponent-controlled
      ctx.fillRect(x * cw, y * ch, cw, ch);
      ctx.globalAlpha = 1;
    }
  }

  // 5. Draw planet dots on all revealed tiles with planets
  for (const [key, meta] of cells) {
    if (!meta.hasPlanet) continue;
    const [xs, ys] = key.split(",").map(Number);
    if (xs < xMin || xs > xMax || ys < yMin || ys > yMax) continue;
    if (!isExploitable(xs, ys)) continue;
    const h32 = hash2u32(xs, ys, seedU32 ^ 0x1337);
    const rng  = mulberry32(h32);
    ctx.fillStyle = `hsl(${Math.floor(rng() * 360)} 85% 65%)`;
    const r = Math.max(1.6, Math.min(cw, ch) * 0.28);
    ctx.beginPath();
    ctx.arc(xs * cw + cw / 2, ys * ch + ch / 2, r, 0, Math.PI * 2);
    ctx.fill();
  }

  // 6. Grid lines between adjacent exploitable tiles (visible range only)
  ctx.strokeStyle = "rgba(255,255,255,0.10)";
  ctx.lineWidth = 1 / zoom;   // keep grid lines thin regardless of zoom
  ctx.beginPath();
  for (let x = Math.max(1, xMin); x <= Math.min(GRID_W - 1, xMax); x++) for (let y = yMin; y <= yMax; y++) {
    if (!isExploitable(x - 1, y) || !isExploitable(x, y)) continue;
    ctx.moveTo(x * cw, y * ch); ctx.lineTo(x * cw, (y + 1) * ch);
  }
  for (let y = Math.max(1, yMin); y <= Math.min(GRID_H - 1, yMax); y++) for (let x = xMin; x <= xMax; x++) {
    if (!isExploitable(x, y - 1) || !isExploitable(x, y)) continue;
    ctx.moveTo(x * cw, y * ch); ctx.lineTo((x + 1) * cw, y * ch);
  }
  ctx.stroke();

  ctx.restore();
}

// ── Map action animation ─────────────────────────────────────────────────────

const mapAnimTextByType = {
  "reveal-empty": "🔍",
  "reveal-planet": "👁️",
  "capture": "⚔️",
};

/**
 * Spawns a floating emoji that rises one cell height above the clicked cell
 * and fades in/out over 1 second.
 * @param {"reveal-empty"|"reveal-planet"|"capture"} type
 * @param {number} x  cell grid column
 * @param {number} y  cell grid row
 */
function triggerMapAnimation(type, x, y) {
  const cw = SVG_W / GRID_W;
  const ch = SVG_H / GRID_H;
  const rect = canvas.getBoundingClientRect();

  // Top-centre of the cell in screen pixels, relative to the canvas element
  const screenX = ((x * cw + cw / 2 - panX) * zoom / SVG_W) * rect.width;
  const screenY = ((y * ch           - panY) * zoom / SVG_H) * rect.height;

  // One cell height in screen pixels (travel distance)
  const cellScreenH = (ch * zoom / SVG_H) * rect.height;
  const fontSize    = Math.max(8, (cw * zoom / SVG_W) * rect.width);

  const el = document.createElement("div");
  el.className   = "mapAnimFloat";
  el.textContent = mapAnimTextByType[type] || "🔍";
  el.style.left     = `${screenX}px`;
  el.style.top      = `${screenY}px`;
  el.style.fontSize = `${fontSize}px`;
  el.style.transform = "translate(-50%, 0)";
  canvas.parentElement.appendChild(el);

  el.animate(
    [
      { opacity: 0, transform: "translate(-50%, 0)" },
      { opacity: 1, transform: `translate(-50%, -${cellScreenH * 0.2}px)`, offset: 0.2 },
      { opacity: 1, transform: `translate(-50%, -${cellScreenH * 0.8}px)`, offset: 0.8 },
      { opacity: 0, transform: `translate(-50%, -${cellScreenH}px)` },
    ],
    { duration: 1000, easing: "ease-out", fill: "forwards" }
  ).onfinish = () => el.remove();
}

// ── Cursor ────────────────────────────────────────────────────────────────────

function pickCell(ev) {
  const rect = canvas.getBoundingClientRect();
  // Convert screen coords → map coords accounting for zoom/pan
  const mapX = (ev.clientX - rect.left) / rect.width  * (SVG_W / zoom) + panX;
  const mapY = (ev.clientY - rect.top)  / rect.height * (SVG_H / zoom) + panY;
  const x = Math.floor(mapX / (SVG_W / GRID_W));
  const y = Math.floor(mapY / (SVG_H / GRID_H));
  if (x < 0 || x >= GRID_W || y < 0 || y >= GRID_H) return null;
  return { x, y };
}

function refreshCursor(ev) {
  const cell = pickCell(ev);
  if (!cell || !isExploitable(cell.x, cell.y)) {
    canvas.style.cursor = "default";
    return;
  }
  const key = cellKey(cell.x, cell.y);
  const meta = cells.get(key);
  if (!meta) {
    // Not yet revealed — can reveal if quota available
    canvas.style.cursor = cooldownActive() ? "default" : "pointer";
  } else if (meta.hasPlanet && meta.controlledBy !== currentTeam) {
    // Capturable (neutral or opponent-controlled) planet
    canvas.style.cursor = "pointer";
  } else {
    canvas.style.cursor = "default";
  }
}

function refreshCursorFromLast() {
  if (lastPointerEvent) refreshCursor(lastPointerEvent);
}

// ── Selection display ─────────────────────────────────────────────────────────

/** Escapes a string for safe HTML insertion. */
function escHtml(str) {
  return String(str).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}

/** Shows or hides the captor info banner below planet stats. */
function showCaptorInfo(capturedBy, team) {
  if (!captorInfoEl) return;
  if (!capturedBy) { captorInfoEl.className = "captorInfo hidden"; return; }
  const teamClass = team === "blue" ? "captorInfo--blue" : team === "red" ? "captorInfo--red" : "";
  captorInfoEl.className = `captorInfo ${teamClass}`;
  captorInfoEl.innerHTML = `Capturée par <strong>${escHtml(capturedBy)}</strong>`;
}

function applyRevealPayload(cell) {
  const _revealKey = cellKey(cell.x, cell.y);
  markTileReveal(_revealKey);
  cells.set(_revealKey, {
    controlledBy: cell.discoveredBy ?? null,
    hasPlanet:    Boolean(cell.hasPlanet),
    planet:       cell.planet ?? null,
    capturedBy:   cell.capturedBy ?? null,
  });
  details.classList.remove("details--hidden");
  if (!cell.exploitable) {
    hint.textContent    = `(${cell.x},${cell.y}) Inexploitable`;
    details.textContent = `Tuile (${cell.x},${cell.y})\n\nStatus : Inexploitable`;
    showCaptorInfo(null, null);
    return;
  }
  if (!cell.hasPlanet) {
    hint.textContent    = `(${cell.x},${cell.y}) Vide`;
    details.textContent = `Tuile (${cell.x},${cell.y})\n\nStatus : Vide`;
    showCaptorInfo(null, null);
    return;
  }
  const controlStatus = cell.discoveredBy === null
    ? "Neutre (non capturée)"
    : cell.discoveredBy === currentTeam
      ? `Contrôlée (${currentTeam === "blue" ? "Résistance" : "Premier Ordre"})`
      : `Contrôlée par l'adversaire`;
  hint.textContent    = `(${cell.x},${cell.y}) Planète présente`;
  details.textContent = `Tuile (${cell.x},${cell.y})\n\nStatus : ${controlStatus}\n\n${formatPlanet(cell.planet)}`;
  showCaptorInfo(cell.capturedBy ?? null, cell.discoveredBy);
}

function showLocalSelection(x, y) {
  const k = cellKey(x, y);
  const meta = cellMeta(k);
  details.classList.remove("details--hidden");
  if (!meta) return;
  if (!isExploitable(x, y)) {
    hint.textContent    = `(${x},${y}) Inexploitable`;
    details.textContent = `Tuile (${x},${y})\n\nStatus : Inexploitable`;
    showCaptorInfo(null, null);
    return;
  }
  if (!meta.hasPlanet) {
    hint.textContent    = `(${x},${y}) Vide`;
    details.textContent = `Tuile (${x},${y})\n\nStatus : Vide`;
    showCaptorInfo(null, null);
    return;
  }
  let planet = meta.planet;
  if (!planet) {
    const h = hash2u32(x, y, seedU32 ^ 0xa5a5a5a5);
    planet = generatePlanet(mulberry32(h));
  }
  const controlStatus = meta.controlledBy === null
    ? "Neutre (non capturée)"
    : meta.controlledBy === currentTeam
      ? `Contrôlée (${currentTeam === "blue" ? "Résistance" : "Premier Ordre"})`
      : `Contrôlée par l'adversaire`;
  hint.textContent    = `(${x},${y}) Planète présente`;
  details.textContent = `Tuile (${x},${y})\n\nStatus : ${controlStatus}\n\n${formatPlanet(planet)}`;
  showCaptorInfo(meta.capturedBy ?? null, meta.controlledBy);
}

// ── Military attack modal ────────────────────────────────────────────────────

/**
 * Shows the styled attack confirmation modal and returns a Promise<boolean>.
 * Resolves true if the player confirms, false if they cancel.
 */
function showAttackModal(milNetM, x, y) {
  attackModalBodyEl.textContent =
    `Tuile (${x}, ${y}) appartenant à l'adversaire.\n\n` +
    `Coût : 1000.0\n` +
    `Puissance actuelle : ${milNetM.toFixed(1)}\n\n` +
    `Cette dépense est permanente, irréversible et potentiellement risquée sur une tuile vide.\n\n` +
    `La tuile passera sous votre contrôle.`;
  attackOverlayEl.classList.remove("hidden");
  return new Promise((resolve) => {
    function cleanup() {
      attackOverlayEl.classList.add("hidden");
      attackModalYesEl.removeEventListener("click", onYes);
      attackModalNoEl.removeEventListener("click", onNo);
    }
    function onYes() { cleanup(); resolve(true); }
    function onNo()  { cleanup(); resolve(false); }
    attackModalYesEl.addEventListener("click", onYes);
    attackModalNoEl.addEventListener("click", onNo);
  });
}

// ── Planet capture modal ─────────────────────────────────────────────────────

/**
 * Shows the capture confirmation modal.
 * Returns a Promise<boolean> — true if confirmed, false if cancelled.
 */
function showCaptureModal(planet, controlledBy, cost, x, y) {
  const opponentName = currentTeam === "blue" ? "Premier Ordre" : "Résistance";
  const controlLabel = controlledBy === null
    ? "Planète neutre"
    : `Planète contrôlée par ${opponentName} (coût doublé)`;
  captureModalBodyEl.textContent =
    `Planète (${x}, ${y}) — ${planet?.name ?? "Inconnue"}\n\n` +
    `${controlLabel}\n\n` +
    `Coût : ${cost} action${cost > 1 ? "s" : ""} d'équipe\n` +
    `Actions d'équipe disponibles : ${teamActionsRemaining ?? "?"}\n\n` +
    `La planète passera sous votre contrôle. Ses ressources, bonus et puissance militaire\n` +
    `seront calculés pour votre équipe.`;
  captureOverlayEl.classList.remove("hidden");
  return new Promise((resolve) => {
    function cleanup() {
      captureOverlayEl.classList.add("hidden");
      captureModalYesEl.removeEventListener("click", onYes);
      captureModalNoEl.removeEventListener("click", onNo);
    }
    function onYes() { cleanup(); resolve(true); }
    function onNo()  { cleanup(); resolve(false); }
    captureModalYesEl.addEventListener("click", onYes);
    captureModalNoEl.addEventListener("click", onNo);
  });
}

// ── Canvas click handler ──────────────────────────────────────────────────────

async function onCanvasClick(ev) {
  const cell = pickCell(ev);
  if (!cell || !isExploitable(cell.x, cell.y)) return;
  const key = cellKey(cell.x, cell.y);
  const meta = cells.get(key);

  // ── Not yet revealed: first click = spend user action to reveal ──
  if (!meta) {
    if (cooldownActive()) {
      hint.textContent = "Quota d'actions épuisé pour aujourd'hui — revenez après 12h00 UTC.";
      return;
    }
    try {
      const res = await apiRevealCell(seedStr, cell.x, cell.y, currentTeam);
      if (res.status === 429) {
        actionsRemaining = 0;
        updateActionsDisplay();
        hint.textContent = "Quota d'actions épuisé pour aujourd'hui — revenez après 12h00 UTC.";
        return;
      }
      if (res.status === 410) {
        hint.textContent = "Le serveur change sa base de planètes — synchronisation...";
        await refreshFromServer(); return;
      }
      if (!res.ok) throw new Error("reveal");
      applyRevealPayload(await res.json());
      triggerMapAnimation(cells.get(key)?.hasPlanet ? "reveal-planet" : "reveal-empty", cell.x, cell.y);
      startCooldown();
      updateEconomyDisplay();
      draw();
    } catch (e) {
      if (e?.code === "SEED_EXPIRED") { await refreshFromServer(); return; }
      hint.textContent = "Could not save reveal — check server / database.";
    }
    return;
  }

  // ── Already revealed: empty cell or own planet — show info only ──
  if (!meta.hasPlanet || meta.controlledBy === currentTeam) {
    showLocalSelection(cell.x, cell.y);
    return;
  }

  // ── Revealed planet, neutral or opponent-controlled: offer capture ──
  showLocalSelection(cell.x, cell.y);

  const popBillions = meta.planet?.population?.billions ?? 0;
  const baseCost = Math.max(1, Math.ceil(popBillions / 10));
  const cost = (meta.controlledBy !== null) ? baseCost * 2 : baseCost;

  if (teamActionsRemaining !== null && teamActionsRemaining < cost) {
    hint.textContent = `Planète repérée. Actions d'équipe insuffisantes pour capturer (coût : ${cost}, disponibles : ${teamActionsRemaining}).`;
    return;
  }

  const confirmed = await showCaptureModal(meta.planet, meta.controlledBy, cost, cell.x, cell.y);
  if (!confirmed) return;

  try {
    const res = await apiCaptureCell(seedStr, cell.x, cell.y);
    if (res.status === 410) {
      hint.textContent = "Le serveur change sa base de planètes — synchronisation...";
      await refreshFromServer(); return;
    }
    if (res.status === 429) {
      const data = await res.json();
      teamActionsRemaining = data.teamActionsRemaining ?? teamActionsRemaining;
      updateTeamQuotaDisplay();
      hint.textContent = `Actions d'équipe insuffisantes (coût : ${data.cost ?? cost}, disponibles : ${data.teamActionsRemaining ?? "?"}).`;
      return;
    }
    if (res.status === 409) {
      const data = await res.json();
      if (data.error === "already_owned") {
        hint.textContent = "Cette planète vous appartient déjà.";
      } else {
        hint.textContent = "Impossible de capturer cette tuile.";
      }
      return;
    }
    if (!res.ok) { hint.textContent = "Erreur lors de la capture."; return; }
    const data = await res.json();
    markTileReveal(key);
    cells.set(key, { ...meta, controlledBy: currentTeam, capturedBy: data.cell?.capturedBy ?? null });
    if (data.teamActionsRemaining !== undefined && data.teamActionsRemaining !== null) {
      teamActionsRemaining = data.teamActionsRemaining;
      updateTeamQuotaDisplay();
    }
    triggerMapAnimation("capture", cell.x, cell.y);
    hint.textContent = `🏴 Planète (${cell.x},${cell.y}) capturée !`;
    showLocalSelection(cell.x, cell.y);
    updateEconomyDisplay();
    draw();
  } catch {
    hint.textContent = "Erreur réseau lors de la capture.";
  }
}

// ── Team segmented control ────────────────────────────────────────────────────


// ── Lightweight grid refresh (called every second) ────────────────────────────

/** Fetches the current grid and redraws without touching config or scores. */
export async function refreshGridDisplay() {
  try {
    await fetchGridForSeed(seedStr);
    updateEconomyDisplay();
    draw();
    refreshCursorFromLast();
  } catch { /* ignore */ }
}

// ── Full server refresh ───────────────────────────────────────────────────────
// Exported so auth.js / main.js can call it after login or on timer.

export async function refreshFromServer() {
  try {
    const seedChanged = await fetchConfig();
    if (seedChanged) {
      resetEconScores();
      loadEconScores();
      details.textContent = "Les stats sont vides jusqu'au clic sur une tuile.";
      details.classList.add("details--hidden");
      hint.textContent = "Le monde a été réinitilisaté. Vous pouvez cliquer sur une tuile pour recommencer le jeu.";
    }
    await fetchGridForSeed(seedStr);
    await fetchAndApplyActivePlayers();
    updateEconomyDisplay();
    draw();
    refreshCursorFromLast();
  } catch {
    hint.textContent = "Could not sync with server. Is the API running?";
  }
}

// ── Event listeners ───────────────────────────────────────────────────────────

// ── Resource table sort (delegated, set up once) ──────────────────────────────

resourceTableEl?.addEventListener("click", (ev) => {
  const th = ev.target.closest("th[data-sort-col]");
  if (!th) return;
  const col = Number(th.dataset.sortCol);
  const { col: curCol, dir: curDir } = getEconSort();
  // Toggle direction if same column; otherwise default to desc for numeric cols, asc for text
  const newDir = col === curCol
    ? (curDir === "asc" ? "desc" : "asc")
    : (col === 2 || col === 3 ? "desc" : "asc");
  setEconSort(col, newDir);
  updateEconomyDisplay();
});

elemBonusTableEl?.addEventListener("click", (ev) => {
  const th = ev.target.closest("th[data-elem-sort-col]");
  if (!th) return;
  const col = Number(th.dataset.elemSortCol);
  const { col: curCol, dir: curDir } = getElemSort();
  const newDir = col === curCol
    ? (curDir === "asc" ? "desc" : "asc")
    : (col === 0 ? "asc" : "desc");
  setElemSort(col, newDir);
  updateEconomyDisplay();
});

milTableEl?.addEventListener("click", (ev) => {
  const th = ev.target.closest("th[data-mil-sort-col]");
  if (!th) return;
  const col = Number(th.dataset.milSortCol);
  const { col: curCol, dir: curDir } = getMilSort();
  const newDir = col === curCol
    ? (curDir === "asc" ? "desc" : "asc")
    : (col === 0 ? "asc" : "desc");
  setMilSort(col, newDir);
  updateEconomyDisplay();
});

canvas.addEventListener("mousemove",  (ev) => { lastPointerEvent = ev; refreshCursor(ev); });
canvas.addEventListener("mouseleave", ()   => { lastPointerEvent = null; canvas.style.cursor = "default"; });
canvas.addEventListener("click",      onCanvasClick);

// ── Zoom (mouse wheel) ───────────────────────────────────────────────────────

canvas.addEventListener("wheel", (ev) => {
  ev.preventDefault();
  const rect = canvas.getBoundingClientRect();
  // Cursor position in map-space before zoom
  const mx = (ev.clientX - rect.left) / rect.width  * (SVG_W / zoom) + panX;
  const my = (ev.clientY - rect.top)  / rect.height * (SVG_H / zoom) + panY;

  const oldZoom = zoom;
  // Negative deltaY = scroll up = zoom in
  zoom *= 1 - ev.deltaY * ZOOM_SPEED;
  zoom = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, zoom));

  // Adjust pan so the map point under the cursor stays in place
  panX = mx - (ev.clientX - rect.left) / rect.width  * (SVG_W / zoom);
  panY = my - (ev.clientY - rect.top)  / rect.height * (SVG_H / zoom);
  clampPan();

  draw();
  refreshCursorFromLast();
}, { passive: false });

// ── Pan (mouse drag) ─────────────────────────────────────────────────────────

let _dragging = false;
let _dragStartX = 0;
let _dragStartY = 0;
let _panStartX = 0;
let _panStartY = 0;

canvas.addEventListener("mousedown", (ev) => {
  if (ev.button !== 0) return;           // left-click only
  _dragging = true;
  _dragStartX = ev.clientX;
  _dragStartY = ev.clientY;
  _panStartX = panX;
  _panStartY = panY;
});

window.addEventListener("mousemove", (ev) => {
  if (!_dragging) return;
  const rect = canvas.getBoundingClientRect();
  const dx = (ev.clientX - _dragStartX) / rect.width  * (SVG_W / zoom);
  const dy = (ev.clientY - _dragStartY) / rect.height * (SVG_H / zoom);
  panX = _panStartX - dx;
  panY = _panStartY - dy;
  clampPan();
  draw();
});

window.addEventListener("mouseup", () => { _dragging = false; });

// Suppress click events that are actually end-of-drag
let _suppressClick = false;
canvas.addEventListener("mousedown", (ev) => {
  _suppressClick = false;
  const sx = ev.clientX, sy = ev.clientY;
  const up = () => {
    window.removeEventListener("mouseup", up);
    // If cursor moved more than 4px it was a drag, not a click
    // (handled via capture-phase click suppression below)
  };
  const move = (mev) => {
    if (Math.hypot(mev.clientX - sx, mev.clientY - sy) > 4) _suppressClick = true;
  };
  window.addEventListener("mousemove", move);
  window.addEventListener("mouseup", () => {
    window.removeEventListener("mousemove", move);
  }, { once: true });
});

canvas.addEventListener("click", (ev) => {
  if (_suppressClick) { ev.stopImmediatePropagation(); _suppressClick = false; }
}, true);   // capture phase — runs before the game click handler

// ── Touch: pinch-to-zoom + pan ───────────────────────────────────────────────

let _touches = [];       // active touch cache
let _pinchDist0 = null;  // initial distance between two fingers
let _pinchZoom0 = 1;     // zoom at pinch start
let _pinchMid0 = null;   // midpoint at pinch start (map-space)
let _touchPanX0 = 0;
let _touchPanY0 = 0;
let _tapStartX = 0;      // tap detection
let _tapStartY = 0;
let _tapStartTime = 0;
let _wasTap = false;

function touchDist(a, b) { return Math.hypot(a.clientX - b.clientX, a.clientY - b.clientY); }

function screenToMap(cx, cy) {
  const rect = canvas.getBoundingClientRect();
  return {
    mx: (cx - rect.left) / rect.width  * (SVG_W / zoom) + panX,
    my: (cy - rect.top)  / rect.height * (SVG_H / zoom) + panY,
  };
}

canvas.addEventListener("touchstart", (ev) => {
  ev.preventDefault();
  _touches = [...ev.touches];
  _wasTap = false;
  if (_touches.length === 1) {
    _dragStartX = _touches[0].clientX;
    _dragStartY = _touches[0].clientY;
    _panStartX = panX;
    _panStartY = panY;
    _tapStartX = _touches[0].clientX;
    _tapStartY = _touches[0].clientY;
    _tapStartTime = Date.now();
    _wasTap = true;
  }
  if (_touches.length === 2) {
    _pinchDist0 = touchDist(_touches[0], _touches[1]);
    _pinchZoom0 = zoom;
    const midCx = (_touches[0].clientX + _touches[1].clientX) / 2;
    const midCy = (_touches[0].clientY + _touches[1].clientY) / 2;
    _pinchMid0 = screenToMap(midCx, midCy);
    _touchPanX0 = panX;
    _touchPanY0 = panY;
  }
}, { passive: false });

canvas.addEventListener("touchmove", (ev) => {
  ev.preventDefault();
  _touches = [...ev.touches];

  // If finger moved significantly, it's not a tap anymore
  if (_wasTap && _touches.length === 1) {
    const dist = Math.hypot(_touches[0].clientX - _tapStartX, _touches[0].clientY - _tapStartY);
    if (dist > 8) _wasTap = false;
  } else {
    _wasTap = false;
  }

  if (_touches.length === 1) {
    // Single-finger pan
    const rect = canvas.getBoundingClientRect();
    const dx = (_touches[0].clientX - _dragStartX) / rect.width  * (SVG_W / zoom);
    const dy = (_touches[0].clientY - _dragStartY) / rect.height * (SVG_H / zoom);
    panX = _panStartX - dx;
    panY = _panStartY - dy;
    clampPan();
    draw();
  }

  if (_touches.length === 2 && _pinchDist0) {
    const newDist = touchDist(_touches[0], _touches[1]);
    zoom = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, _pinchZoom0 * (newDist / _pinchDist0)));

    // Keep the original midpoint anchored
    const midCx = (_touches[0].clientX + _touches[1].clientX) / 2;
    const midCy = (_touches[0].clientY + _touches[1].clientY) / 2;
    const rect = canvas.getBoundingClientRect();
    panX = _pinchMid0.mx - (midCx - rect.left) / rect.width  * (SVG_W / zoom);
    panY = _pinchMid0.my - (midCy - rect.top)  / rect.height * (SVG_H / zoom);
    clampPan();
    draw();
  }
}, { passive: false });

canvas.addEventListener("touchend", (ev) => {
  ev.preventDefault();

  // Detect tap: single finger, short duration, minimal movement
  if (_wasTap && ev.changedTouches.length === 1 && ev.touches.length === 0) {
    const elapsed = Date.now() - _tapStartTime;
    if (elapsed < 300) {
      const t = ev.changedTouches[0];
      // Synthesise a click event so onCanvasClick fires
      const clickEv = new MouseEvent("click", {
        clientX: t.clientX, clientY: t.clientY,
        bubbles: true, cancelable: true,
      });
      canvas.dispatchEvent(clickEv);
    }
  }
  _wasTap = false;

  _touches = [...ev.touches];
  if (_touches.length < 2) { _pinchDist0 = null; }
  if (_touches.length === 1) {
    _dragStartX = _touches[0].clientX;
    _dragStartY = _touches[0].clientY;
    _panStartX = panX;
    _panStartY = panY;
  }
}, { passive: false });
