import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
import type { Layer as DeckLayer } from '@deck.gl/core';
import { GeoJsonLayer, ScatterplotLayer } from '@deck.gl/layers';
import booleanPointInPolygon from '@turf/boolean-point-in-polygon';
import { point, polygon, lineString } from '@turf/helpers';
import type { Ship, VesselAnalysisDto } from '../../types';
import {
  fetchFleetCompanies,
  fetchGroupHistory,
  fetchGroupCorrelations,
  fetchCorrelationTracks,
  fetchGroupParentInference,
  fetchParentCandidateExclusions,
  fetchParentLabelSessions,
  fetchParentInferenceReview,
  createGroupParentLabelSession,
  createGroupCandidateExclusion,
  createGlobalCandidateExclusion,
  cancelParentLabelSession,
  releaseCandidateExclusion,
} from '../../services/vesselAnalysis';
import type {
  FleetCompany,
  GearCorrelationItem,
  CorrelationVesselTrack,
  GroupParentInferenceItem,
  ParentCandidateExclusion,
  ParentLabelSession,
} from '../../services/vesselAnalysis';
import type { UseGroupPolygonsResult } from '../../hooks/useGroupPolygons';
import { useGearReplayStore } from '../../stores/gearReplayStore';
import type { ReplayReviewCandidate } from '../../stores/gearReplayStore';
import { useFleetClusterDeckLayers } from '../../hooks/useFleetClusterDeckLayers';
import { useShipDeckStore } from '../../stores/shipDeckStore';
import type { PickedPolygonFeature } from '../../hooks/useFleetClusterDeckLayers';
import { useFontScale } from '../../hooks/useFontScale';
import { useLocalStorage } from '../../hooks/useLocalStorage';
import { useAuth } from '../../hooks/useAuth';
import type { GroupPolygonDto } from '../../services/vesselAnalysis';

/** 서브클러스터 center 시계열 (독립 추적용) */
export interface SubClusterCenter {
  subClusterId: number;
  path: [number, number][]; // [lon, lat] 시간순
  timestamps: number[];     // epoch ms
}

/**
 * 히스토리를 서브클러스터별로 분리하여:
 * 1. frames: 시간별 멤버 합산 프레임 (리플레이 애니메이션용)
 * 2. subClusterCenters: 서브클러스터별 독립 center 궤적 (PathLayer용)
 */
function splitAndMergeHistory(history: GroupPolygonDto[]) {
  // 시간순 정렬 (오래된 것 먼저)
  const sorted = [...history].sort((a, b) =>
    new Date(a.snapshotTime).getTime() - new Date(b.snapshotTime).getTime(),
  );

  // 1. 서브클러스터별 center 궤적 수집
  const centerMap = new Map<number, { path: [number, number][]; timestamps: number[] }>();
  for (const h of sorted) {
    const sid = h.subClusterId ?? 0;
    const entry = centerMap.get(sid) ?? { path: [], timestamps: [] };
    entry.path.push([h.centerLon, h.centerLat]);
    entry.timestamps.push(new Date(h.snapshotTime).getTime());
    centerMap.set(sid, entry);
  }
  const subClusterCenters: SubClusterCenter[] = [...centerMap.entries()].map(
    ([subClusterId, data]) => ({ subClusterId, ...data }),
  );

  // 2. 시간별 그룹핑 후 서브클러스터 보존
  const byTime = new Map<string, GroupPolygonDto[]>();
  for (const h of sorted) {
    const list = byTime.get(h.snapshotTime) ?? [];
    list.push(h);
    byTime.set(h.snapshotTime, list);
  }

  const frames: GroupPolygonDto[] = [];
  for (const [, items] of byTime) {
    const allSameId = items.every(item => (item.subClusterId ?? 0) === 0);

    if (items.length === 1 || allSameId) {
      // 단일 아이템 또는 모두 subClusterId=0: 통합 서브프레임 1개
      const base = items.length === 1 ? items[0] : (() => {
        const seen = new Set<string>();
        const allMembers: GroupPolygonDto['members'] = [];
        for (const item of items) {
          for (const m of item.members) {
            if (!seen.has(m.mmsi)) { seen.add(m.mmsi); allMembers.push(m); }
          }
        }
        const biggest = items.reduce((a, b) => (a.memberCount >= b.memberCount ? a : b));
        return { ...biggest, subClusterId: 0, members: allMembers, memberCount: allMembers.length };
      })();
      const subFrames: SubFrame[] = [{
        subClusterId: 0,
        centerLon: base.centerLon,
        centerLat: base.centerLat,
        members: base.members,
        memberCount: base.memberCount,
      }];
      frames.push({ ...base, subFrames } as GroupPolygonDto & { subFrames: SubFrame[] });
    } else {
      // 서로 다른 subClusterId: 각 아이템을 개별 서브프레임으로 보존
      const subFrames: SubFrame[] = items.map(item => ({
        subClusterId: item.subClusterId ?? 0,
        centerLon: item.centerLon,
        centerLat: item.centerLat,
        members: item.members,
        memberCount: item.memberCount,
      }));
      const seen = new Set<string>();
      const allMembers: GroupPolygonDto['members'] = [];
      for (const sf of subFrames) {
        for (const m of sf.members) {
          if (!seen.has(m.mmsi)) { seen.add(m.mmsi); allMembers.push(m); }
        }
      }
      const biggest = items.reduce((a, b) => (a.memberCount >= b.memberCount ? a : b));
      frames.push({
        ...biggest,
        subClusterId: 0,
        members: allMembers,
        memberCount: allMembers.length,
        centerLat: biggest.centerLat,
        centerLon: biggest.centerLon,
        subFrames,
      } as GroupPolygonDto & { subFrames: SubFrame[] });
    }
  }

  return { frames: fillGapFrames(frames as HistoryFrame[]), subClusterCenters };
}

function compareReviewQueueItems(
  a: GroupParentInferenceItem,
  b: GroupParentInferenceItem,
  mode: ReviewQueueSortMode,
  zoneDistanceByKey?: Map<string, number>,
) {
  switch (mode) {
    case 'topScore':
      return (b.parentInference?.topScore ?? -1) - (a.parentInference?.topScore ?? -1)
        || (b.memberCount ?? -1) - (a.memberCount ?? -1)
        || a.groupKey.localeCompare(b.groupKey)
        || a.subClusterId - b.subClusterId;
    case 'memberCount':
      return (b.memberCount ?? -1) - (a.memberCount ?? -1)
        || (b.parentInference?.topScore ?? -1) - (a.parentInference?.topScore ?? -1)
        || a.groupKey.localeCompare(b.groupKey)
        || a.subClusterId - b.subClusterId;
    case 'candidateCount':
      return (b.candidateCount ?? -1) - (a.candidateCount ?? -1)
        || (b.parentInference?.topScore ?? -1) - (a.parentInference?.topScore ?? -1)
        || a.groupKey.localeCompare(b.groupKey)
        || a.subClusterId - b.subClusterId;
    case 'name':
      return a.groupKey.localeCompare(b.groupKey)
        || a.subClusterId - b.subClusterId;
    case 'zoneDistance':
      return (zoneDistanceByKey?.get(reviewItemKey(a.groupKey, a.subClusterId)) ?? Number.POSITIVE_INFINITY)
        - (zoneDistanceByKey?.get(reviewItemKey(b.groupKey, b.subClusterId)) ?? Number.POSITIVE_INFINITY)
        || (b.parentInference?.topScore ?? -1) - (a.parentInference?.topScore ?? -1)
        || (b.memberCount ?? -1) - (a.memberCount ?? -1)
        || a.groupKey.localeCompare(b.groupKey)
        || a.subClusterId - b.subClusterId;
    case 'backend':
    default:
      return 0;
  }
}

interface MapPointerPayload {
  coordinate: [number, number];
  screen: [number, number];
}

interface ReviewSpatialVertex {
  coordinate: [number, number];
  screen: [number, number];
}

function reviewItemKey(groupKey: string, subClusterId: number) {
  return `${groupKey}:${subClusterId}`;
}

function haversineNm(a: [number, number], b: [number, number]) {
  const toRad = (deg: number) => deg * Math.PI / 180;
  const [lng1, lat1] = a;
  const [lng2, lat2] = b;
  const dLat = toRad(lat2 - lat1);
  const dLng = toRad(lng2 - lng1);
  const rLat1 = toRad(lat1);
  const rLat2 = toRad(lat2);
  const hav = Math.sin(dLat / 2) ** 2
    + Math.cos(rLat1) * Math.cos(rLat2) * Math.sin(dLng / 2) ** 2;
  const km = 6371 * (2 * Math.atan2(Math.sqrt(hav), Math.sqrt(1 - hav)));
  return km / 1.852;
}

