import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import axios from 'axios';

// Duplicated from src/shared/battlemapEngine.js (kept CommonJS there for the
// Node backend) rather than imported: webpack's CJS/ESM interop for that
// module breaks module.exports when it is pulled into an ES module graph
// like this component, producing a blank page in production.
const METERS_PER_CHECKER = 10;
const DEFAULT_DOOR_WIDTH = 3;

function battlemapVisibleForPlayer(scene, authedPlayer) {
  if (authedPlayer === 'gm') return true;
  const reveal = scene?.reveal || {};
  if (typeof reveal.battlemap === 'boolean') return reveal.battlemap;
  return scene?.revealed === true;
}

function normalizeMovement(movement = {}) {
  const half = Math.max(1, Number(movement.half || 3) || 3);
  const full = Math.max(half, Number(movement.full || half * 2) || half * 2);
  const charge = Math.max(half, Number(movement.charge || full) || full);
  const run = Math.max(full, Number(movement.run || full * 2) || full * 2);
  return { half, full, charge, run };
}

function normalizeTurnState(existing = {}, turnKey = '') {
  if (existing?.turnKey === turnKey) {
    return {
      turnKey,
      movedBy: existing.movedBy && typeof existing.movedBy === 'object' ? existing.movedBy : {},
      movementUsedBy: existing.movementUsedBy && typeof existing.movementUsedBy === 'object' ? existing.movementUsedBy : {},
      moveModeBy: existing.moveModeBy && typeof existing.moveModeBy === 'object' ? existing.moveModeBy : {},
    };
  }
  return { turnKey, movedBy: {}, movementUsedBy: {}, moveModeBy: {} };
}

function movementBudget(movement, mode, isCarrier = false) {
  const profile = normalizeMovement(movement);
  if (isCarrier && (mode === 'charge' || mode === 'run')) return 0;
  const checkers = (metres) => Math.max(1, Math.ceil((Number(metres || 0) || 0) / METERS_PER_CHECKER));
  if (mode === 'half') return checkers(profile.half);
  if (mode === 'charge') return checkers(profile.charge);
  if (mode === 'run') return checkers(profile.run);
  return checkers(profile.full);
}

function movementSpent(turnState, tokenId) {
  return Math.max(0, Number(turnState?.movementUsedBy?.[tokenId] || 0) || 0);
}

function movementModeLocked(turnState, tokenId) {
  return String(turnState?.moveModeBy?.[tokenId] || '');
}

function distance(from, to) {
  const dx = Number(to.x || 0) - Number(from.x || 0);
  const dy = Number(to.y || 0) - Number(from.y || 0);
  return Math.sqrt((dx * dx) + (dy * dy));
}

function pointInRect(point, rect) {
  if (!point || !rect) return false;
  const x = Number(point.x || 0);
  const y = Number(point.y || 0);
  return x >= rect.x && x <= rect.x + rect.w && y >= rect.y && y <= rect.y + rect.h;
}

function pointInAnyZone(zones, point) {
  return (zones || []).some((zone) => pointInRect(point, zone));
}

function doorwaySpan(start, end, width = DEFAULT_DOOR_WIDTH) {
  const min = Math.min(start, end);
  const max = Math.max(start, end);
  const span = max - min;
  if (span <= 0) return null;
  const opening = Math.min(width, span);
  const center = (min + max) / 2;
  return { from: center - (opening / 2), to: center + (opening / 2) };
}

function computeZoneDoorways(zones) {
  const doorways = [];
  (zones || []).forEach((zone, zoneIndex) => {
    (zones || []).slice(zoneIndex + 1).forEach((other) => {
      if (other.y === zone.y + zone.h || zone.y === other.y + other.h) {
        const overlapFrom = Math.max(zone.x, other.x);
        const overlapTo = Math.min(zone.x + zone.w, other.x + other.w);
        if (overlapTo > overlapFrom) {
          const opening = doorwaySpan(overlapFrom, overlapTo);
          if (opening) doorways.push({ axis: 'h', at: other.y === zone.y + zone.h ? zone.y + zone.h : other.y + other.h, from: opening.from, to: opening.to });
        }
      }
      if (other.x === zone.x + zone.w || zone.x === other.x + other.w) {
        const overlapFrom = Math.max(zone.y, other.y);
        const overlapTo = Math.min(zone.y + zone.h, other.y + other.h);
        if (overlapTo > overlapFrom) {
          const opening = doorwaySpan(overlapFrom, overlapTo);
          if (opening) doorways.push({ axis: 'v', at: other.x === zone.x + zone.w ? zone.x + zone.w : other.x + other.w, from: opening.from, to: opening.to });
        }
      }
    });
  });
  return doorways;
}

function segmentCrossesWall(from, to, wall) {
  if (wall.axis === 'h') {
    if (from.y === to.y) return false;
    if ((from.y - wall.at) * (to.y - wall.at) > 0) return false;
    const t = (wall.at - from.y) / (to.y - from.y);
    if (t < 0 || t > 1) return false;
    const x = from.x + (t * (to.x - from.x));
    return x > wall.from && x < wall.to;
  }
  if (from.x === to.x) return false;
  if ((from.x - wall.at) * (to.x - wall.at) > 0) return false;
  const t = (wall.at - from.x) / (to.x - from.x);
  if (t < 0 || t > 1) return false;
  const y = from.y + (t * (to.y - from.y));
  return y > wall.from && y < wall.to;
}

function computeZoneWalls(zones, doors = []) {
  const walls = [];
  const doorways = doors.length ? doors : computeZoneDoorways(zones);
  (zones || []).forEach((zone) => {
    [zone.y, zone.y + zone.h].forEach((atY) => {
      const doorway = doorways.find((entry) => entry.axis === 'h' && entry.at === atY && entry.from < zone.x + zone.w && entry.to > zone.x);
      if (!doorway) walls.push({ axis: 'h', at: atY, from: zone.x, to: zone.x + zone.w });
      else {
        if (zone.x < doorway.from) walls.push({ axis: 'h', at: atY, from: zone.x, to: doorway.from });
        if (doorway.to < zone.x + zone.w) walls.push({ axis: 'h', at: atY, from: doorway.to, to: zone.x + zone.w });
      }
    });
    [zone.x, zone.x + zone.w].forEach((atX) => {
      const doorway = doorways.find((entry) => entry.axis === 'v' && entry.at === atX && entry.from < zone.y + zone.h && entry.to > zone.y);
      if (!doorway) walls.push({ axis: 'v', at: atX, from: zone.y, to: zone.y + zone.h });
      else {
        if (zone.y < doorway.from) walls.push({ axis: 'v', at: atX, from: zone.y, to: doorway.from });
        if (doorway.to < zone.y + zone.h) walls.push({ axis: 'v', at: atX, from: doorway.to, to: zone.y + zone.h });
      }
    });
  });
  return walls;
}

function wallBetween(layout = {}, from, to) {
  const walls = computeZoneWalls(layout.zones || [], layout.doors || []);
  return walls.find((wall) => segmentCrossesWall(from, to, wall)) || null;
}

function pointToCell(point) {
  return {
    x: Math.max(0, Math.round(Number(point?.x || 0) - 0.5)),
    y: Math.max(0, Math.round(Number(point?.y || 0) - 0.5)),
  };
}

function cellToPoint(cell) {
  return { x: Number(cell.x) + 0.5, y: Number(cell.y) + 0.5 };
}

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

function pointBlocked(layout = {}, point) {
  if (!pointInAnyZone(layout.zones || [], point)) return true;
  return (layout.blockers || []).some((item) => pointInRect(point, item));
}

function movementBlockedBetween(layout = {}, from, to) {
  if (pointBlocked(layout, to)) return true;
  if ((layout.blockers || []).some((item) => pointInRect(to, item))) return true;
  if (wallBetween(layout, from, to)) return true;
  return false;
}

function pointTerrainCost(layout = {}, point) {
  const hazard = (layout.hazards || []).find((item) => pointInRect(point, item));
  if (!hazard) return 1;
  if (hazard.kind === 'no-charge') return 3;
  if (hazard.kind === 'difficult' || hazard.kind === 'agility' || hazard.kind === 'vent') return 2;
  return 1;
}

function buildOccupiedSet(occupied = [], allow = []) {
  const allowed = new Set((allow || []).map((point) => cellKey(pointToCell(point))));
  return new Set(
    (occupied || [])
      .map((point) => pointToCell(point))
      .map(cellKey)
      .filter((key) => !allowed.has(key))
  );
}

function targetGoalCells(targetPoint, stopRange, bounds, layout, occupiedSet) {
  const targetCell = pointToCell(targetPoint);
  const goals = [];
  const radius = Math.max(0, Number(stopRange || 0));
  for (let dx = -radius; dx <= radius; dx += 1) {
    for (let dy = -radius; dy <= radius; dy += 1) {
      const chebyshev = Math.max(Math.abs(dx), Math.abs(dy));
      if (radius === 0 && (dx !== 0 || dy !== 0)) continue;
      if (radius > 0 && (chebyshev === 0 || chebyshev > radius)) continue;
      const cell = { x: targetCell.x + dx, y: targetCell.y + dy };
      if (cell.x < 0 || cell.y < 0 || cell.x >= bounds.width || cell.y >= bounds.height) continue;
      const point = cellToPoint(cell);
      if (pointBlocked(layout, point)) continue;
      if (occupiedSet.has(cellKey(cell))) continue;
      goals.push(cell);
    }
  }
  return goals.length ? goals : (radius === 0 ? [targetCell] : []);
}

function moveWithinRange(from, to, maxDistance, bounds = { width: GRID_WIDTH, height: GRID_HEIGHT }, options = {}) {
  const layout = options.layout || {};
  const startCell = pointToCell(from);
  const occupiedSet = buildOccupiedSet(options.occupied || [], [from, ...(options.allowOccupied || [])]);
  const goals = targetGoalCells(to, options.stopRange || 0, bounds, layout, occupiedSet);
  if (!goals.length) return { x: from.x, y: from.y, travelled: 0 };

  const goalKeys = new Set(goals.map(cellKey));
  const visited = new Map([[cellKey(startCell), { cost: 0, prev: null }]]);
  const queue = [{ cell: startCell, cost: 0 }];
  let bestKey = cellKey(startCell);
  let bestScore = Infinity;

  while (queue.length) {
    queue.sort((a, b) => a.cost - b.cost);
    const current = queue.shift();
    const currentKey = cellKey(current.cell);
    const currentInfo = visited.get(currentKey);
    if (!currentInfo || currentInfo.cost !== current.cost) continue;

    const distanceToGoal = Math.min(...goals.map((goal) => Math.max(Math.abs(goal.x - current.cell.x), Math.abs(goal.y - current.cell.y))));
    const score = distanceToGoal * 100 + current.cost;
    if (score < bestScore) {
      bestScore = score;
      bestKey = currentKey;
    }
    if (goalKeys.has(currentKey)) {
      bestKey = currentKey;
      break;
    }

    const directions = [
      [-1, -1], [0, -1], [1, -1],
      [-1, 0],            [1, 0],
      [-1, 1],  [0, 1],   [1, 1],
    ];
    for (const [dx, dy] of directions) {
      const nextCell = { x: current.cell.x + dx, y: current.cell.y + dy };
      if (nextCell.x < 0 || nextCell.y < 0 || nextCell.x >= bounds.width || nextCell.y >= bounds.height) continue;
      const nextKey = cellKey(nextCell);
      if (occupiedSet.has(nextKey)) continue;
      const nextPoint = cellToPoint(nextCell);
      if (movementBlockedBetween(layout, cellToPoint(current.cell), nextPoint)) continue;
      const nextCost = current.cost + pointTerrainCost(layout, nextPoint);
      if (nextCost > maxDistance) continue;
      const previous = visited.get(nextKey);
      if (!previous || nextCost < previous.cost) {
        visited.set(nextKey, { cost: nextCost, prev: currentKey });
        queue.push({ cell: nextCell, cost: nextCost });
      }
    }
  }

  const [x, y] = bestKey.split(',').map(Number);
  const bestPoint = cellToPoint({ x, y });
  return { x: bestPoint.x, y: bestPoint.y, travelled: visited.get(bestKey)?.cost || 0 };
}

