'use client';

import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { Map as MapboxMap } from 'mapbox-gl';
import {
  TerraDraw,
  TerraDrawLineStringMode,
  TerraDrawSelectMode,
  TerraDrawRenderMode,
  type GeoJSONStoreFeatures,
} from 'terra-draw';
import { TerraDrawMapboxGLAdapter } from 'terra-draw-mapbox-gl-adapter';
import type { Feature, LineString, MultiPolygon, Polygon, Position } from 'geojson';

import type {
  DrawMode,
  PartyFeature,
  PartyFeatureCollection,
  PartyId,
} from '@/lib/types';
import { partyFillColor, partyOutlineColor } from '@/lib/parties';
import { loadPIBoundary, piBoundaryAsLines } from '@/lib/piBoundary';
import {
  clipPolygonToBoundary as clipPolygonToBoundaryExact,
  setDrawingBoundaryFromCollection,
} from '@/lib/drawingBoundary';
import { simplifyPolygon, simplifyDrawnPortion } from '@/lib/simplify';
import { autoCompleteAlongBoundary } from '@/lib/autoComplete';
import { buildBoundaryIndex, type BoundaryIndex } from '@/lib/boundaryIndex';
import { boundarySnapKm } from '@/lib/mapConfig';
import booleanPointInPolygon from '@turf/boolean-point-in-polygon';
import { point as turfPoint } from '@turf/helpers';

interface UseDrawingOptions {
  map: MapboxMap | null;
}

interface UseDrawingResult {
  mode: DrawMode;
  setMode: (mode: DrawMode) => void;
  activeParty: PartyId;
  setActiveParty: (id: PartyId) => void;
  features: PartyFeatureCollection;
  deleteSelected: () => void;
  clearAll: () => void;
  selectedFeatureId: string | number | null;
  /**
   * Programmatically select a feature by id (called from MapView when the
   * user clicks our coloured fill layer -- terra-draw can't detect those
   * clicks itself because its store only holds the line-string outline).
   */
  selectFeatureById: (id: string | number) => void;
  /** True while the user is mid-stroke (between first click and finish). */
  isDrawingActive: boolean;
  /**
   * True for ~1 s after a shape finishes. During this window, terra-draw
   * is temporarily switched to Select mode so a stray click can't immediately
   * start a brand-new shape -- gives the user a visual beat to register the
   * shape was saved.
   */
  postFinishBeat: boolean;
  /**
   * B8: the closing-vertex position of the shape that JUST finished, plus
   * an animation nonce that changes each finish. MapView renders a brief
   * "pulse" circle at this coord (enlarges then shrinks) matching Bill's
   * mental model of the completion beat -- much more visible than the
   * previous cursor-state-only beat.
   */
  finishPulse: { coord: Position; nonce: number } | null;
  /** Cancel any in-progress drawing back to idle (open hand). */
  cancelInProgress: () => void;
  /**
   * The in-progress Freehand stroke as a renderable LineString, or null
   * when no Freehand stroke is active. MapView draws this as a coloured
   * preview while the user traces.
   */
  freehandPreview: Feature<LineString> | null;
  /**
   * The start-click coord for the in-progress Freehand stroke (`null` when
   * no Freehand stroke is active). Rendered as a small dot the moment the
   * user clicks to start -- before any cursor movement, the preview line
   * doesn't exist yet (a single-coord LineString isn't renderable), so the
   * dot is what gives feedback that the first click registered.
   */
  freehandStartDot: Position | null;
  /**
   * The first-placed vertex of the in-progress Polygon (`null` when no
   * polygon is being drawn). MapView renders a start dot + tolerance ring
   * at this position so users have a persistent "click here to close"
   * visual affordance. Terra-draw's own `closingPoint` style enlarges the
   * LAST placed vertex (the currently-open end), not the first -- so we
   * add our own overlay for the correct close-target.
   */
  polygonStartDot: Position | null;
}

const EMPTY_FC: PartyFeatureCollection = {
  type: 'FeatureCollection',
  features: [],
};

/**
 * Terra-draw's own defaults for the pointer-event gates. We override only
 * `leftClick`; everything else must keep its stock behaviour or dragging and
 * the context menu stop working.
 */
const DEFAULT_POINTER_EVENTS = {
  rightClick: true,
  contextMenu: false,
  leftClick: true,
  onDragStart: true,
  onDrag: true,
  onDragEnd: true,
} as const;

/**
 * Map the public `DrawMode` to terra-draw's internal mode strings.
 *
 * - **Polygon UI** → terra-draw `linestring`: click-to-place vertices.
 * - **Freehand UI** → terra-draw `static` (no-op). Our own click +
 *   mousemove handlers below trace a continuous line as the cursor moves
 *   between the two clicks. On finish we close the line into a polygon and
 *   inject it back into terra-draw as a `linestring` feature so Select /
 *   Edit still work on it.
 *
 * Press-and-hold drag drawing is removed entirely -- drag is reserved for
 * panning the map in every mode.
 */
const TD_MODE: Record<DrawMode, string> = {
  freehand: 'freehand-noop',
  polygon: 'linestring',
  select: 'select',
};

/** Close an open LineString into a Polygon ring. */
function lineStringToPolygon(coords: Position[]): Polygon | null {
  if (coords.length < 3) return null;
  const ring: Position[] = [...coords];
  // Ensure ring is closed.
  const first = ring[0];
  const last = ring[ring.length - 1];
  if (first[0] !== last[0] || first[1] !== last[1]) {
    ring.push([first[0], first[1]]);
  }
  if (ring.length < 4) return null;
  return { type: 'Polygon', coordinates: [ring] };
}

/**
 * "Effectively inside PI" check. Accepts a point as inside if either:
 *   - `booleanPointInPolygon` returns true (strictly inside), or
 *   - the point is within 20 m of the boundary line (accommodates
 *     float-point precision at boundary snap points; well below any
 *     real user editing distance).
 *
 * The near-boundary test goes through the grid index rather than scanning the
 * whole ring. The previous version called turf's `nearestPointOnLine` across
 * every boundary vertex on every check; run per-vertex over a finished shape
 * that had traced the border, that alone blocked the main thread for ~2.3 s and
 * triggered the browser's "page unresponsive" dialog.
 */
function isEffectivelyInsidePI(
  coord: Position,
  constraint: Feature<Polygon | MultiPolygon> | null,
  index: BoundaryIndex | null,
): boolean {
  if (!constraint) return true;
  if (booleanPointInPolygon(turfPoint(coord), constraint)) return true;
  if (!index) return false;
  return index.isWithinKm(coord, 0.02);
}

/**
 * A ring more than this far inside PI, everywhere, cannot have any edge that
 * crosses the border -- an edge can only exit and re-enter through a stretch
 * it comes near, and "near" is exactly what this margin rules out. Generous
 * relative to the border's local curvature at any scale a hand-drawn or
 * hand-dragged shape operates at.
 */
const FAST_CLIP_MARGIN_KM = 2;

/**
 * Clip a polygon to the PI boundary, skipping the expensive case when it
 * provably cannot change the answer.
 *
 * `clipPolygonToBoundaryExact` runs turf's general polygon-clipping algorithm
 * against PI's ~14,250-vertex outer ring. That cost is dominated by the
 * boundary's own complexity, not the input polygon's -- a 3-vertex ring costs
 * the same ~200-250 ms as a 20-vertex one (measured). `syncFeatures` calls
 * this on every terra-draw 'update'/'create' event, i.e. on every vertex
 * placed in Polygon mode and on every coalesced frame of a Select-mode vertex
 * drag. That made both interactions feel broken -- Bill's B4 ("delay ...
 * between when I click ... and ... a line shown") and B5 ("vertex-drag lag")
 * were this same clip running dozens of times per interaction, not an
 * inherent technology limit as the delay understandably looked like.
 *
 * When every vertex of the ring is inside the constraint AND further than
 * `FAST_CLIP_MARGIN_KM` from the boundary line, intersecting against PI is a
 * no-op (intersect(PI, ring) === ring whenever ring is safely interior), so
 * we return the ring unchanged. Any ring that fails that check -- because it
 * genuinely touches or crosses the border, e.g. an auto-completed shape or a
 * vertex dragged near the edge -- falls back to the exact clip, unchanged
 * from before.
 */