function zoneDistanceNm(group: GroupPolygonDto | undefined) {
  if (!group) return Number.POSITIVE_INFINITY;
  const zone = classifyFishingZone(group.centerLat, group.centerLon);
  if (zone.zone !== 'OUTSIDE') return 0;
  const center: [number, number] = [group.centerLon, group.centerLat];
  let nearest = Number.POSITIVE_INFINITY;
  for (const boundaryPoint of ZONE_BOUNDARY_POINTS) {
    nearest = Math.min(nearest, haversineNm(center, boundaryPoint));
  }
  return nearest;
}

// ── 분리된 모듈 ──
import type { PickerCandidate, HoverTooltipState, GearPickerPopupState, SubFrame, HistoryFrame } from './fleetClusterTypes';
import { EMPTY_ANALYSIS } from './fleetClusterTypes';
import { fillGapFrames } from './fleetClusterUtils';
import { useFleetClusterGeoJson } from './useFleetClusterGeoJson';
import FleetClusterMapLayers from './FleetClusterMapLayers';
import CorrelationPanel from './CorrelationPanel';
import HistoryReplayController from './HistoryReplayController';
import FleetGearListPanel from './FleetGearListPanel';
import ParentReviewPanel, { type ReviewQueueSortMode } from './ParentReviewPanel';
import {
  MIN_PARENT_REVIEW_MEMBER_COUNT,
  MIN_PARENT_REVIEW_SCORE,
  MIN_PARENT_REVIEW_SCORE_PCT,
} from './parentInferenceConstants';
import { ZONE_POLYGONS, classifyFishingZone } from '../../utils/fishingAnalysis';

const REVIEW_SORT_MODES: ReviewQueueSortMode[] = ['backend', 'topScore', 'memberCount', 'candidateCount', 'name', 'zoneDistance'];

function sanitizeWorkflowDurationDays(value: unknown): 1 | 3 | 5 {
  return value === 3 || value === 5 ? value : 1;
}

function sanitizeReviewMinTopScorePct(value: unknown) {
  const numeric = Number(value);
  if (!Number.isFinite(numeric)) return MIN_PARENT_REVIEW_SCORE_PCT;
  return Math.max(MIN_PARENT_REVIEW_SCORE_PCT, Math.min(100, Math.round(numeric)));
}

function sanitizeReviewMinMemberCount(value: unknown) {
  const numeric = Number(value);
  if (!Number.isFinite(numeric)) return MIN_PARENT_REVIEW_MEMBER_COUNT;
  return Math.max(MIN_PARENT_REVIEW_MEMBER_COUNT, Math.floor(numeric));
}

function sanitizeReviewSortMode(value: unknown): ReviewQueueSortMode {
  return typeof value === 'string' && REVIEW_SORT_MODES.includes(value as ReviewQueueSortMode)
    ? value as ReviewQueueSortMode
    : 'backend';
}

function sanitizeReviewSearchText(value: unknown) {
  if (typeof value !== 'string') return '';
  return value.slice(0, 80);
}

function normalizeReviewSearchText(value: string) {
  return value.toLowerCase().replace(/\s+/g, '');
}

const ZONE_BOUNDARY_POINTS: [number, number][] = ZONE_POLYGONS.flatMap(zone =>
  zone.geojson.geometry.coordinates.flatMap(poly =>
    poly[0].map(coord => [coord[0], coord[1]] as [number, number]),
  ),
);

// ── re-export (KoreaMap 호환) ──
export type { SelectedGearGroupData, SelectedFleetData } from './fleetClusterTypes';

interface Props {
  ships: Ship[];
  analysisMap?: Map<string, VesselAnalysisDto>;
  clusters?: Map<number, string[]>;
  onShipSelect?: (mmsi: string) => void;
  onFleetZoom?: (bounds: { minLat: number; maxLat: number; minLng: number; maxLng: number }) => void;
  onSelectedGearChange?: (data: import('./fleetClusterTypes').SelectedGearGroupData | null) => void;
  onSelectedFleetChange?: (data: import('./fleetClusterTypes').SelectedFleetData | null) => void;
  groupPolygons?: UseGroupPolygonsResult;
  zoomScale?: number;
  onDeckLayersChange?: (layers: DeckLayer[]) => void;
  registerMapClickHandler?: (handler: ((payload: MapPointerPayload) => void) | null) => void;
  registerMapMoveHandler?: (handler: ((payload: MapPointerPayload) => void) | null) => void;
  autoOpenReviewPanel?: boolean;
}

