'use client';

import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import Map, {
  NavigationControl,
  ScaleControl,
  AttributionControl,
  Source,
  Layer,
  type MapRef,
} from 'react-map-gl/mapbox';
import type { Map as MapboxMap } from 'mapbox-gl';

import DrawingToolbar from './DrawingToolbar';
import OverlayPanel from './OverlayPanel';
import AlignTool from './AlignTool';
import { useDrawing } from '@/hooks/useDrawing';
import {
  fetchOverlayCatalogue,
  fetchOverlayGeoJSON,
  overlayDataUrl,
  imageCorners,
  overlayCategories,
  overlayLegend,
  categoryColorExpression,
  type OverlayMeta,
} from '@/lib/overlays';
import { PARTIES, partyOutlineColor } from '@/lib/parties';
import {
  MAX_BOUNDS,
  MIN_ZOOM,
  MAX_ZOOM,
  CANONICAL_BOUNDS,
  CANONICAL_FIT_PADDING,
  PI_BBOX,
} from '@/lib/mapConfig';
import { loadPIBoundary } from '@/lib/piBoundary';
import type { Feature, FeatureCollection, Polygon, MultiPolygon, Position } from 'geojson';

const MAPBOX_TOKEN = process.env.NEXT_PUBLIC_MAPBOX_TOKEN;

/**
 * Layer the overlay is inserted beneath.
 *
 * `freehand-preview-line` is the earliest of our own layers and is mounted
 * unconditionally, so it is always present by the time an overlay's data has
 * finished loading -- which makes it a safe anchor for keeping overlays under
 * the boundary outline and the user's drawn areas.
 */
const OVERLAY_ANCHOR_LAYER_ID = 'freehand-preview-line';