function stepToward(from, to, maxDistance, bounds = { width: GRID_WIDTH, height: GRID_HEIGHT }, options = {}) {
  return moveWithinRange(from, to, maxDistance, bounds, options);
}

function enemyMoveTowardNearest(enemyToken, playerTokens, maxDistance, bounds = { width: GRID_WIDTH, height: GRID_HEIGHT }, options = {}) {
  const alivePlayers = Array.isArray(playerTokens) ? playerTokens : [];
  if (!enemyToken || !alivePlayers.length) return null;
  const target = alivePlayers
    .map(token => ({ token, dist: distance(enemyToken, token) }))
    .sort((a, b) => a.dist - b.dist)[0]?.token;
  if (!target) return null;
  const next = stepToward(enemyToken, target, maxDistance, bounds, {
    ...options,
    stopRange: Math.max(1, Number(options.stopRange || 1)),
  });
  return { target, ...next };
}

function sceneBattlemapTheme(scene = {}) {
  const title = String(scene.title || scene.name || '').toLowerCase();
  const checks = Array.isArray(scene.checks) ? scene.checks.map(check => String(check.name || '').toLowerCase()).join(' ') : '';
  const combined = `${title} ${checks}`;
  if (/landing|dock|breach|hangar/.test(combined)) {
    return {
      layoutKey: 'landing',
      boardTitle: 'Landing Zone Breach',
      boardSummary: 'Landing pad, gantries, cargo lanes, and breach routes tuned to the current mission scene.',
    };
  }
  if (/vault|gene|lab|reactor|core/.test(combined)) {
    return {
      layoutKey: 'vault',
      boardTitle: 'Vault Control Fight',
      boardSummary: 'Objective rooms, crossfire lanes, and extraction pressure for the current mission scene.',
    };
  }
  if (/shrine|temple|cathedral|sanctum/.test(combined)) {
    return {
      layoutKey: 'sanctum',
      boardTitle: 'Sanctum Assault',
      boardSummary: 'Central shrine lanes, elevated firing points, and flanking routes fitted to this scene.',
    };
  }
  if (/corridor|tunnel|passage|service/.test(combined)) {
    return {
      layoutKey: 'corridor',
      boardTitle: 'Corridor Kill Zone',
      boardSummary: 'Long fire lanes, choke points, and service routes fitted to this scene.',
    };
  }
  return {
    layoutKey: 'vault',
    boardTitle: String(scene.title || scene.name || 'Battlemap'),
    boardSummary: 'Dynamic tactical board generated from the active mission scene.',
  };
}

const GRID_WIDTH = 40;
const GRID_HEIGHT = 30;

// Kept in sync with src/shared/battlemapEngine.js's DEFAULT_ZONES/DEFAULT_*
// (duplicated here, not imported, because importing that CommonJS module
// from this ES module component breaks webpack's CJS/ESM interop — see the
// comment above the moveWithinRange/etc. duplicates further up this file).
// Every room touches the corridor along one full edge with zero rectangle
// overlap anywhere, so the floor plan is fully connected and every boundary
// is a real wall — see validateBattlemapLayout/computeZoneWalls in the
// shared engine for the rules this must keep satisfying.
const ZONES = [
  { id: 'extraction', name: 'Extraction Zone', x: 1, y: 11, w: 7, h: 8, tone: 'spawn', elevation: 0, lighting: 'bright', purpose: 'Start and extract here.', cover: 'Light cover from crates, barricades, and wreckage.' },
  { id: 'main-corridor', name: 'Main Kill-Corridor', x: 8, y: 13, w: 24, h: 4, tone: 'corridor', elevation: 0, lighting: 'normal', purpose: 'Long firing lane for bolters, heavy weapons, overwatch, and suppression.', cover: 'Alternating half-cover every 5-6 squares.' },
  { id: 'left-wing', name: 'Left Stabilization Wing', x: 12, y: 3, w: 10, h: 10, tone: 'objective', elevation: 1, lighting: 'bright', purpose: 'Tech-Use or Medicae station under heavy pressure.', cover: 'Heavy cover from cogitator banks and machinery.' },
  { id: 'right-wing', name: 'Right Stabilization Wing', x: 25, y: 3, w: 10, h: 10, tone: 'objective', elevation: 1, lighting: 'dim', purpose: 'Flank route objective room with mixed fire angles.', cover: 'Mixed light and heavy cover.' },
  { id: 'maintenance', name: 'Lower Maintenance Wing', x: 8, y: 17, w: 15, h: 8, tone: 'hazard', elevation: -1, lighting: 'dark', purpose: 'Ambush route and third stabilization station.', cover: 'Pipes, tanks, and broken wall sections.' },
  { id: 'vault', name: 'Central Gene-Vault', x: 23, y: 17, w: 12, h: 8, tone: 'vault', elevation: 0, lighting: 'dim', purpose: 'Secure the gene-core for three rounds.', cover: 'Heavy central containment pillars and shattered equipment.' },
];

const STATIONS = [
  { id: 'left-station', name: 'Alpha Stabilization', zoneId: 'left-wing', x: 17, y: 7, action: 'Full Action / Tech-Use / Medicae', note: 'Heavy machinery blocks easy charges.' },
  { id: 'right-station', name: 'Beta Stabilization', zoneId: 'right-wing', x: 30, y: 7, action: 'Full Action / Tech-Use / Medicae', note: 'Side access makes this wing harder to lock down.' },
  { id: 'maintenance-station', name: 'Gamma Stabilization', zoneId: 'maintenance', x: 20, y: 22, action: 'Full Action / Tech-Use / Medicae', note: 'Maintenance sludge and pipework slow support.' },
];

const SPAWN_POINTS = [
  { id: 'north-hatch', name: 'North maintenance hatch', x: 19, y: 1, lane: 'Melee swarm pressure into the left wing.' },
  { id: 'east-corridor', name: 'East corrupted corridor', x: 38, y: 12, lane: 'Ranged traitors push the right side and corridor.' },
  { id: 'south-tunnel', name: 'South service tunnel', x: 21, y: 29, lane: 'Ambush route through maintenance.' },
  { id: 'vault-breach', name: 'Vault breach point', x: 35, y: 20, lane: 'Elite contest at the core chamber.' },
];

const COVER_OBJECTS = [
  { id: 'ext-crates-1', kind: 'light', material: 'Light Metal', elevation: 0, x: 3, y: 12, w: 2, h: 2, label: 'Crates' },
  { id: 'ext-wreck-1', kind: 'light', material: 'Light Metal', elevation: 0, x: 5, y: 16, w: 2, h: 2, label: 'Wreckage' },
  { id: 'corr-cover-1', kind: 'light', material: 'Heavy Wood', elevation: 0, x: 11, y: 13, w: 1, h: 4, label: 'Bulkhead' },
  { id: 'corr-cover-2', kind: 'light', material: 'Heavy Wood', elevation: 0, x: 17, y: 13, w: 1, h: 4, label: 'Bulkhead' },
  { id: 'corr-cover-3', kind: 'light', material: 'Heavy Wood', elevation: 0, x: 23, y: 13, w: 1, h: 4, label: 'Bulkhead' },
  { id: 'corr-cover-4', kind: 'light', material: 'Heavy Wood', elevation: 0, x: 29, y: 13, w: 1, h: 4, label: 'Bulkhead' },
  { id: 'left-cog-1', kind: 'heavy', material: 'Plasteel', elevation: 1, x: 13, y: 4, w: 3, h: 2, label: 'Cogitators' },
  { id: 'left-cog-2', kind: 'heavy', material: 'Plasteel', elevation: 1, x: 18, y: 9, w: 3, h: 2, label: 'Machinery' },
  { id: 'right-cog-1', kind: 'heavy', material: 'Plasteel', elevation: 1, x: 26, y: 4, w: 3, h: 2, label: 'Cogitators' },
  { id: 'right-cog-2', kind: 'light', material: 'Armour-glas', elevation: 1, x: 31, y: 9, w: 2, h: 2, label: 'Consoles' },
  { id: 'maint-pipe-1', kind: 'light', material: 'Light Metal', elevation: -1, x: 11, y: 18, w: 3, h: 1, label: 'Pipes' },
  { id: 'maint-tank-1', kind: 'heavy', material: 'Thick Iron', elevation: -1, x: 19, y: 21, w: 2, h: 3, label: 'Tank' },
  { id: 'vault-pillar-1', kind: 'heavy', material: 'Rockcrete', elevation: 0, x: 26, y: 19, w: 2, h: 2, label: 'Pillar' },
  { id: 'vault-pillar-2', kind: 'heavy', material: 'Rockcrete', elevation: 0, x: 32, y: 19, w: 2, h: 2, label: 'Pillar' },
  { id: 'vault-pillar-3', kind: 'heavy', material: 'Rockcrete', elevation: 0, x: 26, y: 22, w: 2, h: 2, label: 'Pillar' },
  { id: 'vault-pillar-4', kind: 'heavy', material: 'Rockcrete', elevation: 0, x: 32, y: 22, w: 2, h: 2, label: 'Pillar' },
  { id: 'vault-shatter-1', kind: 'light', material: 'Light Metal', elevation: 0, x: 29, y: 20, w: 2, h: 1, label: 'Shattered gear' },
];

const BLOCKERS = [
  { id: 'blast-door-west', material: 'Plasteel', x: 16, y: 5, w: 2, h: 1, label: 'Blast Door W' },
  { id: 'blast-door-east', material: 'Plasteel', x: 29, y: 5, w: 2, h: 1, label: 'Blast Door E' },
  { id: 'cylinder-1', material: 'Thick Iron', x: 33, y: 19, w: 2, h: 4, label: 'Containment' },
  { id: 'collapsed-slab', material: 'Rockcrete', x: 11, y: 20, w: 4, h: 2, label: 'Collapsed slab' },
];

const HAZARDS = [
  { id: 'sludge', kind: 'difficult', x: 11, y: 21, w: 5, h: 2, label: 'Toxic sludge' },
  { id: 'decking', kind: 'agility', x: 12, y: 20, w: 4, h: 2, label: 'Collapsed decking' },
  { id: 'corpses', kind: 'no-charge', x: 24, y: 22, w: 4, h: 2, label: 'Corpse pile' },
  { id: 'vent', kind: 'vent', x: 29, y: 22, w: 2, h: 2, label: 'Toxic vent' },
];

const DYNAMIC_FEATURES = [
  { id: 'doors', name: 'Blast doors', rule: 'GM can open, seal, or lock them to change line of sight and movement.' },
  { id: 'vents', name: 'Toxic vents', rule: 'Temporary hazardous zones that deny easy movement.' },
  { id: 'alarm', name: 'Alarm lights', rule: 'Low visibility sectors can apply ranged penalties.' },
  { id: 'turrets', name: 'Automated turrets', rule: 'Hack, destroy, or use cover to avoid them.' },
  { id: 'walkways', name: 'Collapsing walkways', rule: 'Force Agility tests and open or close routes mid-fight.' },
];

