import React, { useRef, useEffect, useLayoutEffect, useState, useCallback, memo } from 'react';
import type { MouseEventParams, Time } from 'lightweight-charts';
import type { OHLCVBar, ChartType, Theme, IndicatorConfig, LegendData, Drawing, ChartMode, Timeframe } from '../types';
import { ChartManager } from '../utils/ChartManager';
import { formatUpbitKrwPrice } from '../utils/safeFormat';
import { useChartTimeFormat } from '../utils/chartTimeFormat';
import { setIndicatorChartContext } from '../utils/indicatorRegistry';
import { DISPLAY_COUNT } from '../hooks/useUpbitData';

/** Upbit 실시간 — 초기 히스토리가 이 개수 미만이면 setData 하지 않음 (종목 전환 직후 sparse 봉 → 캔들 폭 비정상) */
const MIN_BARS_FOR_FULL_RELOAD = Math.min(50, Math.floor(DISPLAY_COUNT / 4));

/** pane 비율·가격축 계산에 쓸 수 있는 최소 래퍼 높이 (원격·모바일 레이아웃 지연 대응) */
const MIN_VALID_LAYOUT_HEIGHT = 80;
const LAYOUT_RETRY_MAX = 30;

function createIndicatorReloadCover(containerEl: HTMLDivElement | null): HTMLDivElement | null {
  if (!containerEl?.parentElement) return null;
  const cover = document.createElement('div');
  cover.className = 'chart-loading chart-indicator-reload-cover';
  const spinner = document.createElement('div');
  spinner.className = 'loading-spinner';
  spinner.setAttribute('role', 'status');
  spinner.setAttribute('aria-label', '지표 로딩 중');
  cover.appendChild(spinner);
  containerEl.parentElement.appendChild(cover);
  return cover;
}

function fadeOutIndicatorReloadCover(cover: HTMLDivElement | null): void {
  if (!cover) return;
  requestAnimationFrame(() => requestAnimationFrame(() => {
    cover.style.transition = 'opacity 0.25s ease';
    cover.style.opacity = '0';
    setTimeout(() => { cover.remove(); }, 300);
  }));
}

/** 종목 전환 직후 stale/partial 데이터 차단 — barsMarket 확정 후 렌더 허용 */
function canApplyChartBars(
  barData: OHLCVBar[],
  barsMarket: string | null | undefined,
  market: string,
): boolean {
  if (barData.length < 2) return false;
  if (barsMarket === undefined) return true;
  if (barsMarket == null || barsMarket !== market) return false;
  if (!market.startsWith('KRW-')) return true;
  if (barData.length >= MIN_BARS_FOR_FULL_RELOAD) return true;
  return true;
}
import DrawingCanvas, { hitTestDrawing } from './DrawingCanvas';
import PaneLegend, { type PaneLegendProps } from './PaneLegend';
import ChartRightToolbar, { type CandleOverlayToggleItem } from './ChartRightToolbar';
import CandlePaneTimeAxis from './CandlePaneTimeAxis';
import ChartHoverToolbar from './ChartHoverToolbar';
import ChartMagnifier from './ChartMagnifier';
import ChartContextMenu from './ChartContextMenu';
import CandlePaneControls from './CandlePaneControls';
import IndicatorPaneControls from './IndicatorPaneControls';
import { getKoreanName } from '../utils/marketNameCache';
import { DEFAULT_DISPLAY_TIMEZONE } from '../utils/timezone';
import { chartHasStaleIndicators, classifyIndicatorChartChange, singleIndParamKey } from '../utils/indicatorChartSync';
import { sortIndicatorsForPaneLoad } from '../utils/indicatorPaneMerge';
import type { ChartPaneSeparatorOptions } from '../types/chartPaneSeparator';
import PaneSeparatorOverlay from './PaneSeparatorOverlay';
import TradeSignalLabelOverlay from './TradeSignalLabelOverlay';
import { centerChartOnSignalBar } from '../utils/tradeSignalChartMarkers';

interface TradingChartProps {
  bars:              OHLCVBar[];
  /** bars 가 현재 market 과 일치할 때만 전달 (없으면 bars.length>0 이면 준비된 것으로 간주) */
  barsMarket?:       string | null;
  /** BB 등 다른 심볼 계산용 */
  market?:           string;
  timeframe?:        Timeframe;
  chartType:         ChartType;
  theme:             Theme;
  mode:              ChartMode;
  indicators:        IndicatorConfig[];
  drawingTool:       string;
  drawings:          Drawing[];
  logScale:          boolean;
  drawingsLocked?:   boolean;
  drawingsVisible?:  boolean;
  onCrosshair:       (data: LegendData | null) => void;
  onManagerReady:    (mgr: ChartManager) => void;
  onAddDrawing:      (d: Drawing) => void;
  /** 시리즈 단일 클릭 → (indicatorId | '__main__' | null, 패인 좌측상단 screen 좌표) */
  onSeriesClick?:    (entryId: string | null, point: { x: number; y: number }) => void;
  /** 시리즈 더블 클릭 → 설정 모달 직접 오픈용 (패인 좌측상단 screen 좌표 포함) */
  onSeriesDoubleClick?: (entryId: string | null, point: { x: number; y: number }) => void;
  /** pane 레전드 레이블 호버 */
  onHoverPaneLegend?:  (id: string, sx: number, sy: number) => void;
  /** pane 레전드 레이블 이탈 */
  onLeavePaneLegend?:  () => void;
  /** 멀티차트 전체 보기: 이 차트를 단일 모드로 확장 (있으면 hover toolbar 버튼에 연결) */
  onFullView?:         () => void;
  /** 현재 선택된 드로잉 ID (핸들 렌더용) */
  selectedDrawingId?:  string | null;
  /** cursor 모드에서 드로잉 단일 클릭 */
  onDrawingClick?:     (id: string | null, screenX: number, screenY: number) => void;
  /** cursor 모드에서 드로잉 더블 클릭 */
  onDrawingDoubleClick?: (id: string, screenX: number, screenY: number) => void;
  /** 돋보기 활성 여부 */
  magnifierEnabled?: boolean;
  /** 돋보기 닫기 콜백 */
  onMagnifierClose?: () => void;
  /**
   * 데이터 로드 완료 콜백 — reloadAll 이 setInitialVisibleRange 까지 마친 뒤 호출.
   * ChartSlot 에서 멀티차트 첫 마운트 시 sync range 를 재적용하는 데 사용.
   */
  onDataLoaded?: () => void;
  /** setData 직후 캔들·볼륨 반영 완료 — 실시간 틱 허용 시점 (지표 로드 전) */
  onCandlesReady?: () => void;
  /** 보조지표 pane 순서 변경 (드래그 핸들): fromId 를 insertBeforeId 앞으로 이동 (null = 맨 뒤) */
  onReorderIndicators?: (fromId: string, insertBeforeId: string | null) => void;
  /** 보조지표 pane 병합 */
  onMergeIndicators?:   (fromId: string, intoId: string) => void;
  /** 병합 pane 분리 */
  onSplitIndicatorPane?: (hostId: string) => void;
  /** 지표 단독 전체화면 확장 */
  onExpandIndicator?:   (id: string) => void;
  /** 지표 제거 (X 버튼) */
  onRemoveIndicator?:   (id: string) => void;
  /** 보조지표 pane 복사 */
  onDuplicateIndicator?: (id: string) => void;
  /** 현재 단독 전체화면 중인 지표 id */
  focusedIndicatorId?:  string | null;
  /** 전체화면 → 전체 보기 복원 */
  onRestoreIndicators?: () => void;
  /** 지표 표시/숨기기 */
  onToggleIndicatorHidden?: (id: string) => void;
  /** 지표 설정 모달 열기 */
  onOpenIndicatorSettings?: (id: string) => void;
  /** 우클릭 메뉴에서 매수·매도 선택 시 */
  onTradeOrderRequest?: (req: { market: string; price: number; side: 'buy' | 'sell' }) => void;
  /** 표시 시간대 (IANA) */
  displayTimezone?: string;
  /** 거래량 pane 표시 (기본 true) */
  volumeVisible?: boolean;
  /** 하단 호버 줌·스크롤 툴바 (기본 true) */
  showHoverToolbar?: boolean;
  /** 캔들 영역 오버레이 지표 가격축 라벨·설명 (기본 true) */
  candleAreaPriceLabelsEnabled?: boolean;
  /** 하단 보조지표 pane 가격축 라벨·설명 (기본 true) */
  indicatorAreaPriceLabelsEnabled?: boolean;
  /** @deprecated candleArea + indicatorArea 동일 값 */
  seriesPriceLabelsEnabled?: boolean;
  /** 실시간 차트 화면 표시 여부 — false 이면 숨김 중 무거운 재로드 지연 */
  chartVisible?: boolean;
  /** pane(캔들·거래량·보조지표) 구분선 */
  paneSeparatorOptions?: ChartPaneSeparatorOptions;
  /** true: pane 합산 높이로 chart-container 를 키우지 않고 래퍼 높이에 맞춤 (미니 차트·알림 목록) */
  paneLayoutClamp?: boolean;
  /** paneLayoutClamp=true 환경에서도 캔들/보조지표 비율 드래그 핸들을 표시 (백테스팅 차트 등) */
  showPaneResizeHandle?: boolean;
  /** 캔들+거래량 vs 보조 pane 높이 비율 (aux 0 이면 미적용) */
  paneAreaRatio?: { candle: number; aux: number } | null;
  /** 보조지표 pane 레전드(이름·값 오버레이) 표시 (기본 true) */
  showPaneLegend?: boolean;
  /** pane 레전드와 별도 — 우측 드래그·설정 툴바 (미지정 시 showPaneLegend 와 동일) */
  showChartRightToolbar?: boolean;
  /** 캔들 pane 우측 상단 — 이동평균·일목·볼린저 on/off (미니 차트 등, showChartRightToolbar 없이도 가능) */
  showCandleOverlayControls?: boolean;
  /** 크로스헤어 이동 시 OHLC·보조지표 수치 표시 (기본 true) */
  crosshairInfoVisible?: boolean;
  /** 외부 캔들 데이터 fetch 중 (실시간 차트 초기 로딩) */
  dataLoading?: boolean;
  /** chartPaintReady 변경 알림 (워크스페이스 전체 로딩 오버레이용) */
  onChartPaintReadyChange?: (ready: boolean) => void;
  /** 마운트 시 캔들 pane 전체보기(하단 보조지표 pane 숨김) — 가상매매 카드 등 */
  defaultCandleOnly?: boolean;
  /** 캔들 pane 좌하단 전체보기/복원 버튼 (기본 true) */
  showCandlePaneControls?: boolean;
  /** 캔들 오버레이(이동평균·일목·볼린저) 표시 토글 — 우측 툴바 상단 */
  candleOverlayToggles?: CandleOverlayToggleItem[];
  onToggleCandleOverlay?: (key: import('../utils/chartOverlayVisibility').ChartOverlayToggleKey) => void;
  customOverlayActive?: boolean;
  onCustomOverlayActiveChange?: (active: boolean) => void;
  onOpenCustomOverlaySettings?: () => void;
  custom1OverlayActive?: boolean;
  onCustom1OverlayActiveChange?: (active: boolean) => void;
  onOpenCustom1OverlaySettings?: () => void;
  /** 9·20일 신고가·신저가 라벨 표시 (실시간 차트 우측 툴바) */
  priceExtremeLabelsVisible?: boolean;
  onTogglePriceExtremeLabels?: () => void;
  /** 미니 차트 — plot 너비에 맞는 초기 x축 캔들 수 (미지정 시 DISPLAY_COUNT) */
  resolveInitialDisplayCount?: (plotWidth: number) => number;
  /** true — reload·layout 복구 타이머의 반복 viewport 재조정 생략 (미니 카드 깜빡임 방지) */
  skipViewportRecovery?: boolean;
  /** true — 캔들 숨김, 보조지표 pane 선형 그래프만 표시 (시그널 상세 하단 차트 등) */
  auxIndicatorOnly?: boolean;
  /** 지정 시 초기 visible range 대신 시그널 봉 시간축 중앙 정렬 */
  centerOnSignalTime?: number;
  /** centerOnSignalTime 사용 시 화면에 보일 봉 수 (미지정 시 전체) */
  centerVisibleBarCount?: number;
}

