import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { MouseEvent as ReactMouseEvent, RefObject } from 'react';
import type { DroneFrameBasic } from '../utils/geoProjection';
import { useGeoStore } from '../store/geoStore';
import { useSettingsStore, isGradeVisible } from '../store/settingsStore';
import {
  STAGE_WIDTH,
  TRACK_END_PX,
  TRACK_START_PX,
  TRACK_WIDTH_PX,
  renderX,
  trackXFromRender,
} from './mocks/route';
import { cssVars } from './utils/cssVars';
import { formatMileage } from './utils/mileage';
import { PlaybackControls } from './components/PlaybackControls/PlaybackControls';
import { Timeline } from './components/Timeline/Timeline';
import { TimelineCursor } from './components/TimelineCursor/TimelineCursor';
import styles from './StationBar.module.scss';
import './tokens.css';

/** 영상 실제 fps (RoutePanel·StationOverlay 와 동일 기준). */
const VIDEO_FPS = 30000 / 1001;
/** 프레임→현재측점 precompute 시 프레임 샘플 간격(영상 step). */
const FRAME_STEP = 10;

interface GeoPoint {
  title: string;
  category: string;
  lat: number;
  lon: number;
  z: number;
  type: string;
}

/** 프레임별 precompute 결과. km=배지용 최근접측점, chain=구간방향용 연속 체이니지. */
interface ViewedPoint {
  frameNum: number;
  km: number;
  chain: number;
  time: number;
}

/** 측점 폴리라인(평면 투영). 연속 체이니지 계산용. */
interface StationLine {
  pts: { x: number; y: number; km: number }[];
  k: number; // 경도→m 환산 (cos(lat0)*111000)
}

/** 드론 lat/lon 을 측점 폴리라인에 투영 → 연속 체이니지(m).
 *  100m 양자화된 최근접측점과 달리 전진/후진 전환 시점이 실제 이동과 일치. */
function projectChainage(lat: number, lon: number, line: StationLine): number {
  const px = lon * line.k;
  const py = lat * 111000;
  const pts = line.pts;
  let bestD = Infinity;
  let bestKm = pts.length ? pts[0].km : -1;
  for (let i = 0; i < pts.length - 1; i++) {
    const a = pts[i];
    const b = pts[i + 1];
    const dx = b.x - a.x;
    const dy = b.y - a.y;
    const L2 = dx * dx + dy * dy;
    const t = L2 === 0 ? 0 : Math.max(0, Math.min(1, ((px - a.x) * dx + (py - a.y) * dy) / L2));
    const cx = a.x + dx * t;
    const cy = a.y + dy * t;
    const d = (px - cx) ** 2 + (py - cy) ** 2;
    if (d < bestD) {
      bestD = d;
      bestKm = a.km + (b.km - a.km) * t;
    }
  }
  return bestKm;
}

/** 데이터 기반 트랙 구간: km 증가(dir 1, 주황) / 감소(dir -1, 하늘색). px는 시간축. */
export interface BarSegment {
  startPx: number;
  endPx: number;
  dir: 1 | -1;
}
/** 측점값 라벨 (방향 전환점·시종점). px는 시간축. */
export interface KmLabel {
  px: number;
  text: string;
}
/** 구조물 마크 (교량/터널/역사). px는 통과 시점(시간축), km은 측점값(연속 체이니지, m). */
export interface StructMark {
  px: number;
  title: string;
  category: string;
  km: number;
  /** 원본 속성(KMZ/CSV) — 마커 클릭 팝업 표시용. */
  props?: { k: string; v: string }[];
  /** 종점역인데 영상이 도착하지 못함 → 끝에 '미도착' 스타일(속 빈 링)로 표시. */
  unreached?: boolean;
}

interface StationBarProps {
  currentTime: number;
  /** 라이브 재생시간 ref (매 프레임 갱신). 커서/진행바를 React 리렌더 없이 직접 이동. */
  timeRef?: RefObject<number>;
  duration: number;
  playing: boolean;
  onTogglePlay: () => void;
  onStop: () => void;
  onCapture: () => void;
  onSeek: (time: number) => void;
  showStations: boolean;
  onToggleStations: () => void;
}

const clamp = (v: number, lo: number, hi: number): number =>
  Math.min(hi, Math.max(lo, v));

function stationKm(title: string): number {
  const m = title.match(/(\d+)[Kk](\d+)/);
  return m ? parseInt(m[1], 10) * 1000 + parseInt(m[2], 10) : -1;
}