export function FleetClusterLayer({
  ships,
  analysisMap: analysisMapProp,
  onShipSelect,
  onFleetZoom,
  onSelectedGearChange,
  onSelectedFleetChange,
  groupPolygons,
  zoomScale = 1,
  onDeckLayersChange,
  registerMapClickHandler,
  registerMapMoveHandler,
  autoOpenReviewPanel = false,
}: Props) {
  const analysisMap = analysisMapProp ?? EMPTY_ANALYSIS;
  const { fontScale } = useFontScale();
  const { user } = useAuth();
  const legacyReviewActor = typeof window === 'undefined'
    ? ''
    : (window.localStorage.getItem('kcg-parent-review-actor') || '').trim();

  // ── 선단/어구 패널 상태 ──
  const [companies, setCompanies] = useState<Map<number, FleetCompany>>(new Map());
  const [expandedFleet, setExpandedFleet] = useState<number | null>(null);
  const [activeSection, setActiveSection] = useState<string | null>('fleet');
  const toggleSection = (key: string) => setActiveSection(prev => prev === key ? null : key);
  const [hoveredFleetId, setHoveredFleetId] = useState<number | null>(null);
  const [hoveredGearCompositeKey, setHoveredGearCompositeKey] = useState<string | null>(null);
  const [expandedGearGroup, setExpandedGearGroup] = useState<string | null>(null);
  const [selectedGearGroup, setSelectedGearGroup] = useState<string | null>(null);

  // ── 맵 팝업/툴팁 상태 ──
  const [hoverTooltip, setHoverTooltip] = useState<HoverTooltipState | null>(null);
  const [gearPickerPopup, setGearPickerPopup] = useState<GearPickerPopupState | null>(null);
  const [pickerHoveredGroup, setPickerHoveredGroup] = useState<string | null>(null);

  // ── 연관성 데이터 ──
  const [correlationData, setCorrelationData] = useState<GearCorrelationItem[]>([]);
  const [correlationLoading, setCorrelationLoading] = useState(false);
  const [correlationTracks, setCorrelationTracks] = useState<CorrelationVesselTrack[]>([]);
  const [enabledVessels, setEnabledVessels] = useState<Set<string>>(new Set());
  const [enabledModels, setEnabledModels] = useState<Set<string>>(new Set(['identity', 'default', 'aggressive', 'conservative', 'proximity-heavy', 'visit-pattern']));
  const [hoveredTarget, setHoveredTarget] = useState<{ mmsi: string; model: string } | null>(null);
  const [parentInferenceItems, setParentInferenceItems] = useState<GroupParentInferenceItem[]>([]);
  const [parentInferenceQueue, setParentInferenceQueue] = useState<GroupParentInferenceItem[]>([]);
  const [parentActiveGroupExclusions, setParentActiveGroupExclusions] = useState<ParentCandidateExclusion[]>([]);
  const [parentActiveGlobalExclusions, setParentActiveGlobalExclusions] = useState<ParentCandidateExclusion[]>([]);
  const [parentActiveLabelSessions, setParentActiveLabelSessions] = useState<ParentLabelSession[]>([]);
  const [parentInferenceLoading, setParentInferenceLoading] = useState(false);
  const [parentInferenceSubmittingKey, setParentInferenceSubmittingKey] = useState<string | null>(null);
  const [parentReviewActor, setParentReviewActor] = useLocalStorage<string>('parentReviewActor', legacyReviewActor);
  const [storedParentWorkflowDurationDays, setStoredParentWorkflowDurationDays] = useLocalStorage<1 | 3 | 5>('parentReviewDurationDays', 1);
  const [storedReviewMinTopScorePct, setStoredReviewMinTopScorePct] = useState<number>(MIN_PARENT_REVIEW_SCORE_PCT);
  const [storedReviewMinMemberCount, setStoredReviewMinMemberCount] = useState<number>(MIN_PARENT_REVIEW_MEMBER_COUNT);
  const [storedReviewSortMode, setStoredReviewSortMode] = useState<ReviewQueueSortMode>('backend');
  const [storedReviewSearchText, setStoredReviewSearchText] = useState<string>('');
  const [reviewFiltersTouched, setReviewFiltersTouched] = useState(false);
  const [reviewHighlightedQueueKey, setReviewHighlightedQueueKey] = useState<string | null>(null);
  const [selectedReviewQueueKey, setSelectedReviewQueueKey] = useState<string | null>(null);
  const [reviewSpatialFilterVertices, setReviewSpatialFilterVertices] = useState<ReviewSpatialVertex[]>([]);
  const [reviewSpatialFilterDrawing, setReviewSpatialFilterDrawing] = useState(false);
  const [reviewSpatialCursor, setReviewSpatialCursor] = useState<MapPointerPayload | null>(null);
  const lastReviewSpatialClickRef = useRef<{ coordinate: [number, number]; at: number } | null>(null);
  const autoOpenedReviewRef = useRef(false);

  // ── Zustand store (히스토리 재생) ──
  const historyActive = useGearReplayStore(s => s.historyFrames.length > 0);

  // ── 맵 + ref ──

  // ── 초기 로드 ──
  useEffect(() => {
    fetchFleetCompanies().then(setCompanies).catch(() => {});
  }, []);

  useEffect(() => {
    const sanitized = sanitizeWorkflowDurationDays(storedParentWorkflowDurationDays);
    if (sanitized !== storedParentWorkflowDurationDays) {
      setStoredParentWorkflowDurationDays(sanitized);
    }
  }, [setStoredParentWorkflowDurationDays, storedParentWorkflowDurationDays]);

  useEffect(() => {
    if (!user?.name) return;
    setParentReviewActor(user.name);
  }, [setParentReviewActor, user?.name]);

  const parentWorkflowDurationDays = sanitizeWorkflowDurationDays(storedParentWorkflowDurationDays);
  const reviewMinTopScorePct = sanitizeReviewMinTopScorePct(storedReviewMinTopScorePct);
  const reviewMinMemberCount = sanitizeReviewMinMemberCount(storedReviewMinMemberCount);
  const reviewSortMode = sanitizeReviewSortMode(storedReviewSortMode);
  const reviewSearchText = sanitizeReviewSearchText(storedReviewSearchText);
  const reviewDrivenOverlayMode = !!selectedGearGroup;

  const getSnappedReviewPointer = useCallback((payload: MapPointerPayload): MapPointerPayload => {
    if (reviewSpatialFilterVertices.length < 3) return payload;
    const first = reviewSpatialFilterVertices[0];
    const dx = first.screen[0] - payload.screen[0];
    const dy = first.screen[1] - payload.screen[1];
    const pixelDistance = Math.hypot(dx, dy);
    if (pixelDistance <= 18) {
      return {
        coordinate: first.coordinate,
        screen: first.screen,
      };
    }
    return payload;
  }, [reviewSpatialFilterVertices]);

  const handleReviewSpatialFilterMove = useCallback((payload: MapPointerPayload) => {
    if (!reviewSpatialFilterDrawing) return;
    setReviewSpatialCursor(getSnappedReviewPointer(payload));
  }, [getSnappedReviewPointer, reviewSpatialFilterDrawing]);

  const handleReviewSpatialFilterClick = useCallback((payload: MapPointerPayload) => {
    if (!reviewSpatialFilterDrawing) return;
    const snapped = getSnappedReviewPointer(payload);
    const now = Date.now();
    const last = lastReviewSpatialClickRef.current;
    if (
      last
      && now - last.at < 400
      && Math.abs(last.coordinate[0] - snapped.coordinate[0]) < 1e-6
      && Math.abs(last.coordinate[1] - snapped.coordinate[1]) < 1e-6
    ) {
      return;
    }
    lastReviewSpatialClickRef.current = { coordinate: snapped.coordinate, at: now };
    setReviewSpatialCursor(snapped);

    if (reviewSpatialFilterVertices.length >= 3) {
      const first = reviewSpatialFilterVertices[0];
      const isClosingClick = Math.abs(first.coordinate[0] - snapped.coordinate[0]) < 1e-10
        && Math.abs(first.coordinate[1] - snapped.coordinate[1]) < 1e-10;
      if (isClosingClick) {
        setReviewSpatialFilterDrawing(false);
        return;
      }
    }

    setReviewSpatialFilterVertices(prev => [...prev, {
      coordinate: snapped.coordinate,
      screen: snapped.screen,
    }]);
  }, [getSnappedReviewPointer, reviewSpatialFilterDrawing, reviewSpatialFilterVertices]);

  const startReviewSpatialFilter = useCallback(() => {
    setGearPickerPopup(null);
    setPickerHoveredGroup(null);
    lastReviewSpatialClickRef.current = null;
    setReviewSpatialFilterVertices([]);
    setReviewSpatialCursor(null);
    setReviewSpatialFilterDrawing(true);
  }, []);

  const finishReviewSpatialFilter = useCallback(() => {
    setReviewSpatialFilterDrawing(prev => (reviewSpatialFilterVertices.length >= 3 ? false : prev));
  }, [reviewSpatialFilterVertices.length]);

  const clearReviewSpatialFilter = useCallback(() => {
    lastReviewSpatialClickRef.current = null;
    setReviewSpatialFilterDrawing(false);
    setReviewSpatialFilterVertices([]);
    setReviewSpatialCursor(null);
  }, []);

  useEffect(() => {
    registerMapClickHandler?.(reviewSpatialFilterDrawing ? handleReviewSpatialFilterClick : null);
    registerMapMoveHandler?.(reviewSpatialFilterDrawing ? handleReviewSpatialFilterMove : null);
    return () => {
      registerMapClickHandler?.(null);
      registerMapMoveHandler?.(null);
    };
  }, [
    handleReviewSpatialFilterClick,
    handleReviewSpatialFilterMove,
    registerMapClickHandler,
    registerMapMoveHandler,
    reviewSpatialFilterDrawing,
  ]);

  const reloadParentInference = useCallback(async (
    groupKey: string,
    options?: { reloadQueue?: boolean },
  ) => {
    setParentInferenceLoading(true);
    try {
      const requests: Promise<unknown>[] = [];
      if (options?.reloadQueue !== false) {
        requests.push(
          fetchParentInferenceReview('REVIEW_REQUIRED', 100).catch(
            () => ({ count: 0, items: [] as GroupParentInferenceItem[] }),
          ),
        );
      }
      requests.push(
        fetchGroupParentInference(groupKey).catch(() => ({ groupKey, count: 0, items: [] as GroupParentInferenceItem[] })),
        fetchParentCandidateExclusions({ groupKey, activeOnly: true, limit: 200 }).catch(
          () => ({ count: 0, items: [] as ParentCandidateExclusion[] }),
        ),
        fetchParentCandidateExclusions({ scopeType: 'GLOBAL', activeOnly: true, limit: 500 }).catch(
          () => ({ count: 0, items: [] as ParentCandidateExclusion[] }),
        ),
        fetchParentLabelSessions({ groupKey, activeOnly: true, limit: 100 }).catch(
          () => ({ count: 0, items: [] as ParentLabelSession[] }),
        ),
      );

      const results = await Promise.all(requests);
      let resultIndex = 0;
      if (options?.reloadQueue !== false) {
        const queueRes = results[resultIndex] as { count: number; items: GroupParentInferenceItem[] };
        setParentInferenceQueue(queueRes.items);
        resultIndex += 1;
      }
      const detailRes = results[resultIndex++] as { groupKey: string; count: number; items: GroupParentInferenceItem[] };
      const groupExclusionRes = results[resultIndex++] as { count: number; items: ParentCandidateExclusion[] };
      const globalExclusionRes = results[resultIndex++] as { count: number; items: ParentCandidateExclusion[] };
      const labelSessionRes = results[resultIndex] as { count: number; items: ParentLabelSession[] };

      setParentInferenceItems(detailRes.items);
      setParentActiveGroupExclusions(groupExclusionRes.items);
      setParentActiveGlobalExclusions(globalExclusionRes.items);
      setParentActiveLabelSessions(labelSessionRes.items);
    } finally {
      setParentInferenceLoading(false);
    }
  }, []);

  const refreshParentInferenceQueue = useCallback(async () => {
    setParentInferenceLoading(true);
    try {
      const queueRes = await fetchParentInferenceReview('REVIEW_REQUIRED', 100).catch(
        () => ({ count: 0, items: [] as GroupParentInferenceItem[] }),
      );
      setParentInferenceQueue(queueRes.items);
      if (selectedGearGroup) {
        const [groupExclusionRes, globalExclusionRes, labelSessionRes] = await Promise.all([
          fetchParentCandidateExclusions({ groupKey: selectedGearGroup, activeOnly: true, limit: 200 }).catch(
            () => ({ count: 0, items: [] as ParentCandidateExclusion[] }),
          ),
          fetchParentCandidateExclusions({ scopeType: 'GLOBAL', activeOnly: true, limit: 500 }).catch(
            () => ({ count: 0, items: [] as ParentCandidateExclusion[] }),
          ),
          fetchParentLabelSessions({ groupKey: selectedGearGroup, activeOnly: true, limit: 100 }).catch(
            () => ({ count: 0, items: [] as ParentLabelSession[] }),
          ),
        ]);
        setParentActiveGroupExclusions(groupExclusionRes.items);
        setParentActiveGlobalExclusions(globalExclusionRes.items);
        setParentActiveLabelSessions(labelSessionRes.items);
      }
    } finally {
      setParentInferenceLoading(false);
    }
  }, [selectedGearGroup]);

  // ── 히스토리 로드 (3개 API 순차 await → 스토어 초기화 → 재생) ──
  const loadHistory = async (groupKey: string) => {
    // 1. 모든 데이터를 병렬 fetch
    const [history, corrRes, trackRes] = await Promise.all([
      fetchGroupHistory(groupKey, 12),
      fetchGroupCorrelations(groupKey, 0.3).catch(() => ({ items: [] as GearCorrelationItem[] })),
      fetchCorrelationTracks(groupKey, 24, 0).catch(() => ({ vessels: [] as CorrelationVesselTrack[] })),
    ]);

    // 2. resolution별 분리 → 1h(primary) + 6h(secondary)
    const history1h = history.filter(h => h.resolution === '1h' || h.resolution === '1h-fb');
    const history6h = history.filter(h => h.resolution === '6h');
    // fallback: resolution 필드 없는 기존 데이터는 6h로 취급
    const effective1h = history1h.length > 0 ? history1h : history;
    const effective6h = history6h;

    const { frames: filled, subClusterCenters } = splitAndMergeHistory(effective1h);
    const { frames: filled6h, subClusterCenters: subClusterCenters6h } = splitAndMergeHistory(effective6h);
    const corrData = corrRes.items;
    const corrTracks = trackRes.vessels;

    // 디버그: fetch 결과 확인
    const withTrack = corrTracks.filter(v => v.track && v.track.length > 0).length;
    console.log('[loadHistory] fetch 완료:', {
      history: history.length,
      '1h': history1h.length,
      '6h': history6h.length,
      'filled1h': filled.length,
      'filled6h': filled6h.length,
      corrData: corrData.length,
      corrTracks: corrTracks.length,
      withTrack,
    });

    const vessels = new Set(corrTracks.filter(v => v.score >= 0.7).map(v => v.mmsi));

    // 3. React 상태 동기화 (패널 표시용)
    setCorrelationData(corrData);
    setCorrelationTracks(corrTracks);
    setEnabledVessels(vessels);
    setCorrelationLoading(false);

    // 4. 스토어 초기화 (1h + 6h 모든 데이터 포함) → 재생 시작
    const store = useGearReplayStore.getState();
    store.loadHistory(filled, corrTracks, corrData, new Set(['identity']), vessels, filled6h);
    // 서브클러스터별 독립 center 궤적 + 전체 구간 고유 멤버 저장
    const seen = new Set<string>();
    const allHistoryMembers: { mmsi: string; name: string; isParent: boolean }[] = [];
    for (const f of history) {
      for (const m of f.members) {
        if (!seen.has(m.mmsi)) {
          seen.add(m.mmsi);
          allHistoryMembers.push({ mmsi: m.mmsi, name: m.name, isParent: m.isParent });
        }
      }
    }
    useGearReplayStore.setState({ subClusterCenters, subClusterCenters6h, allHistoryMembers });
    store.play();
  };

  const closeHistory = useCallback(() => {
    useGearReplayStore.getState().reset();
    setHoveredTarget(null);
  }, []);

  // ── cnFishing 탭 off (unmount) 시 재생 상태 + deck layers 전체 초기화 ──
  useEffect(() => {
    return () => {
      useGearReplayStore.getState().reset();
      onDeckLayersChange?.([]);
    };
  // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  // ── enabledModels/enabledVessels/hoveredTarget → store 동기화 ──
  useEffect(() => {
    useGearReplayStore
      .getState()
      .setEnabledModels(reviewDrivenOverlayMode ? new Set(['identity']) : enabledModels);
  }, [enabledModels, reviewDrivenOverlayMode]);

  useEffect(() => {
    useGearReplayStore.getState().setEnabledVessels(enabledVessels);
  }, [enabledVessels]);

  useEffect(() => {
    useGearReplayStore.getState().setHoveredMmsi(hoveredTarget?.mmsi ?? null);
  }, [hoveredTarget]);

  useEffect(() => {
    const replayStore = useGearReplayStore.getState();
    if (!selectedGearGroup || parentInferenceItems.length === 0) {
      replayStore.setReviewCandidates([]);
      return;
    }

      const dedup = new Map<string, ReplayReviewCandidate>();
    for (const item of parentInferenceItems) {
      for (const candidate of (item.candidates ?? []).filter(entry => (entry.finalScore ?? 0) >= MIN_PARENT_REVIEW_SCORE)) {
        const next: ReplayReviewCandidate = {
          mmsi: candidate.candidateMmsi,
          name: candidate.candidateName || candidate.candidateMmsi,
          rank: candidate.rank,
          score: candidate.finalScore ?? null,
          trackAvailable: !!candidate.trackAvailable,
          subClusterId: item.subClusterId,
        };
        const prev = dedup.get(candidate.candidateMmsi);
        if (!prev || next.rank < prev.rank || ((next.score ?? 0) > (prev.score ?? 0))) {
          dedup.set(candidate.candidateMmsi, next);
        }
      }
    }

    replayStore.setReviewCandidates(
      [...dedup.values()].sort((a, b) => {
        if (a.rank !== b.rank) return a.rank - b.rank;
        return (b.score ?? 0) - (a.score ?? 0);
      }),
    );
  }, [parentInferenceItems, selectedGearGroup]);

  useEffect(() => {
    if ((selectedGearGroup && parentInferenceItems.length > 0) || hoveredTarget?.model !== 'parent-review') return;
    setHoveredTarget(null);
  }, [hoveredTarget, parentInferenceItems.length, selectedGearGroup]);

  // ── ESC 키 ──
  useEffect(() => {
    const onKeyDown = (e: KeyboardEvent) => {
      if (e.key === 'Escape') {
        if (historyActive) closeHistory();
        setSelectedGearGroup(null);
        setExpandedFleet(null);
        setExpandedGearGroup(null);
      }
    };
    window.addEventListener('keydown', onKeyDown);
    return () => window.removeEventListener('keydown', onKeyDown);
  }, [historyActive, closeHistory]);

  // 맵 이벤트 → deck.gl 콜백으로 전환 완료 (handleDeckPolygonClick/Hover)

  // ── ships map ──
  const shipMap = useMemo(() => {
    const m = new Map<string, Ship>();
    for (const s of ships) m.set(s.mmsi, s);
    return m;
  }, [ships]);

  const gearGroupLookup = useMemo(() => {
    const byCompositeKey = new Map<string, GroupPolygonDto>();
    const byGroupKey = new Map<string, GroupPolygonDto>();
    const allGroups = groupPolygons?.allGroups.filter(group => group.groupType !== 'FLEET') ?? [];
    for (const group of allGroups) {
      byCompositeKey.set(`${group.groupKey}:${group.subClusterId ?? 0}`, group);
      if (!byGroupKey.has(group.groupKey)) byGroupKey.set(group.groupKey, group);
    }
    return { byCompositeKey, byGroupKey };
  }, [groupPolygons?.allGroups]);

  const reviewSpatialFilterPoints = useMemo(
    () => reviewSpatialFilterVertices.map(vertex => vertex.coordinate),
    [reviewSpatialFilterVertices],
  );

  const reviewSpatialPreviewCoordinate = reviewSpatialCursor?.coordinate ?? null;

  const reviewSpatialFilterPolygon = useMemo(() => {
    if (reviewSpatialFilterPoints.length < 3 || reviewSpatialFilterDrawing) return null;
    return polygon([[...reviewSpatialFilterPoints, reviewSpatialFilterPoints[0]]]);
  }, [reviewSpatialFilterDrawing, reviewSpatialFilterPoints]);

  const reviewZoneDistanceByKey = useMemo(() => {
    const map = new Map<string, number>();
    for (const item of parentInferenceQueue) {
      const key = reviewItemKey(item.groupKey, item.subClusterId);
      const group = gearGroupLookup.byCompositeKey.get(key) ?? gearGroupLookup.byGroupKey.get(item.groupKey);
      map.set(key, zoneDistanceNm(group));
    }
    return map;
  }, [gearGroupLookup.byCompositeKey, gearGroupLookup.byGroupKey, parentInferenceQueue]);

  const reviewSpatialFilterLayers = useMemo<DeckLayer[]>(() => {
    if (reviewSpatialFilterPoints.length === 0) return [];

    const layers: DeckLayer[] = [];
    const previewCoordinates = reviewSpatialPreviewCoordinate
      ? [...reviewSpatialFilterPoints, reviewSpatialPreviewCoordinate]
      : reviewSpatialFilterPoints;
    const previewPolygon = reviewSpatialFilterDrawing && previewCoordinates.length >= 3
      ? polygon([[...previewCoordinates, previewCoordinates[0]]])
      : null;
    const geometry = reviewSpatialFilterPolygon
      ?? previewPolygon
      ?? (previewCoordinates.length >= 2 ? lineString(previewCoordinates) : point(previewCoordinates[0]));

    layers.push(new GeoJsonLayer({
      id: 'parent-review-spatial-filter-shape',
      data: geometry,
      pickable: false,
      stroked: true,
      filled: !!(reviewSpatialFilterPolygon || previewPolygon),
      getLineColor: reviewSpatialFilterDrawing ? [56, 189, 248, 255] : [34, 197, 94, 255],
      getFillColor: reviewSpatialFilterDrawing ? [56, 189, 248, 28] : [34, 197, 94, 26],
      lineWidthMinPixels: reviewSpatialFilterPolygon ? 3 : 2,
    }));

    layers.push(new ScatterplotLayer({
      id: 'parent-review-spatial-filter-points',
      data: [
        ...reviewSpatialFilterPoints.map((position, index) => ({
          position,
          isLast: index === reviewSpatialFilterPoints.length - 1,
          isPreview: false,
          isStart: index === 0,
        })),
        ...(reviewSpatialFilterDrawing && reviewSpatialPreviewCoordinate
          ? [{
              position: reviewSpatialPreviewCoordinate,
              isLast: false,
              isPreview: true,
              isStart: false,
            }]
          : []),
      ],
      pickable: false,
      getPosition: (datum: { position: [number, number] }) => datum.position,
      getFillColor: (datum: { position: [number, number]; isLast: boolean; isPreview: boolean; isStart: boolean }) => {
        if (datum.isPreview) return [56, 189, 248, 220];
        if (datum.isStart && reviewSpatialPreviewCoordinate
          && datum.position[0] === reviewSpatialPreviewCoordinate[0]
          && datum.position[1] === reviewSpatialPreviewCoordinate[1]) {
          return [34, 197, 94, 255];
        }
        return datum.isLast ? [125, 211, 252, 255] : [226, 232, 240, 220];
      },
      getLineColor: [8, 15, 24, 255],
      getRadius: (datum: { position: [number, number]; isLast: boolean; isPreview: boolean; isStart: boolean }) => {
        if (datum.isPreview) return 7;
        if (datum.isStart && reviewSpatialPreviewCoordinate
          && datum.position[0] === reviewSpatialPreviewCoordinate[0]
          && datum.position[1] === reviewSpatialPreviewCoordinate[1]) {
          return 9;
        }
        return datum.isLast ? 8 : 6;
      },
      radiusUnits: 'pixels',
      stroked: true,
      lineWidthMinPixels: 1.5,
    }));

    return layers;
  }, [
    reviewSpatialFilterDrawing,
    reviewSpatialFilterPoints,
    reviewSpatialFilterPolygon,
    reviewSpatialPreviewCoordinate,
  ]);

  const filteredParentInferenceQueue = useMemo(() => {
    const threshold = Math.max(MIN_PARENT_REVIEW_SCORE_PCT, reviewMinTopScorePct) / 100;
    const normalizedSearch = normalizeReviewSearchText(reviewSearchText);
    const filtered = parentInferenceQueue.filter(item => {
      const topScore = item.parentInference?.topScore ?? 0;
      if (topScore < threshold) return false;
      if ((item.memberCount ?? 0) < reviewMinMemberCount) return false;
      if (normalizedSearch) {
        const haystack = normalizeReviewSearchText([
          item.groupKey,
          item.groupLabel,
          item.zoneName,
          item.parentInference?.selectedParentName,
        ].filter(Boolean).join(' '));
        if (!haystack.includes(normalizedSearch)) return false;
      }
      if (!reviewSpatialFilterPolygon) return true;
      const group = gearGroupLookup.byCompositeKey.get(`${item.groupKey}:${item.subClusterId}`)
        ?? gearGroupLookup.byGroupKey.get(item.groupKey);
      if (!group) return false;
      return booleanPointInPolygon(point([group.centerLon, group.centerLat]), reviewSpatialFilterPolygon);
    });

    if (reviewSortMode === 'backend') return filtered;
    return [...filtered].sort((a, b) => compareReviewQueueItems(a, b, reviewSortMode, reviewZoneDistanceByKey));
  }, [
    gearGroupLookup.byCompositeKey,
    gearGroupLookup.byGroupKey,
    parentInferenceQueue,
    reviewMinMemberCount,
    reviewSearchText,
    reviewMinTopScorePct,
    reviewSortMode,
    reviewSpatialFilterPolygon,
    reviewZoneDistanceByKey,
  ]);

  // eslint-disable-next-line @typescript-eslint/no-unused-vars
  const filteredParentInferenceQueueKeys = useMemo(
    () => new Set(filteredParentInferenceQueue.map(item => reviewItemKey(item.groupKey, item.subClusterId))),
    [filteredParentInferenceQueue],
  );

  const reviewFilterFallbackActive = autoOpenReviewPanel
    && !reviewFiltersTouched
    && filteredParentInferenceQueue.length === 0
    && parentInferenceQueue.length > 0;

  const displayedParentInferenceQueue = reviewFilterFallbackActive
    ? parentInferenceQueue
    : filteredParentInferenceQueue;

  const displayedParentInferenceQueueKeys = useMemo(
    () => new Set(displayedParentInferenceQueue.map(item => reviewItemKey(item.groupKey, item.subClusterId))),
    [displayedParentInferenceQueue],
  );

  const selectedQueueKeyForView = useMemo(() => {
    if (selectedReviewQueueKey) return selectedReviewQueueKey;
    if (!selectedGearGroup) return null;
    const firstMatch = displayedParentInferenceQueue.find(item => item.groupKey === selectedGearGroup)
      ?? parentInferenceQueue.find(item => item.groupKey === selectedGearGroup);
    return firstMatch ? reviewItemKey(firstMatch.groupKey, firstMatch.subClusterId) : null;
  }, [displayedParentInferenceQueue, parentInferenceQueue, selectedGearGroup, selectedReviewQueueKey]);

  const focusedReviewQueueKey = reviewHighlightedQueueKey ?? (historyActive ? selectedQueueKeyForView : null);
  const reviewScrollTargetQueueKey = useMemo(() => {
    if (hoverTooltip?.type === 'gear') {
      const hoveredQueueKey = hoverTooltip.compositeKey ?? null;
      return hoveredQueueKey && displayedParentInferenceQueueKeys.has(hoveredQueueKey) ? hoveredQueueKey : null;
    }
    return historyActive ? selectedQueueKeyForView : null;
  }, [displayedParentInferenceQueueKeys, historyActive, hoverTooltip, selectedQueueKeyForView]);

  const reviewMapFilterActive = reviewMinTopScorePct > MIN_PARENT_REVIEW_SCORE_PCT
    || reviewMinMemberCount > MIN_PARENT_REVIEW_MEMBER_COUNT
    || reviewSearchText.trim().length > 0
    || !!reviewSpatialFilterPolygon;
  const reviewAutoFocusMode = autoOpenReviewPanel && !historyActive;
  const effectiveEnabledModels = useMemo(
    () => (reviewAutoFocusMode ? new Set<string>() : reviewDrivenOverlayMode ? new Set<string>(['identity']) : enabledModels),
    [enabledModels, reviewAutoFocusMode, reviewDrivenOverlayMode],
  );

  const visibleReviewQueueKeys = useMemo(
    () => (reviewMapFilterActive ? displayedParentInferenceQueueKeys : null),
    [displayedParentInferenceQueueKeys, reviewMapFilterActive],
  );

  const activeLabelSessionByQueueKey = useMemo(() => {
    const map = new Map<string, ParentLabelSession>();
    for (const session of parentActiveLabelSessions) {
      map.set(reviewItemKey(session.groupKey, session.subClusterId), session);
    }
    return map;
  }, [parentActiveLabelSessions]);

  const activeGroupExclusionByKey = useMemo(() => {
    const map = new Map<string, ParentCandidateExclusion>();
    for (const exclusion of parentActiveGroupExclusions) {
      if (!exclusion.groupKey || exclusion.subClusterId == null) continue;
      map.set(
        `${reviewItemKey(exclusion.groupKey, exclusion.subClusterId)}:${exclusion.candidateMmsi}`,
        exclusion,
      );
    }
    return map;
  }, [parentActiveGroupExclusions]);

  const activeGlobalExclusionByMmsi = useMemo(() => {
    const map = new Map<string, ParentCandidateExclusion>();
    for (const exclusion of parentActiveGlobalExclusions) {
      map.set(exclusion.candidateMmsi, exclusion);
    }
    return map;
  }, [parentActiveGlobalExclusions]);



  // ── 부모 콜백 동기화: 어구 그룹 선택 ──
  useEffect(() => {
    if (!selectedGearGroup || historyActive) {
      onSelectedGearChange?.(null);
      if (historyActive) return;
      return;
    }
    const allGroups = groupPolygons ? [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups] : [];
    const matches = allGroups.filter(g => g.groupKey === selectedGearGroup);
    if (matches.length === 0) { onSelectedGearChange?.(null); return; }
    // 서브클러스터 멤버 합산
    const seen = new Set<string>();
    const allMembers: typeof matches[0]['members'] = [];
    for (const g of matches) for (const m of g.members) { if (!seen.has(m.mmsi)) { seen.add(m.mmsi); allMembers.push(m); } }
    const parent = allMembers.find(m => m.isParent);
    const gears = allMembers.filter(m => !m.isParent);
    const toShip = (m: typeof allMembers[0]): Ship => ({
      mmsi: m.mmsi, name: m.name, lat: m.lat, lng: m.lon,
      heading: m.cog, speed: m.sog, course: m.cog,
      category: 'fishing', lastSeen: Date.now(),
    });
    onSelectedGearChange?.({ parent: parent ? toShip(parent) : null, gears: gears.map(toShip), groupName: selectedGearGroup });
  }, [selectedGearGroup, groupPolygons, onSelectedGearChange, historyActive]);

  // ── 연관성 데이터 로드 (loadHistory에서 일괄 처리, 여기서는 비재생 모드만) ──
  useEffect(() => {
    if (!selectedGearGroup || historyActive) { if (!selectedGearGroup) { setCorrelationData([]); } return; }
    let cancelled = false;
    setCorrelationLoading(true);
    fetchGroupCorrelations(selectedGearGroup, 0.3)
      .then(res => { if (!cancelled) setCorrelationData(res.items); })
      .catch(() => { if (!cancelled) setCorrelationData([]); })
      .finally(() => { if (!cancelled) setCorrelationLoading(false); });
    return () => { cancelled = true; };
  }, [selectedGearGroup, historyActive]);

  useEffect(() => {
    if (!autoOpenReviewPanel) return;
    void refreshParentInferenceQueue();
  }, [autoOpenReviewPanel, refreshParentInferenceQueue]);

  useEffect(() => {
    if (!selectedGearGroup) {
      setParentInferenceItems([]);
      setParentActiveGroupExclusions([]);
      setParentActiveGlobalExclusions([]);
      setParentActiveLabelSessions([]);
      setSelectedReviewQueueKey(null);
      return;
    }
    let cancelled = false;
    void reloadParentInference(selectedGearGroup, { reloadQueue: false }).catch(() => {
      if (!cancelled) {
        setParentInferenceItems([]);
        setParentActiveGroupExclusions([]);
        setParentActiveGlobalExclusions([]);
        setParentActiveLabelSessions([]);
      }
    });
    return () => { cancelled = true; };
  }, [reloadParentInference, selectedGearGroup]);

  useEffect(() => {
    if (!selectedReviewQueueKey) return;
    if (!selectedGearGroup || !selectedReviewQueueKey.startsWith(`${selectedGearGroup}:`)) {
      setSelectedReviewQueueKey(null);
    }
  }, [selectedGearGroup, selectedReviewQueueKey]);

  // ── 연관 선박 항적 로드 (loadHistory에서 일괄 처리, 여기서는 비재생 모드만) ──
  useEffect(() => {
    if (!selectedGearGroup || historyActive) { if (!selectedGearGroup) { setCorrelationTracks([]); setEnabledVessels(new Set()); } return; }
    let cancelled = false;
      fetchCorrelationTracks(selectedGearGroup, 24, 0)
      .then(res => {
        if (!cancelled) {
          setCorrelationTracks(res.vessels);
          setEnabledVessels(new Set(res.vessels.filter(v => v.score >= 0.7).map(v => v.mmsi)));
        }
      })
      .catch(() => { if (!cancelled) setCorrelationTracks([]); });
    return () => { cancelled = true; };
  }, [selectedGearGroup, historyActive]);

  // ── 부모 콜백 동기화: 선단 선택 ──
  useEffect(() => {
    if (expandedFleet === null || historyActive) {
      onSelectedFleetChange?.(null);
      if (historyActive) return;
      return;
    }
    const group = groupPolygons?.fleetGroups.find(g => Number(g.groupKey) === expandedFleet);
    const company = companies.get(expandedFleet);
    if (!group) { onSelectedFleetChange?.(null); return; }
    const fleetShips: Ship[] = group.members.map(m => ({
      mmsi: m.mmsi, name: m.name, lat: m.lat, lng: m.lon,
      heading: m.cog, speed: m.sog, course: m.cog,
      category: 'fishing', lastSeen: Date.now(),
    }));
    onSelectedFleetChange?.({ clusterId: expandedFleet, ships: fleetShips, companyName: company?.nameCn || group.groupLabel || `선단 #${expandedFleet}` });
  }, [expandedFleet, groupPolygons, companies, onSelectedFleetChange, historyActive]);

  // ── GeoJSON 훅 ──
  const hoveredMmsi = hoveredTarget?.mmsi ?? null;
  const hoveredParentCandidateMmsi = hoveredTarget?.model === 'parent-review' ? hoveredTarget.mmsi : null;
  const geo = useFleetClusterGeoJson({
    ships, shipMap, groupPolygons, analysisMap,
    hoveredFleetId,
    hoveredGearCompositeKey,
    visibleGearCompositeKeys: visibleReviewQueueKeys,
    selectedGearGroup,
    selectedGearCompositeKey: selectedQueueKeyForView,
    pickerHoveredGroup,
    historyActive,
    correlationData, correlationTracks,
    enabledModels: effectiveEnabledModels, enabledVessels, hoveredMmsi,
  });

  // ── deck.gl 이벤트 콜백 ──
  const handleDeckPolygonClick = useCallback((features: PickedPolygonFeature[], coordinate: [number, number]) => {
    if (reviewSpatialFilterDrawing) {
      handleReviewSpatialFilterClick(
        reviewSpatialCursor
          ? { coordinate, screen: reviewSpatialCursor.screen }
          : { coordinate, screen: [0, 0] },
      );
      return;
    }
    if (features.length === 0) return;
    if (features.length === 1) {
      const c = features[0];
      if (c.type === 'fleet' && c.clusterId != null) {
        setExpandedFleet(prev => prev === c.clusterId ? null : c.clusterId!);
        setActiveSection('fleet');
        setSelectedReviewQueueKey(null);
        const group = groupPolygons?.fleetGroups.find(g => Number(g.groupKey) === c.clusterId);
        if (group && group.members.length > 0) {
          let minLat = Infinity, maxLat = -Infinity, minLng = Infinity, maxLng = -Infinity;
          for (const m of group.members) {
            if (m.lat < minLat) minLat = m.lat;
            if (m.lat > maxLat) maxLat = m.lat;
            if (m.lon < minLng) minLng = m.lon;
            if (m.lon > maxLng) maxLng = m.lon;
          }
          if (minLat !== Infinity) onFleetZoom?.({ minLat, maxLat, minLng, maxLng });
        }
        loadHistory(String(c.clusterId));
      } else if (c.type === 'gear' && c.groupKey) {
        const nextQueueKey = c.compositeKey ?? reviewItemKey(c.groupKey, c.subClusterId ?? 0);
        const isSameSelection = selectedGearGroup === c.groupKey && selectedQueueKeyForView === nextQueueKey;
        setSelectedReviewQueueKey(isSameSelection ? null : nextQueueKey);
        setSelectedGearGroup(isSameSelection ? null : c.groupKey);
        setExpandedGearGroup(c.groupKey);
        const isInZone = groupPolygons?.gearInZoneGroups.some(g => g.groupKey === c.groupKey);
        setActiveSection(isInZone ? 'inZone' : 'outZone');
        const allGroups = groupPolygons ? [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups] : [];
        const group = c.compositeKey
          ? gearGroupLookup.byCompositeKey.get(c.compositeKey) ?? allGroups.find(g => g.groupKey === c.groupKey)
          : allGroups.find(g => g.groupKey === c.groupKey);
        if (group && group.members.length > 0) {
          let minLat = Infinity, maxLat = -Infinity, minLng = Infinity, maxLng = -Infinity;
          for (const m of group.members) {
            if (m.lat < minLat) minLat = m.lat;
            if (m.lat > maxLat) maxLat = m.lat;
            if (m.lon < minLng) minLng = m.lon;
            if (m.lon > maxLng) maxLng = m.lon;
          }
          if (minLat !== Infinity) onFleetZoom?.({ minLat, maxLat, minLng, maxLng });
        }
        loadHistory(c.groupKey);
      }
    } else {
      // 겹친 폴리곤 → 피커 팝업
      const candidates: PickerCandidate[] = features.map(f => {
        if (f.type === 'fleet') {
          const g = groupPolygons?.fleetGroups.find(x => Number(x.groupKey) === f.clusterId);
          return { name: g?.groupLabel ?? `선단 #${f.clusterId}`, count: g?.memberCount ?? 0, inZone: false, isFleet: true, clusterId: f.clusterId };
        }
        return { name: f.name ?? '', count: f.gearCount ?? 0, inZone: !!f.inZone, isFleet: false };
      });
      setGearPickerPopup({ lng: coordinate[0], lat: coordinate[1], candidates });
    }
  }, [
    groupPolygons,
    gearGroupLookup.byCompositeKey,
    handleReviewSpatialFilterClick,
    loadHistory,
    onFleetZoom,
    reviewSpatialCursor,
    reviewSpatialFilterDrawing,
    selectedGearGroup,
    selectedQueueKeyForView,
  ]);

  const handleDeckPolygonHover = useCallback((info: {
    lng: number;
    lat: number;
    type: 'fleet' | 'gear';
    id: string | number;
    groupKey?: string;
    subClusterId?: number;
    compositeKey?: string;
  } | null) => {
    if (info) {
      if (info.type === 'fleet') {
        setHoveredFleetId(info.id as number);
        setHoveredGearCompositeKey(null);
        setReviewHighlightedQueueKey(null);
      } else {
        setHoveredFleetId(null);
        const compositeKey = info.compositeKey ?? null;
        setHoveredGearCompositeKey(compositeKey);
        setReviewHighlightedQueueKey(compositeKey && displayedParentInferenceQueueKeys.has(compositeKey) ? compositeKey : null);
      }
      setHoverTooltip({
        lng: info.lng,
        lat: info.lat,
        type: info.type,
        id: info.id,
        groupKey: info.groupKey,
        subClusterId: info.subClusterId,
        compositeKey: info.compositeKey,
      });
    } else {
      setHoveredFleetId(null);
      setHoveredGearCompositeKey(null);
      setReviewHighlightedQueueKey(null);
      setHoverTooltip(null);
    }
  }, [displayedParentInferenceQueueKeys]);

  // ── deck.gl 레이어 빌드 ──
  const focusMode = useGearReplayStore(s => s.focusMode);
  const zoomLevel = useShipDeckStore(s => s.zoomLevel);
  const fleetDeckLayers = useFleetClusterDeckLayers(geo, {
    selectedGearGroup,
    hoveredMmsi,
    hoveredGearCompositeKey,
    enabledModels: effectiveEnabledModels,
    historyActive,
    hasCorrelationTracks: correlationTracks.length > 0,
    zoomScale,
    zoomLevel,
    fontScale: fontScale.analysis,
    focusMode,
    onPolygonClick: handleDeckPolygonClick,
    onPolygonHover: handleDeckPolygonHover,
  });

  useEffect(() => {
    onDeckLayersChange?.([...fleetDeckLayers, ...reviewSpatialFilterLayers]);
  }, [fleetDeckLayers, onDeckLayersChange, reviewSpatialFilterLayers]);

  // ── 어구 그룹 데이터 ──
  const inZoneGearGroups = groupPolygons?.gearInZoneGroups ?? [];
  const outZoneGearGroups = groupPolygons?.gearOutZoneGroups ?? [];

  // ── 핸들러 ──
  const handleFleetZoom = useCallback((clusterId: number) => {
    const group = groupPolygons?.fleetGroups.find(g => Number(g.groupKey) === clusterId);
    if (!group || group.members.length === 0) return;
    let minLat = Infinity, maxLat = -Infinity, minLng = Infinity, maxLng = -Infinity;
    for (const m of group.members) {
      if (m.lat < minLat) minLat = m.lat;
      if (m.lat > maxLat) maxLat = m.lat;
      if (m.lon < minLng) minLng = m.lon;
      if (m.lon > maxLng) maxLng = m.lon;
    }
    if (minLat === Infinity) return;
    onFleetZoom?.({ minLat, maxLat, minLng, maxLng });
  }, [groupPolygons?.fleetGroups, onFleetZoom]);

  const focusGearGroupBounds = useCallback((parentName: string, subClusterId?: number | null) => {
    const allGroups = groupPolygons ? [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups] : [];
    const group = subClusterId != null
      ? gearGroupLookup.byCompositeKey.get(reviewItemKey(parentName, subClusterId))
        ?? allGroups.find(g => g.groupKey === parentName && (g.subClusterId ?? 0) === subClusterId)
        ?? allGroups.find(g => g.groupKey === parentName)
      : allGroups.find(g => g.groupKey === parentName);
    if (!group || group.members.length === 0) return;
    let minLat = Infinity;
    let maxLat = -Infinity;
    let minLng = Infinity;
    let maxLng = -Infinity;
    for (const m of group.members) {
      if (m.lat < minLat) minLat = m.lat;
      if (m.lat > maxLat) maxLat = m.lat;
      if (m.lon < minLng) minLng = m.lon;
      if (m.lon > maxLng) maxLng = m.lon;
    }
    if (minLat === Infinity) return;
    onFleetZoom?.({ minLat, maxLat, minLng, maxLng });
  }, [gearGroupLookup.byCompositeKey, groupPolygons, onFleetZoom]);

  const handleGearGroupZoom = useCallback((parentName: string, subClusterId?: number | null) => {
    const nextQueueKey = subClusterId != null ? reviewItemKey(parentName, subClusterId) : null;
    const isSameSelection = subClusterId != null
      ? selectedGearGroup === parentName && selectedQueueKeyForView === nextQueueKey
      : selectedGearGroup === parentName;
    if (subClusterId != null) {
      setSelectedReviewQueueKey(isSameSelection ? null : nextQueueKey);
    }
    setSelectedGearGroup(isSameSelection ? null : parentName);
    setExpandedGearGroup(parentName);
    const isInZone = groupPolygons?.gearInZoneGroups.some(g => g.groupKey === parentName);
    setActiveSection(isInZone ? 'inZone' : 'outZone');
    requestAnimationFrame(() => {
      setTimeout(() => {
        document.getElementById(`gear-row-${parentName}`)?.scrollIntoView({ behavior: 'smooth', block: 'start' });
      }, 50);
    });
    focusGearGroupBounds(parentName, subClusterId);
    loadHistory(parentName);
  }, [focusGearGroupBounds, groupPolygons, selectedGearGroup, selectedQueueKeyForView]);

  const handleJumpToReviewGroup = useCallback((groupKey: string, subClusterId: number) => {
    setSelectedGearGroup(groupKey);
    setExpandedGearGroup(groupKey);
    setSelectedReviewQueueKey(reviewItemKey(groupKey, subClusterId));
    const isInZone = groupPolygons?.gearInZoneGroups.some(g => g.groupKey === groupKey);
    setActiveSection(isInZone ? 'inZone' : 'outZone');
    focusGearGroupBounds(groupKey, subClusterId);
  }, [focusGearGroupBounds, groupPolygons?.gearInZoneGroups]);

  const handleParentWorkflowAction = useCallback(async (
    subClusterId: number,
    action: 'LABEL' | 'GROUP_EXCLUDE' | 'GLOBAL_EXCLUDE' | 'CANCEL_LABEL' | 'RELEASE_GROUP_EXCLUSION' | 'RELEASE_GLOBAL_EXCLUSION',
    candidateMmsi?: string,
  ) => {
    if (!selectedGearGroup) return;
    const actor = parentReviewActor.trim() || user?.name?.trim() || 'lab-ui';
    const submitKey = `${selectedGearGroup}:${subClusterId}`;
    const queueKey = reviewItemKey(selectedGearGroup, subClusterId);
    setParentInferenceSubmittingKey(submitKey);
    try {
      switch (action) {
        case 'LABEL':
          if (!candidateMmsi) throw new Error('selectedParentMmsi is required');
          await createGroupParentLabelSession(selectedGearGroup, subClusterId, {
            selectedParentMmsi: candidateMmsi,
            durationDays: parentWorkflowDurationDays,
            actor,
          });
          break;
        case 'GROUP_EXCLUDE':
          if (!candidateMmsi) throw new Error('candidateMmsi is required');
          await createGroupCandidateExclusion(selectedGearGroup, subClusterId, {
            candidateMmsi,
            durationDays: parentWorkflowDurationDays,
            actor,
          });
          break;
        case 'GLOBAL_EXCLUDE':
          if (!candidateMmsi) throw new Error('candidateMmsi is required');
          await createGlobalCandidateExclusion({
            candidateMmsi,
            actor,
          });
          break;
        case 'CANCEL_LABEL': {
          const session = activeLabelSessionByQueueKey.get(queueKey);
          if (!session) throw new Error('active label session not found');
          await cancelParentLabelSession(session.id, { actor });
          break;
        }
        case 'RELEASE_GROUP_EXCLUSION': {
          if (!candidateMmsi) throw new Error('candidateMmsi is required');
          const exclusion = activeGroupExclusionByKey.get(`${queueKey}:${candidateMmsi}`);
          if (!exclusion) throw new Error('active group exclusion not found');
          await releaseCandidateExclusion(exclusion.id, { actor });
          break;
        }
        case 'RELEASE_GLOBAL_EXCLUSION': {
          if (!candidateMmsi) throw new Error('candidateMmsi is required');
          const exclusion = activeGlobalExclusionByMmsi.get(candidateMmsi);
          if (!exclusion) throw new Error('active global exclusion not found');
          await releaseCandidateExclusion(exclusion.id, { actor });
          break;
        }
        default:
          throw new Error('unsupported parent workflow action');
      }
      await reloadParentInference(selectedGearGroup);
    } catch (error) {
      const message = error instanceof Error ? error.message : 'parent workflow action failed';
      console.warn('[parent-workflow] 실패:', message);
      window.alert(message);
    } finally {
      setParentInferenceSubmittingKey(null);
    }
  }, [
    activeGlobalExclusionByMmsi,
    activeGroupExclusionByKey,
    activeLabelSessionByQueueKey,
    parentReviewActor,
    parentWorkflowDurationDays,
    reloadParentInference,
    selectedGearGroup,
    user?.name,
  ]);

  const handleParentReviewSelectGroup = useCallback((groupKey: string, subClusterId: number) => {
    setSelectedReviewQueueKey(reviewItemKey(groupKey, subClusterId));
    if (groupKey === selectedGearGroup) {
      void reloadParentInference(groupKey);
      return;
    }
    handleGearGroupZoom(groupKey, subClusterId);
  }, [handleGearGroupZoom, reloadParentInference, selectedGearGroup]);

  useEffect(() => {
    if (!autoOpenReviewPanel || autoOpenedReviewRef.current || selectedGearGroup || displayedParentInferenceQueue.length === 0) {
      return;
    }
    const first = displayedParentInferenceQueue[0];
    autoOpenedReviewRef.current = true;
    setSelectedReviewQueueKey(reviewItemKey(first.groupKey, first.subClusterId));
    setSelectedGearGroup(first.groupKey);
    setExpandedGearGroup(first.groupKey);
    const isInZone = groupPolygons?.gearInZoneGroups.some(g => g.groupKey === first.groupKey);
    setActiveSection(isInZone ? 'inZone' : 'outZone');
  }, [autoOpenReviewPanel, displayedParentInferenceQueue, groupPolygons?.gearInZoneGroups, selectedGearGroup]);

  const resetParentReviewFilters = useCallback(() => {
    setReviewFiltersTouched(false);
    setStoredReviewMinTopScorePct(MIN_PARENT_REVIEW_SCORE_PCT);
    setStoredReviewMinMemberCount(MIN_PARENT_REVIEW_MEMBER_COUNT);
    setStoredReviewSortMode('backend');
    setStoredReviewSearchText('');
    clearReviewSpatialFilter();
  }, [
    clearReviewSpatialFilter,
    setStoredReviewMinMemberCount,
    setStoredReviewMinTopScorePct,
    setStoredReviewSearchText,
    setStoredReviewSortMode,
  ]);

  // ── Picker 콜백 ──
  const handlePickerSelect = useCallback((c: PickerCandidate) => {
    if (c.isFleet && c.clusterId != null) {
      setExpandedFleet(prev => prev === c.clusterId! ? null : c.clusterId!);
      setActiveSection('fleet');
      handleFleetZoom(c.clusterId);
    } else {
      handleGearGroupZoom(c.name);
    }
    setGearPickerPopup(null);
    setPickerHoveredGroup(null);
  }, [handleFleetZoom, handleGearGroupZoom]);

  // ── CorrelationPanel용 멤버 수 ──
  const selectedGroupMemberCount = useMemo(() => {
    if (!selectedGearGroup || !groupPolygons) return 0;
    const allGroups = [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups];
    return allGroups.filter(g => g.groupKey === selectedGearGroup).reduce((sum, g) => sum + g.memberCount, 0);
  }, [selectedGearGroup, groupPolygons]);

  return (
    <>
      {/* ── 맵 레이어 ── */}
      <FleetClusterMapLayers
        selectedGearGroup={selectedGearGroup}
        expandedFleet={expandedFleet}
        hoverTooltip={hoverTooltip}
        gearPickerPopup={gearPickerPopup}
        pickerHoveredGroup={pickerHoveredGroup}
        groupPolygons={groupPolygons}
        companies={companies}
        analysisMap={analysisMap}
        onPickerHover={setPickerHoveredGroup}
        onPickerSelect={handlePickerSelect}
        onPickerClose={() => { setGearPickerPopup(null); setPickerHoveredGroup(null); }}
      />

      {/* ── 연관성 패널 ── */}
      {selectedGearGroup && !reviewAutoFocusMode && (
        <CorrelationPanel
          selectedGearGroup={selectedGearGroup}
          memberCount={selectedGroupMemberCount}
          groupPolygons={groupPolygons}
          correlationByModel={geo.correlationByModel}
          availableModels={geo.availableModels}
          enabledModels={enabledModels}
          enabledVessels={enabledVessels}
          correlationLoading={correlationLoading}
          hoveredTarget={hoveredTarget}
          hasRightReviewPanel={!!selectedGearGroup}
          reviewDriven={reviewDrivenOverlayMode}
          onEnabledModelsChange={(updater) => setEnabledModels(updater)}
          onEnabledVesselsChange={(updater) => setEnabledVessels(updater)}
          onHoveredTargetChange={setHoveredTarget}
        />
      )}

      {selectedGearGroup && (
        <ParentReviewPanel
          selectedGearGroup={selectedGearGroup}
          items={parentInferenceItems}
          reviewQueue={displayedParentInferenceQueue}
          reviewQueueFilteredCount={filteredParentInferenceQueue.length}
          activeGroupExclusions={parentActiveGroupExclusions}
          activeGlobalExclusions={parentActiveGlobalExclusions}
          activeLabelSessions={parentActiveLabelSessions}
          reviewQueueTotalCount={parentInferenceQueue.length}
          filterFallbackActive={reviewFilterFallbackActive}
          isLoading={parentInferenceLoading}
          submittingKey={parentInferenceSubmittingKey}
          actor={parentReviewActor}
          workflowDurationDays={parentWorkflowDurationDays}
          hoveredCandidateMmsi={hoveredParentCandidateMmsi}
          minTopScorePct={reviewMinTopScorePct}
          minMemberCount={reviewMinMemberCount}
          sortMode={reviewSortMode}
          searchText={reviewSearchText}
          selectedQueueKey={selectedQueueKeyForView}
          focusedQueueKey={focusedReviewQueueKey}
          scrollTargetQueueKey={reviewScrollTargetQueueKey}
          isSpatialFilterDrawing={reviewSpatialFilterDrawing}
          hasSpatialFilter={!!reviewSpatialFilterPolygon}
          spatialFilterPointCount={reviewSpatialFilterPoints.length}
          onActorChange={setParentReviewActor}
          onWorkflowDurationDaysChange={(value) => setStoredParentWorkflowDurationDays(sanitizeWorkflowDurationDays(value))}
          onRefresh={() => { void reloadParentInference(selectedGearGroup); }}
          onSelectGroup={handleParentReviewSelectGroup}
          onJumpToGroup={handleJumpToReviewGroup}
          onQueueHover={(queueKey) => {
            setReviewHighlightedQueueKey(queueKey);
            setHoveredGearCompositeKey(queueKey);
          }}
          onCandidateHover={(mmsi) => {
            setHoveredTarget(mmsi ? { mmsi, model: 'parent-review' } : null);
          }}
          onMinTopScorePctChange={(value) => {
            setReviewFiltersTouched(true);
            setStoredReviewMinTopScorePct(sanitizeReviewMinTopScorePct(value));
          }}
          onMinMemberCountChange={(value) => {
            setReviewFiltersTouched(true);
            setStoredReviewMinMemberCount(sanitizeReviewMinMemberCount(value));
          }}
          onSortModeChange={(value) => {
            setReviewFiltersTouched(true);
            setStoredReviewSortMode(sanitizeReviewSortMode(value));
          }}
          onSearchTextChange={(value) => {
            setReviewFiltersTouched(true);
            setStoredReviewSearchText(sanitizeReviewSearchText(value));
          }}
          onResetFilters={resetParentReviewFilters}
          onStartSpatialFilter={() => {
            setReviewFiltersTouched(true);
            startReviewSpatialFilter();
          }}
          onFinishSpatialFilter={() => {
            setReviewFiltersTouched(true);
            finishReviewSpatialFilter();
          }}
          onClearSpatialFilter={() => {
            setReviewFiltersTouched(true);
            clearReviewSpatialFilter();
          }}
          onWorkflowAction={(subClusterId, action, candidateMmsi) => {
            void handleParentWorkflowAction(subClusterId, action, candidateMmsi);
          }}
        />
      )}

      {/* ── 재생 컨트롤러 ── */}
      {historyActive && (
        <HistoryReplayController
          onClose={closeHistory}
          hasRightReviewPanel={!!selectedGearGroup}
        />
      )}

      {/* ── 좌측 목록 패널 ── */}
      <FleetGearListPanel
        fleetList={geo.fleetList}
        companies={companies}
        analysisMap={analysisMap}
        inZoneGearGroups={inZoneGearGroups}
        outZoneGearGroups={outZoneGearGroups}
        activeSection={activeSection}
        expandedFleet={expandedFleet}
        expandedGearGroup={expandedGearGroup}
        hoveredFleetId={hoveredFleetId}
        onToggleSection={toggleSection}
        onExpandFleet={setExpandedFleet}
        onHoverFleet={setHoveredFleetId}
        onFleetZoom={handleFleetZoom}
        onGearGroupZoom={handleGearGroupZoom}
        onExpandGearGroup={setExpandedGearGroup}
        onShipSelect={(mmsi) => onShipSelect?.(mmsi)}
      />
    </>
  );
}