const MOVEMENT_NOTES = {
  corridorWidths: [
    'Main corridor is 3-4 squares wide for Space Marines, enemies, and crossfire.',
    'Side lanes pinch to 2 squares to create chokepoints.',
    'Maintenance approach drops to 1-2 squares for ambush pressure.',
  ],
  chargeLanes: [
    'Main kill-corridor gives a clean 8-12 square lane into the vault approach.',
    'Each stabilization room has one straight approach lane, partially broken by cover.',
  ],
  difficultTerrain: [
    'Toxic sludge doubles movement cost.',
    'Collapsed decking can call for Agility or stalled movement.',
    'Corpse piles and cable nests block running and charging.',
  ],
  verticality: [
    'Raised gantry gives 3m high ground and partial cover onto the vault approach.',
    'Broken stairwell creates an alternate Assault Marine route.',
  ],
};

const ENEMY_ROLES = [
  'Melee swarm enemies pressure movement lanes and tie up the objective actor.',
  'Ranged cultists or traitor guardsmen exploit corridor cover.',
  'Elite corrupted marine or Nurgle lieutenant contests the vault.',
  'Objective saboteurs try to undo stabilization progress.',
];

function layoutPresetForKey(key = 'vault') {
  if (key === 'landing') {
    return {
      zones: [
        { id: 'extraction', name: 'Dropship LZ', x: 1, y: 18, w: 8, h: 8, tone: 'spawn', elevation: 0, lighting: 'bright' },
        { id: 'main-corridor', name: 'Cargo Tarmac', x: 8, y: 18, w: 20, h: 5, tone: 'corridor', elevation: 0, lighting: 'bright' },
        { id: 'left-wing', name: 'West Gantry', x: 10, y: 4, w: 10, h: 8, tone: 'objective', elevation: 1, lighting: 'normal' },
        { id: 'right-wing', name: 'East Gantry', x: 24, y: 3, w: 10, h: 7, tone: 'objective', elevation: 1, lighting: 'normal' },
        { id: 'maintenance', name: 'Fuel Line', x: 28, y: 19, w: 8, h: 8, tone: 'hazard', elevation: 0, lighting: 'dim' },
        { id: 'vault', name: 'Breach Crater', x: 20, y: 11, w: 10, h: 6, tone: 'vault', elevation: 0, lighting: 'dark' },
      ],
      stations: [
        { id: 'left-station', name: 'West Vox Relay', zoneId: 'left-wing', x: 15, y: 8 },
        { id: 'right-station', name: 'East Loader Controls', zoneId: 'right-wing', x: 29, y: 8 },
        { id: 'maintenance-station', name: 'Fuel Cutoff', zoneId: 'maintenance', x: 32, y: 23 },
      ],
      spawnPoints: [
        { id: 'north-hatch', name: 'North maglift', x: 19, y: 1 },
        { id: 'east-corridor', name: 'East dock tunnel', x: 38, y: 20 },
        { id: 'south-tunnel', name: 'South rubble path', x: 14, y: 29 },
        { id: 'vault-breach', name: 'Central breach', x: 25, y: 14 },
      ],
      cover: [
        { id: 'lz-crates', kind: 'light', material: 'Light Metal', x: 3, y: 20, w: 2, h: 2, label: 'Crates' },
        { id: 'lz-wreck', kind: 'heavy', material: 'Rockcrete', x: 6, y: 23, w: 2, h: 2, label: 'Wreckage' },
        { id: 'cargo-1', kind: 'light', material: 'Heavy Wood', x: 12, y: 18, w: 1, h: 5, label: 'Cargo stack' },
        { id: 'cargo-2', kind: 'light', material: 'Heavy Wood', x: 18, y: 18, w: 1, h: 5, label: 'Cargo stack' },
        { id: 'cargo-3', kind: 'light', material: 'Heavy Wood', x: 24, y: 18, w: 1, h: 5, label: 'Cargo stack' },
        { id: 'west-gunrail', kind: 'heavy', material: 'Plasteel', x: 12, y: 5, w: 3, h: 2, label: 'Gunrail' },
        { id: 'east-gunrail', kind: 'heavy', material: 'Plasteel', x: 26, y: 5, w: 3, h: 2, label: 'Gunrail' },
        { id: 'breach-wreck', kind: 'heavy', material: 'Rockcrete', x: 22, y: 12, w: 2, h: 2, label: 'Breach wreck' },
        { id: 'fuel-pipe', kind: 'heavy', material: 'Thick Iron', x: 30, y: 21, w: 2, h: 3, label: 'Fuel pipe' },
      ],
      blockers: [
        { id: 'shuttle-tail', material: 'Plasteel', x: 8, y: 23, w: 3, h: 2, label: 'Shuttle tail' },
        { id: 'collapsed-bus', material: 'Rockcrete', x: 20, y: 16, w: 4, h: 2, label: 'Collapsed bus' },
      ],
      hazards: [
        { id: 'burning-fuel', kind: 'difficult', x: 28, y: 24, w: 4, h: 2, label: 'Burning fuel' },
        { id: 'open-breach', kind: 'agility', x: 23, y: 13, w: 3, h: 2, label: 'Open breach' },
      ],
    };
  }
  if (key === 'corridor') {
    return {
      zones: [
        { id: 'extraction', name: 'Rear Bulkhead', x: 1, y: 12, w: 6, h: 6, tone: 'spawn', elevation: 0, lighting: 'bright' },
        { id: 'main-corridor', name: 'Main Corridor', x: 7, y: 12, w: 26, h: 6, tone: 'corridor', elevation: 0, lighting: 'normal' },
        { id: 'left-wing', name: 'West Junction', x: 14, y: 3, w: 8, h: 7, tone: 'objective', elevation: 0, lighting: 'normal' },
        { id: 'right-wing', name: 'East Junction', x: 25, y: 20, w: 8, h: 7, tone: 'objective', elevation: 0, lighting: 'dim' },
        { id: 'maintenance', name: 'Service Ducts', x: 33, y: 11, w: 5, h: 8, tone: 'hazard', elevation: 0, lighting: 'dark' },
        { id: 'vault', name: 'Lockdown Node', x: 22, y: 12, w: 8, h: 6, tone: 'vault', elevation: 0, lighting: 'dark' },
      ],
      stations: [
        { id: 'left-station', name: 'Junction Console', zoneId: 'left-wing', x: 18, y: 6 },
        { id: 'right-station', name: 'Blast Door Relay', zoneId: 'right-wing', x: 29, y: 24 },
        { id: 'maintenance-station', name: 'Vent Purge Controls', zoneId: 'maintenance', x: 35, y: 15 },
      ],
      spawnPoints: [
        { id: 'north-hatch', name: 'Ceiling hatch', x: 18, y: 1 },
        { id: 'east-corridor', name: 'Forward seal', x: 38, y: 15 },
        { id: 'south-tunnel', name: 'Lower conduit', x: 28, y: 29 },
        { id: 'vault-breach', name: 'Lockdown iris', x: 26, y: 15 },
      ],
      cover: [
        { id: 'rear-crates', kind: 'light', material: 'Light Metal', x: 3, y: 13, w: 2, h: 2, label: 'Crates' },
        { id: 'corridor-1', kind: 'light', material: 'Heavy Wood', x: 11, y: 12, w: 1, h: 6, label: 'Bulkhead' },
        { id: 'corridor-2', kind: 'light', material: 'Heavy Wood', x: 17, y: 12, w: 1, h: 6, label: 'Bulkhead' },
        { id: 'corridor-3', kind: 'light', material: 'Heavy Wood', x: 31, y: 12, w: 1, h: 6, label: 'Bulkhead' },
        { id: 'west-junction', kind: 'heavy', material: 'Plasteel', x: 15, y: 4, w: 2, h: 2, label: 'Junction cover' },
        { id: 'east-junction', kind: 'heavy', material: 'Plasteel', x: 27, y: 22, w: 2, h: 2, label: 'Junction cover' },
        { id: 'node-cover', kind: 'heavy', material: 'Rockcrete', x: 24, y: 13, w: 2, h: 2, label: 'Node cover' },
      ],
      blockers: [
        { id: 'sealed-door-west', material: 'Plasteel', x: 13, y: 10, w: 2, h: 2, label: 'Sealed door' },
        { id: 'sealed-door-east', material: 'Plasteel', x: 25, y: 18, w: 2, h: 2, label: 'Sealed door' },
      ],
      hazards: [
        { id: 'steam', kind: 'vent', x: 34, y: 13, w: 2, h: 2, label: 'Steam vent' },
        { id: 'cables', kind: 'difficult', x: 22, y: 17, w: 3, h: 2, label: 'Cable snare' },
      ],
    };
  }
  if (key === 'sanctum') {
    return {
      zones: [
        { id: 'extraction', name: 'Nave Entry', x: 1, y: 12, w: 7, h: 7, tone: 'spawn', elevation: 0, lighting: 'normal' },
        { id: 'main-corridor', name: 'Central Nave', x: 8, y: 12, w: 24, h: 6, tone: 'corridor', elevation: 0, lighting: 'dim' },
        { id: 'left-wing', name: 'Choir Gallery', x: 11, y: 3, w: 8, h: 7, tone: 'objective', elevation: 1, lighting: 'bright' },
        { id: 'right-wing', name: 'Reliquary', x: 23, y: 3, w: 8, h: 7, tone: 'objective', elevation: 1, lighting: 'dim' },
        { id: 'maintenance', name: 'Crypt Passage', x: 16, y: 22, w: 10, h: 6, tone: 'hazard', elevation: -1, lighting: 'dark' },
        { id: 'vault', name: 'Sanctum Dais', x: 21, y: 11, w: 10, h: 8, tone: 'vault', elevation: 1, lighting: 'glow' },
      ],
      stations: [
        { id: 'left-station', name: 'Choir Vox Pulpit', zoneId: 'left-wing', x: 15, y: 6 },
        { id: 'right-station', name: 'Reliquary Seal', zoneId: 'right-wing', x: 27, y: 6 },
        { id: 'maintenance-station', name: 'Crypt Winch', zoneId: 'maintenance', x: 21, y: 25 },
      ],
      spawnPoints: [
        { id: 'north-hatch', name: 'Rose window breach', x: 21, y: 1 },
        { id: 'east-corridor', name: 'Reliquary side door', x: 38, y: 11 },
        { id: 'south-tunnel', name: 'Crypt stair', x: 21, y: 29 },
        { id: 'vault-breach', name: 'Dais warp-rift', x: 27, y: 14 },
      ],
      cover: [
        { id: 'nave-statue-1', kind: 'heavy', material: 'Stone', x: 12, y: 13, w: 2, h: 2, label: 'Statue' },
        { id: 'nave-statue-2', kind: 'heavy', material: 'Stone', x: 18, y: 13, w: 2, h: 2, label: 'Statue' },
        { id: 'nave-statue-3', kind: 'heavy', material: 'Stone', x: 24, y: 13, w: 2, h: 2, label: 'Statue' },
        { id: 'nave-statue-4', kind: 'heavy', material: 'Stone', x: 30, y: 13, w: 2, h: 2, label: 'Statue' },
        { id: 'choir-rail', kind: 'light', material: 'Heavy Wood', x: 13, y: 7, w: 4, h: 1, label: 'Rail' },
        { id: 'reliquary-screen', kind: 'light', material: 'Armour-glas', x: 25, y: 7, w: 4, h: 1, label: 'Screen' },
        { id: 'dais-pillar-1', kind: 'heavy', material: 'Rockcrete', x: 23, y: 12, w: 2, h: 2, label: 'Pillar' },
        { id: 'dais-pillar-2', kind: 'heavy', material: 'Rockcrete', x: 27, y: 16, w: 2, h: 2, label: 'Pillar' },
      ],
      blockers: [
        { id: 'fallen-bell', material: 'Thick Iron', x: 19, y: 20, w: 3, h: 2, label: 'Fallen bell' },
      ],
      hazards: [
        { id: 'warp-flame', kind: 'vent', x: 25, y: 14, w: 2, h: 2, label: 'Warp flame' },
        { id: 'broken-tiles', kind: 'agility', x: 17, y: 23, w: 4, h: 2, label: 'Broken tiles' },
      ],
    };
  }
  return {
    zones: ZONES,
    stations: STATIONS,
    spawnPoints: SPAWN_POINTS,
    cover: COVER_OBJECTS,
    blockers: BLOCKERS,
    hazards: HAZARDS,
  };
}