/** 드론 GPS 최근접 측점 km (FOV 안에 측점이 없을 때 폴백). */
function nearestStationKm(f: DroneFrameBasic, stations: GeoPoint[]): number {
  let best = -1;
  let bd = Infinity;
  for (const st of stations) {
    const d = (f.lat - st.lat) ** 2 + (f.lon - st.lon) ** 2;
    if (d < bd) {
      bd = d;
      best = stationKm(st.title);
    }
  }
  return best;
}

/** station 값(숫자=미터, "158k400" 문자열) → 미터. 실패 시 -1. */
function mileageToMeters(v: number | string): number {
  if (typeof v === 'number') return v;
  const m = String(v).match(/(\d+)\s*[kK]\s*(\d+)/);
  if (m) return parseInt(m[1], 10) * 1000 + parseInt(m[2], 10);
  const n = parseInt(String(v).replace(/[^0-9]/g, ''), 10);
  return Number.isFinite(n) ? n : -1;
}

/** 미터값 → "158k710" (10m 단위 반올림). 커서 배지용 십단위 표시. */
function formatMileage10(m: number): string {
  const r = Math.round(m / 10) * 10;
  const km = Math.floor(r / 1000);
  const mm = r % 1000;
  return `${km}k${String(mm).padStart(3, '0')}`;
}

/**
 * abcVideo 실제 영상 데이터 기반 측점 바.
 * - 트랙 x축 = 프레임(시간) 진행. 구간 색 = 측점 km 증가(주황)/감소(하늘색).
 * - 측점값 라벨·구조물(교량/터널/역사) = 재생 파일의 측점/POI 데이터로 배치.
 * - 커서 = 프레임 진행, 배지 = 드론 GPS 최근접 측점.
 * - 클릭/드래그/측점입력 seek = 시간(프레임) 기준 이동.
 */
