<script lang="ts">
  import { onMount, onDestroy } from 'svelte';
  import { backendUrl } from '$lib/socket';
  import { gameState, myPlayerId, selectedUnit, selectedBuilding, mapViewport, mapVisibleCells, mapExploredCells, mapCenterRequest, mapTextureUrl, isTutorial } from '$lib/stores/game';
  import { BUILDING_SIZES } from '$lib/types';
  import type { Unit, Building, UnitType, BuildingType, GameState } from '$lib/types';

  // ── Per-unit smooth interpolation state ───────────────────────────────────
  const TICK_MS = 250; // must match server TICK_INTERVAL * 1000

  type UnitRenderState = {
    renderX: number;
    renderY: number;
    prevX: number;
    prevY: number;
    nextX: number;
    nextY: number;
    angle: number;       // degrees – 0 = sprite faces up (north)
    updateTime: number;  // performance.now() when nextX/Y was received
  };

  let unitRenderStates: Record<string, UnitRenderState> = {};
  let rafId = 0;

  // ── Attack beam animations ─────────────────────────────────────────────────
  const BEAM_DURATION_MS = 220; // fade-out duration per flash

  type BeamStyle = { color: string; width: number; dasharray?: string; glowStd?: number };
  const UNIT_BEAM: Record<UnitType, BeamStyle> = {
    scv:     { color: '#ff9500', width: 0.10, glowStd: 0.12 },
    marine:  { color: '#00e5ff', width: 0.05, glowStd: 0.10 },
    medic:   { color: '#00e676', width: 0.05, dasharray: '0.25 0.12', glowStd: 0.08 },
    goliath: { color: '#cddc39', width: 0.13, glowStd: 0.14 },
    tank:    { color: '#ff5722', width: 0.22, glowStd: 0.22 },
    wraith:  { color: '#e040fb', width: 0.07, glowStd: 0.12 },
  };

  // beamFlashTimes[unitId] = performance.now() of last tick where unit fired
  let beamFlashTimes: Record<string, number> = {};
  // beamOpacities[unitId] = current rendered opacity (0–1), updated each animLoop frame
  let beamOpacities: Record<string, number> = {};
  // prevCooldowns[unitId] = attack_cooldown value from the previous game state tick
  let prevCooldowns: Record<string, number> = {};

  // Use progressive blob URL from preloader when available, fallback to direct URL
  $: mapImageUrl = $mapTextureUrl ?? (typeof window !== 'undefined' ? `${backendUrl()}/static/MAP.png` : '');

  const UNIT_SPRITE: Record<UnitType, string> = {
    scv: 'scv.png', marine: 'marine.png', medic: 'medic.png',
    goliath: 'goliath.png', tank: 'tank.png', wraith: 'wraith.png',
  };
  $: spriteBase = typeof window !== 'undefined' ? `${backendUrl()}/sprites` : '';

  const MAP_W = 80;
  const MAP_H = 80;

  // Geographic landmarks loaded from map.json (same source as backend MAP_LANDMARKS)
  type Landmark = { slug: string; name: string; x: number; y: number };
  let MAP_LANDMARKS: Landmark[] = [];
  onMount(() => {
    fetch(`${backendUrl()}/static/map.json`)
      .then((r) => r.json())
      .then((data) => {
        const locs: Array<{ name: string; x: number; y: number }> = data.locations ?? [];
        MAP_LANDMARKS = locs
          .filter((l) => l.name)
          .map((l) => ({
            slug: l.name.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_|_$/g, ''),
            name: l.name,
            x: Math.min(80, Math.max(0, Math.round(l.x * 80 / 100 * 10) / 10)),
            y: Math.min(80, Math.max(0, Math.round(l.y * 80 / 100 * 10) / 10)),
          }));
      })
      .catch(() => {});
  });

  // Vision radii for fog of war (in game cells)
  const UNIT_VISION: Record<UnitType, number> = {
    scv: 6, marine: 6, medic: 6, goliath: 8, tank: 8, wraith: 9,
  };
  const SIEGED_TANK_VISION = 12;
  const BUILDING_VISION: Record<BuildingType, number> = {
    command_center: 10, supply_depot: 7, barracks: 7, engineering_bay: 7,
    refinery: 7, factory: 7, armory: 7, starport: 7,
  };

  function visionRadiusUnit(u: Unit): number {
    if (u.unit_type === 'tank' && u.is_sieged) return SIEGED_TANK_VISION;
    return UNIT_VISION[u.unit_type];
  }

  function visionRadiusBuilding(b: Building): number {
    return BUILDING_VISION[b.building_type];
  }

  function cellsInRadius(cx: number, cy: number, r: number): Set<string> {
    const out = new Set<string>();
    const i0 = Math.max(0, Math.floor(cx - r));
    const i1 = Math.min(MAP_W - 1, Math.ceil(cx + r));
    const j0 = Math.max(0, Math.floor(cy - r));
    const j1 = Math.min(MAP_H - 1, Math.ceil(cy + r));
    for (let i = i0; i <= i1; i++) {
      for (let j = j0; j <= j1; j++) {
        const cellCx = i + 0.5, cellCy = j + 0.5;
        if ((cellCx - cx) ** 2 + (cellCy - cy) ** 2 <= r * r) {
          out.add(`${i},${j}`);
        }
      }
    }
    return out;
  }

  // ViewBox state (in game coordinates) — vw/vh adapt to the container's aspect ratio
  // so the map fills the screen without letterboxing. vh is the reference height (18 units);
  // vw is computed from the actual container width/height ratio.
  const DEFAULT_VIEW_H = 18;
  let vx = 0, vy = 0, vw = DEFAULT_VIEW_H, vh = DEFAULT_VIEW_H;
  let svgEl: SVGSVGElement;
  let cameraInitialized = false;

  function updateViewSize() {
    if (!svgEl) return;
    const { width, height } = svgEl.getBoundingClientRect();
    if (height > 0) {
      vw = DEFAULT_VIEW_H * (width / height);
      vx = clamp(vx, 0, MAP_W - vw);
      vy = clamp(vy, 0, MAP_H - vh);
    }
  }

  // Touch tracking (pan only, no pinch zoom)
  type TouchState =
    | { kind: 'pan'; startX: number; startY: number; lastX: number; lastY: number; moved: boolean };

  let touchState: TouchState | null = null;

  // ── Coordinate helpers ────────────────────────────────────────────────────

  function screenToWorld(sx: number, sy: number): [number, number] {
    const rect = svgEl.getBoundingClientRect();
    return [
      vx + (sx - rect.left) * (vw / rect.width),
      vy + (sy - rect.top)  * (vh / rect.height),
    ];
  }

  function clamp(val: number, min: number, max: number) {
    return Math.max(min, Math.min(max, val));
  }

  // ── Touch handlers ─────────────────────────────────────────────────────────

  function onTouchStart(e: TouchEvent) {
    if (e.touches.length === 1) {
      touchState = {
        kind: 'pan',
        startX: e.touches[0].clientX, startY: e.touches[0].clientY,
        lastX:  e.touches[0].clientX, lastY:  e.touches[0].clientY,
        moved: false,
      };
    }
  }

  function onTouchMove(e: TouchEvent) {
    if (!touchState) return;

    if (touchState.kind === 'pan' && e.touches.length === 1) {
      const rect = svgEl.getBoundingClientRect();
      const sx = vw / rect.width;
      const sy = vh / rect.height;
      const dx = (e.touches[0].clientX - touchState.lastX) * sx;
      const dy = (e.touches[0].clientY - touchState.lastY) * sy;
      const moved = Math.hypot(
        e.touches[0].clientX - touchState.startX,
        e.touches[0].clientY - touchState.startY
      ) > 6;

      pendingPanDx -= dx;
      pendingPanDy -= dy;
      touchState = { ...touchState, lastX: e.touches[0].clientX, lastY: e.touches[0].clientY, moved };
    }
  }

  function onTouchEnd(e: TouchEvent) {
    if (touchState?.kind === 'pan' && !touchState.moved) {
      const [wx, wy] = screenToWorld(touchState.startX, touchState.startY);
      handleTap(wx, wy);
    }
    if (e.touches.length === 0) touchState = null;
  }

  // Mouse pan for desktop
  let mouseDown = false;
  let mouseLastX = 0, mouseLastY = 0;
  // Pending pan deltas accumulated from input events, applied in animLoop to keep
  // viewBox and sprite positions in the same RAF frame (avoids 1-frame jump).
  let pendingPanDx = 0, pendingPanDy = 0;

  function onMouseDown(e: MouseEvent) {
    mouseDown = true;
    mouseLastX = e.clientX;
    mouseLastY = e.clientY;
  }

  function onMouseMove(e: MouseEvent) {
    if (!mouseDown) return;
    const rect = svgEl.getBoundingClientRect();
    const sx = vw / rect.width;
    const sy = vh / rect.height;
    pendingPanDx -= (e.clientX - mouseLastX) * sx;
    pendingPanDy -= (e.clientY - mouseLastY) * sy;
    mouseLastX = e.clientX;
    mouseLastY = e.clientY;
  }

  function onMouseUp() { mouseDown = false; }

  // ── Tap to select ─────────────────────────────────────────────────────────

  function handleTap(wx: number, wy: number) {
    const gs = $gameState;
    if (!gs) return;

    // Check units first (smaller hit area)
    for (const player of Object.values(gs.players)) {
      for (const unit of Object.values(player.units)) {
        if (Math.hypot(unit.x - wx, unit.y - wy) < 0.7) {
          selectedUnit.set(unit);
          selectedBuilding.set(null);
          return;
        }
      }
    }

    // Check buildings
    for (const player of Object.values(gs.players)) {
      for (const b of Object.values(player.buildings)) {
        const size = BUILDING_SIZES[b.building_type];
        if (wx >= b.x - size.w / 2 && wx < b.x + size.w / 2 && wy >= b.y - size.h / 2 && wy < b.y + size.h / 2) {
          selectedBuilding.set(b);
          selectedUnit.set(null);
          return;
        }
      }
    }

    // Tap on empty: deselect
    selectedUnit.set(null);
    selectedBuilding.set(null);
  }

  // ── Derived rendering data ────────────────────────────────────────────────

  $: gs = $gameState;
  $: myId = $myPlayerId;
  $: isObserver = !myId && !!gs;

  // When game state changes, record new server positions for each unit.
  // This is done via an explicit store subscription in onMount (NOT a $: reactive block)
  // to avoid unitRenderStates being tracked as a reactive dependency, which would cause
  // the $: block to re-run on every animLoop frame and reset updateTime each frame.
  function onGameStateUpdate(newGs: typeof gs) {
    if (!newGs) return;
    const now = performance.now();
    const activeIds = new Set<string>();

    for (const player of Object.values(newGs.players)) {
      for (const u of Object.values(player.units)) {
        activeIds.add(u.id);
        const prev = unitRenderStates[u.id];
        if (!prev) {
          unitRenderStates[u.id] = {
            renderX: u.x, renderY: u.y,
            prevX: u.x, prevY: u.y,
            nextX: u.x, nextY: u.y,
            angle: 0,
            updateTime: now,
          };
        } else {
          const dx = u.x - prev.nextX;
          const dy = u.y - prev.nextY;
          const dist = Math.hypot(dx, dy);
          // atan2: dy/dx in SVG coords (y down). Sprites face north (up) by default → +90°
          let angle = prev.angle;
          if (dist > 0.02) {
            angle = Math.atan2(dy, dx) * (180 / Math.PI) + 90;
          } else if (u.attack_target_id || u.attack_target_building_id) {
            // Unit is stationary but shooting — face the target
            let tx: number | undefined, ty: number | undefined;
            if (u.attack_target_id) {
              for (const p of Object.values(newGs.players)) {
                const t = p.units[u.attack_target_id];
                if (t) { tx = t.x; ty = t.y; break; }
              }
            } else if (u.attack_target_building_id) {
              for (const p of Object.values(newGs.players)) {
                const t = p.buildings[u.attack_target_building_id];
                if (t) { tx = t.x; ty = t.y; break; }
              }
            }
            if (tx !== undefined && ty !== undefined) {
              const adx = tx - u.x;
              const ady = ty - u.y;
              if (Math.hypot(adx, ady) > 0.01) {
                angle = Math.atan2(ady, adx) * (180 / Math.PI) + 90;
              }
            }
          }
          unitRenderStates[u.id] = {
            ...prev,
            prevX: prev.renderX,
            prevY: prev.renderY,
            nextX: u.x,
            nextY: u.y,
            angle,
            updateTime: now,
          };
        }
      }
    }

    // Remove stale entries for dead units
    for (const id of Object.keys(unitRenderStates)) {
      if (!activeIds.has(id)) delete unitRenderStates[id];
    }

    // Track attack flashes: trigger only when attack_cooldown resets (unit just fired)
    for (const player of Object.values(newGs.players)) {
      for (const u of Object.values(player.units)) {
        const prev = prevCooldowns[u.id];
        if (prev !== undefined && u.attack_cooldown > prev) {
          beamFlashTimes[u.id] = now;
        }
        prevCooldowns[u.id] = u.attack_cooldown;
      }
    }
    for (const id of Object.keys(prevCooldowns)) {
      if (!activeIds.has(id)) delete prevCooldowns[id];
    }

    unitRenderStates = { ...unitRenderStates };

    // Accumulate fog explored sources (reliable here: explicit subscription, not $: block)
    accumulateExploredSources(newGs as GameState);
  }

  function lerp(a: number, b: number, t: number) { return a + (b - a) * t; }

  function animLoop() {
    const now = performance.now();
    let changed = false;

    // Apply accumulated pan deltas here so viewBox + sprite positions update in the same frame.
    if (pendingPanDx !== 0 || pendingPanDy !== 0) {
      vx = clamp(vx + pendingPanDx, 0, MAP_W - vw);
      vy = clamp(vy + pendingPanDy, 0, MAP_H - vh);
      pendingPanDx = 0;
      pendingPanDy = 0;
    }

    for (const id of Object.keys(unitRenderStates)) {
      const s = unitRenderStates[id];
      const t = Math.min((now - s.updateTime) / TICK_MS, 1);
      const nx = lerp(s.prevX, s.nextX, t);
      const ny = lerp(s.prevY, s.nextY, t);
      if (Math.abs(nx - s.renderX) > 0.0005 || Math.abs(ny - s.renderY) > 0.0005) {
        unitRenderStates[id] = { ...s, renderX: nx, renderY: ny };
        changed = true;
      }
    }

    if (changed) unitRenderStates = { ...unitRenderStates };

    // Fade beam opacities
    let beamsChanged = false;
    for (const [id, t] of Object.entries(beamFlashTimes)) {
      const elapsed = now - t;
      const newOp = elapsed < BEAM_DURATION_MS ? 0.85 * (1 - elapsed / BEAM_DURATION_MS) + 0.1 : 0;
      if (Math.abs((beamOpacities[id] ?? 0) - newOp) > 0.005) {
        beamOpacities[id] = newOp;
        beamsChanged = true;
      }
      if (newOp <= 0) {
        delete beamFlashTimes[id];
        delete beamOpacities[id];
        beamsChanged = true;
      }
    }
    if (beamsChanged) beamOpacities = { ...beamOpacities };

    rafId = requestAnimationFrame(animLoop);
  }

  // Center camera on own Command Center on first game state received
  $: if (!cameraInitialized && gs && myId) {
    const myP = gs.players[myId];
    const cc = myP && Object.values(myP.buildings).find((b) => b.building_type === 'command_center');
    if (cc) {
      const cx = cc.x;
      const cy = cc.y;
      vx = Math.max(0, Math.min(MAP_W - vw, cx - vw / 2));
      vy = Math.max(0, Math.min(MAP_H - vh, cy - vh / 2));
      cameraInitialized = true;
    }
  }

  // Observer: center camera on map center
  $: if (!cameraInitialized && gs && isObserver) {
    vx = Math.max(0, MAP_W / 2 - vw / 2);
    vy = Math.max(0, MAP_H / 2 - vh / 2);
    cameraInitialized = true;
  }

  $: resources = gs?.game_map.resources ?? [];

  const PLAYER_COLORS_BG   = ['#1a3a6b', '#6b1a1a'];
  const PLAYER_COLORS_BORDER = ['#58a6ff', '#f85149'];

  $: allBuildings = gs
    ? Object.values(gs.players).flatMap((p, pi) =>
        Object.values(p.buildings).map((b) => ({
          b,
          isOwn: p.player_id === myId,
          colorBg: isObserver ? PLAYER_COLORS_BG[pi % 2] : (p.player_id === myId ? '#1a3a6b' : '#6b1a1a'),
          colorBorder: isObserver ? PLAYER_COLORS_BORDER[pi % 2] : (p.player_id === myId ? '#58a6ff' : '#f85149'),
        }))
      )
    : [];

  $: allUnits = gs
    ? Object.values(gs.players).flatMap((p, pi) =>
        Object.values(p.units).map((u) => ({
          u,
          isOwn: p.player_id === myId,
          color: isObserver ? PLAYER_COLORS_BORDER[pi % 2] : (p.player_id === myId ? '#58a6ff' : '#f85149'),
        }))
      )
    : [];

  // Building center positions for beam targeting
  $: buildingPosMap = gs
    ? Object.fromEntries(
        Object.values(gs.players).flatMap((p) =>
          Object.values(p.buildings).map((b) => {
            return [b.id, { x: b.x, y: b.y }];
          })
        )
      )
    : {} as Record<string, { x: number; y: number }>;

  // Fog of war: visible cells from own units and buildings
  $: visibleCells = (() => {
    const set = new Set<string>();
    if (!gs || !myId) return set;
    const myP = gs.players[myId];
    if (!myP) return set;
    for (const u of Object.values(myP.units)) {
      const r = visionRadiusUnit(u);
      cellsInRadius(u.x, u.y, r).forEach((c) => set.add(c));
    }
    for (const b of Object.values(myP.buildings)) {
      if (b.status === 'destroyed') continue;
      const r = visionRadiusBuilding(b);
      cellsInRadius(b.x, b.y, r).forEach((c) => set.add(c));
    }
    return set;
  })();

  // Persist explored cells (once seen, stay in explored). Reassign so Svelte reactivity updates the fog masks.
  let exploredCells = new Set<string>();
  $: {
    const next = new Set(exploredCells);
    visibleCells.forEach((c) => next.add(c));
    if (next.size !== exploredCells.size) exploredCells = next;
  }

  // Fog of war: circle-based vision sources for SVG mask rendering.
  // getVisibleSources is called INLINE in the template (not via $: block) so that
  // Svelte's template renderer tracks gs/myId as dependencies — same pattern as the
  // original buildFogRects, which avoids the production-build reactive scheduling issue.
  type FogSource = { x: number; y: number; r: number };

  function getVisibleSources(
    _gs: typeof gs,
    _myId: typeof myId,
  ): FogSource[] {
    const sources: FogSource[] = [];
    if (!_gs || !_myId) return sources;
    const myP = _gs.players[_myId];
    if (!myP) return sources;
    for (const u of Object.values(myP.units)) {
      sources.push({ x: u.x, y: u.y, r: visionRadiusUnit(u) });
    }
    for (const b of Object.values(myP.buildings)) {
      if (b.status === 'destroyed') continue;
      sources.push({ x: b.x, y: b.y, r: visionRadiusBuilding(b) });
    }
    return sources;
  }

  // Explored sources: accumulated in onGameStateUpdate (explicit subscription, reliable in prod).
  let exploredSources: FogSource[] = [];
  let _exploredSourceKeys = new Set<string>();

  function accumulateExploredSources(newGs: GameState) {
    if (!myId) return;
    const myP = newGs.players[myId];
    if (!myP) return;
    let changed = false;
    for (const u of Object.values(myP.units) as Unit[]) {
      const r = visionRadiusUnit(u);
      const key = `${Math.round(u.x * 2)},${Math.round(u.y * 2)},${r}`;
      if (!_exploredSourceKeys.has(key)) {
        _exploredSourceKeys.add(key);
        exploredSources.push({ x: u.x, y: u.y, r });
        changed = true;
      }
    }
    for (const b of Object.values(myP.buildings) as Building[]) {
      if (b.status === 'destroyed') continue;
      const r = visionRadiusBuilding(b);
      const key = `${Math.round(b.x * 2)},${Math.round(b.y * 2)},${r}`;
      if (!_exploredSourceKeys.has(key)) {
        _exploredSourceKeys.add(key);
        exploredSources.push({ x: b.x, y: b.y, r });
        changed = true;
      }
    }
    if (changed) exploredSources = [...exploredSources];
  }

  // Expose viewport and fog for Minimap
  $: mapViewport.set({ vx, vy, vw, vh });
  $: mapVisibleCells.set(visibleCells);
  $: mapExploredCells.set(exploredCells);

  // Recenter when Minimap requests (click on minimap) — subscribe so it always runs
  let unsubCenter: (() => void) | null = null;
  let unsubGameState: (() => void) | null = null;
  onMount(() => {
    updateViewSize();
    const ro = new ResizeObserver(updateViewSize);
    ro.observe(svgEl);

    unsubCenter = mapCenterRequest.subscribe((req) => {
      if (!req) return;
      vx = clamp(req.cx - vw / 2, 0, MAP_W - vw);
      vy = clamp(req.cy - vh / 2, 0, MAP_H - vh);
      mapCenterRequest.set(null);
    });
    unsubGameState = gameState.subscribe(onGameStateUpdate);
    rafId = requestAnimationFrame(animLoop);

    return () => ro.disconnect();
  });
  onDestroy(() => {
    unsubCenter?.();
    unsubGameState?.();
    if (rafId) cancelAnimationFrame(rafId);
  });

  function isVisible(wx: number, wy: number): boolean {
    if (isObserver) return true;
    const ix = Math.floor(wx), iy = Math.floor(wy);
    if (ix < 0 || ix >= MAP_W || iy < 0 || iy >= MAP_H) return false;
    return visibleCells.has(`${ix},${iy}`);
  }

  function hpColor(hp: number, max: number): string {
    const r = hp / max;
    if (r > 0.6) return '#3fb950';
    if (r > 0.3) return '#d29922';
    return '#f85149';
  }

  function buildingOpacity(status: string): number {
    if (status === 'destroyed') return 0.2;
    if (status === 'constructing') return 0.55;
    return 1;
  }

  // ── Walkable overlay (debug) ───────────────────────────────────────────────
  const SCALE = MAP_W / 100.0;
  let showWalkable = false;
  let showNavPoints = false;
  let walkablePolygons: string[] = [];
  let navPoints: [number, number][] = [];

  onMount(() => {
    fetch(`${backendUrl()}/static/walkable.json`)
      .then((r) => r.json())
      .then((data) => {
        walkablePolygons = (data.polygons ?? []).map((poly: number[][]) =>
          poly.map(([x, y]) => `${x * SCALE},${y * SCALE}`).join(' ')
        );
      })
      .catch(() => {});

    fetch(`${backendUrl()}/static/compiled_map.json`)
      .then((r) => r.json())
      .then((data) => {
        navPoints = (data.nav_points ?? []) as [number, number][];
      })
      .catch(() => {});

    const onKey = (e: KeyboardEvent) => {
      if (e.altKey && (e.key === 'w' || e.key === 'W')) showWalkable = !showWalkable;
      if (e.altKey && (e.key === 'n' || e.key === 'N')) showNavPoints = !showNavPoints;
    };
    window.addEventListener('keydown', onKey);
    return () => window.removeEventListener('keydown', onKey);
  });