function titleForScene(scene, index) {
  return scene?.title || scene?.name || `Scene ${index + 1}`;
}

function sceneEnemyObjects(scene) {
  if (!Array.isArray(scene?.enemies)) return [];
  return scene.enemies
    .map(enemy => (typeof enemy === 'string' ? { name: enemy } : enemy))
    .filter(enemy => enemy?.name || enemy?.type)
    .map((enemy, index) => ({ ...enemy, name: enemy.name || enemy.type || `Enemy ${index + 1}` }));
}

function playerEntriesForScene(scene, authedPlayer) {
  const initiatives = Array.isArray(scene?.combatState?.initiatives) ? scene.combatState.initiatives : [];
  const names = initiatives
    .filter(entry => String(entry?.type || 'player').toLowerCase() !== 'npc')
    .map(entry => String(entry.player || entry.name || '').trim())
    .filter(Boolean);
  if (!names.length && authedPlayer && authedPlayer !== 'gm') return [authedPlayer];
  return names.filter((name, index, list) => list.indexOf(name) === index);
}

function currentTurnName(scene) {
  const turnKey = scene?.combatState?.turnKey || '';
  const initiatives = Array.isArray(scene?.combatState?.initiatives) ? scene.combatState.initiatives : [];
  const current = initiatives.find((entry) => {
    const name = String(entry.player || entry.name || '').trim().toLowerCase();
    const type = String(entry.type || 'player').toLowerCase();
    const key = type === 'npc'
      ? `npc:${name}:${entry.ts ?? entry.createdAt ?? entry.created_at ?? entry.dieRoll ?? entry.total ?? ''}`
      : `player:${name}`;
    return key === turnKey;
  });
  return String(current?.player || current?.name || '').trim();
}

function defaultObjectiveState(existing) {
  const prior = existing && typeof existing === 'object' ? existing : {};
  return {
    stations: STATIONS.map((station) => ({
      id: station.id,
      active: Boolean(prior.stations?.find(item => item.id === station.id)?.active),
    })),
    geneCoreSecuredRounds: Math.max(0, Number(prior.geneCoreSecuredRounds || 0) || 0),
    extractionProgress: Math.max(0, Number(prior.extractionProgress || 0) || 0),
    carrier: String(prior.carrier || ''),
    activeSpawnId: prior.activeSpawnId || SPAWN_POINTS[0].id,
  };
}

function normalizeToken(token, index) {
  return {
    id: token.id || `token-${index + 1}`,
    name: token.name || `Token ${index + 1}`,
    side: token.side === 'enemy' ? 'enemy' : 'player',
    x: Number.isFinite(Number(token.x)) ? Number(token.x) : 1.5,
    y: Number.isFinite(Number(token.y)) ? Number(token.y) : 1.5,
    hp: token.hp ?? null,
    maxHp: token.maxHp ?? null,
    color: token.color || (token.side === 'enemy' ? '#ef4444' : '#38bdf8'),
  };
}

function normalizeTokens(tokens) {
  return Array.isArray(tokens) ? tokens.map(normalizeToken) : [];
}

function zoneById(layout, id) {
  return (layout?.zones || ZONES).find(zone => zone.id === id) || null;
}

function tokenAnchorPositions(players, enemies, layout = null) {
  const extraction = zoneById(layout, 'extraction');
  const vault = zoneById(layout, 'vault');
  const maintenance = zoneById(layout, 'maintenance');
  const rightWing = zoneById(layout, 'right-wing');
  // A couple of the default anchors are placed on top of COVER_OBJECTS
  // (crates/wreckage/pillars/tank) so cover bonuses actually get exercised
  // by default, instead of every token always starting in the open.
  const playerAnchors = [
    [extraction.x + 3, extraction.y + 2], // on ext-crates-1
    [extraction.x + 4.5, extraction.y + 1.5],
    [extraction.x + 1.5, extraction.y + 4.5],
    [extraction.x + 6, extraction.y + 6], // on ext-wreck-1
    [extraction.x + 6.5, extraction.y + 3.5],
  ];
  const enemyAnchors = [
    [vault.x + 8.5, vault.y + 5.5],
    [rightWing.x + 6.5, rightWing.y + 2.5],
    [maintenance.x + 8.5, maintenance.y + 2.5],
    [vault.x + 3.5, vault.y + 6], // on vault-pillar-3
    [maintenance.x + 2.5, maintenance.y + 1.5], // on maint-pipe-1
  ];
  return {
    players: players.map((_, index) => playerAnchors[index % playerAnchors.length]),
    enemies: enemies.map((_, index) => enemyAnchors[index % enemyAnchors.length]),
  };
}

function buildTokensForScene(scene, authedPlayer, existingTokens = []) {
  const previousByName = new Map(existingTokens.map(token => [String(token.name).toLowerCase(), token]));
  const players = playerEntriesForScene(scene, authedPlayer);
  const enemies = sceneEnemyObjects(scene);
  const layout = createLayoutSpec(scene);
  const anchors = tokenAnchorPositions(players, enemies, layout);

  const nextTokens = players.map((name, index) => {
    const previous = previousByName.get(name.toLowerCase());
    const [x, y] = anchors.players[index];
    return normalizeToken({
      id: `player:${name.toLowerCase()}`,
      name,
      side: 'player',
      x: previous ? previous.x : x,
      y: previous ? previous.y : y,
      color: '#38bdf8',
    }, index);
  });

  enemies.forEach((enemy, index) => {
    const previous = previousByName.get(enemy.name.toLowerCase());
    const [x, y] = anchors.enemies[index];
    nextTokens.push(normalizeToken({
      id: `enemy:${enemy.name.toLowerCase()}:${index + 1}`,
      name: enemy.name,
      side: 'enemy',
      x: previous ? previous.x : x,
      y: previous ? previous.y : y,
      hp: enemy.wounds ?? enemy.magnitude ?? null,
      maxHp: enemy.wounds ?? enemy.magnitude ?? null,
      color: '#ef4444',
    }, players.length + index));
  });

  return nextTokens;
}

function createLayoutSpec(scene) {
  const theme = sceneBattlemapTheme(scene);
  const preset = layoutPresetForKey(theme.layoutKey);
  return {
    title: theme.boardTitle,
    summary: theme.boardSummary,
    gridWidth: GRID_WIDTH,
    gridHeight: GRID_HEIGHT,
    zones: preset.zones,
    stations: preset.stations,
    spawnPoints: preset.spawnPoints,
    cover: preset.cover,
    blockers: preset.blockers,
    hazards: preset.hazards,
    dynamicFeatures: DYNAMIC_FEATURES,
    viewBox: `0 0 ${GRID_WIDTH} ${GRID_HEIGHT}`,
  };
}

function normalizeLayoutSpec(scene, existingLayout) {
  const base = createLayoutSpec(scene);
  const incoming = existingLayout && typeof existingLayout === 'object' ? existingLayout : {};
  return {
    ...base,
    ...incoming,
    gridWidth: Number(incoming.gridWidth || base.gridWidth) || base.gridWidth,
    gridHeight: Number(incoming.gridHeight || base.gridHeight) || base.gridHeight,
    title: incoming.title || base.title,
    summary: incoming.summary || base.summary,
    zones: Array.isArray(incoming.zones) && incoming.zones.length ? incoming.zones : base.zones,
    stations: Array.isArray(incoming.stations) && incoming.stations.length ? incoming.stations : base.stations,
    spawnPoints: Array.isArray(incoming.spawnPoints) && incoming.spawnPoints.length ? incoming.spawnPoints : base.spawnPoints,
    cover: Array.isArray(incoming.cover) ? incoming.cover : base.cover,
    blockers: Array.isArray(incoming.blockers) ? incoming.blockers : base.blockers,
    hazards: Array.isArray(incoming.hazards) ? incoming.hazards : base.hazards,
    dynamicFeatures: Array.isArray(incoming.dynamicFeatures) ? incoming.dynamicFeatures : base.dynamicFeatures,
    viewBox: incoming.viewBox || base.viewBox,
  };
}

function buildBattlemapState(scene, authedPlayer, existingState) {
  const prior = existingState && typeof existingState === 'object' ? existingState : {};
  const turnKey = scene?.combatState?.turnKey || '';
  return {
    layout: normalizeLayoutSpec(scene, prior.layout),
    tokens: buildTokensForScene(scene, authedPlayer, normalizeTokens(prior.tokens)),
    objectives: defaultObjectiveState(prior.objectives),
    turnState: normalizeTurnState(prior.turnState, turnKey),
    updatedAt: Date.now(),
  };
}

function initialBoardFromMission(mission, authedPlayer) {
  const sceneIndex = mission?.active_scene_index ?? mission?.current_scene ?? 0;
  const scene = Array.isArray(mission?.scenes) ? mission.scenes[sceneIndex] || mission.scenes[0] || null : null;
  if (!scene) return null;
  const visible = battlemapVisibleForPlayer(scene, authedPlayer);
  return {
    missionId: mission.id,
    missionName: mission.name || 'Active mission',
    sceneIndex,
    sceneTitle: titleForScene(scene, sceneIndex),
    scene,
    battlemapVisible: visible,
    battlemapState: visible || authedPlayer === 'gm' ? buildBattlemapState(scene, authedPlayer, scene.battlemapState) : null,
  };
}

function snapCoordinate(value, max) {
  const snapped = Math.round(value - 0.5) + 0.5;
  return Math.max(0.5, Math.min(max - 0.5, snapped));
}

function findZoneForPoint(layout, x, y) {
  const zones = Array.isArray(layout?.zones) ? layout.zones : [];
  return zones.find(zone => x >= zone.x && x <= zone.x + zone.w && y >= zone.y && y <= zone.y + zone.h) || null;
}

function fillForZoneTone(tone) {
  if (tone === 'spawn') return '#7c2d12';
  if (tone === 'corridor') return '#1d4ed8';
  if (tone === 'objective') return '#991b1b';
  if (tone === 'hazard') return '#0f766e';
  if (tone === 'vault') return '#4c1d95';
  return '#334155';
}

function lightingOverlay(lighting) {
  if (lighting === 'dark') return { fill: 'url(#bm-darkness)', opacity: 0.5, label: 'Darkness' };
  if (lighting === 'dim') return { fill: 'url(#bm-dimness)', opacity: 0.35, label: 'Dim light' };
  if (lighting === 'glow') return { fill: 'url(#bm-glow)', opacity: 0.32, label: 'Warp glow' };
  return null;
}