export default function MapView() {
  const mapRef = useRef<MapRef | null>(null);
  const [mapInstance, setMapInstance] = useState<MapboxMap | null>(null);
  const [isLoading, setIsLoading] = useState(true);
  const [errorMessage, setErrorMessage] = useState<string | null>(null);

  // Whether the map has ever finished loading. Errors before that point mean
  // the map genuinely could not start; errors after it are recoverable.
  const mapLoadedRef = useRef(false);

  const handleLoad = useCallback(() => {
    mapLoadedRef.current = true;
    setIsLoading(false);
    const map = mapRef.current?.getMap() ?? null;
    setMapInstance(map);
  }, []);

  /**
   * Fatal only when the map genuinely cannot start: an error BEFORE the first
   * load that is not tied to a single source -- a bad token, an unreachable
   * style. That is what the full-screen "Map failed to load" message is for.
   *
   * Everything else is recoverable and only logged. Mapbox emits `error` for
   * a satellite tile or overlay image that fails to download (those events
   * name their source), and, once the map is up, for things like a layer
   * re-added in an unexpected order during a style change. Treated as fatal,
   * each of those blanked the entire map over a working one -- at start-up
   * too, where a few dropped tiles on a patchy connection were enough.
   */
  const handleError = useCallback(
    (event: { error: { message: string }; sourceId?: string }) => {
      const message = event.error?.message ?? 'Map failed to load';
      if (mapLoadedRef.current || event.sourceId) {
        console.warn('Map warning (recoverable):', message);
        return;
      }
      setErrorMessage(message);
      setIsLoading(false);
    },
    [],
  );

  const drawing = useDrawing({ map: mapInstance });

  // Visual aids: a thin outline showing where the PI boundary is, plus a
  // soft dark fill over everything *outside* the boundary so the user
  // intuitively keeps strokes inside. The boundary file is the same one
  // used as the drawing constraint, so what's dimmed matches what gets
  // clipped on finish.
  const [boundaryOutline, setBoundaryOutline] = useState<FeatureCollection<
    Polygon | MultiPolygon
  > | null>(null);
  const [outsideMask, setOutsideMask] = useState<Feature<Polygon> | null>(null);

  useEffect(() => {
    let cancelled = false;
    loadPIBoundary()
      .then((fc) => {
        if (cancelled) return;
        setBoundaryOutline(fc);
        setOutsideMask(buildOutsideMask(fc));
      })
      .catch(() => {
        // Non-fatal -- the constraint still applies on finish.
      });
    return () => {
      cancelled = true;
    };
  }, []);

  // ---- Overlays (M1C) ----
  //
  // The catalogue is fetched once; the payload for whichever single overlay is
  // active is fetched on demand and dropped when it is switched off. None of
  // this touches the drawing state, which is what keeps SRS 3.2.3 (you can
  // keep drawing while an overlay is up) and 3.2.4 (your lines survive the
  // overlay being removed) true by construction rather than by careful
  // handling: the overlay is simply another map layer beneath the drawings.
  const [overlays, setOverlays] = useState<OverlayMeta[]>([]);
  const [overlaysLoading, setOverlaysLoading] = useState(true);
  const [overlayError, setOverlayError] = useState<string | null>(null);
  const [activeOverlaySlug, setActiveOverlaySlug] = useState<string | null>(null);
  // Payload is stored WITH the slug it belongs to, so switching overlays never
  // needs a synchronous clear -- data for a slug that is no longer active is
  // simply not rendered, and is replaced when the new payload arrives.
  const [overlayPayload, setOverlayPayload] = useState<{
    slug: string;
    data: FeatureCollection;
  } | null>(null);

  useEffect(() => {
    const controller = new AbortController();
    fetchOverlayCatalogue(controller.signal)
      .then((list) => {
        setOverlays(list);
        setOverlayError(null);
      })
      .catch((err: unknown) => {
        if (controller.signal.aborted) return;
        // A missing overlay service must never take the map down with it --
        // drawing is the primary function and works without any overlay.
        setOverlayError(
          err instanceof Error && err.message
            ? `Overlays unavailable: ${err.message}`
            : 'Overlays unavailable',
        );
      })
      .finally(() => {
        if (!controller.signal.aborted) setOverlaysLoading(false);
      });
    return () => controller.abort();
  }, []);

  const activeOverlay = useMemo(
    () => overlays.find((o) => o.slug === activeOverlaySlug) ?? null,
    [overlays, activeOverlaySlug],
  );

  useEffect(() => {
    if (!activeOverlay || activeOverlay.kind !== 'vector') return;
    const controller = new AbortController();
    const { slug, title } = activeOverlay;
    fetchOverlayGeoJSON(slug, controller.signal)
      .then((fc) => setOverlayPayload({ slug, data: fc as FeatureCollection }))
      .catch((err: unknown) => {
        if (controller.signal.aborted) return;
        setOverlayError(
          err instanceof Error && err.message
            ? `Could not load “${title}”: ${err.message}`
            : `Could not load “${title}”`,
        );
      });
    return () => controller.abort();
  }, [activeOverlay]);

  // Georeferencing mode for scanned maps: `?align=<slug>`. Read once --
  // MapView is client-only (ssr: false in MapClient), so `window` is always
  // available here and there is no hydration mismatch to worry about.
  const [alignSlug] = useState<string | null>(() =>
    new URLSearchParams(window.location.search).get('align'),
  );

  // While aligning, park the drawing tools in Select mode. Freehand is the
  // default and starts a stroke on any map click -- including a click that
  // lands on one of the scan's corner handles.
  const setDrawingMode = drawing.setMode;
  useEffect(() => {
    if (alignSlug && mapInstance) setDrawingMode('select');
  }, [alignSlug, mapInstance, setDrawingMode]);

  // Where the active scan sits, if it is a georeferenced raster.
  const rasterCorners = useMemo(
    () => (activeOverlay?.kind === 'raster' ? imageCorners(activeOverlay) : null),
    [activeOverlay],
  );

  /** Payload for the overlay currently selected, or null while it loads. */
  const overlayData =
    overlayPayload && overlayPayload.slug === activeOverlaySlug
      ? overlayPayload.data
      : null;

  // Colour each feature by its category when the overlay declares one --
  // Areas A, B and C being the case this exists for.
  const overlayFillColor = useMemo(() => {
    const fallback = activeOverlay?.fillColor ?? '#38bdf8';
    if (!overlayData || !activeOverlay?.categoryProperty) return fallback;
    const categories = overlayCategories(overlayData, activeOverlay.categoryProperty);
    return categoryColorExpression(categories, activeOverlay.categoryProperty, fallback);
  }, [overlayData, activeOverlay]);

  // Legend for the active overlay, coloured exactly as the map draws it.
  const overlayLegendEntries = useMemo(() => {
    if (!overlayData || !activeOverlay?.categoryProperty) return [];
    return overlayLegend(
      overlayData,
      activeOverlay.categoryProperty,
      activeOverlay.fillColor ?? '#38bdf8',
    );
  }, [overlayData, activeOverlay]);

  // Compute the cursor state. The container gets a data-cursor-state
  // attribute that CSS uses with !important to lock the cursor in across
  // Mapbox/terra-draw hover events.
  //   "idle"     -> open hand (in draw mode, no stroke active, no beat)
  //   "drawing"  -> pointer (index finger) while a stroke is in progress
  //   "beat"     -> open hand for ~1 s after a polygon finishes
  //   "select"   -> default (browser handles hover)
  let cursorState: 'idle' | 'drawing' | 'beat' | 'select';
  if (drawing.mode === 'select') {
    cursorState = 'select';
  } else if (drawing.postFinishBeat) {
    cursorState = 'beat';
  } else if (drawing.isDrawingActive) {
    cursorState = 'drawing';
  } else {
    cursorState = 'idle';
  }

  useEffect(() => {
    if (!mapInstance) return;
    const canvas = mapInstance.getCanvas();
    // Set an inline default; CSS rules override on hover via !important.
    if (cursorState === 'drawing') canvas.style.cursor = 'pointer';
    else if (cursorState === 'select') canvas.style.cursor = '';
    else canvas.style.cursor = 'grab';
  }, [cursorState, mapInstance]);

  // Escape cancels any in-progress stroke (returns to idle).
  useEffect(() => {
    const onKey = (e: KeyboardEvent) => {
      if (e.key === 'Escape') {
        drawing.cancelInProgress();
      }
    };
    window.addEventListener('keydown', onKey);
    return () => window.removeEventListener('keydown', onKey);
  }, [drawing]);

  // Edge-pan while drawing. When the cursor approaches a screen edge mid-
  // stroke, gently scroll the map so the user can extend the line past the
  // current viewport. Runs only while a stroke is active -- ordinary cursor
  // movement (or movement in Select mode) never triggers this.
  const isDrawingActiveRef = useRef(drawing.isDrawingActive);
  useEffect(() => {
    isDrawingActiveRef.current = drawing.isDrawingActive;
  }, [drawing.isDrawingActive]);

  // True while the camera is still showing the canonical crop, i.e. nothing
  // but our own canonical fits has moved it. Cleared by any user pan/zoom and
  // by edge-pan; set again by Reset view and by the P5 tether. See the resize
  // re-fit below for why this matters.
  const viewIsCanonicalRef = useRef(true);

  useEffect(() => {
    if (!mapInstance) return;
    const EDGE_PX = 30;
    // If no fresh mousemove arrives within IDLE_MS, stop panning. Without
    // this, pausing the cursor near a screen edge mid-draw leaves the
    // tick loop running with the last edge-vector and the map drifts on
    // its own -- which the tester saw as "map view drifted/zoomed in
    // noticeably" during pauses.
    const IDLE_MS = 100;
    // Pan speed is zoom-adaptive: at ERF's high zooms the same px/frame moves
    // the map much less real-world distance, so we scale down as zoom rises.
    // Formula picks ~3 px/frame at MIN_ZOOM (~5.8) and ~0.75 px/frame at
    // MAX_ZOOM (15). This fixes Bill's "pan goes too fast when zoomed in"
    // complaint (video 3); halved again per P1 (5 Aug/12 Aug feedback --
    // still too fast at screen edge while drawing).
    const speedForZoom = (z: number): number => {
      const t = Math.max(0, Math.min(1, (z - MIN_ZOOM) / (MAX_ZOOM - MIN_ZOOM)));
      return 3 - t * 2.25;
    };
    let rafId: number | null = null;
    let lastVec: { x: number; y: number } | null = null;
    let lastMoveTs = 0;

    const tick = () => {
      if (!lastVec || !isDrawingActiveRef.current) {
        rafId = null;
        return;
      }
      // Idle guard: if the cursor hasn't produced a fresh mousemove within
      // IDLE_MS, treat the edge-pan as expired and stop.
      if (performance.now() - lastMoveTs > IDLE_MS) {
        lastVec = null;
        rafId = null;
        return;
      }
      // Edge-pan is programmatic, so it carries no originalEvent -- mark the
      // view as moved here, or a later resize would snap it back mid-work.
      viewIsCanonicalRef.current = false;
      mapInstance.panBy([lastVec.x, lastVec.y], { duration: 0, animate: false });
      rafId = requestAnimationFrame(tick);
    };

    const onMove = (e: mapboxgl.MapMouseEvent) => {
      if (!isDrawingActiveRef.current) {
        lastVec = null;
        return;
      }
      const canvas = mapInstance.getCanvas();
      const w = canvas.clientWidth;
      const h = canvas.clientHeight;
      const speed = speedForZoom(mapInstance.getZoom());
      let dx = 0;
      let dy = 0;
      if (e.point.x < EDGE_PX) dx = -speed;
      else if (e.point.x > w - EDGE_PX) dx = speed;
      if (e.point.y < EDGE_PX) dy = -speed;
      else if (e.point.y > h - EDGE_PX) dy = speed;
      if (dx === 0 && dy === 0) {
        lastVec = null;
        return;
      }
      lastVec = { x: dx, y: dy };
      lastMoveTs = performance.now();
      if (rafId === null) {
        rafId = requestAnimationFrame(tick);
      }
    };

    mapInstance.on('mousemove', onMove);
    return () => {
      mapInstance.off('mousemove', onMove);
      if (rafId !== null) cancelAnimationFrame(rafId);
    };
  }, [mapInstance]);

  const isDrawingMode = drawing.mode === 'freehand' || drawing.mode === 'polygon';

  // Bridge: terra-draw's hit-test only sees its own line-string outline.
  // Clicks on the *interior* of our coloured fill never reach it. In Select
  // mode, listen on Mapbox for clicks on our fill layers and programmatically
  // tell terra-draw which feature was hit.
  const selectFeatureByIdRef = useRef(drawing.selectFeatureById);
  const drawingModeRef = useRef(drawing.mode);
  useEffect(() => {
    selectFeatureByIdRef.current = drawing.selectFeatureById;
  }, [drawing.selectFeatureById]);
  useEffect(() => {
    drawingModeRef.current = drawing.mode;
  }, [drawing.mode]);

  useEffect(() => {
    if (!mapInstance) return;
    const fillLayerIds = PARTIES.map((p) => `fill-${p.id}`);
    const onClick = (e: mapboxgl.MapMouseEvent) => {
      if (drawingModeRef.current !== 'select') return;
      const hits = mapInstance.queryRenderedFeatures(e.point, { layers: fillLayerIds });
      if (hits.length === 0) return;
      // Prefer the top-level feature id; fall back to properties.id (which
      // we mirror at source time as a `promoteId` target).
      const hit = hits[0];
      const rawId =
        hit.id !== undefined && hit.id !== null
          ? hit.id
          : (hit.properties as { id?: string | number } | null)?.id;
      if (rawId === undefined || rawId === null) return;
      selectFeatureByIdRef.current(rawId as string | number);
    };
    mapInstance.on('click', onClick);
    return () => {
      mapInstance.off('click', onClick);
    };
  }, [mapInstance]);

  const handleResetView = useCallback(() => {
    if (!mapInstance) return;
    // Cancel any in-flight camera motion (inertia, ease/fly, edge-pan tick)
    // BEFORE jumping. Without this, a residual velocity vector persists and
    // Mapbox re-applies it after our jump, producing the "drift-to-random-
    // coordinates" bug Bill flagged in B2.
    try {
      mapInstance.stop();
    } catch {
      // best-effort
    }
    // jumpTo (no animation) guarantees the camera lands exactly on the
    // canonical view. easeTo can interact with maxBounds mid-animation and
    // finish on a slightly-shifted center.
    // P6: fit the canonical BOX rather than jumping to a fixed centre+zoom, so
    // the northern and southern crops Bill specified hold on any window size.
    fitCanonical(mapInstance, false);
    viewIsCanonicalRef.current = true;
  }, [mapInstance]);

  /**
   * P5 -- broader pan, with PI on a tether.
   *
   * MAX_BOUNDS is now wide enough to take in Cyprus, Türkiye, Saudi Arabia and
   * Egypt, which is what Bill asked for. The risk that opens up is someone
   * panning until PI is off-screen entirely and not knowing how to get back --
   * so when panning ends with the region no longer intersecting the viewport,
   * the camera eases back to the canonical view.
   *
   * Deliberately runs on `moveend`, not during the drag: snapping mid-gesture
   * would fight the user's hand. It also stays out of the way while a stroke is
   * in progress, where the edge-pan logic is legitimately walking the view
   * around, and skips its own corrective move so the ease doesn't re-trigger
   * itself.
   *
   * Switched off entirely in alignment mode. A paper map of the wider region
   * has corners out over Lebanon, Jordan or open sea, and fine-tuning one
   * means zooming right in on it -- a view with no PI in it, which the tether
   * would yank straight back to the canonical framing every time the user
   * stopped moving. The tether protects public visitors from getting lost; the
   * alignment tool is an operator's instrument that has to go anywhere.
   */
  const tetherRestoringRef = useRef(false);
  useEffect(() => {
    if (!mapInstance || alignSlug) return;
    const onMoveEnd = () => {
      if (tetherRestoringRef.current) {
        tetherRestoringRef.current = false;
        return;
      }
      if (isDrawingActiveRef.current) return;
      const b = mapInstance.getBounds();
      if (!b) return;
      const visible =
        b.getEast() > PI_BBOX.west &&
        b.getWest() < PI_BBOX.east &&
        b.getNorth() > PI_BBOX.south &&
        b.getSouth() < PI_BBOX.north;
      if (visible) return;
      tetherRestoringRef.current = true;
      fitCanonical(mapInstance, true);
      viewIsCanonicalRef.current = true;
    };
    mapInstance.on('moveend', onMoveEnd);
    return () => {
      mapInstance.off('moveend', onMoveEnd);
    };
  }, [mapInstance, alignSlug]);

  /**
   * P6 follow-up -- keep the canonical crop when the window changes size.
   *
   * The crop is fitted once, when the map is created, for the window size at
   * that moment. Mapbox keeps the zoom level on any later resize rather than
   * re-fitting, so if the window then grows -- maximising it, changing the
   * browser zoom, a toolbar docking or undocking -- the view simply shows more
   * of the region. Bill saw exactly that on his 14" laptop (13 Sep): Lebanon at
   * the top and most of the Gulf of Aqaba at the bottom. Reproduced by growing
   * the window from 600 to 900 px after load: the view went from the intended
   * 29.15-33.45 N to 27.95-34.58 N, a match for his photo.
   *
   * So on resize, re-fit the crop -- but only while the view is still the
   * canonical one. Once the user has panned or zoomed, the view is theirs and
   * a resize leaves it alone. Also stays out of the way mid-stroke and in
   * alignment mode.
   *
   * Telling a user move from anything else takes care. User interactions
   * carry an `originalEvent` (mouse, wheel, touch, pointer, keyboard, or the
   * zoom buttons' click); our own fits carry none. But Mapbox ALSO attaches
   * one to its automatic window-resize handling -- `_onWindowResize` calls
   * `resize({ originalEvent: event })` with the window's `resize` event, and
   * resize() fires movestart with it. Counting every originalEvent as the user
   * therefore treated each window resize as the user moving the map, and the
   * re-fit never ran. Those event types are excluded below.
   */
  useEffect(() => {
    if (!mapInstance || alignSlug) return;
    const NOT_USER = new Set(['resize', 'orientationchange', 'fullscreenchange', 'webkitfullscreenchange']);
    const onMoveStart = (e: { originalEvent?: Event }) => {
      const original = e.originalEvent;
      if (original && !NOT_USER.has(original.type)) viewIsCanonicalRef.current = false;
    };
    const onResize = () => {
      if (!viewIsCanonicalRef.current || isDrawingActiveRef.current) return;
      fitCanonical(mapInstance, false);
    };
    mapInstance.on('movestart', onMoveStart);
    mapInstance.on('resize', onResize);
    return () => {
      mapInstance.off('movestart', onMoveStart);
      mapInstance.off('resize', onResize);
    };
  }, [mapInstance, alignSlug]);

  // We render the canonical party-tagged features as our own styled layers
  // below terra-draw's working layers, so the party color stays correct even
  // when nothing is selected. terra-draw renders its own (neutral) overlay
  // for in-progress geometry.
  const featuresData = useMemo(() => drawing.features, [drawing.features]);

  /**
   * P4 -- attribution strip.
   *
   * Two things were wrong here and both were visible to Bill in every browser.
   *
   * 1. OpenStreetMap was linked TWICE. Mapbox derives its own attribution from
   *    the style's sources and appends it to whatever we pass in
   *    `customAttribution` -- `attributionControl={false}` on the Map only
   *    suppresses the DEFAULT control instance, not the source-derived text on
   *    the one we add ourselves. So our "Boundary data © OpenStreetMap" link
   *    sat alongside Mapbox's own "© OpenStreetMap", giving two OSM links plus
   *    "Improve this map". Mapbox's half is required by their terms and cannot
   *    be removed, so ours is the half that changes: the boundary credit now
   *    names OSM in plain text and lets Mapbox's link carry the ODbL
   *    requirement, leaving exactly one OSM link in the strip.
   *
   * 2. The licence link rendered as dead plain text. Mapbox sanitises
   *    attribution HTML and drops anchors whose href is not absolute, so the
   *    relative `/pi-boundary.LICENSE.md` lost its tag. Resolved against the
   *    current origin at runtime it survives and is clickable again.
   */
  const customAttribution = useMemo(() => {
    const origin = typeof window !== 'undefined' ? window.location.origin : '';
    // "OpenStreetMap" appears exactly once in the strip: Mapbox's own
    // "© OpenStreetMap" link, which is required by their terms, points at
    // openstreetmap.org/copyright, and credits OSM for all OSM-derived data on
    // the map -- ours included. Naming OSM again here read as a duplicate (Bill,
    // 13 Sep: "the 2 open street map phrases continue"). The full
    // "© OpenStreetMap contributors, ODbL" statement for the boundary file
    // lives on the licence page linked below.
    return [
      'Palestine boundary © UN OCHA FISS ' +
        '(<a href="http://creativecommons.org/licenses/by/3.0/igo/legalcode" target="_blank" rel="noopener noreferrer">CC BY 3.0 IGO</a>)',
      `<a href="${origin}/pi-boundary.LICENSE.md" target="_blank" rel="noopener noreferrer">Boundary licence details</a>`,
    ];
  }, []);

  if (!MAPBOX_TOKEN) {
    return (
      <div className="flex h-full w-full items-center justify-center bg-zinc-900 text-zinc-100">
        <div className="max-w-md p-6 text-center">
          <p className="mb-2 text-lg font-semibold">Mapbox token missing</p>
          <p className="text-sm text-zinc-400">
            Set <code className="rounded bg-zinc-800 px-1.5 py-0.5">NEXT_PUBLIC_MAPBOX_TOKEN</code> in your environment.
          </p>
        </div>
      </div>
    );
  }

  return (
    <div
      className={`relative h-full w-full ${isDrawingMode ? 'is-drawing' : ''}`}
      data-cursor-state={cursorState}
    >
      <Map
        ref={mapRef}
        mapboxAccessToken={MAPBOX_TOKEN}
        // P6: open on the canonical BOX so the north/south crops hold on every
        // window size. INITIAL_VIEW is kept only as the fallback centre.
        initialViewState={{
          bounds: CANONICAL_BOUNDS,
          fitBoundsOptions: { padding: CANONICAL_FIT_PADDING },
        }}
        mapStyle="mapbox://styles/mapbox/satellite-v9"
        minZoom={MIN_ZOOM}
        maxZoom={MAX_ZOOM}
        maxBounds={MAX_BOUNDS}
        dragPan={true}
        scrollZoom={true}
        touchZoomRotate={true}
        doubleClickZoom={true}
        onLoad={handleLoad}
        onError={handleError}
        // Disable the default text attribution so we can swap in the
        // compact "i" button -- Mapbox's terms-compliant minimal form.
        attributionControl={false}
        style={{ width: '100%', height: '100%' }}
      >
        {/* Bottom-right, stacked above the attribution button. It used to sit
            top-right, where the page's "Educational Use Only" badge covered the
            top of the zoom-in button and the overlay panel then hid the rest
            of the control entirely. Bottom-right is otherwise empty. */}
        <NavigationControl position="bottom-right" showCompass={true} />
        {/* Dual scale bars (km / m and mi / ft) stacked bottom-left. */}
        <ScaleControl position="bottom-left" unit="metric" maxWidth={120} />
        <ScaleControl position="bottom-left" unit="imperial" maxWidth={120} />
        {/* Compact attribution: collapses to an 'i' button. Still
            Mapbox-ToS compliant (the strip opens on click/hover).
            The PI outer-boundary polygon is derived from two open-licence
            sources -- OSM (ODbL) for Israel + Golan cut, and UN OCHA
            (CC BY-IGO) for Palestine -- so both credits appear here.
            Licence details live in public/pi-boundary.LICENSE.md. */}
        <AttributionControl
          position="bottom-right"
          compact={true}
          customAttribution={customAttribution}
        />

        {/* Soft dark fill over everything OUTSIDE the PI boundary. Pulls
            the user's eye to the drawable area without hiding the
            satellite imagery outside. Lines drawn outside still get
            clipped on finish; this is the visual hint that they should
            not go there in the first place. */}
        {outsideMask && (
          <Source id="pi-outside-mask" type="geojson" data={outsideMask}>
            <Layer
              id="pi-outside-mask-fill"
              type="fill"
              paint={{
                'fill-color': '#000000',
                'fill-opacity': 0.35,
              }}
            />
          </Source>
        )}

        {/* Thin outline of the PI boundary so the user can see where the
            drawing constraint actually runs. */}
        {boundaryOutline && (
          <Source id="pi-boundary-outline" type="geojson" data={boundaryOutline}>
            <Layer
              id="pi-boundary-outline-line"
              type="line"
              paint={{
                'line-color': '#ffffff',
                'line-width': 1.5,
                'line-opacity': 0.85,
              }}
            />
          </Source>
        )}

        {/* Live Freehand preview line. terra-draw is in a no-op mode
            while Freehand UI is active; our hook traces vertices on
            mouse-move and exposes them as a LineString feature here.
            Mounted unconditionally with an empty fallback when no stroke
            is active -- the Source then persists across stroke start/
            finish, so Mapbox only does an in-place data update rather
            than tearing the source/layer down and rebuilding it (which
            can drop the first frame or two of the trail). */}
        <Source
          id="freehand-preview"
          type="geojson"
          data={
            drawing.freehandPreview ?? {
              type: 'FeatureCollection',
              features: [],
            }
          }
        >
          <Layer
            id="freehand-preview-line"
            type="line"
            paint={{
              'line-color': partyOutlineColor(drawing.activeParty),
              'line-width': 2.5,
              'line-opacity': 0.95,
            }}
          />
        </Source>

        {/* M1C: the active overlay.

            Two separate things decide where this ends up, and both matter.

            `beforeId` decides the STACKING. Mapbox orders layers by when they
            are added, and the overlay is added only once its data has been
            fetched -- long after the drawing layers. Left to itself it landed
            on top of the user's own areas and buried them. Anchoring it
            beneath `freehand-preview-line`, the lowest of our own layers,
            puts it directly above the basemap and below everything we draw.
            That is what SRS 3.2.2 asks for: the overlay reads clearly against
            the basemap while the division markings over it stay visible.

            Its position HERE in the JSX, directly after its anchor, is what
            keeps that working when the basemap changes. A style change strips
            every layer and react-map-gl re-adds them in tree order; with the
            overlay earlier in the tree it was re-added before its anchor
            existed, and Mapbox refused the insert -- the "layer does not exist"
            failure that surfaced when the M1C basemap-swap requirement was
            tested. After its anchor, the anchor is always there first.

            Semi-transparency is fixed, taken from the overlay's own record.
            The variable transparency slider (SRS 3.2.6) is deferred to a later
            phase, so this is a stored value rather than a user control. */}
        {activeOverlay?.kind === 'vector' && overlayData && (
          <Source id="overlay-vector" type="geojson" data={overlayData}>
            <Layer
              id="overlay-vector-fill"
              type="fill"
              beforeId={OVERLAY_ANCHOR_LAYER_ID}
              paint={{
                'fill-color': overlayFillColor as string,
                'fill-opacity': activeOverlay.opacity,
              }}
            />
            <Layer
              id="overlay-vector-line"
              type="line"
              beforeId={OVERLAY_ANCHOR_LAYER_ID}
              paint={{
                'line-color': activeOverlay.lineColor ?? '#0ea5e9',
                'line-width': 1.2,
                'line-opacity': Math.min(1, activeOverlay.opacity + 0.25),
              }}
            />
          </Source>
        )}

        {/* A georeferenced scan of a paper map -- the analog half of SRS
            3.2.5. Positioned by the corner coordinates recorded against the
            overlay when it was georeferenced. */}
        {activeOverlay?.kind === 'raster' && rasterCorners && (
          <Source
            id="overlay-raster"
            type="image"
            url={overlayDataUrl(activeOverlay.slug)}
            coordinates={rasterCorners}
          >
            <Layer
              id="overlay-raster-layer"
              type="raster"
              beforeId={OVERLAY_ANCHOR_LAYER_ID}
              paint={{ 'raster-opacity': activeOverlay.opacity }}
            />
          </Source>
        )}

        {/* Freehand start dot. Appears the moment the user clicks to
            start, before any cursor movement -- a single-coord LineString
            isn't renderable, so the dot is what gives "I registered your
            first click" feedback.

            A large translucent "tolerance ring" is drawn around it (per
            Bill's Bucket 2 request: "make bigger and visible, for testing")
            -- clicks anywhere INSIDE the ring close the polygon; anywhere
            outside extends it. The visible ring removes the guessing at
            high zoom where 15 px (previous tolerance) was invisibly small. */}
        {drawing.freehandStartDot && (
          <Source
            id="freehand-start-dot"
            type="geojson"
            data={{
              type: 'Feature',
              properties: {},
              geometry: { type: 'Point', coordinates: drawing.freehandStartDot },
            }}
          >
            <Layer
              id="freehand-start-dot-tolerance"
              type="circle"
              paint={{
                'circle-radius': 28,
                'circle-color': partyOutlineColor(drawing.activeParty),
                'circle-opacity': 0.15,
                'circle-stroke-color': '#ffffff',
                'circle-stroke-width': 1.5,
                'circle-stroke-opacity': 0.8,
              }}
            />
            <Layer
              id="freehand-start-dot-circle"
              type="circle"
              paint={{
                'circle-radius': 5,
                'circle-color': partyOutlineColor(drawing.activeParty),
                'circle-stroke-color': '#ffffff',
                'circle-stroke-width': 2,
              }}
            />
          </Source>
        )}

        {/* B8: finish-vertex pulse. When a polygon or freehand finishes,
            a bright ring pulses at the closing-vertex coordinate for ~900 ms
            (enlarges then fades). This is the "beat" Bill's mental model
            already assumed the tool had ("the ending vertex getting bigger
            then smaller"), and it's much more visible than the previous
            cursor-state-only beat. */}
        {drawing.finishPulse && (
          <FinishPulseLayer
            key={drawing.finishPulse.nonce}
            coord={drawing.finishPulse.coord}
            color={partyOutlineColor(drawing.activeParty)}
          />
        )}

        {/* Polygon start-dot + tolerance ring. Renders at the FIRST-placed
            vertex of an in-progress polygon so the user always knows where
            to click to close. Matches the freehand start-dot visual
            treatment for cross-mode consistency. Terra-draw's own
            closingPoint style enlarges the LAST placed vertex, not the
            first -- so we render our own overlay at the correct target. */}
        {drawing.polygonStartDot && (
          <Source
            id="polygon-start-dot"
            type="geojson"
            data={{
              type: 'Feature',
              properties: {},
              geometry: { type: 'Point', coordinates: drawing.polygonStartDot },
            }}
          >
            <Layer
              id="polygon-start-dot-tolerance"
              type="circle"
              paint={{
                'circle-radius': 28,
                'circle-color': partyOutlineColor(drawing.activeParty),
                'circle-opacity': 0.15,
                'circle-stroke-color': '#ffffff',
                'circle-stroke-width': 1.5,
                'circle-stroke-opacity': 0.8,
              }}
            />
            <Layer
              id="polygon-start-dot-circle"
              type="circle"
              paint={{
                'circle-radius': 6,
                'circle-color': partyOutlineColor(drawing.activeParty),
                'circle-stroke-color': '#ffffff',
                'circle-stroke-width': 2,
              }}
            />
          </Source>
        )}

        {/* One styled fill + outline per party, filtered from the shared
            store. `promoteId: 'id'` tells Mapbox to use each feature's
            `properties.id` as its feature id, so `queryRenderedFeatures`
            (the click-bridge for selecting filled interiors) reliably
            returns the same id terra-draw uses. */}
        <Source id="party-features" type="geojson" data={featuresData} promoteId="id">
          {PARTIES.map((p) => (
            <Layer
              key={`fill-${p.id}`}
              id={`fill-${p.id}`}
              type="fill"
              filter={['==', ['get', 'party'], p.id]}
              paint={{
                'fill-color': p.fillColor,
                'fill-opacity': 0.35,
              }}
            />
          ))}
          {PARTIES.map((p) => (
            <Layer
              key={`outline-${p.id}`}
              id={`outline-${p.id}`}
              type="line"
              filter={['==', ['get', 'party'], p.id]}
              paint={{
                'line-color': p.outlineColor,
                'line-width': 2,
              }}
            />
          ))}
          {/* Selection highlight: a thicker white outline drawn on top of
              the selected feature's outline. terra-draw's vertex handles
              don't render for freehand-injected polygons (the engine
              rejects them on validation), so this highlight is what
              tells the user a shape is selected, regardless of which mode
              it was drawn in. */}
          {drawing.selectedFeatureId !== null && (
            <Layer
              id="selection-highlight"
              type="line"
              filter={['==', ['get', 'id'], drawing.selectedFeatureId]}
              paint={{
                'line-color': '#ffffff',
                'line-width': 4,
                'line-opacity': 0.9,
                'line-dasharray': [2, 1.5],
              }}
            />
          )}
        </Source>

        {/* Georeferencing tool for scanned maps (`?align=<slug>`). Waits for
            the catalogue so it can resume from an existing registration. */}
        {alignSlug && !overlaysLoading && (
          <AlignTool
            slug={alignSlug}
            overlay={overlays.find((o) => o.slug === alignSlug) ?? null}
            anchorLayerId={OVERLAY_ANCHOR_LAYER_ID}
          />
        )}
      </Map>

      <DrawingToolbar
        mode={drawing.mode}
        setMode={drawing.setMode}
        activeParty={drawing.activeParty}
        setActiveParty={drawing.setActiveParty}
        selectedFeatureId={drawing.selectedFeatureId}
        onDeleteSelected={drawing.deleteSelected}
        onClearAll={drawing.clearAll}
        onResetView={handleResetView}
      />

      <OverlayPanel
        overlays={overlays}
        activeSlug={activeOverlaySlug}
        onSelect={setActiveOverlaySlug}
        isLoading={overlaysLoading}
        error={overlayError}
        legend={overlayLegendEntries}
      />

      {isLoading && !errorMessage && (
        <div className="absolute inset-0 z-20 flex flex-col items-center justify-center gap-4 bg-black">
          <div
            className="h-10 w-10 animate-spin rounded-full border-2 border-zinc-700 border-t-zinc-100"
            role="status"
            aria-label="Loading map"
          />
          <p className="text-sm font-medium tracking-wide text-zinc-300">Loading map…</p>
        </div>
      )}

      {errorMessage && (
        <div className="absolute inset-0 flex items-center justify-center bg-zinc-900/90">
          <div className="max-w-md rounded-md bg-red-950 p-4 text-center">
            <p className="mb-1 font-semibold text-red-200">Map failed to load</p>
            <p className="text-sm text-red-300">{errorMessage}</p>
            <p className="mt-2 text-xs text-red-400">
              Check that NEXT_PUBLIC_MAPBOX_TOKEN is set and valid.
            </p>
          </div>
        </div>
      )}
    </div>
  );
}