const TradingChart: React.FC<TradingChartProps> = ({
  bars, barsMarket, market = '', timeframe = '1D', chartType, theme, mode, indicators, drawingTool, drawings,
  logScale, drawingsLocked = false, drawingsVisible = true,
  onCrosshair, onManagerReady, onAddDrawing,
  onSeriesClick, onSeriesDoubleClick,
  onHoverPaneLegend, onLeavePaneLegend,
  onFullView,
  selectedDrawingId,
  onDrawingClick,
  onDrawingDoubleClick,
  magnifierEnabled = false,
  onMagnifierClose,
  onDataLoaded,
  onCandlesReady,
  onReorderIndicators,
  onMergeIndicators,
  onSplitIndicatorPane,
  onExpandIndicator,
  onRemoveIndicator,
  onDuplicateIndicator,
  focusedIndicatorId,
  onRestoreIndicators,
  onToggleIndicatorHidden,
  onOpenIndicatorSettings,
  onTradeOrderRequest,
  displayTimezone = DEFAULT_DISPLAY_TIMEZONE,
  volumeVisible = true,
  showHoverToolbar = true,
  candleAreaPriceLabelsEnabled,
  indicatorAreaPriceLabelsEnabled,
  seriesPriceLabelsEnabled,
  chartVisible = true,
  paneSeparatorOptions,
  paneLayoutClamp = false,
  showPaneResizeHandle = false,
  paneAreaRatio = null,
  showPaneLegend = true,
  showChartRightToolbar,
  showCandleOverlayControls = false,
  crosshairInfoVisible = true,
  dataLoading = false,
  onChartPaintReadyChange,
  defaultCandleOnly = false,
  showCandlePaneControls = true,
  candleOverlayToggles,
  onToggleCandleOverlay,
  customOverlayActive,
  onCustomOverlayActiveChange,
  onOpenCustomOverlaySettings,
  custom1OverlayActive,
  onCustom1OverlayActiveChange,
  onOpenCustom1OverlaySettings,
  priceExtremeLabelsVisible,
  onTogglePriceExtremeLabels,
  resolveInitialDisplayCount,
  skipViewportRecovery = false,
  auxIndicatorOnly = false,
  centerOnSignalTime,
  centerVisibleBarCount,
}) => {
  const effectiveShowChartRightToolbar = showChartRightToolbar ?? showPaneLegend;
  const showOverlayControlsOnly = !!(
    showCandleOverlayControls
    && candleOverlayToggles?.length
    && onToggleCandleOverlay
    && !effectiveShowChartRightToolbar
  );
  const containerRef  = useRef<HTMLDivElement>(null);
  const wrapperRef    = useRef<HTMLDivElement>(null);   // 스크롤 래퍼
  const managerRef    = useRef<ChartManager | null>(null);
  const barsRef       = useRef<OHLCVBar[]>([]);
  // 드로잉 최신 참조 (캡처 리스너에서 closure 없이 접근)
  const drawingsRef         = useRef<Drawing[]>(drawings);
  const drawingToolRef      = useRef<string>(drawingTool);
  const drawingsVisibleRef  = useRef<boolean>(drawingsVisible);
  const drawingsLockedRef   = useRef<boolean>(drawingsLocked);
  const modeRef             = useRef<ChartMode>(mode);
  const onDrawingClickRef      = useRef(onDrawingClick);
  const onDrawingDoubleClickRef = useRef(onDrawingDoubleClick);

  const canPanRef = useRef(false);
  const panStateRef = useRef({
    active: false,
    lastX: 0,
    lastY: 0,
    moved: false,
    /** 0=캔들 pane — 세로 패닝 허용 */
    originPaneIndex: 0,
  });
  const suppressClickRef = useRef(false);
  const seriesDblSuppressRef = useRef(false);
  const toggleCandleOnlyRef = useRef<() => void>(() => {});
  const candleOnlyModeRef = useRef(false);
  const paneAreaRatioRef = useRef(paneAreaRatio);
  paneAreaRatioRef.current = paneAreaRatio;
  const paneLayoutClampRef = useRef(paneLayoutClamp);
  paneLayoutClampRef.current = paneLayoutClamp;
  /** 사용자 드래그로 설정한 pane 비율 — prop 변경 시 초기화 */
  const userDraggedRatioRef = useRef<{ candle: number; aux: number } | null>(null);
  const lastCrosshairPriceRef = useRef<number | null>(null);
  const onTradeOrderRequestRef = useRef(onTradeOrderRequest);
  onTradeOrderRequestRef.current = onTradeOrderRequest;
  const onSeriesDoubleClickRef = useRef(onSeriesDoubleClick);
  onSeriesDoubleClickRef.current = onSeriesDoubleClick;
  const marketRef = useRef(market);
  marketRef.current = market;
  const barsMarketRef = useRef(barsMarket);
  barsMarketRef.current = barsMarket;

  const [ctxMenu, setCtxMenu] = useState<{
    x: number; y: number; price: number;
  } | null>(null);

  // 드로잉 더블클릭 감지용
  const lastDrawingClickRef = useRef({ id: '', time: 0 });
  const drawingSingleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);

  // 직전 값 추적 (불필요한 재실행 방지)
  const prevBarsKey     = useRef<string>('');
  const prevIndKey      = useRef<string>('');
  const prevSortedPKRef = useRef<string>('');   // 순서 무관 paramKey (reorder 감지용)
  const prevIndicatorsListRef = useRef<IndicatorConfig[]>(indicators);
  const indicatorSyncInFlightRef = useRef(false);
  const [paneLegendPaused, setPaneLegendPaused] = useState(false);
  const [chartBodyHeight, setChartBodyHeight] = useState(0);
  /** 캔들 pane 하단 Y 위치 (드래그 핸들용) — null 이면 보조지표 없음 */
  const [candlePaneBottomY, setCandlePaneBottomY] = useState<number | null>(null);
  const paneResizeDragRef = useRef<{
    startClientY: number;
    startCandleH: number;
    wrapperH: number;
  } | null>(null);
  const pendingIndicatorResyncRef = useRef(false);
  const indicatorReloadGenRef = useRef(0);
  const prevMarket      = useRef<string>(market);
  const prevMarketTf    = useRef<Timeframe>(timeframe);
  const lastAppliedMarketRef = useRef<string>('');
  const prevChartType   = useRef<ChartType>(chartType);
  const prevTheme       = useRef<Theme>(theme);
  const prevLogScale    = useRef<boolean>(logScale);
  const indicatorsRef   = useRef(indicators);
  const recoveryTimerRef = useRef<number | null>(null);
  const resizeDebounceRef = useRef<number | null>(null);
  const chartVisibleRef = useRef(chartVisible);
  const resolveInitialDisplayCountRef = useRef(resolveInitialDisplayCount);
  const skipViewportRecoveryRef = useRef(skipViewportRecovery);
  const auxIndicatorOnlyRef = useRef(auxIndicatorOnly);
  const centerOnSignalTimeRef = useRef(centerOnSignalTime);
  const centerVisibleBarCountRef = useRef(centerVisibleBarCount);
  const lastViewportFitRef = useRef<{ count: number; width: number } | null>(null);

  useEffect(() => {
    resolveInitialDisplayCountRef.current = resolveInitialDisplayCount;
  }, [resolveInitialDisplayCount]);

  useEffect(() => {
    skipViewportRecoveryRef.current = skipViewportRecovery;
  }, [skipViewportRecovery]);

  useEffect(() => {
    auxIndicatorOnlyRef.current = auxIndicatorOnly;
  }, [auxIndicatorOnly]);

  useEffect(() => {
    centerOnSignalTimeRef.current = centerOnSignalTime;
  }, [centerOnSignalTime]);

  useEffect(() => {
    centerVisibleBarCountRef.current = centerVisibleBarCount;
  }, [centerVisibleBarCount]);

  const applySnapshotViewport = useCallback((mgr: ChartManager) => {
    const t = centerOnSignalTimeRef.current;
    if (t != null && Number.isFinite(t)) {
      centerChartOnSignalBar(mgr, t, centerVisibleBarCountRef.current);
    } else {
      mgr.fitContent();
    }
  }, []);

  const chartTimeFormat = useChartTimeFormat();
  const [chartMgr, setChartMgr] = useState<ChartManager | null>(null);
  /** 초기 reloadAll 완료 전 — pane 구분선·부분 레이아웃 노출 방지 */
  const [chartPaintReady, setChartPaintReady] = useState(false);
  /** 캔들 pane 전체보기 (오버레이 지표 유지, 하단 보조지표 pane 숨김) */
  const candleOnlyLocked = defaultCandleOnly;
  const [candleOnlyMode, setCandleOnlyMode] = useState(defaultCandleOnly);
  const paneDragRef = useRef<((id: string, e: React.PointerEvent) => void) | null>(null);

  useEffect(() => {
    if (barsMarket === market) {
      barsRef.current = bars;
    } else {
      barsRef.current = [];
    }
  }, [bars, barsMarket, market]);

  useEffect(() => {
    indicatorsRef.current = indicators;
  }, [indicators]);

  useEffect(() => {
    chartVisibleRef.current = chartVisible;
  }, [chartVisible]);

  useEffect(() => {
    if (dataLoading) setChartPaintReady(false);
  }, [dataLoading]);

  useEffect(() => {
    onChartPaintReadyChange?.(chartPaintReady && !dataLoading);
  }, [chartPaintReady, dataLoading, onChartPaintReadyChange]);

  useEffect(() => {
    const el = containerRef.current;
    if (!el) return;
    const update = () => setChartBodyHeight(el.clientHeight);
    update();
    const ro = new ResizeObserver(update);
    ro.observe(el);
    return () => ro.disconnect();
  }, [chartMgr]);

  /** 캔들 pane 하단 Y 추적 — 보조지표 있을 때만 드래그 핸들 표시 */
  useEffect(() => {
    const mgr = managerRef.current;
    if (!mgr || !chartMgr) return;

    const updateHandle = () => {
      const layouts = mgr.getPaneLayouts();
      const candle = layouts.find(l => l.paneIndex === 0);
      const hasAux = layouts.some(l => l.paneIndex >= 2 && l.height > 20);
      if (candle && candle.height > 20 && hasAux) {
        setCandlePaneBottomY(candle.topY + candle.height);
      } else {
        setCandlePaneBottomY(null);
      }
    };

    updateHandle();
    return mgr.subscribePaneLayout(updateHandle);
  }, [chartMgr]);

  /** 캔들/보조지표 영역 분리선 드래그 핸들러 */
  const onPaneResizePointerDown = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
    const mgr = managerRef.current;
    const wrapper = wrapperRef.current;
    if (!mgr || !wrapper) return;
    e.preventDefault();
    e.stopPropagation();
    (e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId);
    const layouts = mgr.getPaneLayouts();
    const candle = layouts.find(l => l.paneIndex === 0);
    paneResizeDragRef.current = {
      startClientY: e.clientY,
      startCandleH: candle?.height ?? 200,
      wrapperH: wrapper.clientHeight,
    };
  }, []);

  const onPaneResizePointerMove = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
    const drag = paneResizeDragRef.current;
    const mgr = managerRef.current;
    const wrapper = wrapperRef.current;
    const container = containerRef.current;
    if (!drag || !mgr || !wrapper || !container) return;

    const dy = e.clientY - drag.startClientY;
    const H = drag.wrapperH;
    const MIN_CANDLE = 100;
    const MIN_AUX = 60;
    const newCandleH = Math.max(MIN_CANDLE, Math.min(H - MIN_AUX, drag.startCandleH + dy));
    const newCandle = newCandleH / H;
    const newAux = 1 - newCandle;

    mgr.setPaneAreaRatio(newCandle, newAux);
    // applyPaneLayout 이 ResizeObserver 로 재실행돼도 이 비율 유지
    userDraggedRatioRef.current = { candle: newCandle, aux: newAux };
    const required = mgr.resetPaneHeights(H);

    // paneLayoutClamp 모드: 컨테이너를 래퍼 높이에 고정 (overflow:hidden 으로 클립)
    // 일반 모드: 보조지표 필요 시 컨테이너 확장하여 스크롤 영역 확보
    const newContainerH = paneLayoutClampRef.current
      ? H
      : (required > H + 4 ? required : H);
    if (container.style.height !== `${newContainerH}px`) {
      container.style.height = `${newContainerH}px`;
    }
  }, []);

  const onPaneResizePointerUp = useCallback(() => {
    paneResizeDragRef.current = null;
  }, []);

  const toggleCandleOnly = useCallback(() => {
    if (candleOnlyLocked) return;
    setCandleOnlyMode(v => !v);
  }, [candleOnlyLocked]);

  useEffect(() => { toggleCandleOnlyRef.current = toggleCandleOnly; }, [toggleCandleOnly]);
  useEffect(() => { candleOnlyModeRef.current = candleOnlyMode; }, [candleOnlyMode]);
  const prevCandleOnlyRef = useRef(false);

  // 최신 값을 ref에 동기화 (캡처 리스너 closure 탈출)
  useEffect(() => {
    setIndicatorChartContext(market, timeframe);
  }, [market, timeframe]);

  useEffect(() => { drawingsRef.current          = drawings; },         [drawings]);
  useEffect(() => { drawingToolRef.current        = drawingTool; },     [drawingTool]);
  useEffect(() => { drawingsVisibleRef.current    = drawingsVisible; },  [drawingsVisible]);
  useEffect(() => { drawingsLockedRef.current     = drawingsLocked; },   [drawingsLocked]);
  useEffect(() => { modeRef.current               = mode; },            [mode]);
  useEffect(() => { onDrawingClickRef.current     = onDrawingClick; },  [onDrawingClick]);
  useEffect(() => { onDrawingDoubleClickRef.current = onDrawingDoubleClick; }, [onDrawingDoubleClick]);
  useEffect(() => {
    canPanRef.current = drawingTool === 'cursor';
    const el = containerRef.current;
    if (el && !panStateRef.current.active) {
      el.style.cursor = canPanRef.current ? 'grab' : '';
    }
  }, [mode, drawingTool]);

  const volumeVisibleRef = useRef(volumeVisible);
  useEffect(() => { volumeVisibleRef.current = volumeVisible; }, [volumeVisible]);

  useEffect(() => {
    const mgr = managerRef.current;
    if (!mgr) return;
    const wrapperH = wrapperRef.current?.clientHeight ?? 0;
    mgr.setVolumeVisible(volumeVisible, wrapperH > 0 ? wrapperH : undefined);
  }, [volumeVisible]);

  const resolvedCandlePriceLabels = candleAreaPriceLabelsEnabled ?? seriesPriceLabelsEnabled ?? true;
  const resolvedIndicatorPriceLabels = indicatorAreaPriceLabelsEnabled ?? seriesPriceLabelsEnabled ?? true;

  useEffect(() => {
    managerRef.current?.setCandleAreaPriceLabelsEnabled(resolvedCandlePriceLabels);
  }, [resolvedCandlePriceLabels]);

  useEffect(() => {
    managerRef.current?.setIndicatorAreaPriceLabelsEnabled(resolvedIndicatorPriceLabels);
  }, [resolvedIndicatorPriceLabels]);

  useEffect(() => {
    if (!paneSeparatorOptions) return;
    managerRef.current?.setPaneSeparatorOptions(paneSeparatorOptions);
    managerRef.current?.refreshPaneSeparatorOverlay();
  }, [paneSeparatorOptions]);

  /**
   * pane 높이 재배분 + 스크롤 컨테이너 확장
   *
   * wrapper.clientHeight 를 정확한 가용 높이로 ChartManager 에 전달합니다.
   * CSS Grid 레이아웃 초기화 지연으로 wrapperH=0 이 될 수 있으므로,
   * retryCount 를 통해 최대 30회 자동 재시도합니다 (원격·테더링 등 느린 레이아웃 대응).
   */
  const applyPaneLayout = useCallback((mgr: ChartManager, retryCount = 0) => {
    const wrapper   = wrapperRef.current;
    const container = containerRef.current;
    if (!wrapper || !container) return;

    const wrapperH = wrapper.clientHeight;
    if (wrapperH < MIN_VALID_LAYOUT_HEIGHT) {
      if (retryCount < LAYOUT_RETRY_MAX) {
        const delay = Math.min(120 * (retryCount + 1), 500);
        setTimeout(() => applyPaneLayout(mgr, retryCount + 1), delay);
      }
      return;
    }

    const candleOnly = candleOnlyModeRef.current;
    let required: number;
    if (candleOnly) {
      mgr.applyCandleOnlyLayout(true, wrapperH);
      required = wrapperH;
    } else {
      if (auxIndicatorOnlyRef.current) {
        mgr.applyAuxIndicatorOnlyLayout(true);
      } else if (mgr.isCandleOnlyLayout()) {
        mgr.restoreFromCandleFullscreen(wrapperH);
      }
      // 사용자 드래그 비율 우선, 없으면 prop 기반 비율
      const ratio = userDraggedRatioRef.current ?? paneAreaRatioRef.current;
      if (ratio && ratio.aux > 0) {
        mgr.setPaneAreaRatio(ratio.candle, ratio.aux);
      } else {
        mgr.setPaneAreaRatio(0, 0);
      }
      required = mgr.resetPaneHeights(wrapperH);
    }

    // LWC는 chart-container에 마운트 — 우측 툴바(44px) 제외 너비로 맞춰 가격 라벨이 가리지 않음
    const plotW = Math.max(0, container.clientWidth);

    if (candleOnly || paneLayoutClamp) {
      // 전체보기·미니차트: 래퍼 높이에 고정 resize (autoSize 피드백 루프·무한 스크롤 방지)
      container.style.height = `${wrapperH}px`;
      try {
        if (plotW > 0 && wrapperH > 0) mgr.resize(plotW, wrapperH);
      } catch { /* ok */ }
      if (candleOnly) wrapper.scrollTop = 0;
    } else if (required > wrapperH + 4) {
      // 보조지표 있음: 컨테이너를 required 크기로 확장해 스크롤 영역 확보
      container.style.height = `${required}px`;
    } else {
      // 기본 상태: 항상 명시적 픽셀 높이를 부여 (height:'' → CSS height:100% → auto 해석 문제 방지)
      // autoSize:true LWC가 container.contentRect.height를 읽어 정확히 wrapperH 에 맞게 렌더링
      container.style.height = `${wrapperH}px`;
      try {
        if (plotW > 0 && wrapperH > 0) mgr.resize(plotW, wrapperH);
      } catch { /* ok */ }
    }
  // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [paneLayoutClamp, paneAreaRatio]);

  /** paneAreaRatio prop 이 외부에서 변경되면 드래그 비율 초기화 (전략 변경 등) */
  useEffect(() => {
    userDraggedRatioRef.current = null;
  }, [paneAreaRatio]);

  const getInitialDisplayCount = useCallback((): number => {
    const plotW = containerRef.current?.clientWidth ?? wrapperRef.current?.clientWidth ?? 0;
    const resolver = resolveInitialDisplayCountRef.current;
    return resolver ? resolver(plotW) : DISPLAY_COUNT;
  }, []);

  const applyInitialVisibleRange = useCallback((mgr: ChartManager) => {
    const plotW = containerRef.current?.clientWidth ?? wrapperRef.current?.clientWidth ?? 0;
    if (plotW <= 0) return;
    const count = getInitialDisplayCount();
    const prev = lastViewportFitRef.current;
    if (prev && prev.count === count && Math.abs(prev.width - plotW) < 4) return;
    lastViewportFitRef.current = { count, width: plotW };
    const fb = mgr.hasIchimoku() ? 28 : 0;
    mgr.setInitialVisibleRange(count, fb);
  }, [getInitialDisplayCount]);

  /** pane 레이아웃 + 가격·시간축 정상화 (느린 네트워크·레이아웃 지연 후 복구용) */
  const normalizeChartViewport = useCallback((mgr: ChartManager) => {
    applyPaneLayout(mgr);
    requestAnimationFrame(() => {
      try { mgr.autoScale(); } catch { /* ok */ }
      if (centerOnSignalTimeRef.current != null && Number.isFinite(centerOnSignalTimeRef.current)) {
        applySnapshotViewport(mgr);
      } else {
        applyInitialVisibleRange(mgr);
      }
    });
  }, [applyPaneLayout, applyInitialVisibleRange, applySnapshotViewport]);

  /** 캔들 확대 보기 → 원복 직후 가격·시간축·pane 비율 정상화 */
  useEffect(() => {
    const mgr = managerRef.current;
    if (!mgr || !chartMgr) return;
    const wasOnly = prevCandleOnlyRef.current;
    prevCandleOnlyRef.current = candleOnlyMode;
    if (!wasOnly && candleOnlyMode) {
      applyPaneLayout(mgr);
    } else if (wasOnly && !candleOnlyMode && mgr.hasMainSeries()) {
      const runRestore = () => {
        const m = managerRef.current;
        if (!m?.hasMainSeries()) return;
        const wrapperH = wrapperRef.current?.clientHeight ?? 0;
        m.restoreFromCandleFullscreen(wrapperH);
        applyPaneLayout(m);
        applyInitialVisibleRange(m);
      };
      requestAnimationFrame(() => requestAnimationFrame(runRestore));
      [200, 500, 1000].forEach(delay => setTimeout(runRestore, delay));
    }
  }, [candleOnlyMode, chartMgr, applyPaneLayout, applyInitialVisibleRange]);

  // ── 전체 재로드 (데이터 + 인디케이터) ─────────────────────────────────────
  const reloadSafetyTimers = useRef<ReturnType<typeof setTimeout>[]>([]);
  const reloadGenRef = useRef(0);
  const reloadInFlightRef = useRef(false);
  const reloadCoalesceRef = useRef(false);
  const reloadRetryRef = useRef(0);
  const reloadAllRef = useRef<(
    mgr: ChartManager,
    newBars: OHLCVBar[],
    ct: ChartType,
    th: Theme,
    ls: boolean,
    inds: IndicatorConfig[],
  ) => Promise<void>>(async () => {});

  const syncIndicatorTrackingRefs = useCallback((inds: IndicatorConfig[]) => {
    prevIndKey.current = indKey(inds);
    prevSortedPKRef.current = sortedParamKey(inds);
    prevIndicatorsListRef.current = inds;
  }, []);

  const restoreLogicalRange = useCallback((
    mgr: ChartManager,
    savedLR: { from: number; to: number } | null,
  ) => {
    if (!savedLR) return;
    requestAnimationFrame(() => {
      mgr.applyVisibleLogicalRange(savedLR.from, savedLR.to);
    });
  }, []);

  const afterIndicatorPaneMutation = useCallback((mgr: ChartManager) => {
    if (candleOnlyModeRef.current) {
      mgr.applyCandleOnlyLayout(true, wrapperRef.current?.clientHeight);
    } else if (auxIndicatorOnlyRef.current) {
      mgr.applyAuxIndicatorOnlyLayout(true);
    } else if (mgr.isCandleOnlyLayout()) {
      mgr.restoreFromCandleFullscreen(wrapperRef.current?.clientHeight);
    }
    applyPaneLayout(mgr);
  }, [applyPaneLayout]);

  /** 누락된 지표만 추가 (전체 reloadIndicatorsOnly 회피) */
  const repairMissingIndicators = useCallback(async (
    mgr: ChartManager,
    inds: IndicatorConfig[],
  ) => {
    const loadedIds = mgr.getLoadedIndicatorIds();
    const missing = inds.filter(i => i.hidden !== true && !loadedIds.has(i.id));
    if (missing.length === 0) return;
    indicatorSyncInFlightRef.current = true;
    try {
      await mgr.addIndicatorsBatch(missing);
      afterIndicatorPaneMutation(mgr);
      mgr.notifyPaneLayoutChanged();
      requestAnimationFrame(() => requestAnimationFrame(() => {
        afterIndicatorPaneMutation(mgr);
        mgr.notifyPaneLayoutChanged();
      }));
    } finally {
      indicatorSyncInFlightRef.current = false;
    }
  }, [afterIndicatorPaneMutation]);

  /** 지표만 재로드 — 커버 + 스크롤 위치 복원으로 깜빡임·떨림 완화 */
  const reloadIndicatorsWithCover = useCallback((
    mgr: ChartManager,
    inds: IndicatorConfig[],
    onComplete?: () => void,
  ) => {
    const cover = createIndicatorReloadCover(containerRef.current);
    const savedLR = mgr.getVisibleLogicalRange();
    const reloadGen = ++indicatorReloadGenRef.current;

    indicatorSyncInFlightRef.current = true;
    void mgr.reloadIndicatorsOnly(inds).then(() => {
      if (reloadGen !== indicatorReloadGenRef.current) {
        fadeOutIndicatorReloadCover(cover);
        return;
      }
      afterIndicatorPaneMutation(mgr);
      // resetPaneHeights 직후 레이블 좌표 재동기화 (탭 교체 후 어긋남 방지)
      mgr.notifyPaneLayoutChanged();
      requestAnimationFrame(() => requestAnimationFrame(() => {
        if (reloadGen !== indicatorReloadGenRef.current) {
          fadeOutIndicatorReloadCover(cover);
          return;
        }
        restoreLogicalRange(mgr, savedLR);
        fadeOutIndicatorReloadCover(cover);
        indicatorSyncInFlightRef.current = false;
        mgr.notifyPaneLayoutChanged();
        [50, 150, 350, 700, 1200].forEach(ms => {
          setTimeout(() => {
            if (reloadGen === indicatorReloadGenRef.current) {
              mgr.notifyPaneLayoutChanged();
            }
          }, ms);
        });
        onComplete?.();
      }));
    });
  }, [afterIndicatorPaneMutation, restoreLogicalRange]);

  /** 설정 저장 등으로 연속 갱신이 겹칠 때 마지막 상태로 한 번 더 동기화 */
  const flushPendingIndicatorResync = useCallback((
    mgr: ChartManager,
    completedGen: number,
  ) => {
    if (!pendingIndicatorResyncRef.current) return;
    if (completedGen !== indicatorReloadGenRef.current) return;
    pendingIndicatorResyncRef.current = false;
    const latest = indicatorsRef.current;
    indicatorSyncInFlightRef.current = true;
    const flushGen = ++indicatorReloadGenRef.current;
    reloadIndicatorsWithCover(mgr, latest, () => {
      if (flushGen !== indicatorReloadGenRef.current) {
        indicatorSyncInFlightRef.current = false;
        return;
      }
      syncIndicatorTrackingRefs(latest);
      indicatorSyncInFlightRef.current = false;
    });
  }, [reloadIndicatorsWithCover, syncIndicatorTrackingRefs]);

  const queueFullReload = useCallback(() => {
    if (!chartVisibleRef.current) return;
    if (recoveryTimerRef.current) clearTimeout(recoveryTimerRef.current);
    recoveryTimerRef.current = window.setTimeout(() => {
      recoveryTimerRef.current = null;
      const m = managerRef.current;
      const barData = barsRef.current;
      const inds = indicatorsRef.current;
      if (!m || barData.length === 0) return;
      if (!canApplyChartBars(barData, barsMarket, market)) return;
      prevBarsKey.current = '';
      reloadAllRef.current?.(
        m, barData,
        prevChartType.current, prevTheme.current, prevLogScale.current,
        inds,
      );
    }, 80);
  }, [market, barsMarket]);

  const queueIndicatorRecovery = useCallback(() => {
    if (!chartVisibleRef.current) return;
    if (reloadInFlightRef.current) return;
    if (recoveryTimerRef.current) clearTimeout(recoveryTimerRef.current);
    recoveryTimerRef.current = window.setTimeout(() => {
      recoveryTimerRef.current = null;
      const m = managerRef.current;
      const barData = barsRef.current;
      const inds = indicatorsRef.current;
      if (!m || barData.length === 0) return;
      if (!canApplyChartBars(barData, barsMarket, market)) return;

      const expected = countExpectedVisibleIndicators(inds);
      if (expected === 0) return;

      const loadedIds = m.getLoadedIndicatorIds();
      if (chartHasStaleIndicators(loadedIds, inds)) {
        reloadIndicatorsWithCover(m, inds, () => {
          prevBarsKey.current = barsKey(barData, market);
          syncIndicatorTrackingRefs(inds);
          applyPaneLayout(m);
        });
        return;
      }
      if (loadedIds.size >= expected) return;

      prevBarsKey.current = '';
      const ct = prevChartType.current;
      const th = prevTheme.current;
      const ls = prevLogScale.current;

      if (m.hasMainSeries()) {
        void repairMissingIndicators(m, inds).then(() => {
          if (
            m.getLoadedIndicatorCount() >= expected
            && !chartHasStaleIndicators(m.getLoadedIndicatorIds(), inds)
          ) {
            prevBarsKey.current = barsKey(barData, market);
            syncIndicatorTrackingRefs(inds);
            applyPaneLayout(m);
            return;
          }
          reloadAllRef.current?.(m, barData, ct, th, ls, inds);
        });
        return;
      }
      reloadAllRef.current?.(m, barData, ct, th, ls, inds);
    }, 120);
  }, [market, barsMarket, applyPaneLayout, repairMissingIndicators, reloadIndicatorsWithCover, syncIndicatorTrackingRefs]);

  /** setData 직후 캔들·볼륨 pane 레이아웃 확정 (지표 로드 중단 시에도 빈 화면 방지) */
  const commitCandleLayout = useCallback((mgr: ChartManager) => {
    requestAnimationFrame(() => {
      applyPaneLayout(mgr);
      requestAnimationFrame(() => {
        applyInitialVisibleRange(mgr);
      });
    });
  }, [applyPaneLayout, applyInitialVisibleRange]);

  const reloadAll = useCallback(async (
    mgr: ChartManager,
    newBars: OHLCVBar[],
    ct: ChartType,
    th: Theme,
    ls: boolean,
    inds: IndicatorConfig[],
  ) => {
    if (newBars.length === 0) return;

    if (reloadInFlightRef.current) {
      reloadCoalesceRef.current = true;
      barsRef.current = newBars;
      return;
    }

    barsRef.current = newBars;
    reloadInFlightRef.current = true;
    setChartPaintReady(false);

    try {
      do {
        reloadCoalesceRef.current = false;
        const gen = reloadGenRef.current;
        const barData = barsRef.current.length >= 2 ? barsRef.current : newBars;
        if (barData.length < 2) break;

        reloadSafetyTimers.current.forEach(clearTimeout);
        reloadSafetyTimers.current = [];
        barsRef.current = barData;

        mgr.setData(barData, ct, th);
        if (gen !== reloadGenRef.current) {
          prevBarsKey.current = '';
          break;
        }
        mgr.setTheme(th);
        mgr.setLogScale(ls);
        mgr.setDisplayTimezone(displayTimezone, timeframe, chartTimeFormat);
        lastAppliedMarketRef.current = market;

        const sortedInds = sortIndicatorsForPaneLoad(inds);
        if (sortedInds.length > 0) {
          await mgr.addIndicatorsBatch(sortedInds);
        }
        if (gen !== reloadGenRef.current) {
          prevBarsKey.current = '';
          break;
        }

        const expected = countExpectedVisibleIndicators(inds);
        const loaded = mgr.getLoadedIndicatorCount();

        if (expected > 0 && loaded < expected) {
          prevBarsKey.current = '';
          if (reloadRetryRef.current < 4 && mgr.hasMainSeries()) {
            reloadRetryRef.current += 1;
            await mgr.reloadIndicatorsOnly(inds);
            if (gen !== reloadGenRef.current) break;
          }
          if (mgr.getLoadedIndicatorCount() < expected) {
            if (reloadRetryRef.current < 6) {
              reloadRetryRef.current += 1;
              queueIndicatorRecovery();
            }
            break;
          }
        }

        reloadRetryRef.current = 0;
        prevChartType.current = ct;
        prevTheme.current     = th;
        prevLogScale.current  = ls;
        prevBarsKey.current   = barsKey(barData, market);
        syncIndicatorTrackingRefs(inds);
        lastAppliedMarketRef.current = market;

        await new Promise<void>(resolve => requestAnimationFrame(() => requestAnimationFrame(() => {
          normalizeChartViewport(mgr);
          requestAnimationFrame(() => {
            onCandlesReady?.();
            setChartPaintReady(true);
            resolve();
          });
        })));

        if (gen !== reloadGenRef.current) {
          prevBarsKey.current = '';
          break;
        }

        onDataLoaded?.();

        reloadSafetyTimers.current = [300, 700, 1400, 2500].map(delay =>
          setTimeout(() => {
            const m = managerRef.current;
            if (!m || !m.hasMainSeries()) return;
            const expectedLater = countExpectedVisibleIndicators(indicatorsRef.current);
            if (expectedLater > 0 && m.getLoadedIndicatorCount() < expectedLater) {
              prevBarsKey.current = '';
              queueIndicatorRecovery();
              return;
            }
            if (skipViewportRecoveryRef.current) return;
            const w = wrapperRef.current;
            if (!w || w.clientHeight < MIN_VALID_LAYOUT_HEIGHT) return;
            normalizeChartViewport(m);
            if (delay === 2500) onDataLoaded?.();
          }, delay)
        );
      } while (reloadCoalesceRef.current);
    } finally {
      reloadInFlightRef.current = false;
      if (reloadCoalesceRef.current) {
        reloadCoalesceRef.current = false;
        queueFullReload();
        return;
      }
      const m = managerRef.current;
      if (m?.hasMainSeries()) {
        // 메인 캔들만 준비되어도 paint·실시간 틱 허용 (지표는 백그라운드 복구)
        setChartPaintReady(true);
        onCandlesReady?.();
      }
    }
  }, [normalizeChartViewport, onCandlesReady, onDataLoaded, displayTimezone, timeframe, market, queueFullReload, queueIndicatorRecovery]);

  useEffect(() => {
    reloadAllRef.current = reloadAll;
  }, [reloadAll]);

  // ── 차트 초기화 (마운트 시 1회) ──────────────────────────────────────────
  useEffect(() => {
    if (!containerRef.current) return;

    const mgr = new ChartManager(containerRef.current, theme, {
      compactPaneLayout: paneLayoutClamp,
      autoSize: !paneLayoutClamp,
      volumePaneEnabled: volumeVisibleRef.current,
    });
    managerRef.current = mgr;
    setChartMgr(mgr);
    if (!volumeVisibleRef.current) {
      const wrapperH = wrapperRef.current?.clientHeight ?? 0;
      mgr.setVolumeVisible(false, wrapperH > 0 ? wrapperH : undefined);
    }
    mgr.setCandleAreaPriceLabelsEnabled(resolvedCandlePriceLabels);
    mgr.setIndicatorAreaPriceLabelsEnabled(resolvedIndicatorPriceLabels);
    if (paneSeparatorOptions) mgr.setPaneSeparatorOptions(paneSeparatorOptions);
    if (auxIndicatorOnlyRef.current) mgr.applyAuxIndicatorOnlyLayout(true);
    onManagerReady(mgr);

    const unsub = mgr.subscribeCrosshair((p: MouseEventParams<Time>) => {
      if (p.point?.y != null) {
        const yPrice = mgr.yToPrice(p.point.y);
        if (yPrice != null && yPrice > 0) lastCrosshairPriceRef.current = yPrice;
      }
      if (!p.time) { onCrosshair(null); return; }
      const bar = mgr.getBarAtTime(p.time as number)
               ?? barsRef.current.find(b => b.time === p.time);
      if (bar) {
        const crosshairPrice = p.point?.y != null ? mgr.yToPrice(p.point.y) ?? undefined : undefined;
        const indicatorValues = mgr.getIndicatorValuesFromParams(p);
        onCrosshair({
          time: p.time as number,
          open: bar.open, high: bar.high, low: bar.low,
          close: bar.close, volume: bar.volume,
          crosshairPrice: crosshairPrice ?? undefined,
          indicatorValues,
        });
      }
    });

    const container = containerRef.current!;

    // ── 드로잉 클릭/더블클릭 감지 (capture phase → 인디케이터 클릭보다 우선) ──────
    // cursor 모드 + trading + visible + !locked 일 때만 처리
    const handleDrawingCapture = (e: MouseEvent) => {
      if (suppressClickRef.current) {
        suppressClickRef.current = false;
        e.stopImmediatePropagation();
        return;
      }
      if (
        modeRef.current !== 'trading'      ||
        drawingToolRef.current !== 'cursor'||
        !drawingsVisibleRef.current        ||
        drawingsLockedRef.current
      ) return;

      const rect  = container.getBoundingClientRect();
      const cx    = e.clientX - rect.left;
      const cy    = e.clientY - rect.top;
      const cssW  = container.clientWidth;
      const cssH  = container.clientHeight;
      const m     = managerRef.current;
      if (!m) return;

      // 역순(나중에 그린 것 우선)으로 hit-test
      const ds = drawingsRef.current;
      for (let i = ds.length - 1; i >= 0; i--) {
        if (hitTestDrawing(cx, cy, ds[i], m, cssW, cssH)) {
          const hitId = ds[i].id;
          // 인디케이터 컨텍스트 툴바가 열리지 않도록 이벤트 전파 차단
          e.stopImmediatePropagation();

          const now = Date.now();
          const isDouble =
            now - lastDrawingClickRef.current.time < 400 &&
            lastDrawingClickRef.current.id === hitId;
          lastDrawingClickRef.current = { id: hitId, time: now };

          if (isDouble) {
            if (drawingSingleTimerRef.current) {
              clearTimeout(drawingSingleTimerRef.current);
              drawingSingleTimerRef.current = null;
            }
            onDrawingDoubleClickRef.current?.(hitId, e.clientX, e.clientY);
          } else {
            if (drawingSingleTimerRef.current) clearTimeout(drawingSingleTimerRef.current);
            drawingSingleTimerRef.current = setTimeout(() => {
              drawingSingleTimerRef.current = null;
              onDrawingClickRef.current?.(hitId, e.clientX, e.clientY);
            }, 220);
          }
          return;
        }
      }
      // 빈 공간 클릭 → 선택 해제
      if (
        modeRef.current === 'trading' &&
        drawingToolRef.current === 'cursor'
      ) {
        onDrawingClickRef.current?.(null, e.clientX, e.clientY);
      }
    };

    // capture:true → subscribeSeriesClick(bubble) 보다 먼저 실행
    container.addEventListener('click', handleDrawingCapture, { capture: true });

    const onContextMenuCapture = (e: MouseEvent) => {
      if (!onTradeOrderRequestRef.current || !marketRef.current) return;
      const m = managerRef.current;
      if (!m) return;
      const rect = container.getBoundingClientRect();
      const chartX = e.clientX - rect.left;
      const chartY = e.clientY - rect.top;
      const price = m.getTradePriceAtChartPoint(chartX, chartY);
      if (price == null) return;
      e.preventDefault();
      e.stopPropagation();
      setCtxMenu({ x: e.clientX, y: e.clientY, price });
    };
    container.addEventListener('contextmenu', onContextMenuCapture, { capture: true });

    const wrapper = wrapperRef.current;
    const isChartPlotPointer = (clientX: number, clientY: number) => {
      const rect = container.getBoundingClientRect();
      const chartX = clientX - rect.left;
      const chartY = clientY - rect.top;
      if (chartX < 0 || chartX > container.clientWidth) return null;
      if (chartY < 0 || chartY > container.clientHeight) return null;
      return { chartX, chartY };
    };

    const onPanPointerDown = (e: PointerEvent) => {
      if (!canPanRef.current || e.button !== 0) return;
      const target = e.target as HTMLElement | null;
      if (target?.closest('[data-no-chart-pan], .chart-right-toolbar, .pane-legend-item, .pane-drag-handle, .pane-btn, .candle-pane-controls')) {
        return;
      }
      const pt = isChartPlotPointer(e.clientX, e.clientY);
      if (!pt) return;
      if (mgr.isOnPriceAxis(pt.chartX, pt.chartY) || mgr.isOnTimeAxis(pt.chartY)) return;
      const originPaneIndex = mgr.getPaneIndexAtChartY(pt.chartY);
      const captureEl = wrapper ?? container;
      try {
        captureEl.setPointerCapture(e.pointerId);
      } catch {
        /* ignore */
      }
      panStateRef.current = {
        active: true,
        lastX: e.clientX,
        lastY: e.clientY,
        moved: false,
        originPaneIndex,
      };
      container.style.cursor = 'grabbing';
    };

    const onPanPointerMove = (e: PointerEvent) => {
      if (!panStateRef.current.active) return;
      const dx = e.clientX - panStateRef.current.lastX;
      const dy = e.clientY - panStateRef.current.lastY;
      panStateRef.current.lastX = e.clientX;
      panStateRef.current.lastY = e.clientY;
      const allowVertical = panStateRef.current.originPaneIndex === 0;
      if (!panStateRef.current.moved) {
        const hitY = allowVertical && Math.abs(dy) > 2;
        if (Math.abs(dx) > 2 || hitY) panStateRef.current.moved = true;
      }
      if (!panStateRef.current.moved) return;
      if (e.cancelable) e.preventDefault();
      managerRef.current?.panByPixelDelta(dx, dy, {
        allowVerticalPan: allowVertical,
      });
      managerRef.current?.notifyViewportChanged();
    };

    const onPanPointerUp = (e: PointerEvent) => {
      if (!panStateRef.current.active) return;
      const captureEl = wrapper ?? container;
      try {
        if (captureEl.hasPointerCapture(e.pointerId)) {
          captureEl.releasePointerCapture(e.pointerId);
        }
      } catch {
        /* ignore */
      }
      if (panStateRef.current.moved) {
        suppressClickRef.current = true;
        managerRef.current?.notifyViewportChanged();
      }
      panStateRef.current.active = false;
      container.style.cursor = canPanRef.current ? 'grab' : '';
    };

    const panCapture = true;
    const panTarget = wrapper ?? container;
    panTarget.addEventListener('pointerdown', onPanPointerDown, panCapture);
    window.addEventListener('pointermove', onPanPointerMove, { passive: false });
    window.addEventListener('pointerup', onPanPointerUp);
    window.addEventListener('pointercancel', onPanPointerUp);

    const onWheelCapture = (e: WheelEvent) => {
      const m = managerRef.current;
      if (!m) return;
      const rect = container.getBoundingClientRect();
      const chartX = e.clientX - rect.left;
      const chartY = e.clientY - rect.top;
      if (!m.isCandlePanePriceAxis(chartX, chartY)) return;

      e.preventDefault();
      e.stopPropagation();
      m.zoomMainPriceByWheel(e.deltaY, chartY);
    };
    container.addEventListener('wheel', onWheelCapture, { capture: true, passive: false });

    // subscribeSeriesClick 은 이제 native DOM 이벤트 기반.
    // point 는 ChartManager 내부에서 chart-container 좌표로 계산된 screen 좌표.
    /** 캔들 pane: 시리즈 위 → 설정 팝업, 빈 공간 → 전체보기/원복 토글 */
    const onCandlePaneDblClick = (e: MouseEvent) => {
      const m = managerRef.current;
      if (!m) return;
      const rect = container.getBoundingClientRect();
      const chartX = e.clientX - rect.left;
      const chartY = e.clientY - rect.top;

      const entryId = m.resolveEntryAtChartPoint(chartX, chartY);
      if (entryId && entryId !== '__main__') {
        e.preventDefault();
        e.stopImmediatePropagation();
        suppressClickRef.current = true;
        seriesDblSuppressRef.current = true;
        onSeriesDoubleClickRef.current?.(entryId, { x: e.clientX, y: e.clientY });
        return;
      }

      if (!m.isInCandlePlotArea(chartX, chartY)) return;

      e.preventDefault();
      e.stopImmediatePropagation();
      suppressClickRef.current = true;
      seriesDblSuppressRef.current = true;

      if (m.isClickOnMainSeries(chartX, chartY)) {
        onSeriesDoubleClickRef.current?.('__main__', { x: e.clientX, y: e.clientY });
      } else if (!candleOnlyLocked) {
        toggleCandleOnlyRef.current();
      }
    };
    container.addEventListener('dblclick', onCandlePaneDblClick, { capture: true });

    const unsubClick = mgr.subscribeSeriesClick(
      (entryId, point) => { onSeriesClick?.(entryId, point); },
      (entryId, point) => {
        if (seriesDblSuppressRef.current) {
          seriesDblSuppressRef.current = false;
          return;
        }
        // 캔들 pane 더블클릭은 dblclick 캡처에서 처리 (시리즈/빈공간 구분)
        if (entryId === '__main__') return;
        onSeriesDoubleClick?.(entryId, point);
      },
    );

    // autoSize:true 를 쓰면 LWC 가 내부 ResizeObserver 로 자동 크기 조절.
    // 이 외부 ResizeObserver 는 applyPaneLayout 재트리거 + 데이터 재로드 보장용.
    let prevW = 0, prevH = 0;
    const ro = new ResizeObserver((entries) => {
      let width = 0;
      let height = 0;
      for (const e of entries) {
        width = Math.max(width, e.contentRect.width);
        height = Math.max(height, e.contentRect.height);
      }
      if (width === prevW && height === prevH) return;
      // 이전 크기가 0이었다가 유효해진 경우 (= display:none → visible 전환)
      const wasHidden = prevW <= 0 || prevH <= 0;
      if (width <= 0 || height <= 0) return;

      // 캔들 전체보기: 컨테이너가 래퍼보다 커지면 즉시 클램프 (ResizeObserver ↔ autoSize 루프 차단)
      if (candleOnlyModeRef.current) {
        const wh = wrapperRef.current?.clientHeight ?? 0;
        if (wh > 0 && height > wh + 4) {
          prevW = width;
          prevH = wh;
          const m = managerRef.current;
          if (m) applyPaneLayout(m);
          return;
        }
      }

      prevW = width; prevH = height;

      if (resizeDebounceRef.current) clearTimeout(resizeDebounceRef.current);
      resizeDebounceRef.current = window.setTimeout(() => {
        resizeDebounceRef.current = null;
        const m = managerRef.current;
        if (!m || reloadInFlightRef.current) return;

        // 컨테이너가 처음으로 유효한 크기를 가질 때:
        // 데이터가 이미 barsRef에 있지만 차트에 아직 세팅되지 않은 경우 (예: 멀티레이아웃 초기 렌더) 재로드
        if (barsRef.current.length > 0 && !m.hasMainSeries()) {
          const bm = barsMarketRef.current;
          if (!canApplyChartBars(barsRef.current, bm, marketRef.current)) return;
          void reloadAll(
            m,
            barsRef.current,
            prevChartType.current,
            prevTheme.current,
            prevLogScale.current,
            indicatorsRef.current,
          );
          return;
        }

        const savedLR = wasHidden && m.hasMainSeries() ? m.getVisibleLogicalRange() : null;
        applyPaneLayout(m);
        // 숨김→표시 전환: 줌/스크롤 위치 유지 (setInitialVisibleRange 로 초기화하면 화면이 튐)
        if (wasHidden && m.hasMainSeries()) {
          requestAnimationFrame(() => {
            if (savedLR) {
              m.applyVisibleLogicalRange(savedLR.from, savedLR.to);
            } else {
              applyInitialVisibleRange(m);
            }
          });
        }
      }, 80);
    });
    const wrapperEl = wrapperRef.current;
    if (wrapperEl) ro.observe(wrapperEl);
    ro.observe(container);

    const bootstrapIfReady = () => {
      const m = managerRef.current;
      if (!m || m.hasMainSeries()) return;
      const data = barsRef.current;
      if (!canApplyChartBars(data, barsMarketRef.current, marketRef.current)) return;
      void reloadAllRef.current(
        m,
        data,
        prevChartType.current,
        prevTheme.current,
        prevLogScale.current,
        indicatorsRef.current,
      );
    };
    requestAnimationFrame(() => requestAnimationFrame(bootstrapIfReady));

    return () => {
      unsub();
      unsubClick();
      container.removeEventListener('click', handleDrawingCapture, { capture: true });
      container.removeEventListener('contextmenu', onContextMenuCapture, { capture: true });
      container.removeEventListener('dblclick', onCandlePaneDblClick, { capture: true });
      panTarget.removeEventListener('pointerdown', onPanPointerDown, panCapture);
      container.removeEventListener('wheel', onWheelCapture, { capture: true });
      window.removeEventListener('pointermove', onPanPointerMove);
      window.removeEventListener('pointerup', onPanPointerUp);
      window.removeEventListener('pointercancel', onPanPointerUp);
      if (drawingSingleTimerRef.current) {
        clearTimeout(drawingSingleTimerRef.current);
        drawingSingleTimerRef.current = null;
      }
      reloadSafetyTimers.current.forEach(clearTimeout);
      reloadSafetyTimers.current = [];
      if (recoveryTimerRef.current) clearTimeout(recoveryTimerRef.current);
      if (resizeDebounceRef.current) clearTimeout(resizeDebounceRef.current);
      ro.disconnect();
      managerRef.current?.destroy();
      managerRef.current = null;
      setChartMgr(null);
    };
  // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  useEffect(() => {
    managerRef.current?.setDisplayTimezone(displayTimezone, timeframe, chartTimeFormat);
    managerRef.current?.setChartTimeFormat(chartTimeFormat);
  }, [displayTimezone, timeframe, chartTimeFormat]);

  // 종목·타임프레임 변경 시 barsKey 캐시만 초기화 (reloadAll 은 bars effect 에서 1회만)
  useEffect(() => {
    if (prevMarket.current === market && prevMarketTf.current === timeframe) return;
    prevMarket.current = market;
    prevMarketTf.current = timeframe;
    prevBarsKey.current = '';
    prevIndKey.current = '';
    prevIndicatorsListRef.current = [];
    lastAppliedMarketRef.current = '';
    barsRef.current = [];
    reloadRetryRef.current = 0;
    reloadGenRef.current += 1;
    reloadInFlightRef.current = false;
    reloadCoalesceRef.current = false;
    reloadSafetyTimers.current.forEach(clearTimeout);
    reloadSafetyTimers.current = [];
    setChartPaintReady(false);
    managerRef.current?.clearForSymbolChange();
    managerRef.current?.cancelPendingIndicatorUpdates();
  }, [market, timeframe]);

  // ── 데이터/인디케이터 동기화 ─────────────────────────────────────────────
  useEffect(() => {
    const mgr = managerRef.current;
    if (!mgr || !chartMgr || bars.length < 2) return;
    if (!canApplyChartBars(bars, barsMarket, market)) return;

    if (!chartVisible) {
      // 설정 등 다른 화면에서 숨김 중 — 키만 동기화해 복귀 시 불필요한 전체 재로드 방지
      prevBarsKey.current = barsKey(bars, market);
      syncIndicatorTrackingRefs(indicators);
      prevChartType.current = chartType;
      prevTheme.current = theme;
      prevLogScale.current = logScale;
      return;
    }

    // manager 준비 전·종목 미적용 시 reloadAll 강제
    if (!mgr.hasMainSeries() || lastAppliedMarketRef.current !== market) {
      void reloadAll(mgr, bars, chartType, theme, logScale, indicators);
      return;
    }

    const bk = barsKey(bars, market);
    const ik = indKey(indicators);
    const barsChanged = bk !== prevBarsKey.current;
    const indChanged  = ik !== prevIndKey.current;
    const ctChanged   = chartType !== prevChartType.current;
    const thChanged   = theme     !== prevTheme.current;
    const lsChanged   = logScale  !== prevLogScale.current;
    const expectIndicators = countExpectedVisibleIndicators(indicators);
    const loadedIndicators = mgr.getLoadedIndicatorCount();
    const indicatorsIncomplete = expectIndicators > 0
      && mgr.hasMainSeries()
      && loadedIndicators < expectIndicators;

    if (!barsChanged && !indChanged && !ctChanged && !thChanged && !lsChanged && !indicatorsIncomplete) return;

    if (thChanged && !barsChanged && !indChanged && !ctChanged && !lsChanged && !indicatorsIncomplete) {
      prevTheme.current = theme;
      mgr.setTheme(theme);
      return;
    }

    if (barsChanged || ctChanged) {
      void reloadAll(mgr, bars, chartType, theme, logScale, indicators);
    } else if (indicatorsIncomplete && !indChanged) {
      // 지표 목록은 같지만 일부만 로드된 경우 — 누락분만 추가.
      // 차트에 이전 탭 id가 남아 있으면(설정 화면에서 탭 교체 등) 전체 재로드.
      const loadedIds = mgr.getLoadedIndicatorIds();
      if (chartHasStaleIndicators(loadedIds, indicators)) {
        reloadIndicatorsWithCover(mgr, indicators, () => {
          syncIndicatorTrackingRefs(indicators);
          prevBarsKey.current = barsKey(bars, market);
        });
      } else {
        void repairMissingIndicators(mgr, indicators).then(() => {
          syncIndicatorTrackingRefs(indicators);
          prevBarsKey.current = barsKey(bars, market);
        });
      }
    } else if (lsChanged) {
      prevLogScale.current = logScale;
      mgr.setLogScale(logScale);
    } else if (indChanged) {
      if (indicatorSyncInFlightRef.current) {
        pendingIndicatorResyncRef.current = true;
        return;
      }
      const prevList = prevIndicatorsListRef.current;
      const prevPK = prevIndKey.current.split('@@')[0] ?? '';
      const currPK = paramKey(indicators);
      const prevSK = prevIndKey.current.split('@@')[1] ?? '';
      const currSK = styleKey(indicators);

      const needsRecalc  = prevPK !== currPK;
      const chartChange = classifyIndicatorChartChange(prevList, indicators);

      const prevMap = new Map(prevList.map(i => [i.id, i]));
      const removedIds = prevList
        .filter(p => !indicators.some(c => c.id === p.id))
        .map(p => p.id);
      const addedConfigs = indicators.filter(c => !prevMap.has(c.id));
      const paramsChangedConfigs = indicators.filter(c => {
        const p = prevMap.get(c.id);
        if (!p) return false;
        return singleIndParamKey(p) !== singleIndParamKey(c);
      });

      if (
        chartChange.mode === 'reorder-only'
        || chartChange.mode === 'layout-changed'
      ) {
        /** 순서·병합·분리 — 설정에 맞게 sub-pane 전체 재배치 */
        indicatorSyncInFlightRef.current = true;
        reloadIndicatorsWithCover(mgr, indicators, () => {
          syncIndicatorTrackingRefs(indicators);
          indicatorSyncInFlightRef.current = false;
        });
      } else if (!needsRecalc && prevSK !== currSK) {
        for (const ind of indicators) {
          mgr.applyIndicatorStyle(ind);
        }
        void mgr.repairDualLineSeries();
        syncIndicatorTrackingRefs(indicators);
      } else if (
        removedIds.length > 0
        && addedConfigs.length === 0
        && paramsChangedConfigs.length === 0
      ) {
        const savedLR = mgr.getVisibleLogicalRange();
        indicatorSyncInFlightRef.current = true;
        mgr.removeIndicators(removedIds);
        afterIndicatorPaneMutation(mgr);
        mgr.notifyPaneLayoutChanged();
        restoreLogicalRange(mgr, savedLR);
        syncIndicatorTrackingRefs(indicators);
        indicatorSyncInFlightRef.current = false;
        requestAnimationFrame(() => requestAnimationFrame(() => {
          afterIndicatorPaneMutation(mgr);
          mgr.notifyPaneLayoutChanged();
        }));
        void repairMissingIndicators(mgr, indicators);
      } else if (
        addedConfigs.length > 0
        && removedIds.length > 0
        && paramsChangedConfigs.length === 0
      ) {
        /** 지표 교체(제거+추가 동시) — CCI 제거 후 재추가 등 orphan pane·pane index 어긋남 방지 */
        const savedLR = mgr.getVisibleLogicalRange();
        indicatorSyncInFlightRef.current = true;
        mgr.removeIndicators(removedIds);
        void mgr.addIndicatorsBatch(addedConfigs).then(() => {
          afterIndicatorPaneMutation(mgr);
          mgr.notifyPaneLayoutChanged();
          restoreLogicalRange(mgr, savedLR);
          syncIndicatorTrackingRefs(indicators);
          indicatorSyncInFlightRef.current = false;
          requestAnimationFrame(() => requestAnimationFrame(() => {
            afterIndicatorPaneMutation(mgr);
            mgr.notifyPaneLayoutChanged();
            [50, 150, 350].forEach(ms => {
              setTimeout(() => mgr.notifyPaneLayoutChanged(), ms);
            });
          }));
        });
      } else if (
        addedConfigs.length > 0
        && removedIds.length === 0
        && paramsChangedConfigs.length === 0
      ) {
        const savedLR = mgr.getVisibleLogicalRange();
        indicatorSyncInFlightRef.current = true;
        /** 복사 등 목록 중간 삽입 — pane 순서를 맞추려면 전체 재로드 */
        const insertIdx = addedConfigs.length === 1
          ? indicators.findIndex(i => i.id === addedConfigs[0].id)
          : -1;
        const reloadForPaneOrder = insertIdx >= 0 && insertIdx < indicators.length - 1;

        const finishAdd = () => {
          afterIndicatorPaneMutation(mgr);
          mgr.notifyPaneLayoutChanged();
          restoreLogicalRange(mgr, savedLR);
          syncIndicatorTrackingRefs(indicators);
          indicatorSyncInFlightRef.current = false;
          requestAnimationFrame(() => requestAnimationFrame(() => {
            afterIndicatorPaneMutation(mgr);
            mgr.notifyPaneLayoutChanged();
            [50, 150, 350].forEach(ms => {
              setTimeout(() => mgr.notifyPaneLayoutChanged(), ms);
            });
          }));
        };

        if (reloadForPaneOrder) {
          reloadIndicatorsWithCover(mgr, indicators, finishAdd);
        } else {
          void mgr.addIndicatorsBatch(addedConfigs).then(finishAdd);
        }
      } else if (
        paramsChangedConfigs.length > 0
        && removedIds.length === 0
        && addedConfigs.length === 0
      ) {
        const savedLR = mgr.getVisibleLogicalRange();
        const syncGen = ++indicatorReloadGenRef.current;
        indicatorSyncInFlightRef.current = true;
        setPaneLegendPaused(true);
        void mgr.refreshIndicatorParamsInPlace(paramsChangedConfigs).then(() => {
          if (syncGen !== indicatorReloadGenRef.current) {
            indicatorSyncInFlightRef.current = false;
            setPaneLegendPaused(false);
            return;
          }
          afterIndicatorPaneMutation(mgr);
          mgr.notifyPaneLayoutChanged();
          restoreLogicalRange(mgr, savedLR);
          syncIndicatorTrackingRefs(indicators);
          indicatorSyncInFlightRef.current = false;
          setPaneLegendPaused(false);
          flushPendingIndicatorResync(mgr, syncGen);
        });
      } else if (chartChange.mode === 'full') {
        reloadIndicatorsWithCover(mgr, indicators, () => {
          syncIndicatorTrackingRefs(indicators);
        });
      } else {
        syncIndicatorTrackingRefs(indicators);
      }
    }
  // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [chartMgr, bars, barsMarket, market, chartType, theme, logScale, indicators, chartVisible, reloadIndicatorsWithCover, afterIndicatorPaneMutation, restoreLogicalRange, syncIndicatorTrackingRefs, repairMissingIndicators, flushPendingIndicatorResync]);

  // 원격·모바일 등 레이아웃·데이터 로드 지연 후 pane·가격축 안정화
  const layoutRecoveryAttemptedRef = useRef(false);
  useEffect(() => {
    layoutRecoveryAttemptedRef.current = false;
    lastViewportFitRef.current = null;
  }, [market, timeframe]);

  useEffect(() => {
    if (!chartVisible || skipViewportRecovery) return;
    const mgr = managerRef.current;
    if (!mgr || !chartMgr || bars.length < 2) return;
    if (!canApplyChartBars(bars, barsMarket, market)) return;
    if (!mgr.hasMainSeries()) return;

    const timers = [600, 1200, 2500, 4000].map(delay =>
      window.setTimeout(() => {
        if (reloadInFlightRef.current) return;
        const m = managerRef.current;
        if (!m?.hasMainSeries()) return;
        const w = wrapperRef.current;
        if (!w || w.clientHeight < MIN_VALID_LAYOUT_HEIGHT) return;
        normalizeChartViewport(m);
        if (delay >= 2500 && m.isMainPriceScaleLikelyCorrupt() && !layoutRecoveryAttemptedRef.current) {
          layoutRecoveryAttemptedRef.current = true;
          prevBarsKey.current = '';
          void reloadAllRef.current(
            m,
            barsRef.current,
            prevChartType.current,
            prevTheme.current,
            prevLogScale.current,
            indicatorsRef.current,
          );
        }
      }, delay),
    );

    return () => { timers.forEach(clearTimeout); };
  }, [chartMgr, bars.length, barsMarket, market, chartVisible, normalizeChartViewport, skipViewportRecovery]);

  // 데이터는 준비됐으나 메인 시리즈가 없는 빈 차트 자동 복구 (종목 전환·레이아웃 전환 직후)
  useEffect(() => {
    if (!chartVisible) return;
    const mgr = managerRef.current;
    if (!mgr || !chartMgr || bars.length < 2) return;
    if (!canApplyChartBars(bars, barsMarket, market)) return;
    if (mgr.hasMainSeries()) return;

    const timers = [0, 80, 200, 500, 1200, 2500].map(delay =>
      window.setTimeout(() => {
        const m = managerRef.current;
        if (!m || m.hasMainSeries()) return;
        const data = barsRef.current;
        if (!canApplyChartBars(data, barsMarketRef.current, marketRef.current)) return;
        void reloadAllRef.current(
          m,
          data,
          prevChartType.current,
          prevTheme.current,
          prevLogScale.current,
          indicatorsRef.current,
        );
      }, delay),
    );

    return () => { timers.forEach(clearTimeout); };
  }, [chartMgr, bars, barsMarket, market, chartType, theme, logScale, indicators, chartVisible]);

  // 종목 전환 직후 reloadAll 이 중단되어 캔들만 남은 경우 자동 복구
  useEffect(() => {
    if (!chartVisible) return;
    const mgr = managerRef.current;
    if (!mgr || !chartMgr || bars.length < 2) return;
    if (!canApplyChartBars(bars, barsMarket, market)) return;

    const expected = countExpectedVisibleIndicators(indicators);
    if (expected === 0) return;
    const loadedIds = mgr.getLoadedIndicatorIds();
    if (
      loadedIds.size >= expected
      && !chartHasStaleIndicators(loadedIds, indicators)
    ) return;

    const timers = [120, 400, 900].map(delay =>
      window.setTimeout(() => {
        if (reloadInFlightRef.current || indicatorSyncInFlightRef.current) return;
        const m = managerRef.current;
        if (!m || !m.hasMainSeries()) return;
        if (!canApplyChartBars(bars, barsMarket, market)) return;
        const inds = indicatorsRef.current;
        const exp = countExpectedVisibleIndicators(inds);
        if (exp === 0) return;
        const ids = m.getLoadedIndicatorIds();
        if (chartHasStaleIndicators(ids, inds)) {
          reloadIndicatorsWithCover(m, inds, () => {
            syncIndicatorTrackingRefs(inds);
            prevBarsKey.current = barsKey(bars, market);
          });
          return;
        }
        if (ids.size >= exp) return;
        void repairMissingIndicators(m, inds).then(() => {
          if (
            m.getLoadedIndicatorCount() >= exp
            && !chartHasStaleIndicators(m.getLoadedIndicatorIds(), inds)
          ) return;
          prevBarsKey.current = '';
          queueIndicatorRecovery();
        });
      }, delay),
    );

    return () => { timers.forEach(clearTimeout); };
  }, [chartMgr, bars, barsMarket, market, chartType, theme, logScale, indicators, chartVisible, queueIndicatorRecovery, repairMissingIndicators, reloadIndicatorsWithCover, syncIndicatorTrackingRefs]);

  // cursor 모드에서 드로잉 위에 있으면 pointer 커서로 변경
  const handleWrapperMouseMove = useCallback((e: React.PointerEvent) => {
    if (panStateRef.current.active) return;
    if (canPanRef.current) {
      const container = containerRef.current;
      if (!container) return;
      if (
        modeRef.current === 'trading' &&
        drawingToolRef.current === 'cursor' &&
        drawingsVisibleRef.current &&
        !drawingsLockedRef.current
      ) {
        const rect = container.getBoundingClientRect();
        const cx = e.clientX - rect.left;
        const cy = e.clientY - rect.top;
        const m = managerRef.current;
        if (m) {
          const isHit = drawingsRef.current.some(d =>
            hitTestDrawing(cx, cy, d, m, container.clientWidth, container.clientHeight),
          );
          container.style.cursor = isHit ? 'pointer' : 'grab';
          return;
        }
      }
      container.style.cursor = 'grab';
      return;
    }
    if (
      modeRef.current !== 'trading'        ||
      drawingToolRef.current !== 'cursor'  ||
      !drawingsVisibleRef.current          ||
      drawingsLockedRef.current
    ) {
      if (containerRef.current) containerRef.current.style.cursor = '';
      return;
    }
    const container = containerRef.current;
    if (!container) return;
    const rect = container.getBoundingClientRect();
    const cx = e.clientX - rect.left;
    const cy = e.clientY - rect.top;
    const cssW = container.clientWidth;
    const cssH = container.clientHeight;
    const m = managerRef.current;
    if (!m) return;

    const ds = drawingsRef.current;
    const isHit = ds.some(d => hitTestDrawing(cx, cy, d, m, cssW, cssH));
    container.style.cursor = isHit ? 'pointer' : '';
  }, []);

  const fmtCtxPrice = (p: number) => formatUpbitKrwPrice(p);

  const closeCtxAndRequest = useCallback((side: 'buy' | 'sell') => {
    if (!ctxMenu || !market) return;
    onTradeOrderRequestRef.current?.({ market, price: ctxMenu.price, side });
    setCtxMenu(null);
  }, [ctxMenu, market]);

  const showChartLoading = !chartPaintReady || dataLoading;
  const showRightToolbar = !!(
    chartMgr
    && !candleOnlyMode
    && chartPaintReady
    && (effectiveShowChartRightToolbar || showOverlayControlsOnly)
  );

  return (
    // wrapperRef: 스크롤 영역 (overflow-y: auto → App.css .tv-chart-wrap)
    <div
      ref={wrapperRef}
      className={`tv-chart-wrap${candleOnlyMode ? ' tv-chart-wrap--candle-only' : ''}${chartPaintReady ? '' : ' tv-chart-wrap--bootstrapping'}`}
      style={{ flex: 1, position: 'relative', minHeight: 0 }}
      onPointerMove={handleWrapperMouseMove}
    >
      {showChartLoading && (
        <div className="chart-loading chart-loading--chart-area" role="status" aria-live="polite">
          <div className="loading-spinner" />
          <span>차트 로딩중...</span>
        </div>
      )}
      <div className="chart-main-row">
        <div
          ref={containerRef}
          className="chart-container"
        />
        {/* 캔들 pane 하단 드래그 핸들 — 캔들/보조지표 높이 비율 조절 */}
        {candlePaneBottomY != null && !candleOnlyMode && (!paneLayoutClamp || showPaneResizeHandle) && chartPaintReady && (
          <div
            className="chart-pane-resize-handle"
            style={{ top: candlePaneBottomY }}
            onPointerDown={onPaneResizePointerDown}
            onPointerMove={onPaneResizePointerMove}
            onPointerUp={onPaneResizePointerUp}
            onPointerCancel={onPaneResizePointerUp}
          />
        )}
        {showRightToolbar && (
          <ChartRightToolbar
            manager={chartMgr}
            indicators={indicators}
            chartHeight={chartBodyHeight}
            paused={paneLegendPaused}
            candleOverlayToggles={candleOverlayToggles}
            onToggleCandleOverlay={onToggleCandleOverlay}
            overlayControlsOnly={showOverlayControlsOnly}
            customOverlayActive={customOverlayActive}
            onCustomOverlayActiveChange={onCustomOverlayActiveChange}
            onOpenCustomOverlaySettings={onOpenCustomOverlaySettings}
            custom1OverlayActive={custom1OverlayActive}
            onCustom1OverlayActiveChange={onCustom1OverlayActiveChange}
            onOpenCustom1OverlaySettings={onOpenCustom1OverlaySettings}
            priceExtremeLabelsVisible={priceExtremeLabelsVisible}
            onTogglePriceExtremeLabels={onTogglePriceExtremeLabels}
            onSplit={onSplitIndicatorPane}
            onRemove={onRemoveIndicator}
            onDuplicate={onDuplicateIndicator}
            onExpand={onExpandIndicator}
            onRestore={onRestoreIndicators}
            focusedId={focusedIndicatorId}
            onDragStart={(id, e) => paneDragRef.current?.(id, e)}
            onToggleHidden={onToggleIndicatorHidden}
            onSettings={onOpenIndicatorSettings}
          />
        )}
      </div>
      {ctxMenu && (
        <ChartContextMenu
          x={ctxMenu.x}
          y={ctxMenu.y}
          marketLabel={getKoreanName(market) || market.replace(/^KRW-/, '')}
          priceLabel={`${fmtCtxPrice(ctxMenu.price)} KRW`}
          onBuy={() => closeCtxAndRequest('buy')}
          onSell={() => closeCtxAndRequest('sell')}
          onClose={() => setCtxMenu(null)}
        />
      )}
      {chartMgr && (
        <CandlePaneTimeAxisPortal
          manager={chartMgr}
          getContainer={() => containerRef.current}
        />
      )}
      {chartMgr && chartPaintReady && paneSeparatorOptions && (
        <PaneSeparatorOverlay
          manager={chartMgr}
          options={paneSeparatorOptions}
        />
      )}
      {chartMgr && (
        <DrawingCanvas
          manager={chartMgr}
          activeTool={drawingTool}
          drawings={drawings}
          onAddDrawing={onAddDrawing}
          onZoom={(x0, x1) => chartMgr.zoomToXRange(x0, x1)}
          theme={theme}
          enabled={mode === 'trading' || drawingTool === 'zoom' || drawingTool === 'magnifier'}
          visible={drawingsVisible}
          locked={drawingsLocked}
          selectedDrawingId={selectedDrawingId}
        />
      )}
      {chartMgr && chartPaintReady && (
        <TradeSignalLabelOverlay
          manager={chartMgr}
          getMountTarget={() => wrapperRef.current}
        />
      )}
      {/* PaneLegend: containerRef 를 상태에 저장해 React 렌더와 동기화 */}
      {chartMgr && showCandlePaneControls && !candleOnlyLocked && (
        <CandlePaneControlsPortal
          manager={chartMgr}
          getContainer={() => containerRef.current}
          candleOnly={candleOnlyMode}
          onExpand={() => setCandleOnlyMode(true)}
          onRestore={() => setCandleOnlyMode(false)}
        />
      )}
      {chartMgr && focusedIndicatorId && onRestoreIndicators && chartPaintReady && (
        <IndicatorPaneControlsPortal
          manager={chartMgr}
          indicators={indicators}
          getContainer={() => containerRef.current}
          focusedId={focusedIndicatorId}
          onRestore={onRestoreIndicators}
        />
      )}
      {chartMgr && !candleOnlyMode && showPaneLegend && chartPaintReady && (
        <PaneLegendPortal
          key={indKey(indicators)}
          manager={chartMgr}
          indicators={indicators}
          paused={paneLegendPaused}
          getContainer={() => containerRef.current}
          onHoverName={(id, sx, sy) => onHoverPaneLegend?.(id, sx, sy)}
          onLeaveName={() => onLeavePaneLegend?.()}
          onDoubleClick={(id) => onSeriesDoubleClick?.(id, { x: 0, y: 0 })}
          onReorder={onReorderIndicators}
          onMerge={onMergeIndicators}
          dragHandleRef={paneDragRef}
          crosshairInfoVisible={crosshairInfoVisible}
        />
      )}

      {/* 마우스 오버 시 표시되는 플로팅 줌/스크롤 툴바 */}
      {chartMgr && showHoverToolbar && (
        <ChartHoverToolbar
          onZoomOut={()      => managerRef.current?.zoomOut()}
          onZoomIn={()       => managerRef.current?.zoomIn()}
          onFit={()          => managerRef.current?.fitContent()}
          onScrollLeft={()   => managerRef.current?.scrollLeft()}
          onScrollRight={()  => managerRef.current?.scrollRight()}
          onFullView={onFullView}
        />
      )}

      {/* 돋보기 오버레이 */}
      <ChartMagnifier
        containerRef={containerRef}
        wrapperRef={wrapperRef}
        enabled={magnifierEnabled}
        onClose={onMagnifierClose ?? (() => {})}
      />
    </div>
  );
};

const CandlePaneTimeAxisPortal: React.FC<{
  manager: ChartManager;
  getContainer: () => HTMLElement | null;
}> = ({ manager, getContainer }) => {
  const [el, setEl] = useState<HTMLElement | null>(null);
  useEffect(() => {
    const c = getContainer();
    if (c) { setEl(c); return; }
    const tid = setTimeout(() => setEl(getContainer()), 100);
    return () => clearTimeout(tid);
  // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);
  if (!el) return null;
  return <CandlePaneTimeAxis manager={manager} containerEl={el} />;
};

const IndicatorPaneControlsPortal: React.FC<{
  manager: ChartManager;
  indicators: IndicatorConfig[];
  getContainer: () => HTMLElement | null;
  focusedId: string;
  onRestore: () => void;
}> = ({ manager, indicators, getContainer, focusedId, onRestore }) => {
  const [el, setEl] = useState<HTMLElement | null>(null);
  useEffect(() => {
    const c = getContainer();
    if (c) { setEl(c); return; }
    const tid = setTimeout(() => setEl(getContainer()), 100);
    return () => clearTimeout(tid);
  // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);
  if (!el) return null;
  return (
    <IndicatorPaneControls
      manager={manager}
      containerEl={el}
      indicators={indicators}
      focusedId={focusedId}
      onRestore={onRestore}
    />
  );
};

const CandlePaneControlsPortal: React.FC<{
  manager: ChartManager;
  getContainer: () => HTMLElement | null;
  candleOnly: boolean;
  onExpand: () => void;
  onRestore: () => void;
}> = ({ manager, getContainer, candleOnly, onExpand, onRestore }) => {
  const [el, setEl] = useState<HTMLElement | null>(null);
  useEffect(() => {
    const c = getContainer();
    if (c) { setEl(c); return; }
    const tid = setTimeout(() => setEl(getContainer()), 100);
    return () => clearTimeout(tid);
  // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);
  if (!el) return null;
  return (
    <CandlePaneControls
      manager={manager}
      containerEl={el}
      candleOnly={candleOnly}
      onExpand={onExpand}
      onRestore={onRestore}
    />
  );
};

// ── containerEl 을 안전하게 주입하는 래퍼 ───────────────────────────────────
const PaneLegendPortal: React.FC<
  Omit<PaneLegendProps, 'containerEl'> & { getContainer: () => HTMLElement | null }
> = ({ getContainer, paused, ...rest }) => {
  const [el, setEl] = useState<HTMLElement | null>(null);

  useEffect(() => {
    // 마운트 직후 containerEl 확보
    const container = getContainer();
    if (container) { setEl(container); return; }
    // 혹시 null 이면 짧게 재시도
    const tid = setTimeout(() => setEl(getContainer()), 100);
    return () => clearTimeout(tid);
  // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  if (!el) return null;
  return <PaneLegend {...rest} containerEl={el} paused={paused} />;
};

// ── 변경 감지용 키 생성 헬퍼 ────────────────────────────────────────────────
function countExpectedVisibleIndicators(inds: IndicatorConfig[]): number {
  return inds.filter(i => i.hidden !== true).length;
}

function barsKey(bars: OHLCVBar[], market = ''): string {
  if (bars.length === 0) return '';
  const last = bars[bars.length - 1];
  // last.close 제외 — 틱마다 full reload 방지 (실시간은 updateBar/appendBar 경로)
  // volume 합계 — STOMP volume=0 → Upbit 폴백 후 OBV 등 지표 재계산 유도
  const volSum = bars.reduce((s, b) => s + (Number(b.volume) || 0), 0);
  const volFlag = volSum > 0 ? 'v' : '0';
  return `${market}:${bars.length}:${bars[0].time}:${last.time}:${volFlag}`;
}

/**
 * 재계산이 필요한 변경 키 (params·플롯 구성·추가·제거) — 배열 순서 기준
 * 이 키가 바뀌면 시리즈를 재생성해야 한다.
 */
function paramKey(inds: IndicatorConfig[]): string {
  return inds.map(i =>
    `${i.id}|${JSON.stringify(i.params)}|${(i.plots ?? []).map(p => p.id).sort().join(',')}|${i.mergedWith ?? ''}`
  ).join(';');
}

/**
 * 순서 무관 파라미터 키 — id 기준으로 정렬
 * paramKey 값은 같지만 배열 순서만 다른 경우를 감지하는 데 사용.
 */
function sortedParamKey(inds: IndicatorConfig[]): string {
  return [...inds]
    .sort((a, b) => a.id.localeCompare(b.id))
    .map(i => `${i.id}|${JSON.stringify(i.params)}|${(i.plots ?? []).map(p => p.id).sort().join(',')}|${i.mergedWith ?? ''}`)
    .join(';');
}

/**
 * 스타일만 포함하는 키 (색상·선폭·plotVisibility)
 * 이 키만 바뀌면 시리즈를 재생성하지 않고 in-place 업데이트.
 */
function styleKey(inds: IndicatorConfig[]): string {
  return inds.map(i =>
    `${i.id}|${i.hidden ? '1' : '0'}|${i.lastValueVisible === false ? '0' : '1'}|${(i.plots ?? []).map(p => `${p.color ?? ''}:${p.lineWidth ?? 1}:${p.lineStyle ?? 'solid'}:${p.histogramUpColor ?? ''}:${p.histogramDownColor ?? ''}`).join(',')}|${JSON.stringify(i.plotVisibility ?? {})}|${JSON.stringify((i.hlines ?? []).map(h => `${h.price}:${h.color}:${h.visible ?? true}:${h.lineStyle ?? 'dashed'}:${h.lineWidth ?? 1}`))}|${JSON.stringify(i.cloudColors ?? {})}|${JSON.stringify(i.hlinesBackground ?? {})}|${JSON.stringify(i.bandBackground ?? {})}`
  ).join(';');
}

/** 전체 변경 감지 키 (두 키를 합침) */
function indKey(inds: IndicatorConfig[]): string {
  return paramKey(inds) + '@@' + styleKey(inds);
}

export default memo(TradingChart);