function clipPolygonToBoundaryFast(
  polygon: Polygon,
  constraint: Feature<Polygon | MultiPolygon> | null,
  index: BoundaryIndex | null,
): Polygon | null {
  if (!constraint) return polygon;
  const ring = polygon.coordinates[0];
  if (ring) {
    let providablyInterior = true;
    for (const c of ring) {
      if (!booleanPointInPolygon(turfPoint(c), constraint)) {
        providablyInterior = false;
        break;
      }
      if (index && index.isWithinKm(c, FAST_CLIP_MARGIN_KM)) {
        providablyInterior = false;
        break;
      }
    }
    if (providablyInterior) return polygon;
  }
  return clipPolygonToBoundaryExact(polygon, constraint);
}

/**
 * Cheap array-of-Position equality check. Returns true iff both arrays
 * have the same length AND every corresponding coordinate has the same
 * lng and lat. Used by the 'update' handler to distinguish "user really
 * edited a vertex" from "terra-draw fired update for a non-geometry
 * reason like selection state change."
 */
function coordsEqual(a: Position[], b: Position[]): boolean {
  if (a.length !== b.length) return false;
  for (let i = 0; i < a.length; i++) {
    if (a[i][0] !== b[i][0] || a[i][1] !== b[i][1]) return false;
  }
  return true;
}

/**
 * Shoelace-formula absolute area of a closed lng/lat ring in square
 * degrees. Not a real-world area, but sufficient for a "is this a
 * meaningful shape or a degenerate sliver?" threshold check.
 */
function ringAreaSqDeg(ring: Position[]): number {
  let area = 0;
  for (let i = 0, n = ring.length - 1; i < n; i++) {
    area += ring[i][0] * ring[i + 1][1] - ring[i + 1][0] * ring[i][1];
  }
  return Math.abs(area) / 2;
}

/**
 * Drawing state hook.
 *
 * Architecture (M1B + Refinements + line-string rewrite):
 *
 * - terra-draw owns the raw drawing geometry as a LINE-STRING (not a
 *   polygon), so there is no live "closing edge" back to the start point
 *   while the user is drawing.
 * - On finish, we close the line-string into a Polygon ourselves, then run
 *   it through auto-complete -> clip -> simplify and cache the result in
 *   `processedGeomByIdRef`.
 * - `syncFeatures` builds our rendered FeatureCollection from terra-draw's
 *   snapshot, preferring the cached processed Polygon. For in-progress
 *   edits we close the current line-string on the fly (no simplify, so the
 *   edit stays responsive).
 * - terra-draw's own line/point rendering is suppressed for finalised
 *   features via per-feature opacity functions (the parent feature and any
 *   coordinate-point children both flip to opacity 0), so users only see
 *   our coloured polygon fill once the shape is closed.
 */