/**
 * B8 finish-vertex pulse renderer. On mount, animates a hollow ring
 * (thick white stroke, translucent party-colour fill) from small -> very
 * large and opacity from full -> 0 over ~1.3 s. Two staggered concentric
 * rings for extra visual weight -- makes the beat clearly visible even
 * when the user's eye isn't already on the closing vertex.
 *
 * Uses Mapbox's built-in paint transitions rather than CSS/rAF so the
 * animation stays smooth during map interactions.
 */
function FinishPulseLayer({
  coord,
  color,
}: {
  coord: Position;
  color: string;
}) {
  const [phase, setPhase] = useState<'seed' | 'expand'>('seed');
  useEffect(() => {
    // Two-frame delay before flipping state -- gives Mapbox one full paint
    // cycle to render the seed state before the transition target is set,
    // otherwise the source and target render in the same frame and no
    // animation plays.
    let rafA: number | null = null;
    let rafB: number | null = null;
    rafA = requestAnimationFrame(() => {
      rafB = requestAnimationFrame(() => {
        setPhase('expand');
      });
    });
    return () => {
      if (rafA !== null) cancelAnimationFrame(rafA);
      if (rafB !== null) cancelAnimationFrame(rafB);
    };
  }, []);
  const isExpanded = phase === 'expand';
  return (
    <Source
      id="finish-pulse"
      type="geojson"
      data={{
        type: 'Feature',
        properties: {},
        geometry: { type: 'Point', coordinates: coord },
      }}
    >
      {/* Outer ring — the main pulse. Grows biggest, fades slowest. */}
      <Layer
        id="finish-pulse-outer"
        type="circle"
        paint={{
          'circle-radius': isExpanded ? 48 : 8,
          'circle-radius-transition': { duration: 1300, delay: 0 },
          'circle-color': color,
          'circle-opacity': isExpanded ? 0 : 0.4,
          'circle-opacity-transition': { duration: 1300, delay: 0 },
          'circle-stroke-color': '#ffffff',
          'circle-stroke-width': 3,
          'circle-stroke-opacity': isExpanded ? 0 : 1,
          'circle-stroke-opacity-transition': { duration: 1300, delay: 0 },
        }}
      />
      {/* Inner ring — staggered, grows to medium size, fades on the same
          curve so the pair reads as one thick pulse rather than two blips. */}
      <Layer
        id="finish-pulse-inner"
        type="circle"
        paint={{
          'circle-radius': isExpanded ? 24 : 4,
          'circle-radius-transition': { duration: 1100, delay: 100 },
          'circle-color': color,
          'circle-opacity': isExpanded ? 0 : 0.9,
          'circle-opacity-transition': { duration: 1100, delay: 100 },
          'circle-stroke-color': '#ffffff',
          'circle-stroke-width': 2,
          'circle-stroke-opacity': isExpanded ? 0 : 1,
          'circle-stroke-opacity-transition': { duration: 1100, delay: 100 },
        }}
      />
    </Source>
  );
}

