import { useEffect, useMemo, useRef, type MutableRefObject } from 'react';
import type maplibregl from 'maplibre-gl';
import type { GeoJSONSource, GeoJSONSourceSpecification, LayerSpecification } from 'maplibre-gl';
import type { AisTarget } from '../../../entities/aisTarget/model/types';
import type { LegacyVesselInfo } from '../../../entities/legacyVessel/model/types';
import { ALARM_BADGE, type LegacyAlarmKind } from '../../../features/legacyDashboard/model/types';
import type { MapToggleState } from '../../../features/mapToggles/MapToggles';
import type { Map3DSettings, MapProjectionId } from '../types';
import {
  ANCHORED_SHIP_ICON_ID,
  GLOBE_ICON_HEADING_OFFSET_DEG,
} from '../constants';
import { isFiniteNumber } from '../lib/setUtils';
import { kickRepaint, onMapStyleReady } from '../lib/mapCore';
import {
  isAnchoredShip,
  getDisplayHeading,
  getGlobeBaseShipColor,
} from '../lib/shipUtils';
import {
  buildFallbackGlobeAnchoredShipIcon,
  ensureFallbackShipImage,
} from '../lib/globeShipIcon';
import { clampNumber } from '../lib/geometry';
import { guardedSetVisibility } from '../lib/layerHelpers';

// ── Alarm pulse animation constants ──
// Offset from outline radius so pulse ring never overlaps the outline stroke
const ALARM_PULSE_OFFSET_MIN = 5;   // px offset from base circle at rest
const ALARM_PULSE_OFFSET_MAX = 11;  // px offset at peak
const ALARM_PULSE_HOVER_OFFSET_MIN = 7;
const ALARM_PULSE_HOVER_OFFSET_MAX = 14;
const ALARM_PULSE_PERIOD_MS = 1500;

// Base circle radii per zoom (from mlExpressions BASE_VALUES)
const BASE_R_BY_ZOOM = [
  [3, 4], [7, 6], [10, 8], [14, 12], [18, 32],
] as const;

/** Build zoom-interpolated radius = base + offset for alarm pulse */
function buildAlarmPulseRadiusExpr(offset: number) {
  const stops: unknown[] = ['interpolate', ['linear'], ['zoom']];
  for (const [z, base] of BASE_R_BY_ZOOM) {
    stops.push(z, base + offset);
  }
  return stops as never;
}

/** Build zoom-interpolated radius with hover/normal case for alarm pulse */
function buildAlarmPulseRadiusCaseExpr(normalOffset: number, hoverOffset: number) {
  const stops: unknown[] = ['interpolate', ['linear'], ['zoom']];
  for (const [z, base] of BASE_R_BY_ZOOM) {
    stops.push(z, [
      'case',
      ['any', ['==', ['feature-state', 'highlighted'], 1], ['==', ['feature-state', 'selected'], 1]],
      base + hoverOffset,
      base + normalOffset,
    ]);
  }
  return stops as never;
}