</script>

<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
<svg
  bind:this={svgEl}
  role="img"
  aria-label="Game map"
  viewBox="{vx} {vy} {vw} {vh}"
  class="map-svg"
  on:touchstart|nonpassive={onTouchStart}
  on:touchmove|nonpassive={onTouchMove}
  on:touchend={onTouchEnd}
  on:mousedown={onMouseDown}
  on:mousemove={onMouseMove}
  on:mouseup={onMouseUp}
  on:mouseleave={onMouseUp}
>
  <!-- Background: MAP.png (game grid 40x40; image scaled to fit) -->
  {#if mapImageUrl}
    <image href={mapImageUrl} x="0" y="0" width={MAP_W} height={MAP_H} preserveAspectRatio="xMidYMid slice" />
  {:else}
    <rect x="0" y="0" width={MAP_W} height={MAP_H} fill="#0b1a0b" />
  {/if}

  <!-- Grid lines (subtle) -->
  {#each { length: MAP_W + 1 } as _, i}
    <line x1={i} y1={0} x2={i} y2={MAP_H} stroke="rgba(255,255,255,0.025)" stroke-width="0.02" />
  {/each}
  {#each { length: MAP_H + 1 } as _, i}
    <line x1={0} y1={i} x2={MAP_W} y2={i} stroke="rgba(255,255,255,0.025)" stroke-width="0.02" />
  {/each}

  <!-- Geographic landmark labels -->
  <defs>
    <filter id="landmark-glow" x="-50%" y="-50%" width="200%" height="200%">
      <feGaussianBlur in="SourceGraphic" stdDeviation="0.3" result="blur" />
      <feMerge><feMergeNode in="blur" /><feMergeNode in="SourceGraphic" /></feMerge>
    </filter>
  </defs>
  {#each MAP_LANDMARKS as lm}
    <g pointer-events="none" opacity="0.55">
      <text
        x={lm.x} y={lm.y}
        text-anchor="middle"
        dominant-baseline="middle"
        font-size="1.35"
        font-family="'Courier New', monospace"
        font-weight="600"
        letter-spacing="0.08"
        fill="rgba(200,220,255,0.9)"
        stroke="rgba(0,0,0,0.7)"
        stroke-width="0.25"
        paint-order="stroke"
        filter="url(#landmark-glow)"
        style="text-transform: uppercase;"
      >{lm.name}</text>
    </g>
  {/each}

  <!-- Resources -->
  {#each resources as res}
    {#if res.resource_type === 'mineral' && res.amount > 0}
      <image
        href="{spriteBase}/resources/mineral.png"
        x={res.x + 0.05} y={res.y + 0.05}
        width="0.9" height="0.9"
        preserveAspectRatio="xMidYMid meet"
      />
    {:else if res.resource_type === 'geyser'}
      <image
        href="{spriteBase}/resources/geyser.png"
        x={res.x + 0.05} y={res.y + 0.05}
        width="0.9" height="0.9"
        opacity={res.has_refinery ? 0.5 : 1}
        preserveAspectRatio="xMidYMid meet"
      />
    {/if}
  {/each}

  <!-- Buildings (enemies only if visible in fog of war) -->
  {#each allBuildings as { b, isOwn, colorBg, colorBorder }}
    {#if b.status !== 'destroyed' && (isOwn || isVisible(b.x, b.y))}
      {@const size = BUILDING_SIZES[b.building_type]}
      {@const color = colorBg}
      {@const border = colorBorder}
      {@const bx = b.x - size.w / 2}
      {@const by = b.y - size.h / 2}

      <g opacity={buildingOpacity(b.status)}>
        <!-- Selection border -->
        {#if b.id === $selectedBuilding?.id}
          <rect
            x={bx + 0.04} y={by + 0.04}
            width={size.w - 0.08} height={size.h - 0.08}
            fill="none"
            stroke={border}
            stroke-width="0.12"
            rx="0.15"
          />
        {/if}

        <!-- Player color tint background -->
        <rect
          x={bx + 0.06} y={by + 0.06}
          width={size.w - 0.12} height={size.h - 0.12}
          fill={color}
          rx="0.12"
          opacity="0.55"
        />

        <!-- Building sprite -->
        <image
          href="{spriteBase}/buildings/{b.building_type}.png"
          x={bx + 0.08} y={by + 0.08}
          width={size.w - 0.16} height={size.h - 0.16}
          preserveAspectRatio="xMidYMid meet"
        />

        <!-- Under construction: dashed overlay -->
        {#if b.status === 'constructing'}
          <rect
            x={bx + 0.06} y={by + 0.06}
            width={size.w - 0.12} height={size.h - 0.12}
            fill="none"
            stroke={border}
            stroke-width="0.07"
            stroke-dasharray="0.25 0.15"
            rx="0.15"
            opacity="0.8"
          />
        {/if}

        <!-- HP bar -->
        <rect
          x={bx + 0.1} y={by + size.h - 0.22}
          width={size.w - 0.2} height="0.12"
          fill="rgba(0,0,0,0.5)"
          rx="0.06"
        />
        <rect
          x={bx + 0.1} y={by + size.h - 0.22}
          width={(size.w - 0.2) * (b.hp / b.max_hp)}
          height="0.12"
          fill={hpColor(b.hp, b.max_hp)}
          rx="0.06"
        />

        <!-- Production dots -->
        {#if b.production_queue.length > 0}
          {#each { length: Math.min(b.production_queue.length, 5) } as _, qi}
            <circle
              cx={bx + 0.3 + qi * 0.28} cy={by + 0.22}
              r="0.1"
              fill={border}
              opacity="0.9"
            />
          {/each}
        {/if}
      </g>
    {/if}
  {/each}

  <!-- Attack beams (rendered below units, above buildings) -->
  <defs>
    <filter id="beam-glow" x="-80%" y="-80%" width="260%" height="260%">
      <feGaussianBlur in="SourceGraphic" stdDeviation="0.18" result="blur" />
      <feMerge><feMergeNode in="blur" /><feMergeNode in="SourceGraphic" /></feMerge>
    </filter>
  </defs>
  {#each allUnits as { u, isOwn } (u.id)}
    {@const op = beamOpacities[u.id] ?? 0}
    {#if op > 0 && (isOwn || isVisible(u.x, u.y))}
      {@const beam = UNIT_BEAM[u.unit_type]}
      {@const isSiegedTank = u.unit_type === 'tank' && u.is_sieged}
      {@const bw = isSiegedTank ? beam.width * 1.6 : beam.width}
      {@const srcRs = unitRenderStates[u.id]}
      {@const srcX = srcRs?.renderX ?? u.x}
      {@const srcY = srcRs?.renderY ?? u.y}
      {@const targetPos = u.attack_target_id
        ? (() => { const t = allUnits.find(a => a.u.id === u.attack_target_id); const trs = unitRenderStates[u.attack_target_id]; return t ? { x: trs?.renderX ?? t.u.x, y: trs?.renderY ?? t.u.y } : null; })()
        : u.attack_target_building_id
          ? (buildingPosMap[u.attack_target_building_id] ?? null)
          : null}
      {#if targetPos}
        <!-- Glow halo -->
        <line
          x1={srcX} y1={srcY} x2={targetPos.x} y2={targetPos.y}
          stroke={beam.color}
          stroke-width={bw * 3}
          stroke-linecap="round"
          opacity={op * 0.25}
          filter="url(#beam-glow)"
          pointer-events="none"
        />
        <!-- Core beam -->
        <line
          x1={srcX} y1={srcY} x2={targetPos.x} y2={targetPos.y}
          stroke={beam.color}
          stroke-width={bw}
          stroke-linecap="round"
          stroke-dasharray={beam.dasharray ?? ''}
          opacity={op}
          pointer-events="none"
        />
      {/if}
    {/if}
  {/each}

  <!-- Units (enemies only if visible in fog of war) -->
  {#each allUnits as { u, isOwn, color } (u.id)}
    {#if isOwn || isVisible(u.x, u.y)}
    {@const rs = unitRenderStates[u.id]}
    {@const rx = rs?.renderX ?? u.x}
    {@const ry = rs?.renderY ?? u.y}
    {@const angle = rs?.angle ?? 0}
    {@const isSelected = u.id === $selectedUnit?.id}
    {@const isLargeUnit = u.unit_type === 'goliath' || u.unit_type === 'tank' || u.unit_type === 'wraith'}
    {@const isTank = u.unit_type === 'tank'}
    {@const spriteHalf = isTank ? 1.52 : isLargeUnit ? 0.76 : 0.38}
    {@const spriteSize = isTank ? 3.04 : isLargeUnit ? 1.52 : 0.76}

    <g transform="translate({rx},{ry})">
      <!-- Selection ring -->
      {#if isSelected}
        <circle r="0.55" fill="none" stroke={color} stroke-width="0.1" opacity="0.7" />
      {/if}

      <!-- Shadow -->
      <circle r="0.38" fill="rgba(0,0,0,0.35)" />

      <!-- Health ring: black background + player color arc proportional to HP -->
      <circle r="0.42" fill="none" stroke="black" stroke-width="0.1" />
      <circle r="0.42" fill="none" stroke={color} stroke-width="0.1"
        stroke-dasharray="{2 * Math.PI * 0.42 * (u.hp / u.max_hp)} {2 * Math.PI * 0.42 * (1 - u.hp / u.max_hp)}"
        transform="rotate(-90)"
      />

      <!-- Unit sprite, rotated toward movement direction -->
      <image
        href="{spriteBase}/units/{UNIT_SPRITE[u.unit_type]}"
        x={-spriteHalf} y={-spriteHalf}
        width={spriteSize} height={spriteSize}
        opacity={u.is_cloaked ? 0.35 : 1}
        preserveAspectRatio="xMidYMid meet"
        transform="rotate({angle})"
      />


      <!-- Siege mode indicator -->
      {#if u.is_sieged}
        <polygon points="0,-0.48 0.14,-0.28 -0.14,-0.28" fill="#ffa726" />
      {/if}

      <!-- Cloaked indicator -->
      {#if u.is_cloaked}
        <circle r="0.32" fill="none" stroke="#b39ddb" stroke-width="0.08" stroke-dasharray="0.1 0.08" />
      {/if}
    </g>
    {/if}
  {/each}

  <!-- Walkable zone debug overlay (toggle with W key) -->
  {#if showWalkable}
    {#each walkablePolygons as pts}
      <polygon
        points={pts}
        fill="rgba(0,255,100,0.18)"
        stroke="rgba(0,255,100,0.7)"
        stroke-width="0.12"
        pointer-events="none"
      />
    {/each}
  {/if}

  <!-- Nav-points debug overlay (toggle with N key) -->
  {#if showNavPoints}
    {#each navPoints as [nx, ny]}
      <circle cx={nx} cy={ny} r="0.18" fill="rgba(255,200,0,0.8)" pointer-events="none" />
    {/each}
  {/if}

  <!-- Fog of war: single black rect covering the viewport, with vision circles masked out.
       getVisibleSources is called INLINE so Svelte's template renderer tracks gs/myId as
       dependencies — avoids the production-build $: scheduling issue (same pattern as the
       original buildFogRects). exploredSources is populated via onGameStateUpdate. -->
  {#if !isObserver}
    {@const _visSrc = getVisibleSources(gs, myId)}
    <defs>
      <mask id="fog-explored-mask" maskUnits="userSpaceOnUse" x="0" y="0" width={MAP_W} height={MAP_H}>
        <rect x="0" y="0" width={MAP_W} height={MAP_H} fill="white"/>
        {#each exploredSources as s}
          <circle cx={s.x} cy={s.y} r={s.r} fill="black"/>
        {/each}
      </mask>
      <mask id="fog-visible-mask" maskUnits="userSpaceOnUse" x="0" y="0" width={MAP_W} height={MAP_H}>
        <rect x="0" y="0" width={MAP_W} height={MAP_H} fill="white"/>
        {#each _visSrc as s}
          <circle cx={s.x} cy={s.y} r={s.r} fill="black"/>
        {/each}
      </mask>
    </defs>
    <!-- Unexplored fog (dark) -->
    <rect x={vx} y={vy} width={vw} height={vh} fill="rgba(0,0,0,0.85)" mask="url(#fog-explored-mask)" pointer-events="none"/>
    <!-- Explored but not visible (dim) -->
    <rect x={vx} y={vy} width={vw} height={vh} fill="rgba(0,0,0,0.45)" mask="url(#fog-visible-mask)" pointer-events="none"/>
  {/if}

  <!-- Tutorial target beacon (rendered above fog so it's always visible) -->
  {#if $isTutorial && $gameState?.tutorial_target_x != null && $gameState?.tutorial_target_y != null}
    {@const bx = $gameState.tutorial_target_x}
    {@const by = $gameState.tutorial_target_y}
    <defs>
      <filter id="beacon-glow" x="-100%" y="-100%" width="300%" height="300%">
        <feGaussianBlur in="SourceGraphic" stdDeviation="0.5" result="blur" />
        <feMerge><feMergeNode in="blur" /><feMergeNode in="SourceGraphic" /></feMerge>
      </filter>
    </defs>
    <g pointer-events="none">
      <!-- Outer pulse ring 1 -->
      <circle cx={bx} cy={by} r="2.2" fill="none" stroke="rgba(255,60,60,0.35)" stroke-width="0.18">
        <animate attributeName="r" values="1.8;3.2;1.8" dur="2s" repeatCount="indefinite" />
        <animate attributeName="opacity" values="0.5;0;0.5" dur="2s" repeatCount="indefinite" />
      </circle>
      <!-- Outer pulse ring 2 (offset phase) -->
      <circle cx={bx} cy={by} r="2.8" fill="none" stroke="rgba(255,60,60,0.2)" stroke-width="0.12">
        <animate attributeName="r" values="2.4;4.0;2.4" dur="2s" begin="1s" repeatCount="indefinite" />
        <animate attributeName="opacity" values="0.4;0;0.4" dur="2s" begin="1s" repeatCount="indefinite" />
      </circle>
      <!-- Inner solid circle -->
      <circle cx={bx} cy={by} r="0.7" fill="rgba(255,40,40,0.85)" filter="url(#beacon-glow)">
        <animate attributeName="r" values="0.6;0.8;0.6" dur="1s" repeatCount="indefinite" />
      </circle>
      <!-- Cross-hair lines -->
      <line x1={bx - 1.4} y1={by} x2={bx - 0.85} y2={by} stroke="rgba(255,80,80,0.75)" stroke-width="0.12" stroke-linecap="round" />
      <line x1={bx + 0.85} y1={by} x2={bx + 1.4} y2={by} stroke="rgba(255,80,80,0.75)" stroke-width="0.12" stroke-linecap="round" />
      <line x1={bx} y1={by - 1.4} x2={bx} y2={by - 0.85} stroke="rgba(255,80,80,0.75)" stroke-width="0.12" stroke-linecap="round" />
      <line x1={bx} y1={by + 0.85} x2={bx} y2={by + 1.4} stroke="rgba(255,80,80,0.75)" stroke-width="0.12" stroke-linecap="round" />
      <!-- Label -->
      <text
        x={bx} y={by - 1.7}
        text-anchor="middle"
        font-size="1.0"
        font-family="'Courier New', monospace"
        font-weight="700"
        fill="#ff4444"
        stroke="rgba(0,0,0,0.8)"
        stroke-width="0.2"
        paint-order="stroke"
        filter="url(#beacon-glow)"
      >OBJECTIVE</text>
    </g>
  {/if}
</svg>

<style>
  .map-svg {
    width: 100%;
    height: 100%;
    display: block;
    cursor: grab;
    touch-action: none;
  }

  .map-svg:active { cursor: grabbing; }
</style>