/**
 * Frame {@link CANONICAL_BOUNDS} -- the shared implementation behind both the
 * Reset View button and the P5 pan-tether.
 *
 * `stop()` first: a residual velocity vector from inertia or the edge-pan tick
 * otherwise gets re-applied after the camera move and drifts the view off the
 * mark, which is the "drifted to random coordinates" symptom from B2.
 */
function fitCanonical(map: MapboxMap, animate: boolean): void {
  try {
    map.stop();
  } catch {
    // best-effort
  }
  map.fitBounds(CANONICAL_BOUNDS, {
    padding: CANONICAL_FIT_PADDING,
    bearing: 0,
    pitch: 0,
    duration: animate ? 600 : 0,
  });
}

/**
 * Build a polygon that covers everything *outside* the PI boundary.
 *
 * Uses Mapbox's polygon-with-holes convention: outer ring = the world (a
 * large lng/lat rectangle), inner rings = the PI outer rings. The result
 * is renderable as a single fill that "darkens everything except PI".
 *
 * Inner ring winding order is reversed so it cuts a hole correctly per
 * GeoJSON's right-hand rule.
 */
function buildOutsideMask(
  fc: FeatureCollection<Polygon | MultiPolygon>,
): Feature<Polygon> | null {
  const holes: Position[][] = [];
  for (const feat of fc.features) {
    const g = feat.geometry;
    if (g.type === 'Polygon') {
      const outer = g.coordinates[0];
      if (outer && outer.length >= 4) holes.push([...outer].reverse());
    } else if (g.type === 'MultiPolygon') {
      for (const poly of g.coordinates) {
        const outer = poly[0];
        if (outer && outer.length >= 4) holes.push([...outer].reverse());
      }
    }
  }
  if (holes.length === 0) return null;

  // Outer ring covers most of the world (kept inside Mapbox's valid lat range).
  const worldRing: Position[] = [
    [-180, -85],
    [180, -85],
    [180, 85],
    [-180, 85],
    [-180, -85],
  ];

  return {
    type: 'Feature',
    properties: {},
    geometry: {
      type: 'Polygon',
      coordinates: [worldRing, ...holes],
    },
  };
}