function elevationStroke(elevation) {
  if (Number(elevation) > 0) return '#facc15';
  if (Number(elevation) < 0) return '#22d3ee';
  return 'rgba(226,232,240,0.8)';
}

function elevationBadge(elevation) {
  if (Number(elevation) > 0) return `▲${Number(elevation)}`;
  if (Number(elevation) < 0) return `▼${Math.abs(Number(elevation))}`;
  return '';
}

function fillForCover(kind) {
  if (kind === 'heavy') return '#b45309';
  return '#cbd5e1';
}

function fillForHazard(kind) {
  if (kind === 'difficult') return '#0f766e';
  if (kind === 'agility') return '#f59e0b';
  if (kind === 'no-charge') return '#b91c1c';
  return '#14b8a6';
}

function stationMeta(layout, id) {
  const stations = Array.isArray(layout?.stations) ? layout.stations : STATIONS;
  return stations.find(station => station.id === id) || null;
}

function coverPatternId(kind) {
  return kind === 'heavy' ? 'bm-cover-heavy' : 'bm-cover-light';
}

function hazardPatternId(kind) {
  if (kind === 'vent') return 'bm-hazard-vent';
  if (kind === 'agility') return 'bm-hazard-agility';
  return 'bm-hazard-default';
}

// Exported for the layout-validation regression test (battlemapTabLayout.test.js)
// only — not part of the component's public API.
export { ZONES as __ZONES, COVER_OBJECTS as __COVER_OBJECTS, tokenAnchorPositions as __tokenAnchorPositions };