export function StationBar({
  currentTime,
  timeRef,
  duration,
  playing,
  onTogglePlay,
  onStop,
  onCapture,
  onSeek,
  showStations,
  onToggleStations,
}: StationBarProps) {
  const wrapRef = useRef<HTMLDivElement>(null);
  const stageRef = useRef<HTMLDivElement>(null);
  const [scale, setScale] = useState(0.5);
  const draggingRef = useRef(false);

  // 측점/POI/드론프레임은 폴더 선택으로 채워지는 geoStore에서 읽는다 (서버 /api/geo fetch 대체).
  const storePois = useGeoStore((s) => s.pois);
  const storeStations = useGeoStore((s) => s.stations);
  const pois = useMemo<GeoPoint[]>(
    () => [...storeStations, ...storePois],
    [storeStations, storePois],
  );
  const storeFrames = useGeoStore((s) => s.frames);
  const routeMeta = useGeoStore((s) => s.routeMeta);
  // v2.0: 구조물은 geoStore.structures(CSV 03)교량/04)터널/06)구교 + route.json 보정) 를 우선 사용.
  const storeStructuresRaw = useGeoStore((s) => s.structures);
  // 시설종별 필터(설정 스토어) — 체크 변경 시 즉시 반영(구독).
  const gradeFilter = useSettingsStore((s) => s.gradeFilter);
  const storeStructures = useMemo(
    () => storeStructuresRaw.filter((s) => isGradeVisible(s.grade, gradeFilter)),
    [storeStructuresRaw, gradeFilter],
  );
  const viewedRef = useRef<ViewedPoint[]>([]);
  const [ready, setReady] = useState(false);

  const stations = useMemo(
    () => pois.filter((p) => p.type === 'station' && stationKm(p.title) >= 0),
    [pois],
  );

  // 측점 폴리라인(평면 투영) — 연속 체이니지 계산용.
  const stationLine = useMemo<StationLine | null>(() => {
    if (!stations.length) return null;
    const sorted = [...stations].sort((a, b) => stationKm(a.title) - stationKm(b.title));
    const lat0 = sorted.reduce((s, p) => s + p.lat, 0) / sorted.length;
    const k = Math.cos((lat0 * Math.PI) / 180) * 111000;
    return {
      pts: sorted.map((p) => ({ x: p.lon * k, y: p.lat * 111000, km: stationKm(p.title) })),
      k,
    };
  }, [stations]);

  // 프레임별 precompute (frames·stations 준비되면 1회).
  //  - km: 드론 GPS 최근접 측점 (배지 표시용, 좌측 RoutePanel 과 동일).
  //  - chain: 측점 폴리라인 투영 연속 체이니지 (구간 방향용, 전환 타이밍 정확).
  // FPS는 고정값(29.97)이 아니라 실데이터(마지막 프레임 번호 / 재생시간)에서 유도 → 영상별 자동 적응.
  // 데이터 미비 시에만 VIDEO_FPS(29.97) 폴백. (frame/fps = 절대시간)
  const videoFps = useMemo<number>(() => {
    const last = storeFrames.length ? storeFrames[storeFrames.length - 1].frame : 0;
    return last > 0 && duration > 0 ? last / duration : VIDEO_FPS;
  }, [storeFrames, duration]);

  useEffect(() => {
    const frames = storeFrames;
    if (!frames.length || !stations.length || !stationLine) { setReady(false); return; }
    const out: ViewedPoint[] = new Array(frames.length);
    for (let i = 0; i < frames.length; i++) {
      const f = frames[i];
      out[i] = {
        frameNum: f.frame,
        km: nearestStationKm(f, stations),
        chain: projectChainage(f.lat, f.lon, stationLine),
        time: f.frame / videoFps,
      };
    }
    viewedRef.current = out;
    setReady(true);
  }, [stations, stationLine, storeFrames, videoFps]);

  /** 현재 시간에 가장 가까운 precompute 인덱스. */
  const viewedIdxAtTime = useCallback((t: number): number => {
    const arr = viewedRef.current;
    if (!arr.length) return -1;
    const target = t * videoFps;
    let best = 0;
    let bd = Math.abs(arr[0].frameNum - target);
    for (let i = 1; i < arr.length; i++) {
      const d = Math.abs(arr[i].frameNum - target);
      if (d < bd) {
        bd = d;
        best = i;
      }
    }
    return best;
  }, [videoFps]);

  // 현재 보는 측점값 — 연속 체이니지(chain)로 10m 해상도 (km 필드는 100m 양자화).
  const realChain = useMemo<number | null>(() => {
    if (!ready) return null;
    const idx = viewedIdxAtTime(currentTime);
    return idx >= 0 ? viewedRef.current[idx].chain : null;
  }, [currentTime, ready, viewedIdxAtTime]);

  // 종점역 '미도착' 폭(px) — 영상이 종점역 도착 전 끝날 때, 트랙 우측에 이만큼 비워둔다.
  // 영상 타임라인은 [START … END−endGapPx] 에만 매핑되고, 종점역 마커는 END(미도착)에 둔다.
  // 폭 = 미도착거리(m) / 노선길이(m) × 트랙폭 (옵션 2: 실제 비례). 안전상 15%로 상한.
  const endGapPx = useMemo<number>(() => {
    const gapM = routeMeta?.routeInfo?.endStationGapMeters ?? 0;
    const lenM = (routeMeta?.routeInfo?.lengthKm ?? 0) * 1000;
    if (gapM <= 0 || lenM <= 0) return 0;
    return Math.min(TRACK_WIDTH_PX * 0.15, (gapM / lenM) * TRACK_WIDTH_PX);
  }, [routeMeta]);
  // 시간→px 변환에 쓰는 유효 트랙폭 (미도착폭 제외).
  const timeTrackWidth = TRACK_WIDTH_PX - endGapPx;

  // 프레임(시간) 진행률 px — 커서의 단조 이동 기준.
  // 영상은 노선을 시간순으로 한 번 통과(leg 01→10)하므로, 시간 진행률이 곧
  // 트랙을 좌→우로 지나는 경로 진행률이다.
  const progressPx =
    duration > 0
      ? TRACK_START_PX + clamp(currentTime / duration, 0, 1) * timeTrackWidth
      : TRACK_START_PX;

  // 커서 위치 = 프레임 진행률(progressPx)로만 단조 이동.
  // 측점 km은 노선에서 여러 번 반복(증가↔감소)되어, km으로 px를 역산하면
  // 같은 km의 여러 후보 중 하나로 스냅되며 구간 점프가 생긴다(사용자 보고).
  // → 진행은 유니크한 프레임 기준, 표시(배지)만 현재 보는 측점(realKm)으로 한다.
  const cursorPx = progressPx;

  // 커서 배지 = 폴더 데이터 기반 연속 체이니지(realChain)를 10m 단위로 표시.
  // mock ROUTE_LEGS(mileageAtPx) 의존 제거. 데이터 없으면 빈 문자열.
  const cursorText =
    realChain !== null && realChain >= 0 ? formatMileage10(realChain) : '';

  // ── 데이터 기반 측점 바 ──────────────────────────────────────────
  // 시간(프레임) 진행을 트랙 px로 선형 변환.
  const pxAtTime = useCallback(
    (t: number): number =>
      duration > 0
        ? TRACK_START_PX + clamp(t / duration, 0, 1) * timeTrackWidth
        : TRACK_START_PX,
    [duration, timeTrackWidth],
  );

  // viewedRef(프레임→측점 km) 추이로 구간 색·전환점 산출.
  // km 증가 구간 = dir 1(주황), 감소 구간 = dir -1(하늘색). HYST로 최근접 지터 무시.
  const { barSegments, kmLabels } = useMemo<{
    barSegments: BarSegment[];
    kmLabels: KmLabel[];
  }>(() => {
    const arr = viewedRef.current;
    if (!ready || !arr.length || duration <= 0)
      return { barSegments: [], kmLabels: [] };
    // 방향은 연속 체이니지(chain)로 판정 — 100m 양자화 km은 전환을 ~13s 빨리 잡아 영상과 어긋남.
    const HYST = 100; // m — 이 이상 반대로 움직여야 방향 전환으로 인정 (GPS 노이즈 무시)
    const segs: BarSegment[] = [];
    const bounds: { chain: number; time: number; turn: boolean }[] = [
      { chain: arr[0].chain, time: arr[0].time, turn: false },
    ];
    // 시작 방향을 실제 데이터(첫 유의미 이동)로 판정. 기본 증가 가정이 틀리면 첫 구간 색이 반대로 나옴.
    let dir: 1 | -1 = 1;
    for (let i = 1; i < arr.length; i++) {
      const d = arr[i].chain - arr[0].chain;
      if (Math.abs(d) >= HYST) { dir = d > 0 ? 1 : -1; break; }
    }
    let extCh = arr[0].chain;
    let extIdx = 0;
    let startIdx = 0;
    for (let i = 1; i < arr.length; i++) {
      const c = arr[i].chain;
      if (dir > 0 ? c > extCh : c < extCh) {
        extCh = c;
        extIdx = i;
      } else if (dir > 0 ? extCh - c >= HYST : c - extCh >= HYST) {
        segs.push({
          startPx: pxAtTime(arr[startIdx].time),
          endPx: pxAtTime(arr[extIdx].time),
          dir,
        });
        bounds.push({ chain: extCh, time: arr[extIdx].time, turn: true });
        dir = dir > 0 ? -1 : 1;
        startIdx = extIdx;
        extCh = c;
        extIdx = i;
      }
    }
    segs.push({
      startPx: pxAtTime(arr[startIdx].time),
      endPx: pxAtTime(arr[arr.length - 1].time),
      dir,
    });
    bounds.push({ chain: arr[arr.length - 1].chain, time: arr[arr.length - 1].time, turn: false });
    // 전환(턴) 지점은 실제 위치를 10m 단위로, 시·종점 등 기본 라벨은 100m 단위로 표시.
    const labels: KmLabel[] = bounds.map((b) => ({
      px: pxAtTime(b.time),
      text: b.turn ? formatMileage10(b.chain) : formatMileage(Math.round(b.chain / 100) * 100),
    }));
    return { barSegments: segs, kmLabels: labels };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [ready, duration, storeFrames, pxAtTime]);

  // viewedRef의 km(최근접 측점)가 주어진 mileage(m)에 가장 가까운 프레임 시간 → px.
  const pxAtMileage = useCallback(
    (mileage: number): number | null => {
      const arr = viewedRef.current;
      if (!arr.length) return null;
      let best = -1;
      let bd = Infinity;
      for (let i = 0; i < arr.length; i++) {
        const d = Math.abs(arr[i].km - mileage);
        if (d < bd) {
          bd = d;
          best = i;
        }
      }
      return best >= 0 ? pxAtTime(arr[best].time) : null;
    },
    [pxAtTime],
  );

  // 구조물(교량/터널/역사) 위치 = 실제 좌표(POI 위경도)에 드론 GPS 가 가장 가까워지는
  // 프레임의 시점 px. → 영상에 구조물이 실제 지나가는 순간과 일치.
  // route.json structures 의 이정값은 매칭되는 POI 가 없을 때만 폴백으로 사용.
  const structureMarks = useMemo<StructMark[]>(() => {
    const arr = viewedRef.current;
    const frames = storeFrames;
    if (!ready || !arr.length || duration <= 0 || !frames.length) return [];

    // 실좌표(lat/lon)에 드론이 가까워지는 '모든 통과 구간'의 px 목록.
    // (드론이 같은 구조물을 2번 이상 지날 때 각 통과마다 마커를 찍기 위함)
    const pxPassesTo = (lat: number, lon: number, offsetM = 0): { px: number; km: number }[] => {
      const cosLat = Math.cos((lat * Math.PI) / 180);
      const ds: number[] = new Array(frames.length);
      let gmin = Infinity;
      for (let i = 0; i < frames.length; i++) {
        const dy = (frames[i].lat - lat) * 111000;
        const dx = (frames[i].lon - lon) * 111000 * cosLat;
        const d = Math.hypot(dx, dy); // m
        ds[i] = d;
        if (d < gmin) gmin = d;
      }
      if (!isFinite(gmin)) return [];
      const TH = Math.max(100, gmin * 3); // 통과 인정 거리(m)
      // 통과 프레임에서 진행방향으로 offsetM 만큼 이동(GPS 누적거리 기준).
      const shift = (idx: number): number => {
        if (!offsetM) return idx;
        const step = offsetM >= 0 ? 1 : -1;
        const target = Math.abs(offsetM);
        let fi = idx, acc = 0;
        while (acc < target) {
          const n = fi + step;
          if (n < 0 || n >= frames.length) break;
          const dy = (frames[n].lat - frames[fi].lat) * 111000;
          const dx = (frames[n].lon - frames[fi].lon) * 111000 * cosLat;
          acc += Math.hypot(dx, dy);
          fi = n;
        }
        return fi;
      };
      const out: { px: number; km: number }[] = [];
      let i = 0;
      while (i < frames.length) {
        if (ds[i] < TH) {
          let j = i, bj = i, bd = ds[i];
          while (j < frames.length && ds[j] < TH) {
            if (ds[j] < bd) { bd = ds[j]; bj = j; }
            j++;
          }
          const fi = shift(bj);
          const px = pxAtTime(frames[fi].frame / videoFps);
          if (px !== null) out.push({ px, km: viewedRef.current[fi]?.chain ?? -1 });
          i = j;
        } else i++;
      }
      return out;
    };

    // 측점값(미터)에 해당하는 모든 통과 지점 px (연속 체이니지 기준).
    const pxPassesAtMileage = (targetM: number): { px: number; km: number }[] => {
      const arr = viewedRef.current;
      if (!arr.length) return [];
      // 측점 인정 범위(m): route.json routeInfo.stationTolerance (폴더별), 기본 20.
      // 너무 크면 인접 통과가 합쳐져 마커가 밀린다 → 멀리 떨어진 통과만 안 잡힐 때 폴더에서 키울 것.
      const TH = routeMeta?.routeInfo?.stationTolerance ?? 20;
      const out: { px: number; km: number }[] = [];
      let i = 0;
      while (i < arr.length) {
        if (Math.abs(arr[i].chain - targetM) < TH) {
          let j = i, bj = i, bd = Math.abs(arr[i].chain - targetM);
          while (j < arr.length && Math.abs(arr[j].chain - targetM) < TH) {
            const d = Math.abs(arr[j].chain - targetM);
            if (d < bd) { bd = d; bj = j; }
            j++;
          }
          const px = pxAtTime(arr[bj].time);
          if (px !== null) out.push({ px, km: targetM });
          i = j;
        } else i++;
      }
      return out;
    };

    // 구조물 후보 POI(교량/터널/역사) base 이름 → 실좌표.
    const cats = new Set(['교량', '터널', '역사']);
    const poiByName = new Map<string, { lat: number; lon: number; category: string }>();
    for (const p of pois) {
      if (!cats.has(p.category)) continue;
      const base = p.title.replace(/\s*[(（].*$/, '').trim();
      if (!poiByName.has(base)) poiByName.set(base, { lat: p.lat, lon: p.lon, category: p.category });
    }

    // 첫·마지막 스테이션(역사)을 트랙 양끝에 고정 표출.
    // 역사 마커 중 최좌측 → 트랙 시작(TRACK_START_PX), 최우측 → 트랙 끝(TRACK_END_PX)으로 스냅.
    // (드론 항로가 역사에서 떨어져 지나가도 첫/끝 역이 바 양끝에 앵커되도록.)
    const snapStationEnds = (marks: StructMark[]): StructMark[] => {
      const st = marks
        .map((m, i) => ({ i, px: m.px }))
        .filter((_, i) => marks[i].category === '역사' || marks[i].category === '역');
      if (st.length < 2) return marks;
      let lo = st[0], hi = st[0];
      for (const x of st) {
        if (x.px < lo.px) lo = x;
        if (x.px > hi.px) hi = x;
      }
      if (lo.i !== hi.i) {
        marks[lo.i] = { ...marks[lo.i], px: TRACK_START_PX };
        // 종점역: 끝(END)에 두되, 미도착폭이 설정돼 있으면 '미도착'으로 표시
        // (영상 진행바·커서는 END−endGapPx 까지만 도달 → 끝의 회색 구간 = 미도착).
        marks[hi.i] = { ...marks[hi.i], px: TRACK_END_PX, unreached: endGapPx > 0 };
      }
      return marks;
    };

    // v2.0: CSV 유래 구조물(storeStructures) 우선. route.json 보정은 geoData 에서 이미 병합됨.
    if (storeStructures && storeStructures.length) {
      const out: StructMark[] = [];
      for (const s of storeStructures) {
        // 구교(06)는 영상 오버레이에 표출 → 스테이션바에서는 제외(교량/터널만).
        if (s.category === '구교') continue;
        const cat = s.type === 'tunnel' ? '터널' : s.type === 'bridge' ? '교량' : '역사';
        const sBase = s.name.replace(/\s*[(（].*$/, '').trim();
        // 이름 매칭 POI 실좌표 우선 → 없으면 route.json 이정값 폴백.
        const match =
          poiByName.get(sBase) ??
          [...poiByName.entries()].find(([k]) => k.includes(sBase) || sBase.includes(k))?.[1];
        // 우선순위: station(측점값) → 직접 좌표 → POI 이름매칭 → 이정값 폴백.
        // 각 통과 지점마다 마커(드론이 2번 지나면 2개). 동명 시설물은 각자 station 으로 구분.
        let passes: { px: number; km: number }[] = [];
        const off = s.offset ?? 0;
        if (s.station != null) {
          const sM = mileageToMeters(s.station);
          if (sM >= 0) passes = pxPassesAtMileage(sM);
        }
        if (!passes.length && s.lat != null && s.lon != null) passes = pxPassesTo(s.lat, s.lon, off);
        if (!passes.length && match) passes = pxPassesTo(match.lat, match.lon, off);
        if (!passes.length && s.startMileage != null && s.endMileage != null) {
          const mid = (s.startMileage + s.endMileage) / 2;
          const px = pxAtMileage(mid);
          if (px !== null) passes = [{ px, km: mid }];
        }
        // 역사 km 라벨 = '역 좌표 자체'의 측점값(드론 최근접 프레임 chain 아님).
        //   역은 바 양끝에 스냅되므로, 시작/끝 커서값(= 역 위치 측점)과 일치시키기 위함.
        const stationKmVal = cat === '역사' && s.lat != null && s.lon != null && stationLine
          ? projectChainage(s.lat, s.lon, stationLine) : null;
        for (const p of passes) out.push({ px: p.px, title: s.name, category: cat, km: stationKmVal ?? p.km, props: s.props });
      }
      return snapStationEnds(out);
    }

    // 폴백: POI category 기반 (실좌표 GPS 근접, 모든 통과).
    const out: StructMark[] = [];
    for (const [base, info] of poiByName) {
      const stationKmVal = info.category === '역사' && stationLine
        ? projectChainage(info.lat, info.lon, stationLine) : null;
      for (const p of pxPassesTo(info.lat, info.lon)) {
        out.push({ px: p.px, title: base, category: info.category, km: stationKmVal ?? p.km });
      }
    }
    return snapStationEnds(out);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [ready, duration, storeFrames, pois, stations, pxAtTime, routeMeta, storeStructures, pxAtMileage, endGapPx, stationLine, videoFps]);

  // 방향 색 트랙 gradient 빌더(전진/후진 색을 받아 구성). 구간 경계는 부드럽게 섞는다.
  const buildGradient = useCallback(
    (FWD: string, BWD: string): string => {
      const segs = barSegments;
      if (!segs.length) return '';
      const col = (d: 1 | -1) => (d > 0 ? FWD : BWD);
      const pct = (px: number) =>
        clamp(((px - TRACK_START_PX) / TRACK_WIDTH_PX) * 100, 0, 100);
      // 전환점에서 색이 '한번에' 바뀌도록 하드 스톱(같은 위치에 두 색).
      const stops: string[] = [`${col(segs[0].dir)} 0%`];
      for (let i = 1; i < segs.length; i++) {
        const bp = pct(segs[i].startPx).toFixed(2);
        stops.push(`${col(segs[i - 1].dir)} ${bp}%`);
        stops.push(`${col(segs[i].dir)} ${bp}%`);
      }
      stops.push(`${col(segs[segs.length - 1].dir)} 100%`);
      return `linear-gradient(to right, ${stops.join(', ')})`;
    },
    [barSegments, duration],
  );
  // 통과 구간 음영 그라데이션(videoplayer 원본): 구간마다 좌→우 3색 셰이딩, 구간 경계는 하드.
  //  전진 주황: #ffc257 → #ff8a25 → #ff7b1b / 역방향 청록: #5ca887 → #35a7a7 → #06a4c8
  const buildShaded = useCallback(
    (fwd: [string, string, string], bwd: [string, string, string]): string => {
      const segs = barSegments;
      if (!segs.length) return '';
      const cols = (d: 1 | -1) => (d > 0 ? fwd : bwd);
      const pct = (px: number) =>
        clamp(((px - TRACK_START_PX) / TRACK_WIDTH_PX) * 100, 0, 100);
      const stops: string[] = [];
      for (const s of segs) {
        const a = pct(s.startPx);
        const b = pct(s.endPx);
        const c = cols(s.dir);
        stops.push(`${c[0]} ${a.toFixed(2)}%`);
        stops.push(`${c[1]} ${((a + b) / 2).toFixed(2)}%`);
        stops.push(`${c[2]} ${b.toFixed(2)}%`);
      }
      return `linear-gradient(to right, ${stops.join(', ')})`;
    },
    [barSegments],
  );
  // 통과(지나간): 구간별 음영 그라데이션. 미통과: 단색(전진 회색 / 역방향 청회색).
  const trackGradient = useMemo(
    () => buildShaded(['#ffc257', '#ff8a25', '#ff7b1b'], ['#5ca887', '#35a7a7', '#06a4c8']),
    [buildShaded],
  );
  const trackGradientIdle = useMemo(() => buildGradient('#7a7a7a', '#637789'), [buildGradient]);
  // 방향(색)이 바뀌는 전환점 px — 구분선 위치.
  const dividers = useMemo(() => barSegments.slice(1).map((s) => s.startPx), [barSegments]);

  // 현재 위치가 역방향(km 감소, 하늘색) 구간이면 커서도 파란색.
  const currentReverse = useMemo<boolean>(() => {
    for (const s of barSegments) {
      if (cursorPx >= s.startPx && cursorPx <= s.endPx) return s.dir < 0;
    }
    return false;
  }, [barSegments, cursorPx]);

  // 컨테이너 폭 기준 균등 스케일
  useEffect(() => {
    const el = wrapRef.current;
    if (!el) return;
    const update = (): void =>
      setScale((el.clientWidth || STAGE_WIDTH) / STAGE_WIDTH);
    update();
    const ro = new ResizeObserver(update);
    ro.observe(el);
    return () => ro.disconnect();
  }, []);

  // 구조물 아이콘(3-slice PNG) 상태별 이미지를 미리 로드해, passed 전환 시
  // 새 이미지 로딩으로 인한 깜빡임(잠시 사라짐)을 방지한다.
  useEffect(() => {
    const base = import.meta.env.BASE_URL;
    for (const type of ['bridge', 'tunnel']) {
      for (const st of ['upcoming', 'passed', 'revisit']) {
        for (const slice of ['left', 'center', 'right']) {
          const img = new Image();
          img.src = `${base}assets/route-segment/${type}/${type}-${st}-${slice}.png`;
        }
      }
    }
    for (const st of ['upcoming', 'passed', 'revisit']) {
      const img = new Image();
      img.src = `${base}assets/route-segment/terminal/circle-${st}.png`;
    }
  }, []);

  // 커서/진행바 매끄러운 이동: 라이브 시간(timeRef)을 매 프레임 읽어 CSS 변수만 직접 갱신.
  // React 리렌더(배지/색)는 currentTime(throttle)으로 별도 처리 → 4K 디코딩 중에도 부드러움.
  useEffect(() => {
    if (!timeRef) return;
    let raf = 0;
    const tick = (): void => {
      const el = wrapRef.current;
      if (el && duration > 0) {
        const t = timeRef.current ?? 0;
        const pos = TRACK_START_PX + clamp(t / duration, 0, 1) * timeTrackWidth;
        el.style.setProperty('--pos-px', `${pos}px`);
        el.style.setProperty('--cursor-x', `${renderX(pos)}px`);
      }
      raf = requestAnimationFrame(tick);
    };
    raf = requestAnimationFrame(tick);
    return () => cancelAnimationFrame(raf);
  }, [timeRef, duration, timeTrackWidth]);

  // 클릭/드래그 트랙 px → 시간(프레임) 선형 변환으로 seek (시간축 일관).
  const seekToTrackX = useCallback(
    (trackX: number) => {
      if (duration <= 0) return;
      const px = clamp(trackX, TRACK_START_PX, TRACK_END_PX);
      // 미도착 구간(END−endGapPx 우측)을 클릭하면 영상 끝(duration)으로 클램프.
      const targetTime = ((px - TRACK_START_PX) / timeTrackWidth) * duration;
      onSeek(clamp(targetTime, 0, duration));
    },
    [duration, onSeek, timeTrackWidth],
  );

  const seekFromClientX = useCallback(
    (clientX: number) => {
      const stage = stageRef.current;
      if (!stage) return;
      const rect = stage.getBoundingClientRect();
      const stageX = ((clientX - rect.left) / rect.width) * STAGE_WIDTH;
      seekToTrackX(trackXFromRender(stageX));
    },
    [seekToTrackX],
  );

  useEffect(() => {
    const move = (e: globalThis.MouseEvent): void => {
      if (draggingRef.current) seekFromClientX(e.clientX);
    };
    const up = (): void => {
      draggingRef.current = false;
    };
    window.addEventListener('mousemove', move);
    window.addEventListener('mouseup', up);
    return () => {
      window.removeEventListener('mousemove', move);
      window.removeEventListener('mouseup', up);
    };
  }, [seekFromClientX]);

  const handleSeekDown = useCallback(
    (e: ReactMouseEvent<HTMLDivElement>) => {
      draggingRef.current = true;
      seekFromClientX(e.clientX);
    },
    [seekFromClientX],
  );

  // 터미널 역명: route.json routeInfo.startStationName/endStationName 값. 없으면 측점 첫/끝 폴백.
  const { startStationName, endStationName } = useMemo(() => {
    const sorted = [...stations].sort(
      (a, b) => stationKm(a.title) - stationKm(b.title),
    );
    const firstTitle = sorted.length ? sorted[0].title : '';
    const lastTitle = sorted.length ? sorted[sorted.length - 1].title : '';
    return {
      startStationName: routeMeta?.routeInfo?.startStationName ?? firstTitle,
      endStationName: routeMeta?.routeInfo?.endStationName ?? lastTitle,
    };
  }, [stations, routeMeta]);

  // 측점입력(예: 158k200) → 그 측점을 보는(최근접) 프레임으로 seek (실데이터 기반).
  // 해당 영상이 커버하는 측점(체이니지) 범위 밖이면 무시(seek 안 함).
  const handleJumpToMileage = useCallback(
    (km: number) => {
      const arr = viewedRef.current;
      if (!arr.length || duration <= 0) return;
      // 연속 체이니지(chain) 기준으로 입력 측점에 가장 가까운 프레임 탐색 + 커버 범위 산출.
      let best = -1;
      let bd = Infinity;
      let lo = Infinity;
      let hi = -Infinity;
      for (let i = 0; i < arr.length; i++) {
        const c = arr[i].chain;
        if (c < lo) lo = c;
        if (c > hi) hi = c;
        const d = Math.abs(c - km);
        if (d < bd) {
          bd = d;
          best = i;
        }
      }
      // 구간 밖(커버 측점 범위 ± 여유 20m)이면 무시.
      const MARGIN = 20;
      if (km < lo - MARGIN || km > hi + MARGIN) return;
      if (best >= 0) onSeek(clamp(arr[best].time, 0, duration));
    },
    [duration, onSeek],
  );

  return (
    <div
      ref={wrapRef}
      className={styles.wrap}
      style={cssVars({ '--bar-scale': scale })}
    >
      <div ref={stageRef} className={styles.layer}>
        <div className={styles.bottomBar}>
          <PlaybackControls
            playing={playing}
            onTogglePlay={onTogglePlay}
            onStop={onStop}
            onCapture={onCapture}
            onJumpToMileage={handleJumpToMileage}
            lineOn={showStations}
            onToggleLine={onToggleStations}
          />
          <Timeline
            posPx={cursorPx}
            onSeekDown={handleSeekDown}
            trackGradient={trackGradient}
            trackGradientIdle={trackGradientIdle}
            dividers={dividers}
            kmLabels={kmLabels}
            structures={structureMarks}
            startStationName={startStationName}
            endStationName={endStationName}
            endGapPx={endGapPx}
            routeDir={
              routeMeta?.routeInfo?.direction?.includes('하') ? '하'
                : routeMeta?.routeInfo?.direction?.includes('상') ? '상'
                : null
            }
          />
        </div>
        <TimelineCursor
          mileageText={cursorText}
          reverse={currentReverse}
          onSeekDown={handleSeekDown}
        />
      </div>
    </div>
  );
}