/** Globe 모드 선박 아이콘 레이어 (halo, outline, symbol, label, alarm pulse, alarm badge) */
export function useGlobeShipLayers(
  mapRef: MutableRefObject<maplibregl.Map | null>,
  projectionBusyRef: MutableRefObject<boolean>,
  reorderGlobeFeatureLayers: () => void,
  opts: {
    projection: MapProjectionId;
    settings: Map3DSettings;
    shipData: AisTarget[];
    overlays: MapToggleState;
    legacyHits: Map<number, LegacyVesselInfo> | null | undefined;
    selectedMmsi: number | null;
    isBaseHighlightedMmsi: (mmsi: number) => boolean;
    mapSyncEpoch: number;
    onGlobeShipsReady?: (ready: boolean) => void;
    alarmMmsiMap?: Map<number, LegacyAlarmKind>;
  },
) {
  const {
    projection, settings, shipData, overlays, legacyHits,
    selectedMmsi, isBaseHighlightedMmsi, mapSyncEpoch, onGlobeShipsReady,
    alarmMmsiMap,
  } = opts;

  const epochRef = useRef(-1);
  const breatheRafRef = useRef<number>(0);
  const prevGeoJsonRef = useRef<GeoJSON.FeatureCollection | null>(null);
  const prevAlarmGeoJsonRef = useRef<GeoJSON.FeatureCollection | null>(null);

  // Globe GeoJSON을 projection과 무관하게 항상 사전 계산
  // Globe 전환 시 즉시 사용 가능하도록 useMemo로 캐싱
  const globeShipGeoJson = useMemo((): GeoJSON.FeatureCollection<GeoJSON.Point> => {
    return {
      type: 'FeatureCollection',
      features: shipData.map((t) => {
        const legacy = legacyHits?.get(t.mmsi) ?? null;
        const alarmKind = alarmMmsiMap?.get(t.mmsi) ?? null;
        const baseName = (legacy?.shipNameRoman?.toUpperCase() || legacy?.shipNameCn || t.name?.toUpperCase() || '').trim();
        const labelName = alarmKind ? `[${ALARM_BADGE[alarmKind].label}] ${baseName}` : baseName;
        const heading = getDisplayHeading({
          cog: t.cog,
          heading: t.heading,
          offset: GLOBE_ICON_HEADING_OFFSET_DEG,
        });
        const isAnchored = isAnchoredShip({ sog: t.sog, cog: t.cog, heading: t.heading });
        const shipHeading = isAnchored ? 0 : heading;
        const hull = clampNumber(
          (isFiniteNumber(t.length) ? t.length : 0) + (isFiniteNumber(t.width) ? t.width : 0) * 3,
          50, 420,
        );
        const baseSizeScale = clampNumber(0.85 + (hull - 50) / 420, 0.82, 1.85);
        // 대상 선박은 1.3x 배율 적용
        const sizeScale = legacy ? baseSizeScale * 1.3 : baseSizeScale;
        const iconSize3 = clampNumber(0.35 * sizeScale, 0.25, 1.3);
        const iconSize7 = clampNumber(0.45 * sizeScale, 0.3, 1.45);
        const iconSize10 = clampNumber(0.58 * sizeScale, 0.35, 1.8);
        const iconSize14 = clampNumber(0.85 * sizeScale, 0.45, 2.6);
        const iconSize18 = clampNumber(2.5 * sizeScale, 1.0, 6.0);
        return {
          type: 'Feature' as const,
          ...(isFiniteNumber(t.mmsi) ? { id: Math.trunc(t.mmsi) } : {}),
          geometry: { type: 'Point' as const, coordinates: [t.lon, t.lat] },
          properties: {
            mmsi: t.mmsi,
            name: t.name || '',
            labelName,
            cog: shipHeading,
            heading: shipHeading,
            sog: isFiniteNumber(t.sog) ? t.sog : 0,
            isAnchored: isAnchored ? 1 : 0,
            shipColor: getGlobeBaseShipColor({
              legacy: legacy?.shipCode || null,
              signalKindCode: t.signalKindCode || t.shipKindCode || '000027',
            }),
            iconSize3,
            iconSize7,
            iconSize10,
            iconSize14,
            iconSize18,
            sizeScale,
            permitted: legacy ? 1 : 0,
            code: legacy?.shipCode || '',
            alarmed: alarmKind ? 1 : 0,
            alarmKind: alarmKind ?? '',
            alarmBadgeLabel: alarmKind ? ALARM_BADGE[alarmKind].label : '',
            alarmBadgeColor: alarmKind ? ALARM_BADGE[alarmKind].color : '#000',
            hasPhoto: t.shipImagePath ? 1 : 0,
          },
        };
      }),
    };
  }, [shipData, legacyHits, alarmMmsiMap]);

  // Alarm-only GeoJSON — separate source to avoid badge symbol re-placement
  // when the main ship source updates (position polling)
  const alarmGeoJson = useMemo((): GeoJSON.FeatureCollection<GeoJSON.Point> => {
    if (!alarmMmsiMap || alarmMmsiMap.size === 0) {
      return { type: 'FeatureCollection', features: [] };
    }
    return {
      type: 'FeatureCollection',
      features: shipData
        .filter((t) => alarmMmsiMap.has(t.mmsi))
        .map((t) => {
          const alarmKind = alarmMmsiMap.get(t.mmsi)!;
          return {
            type: 'Feature' as const,
            ...(isFiniteNumber(t.mmsi) ? { id: Math.trunc(t.mmsi) } : {}),
            geometry: { type: 'Point' as const, coordinates: [t.lon, t.lat] },
            properties: {
              mmsi: t.mmsi,
              alarmed: 1,
              alarmBadgeLabel: ALARM_BADGE[alarmKind].label,
              alarmBadgeColor: ALARM_BADGE[alarmKind].color,
            },
          };
        }),
    };
  }, [shipData, alarmMmsiMap]);

  // Ships in globe mode
  useEffect(() => {
    const map = mapRef.current;
    if (!map) return;

    const imgId = 'ship-globe-icon';
    const anchoredImgId = ANCHORED_SHIP_ICON_ID;
    const srcId = 'ships-globe-src';
    const alarmSrcId = 'ships-globe-alarm-src';
    const haloId = 'ships-globe-halo';
    const outlineId = 'ships-globe-outline';
    const symbolLiteId = 'ships-globe-lite';
    const symbolId = 'ships-globe';
    const labelId = 'ships-globe-label';
    const photoId = 'ships-globe-photo';
    const pulseId = 'ships-globe-alarm-pulse';
    const badgeId = 'ships-globe-alarm-badge';

    // 레이어를 제거하지 않고 visibility만 'none'으로 설정
    // guardedSetVisibility로 현재 값과 동일하면 호출 생략 (style._changed 방지)
    const hide = () => {
      for (const id of [badgeId, photoId, labelId, symbolId, symbolLiteId, pulseId, outlineId, haloId]) {
        guardedSetVisibility(map, id, 'none');
      }
    };

    const ensureImage = () => {
      ensureFallbackShipImage(map, imgId);
      ensureFallbackShipImage(map, anchoredImgId, buildFallbackGlobeAnchoredShipIcon);
      if (map.hasImage(imgId) && map.hasImage(anchoredImgId)) return;
      kickRepaint(map);
    };

    const ensure = () => {
      if (!settings.showShips) {
        hide();
        onGlobeShipsReady?.(false);
        return;
      }

      // 빠른 visibility 토글 — projectionBusy 중에도 실행
      // guardedSetVisibility로 값이 실제로 바뀔 때만 setLayoutProperty 호출
      // → style._changed 방지 → 불필요한 symbol placement 재계산 방지 → 라벨 사라짐 방지
      const visibility: 'visible' | 'none' = projection === 'globe' ? 'visible' : 'none';
      const labelVisibility: 'visible' | 'none' = projection === 'globe' && overlays.shipLabels ? 'visible' : 'none';
      const photoVisibility: 'visible' | 'none' = projection === 'globe' ? 'visible' : 'none';
      if (map.getLayer(symbolId) || map.getLayer(symbolLiteId)) {
        const changed =
          map.getLayoutProperty(symbolId, 'visibility') !== visibility ||
          map.getLayoutProperty(symbolLiteId, 'visibility') !== visibility;
        if (changed) {
          for (const id of [haloId, outlineId, pulseId, symbolLiteId, symbolId, badgeId]) {
            guardedSetVisibility(map, id, visibility);
          }
          if (projection === 'globe') kickRepaint(map);
        }
        guardedSetVisibility(map, labelId, labelVisibility);
        guardedSetVisibility(map, photoId, photoVisibility);
      }

      // 데이터 업데이트는 projectionBusy 중에는 차단
      if (projectionBusyRef.current) {
        // 레이어가 이미 존재하면 ready 상태 유지
        if (map.getLayer(symbolId)) onGlobeShipsReady?.(true);
        return;
      }
      if (!map.isStyleLoaded()) return;

      if (epochRef.current !== mapSyncEpoch) {
        epochRef.current = mapSyncEpoch;
      }

      try {
        ensureImage();
      } catch (e) {
        console.warn('Ship icon image setup failed:', e);
      }

      // 사전 계산된 GeoJSON 사용 (useMemo에서 projection과 무관하게 항상 준비됨)
      // 참조 동일성 기반 setData 스킵 — 위치 변경 없는 epoch/설정 변경 시 재전송 방지
      const geojson = globeShipGeoJson;
      const geoJsonChanged = geojson !== prevGeoJsonRef.current;

      try {
        const existing = map.getSource(srcId) as GeoJSONSource | undefined;
        if (existing) {
          if (geoJsonChanged) existing.setData(geojson);
        } else {
          map.addSource(srcId, { type: 'geojson', data: geojson } as GeoJSONSourceSpecification);
        }
        prevGeoJsonRef.current = geojson;
      } catch (e) {
        console.warn('Ship source setup failed:', e);
        return;
      }

      // Alarm source — isolated from main source for stable badge rendering
      try {
        const existingAlarm = map.getSource(alarmSrcId) as GeoJSONSource | undefined;
        const alarmChanged = alarmGeoJson !== prevAlarmGeoJsonRef.current;
        if (existingAlarm) {
          if (alarmChanged) existingAlarm.setData(alarmGeoJson);
        } else {
          map.addSource(alarmSrcId, { type: 'geojson', data: alarmGeoJson } as GeoJSONSourceSpecification);
        }
        prevAlarmGeoJsonRef.current = alarmGeoJson;
      } catch (e) {
        console.warn('Alarm source setup failed:', e);
      }

      const before = undefined;
      let needReorder = false;
      const priorityFilter = [
        'any',
        ['==', ['to-number', ['get', 'permitted'], 0], 1],
        ['==', ['to-number', ['get', 'alarmed'], 0], 1],
      ] as unknown as unknown[];
      const nonPriorityFilter = [
        'all',
        ['==', ['to-number', ['get', 'permitted'], 0], 0],
        ['==', ['to-number', ['get', 'alarmed'], 0], 0],
      ] as unknown as unknown[];

      // Ship halo + outline circles — disabled (아이콘 본체만 표시)

      // Alarm pulse circle (above outline, below ship icons)
      // Uses separate alarm source for stable rendering
      if (!map.getLayer(pulseId)) {
        needReorder = true;
        try {
          map.addLayer(
            {
              id: pulseId,
              type: 'circle',
              source: alarmSrcId,
              filter: ['==', ['get', 'alarmed'], 1] as never,
              layout: { visibility },
              paint: {
                'circle-radius': buildAlarmPulseRadiusExpr(ALARM_PULSE_OFFSET_MIN),
                'circle-color': 'transparent',
                'circle-opacity': 0,
                'circle-stroke-color': ['coalesce', ['get', 'alarmBadgeColor'], '#6b7280'] as never,
                'circle-stroke-opacity': 0.7,
                'circle-stroke-width': 1.5,
              },
            } as unknown as LayerSpecification,
            before,
          );
        } catch (e) {
          console.warn('Ship alarm pulse layer add failed:', e);
        }
      }

      if (!map.getLayer(symbolLiteId)) {
        needReorder = true;
        try {
          map.addLayer(
            {
              id: symbolLiteId,
              type: 'symbol',
              source: srcId,
              minzoom: 2,
              filter: nonPriorityFilter as never,
              layout: {
                visibility,
                'symbol-sort-key': 40 as never,
                'icon-image': [
                  'case',
                  ['==', ['to-number', ['get', 'isAnchored'], 0], 1],
                  anchoredImgId,
                  imgId,
                ] as never,
                'icon-size': [
                  'interpolate',
                  ['linear'],
                  ['zoom'],
                  2, 0.5,
                  5, 0.6,
                  8, ['max', ['*', ['to-number', ['get', 'iconSize7'], 0.45], 0.806], 0.6],
                  10, ['max', ['*', ['to-number', ['get', 'iconSize10'], 0.58], 0.936], 0.7],
                  14, ['*', ['to-number', ['get', 'iconSize14'], 0.85], 1.014],
                  18, ['*', ['to-number', ['get', 'iconSize18'], 2.5], 1.014],
                ] as unknown as number[],
                'icon-allow-overlap': true,
                'icon-ignore-placement': true,
                'icon-anchor': 'center',
                'icon-rotate': [
                  'case',
                  ['==', ['to-number', ['get', 'isAnchored'], 0], 1],
                  0,
                  ['to-number', ['get', 'heading'], 0],
                ] as never,
                'icon-rotation-alignment': 'map',
                'icon-pitch-alignment': 'map',
              },
              paint: {
                'icon-color': ['coalesce', ['get', 'shipColor'], '#64748b'] as never,
                'icon-opacity': [
                  'interpolate',
                  ['linear'],
                  ['zoom'],
                  6.5, 0.6,
                  8, 0.75,
                  11, 0.9,
                  14, 1,
                ] as never,
                'icon-halo-color': 'rgba(0,0,0,0.5)',
                'icon-halo-width': 0.8,
                'icon-halo-blur': 0.3,
              },
            } as unknown as LayerSpecification,
            before,
          );
        } catch (e) {
          console.warn('Ship lite symbol layer add failed:', e);
        }
      }

      if (!map.getLayer(symbolId)) {
        needReorder = true;
        try {
          map.addLayer(
            {
              id: symbolId,
              type: 'symbol',
              source: srcId,
              filter: priorityFilter as never,
              layout: {
                visibility,
                'symbol-sort-key': [
                  'case',
                  ['all', ['==', ['get', 'alarmed'], 1], ['==', ['get', 'permitted'], 1]], 132,
                  ['==', ['get', 'permitted'], 1], 130,
                  ['==', ['get', 'alarmed'], 1], 47,
                  45,
                ] as never,
                'icon-image': [
                  'case',
                  ['==', ['to-number', ['get', 'isAnchored'], 0], 1],
                  anchoredImgId,
                  imgId,
                ] as never,
                'icon-size': [
                  'interpolate', ['linear'], ['zoom'],
                  2, 0.8,
                  5, 0.9,
                  7, ['max', ['*', ['to-number', ['get', 'iconSize7'], 0.45], 1.3], 0.9],
                  10, ['max', ['*', ['to-number', ['get', 'iconSize10'], 0.58], 1.3], 0.9],
                  14, ['*', ['to-number', ['get', 'iconSize14'], 0.85], 1.3],
                  18, ['*', ['to-number', ['get', 'iconSize18'], 2.5], 1.3],
                ] as unknown as number[],
                'icon-allow-overlap': true,
                'icon-ignore-placement': true,
                'icon-anchor': 'center',
                'icon-rotate': [
                  'case',
                  ['==', ['to-number', ['get', 'isAnchored'], 0], 1], 0,
                  ['to-number', ['get', 'heading'], 0],
                ] as never,
                'icon-rotation-alignment': 'map',
                'icon-pitch-alignment': 'map',
              },
              paint: {
                'icon-color': ['coalesce', ['get', 'shipColor'], '#64748b'] as never,
                'icon-opacity': 1,
                'icon-halo-color': [
                  'case',
                  ['==', ['feature-state', 'selected'], 1], 'rgba(14,234,255,0.95)',
                  ['==', ['feature-state', 'highlighted'], 1], 'rgba(245,158,11,0.95)',
                  'rgba(0,0,0,0.7)',
                ] as never,
                'icon-halo-width': [
                  'case',
                  ['==', ['feature-state', 'selected'], 1], 2.5,
                  ['==', ['feature-state', 'highlighted'], 1], 2,
                  1,
                ] as never,
                'icon-halo-blur': 0.5,
              },
            } as unknown as LayerSpecification,
            before,
          );
        } catch (e) {
          console.warn('Ship symbol layer add failed:', e);
        }
      }

      // Photo indicator circle — disabled (파란 원 아이콘 제거)

      const labelFilter = [
        'all',
        ['!=', ['to-string', ['coalesce', ['get', 'labelName'], '']], ''],
        ['==', ['get', 'permitted'], 1],
      ] as unknown as unknown[];

      if (!map.getLayer(labelId)) {
        needReorder = true;
        try {
          map.addLayer(
            {
              id: labelId,
              type: 'symbol',
              source: srcId,
              minzoom: 4,
              filter: labelFilter as never,
              layout: {
                visibility: labelVisibility,
                'symbol-placement': 'point',
                'text-field': ['get', 'labelName'] as never,
                'text-font': ['Noto Sans Regular'],
                'text-size': ['interpolate', ['linear'], ['zoom'], 7, 10, 10, 11, 12, 12, 14, 13] as never,
                'text-anchor': 'top',
                'text-offset': [0, 1.1],
                'text-padding': 2,
                'text-allow-overlap': false,
                'text-ignore-placement': false,
              },
              paint: {
                'text-color': [
                  'case',
                  ['==', ['feature-state', 'selected'], 1], 'rgba(14,234,255,0.95)',
                  ['==', ['feature-state', 'highlighted'], 1], 'rgba(245,158,11,0.95)',
                  'rgba(226,232,240,0.92)',
                ] as never,
                'text-halo-color': 'rgba(0,0,0,0.9)',
                'text-halo-width': 0.8,
                'text-halo-blur': 0.2,
              },
            } as unknown as LayerSpecification,
            undefined,
          );
        } catch (e) {
          console.warn('Ship label layer add failed:', e);
        }
      }

      // Alarm badge symbol (above labels)
      // Uses separate alarm source for stable rendering
      if (!map.getLayer(badgeId)) {
        needReorder = true;
        try {
          map.addLayer(
            {
              id: badgeId,
              type: 'symbol',
              source: alarmSrcId,
              filter: ['==', ['get', 'alarmed'], 1] as never,
              layout: {
                visibility,
                'text-field': ['get', 'alarmBadgeLabel'] as never,
                'text-font': ['Noto Sans Regular'],
                'text-size': 11,
                'text-allow-overlap': true,
                'text-ignore-placement': true,
                'text-anchor': 'center',
              },
              paint: {
                'text-color': '#ffffff',
                'text-halo-color': ['coalesce', ['get', 'alarmBadgeColor'], '#6b7280'] as never,
                'text-halo-width': 6,
                'text-translate': [12, -12],
              },
            } as unknown as LayerSpecification,
            undefined,
          );
        } catch (e) {
          console.warn('Ship alarm badge layer add failed:', e);
        }
      }

      // 레이어가 준비되었음을 알림 (projection과 무관 — 토글 버튼 활성화용)
      onGlobeShipsReady?.(true);
      // needReorder: 새 레이어가 생성된 경우에만 reorder 호출
      // 매 AIS poll마다 28개 moveLayer → style._changed 방지
      if (projection === 'globe' && needReorder) {
        reorderGlobeFeatureLayers();
      }
      kickRepaint(map);
    };

    const stop = onMapStyleReady(map, ensure);
    return () => {
      stop();
    };
  }, [
    projection,
    settings.showShips,
    overlays.shipLabels,
    globeShipGeoJson,
    alarmGeoJson,
    mapSyncEpoch,
    reorderGlobeFeatureLayers,
    onGlobeShipsReady,
  ]);

  // Feature-state로 상호작용 상태(selected/highlighted) 즉시 반영 — setData 없이
  useEffect(() => {
    const map = mapRef.current;
    if (!map || projection !== 'globe' || projectionBusyRef.current) return;
    if (!map.isStyleLoaded() || !map.getSource('ships-globe-src')) return;

    const raf = requestAnimationFrame(() => {
      if (!map.isStyleLoaded()) return;
      const src = 'ships-globe-src';
      const alarmSrc = 'ships-globe-alarm-src';
      for (const t of shipData) {
        if (!isFiniteNumber(t.mmsi)) continue;
        const id = Math.trunc(t.mmsi);
        const s = t.mmsi === selectedMmsi ? 1 : 0;
        const h = isBaseHighlightedMmsi(t.mmsi) ? 1 : 0;
        try {
          map.setFeatureState({ source: src, id }, { selected: s, highlighted: h });
        } catch { /* ignore */ }
      }
      if (map.getSource(alarmSrc) && alarmMmsiMap) {
        for (const t of shipData) {
          if (!alarmMmsiMap.has(t.mmsi)) continue;
          const id = Math.trunc(t.mmsi);
          try {
            map.setFeatureState(
              { source: alarmSrc, id },
              { selected: t.mmsi === selectedMmsi ? 1 : 0, highlighted: isBaseHighlightedMmsi(t.mmsi) ? 1 : 0 },
            );
          } catch { /* ignore */ }
        }
      }
      kickRepaint(map);
    });
    return () => cancelAnimationFrame(raf);
  }, [projection, selectedMmsi, isBaseHighlightedMmsi, shipData, alarmMmsiMap]);

  // Alarm pulse breathing animation (rAF)
  useEffect(() => {
    const map = mapRef.current;
    if (!map || projection !== 'globe' || !alarmMmsiMap || alarmMmsiMap.size === 0) {
      if (breatheRafRef.current) cancelAnimationFrame(breatheRafRef.current);
      breatheRafRef.current = 0;
      return;
    }

    const animate = () => {
      if (!map.isStyleLoaded()) {
        breatheRafRef.current = requestAnimationFrame(animate);
        return;
      }
      const t = (Math.sin(Date.now() / ALARM_PULSE_PERIOD_MS * Math.PI * 2) + 1) / 2;
      const normalOff = ALARM_PULSE_OFFSET_MIN + t * (ALARM_PULSE_OFFSET_MAX - ALARM_PULSE_OFFSET_MIN);
      const hoverOff = ALARM_PULSE_HOVER_OFFSET_MIN + t * (ALARM_PULSE_HOVER_OFFSET_MAX - ALARM_PULSE_HOVER_OFFSET_MIN);
      try {
        if (map.getLayer('ships-globe-alarm-pulse')) {
          map.setPaintProperty(
            'ships-globe-alarm-pulse',
            'circle-radius',
            buildAlarmPulseRadiusCaseExpr(normalOff, hoverOff),
          );
          map.setPaintProperty('ships-globe-alarm-pulse', 'circle-stroke-opacity', 0.4 + t * 0.5);
        }
      } catch {
        // ignore
      }
      breatheRafRef.current = requestAnimationFrame(animate);
    };
    breatheRafRef.current = requestAnimationFrame(animate);
    return () => {
      if (breatheRafRef.current) cancelAnimationFrame(breatheRafRef.current);
      breatheRafRef.current = 0;
    };
  }, [projection, alarmMmsiMap]);
}