export default function BattlemapTab({ authedPlayer, sessionId }) {
  const isGM = authedPlayer === 'gm';
  const svgRef = useRef(null);
  const [board, setBoard] = useState(null);
  const [selectedTokenId, setSelectedTokenId] = useState('');
  const [status, setStatus] = useState('Loading active mission battlemap...');
  const [saving, setSaving] = useState(false);
  const [playerMovementByName, setPlayerMovementByName] = useState({});
  const [armedMoveMode, setArmedMoveMode] = useState('');

  const loadBoard = useCallback(async (silent = false) => {
    if (!silent) setStatus('Loading active mission battlemap...');
    try {
      const endpoint = isGM ? '/api/missions/active/current' : '/api/missions/active/player';
      const headers = sessionId ? { 'x-session-id': sessionId } : {};
      const [response, playersResponse] = await Promise.all([
        axios.get(endpoint),
        sessionId ? axios.get('/api/players', { headers }) : Promise.resolve({ data: [] }),
      ]);
      const nextBoard = initialBoardFromMission(response.data, authedPlayer);
      const playerRows = Array.isArray(playersResponse?.data) ? playersResponse.data : [];
      const movementMap = playerRows.reduce((acc, player) => {
        acc[player.name] = normalizeMovement(player?.tabInfo?.movement || {});
        return acc;
      }, {});
      setPlayerMovementByName(movementMap);
      if (!nextBoard) {
        setBoard(null);
        setStatus('No active mission loaded.');
        return;
      }
      setBoard(nextBoard);
      setSelectedTokenId((current) => {
        if (!nextBoard.battlemapState?.tokens?.length) return '';
        const ownToken = !isGM ? nextBoard.battlemapState.tokens.find(token => token.side === 'player' && token.name === authedPlayer) : null;
        if (ownToken) return ownToken.id;
        return current && nextBoard.battlemapState.tokens.some(token => token.id === current) ? current : '';
      });
      if (!silent) {
        setStatus(
          nextBoard.battlemapVisible || isGM
            ? (isGM ? 'Battlemap synced to active mission. GM controls token positions and objective state.' : 'Battlemap synced to active mission. Player movement follows turn and move limits.')
            : 'Battlemap is still hidden by the GM.'
        );
      }
    } catch (error) {
      setStatus(`Failed to load battlemap: ${error.response?.data?.error || error.message}`);
    }
  }, [authedPlayer, isGM, sessionId]);

  useEffect(() => {
    loadBoard();
    const timer = setInterval(() => loadBoard(true), 5000);
    return () => clearInterval(timer);
  }, [loadBoard]);

  const persistState = useCallback(async (nextBattlemapState, nextStatus) => {
    if (!isGM) return;
    setSaving(true);
    try {
      await axios.put('/api/missions/active/scene', {
        patch: { battlemapState: { ...nextBattlemapState, updatedAt: Date.now() } },
      });
      setStatus(nextStatus);
    } catch (error) {
      setStatus(`Failed to save battlemap: ${error.response?.data?.error || error.message}`);
    } finally {
      setSaving(false);
    }
  }, [isGM]);

  const applyBattlemapState = useCallback((nextBattlemapState, nextStatus, persist = true) => {
    setBoard((current) => {
      if (!current) return current;
      return {
        ...current,
        battlemapState: {
          ...nextBattlemapState,
          updatedAt: Date.now(),
        },
      };
    });
    if (persist) persistState(nextBattlemapState, nextStatus);
    else setStatus(nextStatus);
  }, [persistState]);

  const selectedToken = useMemo(
    () => board?.battlemapState?.tokens?.find(token => token.id === selectedTokenId) || null,
    [board, selectedTokenId]
  );

  const currentActor = currentTurnName(board?.scene);
  const currentTurnKey = board?.scene?.combatState?.turnKey || '';
  const currentBattleTurnState = useMemo(
    () => normalizeTurnState(board?.battlemapState?.turnState, currentTurnKey),
    [board?.battlemapState?.turnState, currentTurnKey]
  );
  const tokenCounts = useMemo(() => {
    const tokens = board?.battlemapState?.tokens || [];
    return {
      players: tokens.filter(token => token.side === 'player').length,
      enemies: tokens.filter(token => token.side === 'enemy').length,
    };
  }, [board]);

  const activeStations = useMemo(() => {
    const stations = board?.battlemapState?.objectives?.stations || [];
    return stations.filter(station => station.active).length;
  }, [board]);
  const extractionComplete = Number(board?.battlemapState?.objectives?.extractionProgress || 0) >= 2;

  const selectedZone = selectedToken ? findZoneForPoint(board?.battlemapState?.layout, selectedToken.x, selectedToken.y) : null;
  const selectedMoveMode = selectedToken ? movementModeLocked(currentBattleTurnState, selectedToken.id) : '';
  const selectedMoveSpent = selectedToken ? movementSpent(currentBattleTurnState, selectedToken.id) : 0;

  useEffect(() => {
    setArmedMoveMode('');
  }, [currentTurnKey, selectedTokenId]);

  const syncCombatants = () => {
    if (!board || !isGM) return;
    const nextState = buildBattlemapState(board.scene, authedPlayer, board.battlemapState);
    applyBattlemapState(nextState, 'Tokens resynced from the active mission scene.');
  };

  const toggleBattlemapVisibility = () => {
    if (!board || !isGM) return;
    const currentReveal = board.scene?.reveal || {};
    axios.put('/api/missions/active/scene', {
      patch: { reveal: { ...currentReveal, battlemap: !board.battlemapVisible } },
    }).then(() => loadBoard()).catch((error) => {
      setStatus(`Failed to update battlemap visibility: ${error.response?.data?.error || error.message}`);
    });
  };

  const moveSelectedToken = (x, y, moveMeta = null) => {
    if (!board || !selectedToken) return;
    const nextTokens = board.battlemapState.tokens.map((token) => (
      token.id === selectedToken.id
        ? { ...token, x: snapCoordinate(x, GRID_WIDTH), y: snapCoordinate(y, GRID_HEIGHT) }
        : token
    ));
    const priorTurnState = normalizeTurnState(board.battlemapState.turnState, currentTurnKey);
    const nextTurnState = moveMeta?.consumeTurn
      ? {
          ...priorTurnState,
          movedBy: {
            ...priorTurnState.movedBy,
            [selectedToken.id]: currentTurnKey || 'free',
          },
          movementUsedBy: {
            ...priorTurnState.movementUsedBy,
            [selectedToken.id]: movementSpent(priorTurnState, selectedToken.id) + Math.max(0, Number(moveMeta?.cost || 0) || 0),
          },
          moveModeBy: {
            ...priorTurnState.moveModeBy,
            [selectedToken.id]: String(moveMeta?.mode || ''),
          },
        }
      : priorTurnState;
    applyBattlemapState({ ...board.battlemapState, tokens: nextTokens, turnState: nextTurnState }, `${selectedToken.name} moved${moveMeta?.label ? ` (${moveMeta.label})` : ''}.`);
  };

  const handleCanvasClick = (event) => {
    if (!selectedToken || !svgRef.current || (!isGM && selectedToken.name !== authedPlayer)) return;
    const bounds = svgRef.current.getBoundingClientRect();
    if (!bounds.width || !bounds.height) return;
    const x = ((event.clientX - bounds.left) / bounds.width) * GRID_WIDTH;
    const y = ((event.clientY - bounds.top) / bounds.height) * GRID_HEIGHT;
    if (armedMoveMode && selectedToken.side === 'player') {
      const moveProfile = playerMovementByName[selectedToken.name] || normalizeMovement();
      const isCarrier = board?.battlemapState?.objectives?.carrier === selectedToken.name;
      const budget = movementBudget(moveProfile, armedMoveMode, isCarrier);
      if (budget <= 0) {
        setStatus('Carrier cannot use that movement mode.');
        return;
      }
      const turnState = currentBattleTurnState;
      const lockedMode = movementModeLocked(turnState, selectedToken.id);
      const spent = movementSpent(turnState, selectedToken.id);
      const remaining = Math.max(0, budget - spent);
      const actorCanMove = isGM || selectedToken.name === currentActor;
      if (!actorCanMove) {
        setStatus('Only the active combatant can take a movement action.');
        return;
      }
      if (lockedMode && lockedMode !== armedMoveMode) {
        setStatus(`${selectedToken.name} is already using ${lockedMode} movement this turn.`);
        return;
      }
      if (remaining <= 0) {
        setStatus(`${selectedToken.name} has used all ${budget} checker(s) of ${armedMoveMode} movement this turn.`);
        return;
      }
      const fromCell = pointToCell(selectedToken);
      const targetCell = pointToCell({ x, y });
      const stepDistance = Math.max(Math.abs(targetCell.x - fromCell.x), Math.abs(targetCell.y - fromCell.y));
      if (stepDistance !== 1) {
        setStatus('Movement is checker-by-checker: click one adjacent square at a time.');
        return;
      }
      const occupied = (board?.battlemapState?.tokens || [])
        .filter((token) => token.id !== selectedToken.id)
        .map((token) => ({ x: token.x, y: token.y }));
      const next = moveWithinRange(selectedToken, { x, y }, remaining, { width: GRID_WIDTH, height: GRID_HEIGHT }, {
        layout: board?.battlemapState?.layout,
        occupied,
      });
      if (pointToCell(next).x === fromCell.x && pointToCell(next).y === fromCell.y) {
        setStatus('That square is blocked by a wall, door line, blocker, unit, or terrain cost beyond your remaining movement.');
        return;
      }
      const spentNow = Math.max(1, Number(next.travelled || 0) || 0);
      moveSelectedToken(next.x, next.y, { consumeTurn: true, label: armedMoveMode, mode: armedMoveMode, cost: spentNow });
      if ((spent + spentNow) >= budget) setArmedMoveMode('');
      return;
    }
    if (isGM) moveSelectedToken(x, y);
  };

  const nudgeSelectedToken = (dx, dy) => {
    if (!selectedToken || !isGM) return;
    moveSelectedToken(selectedToken.x + dx, selectedToken.y + dy);
  };

  const scriptEnemyTurn = () => {
    if (!board || !board.battlemapState || !isGM) return;
    const enemyToken = board.battlemapState.tokens.find(token => token.side === 'enemy' && token.name === currentActor)
      || board.battlemapState.tokens.find(token => token.side === 'enemy');
    if (!enemyToken) {
      setStatus('No enemy token available for scripted movement.');
      return;
    }
    const playerTokens = board.battlemapState.tokens.filter(token => token.side === 'player');
    const scripted = enemyMoveTowardNearest(enemyToken, playerTokens, 1, { width: GRID_WIDTH, height: GRID_HEIGHT }, {
      layout: board?.battlemapState?.layout,
      occupied: board.battlemapState.tokens
        .filter((token) => token.id !== enemyToken.id)
        .map((token) => ({ x: token.x, y: token.y })),
    });
    if (!scripted) {
      setStatus('No valid player target for enemy script.');
      return;
    }
    const nextTokens = board.battlemapState.tokens.map((token) => (
      token.id === enemyToken.id ? { ...token, x: scripted.x, y: scripted.y } : token
    ));
    applyBattlemapState({ ...board.battlemapState, tokens: nextTokens, turnState: normalizeTurnState(board.battlemapState.turnState, currentTurnKey) }, `${enemyToken.name} advances toward ${scripted.target.name}.`);
  };

  const toggleStation = (stationId) => {
    if (!board || !isGM) return;
    const nextObjectives = {
      ...board.battlemapState.objectives,
      stations: board.battlemapState.objectives.stations.map((station) => (
        station.id === stationId ? { ...station, active: !station.active } : station
      )),
    };
    const meta = stationMeta(board?.battlemapState?.layout, stationId);
    applyBattlemapState({ ...board.battlemapState, objectives: nextObjectives }, `${meta?.name || 'Station'} updated.`);
  };

  const adjustCoreRounds = (delta) => {
    if (!board || !isGM) return;
    const nextObjectives = {
      ...board.battlemapState.objectives,
      geneCoreSecuredRounds: Math.max(0, Math.min(9, Number(board.battlemapState.objectives.geneCoreSecuredRounds || 0) + delta)),
    };
    applyBattlemapState({ ...board.battlemapState, objectives: nextObjectives }, 'Gene-core hold tracker updated.');
  };

  const adjustExtractionProgress = (delta) => {
    if (!board || !isGM) return;
    const nextObjectives = {
      ...board.battlemapState.objectives,
      extractionProgress: Math.max(0, Math.min(2, Number(board.battlemapState.objectives.extractionProgress || 0) + delta)),
    };
    applyBattlemapState({ ...board.battlemapState, objectives: nextObjectives }, 'Extraction tracker updated.');
  };

  const assignCarrier = () => {
    if (!board || !isGM || !selectedToken || selectedToken.side !== 'player') return;
    const nextObjectives = {
      ...board.battlemapState.objectives,
      carrier: selectedToken.name,
    };
    applyBattlemapState({ ...board.battlemapState, objectives: nextObjectives }, `${selectedToken.name} is now carrying the gene-core.`);
  };

  const cycleSpawn = () => {
    if (!board || !isGM) return;
    const currentId = board.battlemapState.objectives.activeSpawnId;
    const currentIndex = Math.max(0, SPAWN_POINTS.findIndex(point => point.id === currentId));
    const next = SPAWN_POINTS[(currentIndex + 1) % SPAWN_POINTS.length];
    const nextObjectives = {
      ...board.battlemapState.objectives,
      activeSpawnId: next.id,
    };
    applyBattlemapState({ ...board.battlemapState, objectives: nextObjectives }, `Active reinforcement point: ${next.name}.`);
  };

  if (!board) {
    return (
      <div className="rounded-lg border border-slate-700 bg-slate-900/80 p-6 shadow-lg">
        <h2 className="text-2xl font-bold text-slate-100">Battlemap</h2>
        <p className="mt-3 text-sm text-slate-300">{status}</p>
      </div>
    );
  }

  if (!board.battlemapVisible && !isGM) {
    return (
      <div className="rounded-lg border border-slate-700 bg-slate-900/80 p-6 shadow-lg">
        <h2 className="text-2xl font-bold text-slate-100">Battlemap</h2>
        <p className="mt-3 text-sm text-slate-300">The GM has not revealed the battlemap for this scene yet.</p>
      </div>
    );
  }

  const layout = board.battlemapState.layout;
  const tokens = board.battlemapState.tokens;
  const objectives = board.battlemapState.objectives;
  const activeSpawn = (layout.spawnPoints || SPAWN_POINTS).find(point => point.id === objectives.activeSpawnId) || (layout.spawnPoints || SPAWN_POINTS)[0];

  return (
    <div className="space-y-4">
      <div className="rounded-lg border border-slate-700 bg-slate-900/80 p-4 shadow-lg">
        <div className="flex flex-col gap-4 xl:flex-row xl:items-end xl:justify-between">
          <div>
            <h2 className="text-2xl font-bold text-slate-100">{layout.title || board.sceneTitle}</h2>
            <p className="mt-1 text-sm text-slate-300">
              Mission: <span className="font-semibold text-slate-100">{board.missionName}</span> · Scene: <span className="font-semibold text-slate-100">{board.sceneTitle}</span>
            </p>
            <p className="mt-2 max-w-3xl text-sm text-slate-400">
              {layout.summary || 'Fixed 40 x 30 tactical board.'} 1 checker = 10 metres. Built for movement, cover, overwatch, suppression, charge lanes, chokepoints, doorway control, objective pressure, and reinforcement entry.
            </p>
          </div>
          <div className="grid gap-2 sm:grid-cols-2 xl:grid-cols-3">
            <button
              onClick={syncCombatants}
              disabled={!isGM || saving}
              className="rounded-lg bg-emerald-600 px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-emerald-500 disabled:opacity-50"
            >
              Sync Combatants
            </button>
            <button
              onClick={toggleBattlemapVisibility}
              disabled={!isGM || saving}
              className="rounded-lg bg-indigo-700 px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-indigo-600 disabled:opacity-50"
            >
              {board.battlemapVisible ? 'Hide From Players' : 'Reveal To Players'}
            </button>
            <button
              onClick={cycleSpawn}
              disabled={!isGM || saving}
              className="rounded-lg bg-rose-700 px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-rose-600 disabled:opacity-50"
            >
              Cycle Reinforcement
            </button>
            <button
              onClick={scriptEnemyTurn}
              disabled={!isGM || saving}
              className="rounded-lg bg-amber-700 px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-amber-600 disabled:opacity-50"
            >
              Script Enemy Move
            </button>
            <button
              onClick={() => loadBoard()}
              disabled={saving}
              className="rounded-lg bg-slate-700 px-4 py-2 text-sm font-semibold text-slate-100 transition-colors hover:bg-slate-600 disabled:opacity-50"
            >
              Refresh
            </button>
          </div>
        </div>
        <div className="mt-4 flex flex-wrap items-center gap-2 text-xs text-slate-300">
          <span className="rounded bg-slate-800 px-2 py-1">40 x 30 grid</span>
          <span className="rounded bg-slate-800 px-2 py-1">{tokenCounts.players} players</span>
          <span className="rounded bg-slate-800 px-2 py-1">{tokenCounts.enemies} enemies</span>
          <span className="rounded bg-slate-800 px-2 py-1">{activeStations}/3 stations online</span>
          <span className="rounded bg-slate-800 px-2 py-1">Gene-Core Held {objectives.geneCoreSecuredRounds}/3</span>
          <span className={`rounded px-2 py-1 ${extractionComplete ? 'bg-emerald-800 text-emerald-100' : 'bg-slate-800 text-slate-200'}`}>Extraction {objectives.extractionProgress || 0}/2</span>
          {objectives.carrier && <span className="rounded bg-violet-900/80 px-2 py-1 text-violet-100">Carrier {objectives.carrier}</span>}
          {currentActor && <span className="rounded bg-amber-700 px-2 py-1 text-amber-100">Turn {currentActor}</span>}
          <span className="rounded bg-rose-900/80 px-2 py-1 text-rose-100">Active Spawn {activeSpawn.name}</span>
          <span className={`rounded px-2 py-1 ${saving ? 'bg-amber-700 text-amber-100' : 'bg-slate-800 text-slate-200'}`}>{saving ? 'Saving...' : status}</span>
        </div>
      </div>

      <div className="grid gap-4 xl:grid-cols-[minmax(0,1.75fr)_minmax(340px,1fr)]">
        <div className="rounded-lg border border-slate-700 bg-slate-900/80 p-3 shadow-lg">
          <div className="mb-3 flex flex-wrap gap-2 text-xs text-slate-300">
            <span className="rounded bg-slate-800 px-2 py-1">Win: Activate 2 of 3 stabilization stations</span>
            <span className="rounded bg-slate-800 px-2 py-1">Hold gene-core 3 rounds</span>
            <span className="rounded bg-slate-800 px-2 py-1">Carry the core back to extraction</span>
          </div>
          <div className="mb-3 flex flex-wrap gap-2 text-[11px] text-slate-300">
            <span className="rounded border border-yellow-500/60 bg-yellow-950/30 px-2 py-1 text-yellow-100">Elevation: amber border + ▲/▼ badge</span>
            <span className="rounded border border-emerald-500/60 bg-emerald-950/30 px-2 py-1 text-emerald-100">Doors: white bars between rooms</span>
            <span className="rounded border border-slate-500/60 bg-slate-800 px-2 py-1 text-slate-100">Light cover: pale steel + diagonal slashes</span>
            <span className="rounded border border-amber-700/60 bg-amber-950/40 px-2 py-1 text-amber-100">Heavy cover: bronze + crosshatch</span>
            <span className="rounded border border-indigo-500/60 bg-indigo-950/30 px-2 py-1 text-indigo-100">Darkness: blue overlay dots</span>
            <span className="rounded border border-cyan-500/60 bg-cyan-950/30 px-2 py-1 text-cyan-100">Hazards: cyan/orange dashed zones</span>
          </div>
          <div className="overflow-hidden rounded-lg border border-slate-700 bg-slate-950">
            <svg
              ref={svgRef}
              data-testid="battlemap-canvas"
              viewBox={layout.viewBox}
              className={`h-[70vh] min-h-[420px] w-full ${(isGM && selectedToken) || armedMoveMode ? 'cursor-crosshair' : ''}`}
              role="img"
              aria-label="Gene-vault battlemap"
              onClick={handleCanvasClick}
            >
              <rect x="0" y="0" width={GRID_WIDTH} height={GRID_HEIGHT} fill="#020617" />
              <defs>
                <pattern id="bm-darkness" width="1.6" height="1.6" patternUnits="userSpaceOnUse">
                  <rect width="1.6" height="1.6" fill="rgba(15,23,42,0.6)" />
                  <circle cx="0.45" cy="0.45" r="0.12" fill="rgba(129,140,248,0.6)" />
                  <circle cx="1.2" cy="1.1" r="0.12" fill="rgba(129,140,248,0.45)" />
                </pattern>
                <pattern id="bm-dimness" width="2" height="2" patternUnits="userSpaceOnUse">
                  <rect width="2" height="2" fill="rgba(30,41,59,0.18)" />
                  <line x1="0" y1="2" x2="2" y2="0" stroke="rgba(191,219,254,0.18)" strokeWidth="0.08" />
                </pattern>
                <radialGradient id="bm-glow" cx="50%" cy="50%" r="60%">
                  <stop offset="0%" stopColor="rgba(196,181,253,0.48)" />
                  <stop offset="55%" stopColor="rgba(168,85,247,0.22)" />
                  <stop offset="100%" stopColor="rgba(30,41,59,0)" />
                </radialGradient>
                <pattern id="bm-cover-light" width="0.8" height="0.8" patternUnits="userSpaceOnUse" patternTransform="rotate(45)">
                  <line x1="0" y1="0" x2="0" y2="0.8" stroke="rgba(15,23,42,0.38)" strokeWidth="0.12" />
                </pattern>
                <pattern id="bm-cover-heavy" width="1.1" height="1.1" patternUnits="userSpaceOnUse">
                  <line x1="0" y1="0" x2="1.1" y2="1.1" stroke="rgba(15,23,42,0.45)" strokeWidth="0.12" />
                  <line x1="1.1" y1="0" x2="0" y2="1.1" stroke="rgba(15,23,42,0.45)" strokeWidth="0.12" />
                </pattern>
                <pattern id="bm-hazard-default" width="0.9" height="0.9" patternUnits="userSpaceOnUse" patternTransform="rotate(45)">
                  <line x1="0" y1="0" x2="0" y2="0.9" stroke="rgba(240,253,250,0.55)" strokeWidth="0.08" />
                </pattern>
                <pattern id="bm-hazard-agility" width="1.2" height="1.2" patternUnits="userSpaceOnUse">
                  <path d="M0.15 1 L0.6 0.2 L1.05 1 Z" fill="rgba(255,251,235,0.45)" />
                </pattern>
                <pattern id="bm-hazard-vent" width="1.2" height="1.2" patternUnits="userSpaceOnUse">
                  <circle cx="0.3" cy="0.35" r="0.12" fill="rgba(240,253,250,0.5)" />
                  <circle cx="0.7" cy="0.75" r="0.14" fill="rgba(240,253,250,0.36)" />
                </pattern>
              </defs>
              {Array.from({ length: GRID_WIDTH + 1 }, (_, index) => (
                <line key={`vx-${index}`} x1={index} y1="0" x2={index} y2={GRID_HEIGHT} stroke="rgba(148,163,184,0.18)" strokeWidth="0.04" />
              ))}
              {Array.from({ length: GRID_HEIGHT + 1 }, (_, index) => (
                <line key={`hy-${index}`} x1="0" y1={index} x2={GRID_WIDTH} y2={index} stroke="rgba(148,163,184,0.18)" strokeWidth="0.04" />
              ))}

              {layout.zones.map((zone) => (
                <g key={zone.id}>
                  <rect x={zone.x} y={zone.y} width={zone.w} height={zone.h} fill={fillForZoneTone(zone.tone)} fillOpacity="0.35" stroke={elevationStroke(zone.elevation)} strokeWidth="0.18" />
                  {lightingOverlay(zone.lighting) && (
                    <rect
                      x={zone.x}
                      y={zone.y}
                      width={zone.w}
                      height={zone.h}
                      fill={lightingOverlay(zone.lighting).fill}
                      fillOpacity={lightingOverlay(zone.lighting).opacity}
                      stroke="none"
                    />
                  )}
                  <text x={zone.x + zone.w / 2} y={zone.y + 0.9} textAnchor="middle" fontSize="0.75" fill="#f8fafc" style={{ fontWeight: 700 }}>
                    {zone.name}
                  </text>
                  {elevationBadge(zone.elevation) && (
                    <text x={zone.x + zone.w - 0.5} y={zone.y + zone.h - 0.4} textAnchor="end" fontSize="0.72" fill={elevationStroke(zone.elevation)} style={{ fontWeight: 700 }}>
                      {elevationBadge(zone.elevation)}
                    </text>
                  )}
                  {lightingOverlay(zone.lighting)?.label && (
                    <text x={zone.x + 0.45} y={zone.y + zone.h - 0.4} fontSize="0.55" fill="#cbd5e1">
                      {lightingOverlay(zone.lighting).label}
                    </text>
                  )}
                </g>
              ))}

              <rect x="27.5" y="13.5" width="3" height="3" fill="#f8fafc" fillOpacity="0.08" stroke="#f8fafc" strokeWidth="0.12" />
              <text x="29" y="15.3" textAnchor="middle" fontSize="0.7" fill="#e2e8f0" style={{ fontWeight: 700 }}>CORE</text>

              <rect x="18" y="1" width="4" height="1.2" fill="#475569" stroke="#cbd5e1" strokeWidth="0.12" />
              <text x="20" y="1.8" textAnchor="middle" fontSize="0.52" fill="#f8fafc">Raised Gantry</text>

              <rect x="11.5" y="19.2" width="2" height="4" fill="#475569" stroke="#cbd5e1" strokeWidth="0.12" />
              <text x="12.5" y="21.5" textAnchor="middle" fontSize="0.46" fill="#f8fafc">Stairs</text>

              {layout.cover.map((item) => (
                <g key={item.id}>
                  <rect x={item.x} y={item.y} width={item.w} height={item.h} fill={fillForCover(item.kind)} fillOpacity={item.kind === 'heavy' ? 0.9 : 0.68} stroke="#0f172a" strokeWidth="0.12" />
                  <rect x={item.x} y={item.y} width={item.w} height={item.h} fill={`url(#${coverPatternId(item.kind)})`} fillOpacity="0.9" stroke="none" />
                </g>
              ))}

              {(layout.doors || []).map((item) => (
                <g key={item.id}>
                  <rect x={item.x} y={item.y} width={item.w} height={item.h} rx="0.08" fill="#f8fafc" stroke="#0f172a" strokeWidth="0.08" />
                </g>
              ))}

              {layout.blockers.map((item) => (
                <g key={item.id}>
                  <rect x={item.x} y={item.y} width={item.w} height={item.h} fill="#0f172a" stroke="#f8fafc" strokeWidth="0.15" />
                </g>
              ))}

              {layout.hazards.map((item) => (
                <g key={item.id}>
                  <rect x={item.x} y={item.y} width={item.w} height={item.h} fill={fillForHazard(item.kind)} fillOpacity="0.55" stroke="#f8fafc" strokeDasharray="0.25 0.25" strokeWidth="0.12" />
                  <rect x={item.x} y={item.y} width={item.w} height={item.h} fill={`url(#${hazardPatternId(item.kind)})`} fillOpacity="0.9" stroke="none" />
                </g>
              ))}

              {layout.stations.map((station) => {
                const active = objectives.stations.find(item => item.id === station.id)?.active;
                return (
                  <g key={station.id}>
                    <rect x={station.x - 0.7} y={station.y - 0.7} width="1.4" height="1.4" fill={active ? '#22c55e' : '#eab308'} stroke="#0f172a" strokeWidth="0.12" />
                    <text x={station.x} y={station.y + 0.2} textAnchor="middle" fontSize="0.55" fill="#020617" style={{ fontWeight: 700 }}>
                      S
                    </text>
                  </g>
                );
              })}

              {layout.spawnPoints.map((point) => {
                const isActiveSpawn = point.id === objectives.activeSpawnId;
                return (
                  <g key={point.id}>
                    <circle cx={point.x + 0.5} cy={point.y + 0.5} r="0.55" fill={isActiveSpawn ? '#fb7185' : '#7f1d1d'} stroke="#f8fafc" strokeWidth={isActiveSpawn ? '0.14' : '0.1'} />
                    {isActiveSpawn && <circle cx={point.x + 0.5} cy={point.y + 0.5} r="0.95" fill="none" stroke="#fb7185" strokeWidth="0.1" strokeDasharray="0.2 0.2" />}
                  </g>
                );
              })}

              {tokens.map((token) => {
                const isSelected = token.id === selectedTokenId;
                const isCurrent = currentActor && currentActor.toLowerCase() === token.name.toLowerCase();
                return (
                  <g
                    key={token.id}
                    data-testid={`battlemap-token-${token.id}`}
                    onClick={(event) => {
                      event.stopPropagation();
                      if (isGM || (token.side === 'player' && token.name === authedPlayer)) setSelectedTokenId(token.id);
                    }}
                  >
                    <circle
                      cx={token.x}
                      cy={token.y}
                      r="0.72"
                      fill={token.color}
                      stroke={isSelected ? '#f8fafc' : isCurrent ? '#fde68a' : '#0f172a'}
                      strokeWidth={isSelected || isCurrent ? '0.18' : '0.12'}
                    />
                    <text x={token.x} y={token.y + 0.2} textAnchor="middle" fontSize="0.44" fill="#f8fafc" style={{ fontWeight: 700 }}>
                      {token.name.slice(0, 2).toUpperCase()}
                    </text>
                  </g>
                );
              })}
            </svg>
          </div>
        </div>

        <div className="space-y-4">
          <div className="rounded-lg border border-slate-700 bg-slate-900/80 p-4 shadow-lg">
            <h3 className="text-lg font-semibold text-slate-100">Objectives</h3>
            <div className="mt-3 space-y-2">
              {(layout.stations || STATIONS).map((station) => {
                const active = objectives.stations.find(item => item.id === station.id)?.active;
                const zone = zoneById(station.zoneId);
                return (
                  <div key={station.id} className="rounded-lg border border-slate-700 bg-slate-950/60 p-3">
                    <div className="flex items-center justify-between gap-3">
                      <div className="min-w-0">
                        <div className="truncate text-sm font-semibold text-slate-100">{station.name}</div>
                        <div className="text-xs text-slate-400">{zone?.name}</div>
                      </div>
                      <button
                        type="button"
                        onClick={() => toggleStation(station.id)}
                        disabled={!isGM || saving}
                        className={`rounded px-3 py-1 text-xs font-semibold ${active ? 'bg-emerald-700 text-emerald-100' : 'bg-amber-700 text-amber-100'} disabled:opacity-50`}
                      >
                        {active ? 'Online' : 'Offline'}
                      </button>
                    </div>
                    <div className="mt-2 text-xs text-slate-300">{station.action}</div>
                    <div className="mt-1 text-xs text-slate-400">{station.note}</div>
                  </div>
                );
              })}
              <div className="rounded-lg border border-slate-700 bg-slate-950/60 p-3">
                <div className="flex items-center justify-between gap-3">
                  <div>
                    <div className="text-sm font-semibold text-slate-100">Gene-Core Hold</div>
                    <div className="text-xs text-slate-400">Apothecary or Techmarine secures the core while the squad holds the vault.</div>
                  </div>
                  <div className="flex items-center gap-2">
                    <button onClick={() => adjustCoreRounds(-1)} disabled={!isGM || saving} className="rounded bg-slate-700 px-2 py-1 text-sm text-white disabled:opacity-50">-</button>
                    <span className="rounded bg-slate-800 px-3 py-1 text-sm font-bold text-slate-100">{objectives.geneCoreSecuredRounds}/3</span>
                    <button onClick={() => adjustCoreRounds(1)} disabled={!isGM || saving} className="rounded bg-slate-700 px-2 py-1 text-sm text-white disabled:opacity-50">+</button>
                  </div>
                </div>
              </div>
              <div className="rounded-lg border border-slate-700 bg-slate-950/60 p-3">
                <div className="flex items-center justify-between gap-3">
                  <div>
                    <div className="text-sm font-semibold text-slate-100">Extraction Withdrawal</div>
                    <div className="text-xs text-slate-400">The carrier cannot run or charge and must fight back to the extraction zone.</div>
                  </div>
                  <div className="flex items-center gap-2">
                    <button onClick={() => adjustExtractionProgress(-1)} disabled={!isGM || saving} className="rounded bg-slate-700 px-2 py-1 text-sm text-white disabled:opacity-50">-</button>
                    <span className="rounded bg-slate-800 px-3 py-1 text-sm font-bold text-slate-100">{objectives.extractionProgress || 0}/2</span>
                    <button onClick={() => adjustExtractionProgress(1)} disabled={!isGM || saving} className="rounded bg-slate-700 px-2 py-1 text-sm text-white disabled:opacity-50">+</button>
                  </div>
                </div>
                <div className="mt-2 flex flex-wrap items-center gap-2">
                  <button onClick={assignCarrier} disabled={!isGM || saving || !selectedToken || selectedToken.side !== 'player'} className="rounded bg-violet-700 px-3 py-1 text-xs font-semibold text-violet-100 disabled:opacity-50">
                    Set Selected Player As Carrier
                  </button>
                  {objectives.carrier && <span className="text-xs text-violet-200">Current carrier: {objectives.carrier}</span>}
                </div>
              </div>
            </div>
          </div>

          <div className="rounded-lg border border-slate-700 bg-slate-900/80 p-4 shadow-lg">
            <h3 className="text-lg font-semibold text-slate-100">Selected Token</h3>
            {!selectedToken ? (
              <p className="mt-3 text-sm text-slate-400">{isGM ? 'Select a token, then click the board or use the nudge controls.' : 'GM controls movement. Your view updates live.'}</p>
            ) : (
              <div className="mt-3 space-y-3">
                <div className="rounded-lg border border-slate-700 bg-slate-950/60 p-3">
                  <div className="flex items-center justify-between gap-3">
                    <div>
                      <div className="text-sm font-semibold text-slate-100">{selectedToken.name}</div>
                      <div className="text-xs text-slate-400">{selectedToken.side}</div>
                    </div>
                    {currentActor && currentActor.toLowerCase() === selectedToken.name.toLowerCase() && (
                      <span className="rounded bg-amber-700 px-2 py-1 text-[11px] font-semibold text-amber-100">Turn</span>
                    )}
                  </div>
                    <div className="mt-2 grid grid-cols-2 gap-2 text-xs text-slate-300">
                      <div className="rounded bg-slate-800/80 px-2 py-1">X {selectedToken.x}</div>
                      <div className="rounded bg-slate-800/80 px-2 py-1">Y {selectedToken.y}</div>
                      <div className="col-span-2 rounded bg-slate-800/80 px-2 py-1">{selectedZone ? selectedZone.name : 'Outside named zone'}</div>
                    </div>
                    {objectives.carrier === selectedToken.name && (
                      <div className="mt-2 rounded bg-violet-950/50 px-2 py-1 text-xs text-violet-200">Carrying gene-core: reduced movement, no run or charge.</div>
                    )}
                  </div>
                {selectedToken.side === 'player' && (isGM || selectedToken.name === authedPlayer) && (
                  <div className="rounded-lg border border-slate-700 bg-slate-950/60 p-3">
                    <div className="text-xs font-semibold uppercase tracking-wide text-slate-400">Move Action</div>
                    <div className="mt-2 grid grid-cols-2 gap-2">
                      {['half', 'full', 'charge', 'run'].map((mode) => {
                        const budget = movementBudget(playerMovementByName[selectedToken.name] || normalizeMovement(), mode, objectives.carrier === selectedToken.name);
                        const lockedToOtherMode = Boolean(selectedMoveMode) && selectedMoveMode !== mode;
                        const spent = selectedMoveMode === mode ? selectedMoveSpent : 0;
                        const remaining = Math.max(0, budget - spent);
                        const active = armedMoveMode === mode || selectedMoveMode === mode;
                        return (
                          <button
                            key={mode}
                            type="button"
                            onClick={() => setArmedMoveMode(armedMoveMode === mode ? '' : mode)}
                            disabled={budget <= 0 || lockedToOtherMode || remaining <= 0}
                            className={`rounded px-3 py-2 text-xs font-semibold ${active ? 'bg-sky-700 text-sky-100' : 'bg-slate-800 text-slate-200'} disabled:opacity-40`}
                          >
                            {mode} {budget > 0 ? `(${remaining}/${budget})` : '(blocked)'}
                          </button>
                        );
                      })}
                    </div>
                    <div className="mt-2 text-xs text-slate-400">
                      Click one adjacent checker at a time. Movement is spent across the whole round for the chosen mode, and walls/doors/terrain are checked on every single step.
                    </div>
                    {selectedMoveMode && (
                      <div className="mt-2 rounded bg-sky-950/40 px-2 py-1 text-xs text-sky-200">
                        Current turn: {selectedMoveMode} movement, {Math.max(0, movementBudget(playerMovementByName[selectedToken.name] || normalizeMovement(), selectedMoveMode, objectives.carrier === selectedToken.name) - selectedMoveSpent)} checker(s) left.
                      </div>
                    )}
                  </div>
                )}
                {isGM && (
                  <div className="grid grid-cols-3 gap-2">
                    <div />
                    <button onClick={() => nudgeSelectedToken(0, -1)} disabled={saving} className="rounded-lg bg-slate-700 px-3 py-2 text-sm font-semibold text-white hover:bg-slate-600 disabled:opacity-50">Up</button>
                    <div />
                    <button onClick={() => nudgeSelectedToken(-1, 0)} disabled={saving} className="rounded-lg bg-slate-700 px-3 py-2 text-sm font-semibold text-white hover:bg-slate-600 disabled:opacity-50">Left</button>
                    <button onClick={syncCombatants} disabled={saving} className="rounded-lg bg-emerald-700 px-3 py-2 text-sm font-semibold text-white hover:bg-emerald-600 disabled:opacity-50">Reset</button>
                    <button onClick={() => nudgeSelectedToken(1, 0)} disabled={saving} className="rounded-lg bg-slate-700 px-3 py-2 text-sm font-semibold text-white hover:bg-slate-600 disabled:opacity-50">Right</button>
                    <div />
                    <button onClick={() => nudgeSelectedToken(0, 1)} disabled={saving} className="rounded-lg bg-slate-700 px-3 py-2 text-sm font-semibold text-white hover:bg-slate-600 disabled:opacity-50">Down</button>
                    <div />
                  </div>
                )}
              </div>
            )}
          </div>

          <div className="rounded-lg border border-slate-700 bg-slate-900/80 p-4 shadow-lg">
            <h3 className="text-lg font-semibold text-slate-100">Movement And LOS</h3>
            <div className="mt-3 space-y-3">
              <div className="rounded-lg border border-slate-700 bg-slate-950/60 p-3">
                <div className="text-sm font-semibold text-slate-100">Corridor Widths</div>
                <div className="mt-2 space-y-1 text-xs text-slate-300">
                  {MOVEMENT_NOTES.corridorWidths.map((item) => <div key={item}>• {item}</div>)}
                </div>
              </div>
              <div className="rounded-lg border border-slate-700 bg-slate-950/60 p-3">
                <div className="text-sm font-semibold text-slate-100">Charge And Fire Lanes</div>
                <div className="mt-2 space-y-1 text-xs text-slate-300">
                  {MOVEMENT_NOTES.chargeLanes.map((item) => <div key={item}>• {item}</div>)}
                </div>
              </div>
              <div className="rounded-lg border border-slate-700 bg-slate-950/60 p-3">
                <div className="text-sm font-semibold text-slate-100">Difficult Terrain And Verticality</div>
                <div className="mt-2 space-y-1 text-xs text-slate-300">
                  {MOVEMENT_NOTES.difficultTerrain.map((item) => <div key={item}>• {item}</div>)}
                  {MOVEMENT_NOTES.verticality.map((item) => <div key={item}>• {item}</div>)}
                </div>
              </div>
            </div>
          </div>

          <div className="rounded-lg border border-slate-700 bg-slate-900/80 p-4 shadow-lg">
            <h3 className="text-lg font-semibold text-slate-100">Reinforcement Pressure</h3>
            <div className="mt-3 space-y-2">
              {(layout.spawnPoints || SPAWN_POINTS).map((point) => {
                const active = point.id === objectives.activeSpawnId;
                return (
                  <div key={point.id} className={`rounded-lg border p-3 ${active ? 'border-rose-500 bg-rose-950/30' : 'border-slate-700 bg-slate-950/60'}`}>
                    <div className="flex items-center justify-between gap-3">
                      <div className="text-sm font-semibold text-slate-100">{point.name}</div>
                      {active && <span className="rounded bg-rose-700 px-2 py-1 text-[11px] font-semibold text-rose-100">Active</span>}
                    </div>
                    <div className="mt-1 text-xs text-slate-400">{point.lane}</div>
                  </div>
                );
              })}
            </div>
          </div>

          <div className="rounded-lg border border-slate-700 bg-slate-900/80 p-4 shadow-lg">
            <h3 className="text-lg font-semibold text-slate-100">Terrain Notes</h3>
            <div className="mt-3 grid gap-2">
              {(layout.zones || ZONES).map((zone) => (
                <div key={zone.id} className="rounded-lg border border-slate-700 bg-slate-950/60 p-3">
                  <div className="text-sm font-semibold text-slate-100">{zone.name}</div>
                  <div className="mt-1 text-xs text-slate-300">{zone.purpose}</div>
                  <div className="mt-1 text-xs text-slate-400">{zone.cover}</div>
                  <div className="mt-1 text-xs text-slate-500">Elevation {zone.elevation || 0}</div>
                  <div className="mt-1 text-xs text-slate-500">Lighting {zone.lighting || 'normal'}</div>
                </div>
              ))}
            </div>
          </div>

          <div className="rounded-lg border border-slate-700 bg-slate-900/80 p-4 shadow-lg">
            <h3 className="text-lg font-semibold text-slate-100">Cover Materials</h3>
            <div className="mt-3 grid gap-2">
              {(layout.cover || []).slice(0, 8).map((item) => (
                <div key={item.id} className="rounded-lg border border-slate-700 bg-slate-950/60 p-3 text-xs text-slate-300">
                  <div className="font-semibold text-slate-100">{item.label}</div>
                  <div className="mt-1">{item.kind} cover · {item.material || 'Unknown material'}</div>
                  <div className="mt-1 text-slate-500">Elevation {item.elevation || 0}</div>
                </div>
              ))}
            </div>
          </div>

          <div className="rounded-lg border border-slate-700 bg-slate-900/80 p-4 shadow-lg">
            <h3 className="text-lg font-semibold text-slate-100">Dynamic Features</h3>
            <div className="mt-3 space-y-2">
              {DYNAMIC_FEATURES.map((feature) => (
                <div key={feature.id} className="rounded-lg border border-slate-700 bg-slate-950/60 p-3">
                  <div className="text-sm font-semibold text-slate-100">{feature.name}</div>
                  <div className="mt-1 text-xs text-slate-400">{feature.rule}</div>
                </div>
              ))}
            </div>
          </div>

          <div className="rounded-lg border border-slate-700 bg-slate-900/80 p-4 shadow-lg">
            <h3 className="text-lg font-semibold text-slate-100">Enemy Roles</h3>
            <div className="mt-3 space-y-2 text-xs text-slate-300">
              {ENEMY_ROLES.map((role) => (
                <div key={role} className="rounded-lg border border-slate-700 bg-slate-950/60 p-3">• {role}</div>
              ))}
            </div>
          </div>
        </div>
      </div>
    </div>
  );
}