export function useDrawing({ map }: UseDrawingOptions): UseDrawingResult {
  const [mode, setModeState] = useState<DrawMode>('freehand');
  const [activeParty, setActiveParty] = useState<PartyId>('A');
  const [features, setFeatures] = useState<PartyFeatureCollection>(EMPTY_FC);
  const [selectedFeatureId, setSelectedFeatureId] = useState<string | number | null>(null);
  // True between first click and finish click.
  const [isDrawingActive, setIsDrawingActive] = useState(false);
  // True for ~1 s after a shape finishes.
  const [postFinishBeat, setPostFinishBeat] = useState(false);
  const beatTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
  // B8: closing-vertex pulse. Coord = the finish click coord; nonce changes
  // each finish so React re-triggers the pulse animation even if two shapes
  // happen to finish at the same coord.
  const [finishPulse, setFinishPulse] = useState<{
    coord: Position;
    nonce: number;
  } | null>(null);
  const finishPulseNonceRef = useRef(0);
  const finishPulseTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(
    null,
  );
  // First-placed vertex of the in-progress polygon. Rendered by MapView as
  // a start dot + tolerance ring so users have a persistent "click here to
  // close" affordance (terra-draw's own closingPoint styling enlarges the
  // LAST placed vertex, not the first).
  const [polygonStartDot, setPolygonStartDot] = useState<Position | null>(null);
  // Ref mirror of polygonStartDot for synchronous access from event
  // handlers (React state is async, and the polygon-close click intercept
  // needs the current value the moment a click fires).
  const polygonStartDotRef = useRef<Position | null>(null);

  // ---- Freehand state ----
  // terra-draw is in `static` (no-op) mode while Freehand UI is active.
  // Our own handlers below trace the line; this state drives the preview
  // layer in MapView.
  const [freehandPreview, setFreehandPreview] = useState<Feature<LineString> | null>(null);
  // The start-click coord, exposed separately so MapView can render a dot
  // from the very first click (before the line has any second vertex to
  // form a renderable LineString).
  const [freehandStartDot, setFreehandStartDot] = useState<Position | null>(null);
  const freehandActiveRef = useRef(false);
  const freehandCoordsRef = useRef<Position[]>([]);
  const freehandLastPxRef = useRef<{ x: number; y: number } | null>(null);
  const freehandRafRef = useRef<number | null>(null);
  // The cursor's current map position, updated on every mousemove. Used as
  // the "live tip" of the preview line so the rendered tip stays at the
  // cursor instead of lagging behind to the last committed vertex.
  const freehandCursorRef = useRef<Position | null>(null);

  const drawRef = useRef<TerraDraw | null>(null);
  // Keep a direct reference to the SelectMode instance so we can call its
  // `selectFeature` method programmatically (clicks on our coloured fill
  // layer can't reach terra-draw -- the fill is rendered by Mapbox, not by
  // terra-draw -- so we bridge by calling selectFeature manually).
  const selectModeRef = useRef<TerraDrawSelectMode | null>(null);
  // Grid index over the boundary rings. Every proximity / trace / crossing
  // query goes through this; see lib/boundaryIndex.ts for why.
  const boundaryIndexRef = useRef<BoundaryIndex | null>(null);
  const constraintBoundaryRef = useRef<Feature<Polygon | MultiPolygon> | null>(null);
  const activePartyRef = useRef<PartyId>(activeParty);
  const mapRef = useRef<MapboxMap | null>(null);
  const partyByIdRef = useRef<Map<string | number, PartyId>>(new Map());
  const finalizedIdsRef = useRef<Set<string | number>>(new Set());
  const processedGeomByIdRef = useRef<Map<string | number, Polygon>>(new Map());
  const autoCompletedIdsRef = useRef<Set<string | number>>(new Set());
  // Polygons finalised by the custom Freehand path. terra-draw doesn't
  // know about these, so syncFeatures stitches them into the rendered
  // FeatureCollection alongside the terra-draw snapshot.
  const freehandPolygonsRef = useRef<Map<string, Polygon>>(new Map());
  // Ids of freehand polygons that were just injected into terra-draw and
  // are still in their post-injection settle window. Terra-draw fires
  // 'update' events during and briefly AFTER addFeatures — some sync,
  // some async on rAF/microtask. The 'update' handler skips its "user is
  // editing" cache invalidation for any id in this set. Ids are removed
  // via setTimeout ~500 ms after injection, well past any terra-draw
  // internal event dispatch.
  const justInjectedRef = useRef<Set<string | number>>(new Set());

  // Auto-complete snap tolerance for the CURRENT camera. Derived per finish
  // rather than fixed, so "close enough to the border" means the same number of
  // screen pixels at every zoom level. See BOUNDARY_SNAP_PX in lib/mapConfig.
  const currentSnapKm = useCallback((): number => {
    const m = mapRef.current;
    if (!m) return boundarySnapKm(8, 31.5);
    return boundarySnapKm(m.getZoom(), m.getCenter().lat);
  }, []);

  /**
   * May a vertex be placed here? True inside PI, and also just outside it —
   * within the same snap tolerance auto-complete uses — so that aiming at the
   * border itself works. A one-pixel-wide line is hit from the wrong side about
   * half the time, and a click that lands a hair outside should not be
   * discarded in silence; the finish-time clip pulls any such vertex back onto
   * the boundary.
   */
  const canPlaceVertexAt = useCallback((coord: Position): boolean => {
    const constraint = constraintBoundaryRef.current;
    if (!constraint) return true;
    if (booleanPointInPolygon(turfPoint(coord), constraint)) return true;
    const hit = boundaryIndexRef.current?.nearest(coord);
    return hit !== undefined && hit !== null && hit.distKm <= currentSnapKm();
  }, [currentSnapKm]);

  useEffect(() => {
    activePartyRef.current = activeParty;
  }, [activeParty]);

  useEffect(() => {
    polygonStartDotRef.current = polygonStartDot;
  }, [polygonStartDot]);

  useEffect(() => {
    mapRef.current = map;
  }, [map]);

  useEffect(() => {
    let cancelled = false;
    loadPIBoundary()
      .then((fc) => {
        if (cancelled) return;
        boundaryIndexRef.current = buildBoundaryIndex(piBoundaryAsLines(fc));
        setDrawingBoundaryFromCollection(fc);
        constraintBoundaryRef.current =
          fc.features.length > 0 ? fc.features[0] : null;
      })
      .catch((err) => {
        console.warn('PI boundary not available; constraint + auto-complete disabled.', err);
      });
    return () => {
      cancelled = true;
    };
  }, []);

  // Build our renderable FeatureCollection from terra-draw's snapshot.
  // terra-draw holds LINESTRINGS during drawing; we close each line into a
  // polygon for rendering. Prefer cached processed geometry for finalised
  // features; for in-progress edits, close the live line-string on the fly.
  const syncFeatures = useCallback(() => {
    const draw = drawRef.current;
    if (!draw) return;
    const snap = draw.getSnapshot();
    const parties = partyByIdRef.current;
    const processed = processedGeomByIdRef.current;
    const autos = autoCompletedIdsRef.current;
    const out: PartyFeature[] = [];

    // Track ids we've already emitted so the freehand-ref fallback below
    // doesn't double-render features terra-draw has accepted.
    const emittedIds = new Set<string | number>();
    for (const f of snap) {
      if (f.id === undefined) continue;
      const party = parties.get(f.id);
      if (!party) continue;

      let geom: Polygon | null;
      const cached = processed.get(f.id);
      if (cached) {
        geom = cached;
      } else if (f.geometry.type === 'LineString') {
        // Fast-path clip: this runs on every vertex placed / drag frame, so
        // the ~200-250 ms exact clip is unaffordable here. See
        // clipPolygonToBoundaryFast.
        const closed = lineStringToPolygon(f.geometry.coordinates);
        geom = closed
          ? clipPolygonToBoundaryFast(closed, constraintBoundaryRef.current, boundaryIndexRef.current)
          : null;
      } else if (f.geometry.type === 'Polygon') {
        // Edge case: select-mode edits sometimes coerce a feature to Polygon.
        geom = clipPolygonToBoundaryFast(f.geometry, constraintBoundaryRef.current, boundaryIndexRef.current);
      } else {
        continue;
      }
      if (!geom) continue;

      emittedIds.add(f.id);
      out.push({
        type: 'Feature',
        id: f.id,
        geometry: geom,
        properties: {
          // Mirror the id into properties too -- Mapbox's `promoteId` reads
          // from properties, and our click bridge uses it as a fallback.
          id: f.id,
          party,
          createdAt:
            typeof f.properties?.createdAt === 'number' ? f.properties.createdAt : Date.now(),
          autoCompleted: autos.has(f.id),
        },
      });
    }

    // Fallback: freehand polygons terra-draw rejected on injection (or
    // simply doesn't have yet). When terra-draw HAS the feature, the loop
    // above already emitted it -- and that emission picks up vertex
    // edits, since the cache is invalidated on every `update` event.
    for (const [id, polygon] of freehandPolygonsRef.current) {
      if (emittedIds.has(id)) continue;
      const party = parties.get(id);
      if (!party) continue;
      out.push({
        type: 'Feature',
        id,
        geometry: polygon,
        properties: {
          id,
          party,
          createdAt: Date.now(),
          autoCompleted: autos.has(id),
        },
      });
    }

    setFeatures({ type: 'FeatureCollection', features: out });
  }, []);

  // Per-feature style helpers used by terra-draw's line-string and freehand-
  // line-string modes. Both the parent feature and any coordinate-point
  // children flip to opacity 0 once the parent is finalised, so terra-draw's
  // own rendering disappears and only our coloured polygon layer remains.
  const modeStyles = useMemo(() => {
    const isFinalised = (f: GeoJSONStoreFeatures): boolean => {
      const fin = finalizedIdsRef.current;
      if (f.id !== undefined && fin.has(f.id as string | number)) return true;
      const parentId = f.properties?.coordinatePointFeatureId;
      if (
        (typeof parentId === 'string' || typeof parentId === 'number') &&
        fin.has(parentId)
      ) {
        return true;
      }
      return false;
    };

    const activeOutline = () => partyOutlineColor(activePartyRef.current);
    const activeFill = () => partyFillColor(activePartyRef.current);

    const lineOpacity = (f: GeoJSONStoreFeatures) => (isFinalised(f) ? 0 : 0.95);
    const pointOpacity = (f: GeoJSONStoreFeatures) => (isFinalised(f) ? 0 : 1);
    const lineColor = (f: GeoJSONStoreFeatures) =>
      isFinalised(f) ? '#ffffff' : (activeOutline() as `#${string}`);
    const pointColor = (f: GeoJSONStoreFeatures) =>
      isFinalised(f) ? '#ffffff' : (activeFill() as `#${string}`);

    // Click-to-place line-string (Polygon UI mode).
    // NOTE: terra-draw's `closingPoint` style applies to the LAST placed
    // vertex (the currently-open end of the linestring), NOT the first
    // vertex. So keep it at a normal size -- the "click here to close"
    // affordance is provided by our own polygonStartDot overlay in
    // MapView, rendered persistently at the first-placed vertex.
    const lineStringStyles = {
      lineStringColor: lineColor,
      lineStringOpacity: lineOpacity,
      lineStringWidth: 2,
      closingPointColor: pointColor,
      closingPointOpacity: pointOpacity,
      closingPointWidth: 6,
      closingPointOutlineColor: '#ffffff' as const,
      closingPointOutlineOpacity: pointOpacity,
      closingPointOutlineWidth: 2,
      coordinatePointColor: pointColor,
      coordinatePointOpacity: pointOpacity,
      coordinatePointWidth: 5,
      coordinatePointOutlineColor: '#ffffff' as const,
      coordinatePointOutlineOpacity: pointOpacity,
      coordinatePointOutlineWidth: 2,
    };

    // Press-and-drag freehand-line-string (Freehand UI mode).
    // Freehand-line-string has no coordinatePoint styles (no per-vertex
    // dots), only the line + a closing point.
    const freehandStyles = {
      lineStringColor: lineColor,
      lineStringOpacity: lineOpacity,
      lineStringWidth: 2.5,
      closingPointColor: pointColor,
      closingPointOpacity: pointOpacity,
      closingPointWidth: 6,
      closingPointOutlineColor: '#ffffff' as const,
      closingPointOutlineOpacity: pointOpacity,
      closingPointOutlineWidth: 2,
    };

    return { freehand: freehandStyles, polygon: lineStringStyles };
  }, []);

  // Select-mode styling. B10: terra-draw's selectedLineString overlay is
  // set to opacity 0 -- we rely on our own selection-highlight layer in
  // MapView (a white dashed outline) so the selection stroke is uniform
  // regardless of whether the shape was injected into terra-draw
  // successfully or lives only in our freehandPolygonsRef fallback.
  // Otherwise terra-draw-hosted segments render blue-and-white while
  // freehand-only segments render all-white -- Bill's B10 finding.
  const selectStyles = useMemo(
    () => ({
      selectedLineStringColor: '#ffffff' as const,
      selectedLineStringWidth: 1,
      selectedLineStringOpacity: 0,
      selectionPointWidth: 8,
      selectionPointColor: '#ffffff' as const,
      selectionPointOpacity: 1,
      selectionPointOutlineColor: '#111111' as const,
      selectionPointOutlineWidth: 2,
      selectionPointOutlineOpacity: 1,
      midPointWidth: 4,
      midPointColor: '#fbbf24' as const,
      midPointOpacity: 0.9,
      midPointOutlineColor: '#111111' as const,
      midPointOutlineWidth: 1,
      midPointOutlineOpacity: 0.9,
    }),
    [],
  );

  const triggerRestyle = useCallback(() => {
    const draw = drawRef.current;
    if (!draw) return;
    try {
      draw.updateModeOptions('linestring', { styles: modeStyles.polygon });
    } catch {
      // ignore if a mode isn't active
    }
  }, [modeStyles]);

  // When the active party changes, force terra-draw to re-evaluate styles
  // so the in-progress preview updates immediately.
  useEffect(() => {
    triggerRestyle();
  }, [activeParty, triggerRestyle]);

  useEffect(() => {
    if (!map) return;
    if (drawRef.current) return;

    const adapter = new TerraDrawMapboxGLAdapter({ map });

    const selectMode = new TerraDrawSelectMode({
      pointerDistance: 12,
      flags: {
        linestring: {
          feature: {
            draggable: false,
            coordinates: { midpoints: true, draggable: true, deletable: true },
          },
        },
      },
      ...({ styles: selectStyles } as object),
    } as ConstructorParameters<typeof TerraDrawSelectMode>[0]);
    selectModeRef.current = selectMode;

    const draw = new TerraDraw({
      adapter,
      // Custom id strategy: terra-draw's default validator requires ids
      // to be exactly 36 characters (UUID format) and silently drops
      // anything else. Our injected freehand features use a
      // `freehand-<timestamp>-<rand>` id so we know which features are
      // ours -- this strategy accepts any non-empty string id while
      // still using `crypto.randomUUID()` for terra-draw's own internal
      // feature creation (so terra-draw-created features keep their
      // standard UUIDs).
      idStrategy: {
        isValidId: (id) =>
          (typeof id === 'string' && id.length > 0) || typeof id === 'number',
        getId: () =>
          typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
            ? crypto.randomUUID()
            : `td-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 14)}`.padEnd(36, '0').slice(0, 36),
      },
      modes: [
        // Polygon UI -- click-to-place vertices; click the start dot or
        // double-click to finish.
        //
        // B1 (polygon side): two layers keep vertices inside PI:
        //
        //   pointerEvents.leftClick -- refuses the click outright, so no vertex
        //     is placed and no feature is created. This is the one that
        //     actually stops an out-of-PI vertex. `validation` alone does NOT:
        //     terra-draw creates the in-progress line-string from the very
        //     first click as *provisional* geometry, and provisional updates
        //     are not gated on the validator, so the bad coordinate landed in
        //     the store anyway. Gating the pointer event is terra-draw's
        //     supported way to say "ignore this click" and, unlike a DOM
        //     capture guard, it leaves drag-to-pan completely untouched.
        //
        //   validation -- backstop for edits that arrive by other routes
        //     (vertex drags in Select mode, programmatic updates).
        new TerraDrawLineStringMode({
          showCoordinatePoints: true,
          pointerEvents: {
            ...DEFAULT_POINTER_EVENTS,
            leftClick: (event) => canPlaceVertexAt([event.lng, event.lat]),
          },
          validation: (feature) => {
            // Skip validation for our own programmatically-injected
            // features (freehand finishes + polygon-mode close-intercept
            // finalizations). They've already been through our clip +
            // simplify pipeline, and rejecting them here would just
            // suppress the vertex-handle UI without preventing anything
            // the user actually did. Only USER clicks in polygon mode
            // should be validated (which arrive as terra-draw's own
            // auto-generated UUID ids, not our "freehand-" prefix).
            if (
              typeof feature.id === 'string' &&
              feature.id.startsWith('freehand-')
            ) {
              return { valid: true };
            }
            const constraint = constraintBoundaryRef.current;
            if (!constraint) return { valid: true };
            if (feature.geometry.type !== 'LineString') {
              return { valid: true };
            }
            // Only the LAST vertex needs checking -- it is the one the user
            // just placed. Validating the whole coordinate array meant that a
            // single out-of-PI vertex poisoned the feature permanently: every
            // later click re-validated the bad coordinate, so terra-draw
            // rejected every subsequent vertex and the polygon could never be
            // continued or closed. That is the "first vertex landed outside PI
            // and drawing froze" report. Out-of-PI clicks are now swallowed
            // before terra-draw ever sees them (see the click guard below), so
            // this is a backstop rather than the primary defence.
            const coords = feature.geometry.coordinates;
            const last = coords[coords.length - 1] as Position | undefined;
            if (
              last &&
              !isEffectivelyInsidePI(last, constraint, boundaryIndexRef.current)
            ) {
              return {
                valid: false,
                reason: 'vertex outside PI boundary',
              };
            }
            return { valid: true };
          },
          ...({ styles: modeStyles.polygon } as object),
        } as ConstructorParameters<typeof TerraDrawLineStringMode>[0]),
        // Freehand UI -- terra-draw is parked in a no-op render-only mode
        // (named "freehand-noop") while our custom click + mousemove
        // handlers (below) trace the line themselves.
        new TerraDrawRenderMode({
          modeName: 'freehand-noop',
          styles: {},
        }),
        selectMode,
      ],
    });

    draw.start();
    draw.setMode(TD_MODE.freehand);
    drawRef.current = draw;

    const handleFinish = (id: string | number) => {
      if (finalizedIdsRef.current.has(id)) return;

      const snapshot = draw.getSnapshot();
      const just = snapshot.find((f) => f.id === id);
      if (!just) return;
      if (just.geometry.type !== 'LineString') return;

      // 1. Close the line-string into a polygon.
      const ls: LineString = just.geometry;
      const closed = lineStringToPolygon(ls.coordinates);
      if (!closed) {
        draw.removeFeatures([id]);
        return;
      }
      let workingPolygon: Polygon = closed;

      // 2. PI-boundary auto-complete. Use the *open* line coordinates --
      //    the stroke's first/last unique points are the natural endpoints.
      let autoCompleted = false;
      let drawnCount = 0;
      const index = boundaryIndexRef.current;
      if (index) {
        const replaced = autoCompleteAlongBoundary(
          ls.coordinates,
          index,
          currentSnapKm(),
        );
        if (replaced && replaced.polygon.coordinates[0]?.length >= 4) {
          workingPolygon = replaced.polygon;
          drawnCount = replaced.drawnCount;
          autoCompleted = true;
        }
      }

      // 3. Zoom-aware simplification, BEFORE the clip so the ring layout is
      //    still known. On an auto-completed shape only the user's own stroke
      //    is thinned -- the traced border half is left verbatim.
      const zoom = mapRef.current?.getZoom() ?? 8;
      const simplified: Polygon = autoCompleted
        ? {
            type: 'Polygon',
            coordinates: [
              simplifyDrawnPortion(workingPolygon.coordinates[0], drawnCount, zoom),
            ],
          }
        : simplifyPolygon(workingPolygon, zoom);

      // 4. Clip to the active drawing-constraint boundary. Last, so nothing
      //    can escape PI regardless of what the earlier steps produced. Fast
      //    path: ordinary shapes well inside PI (the common case) skip the
      //    ~200-250 ms exact clip entirely; auto-completed / border-hugging
      //    shapes correctly fall back to it (their trace vertices sit right
      //    on the border, so the cheap proof can't apply).
      const processed = clipPolygonToBoundaryFast(
        simplified,
        constraintBoundaryRef.current,
        boundaryIndexRef.current,
      );
      if (!processed) {
        draw.removeFeatures([id]);
        return;
      }

      partyByIdRef.current.set(id, activePartyRef.current);
      finalizedIdsRef.current.add(id);
      processedGeomByIdRef.current.set(id, processed);
      if (autoCompleted) autoCompletedIdsRef.current.add(id);

      syncFeatures();
      triggerRestyle();

      // Stroke is done. Cancel any residual map motion (edge-pan velocity,
      // mid-flight inertia) so the view doesn't drift after finish -- Bill's
      // second B2 symptom.
      try {
        mapRef.current?.stop();
      } catch {
        // best-effort
      }
      setIsDrawingActive(false);
      // Polygon just closed -- clear the persistent close-target dot.
      setPolygonStartDot(null);

      // B8: trigger vertex-pulse animation at the closing-vertex position.
      // For polygon (terra-draw linestring), the closing-vertex is the first
      // ring coord (user clicked back on the start dot to close).
      const firstCoord = ls.coordinates[0];
      if (firstCoord) {
        finishPulseNonceRef.current += 1;
        setFinishPulse({
          coord: [firstCoord[0], firstCoord[1]],
          nonce: finishPulseNonceRef.current,
        });
        if (finishPulseTimeoutRef.current)
          clearTimeout(finishPulseTimeoutRef.current);
        finishPulseTimeoutRef.current = setTimeout(() => {
          setFinishPulse(null);
          finishPulseTimeoutRef.current = null;
        }, 900);
      }

      // ~1 s post-finish "beat": temporarily switch terra-draw to select
      // mode so a stray click can't immediately start a new shape. React
      // state.mode stays as freehand/polygon so the toolbar stays
      // consistent; we restore terra-draw's mode when the beat ends.
      const userMode = drawRef.current?.getMode();
      if (userMode === 'linestring') {
        const restoreTo = userMode;
        setPostFinishBeat(true);
        try {
          drawRef.current?.setMode('select');
        } catch {
          // best-effort
        }
        if (beatTimeoutRef.current) clearTimeout(beatTimeoutRef.current);
        beatTimeoutRef.current = setTimeout(() => {
          setPostFinishBeat(false);
          try {
            // Only restore if user hasn't navigated away to another mode.
            if (drawRef.current?.getMode() === 'select') {
              drawRef.current.setMode(restoreTo);
            }
          } catch {
            // best-effort
          }
          beatTimeoutRef.current = null;
        }, 1000);
      }
    };

    // B5: coalesce syncFeatures calls to one per animation frame during
    // rapid change events (vertex drag fires many 'update' events per
    // second). Without this, every micro-move triggers a full re-render
    // and re-serialisation pass, which shows up as the vertex-drag "lag"
    // Bill flagged.
    let syncRafId: number | null = null;
    const scheduleSync = () => {
      if (syncRafId !== null) return;
      syncRafId = requestAnimationFrame(() => {
        syncRafId = null;
        syncFeatures();
      });
    };

    const handleChange = (ids: (string | number)[], type: string) => {
      if (type === 'update') {
        // Only invalidate the cache when the geometry has ACTUALLY changed
        // vs what we cached. Terra-draw fires 'update' on selection state
        // changes too (Select-mode click flips an internal flag), and
        // during addFeatures's validation/apply cycle — both must NOT
        // wipe our auto-completed polygon or its flag.
        //
        //   1. Freshly-injected ids: justInjectedRef guard, ~500 ms
        //      settle window past terra-draw's async event dispatch.
        //   2. Selection-only updates: coordsEqual guard, compares
        //      cached ring against the live linestring coords. Skip if
        //      byte-identical. Only real vertex edits (which change
        //      coord values) invalidate.
        const snap = draw.getSnapshot();
        for (const id of ids) {
          if (justInjectedRef.current.has(id)) continue;
          const cached = processedGeomByIdRef.current.get(id);
          const live = snap.find((f) => f.id === id);
          if (
            cached &&
            live &&
            live.geometry.type === 'LineString' &&
            coordsEqual(cached.coordinates[0], live.geometry.coordinates)
          ) {
            continue;
          }
          processedGeomByIdRef.current.delete(id);
          autoCompletedIdsRef.current.delete(id);
        }
      }
      // Keep polygonStartDot AND isDrawingActive in sync with terra-draw's
      // current snapshot on every change (create/update/delete). Deriving both
      // from the snapshot rather than one-shot-setting them on 'create' and
      // 'delete' means they survive whatever event sequence terra-draw emits
      // during a multi-click draw.
      //
      // That distinction matters: with `showCoordinatePoints: true`, terra-draw
      // creates and destroys a Point feature per vertex as the line grows, and
      // each of those fired a create or delete. Keying off the event type alone
      // meant a vertex-point deletion read as "the stroke ended" mid-draw, and a
      // coordinate-point creation during our post-finish injection read as "a
      // new stroke began" -- which left the cursor stuck on the index-finger
      // pointer after a shape was completed.
      //
      // Functional setState so React bails out when the value is unchanged --
      // no re-render cost during vertex drag on finalised features.
      const snapshot = draw.getSnapshot();
      let firstCoord: Position | null = null;
      let hasInProgressLine = false;
      for (const f of snapshot) {
        if (f.id === undefined) continue;
        if (finalizedIdsRef.current.has(f.id as string | number)) continue;
        if (
          f.geometry.type === 'LineString' &&
          f.geometry.coordinates.length > 0
        ) {
          hasInProgressLine = true;
          const c = f.geometry.coordinates[0];
          firstCoord = [c[0], c[1]];
          break;
        }
      }
      // Freehand strokes live outside terra-draw's store, so OR in that flag.
      const drawing = hasInProgressLine || freehandActiveRef.current;
      setIsDrawingActive((prev) => (prev === drawing ? prev : drawing));
      setPolygonStartDot((prev) => {
        if (!firstCoord) return prev === null ? prev : null;
        if (prev && prev[0] === firstCoord[0] && prev[1] === firstCoord[1]) {
          return prev;
        }
        return firstCoord;
      });

      if (type === 'update' || type === 'create') {
        scheduleSync();
      }
    };

    const handleSelect = (id: string | number) => setSelectedFeatureId(id);
    const handleDeselect = () => setSelectedFeatureId(null);

    draw.on('finish', handleFinish);
    draw.on('change', handleChange);
    draw.on('select', handleSelect);
    draw.on('deselect', handleDeselect);

    return () => {
      if (syncRafId !== null) {
        cancelAnimationFrame(syncRafId);
        syncRafId = null;
      }
      draw.off('finish', handleFinish);
      draw.off('change', handleChange);
      draw.off('select', handleSelect);
      draw.off('deselect', handleDeselect);
      try {
        draw.stop();
      } catch {
        // ignore double-stop on hot reload
      }
      drawRef.current = null;
      selectModeRef.current = null;
    };
  }, [
    map,
    syncFeatures,
    triggerRestyle,
    modeStyles,
    selectStyles,
    currentSnapKm,
    canPlaceVertexAt,
  ]);

  const setMode = useCallback((next: DrawMode) => {
    // Explicit mode change cancels any pending post-finish-beat restore
    // (the user's choice wins).
    if (beatTimeoutRef.current) {
      clearTimeout(beatTimeoutRef.current);
      beatTimeoutRef.current = null;
      setPostFinishBeat(false);
    }
    setIsDrawingActive(false);
    // Discard any half-drawn Freehand stroke too -- it lives outside
    // terra-draw so the orphan sweep below would miss it otherwise.
    if (freehandActiveRef.current) {
      freehandActiveRef.current = false;
      freehandCoordsRef.current = [];
      freehandLastPxRef.current = null;
      freehandCursorRef.current = null;
      if (freehandRafRef.current !== null) {
        cancelAnimationFrame(freehandRafRef.current);
        freehandRafRef.current = null;
      }
      setFreehandPreview(null);
      setFreehandStartDot(null);
    }
    // Any in-progress polygon start-dot is stale after a mode change.
    setPolygonStartDot(null);
    const draw = drawRef.current;
    if (draw) {
      // Discard any un-finalised drawing before switching modes (orphan
      // cleanup) so half-drawn shapes don't linger in terra-draw's store.
      const snapshot = draw.getSnapshot();
      const orphans = snapshot
        .filter(
          (f) =>
            f.id !== undefined &&
            (f.geometry.type === 'LineString' || f.geometry.type === 'Polygon') &&
            !partyByIdRef.current.has(f.id as string | number),
        )
        .map((f) => f.id as string | number);
      if (orphans.length > 0) {
        try {
          draw.removeFeatures(orphans);
        } catch {
          // best-effort
        }
      }
      draw.setMode(TD_MODE[next]);
    }
    setModeState(next);
    if (next !== 'select') setSelectedFeatureId(null);
  }, []);

  const deleteSelected = useCallback(() => {
    const id = selectedFeatureId;
    if (id === null) return;
    // terra-draw's removeFeatures only knows about its own ids; calling
    // it for a Freehand-id is harmless (it logs and skips).
    try {
      drawRef.current?.removeFeatures([id]);
    } catch {
      // Freehand polygons aren't in terra-draw's store, so removeFeatures
      // can throw "No feature with id". Safe to ignore.
    }
    partyByIdRef.current.delete(id);
    finalizedIdsRef.current.delete(id);
    processedGeomByIdRef.current.delete(id);
    autoCompletedIdsRef.current.delete(id);
    if (typeof id === 'string') freehandPolygonsRef.current.delete(id);
    setFeatures((prev) => ({
      type: 'FeatureCollection',
      features: prev.features.filter((f) => f.id !== id),
    }));
    setSelectedFeatureId(null);
  }, [selectedFeatureId]);

  const clearAll = useCallback(() => {
    drawRef.current?.clear();
    partyByIdRef.current.clear();
    finalizedIdsRef.current.clear();
    processedGeomByIdRef.current.clear();
    autoCompletedIdsRef.current.clear();
    freehandPolygonsRef.current.clear();
    setFeatures(EMPTY_FC);
    setSelectedFeatureId(null);
    setPolygonStartDot(null);
  }, []);

  // Programmatic select. Used by MapView when the user clicks on our
  // coloured polygon fill -- terra-draw's own click detection can't reach
  // the fill (the fill is a Mapbox layer, not a terra-draw layer; only the
  // line-string outline is inside terra-draw's hit-test).
  const selectFeatureById = useCallback((id: string | number) => {
    const draw = drawRef.current;
    const selectMode = selectModeRef.current;
    if (!draw || !selectMode) return;
    if (draw.getMode() !== 'select') {
      draw.setMode('select');
      setModeState('select');
    }
    try {
      selectMode.selectFeature(id);
    } catch (err) {
      console.warn('selectMode.selectFeature threw:', err);
    }
    // Mirror into React state directly so the Delete-selected button enables
    // even if terra-draw's select event doesn't propagate for some reason.
    setSelectedFeatureId(id);
  }, []);

  // Cancel any in-progress stroke (Escape key, mode switch, etc).
  const cancelInProgress = useCallback(() => {
    // Reset Freehand state.
    freehandActiveRef.current = false;
    freehandCoordsRef.current = [];
    freehandLastPxRef.current = null;
    freehandCursorRef.current = null;
    if (freehandRafRef.current !== null) {
      cancelAnimationFrame(freehandRafRef.current);
      freehandRafRef.current = null;
    }
    setFreehandPreview(null);
    setFreehandStartDot(null);
    // Also drop any polygon start-dot indicator (Escape while drawing).
    setPolygonStartDot(null);

    const draw = drawRef.current;
    if (draw) {
      const snap = draw.getSnapshot();
      const orphans = snap
        .filter(
          (f) =>
            f.id !== undefined &&
            (f.geometry.type === 'LineString' || f.geometry.type === 'Polygon') &&
            !partyByIdRef.current.has(f.id as string | number),
        )
        .map((f) => f.id as string | number);
      if (orphans.length > 0) {
        try {
          draw.removeFeatures(orphans);
        } catch {
          // best-effort
        }
      }
    }
    setIsDrawingActive(false);
  }, []);

  // Run the standard finalize pipeline (auto-complete -> clip -> simplify)
  // on a closed polygon coming from the custom Freehand path, and tag it
  // with the active party.
  const finalizeFreehandPolygon = useCallback(
    (drawnCoords: Position[]): string | null => {
      const closed = lineStringToPolygon(drawnCoords);
      if (!closed) return null;
      let working: Polygon = closed;

      // Auto-complete: feed the *open* coords (drop the duplicated last
      // point) so the algorithm sees the user's true endpoints.
      let autoCompleted = false;
      let drawnCount = 0;
      const index = boundaryIndexRef.current;
      if (index) {
        // Dedupe consecutive-duplicate coords, and specifically drop any
        // trailing coord that equals the start coord. Both can occur when
        // the user's finish click lands on/near the start dot (they wanted
        // to "manually close" the triangle). Without this, autoComplete
        // sees start == end and computes a degenerate no-op, appearing to
        // "not fire" even when the algorithm is otherwise healthy.
        const openCoords: Position[] = [];
        for (const c of drawnCoords) {
          const prev = openCoords[openCoords.length - 1];
          if (!prev || prev[0] !== c[0] || prev[1] !== c[1]) {
            openCoords.push(c);
          }
        }
        // Also drop a trailing coord that equals the start (user manually
        // closed). Repeat in case the trailing dedup produced a new match.
        while (
          openCoords.length > 2 &&
          openCoords[openCoords.length - 1][0] === openCoords[0][0] &&
          openCoords[openCoords.length - 1][1] === openCoords[0][1]
        ) {
          openCoords.pop();
        }
        const replaced = autoCompleteAlongBoundary(
          openCoords,
          index,
          currentSnapKm(),
        );
        if (replaced && replaced.polygon.coordinates[0]?.length >= 4) {
          // Only the user's own stroke needs checking against the constraint.
          // The rest of the ring is the boundary itself, traced vertex by
          // vertex -- testing those is both pointless and, before the grid
          // index existed, the single largest cost on the finish path
          // (~2.3 s of blocked main thread on a long trace, which the browser
          // surfaced as a "page unresponsive" dialog).
          const constraint = constraintBoundaryRef.current;
          const ring = replaced.polygon.coordinates[0];
          let allInside = true;
          for (let i = 0; i < replaced.drawnCount && i < ring.length; i++) {
            if (!isEffectivelyInsidePI(ring[i], constraint, index)) {
              allInside = false;
              break;
            }
          }
          if (allInside) {
            working = replaced.polygon;
            drawnCount = replaced.drawnCount;
            autoCompleted = true;
          }
        }
      }

      // Simplify BEFORE clipping, while the ring layout is still known: on an
      // auto-completed shape only the user's stroke is thinned, never the
      // traced border.
      const zoom = mapRef.current?.getZoom() ?? 8;
      const simplified: Polygon = autoCompleted
        ? {
            type: 'Polygon',
            coordinates: [simplifyDrawnPortion(working.coordinates[0], drawnCount, zoom)],
          }
        : simplifyPolygon(working, zoom);

      const processedRaw = clipPolygonToBoundaryFast(
        simplified,
        constraintBoundaryRef.current,
        boundaryIndexRef.current,
      );
      if (!processedRaw) return null;

      // Reject degenerate near-zero-area polygons (very few vertices
      // dropped by a too-early finish click, or colinear vertices that
      // form a sliver). Without this, tapping start-then-finish
      // immediately can leave a hair-thin visual sliver Escape can't
      // dismiss because it's already been committed.
      if (ringAreaSqDeg(processedRaw.coordinates[0]) < 1e-10) return null;

      // Bug 2: cache the ROUNDED polygon so the byte-level state matches
      // what terra-draw actually stores after injection. Otherwise our
      // coordsEqual guard on the 'update' handler always returns false
      // (unrounded cache vs rounded live), and every terra-draw 'update'
      // event (including selection-state changes) wipes the cache.
      const roundedRing: Position[] = [];
      for (const [lng, lat] of processedRaw.coordinates[0]) {
        if (typeof lng !== 'number' || typeof lat !== 'number') continue;
        if (Number.isNaN(lng) || Number.isNaN(lat)) continue;
        const r: Position = [
          Math.round(lng * 1e9) / 1e9,
          Math.round(lat * 1e9) / 1e9,
        ];
        const prev = roundedRing[roundedRing.length - 1];
        if (!prev || prev[0] !== r[0] || prev[1] !== r[1]) roundedRing.push(r);
      }
      if (roundedRing.length >= 3) {
        const first = roundedRing[0];
        const last = roundedRing[roundedRing.length - 1];
        if (first[0] !== last[0] || first[1] !== last[1]) {
          roundedRing.push([first[0], first[1]]);
        }
      }
      const processed: Polygon = { type: 'Polygon', coordinates: [roundedRing] };

      const id = `freehand-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
      partyByIdRef.current.set(id, activePartyRef.current);
      finalizedIdsRef.current.add(id);
      processedGeomByIdRef.current.set(id, processed);
      // Source of truth for rendering. Whether or not terra-draw accepts
      // the injection below, the polygon will always show via syncFeatures
      // pulling from this ref.
      freehandPolygonsRef.current.set(id, processed);
      if (autoCompleted) autoCompletedIdsRef.current.add(id);

      // Best-effort inject into terra-draw's store as a (closed)
      // LineString so its Select mode can find the polygon: vertex
      // handles, drag-to-reshape, etc.
      //
      // B7: make injection as robust as possible so vertex handles show
      // consistently every time. Two known failure modes handled here:
      //  1. terra-draw's validator rejects coords with more than
      //     `coordinatePrecision` decimal places (default 9). Mapbox
      //     lngLat has 14+ decimals so round to 9 dp.
      //  2. Adjacent-duplicate coords (very close vertices that round to
      //     the same values) also trip validation. Dedupe consecutive
      //     equal coords before injection.
      const draw = drawRef.current;
      if (draw) {
        const raw = processed.coordinates[0];
        if (raw) {
          const rounded: Position[] = [];
          for (const [lng, lat] of raw) {
            if (typeof lng !== 'number' || typeof lat !== 'number') continue;
            if (Number.isNaN(lng) || Number.isNaN(lat)) continue;
            const r: Position = [
              Math.round(lng * 1e9) / 1e9,
              Math.round(lat * 1e9) / 1e9,
            ];
            const prev = rounded[rounded.length - 1];
            if (!prev || prev[0] !== r[0] || prev[1] !== r[1]) rounded.push(r);
          }
          // Ensure the ring is closed (first == last after dedupe).
          if (rounded.length >= 3) {
            const first = rounded[0];
            const last = rounded[rounded.length - 1];
            if (first[0] !== last[0] || first[1] !== last[1]) {
              rounded.push([first[0], first[1]]);
            }
          }
          if (rounded.length >= 4) {
            // Mark this id as "just injected" so the change handler skips
            // its cache invalidation for any 'update' events fired by
            // terra-draw during and shortly after addFeatures. Removed
            // after a 500 ms settle window, well past any terra-draw
            // internal event dispatch — subsequent user edits still
            // invalidate the cache normally.
            justInjectedRef.current.add(id);
            try {
              const validations = draw.addFeatures([
                {
                  type: 'Feature',
                  id,
                  geometry: { type: 'LineString', coordinates: rounded },
                  properties: {
                    mode: 'linestring',
                    createdAt: Date.now(),
                  },
                } as unknown as Parameters<typeof draw.addFeatures>[0][number],
              ]);
              const rejected = (validations ?? []).filter(
                (v) => v && (v as { valid?: boolean }).valid === false,
              );
              if (rejected.length > 0) {
                console.warn(
                  'Freehand polygon not selectable in terra-draw (validation rejected); fill-click select bridge still works.',
                  rejected,
                );
              }
            } catch (err) {
              console.warn('Freehand inject into terra-draw threw:', err);
            } finally {
              setTimeout(() => {
                justInjectedRef.current.delete(id);
              }, 500);
            }
          }
        }
      }

      return id;
    },
    [currentSnapKm],
  );

  // ---- Freehand: click + mousemove handlers ----
  //
  // terra-draw is parked in a no-op mode while Freehand UI is active. We
  // listen to the map's pointer events ourselves and trace a continuous
  // line.
  //
  // Two flavours of state:
  //   - "Committed" vertices: snapped onto the user's path every
  //     PX_PER_VERTEX screen-pixels. These are what the finalised polygon
  //     is built from.
  //   - "Live" cursor coord: updated on EVERY mousemove. The rendered
  //     preview line ends here -- so the line tip stays exactly at the
  //     cursor between commits rather than lagging behind by ~6 px.
  //
  // We also expose `freehandStartDot` separately so MapView can render a
  // visible dot from the very first click (a one-coord LineString isn't
  // a renderable thing in Mapbox).
  useEffect(() => {
    if (!map) return;
    if (mode !== 'freehand') return;

    const PX_PER_VERTEX = 6;

    const renderPreview = () => {
      if (!freehandActiveRef.current) return;
      const committed = freehandCoordsRef.current;
      const cursor = freehandCursorRef.current;
      if (committed.length === 0 || !cursor) return;
      // If the cursor is essentially on the only committed point (the
      // start dot), don't render a zero-length line -- the dot already
      // gives the start feedback.
      if (
        committed.length === 1 &&
        committed[0][0] === cursor[0] &&
        committed[0][1] === cursor[1]
      ) {
        setFreehandPreview(null);
        return;
      }

      // B1: clip the preview tip at the PI boundary. When the cursor is
      // outside PI, the rendered line stops at the boundary intersection
      // -- the cursor can still move freely, but no line/party colour
      // appears outside PI during drawing.
      let tip: Position = cursor;
      const constraint = constraintBoundaryRef.current;
      if (constraint) {
        const cursorInside = booleanPointInPolygon(
          turfPoint(cursor),
          constraint,
        );
        if (!cursorInside) {
          // Walk backwards along committed vertices to the last one that
          // was inside; find where the segment (last-inside -> cursor)
          // crosses the boundary, and use that crossing as the tip.
          let lastInside: Position | null = null;
          for (let i = committed.length - 1; i >= 0; i--) {
            if (booleanPointInPolygon(turfPoint(committed[i]), constraint)) {
              lastInside = committed[i];
              break;
            }
          }
          if (lastInside) {
            // Indexed crossing lookup. This previously ran turf's
            // `lineIntersect` against the entire boundary on every single
            // mousemove -- i.e. exactly while the user was tracing along the
            // border, which is where the freehand lag was reported.
            const index = boundaryIndexRef.current;
            const crossing = index?.intersectSegment(lastInside, cursor) ?? null;
            // No crossing found -- fall back to the last-inside vertex.
            tip = crossing ?? lastInside;
          } else {
            // The entire stroke is outside PI (nothing committed inside).
            // Don't render a preview line.
            setFreehandPreview(null);
            return;
          }
        }
      }

      setFreehandPreview({
        type: 'Feature',
        properties: {},
        geometry: {
          type: 'LineString',
          coordinates: [...committed, tip],
        },
      });
    };

    const onClick = (e: mapboxgl.MapMouseEvent) => {
      let coord: Position = [e.lngLat.lng, e.lngLat.lat];
      // Clicks outside PI do not place a vertex -- with one deliberate
      // exception. Aiming AT the border is the whole point of the gesture that
      // starts an auto-completed area, but a click on a line one pixel wide
      // lands on the outside of it about half the time, and rejecting those
      // outright reads as "the tool ignored me". So a click that misses by less
      // than the snap tolerance is pulled onto the boundary instead of being
      // dropped: the stroke then genuinely begins ON the border rather than
      // just inside it.
      const constraint = constraintBoundaryRef.current;
      if (constraint && !booleanPointInPolygon(turfPoint(coord), constraint)) {
        const index = boundaryIndexRef.current;
        const hit = index?.nearest(coord);
        if (!hit || hit.distKm > currentSnapKm()) return;
        coord = hit.snapped;
      }
      if (!freehandActiveRef.current) {
        // Start. React 18's auto-batching commits these together on the
        // next paint tick, which is fast enough that the start-dot shows
        // without perceptible delay (Bill's B4 concern -- verified in
        // testing). flushSync was tried here but was throwing under
        // concurrent rendering and silently killed the whole freehand
        // handler chain, so removed.
        freehandActiveRef.current = true;
        freehandCoordsRef.current = [coord];
        freehandLastPxRef.current = { x: e.point.x, y: e.point.y };
        freehandCursorRef.current = coord;
        setIsDrawingActive(true);
        setFreehandStartDot(coord);
        setFreehandPreview(null);
        return;
      }
      // Finish -- include the click coord as the final vertex.
      freehandCoordsRef.current.push(coord);
      const finalCoords = freehandCoordsRef.current.slice();
      freehandActiveRef.current = false;
      freehandCoordsRef.current = [];
      freehandLastPxRef.current = null;
      freehandCursorRef.current = null;
      setFreehandPreview(null);
      setFreehandStartDot(null);
      setIsDrawingActive(false);

      if (finalCoords.length < 3) return;
      const id = finalizeFreehandPolygon(finalCoords);
      if (!id) return;
      syncFeatures();

      // Cancel any residual map motion (edge-pan velocity, mid-flight
      // inertia) so the view doesn't drift after freehand finish either.
      try {
        mapRef.current?.stop();
      } catch {
        // best-effort
      }

      // B8: trigger vertex-pulse at the finish click position.
      finishPulseNonceRef.current += 1;
      setFinishPulse({
        coord,
        nonce: finishPulseNonceRef.current,
      });
      if (finishPulseTimeoutRef.current)
        clearTimeout(finishPulseTimeoutRef.current);
      finishPulseTimeoutRef.current = setTimeout(() => {
        setFinishPulse(null);
        finishPulseTimeoutRef.current = null;
      }, 1500);

      setPostFinishBeat(true);
      if (beatTimeoutRef.current) clearTimeout(beatTimeoutRef.current);
      beatTimeoutRef.current = setTimeout(() => {
        setPostFinishBeat(false);
        beatTimeoutRef.current = null;
      }, 1000);
    };

    const onMove = (e: mapboxgl.MapMouseEvent) => {
      if (!freehandActiveRef.current) return;
      const newCursor: Position = [e.lngLat.lng, e.lngLat.lat];
      freehandCursorRef.current = newCursor;

      // B1 REVISIT: only commit vertices when the cursor is INSIDE PI. If
      // we commit outside-PI vertices, they appear in the rendered
      // preview LineString regardless of the clip logic in renderPreview
      // (which only ever clips the last segment). Skip the commit when
      // outside so the underlying committed geometry is always inside PI;
      // renderPreview then trims the tip against the boundary.
      const constraint = constraintBoundaryRef.current;
      const cursorInside =
        !constraint || booleanPointInPolygon(turfPoint(newCursor), constraint);

      // Commit a new vertex when the cursor moves ≥ PX_PER_VERTEX pixels
      // since the last commit -- keeps the polygon coordinate count bounded
      // while letting the line tip itself follow the cursor exactly between
      // commits.
      const last = freehandLastPxRef.current;
      if (last) {
        const dx = e.point.x - last.x;
        const dy = e.point.y - last.y;
        if (dx * dx + dy * dy >= PX_PER_VERTEX * PX_PER_VERTEX) {
          // Advance the pixel anchor even when the cursor is outside -- otherwise
          // when the cursor re-enters PI we'd trigger a burst of catch-up
          // vertex pushes.
          freehandLastPxRef.current = { x: e.point.x, y: e.point.y };
          if (cursorInside) {
            freehandCoordsRef.current.push(newCursor);
          } else {
            // Outside PI: clamp the vertex onto the boundary rather than
            // dropping it.
            //
            // Dropping it is what produced the artifacts Bill photographed at
            // the southern (Eilat/Umm Rashrash) tip. The wedge there is only a
            // few km across, so a stroke drawn from the Egyptian side to the
            // Jordanian side spends much of its length fractionally outside the
            // border. Every one of those vertices used to vanish, leaving the
            // committed ring with only the handful of points that happened to
            // fall inside -- near-colinear, so closing them produced the thin
            // lens slivers in his screenshot instead of the area he drew.
            //
            // Clamping keeps the stroke continuous and inside the region, which
            // is where it was always going to end up after the finish-time clip
            // anyway. Uses the perpendicular projection rather than the
            // preview's crossing point because once the cursor is fully outside
            // there is no crossing left to compute -- projection still tracks
            // the border as the pointer travels along it.
            const snapped = boundaryIndexRef.current?.nearest(newCursor)?.snapped;
            if (snapped) {
              const committed = freehandCoordsRef.current;
              const last = committed[committed.length - 1];
              if (!last || last[0] !== snapped[0] || last[1] !== snapped[1]) {
                committed.push(snapped);
              }
            }
          }
        }
      } else {
        freehandLastPxRef.current = { x: e.point.x, y: e.point.y };
      }

      // React 18 auto-batches setState across mousemove events, so calling
      // setFreehandPreview directly on every mousemove is performant
      // enough without a separate rAF coalescing step.
      renderPreview();
    };

    map.on('click', onClick);
    map.on('mousemove', onMove);
    return () => {
      map.off('click', onClick);
      map.off('mousemove', onMove);
      if (freehandRafRef.current !== null) {
        cancelAnimationFrame(freehandRafRef.current);
        freehandRafRef.current = null;
      }
      // Don't leave a half-finished stroke if mode changed mid-draw.
      if (freehandActiveRef.current) {
        freehandActiveRef.current = false;
        freehandCoordsRef.current = [];
        freehandLastPxRef.current = null;
        freehandCursorRef.current = null;
        setFreehandPreview(null);
        setFreehandStartDot(null);
        setIsDrawingActive(false);
      }
    };
  }, [map, mode, finalizeFreehandPolygon, syncFeatures, currentSnapKm]);

  // Polygon-mode close-intercept.
  //
  // Terra-draw's internal "click near start vertex to close" tolerance is
  // ~12-15 px. Our visible polygonStartDot tolerance ring is 28 px (to
  // match Bill's Bucket 2 "make it bigger and visible" request). Without
  // this intercept, a user clicking inside the visible ring but outside
  // terra-draw's tighter tolerance ends up placing an extra vertex near
  // the start instead of closing the polygon — the exact bug the user
  // hit ("clicked start, but shape did not close").
  //
  // Approach: after terra-draw processes each polygon-mode click (which
  // adds a vertex), we check whether that vertex landed inside the visible
  // tolerance ring. If yes AND the linestring has ≥3 vertices (enough to
  // form a valid closed shape), we take terra-draw's linestring coords,
  // drop the trailing near-start vertex, remove the in-progress feature
  // from terra-draw, and run the coords through the freehand finalize
  // pipeline (auto-complete → clip → simplify → inject as a fresh
  // polygon). Terra-draw's own 'finish' path is not triggered here — the
  // finalized shape is entirely owned by our freehand pipeline once
  // closed this way.
  useEffect(() => {
    if (!map) return;
    if (mode !== 'polygon') return;
    const CLOSE_PX = 28; // matches polygonStartDot tolerance ring radius

    const onClick = (e: mapboxgl.MapMouseEvent) => {
      const start = polygonStartDotRef.current;
      if (!start) return;
      // Terra-draw runs first (registered earlier via draw.start()), so by
      // the time we get here the click has already been added as a vertex
      // to the in-progress linestring. Compute pixel distance from that
      // start vertex to the click position.
      const startPx = map.project(start as [number, number]);
      const dx = e.point.x - startPx.x;
      const dy = e.point.y - startPx.y;
      if (dx * dx + dy * dy > CLOSE_PX * CLOSE_PX) return;

      const draw = drawRef.current;
      if (!draw) return;
      const snap = draw.getSnapshot();
      const inProgress = snap.find(
        (f) =>
          f.id !== undefined &&
          !finalizedIdsRef.current.has(f.id as string | number) &&
          f.geometry.type === 'LineString',
      );
      if (!inProgress || inProgress.geometry.type !== 'LineString') return;

      // Need at least 3 distinct vertices BEFORE terra-draw's just-added
      // near-start click (so 4 total in the stored linestring) to form a
      // valid closed polygon.
      const liveCoords = inProgress.geometry.coordinates;
      if (liveCoords.length < 4) return;

      // Drop the trailing near-start click and use start as the closing
      // vertex — cleaner than closing on the near-start point.
      const closingCoords: Position[] = [...liveCoords.slice(0, -1), start];

      // Remove terra-draw's in-progress feature so it doesn't linger as
      // an active drawing session.
      try {
        draw.removeFeatures([inProgress.id as string | number]);
      } catch {
        // best-effort
      }

      // Finalize via the freehand pipeline (auto-complete → clip →
      // simplify → cache → inject as a fresh polygon).
      const newId = finalizeFreehandPolygon(closingCoords);

      // The stroke is over either way -- terra-draw's in-progress feature has
      // already been removed above. Leaving these inside the success branch
      // meant that whenever finalize returned null (a degenerate sliver, or a
      // clip that rejected the shape) the tool stayed stuck in "drawing" state:
      // the cursor kept the index-finger pointer instead of reverting to the
      // open hand, and the stale start-dot stayed on screen. That was the
      // reported "after area finish, cursor remains index finger version".
      try {
        mapRef.current?.stop();
      } catch {
        // best-effort
      }
      setIsDrawingActive(false);
      setPolygonStartDot(null);

      if (newId) {
        // Completion feedback only makes sense when a shape was actually kept.
        finishPulseNonceRef.current += 1;
        setFinishPulse({
          coord: [start[0], start[1]],
          nonce: finishPulseNonceRef.current,
        });
        if (finishPulseTimeoutRef.current)
          clearTimeout(finishPulseTimeoutRef.current);
        finishPulseTimeoutRef.current = setTimeout(() => {
          setFinishPulse(null);
          finishPulseTimeoutRef.current = null;
        }, 1500);
        setPostFinishBeat(true);
        if (beatTimeoutRef.current) clearTimeout(beatTimeoutRef.current);
        beatTimeoutRef.current = setTimeout(() => {
          setPostFinishBeat(false);
          beatTimeoutRef.current = null;
        }, 1000);
        syncFeatures();
      }
    };

    map.on('click', onClick);
    return () => {
      map.off('click', onClick);
    };
  }, [map, mode, finalizeFreehandPolygon, syncFeatures]);

  // Clean up the post-finish-beat + pulse timers on unmount.
  useEffect(() => {
    return () => {
      if (beatTimeoutRef.current) {
        clearTimeout(beatTimeoutRef.current);
        beatTimeoutRef.current = null;
      }
      if (finishPulseTimeoutRef.current) {
        clearTimeout(finishPulseTimeoutRef.current);
        finishPulseTimeoutRef.current = null;
      }
    };
  }, []);

  return {
    mode,
    setMode,
    activeParty,
    setActiveParty,
    features,
    deleteSelected,
    clearAll,
    selectedFeatureId,
    selectFeatureById,
    isDrawingActive,
    postFinishBeat,
    cancelInProgress,
    freehandPreview,
    freehandStartDot,
    finishPulse,
    polygonStartDot,
  };
}
