import {
  createChart,
  CandlestickSeries,
  BarSeries,
  LineSeries,
  AreaSeries,
  BaselineSeries,
  HistogramSeries,
  createSeriesMarkers,
  type IChartApi,
  type ISeriesApi,
  type SeriesType,
  type MouseEventParams,
  type Time,
  type TickMarkFormatter,
  type LogicalRange,
  type IPriceLine,
  type ISeriesMarkersPluginApi,
  ColorType,
  LineStyle,
  CrosshairMode,
} from 'lightweight-charts';
import { IndicatorFillPrimitive } from './IndicatorFillPrimitive';
import { BollingerBandFillPrimitive } from './BollingerBandFillPrimitive';
import { resolveBbBandBackground } from './bollingerConfig';
import type { OHLCVBar, ChartType, Theme, IndicatorConfig, Timeframe } from '../types';
import { formatLwcTime, formatLwcTickMark, DEFAULT_DISPLAY_TIMEZONE } from './timezone';
import { sortIndicatorsForPaneLoad } from './indicatorPaneMerge';
import { calculateIndicator, enrichIndicatorConfig, getIndicatorDef, getHLineLabel, type PlotData, type MarkerData } from './indicatorRegistry';
import { IchimokuCloudPlugin, type IchimokuCloudPoint } from './IchimokuCloudPlugin';
import {
  getIchimokuPlotTitle,
  isIchimokuCloudVisible,
  resolveIchimokuCloudColors,
} from './ichimokuConfig';
import type { PlotDef, HLineStyle } from './indicatorRegistry';

function plotSeriesLineStyle(style?: HLineStyle): number {
  if (style === 'dashed') return LineStyle.Dashed;
  if (style === 'dotted') return LineStyle.Dotted;
  return LineStyle.Solid;
}
import { calcAutoFib } from './calculations';

interface ThemeTokens {
  bgColor:        string;
  textColor:      string;
  gridColor:      string;
  borderColor:    string;
  crosshairColor: string;
  upColor:        string;
  downColor:      string;
}

// 다크: 도쿄 나이트 스타일 (THEME_SETTINGS.md §2.2 / §3 darkChartColors)
const DARK: ThemeTokens = {
  bgColor:        '#1a1b26',
  textColor:      '#c0caf5',
  gridColor:      'rgba(122, 162, 247, 0.10)',
  borderColor:    'rgba(122, 162, 247, 0.15)',
  crosshairColor: 'rgba(122, 162, 247, 0.60)',
  upColor:        '#ff6b6b',   // 한국 관례: 상승=빨간색
  downColor:      '#4dabf7',   // 한국 관례: 하락=파란색
};
// 라이트 (THEME_SETTINGS.md §2.1 / §3 lightChartColors)
const LIGHT: ThemeTokens = {
  bgColor:        '#ffffff',
  textColor:      '#212121',
  gridColor:      '#f0f0f0',
  borderColor:    'rgba(0, 0, 0, 0.08)',
  crosshairColor: 'rgba(33, 150, 243, 0.50)',
  upColor:        '#c62828',   // 한국 관례: 상승=빨간색
  downColor:      '#1565c0',   // 한국 관례: 하락=파란색
};
// 블루: 딥 네이비 (THEME_SETTINGS.md §2.3 / §3 blueChartColors)
const BLUE: ThemeTokens = {
  bgColor:        '#0c1929',
  textColor:      '#e8f4ff',
  gridColor:      'rgba(94, 181, 255, 0.10)',
  borderColor:    'rgba(94, 181, 255, 0.15)',
  crosshairColor: 'rgba(94, 181, 255, 0.60)',
  upColor:        '#ff5555',   // 한국 관례: 상승=빨간색
  downColor:      '#55aaff',   // 한국 관례: 하락=파란색
};

interface IndicatorEntry {
  id:           string;
  type:         string;
  seriesList:   ISeriesApi<SeriesType>[];
  /**
   * seriesList 순서와 1:1 대응
   * - plotId: 해당 시리즈가 매핑된 plots 객체의 키 (예: 'plot0', 'plot1')
   *   → _applyLastPointsToSeries 에서 인덱스 대신 plotId 로 정확히 조회
   * - isHistogram: histogram 시리즈 여부 (색상 재계산용)
   * - color: 기본 색상
   */
  seriesMeta:   Array<{ plotId: string; isHistogram: boolean; color: string }>;
  paneIndex?:   number;
  alertLines:   IPriceLine[];
  /** 수평 기준선(과매수/과매도/0선)의 IPriceLine 참조 — 첫 번째 시리즈에만 생성 */
  hlineRefs:     IPriceLine[];
  /** 과매수/과매도 음영 채움 프리미티브 */
  fillPrimitive?: import('./IndicatorFillPrimitive').IndicatorFillPrimitive;
  /** 볼린저밴드 어퍼~로우어 영역 채움 */
  bbFillPrimitive?: BollingerBandFillPrimitive;
  config:        IndicatorConfig;
  cloudPlugin?:  IchimokuCloudPlugin;  // 일목균형표 구름 플러그인
}

interface AlertEntry  { price: number; label: string; line: IPriceLine; }
interface FibLevel    { price: number; label: string; line: IPriceLine; }

function getTheme(theme: Theme): ThemeTokens {
  if (theme === 'blue')  return BLUE;
  if (theme === 'light') return LIGHT;
  return DARK;
}

function seriesByPlotId(
  entry: IndicatorEntry,
  plotId: string,
): ISeriesApi<SeriesType> | undefined {
  const i = entry.seriesMeta.findIndex(m => m.plotId === plotId);
  return i >= 0 ? entry.seriesList[i] : undefined;
}

function shouldShowBbBandFill(config: IndicatorConfig): boolean {
  const bg = resolveBbBandBackground(config.bandBackground);
  if (!bg.visible) return false;
  if (config.hidden === true) return false;
  if (config.plotVisibility?.plot1 === false || config.plotVisibility?.plot2 === false) return false;
  return true;
}

function attachBbBandFill(entry: IndicatorEntry, config: IndicatorConfig): void {
  const basis = seriesByPlotId(entry, 'plot0');
  const upper = seriesByPlotId(entry, 'plot1');
  const lower = seriesByPlotId(entry, 'plot2');
  if (!basis || !upper || !lower || !shouldShowBbBandFill(config)) return;
  const bg = resolveBbBandBackground(config.bandBackground);
  entry.bbFillPrimitive = new BollingerBandFillPrimitive(upper, lower, bg);
  basis.attachPrimitive(entry.bbFillPrimitive);
}

function detachBbBandFill(entry: IndicatorEntry): void {
  if (!entry.bbFillPrimitive) return;
  const basis = seriesByPlotId(entry, 'plot0');
  if (basis) {
    try { basis.detachPrimitive(entry.bbFillPrimitive); } catch { /* ok */ }
  }
  entry.bbFillPrimitive = undefined;
}

export class ChartManager {
  private chart: IChartApi;
  private container: HTMLElement;
  private mainSeries: ISeriesApi<SeriesType> | null = null;
  private volumeSeries: ISeriesApi<'Histogram'> | null = null;
  private indicators   = new Map<string, IndicatorEntry>();
  private alertEntries: AlertEntry[] = [];
  private fibLines:     FibLevel[]   = [];
  private drawingTool   = 'cursor';
  private drawingPoints: { time: Time; price: number }[] = [];
  private drawingLines:  IPriceLine[] = [];
  private _clickHandler: ((p: MouseEventParams<Time>) => void) | null = null;
  private rawBars: OHLCVBar[] = [];
  /** setData 호출 세대 — addIndicator 중 stale rawBars 적용 방지 */
  private _dataGeneration = 0;

  /** 진행 중인 보조지표 스로틀 갱신 취소 (종목 전환·재로드 시) */
  cancelPendingIndicatorUpdates(): void {
    if (this._indUpdateTimer) {
      clearTimeout(this._indUpdateTimer);
      this._indUpdateTimer = null;
    }
    this._pendingIndUpdate = false;
    this._indRunning       = false;
  }
  private currentTheme: Theme = 'dark';
  private currentChartType: ChartType = 'candlestick';
  private displayTimezone = DEFAULT_DISPLAY_TIMEZONE;
  private displayTimeframe: Timeframe = '1D';
  private mainMarkersPlugin: ISeriesMarkersPluginApi<Time> | null = null;
  private patternMarkers: Array<{ id: string; markers: MarkerData[] }> = [];
  /** 백테스팅 매수/매도 시그널 마커 */
  private backtestMarkers: MarkerData[] = [];
  /** 실시간 전략 체크 마커 */
  private liveStrategyMarkers: MarkerData[] = [];
  private compareSeriesMap = new Map<string, ISeriesApi<SeriesType>>();

  // ── 인디케이터 실시간 갱신 스로틀 ─────────────────────────────────────────
  private _indUpdateTimer:  ReturnType<typeof setTimeout> | null = null;
  private _pendingIndUpdate = false;
  /** true 이면 _updateIndicatorLastPoints 가 이미 실행 중 → 중복 실행 방지 */
  private _indRunning       = false;
  private static readonly IND_THROTTLE_MS = 300;

  // ── 시리즈 클릭 감지 (단일/더블) ─────────────────────────────────────────
  private _lastClickTime = 0;
  private _lastClickEntryId = '';
  private _clickSingleTimer: ReturnType<typeof setTimeout> | null = null;

  // ── 크로스헤어 hover 추적 (오버레이 지표 클릭 감지용) ─────────────────────
  /** 현재 크로스헤어가 위에 있는 시리즈의 entryId ('__main__' | indicatorId | null) */
  private _crosshairEntryId: string | null = null;

  /** 우측 가격축 라벨·plot 설명(전환선 등)·금액 색상 하이라이트 */
  private _seriesPriceLabelsEnabled = true;

  /** 차트 하단 거래량 pane 표시 */
  private _volumeVisible = true;

  /** applyPaneLayout / resetPaneHeights 에서 측정한 마지막 가용 높이(px) */
  private _lastLayoutAvailableHeight?: number;

  constructor(container: HTMLElement, theme: Theme) {
    this.container = container;
    const t = getTheme(theme);
    this.chart = createChart(container, {
      /* autoSize: true → LWC가 내부적으로 ResizeObserver를 사용해 컨테이너 크기 변경을 자동으로 처리.
         멀티 레이아웃에서 초기 렌더 시 컨테이너가 0 크기일 때도 차트가 정상 표시됨. */
      autoSize: true,
      layout: {
        background: { type: ColorType.Solid, color: t.bgColor },
        textColor:  t.textColor,
        fontSize:   12,
        panes: { enableResize: false },
      },
      grid: {
        vertLines: { color: t.gridColor },
        horzLines: { color: t.gridColor },
      },
      crosshair: {
        mode: CrosshairMode.Normal,   /* 기본: 마우스 위치 자유 이동 (자석모드 OFF) */
        vertLine: { color: t.crosshairColor, labelBackgroundColor: '#9598A1' },
        horzLine: { color: t.crosshairColor, labelBackgroundColor: '#9598A1' },
      },
      timeScale: {
        borderColor:       t.borderColor,
        timeVisible:       true,
        secondsVisible:    false,
        rightOffset:       12,
        fixLeftEdge:       false,
        fixRightEdge:      false,
        lockVisibleTimeRangeOnResize: false,
      },
      rightPriceScale: { borderColor: t.borderColor },
      // pressedMouseMove: TradingChart 에서 커스텀 패닝 처리 (드로잉·클릭과 충돌 방지)
      handleScroll: { mouseWheel: true, pressedMouseMove: false, horzTouchDrag: true, vertTouchDrag: false },
      handleScale:  { axisPressedMouseMove: true, axisDoubleClickReset: true, mouseWheel: true, pinch: true },
    });

    this._applyDisplayTimezoneOptions();

    // 크로스헤어 이동 시 hover 중인 시리즈 추적 (오버레이 클릭 감지에 사용)
    this.chart.subscribeCrosshairMove((params: MouseEventParams<Time>) => {
      if (params.hoveredSeries) {
        const hovered = params.hoveredSeries as ISeriesApi<SeriesType>;
        for (const [id, entry] of this.indicators) {
          if (entry.seriesList.some(s => s === hovered)) {
            this._crosshairEntryId = id;
            return;
          }
        }
        // mainSeries 또는 volumeSeries 위라면 '__main__'
        this._crosshairEntryId = '__main__';
      } else {
        this._crosshairEntryId = null;
      }
    });
  }

  // ─── Data ───────────────────────────────────────────────────────────────
  setData(bars: OHLCVBar[], chartType: ChartType, theme: Theme): void {
    // 전체 재로드 전: 진행 중인 지표 갱신 타이머·플래그를 초기화해
    // 이전 rawBars 기반의 stale 계산이 새 지표에 적용되는 것을 방지한다.
    this.cancelPendingIndicatorUpdates();
    this._dataGeneration  += 1;

    this.rawBars = bars;
    this.currentTheme     = theme;
    this.currentChartType = chartType;
    const t = getTheme(theme);

    // 기존 보조지표 시리즈를 모두 제거 (새 데이터로 재계산하기 위해)
    for (const entry of this.indicators.values()) {
      if (entry.cloudPlugin) {
        for (const s of entry.seriesList) { try { s.detachPrimitive(entry.cloudPlugin); } catch { /* ok */ } }
      }
      detachBbBandFill(entry);
      for (const s of entry.seriesList) { try { this.chart.removeSeries(s); } catch { /* ok */ } }
    }
    this.indicators.clear();
    this.patternMarkers = [];
    this._removeExtraSubPanes();

    this._disposeMainMarkersPlugin();
    if (this.mainSeries)   { try { this.chart.removeSeries(this.mainSeries); } catch { /* ok */ } this.mainSeries = null; }
    if (this.volumeSeries) { try { this.chart.removeSeries(this.volumeSeries); } catch { /* ok */ } this.volumeSeries = null; }

    this.mainSeries = this._createMainSeries(chartType, t);
    const mainData = bars.map(b => {
      if (chartType === 'line' || chartType === 'area' || chartType === 'baseline')
        return { time: b.time as Time, value: b.close };
      return { time: b.time as Time, open: b.open, high: b.high, low: b.low, close: b.close };
    });
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    (this.mainSeries as ISeriesApi<any>).setData(mainData);

    // Volume sub-pane (pane index 1)
    this.volumeSeries = this.chart.addSeries(HistogramSeries, {
      priceFormat: { type: 'volume' },
      priceScaleId: 'volume',
    }, 1);

    // 초기 volume pane 크기: resetPaneHeights() 호출 전 임시 설정
    // (setHeight 대신 setStretchFactor - LWC 내부 기준 높이와 무관하게 동작)
    const panesInit = this.chart.panes();
    if (panesInit[0]) panesInit[0].setStretchFactor(0.88);
    if (panesInit[1]) panesInit[1].setStretchFactor(0.12);

    this.volumeSeries.setData(bars.map(b => ({
      time:  b.time as Time,
      value: b.volume,
      color: b.close >= b.open ? `${t.upColor}88` : `${t.downColor}88`,
    })));

    if (!this._volumeVisible) {
      try {
        this.volumeSeries.applyOptions({ visible: false } as Parameters<ISeriesApi<SeriesType>['applyOptions']>[0]);
      } catch { /* ok */ }
    }

    this._reapplyAllPatternMarkers();

    // 이전 timeScale visible range를 완전히 리셋 후 fitContent
    // setVisibleRange → 이전 zoom 상태를 무시하고 새 데이터 범위로 명시 초기화
    const ts = this.chart.timeScale();
    if (bars.length >= 2) {
      ts.setVisibleRange({
        from: bars[0].time as Time,
        to:   bars[bars.length - 1].time as Time,
      });
    }
    ts.fitContent();
  }

  private _createMainSeries(chartType: ChartType, t: ThemeTokens): ISeriesApi<SeriesType> {
    switch (chartType) {
      case 'candlestick':
        return this.chart.addSeries(CandlestickSeries, {
          upColor: t.upColor, downColor: t.downColor,
          borderUpColor: t.upColor, borderDownColor: t.downColor,
          wickUpColor: t.upColor, wickDownColor: t.downColor,
        });
      case 'bar':
        return this.chart.addSeries(BarSeries, { upColor: t.upColor, downColor: t.downColor });
      case 'line':
        return this.chart.addSeries(LineSeries, { color: '#2962FF', lineWidth: 2, crosshairMarkerVisible: true });
      case 'area':
        return this.chart.addSeries(AreaSeries, {
          lineColor: '#2962FF', topColor: '#2962FF44', bottomColor: '#2962FF00', lineWidth: 2,
        });
      case 'baseline':
        return this.chart.addSeries(BaselineSeries, {
          baseValue: { type: 'price', price: 0 },
          topLineColor: '#26a69a', topFillColor1: '#26a69a44', topFillColor2: '#26a69a00',
          bottomLineColor: '#ef5350', bottomFillColor1: '#ef535000', bottomFillColor2: '#ef535044',
        });
      default:
        return this.chart.addSeries(CandlestickSeries, {
          upColor: t.upColor, downColor: t.downColor,
          borderUpColor: t.upColor, borderDownColor: t.downColor,
          wickUpColor: t.upColor, wickDownColor: t.downColor,
        });
    }
  }

  // ─── Display timezone ───────────────────────────────────────────────────
  private _tickMarkFormatter(): TickMarkFormatter {
    return (time, tickMarkType) =>
      formatLwcTickMark(
        time as Time,
        tickMarkType,
        this.displayTimeframe,
        this.displayTimezone,
      );
  }

  private _applyDisplayTimezoneOptions(): void {
    const tf = this.displayTimeframe;
    const tz = this.displayTimezone;
    const timeFormatter = (time: Time) =>
      formatLwcTime(time as number | { year: number; month: number; day: number }, tf, tz);
    this.chart.applyOptions({
      localization: { timeFormatter },
      timeScale: { tickMarkFormatter: this._tickMarkFormatter() },
    });
  }

  setDisplayTimezone(tz: string, timeframe?: Timeframe): void {
    this.displayTimezone = tz || DEFAULT_DISPLAY_TIMEZONE;
    if (timeframe) this.displayTimeframe = timeframe;
    this._applyDisplayTimezoneOptions();
  }

  // ─── Theme ──────────────────────────────────────────────────────────────
  setTheme(theme: Theme): void {
    const t = getTheme(theme);
    this.chart.applyOptions({
      layout:    { background: { type: ColorType.Solid, color: t.bgColor }, textColor: t.textColor },
      grid:      { vertLines: { color: t.gridColor }, horzLines: { color: t.gridColor } },
      crosshair: { vertLine: { color: t.crosshairColor, labelBackgroundColor: '#9598A1' }, horzLine: { color: t.crosshairColor, labelBackgroundColor: '#9598A1' } },
      timeScale: { borderColor: t.borderColor },
      rightPriceScale: { borderColor: t.borderColor },
    });
  }

  // ─── Indicators ─────────────────────────────────────────────────────────
  async addIndicator(config: IndicatorConfig): Promise<void> {
    config = enrichIndicatorConfig(config);
    if (this.indicators.has(config.id) || this.rawBars.length === 0) return;
    const dataGenAtStart = this._dataGeneration;
    const indicatorHidden = config.hidden === true;
    const def = getIndicatorDef(config.type);
    const seriesList: ISeriesApi<SeriesType>[] = [];
    const seriesMeta: IndicatorEntry['seriesMeta'] = [];

    try {
      // 스냅샷 전달: await 중 rawBars 가 변경돼도 이 지표는 일관된 초기 데이터로 setData
      const result = await calculateIndicator(config.type, this.rawBars.slice(), config.params);

      if (dataGenAtStart !== this._dataGeneration) return;

      if (def?.returnsMarkers && result.markers && result.markers.length > 0) {
        this.patternMarkers.push({ id: config.id, markers: result.markers });
        this._reapplyAllPatternMarkers();
        this.indicators.set(config.id, { id: config.id, type: config.type, seriesList: [], seriesMeta: [], alertLines: [], hlineRefs: [], config });
        return;
      }

      const pane = def?.overlay ? 0 : this._resolveSubPane(config);

      // ─── 일목균형표 특별 처리 ────────────────────────────────────────────
      if (config.type === 'IchimokuCloud') {
        const { cloudPlugin, seriesMeta: ichiMeta } = await this._addIchimokuSeries(result, pane, config, seriesList);
        this.indicators.set(config.id, { id: config.id, type: config.type, seriesList, seriesMeta: ichiMeta, paneIndex: pane, alertLines: [], hlineRefs: [], config, cloudPlugin });
        this.applyIndicatorStyle(config);
        return;
      }

      const plotDefs = config.plots ?? def?.plots ?? [];

      for (const plotDef of plotDefs) {
        const plotData = result.plots[plotDef.id] as PlotData | undefined;
        if (!plotData || plotData.length === 0) continue;
        const filtered = plotData.filter(p => p.value !== null && !isNaN(p.value));
        if (filtered.length === 0) continue;

        let series: ISeriesApi<SeriesType>;
        if (plotDef.type === 'histogram') {
          series = this.chart.addSeries(HistogramSeries, { color: plotDef.color }, pane);
          const colorData = filtered.map((p, i, arr) => ({
            time:  p.time as Time,
            value: p.value,
            color: p.color ?? (i > 0 && p.value < arr[i - 1].value
              ? '#ef535088'
              : p.value < 0 ? '#ef535088' : `${plotDef.color}88`),
          }));
          series.setData(colorData);
          seriesMeta.push({ plotId: plotDef.id, isHistogram: true, color: plotDef.color });
        } else {
          const isPlotVisible = !indicatorHidden && config.plotVisibility?.[plotDef.id] !== false;
          const showPriceLabel = this.shouldShowSeriesPriceLabel(config);
          series = this.chart.addSeries(LineSeries, {
            color:            plotDef.color,
            lineWidth:        (plotDef.lineWidth ?? 1) as 1 | 2 | 3 | 4,
            lineStyle:        plotSeriesLineStyle(plotDef.lineStyle),
            priceLineVisible: false,
            lastValueVisible: showPriceLabel,
            title:            this.seriesTitleForPlot(config, plotDef),
            visible:          isPlotVisible,
          }, pane);
          series.setData(filtered.map(p => ({ time: p.time as Time, value: p.value })));
          seriesMeta.push({ plotId: plotDef.id, isHistogram: false, color: plotDef.color });
        }

        seriesList.push(series);
      }

      // hlines(과열선/중앙선/침체선)는 첫 번째 시리즈에만 한 번 생성 (중복 방지)
      const hlineRefs: IPriceLine[] = [];
      let fillPrimitive: IndicatorFillPrimitive | undefined;

      if (!indicatorHidden && seriesList.length > 0) {
        const firstSeries = seriesList[0];
        const hlines = config.hlines ?? def?.hlines ?? [];

        // HLineStyle → lightweight-charts LineStyle 변환
        const toLineStyle = (s?: string): number => {
          if (s === 'solid')  return LineStyle.Solid;
          if (s === 'dotted') return LineStyle.Dotted;
          return LineStyle.Dashed; // 기본값
        };

        for (const hl of hlines) {
          if (hl.visible === false) continue;
          const pl = firstSeries.createPriceLine({
            price:            hl.price,
            color:            hl.color,
            lineWidth:        Math.min(4, hl.lineWidth ?? 1) as 1|2|3|4,
            lineStyle:        toLineStyle(hl.lineStyle),
            axisLabelVisible: false,
          });
          hlineRefs.push(pl);
        }

        // 과열선/침체선 레이블이 있는 hlines 가 있으면 음영 프리미티브 부착
        const prices = hlines.filter(h => h.visible !== false).map(h => h.price);
        const hasZone = hlines.some(h => {
          const lbl = h.label ?? getHLineLabel(h.price, prices);
          return (lbl === '과열선' || lbl === '침체선') && h.visible !== false;
        });
        const hasBg = config.hlinesBackground?.visible !== false;
        if (hasZone || hasBg) {
          fillPrimitive = new IndicatorFillPrimitive(hlines, config.hlinesBackground);
          firstSeries.attachPrimitive(fillPrimitive);
        }
      }

      const entry: IndicatorEntry = {
        id: config.id, type: config.type, seriesList, seriesMeta,
        paneIndex: pane, alertLines: [], hlineRefs, fillPrimitive, config,
      };
      this.indicators.set(config.id, entry);
      this.applyIndicatorStyle(config);
      if (config.type === 'BollingerBands' && !indicatorHidden) {
        attachBbBandFill(entry, config);
        entry.bbFillPrimitive?.requestRefresh();
      }
    } catch (e) {
      console.error(`[Indicator] ${config.type} error:`, e);
    } finally {
      if (this._activeIndicatorPaneIndices().size > 0) {
        this.resetPaneHeights(this._lastLayoutAvailableHeight);
      }
    }
  }

  /**
   * 순서 변경 전용 재로드 — 메인 차트·볼륨 시리즈는 유지하고 보조지표만 제거 후 재추가.
   * reloadAll 과 달리 setData()를 호출하지 않으므로 메인 캔들 차트가 깜빡이지 않는다.
   */
  async reloadIndicatorsOnly(inds: import('../types').IndicatorConfig[]): Promise<void> {
    // ① 기존 보조지표 시리즈 모두 제거 (메인·볼륨 제외)
    for (const entry of this.indicators.values()) {
      if (entry.cloudPlugin) {
        for (const s of entry.seriesList) { try { s.detachPrimitive(entry.cloudPlugin); } catch { /* ok */ } }
      }
      if (entry.fillPrimitive && entry.seriesList[0]) {
        try { entry.seriesList[0].detachPrimitive(entry.fillPrimitive); } catch { /* ok */ }
      }
      detachBbBandFill(entry);
      for (const s of entry.seriesList) { try { this.chart.removeSeries(s); } catch { /* ok */ } }
    }
    this.indicators.clear();
    this.patternMarkers = this.patternMarkers.filter(() => false);
    this._reapplyAllPatternMarkers();
    this._removeExtraSubPanes();

    // ② 새 순서로 재추가 (병합 호스트 → 멤버 순)
    this._indRunning = false;
    for (const ind of sortIndicatorsForPaneLoad(inds)) {
      await this.addIndicator(ind);
    }

    // 빈 pane stretch 정리 + 캔들 pane 비율 복구
    this.resetPaneHeights();
    this._notifyPaneLayout();
  }

  removeIndicator(id: string): void {
    const entry = this.indicators.get(id);
    if (!entry) return;
    // hlines price line 및 fill primitive 정리
    if (entry.seriesList.length > 0) {
      for (const pl of entry.hlineRefs) {
        try { entry.seriesList[0].removePriceLine(pl); } catch { /* ok */ }
      }
      if (entry.fillPrimitive) {
        try { entry.seriesList[0].detachPrimitive(entry.fillPrimitive); } catch { /* ok */ }
      }
    }
    if (entry.cloudPlugin) {
      for (const s of entry.seriesList) {
        try { s.detachPrimitive(entry.cloudPlugin); } catch { /* ok */ }
      }
    }
    for (const s of entry.seriesList) { try { this.chart.removeSeries(s); } catch { /* ok */ } }
    this.patternMarkers = this.patternMarkers.filter(p => p.id !== id);
    this._reapplyAllPatternMarkers();
    this.indicators.delete(id);
    this._removeExtraSubPanes();
    this.resetPaneHeights(this._lastLayoutAvailableHeight);
    this._notifyPaneLayout();
  }

  /** 메인 시리즈 마커 플러그인 해제 (setData 등 시리즈 교체 전 호출) */
  private _disposeMainMarkersPlugin(): void {
    if (!this.mainMarkersPlugin) return;
    try { this.mainMarkersPlugin.detach(); } catch { /* ok */ }
    this.mainMarkersPlugin = null;
  }

  private _reapplyAllPatternMarkers(): void {
    if (!this.mainSeries) return;
    const patternAll = this.patternMarkers.flatMap(({ markers }) =>
      markers.map(m => ({
        time:     m.time as Time,
        position: m.position,
        shape:    m.shape,
        color:    m.color,
        text:     m.text,
      }))
    );
    const backtestAll = this.backtestMarkers.map(m => ({
      time:     m.time as Time,
      position: m.position,
      shape:    m.shape,
      color:    m.color,
      text:     m.text,
    }));
    const liveAll = this.liveStrategyMarkers.map(m => ({
      time:     m.time as Time,
      position: m.position,
      shape:    m.shape,
      color:    m.color,
      text:     m.text,
    }));
    const all = [...patternAll, ...backtestAll, ...liveAll];
    if (this.mainMarkersPlugin) {
      try {
        if (this.mainMarkersPlugin.getSeries() !== this.mainSeries) {
          this._disposeMainMarkersPlugin();
        }
      } catch {
        this._disposeMainMarkersPlugin();
      }
    }
    if (!this.mainMarkersPlugin) {
      this.mainMarkersPlugin = createSeriesMarkers(this.mainSeries, all);
    } else {
      this.mainMarkersPlugin.setMarkers(all);
    }
  }

  /**
   * 백테스팅 결과 매수/매도 시그널을 캔들 차트에 마커로 표시.
   * signals 가 빈 배열이면 기존 백테스팅 마커를 제거.
   */
  setBacktestMarkers(
    signals: Array<{ time: number; type: string; price: number }>,
    showPrice = true,
  ): void {
    const isBuy  = (t: string) => t === 'BUY'  || t === 'SHORT_EXIT';
    const isSell = (t: string) => t === 'SELL' || t === 'SHORT_ENTRY' || t === 'PARTIAL_SELL';
    this.backtestMarkers = signals
      .filter(s => isBuy(s.type) || isSell(s.type))
      .map(s => {
        const buy = isBuy(s.type);
        const label =
          s.type === 'PARTIAL_SELL' ? '부분매도'
          : buy ? '매수'
          : '매도';
        const text = showPrice
          ? `${label} : ${s.price.toLocaleString()}`
          : label;
        return {
          time:     s.time,
          position: buy ? ('belowBar' as const) : ('aboveBar' as const),
          shape:    buy ? ('arrowUp' as const)  : ('arrowDown' as const),
          color:    s.type === 'PARTIAL_SELL' ? '#FF9800' : (buy ? '#26A69A' : '#EF5350'),
          text,
        };
      });
    // 시리즈 교체·detach 이후 stale 플러그인 방지 — 백테스트 마커는 항상 재생성
    this._disposeMainMarkersPlugin();
    this._reapplyAllPatternMarkers();
  }

  /** 백테스팅 마커 제거 */
  clearBacktestMarkers(): void {
    this.backtestMarkers = [];
    this._reapplyAllPatternMarkers();
  }

  /**
   * 실시간 전략 체크 마커를 업데이트한다.
   * 호출될 때마다 기존 목록을 완전히 교체하여 LWC 에 반영한다.
   */
  setLiveStrategyMarkers(
    markers: Array<{ time: number; signal: 'BUY' | 'SELL'; price: number }>,
    showPrice = true,
  ): void {
    this.liveStrategyMarkers = markers.map(m => {
      const buy  = m.signal === 'BUY';
      const text = showPrice
        ? `실시간 ${buy ? '매수' : '매도'} : ${m.price.toLocaleString()}`
        : `실시간 ${buy ? '매수' : '매도'}`;
      return {
        time:     m.time,
        position: buy ? ('belowBar' as const) : ('aboveBar' as const),
        shape:    buy ? ('arrowUp' as const)  : ('arrowDown' as const),
        color:    buy ? '#00BCD4' : '#FF9800',  // 백테스팅과 구별: 청록/주황
        text,
      };
    });
    this._reapplyAllPatternMarkers();
  }

  /** 실시간 전략 마커 전체 제거 */
  clearLiveStrategyMarkers(): void {
    this.liveStrategyMarkers = [];
    this._reapplyAllPatternMarkers();
  }

  /**
   * 일목균형표 전용: 전환선·기준선·후행스팬·선행스팬A·B 라인 + 구름 플러그인을 추가합니다.
   *
   * ichimoku.ts 실제 plot 매핑:
   *   plot0 = 전환선(Tenkan)   plot1 = 기준선(Kijun)
   *   plot2 = 후행스팬(Chikou) plot3 = 선행스팬A  plot4 = 선행스팬B
   */
  private async _addIchimokuSeries(
    result: Awaited<ReturnType<typeof calculateIndicator>>,
    pane: number,
    _config: IndicatorConfig,
    seriesList: ISeriesApi<SeriesType>[],
  ): Promise<{ cloudPlugin: IchimokuCloudPlugin; seriesMeta: IndicatorEntry['seriesMeta'] }> {
    const displacement = (_config.params?.displacement as number) ?? 26;
    const laggingPeriod = (_config.params?.laggingSpan2Periods as number) ?? 52;
    const seriesMeta: IndicatorEntry['seriesMeta'] = [];

    const plotInfo = [
      { id: 'plot0', title: getIchimokuPlotTitle('plot0'), color: '#26c6da', lastVal: true  },
      { id: 'plot1', title: getIchimokuPlotTitle('plot1'), color: '#ef5350', lastVal: true  },
      { id: 'plot2', title: getIchimokuPlotTitle('plot2'), color: '#ab47bc', lastVal: false },
      { id: 'plot3', title: getIchimokuPlotTitle('plot3'), color: '#66bb6a', lastVal: true  },
      { id: 'plot4', title: getIchimokuPlotTitle('plot4'), color: '#ef9a9a', lastVal: true  },
    ];

    const convPlot = result.plots['plot0'] as PlotData ?? [];
    const basePlot = result.plots['plot1'] as PlotData ?? [];
    let spanASeriesRef: ISeriesApi<SeriesType> | null = null;

    for (const info of plotInfo) {
      const plotData = result.plots[info.id] as PlotData | undefined;
      if (!plotData || plotData.length === 0) continue;
      const filtered = plotData.filter(p => p.value !== null && !isNaN(p.value));
      if (filtered.length === 0) continue;

      let seriesData: Array<{ time: Time; value: number }> =
        filtered.map(p => ({ time: p.time as Time, value: p.value }));

      // 선행스팬A/B: 미래 26봉 프로젝션 추가
      if (info.id === 'plot3') {
        seriesData = seriesData.concat(
          this._ichimokuFutureSpanA(convPlot, basePlot, displacement)
        );
      } else if (info.id === 'plot4') {
        seriesData = seriesData.concat(
          this._ichimokuFutureSpanB(displacement, laggingPeriod)
        );
      }

      const showPriceLabel = this.shouldShowSeriesPriceLabel(_config, info.lastVal);
      const series = this.chart.addSeries(LineSeries, {
        color: info.color,
        lineWidth: 1,
        priceLineVisible: false,
        lastValueVisible: showPriceLabel,
        title: showPriceLabel ? info.title : '',
      }, pane);
      series.setData(seriesData);
      seriesList.push(series);
      seriesMeta.push({ plotId: info.id, isHistogram: false, color: info.color });
      if (info.id === 'plot3') spanASeriesRef = series;
    }

    // 구름 플러그인 (과거 + 미래 포함)
    const cloudPlugin = new IchimokuCloudPlugin();
    const colors = resolveIchimokuCloudColors(_config.cloudColors);
    cloudPlugin.setColors(colors.bullishColor, colors.bearishColor);
    const host = spanASeriesRef ?? seriesList[0];
    if (host) {
      host.attachPrimitive(cloudPlugin);
      this._applyIchimokuCloudPlugin(cloudPlugin, result.plots, _config);
    }
    return { cloudPlugin, seriesMeta };
  }

  /** 선행스팬A의 미래 26봉: (전환선 + 기준선) / 2, 미래 타임스탬프로 매핑 */
  private _ichimokuFutureSpanA(
    convPlot: PlotData,
    basePlot: PlotData,
    displacement: number,
  ): Array<{ time: Time; value: number }> {
    const N = this.rawBars.length;
    if (N < 2) return [];
    const interval = this.rawBars[N - 1].time - this.rawBars[N - 2].time;
    const lastTime = this.rawBars[N - 1].time;
    const out: Array<{ time: Time; value: number }> = [];

    for (let k = 1; k <= displacement; k++) {
      const srcIdx = N - displacement - 1 + k;   // N-26 +k-1 → 0-based
      if (srcIdx < 0 || srcIdx >= N) continue;
      const conv = convPlot[srcIdx]?.value;
      const base = basePlot[srcIdx]?.value;
      if (conv == null || isNaN(conv) || base == null || isNaN(base)) continue;
      out.push({ time: (lastTime + k * interval) as Time, value: (conv + base) / 2 });
    }
    return out;
  }

  /** 선행스팬B의 미래 26봉: donchian(laggingPeriod), 미래 타임스탬프로 매핑 */
  private _ichimokuFutureSpanB(
    displacement: number,
    laggingPeriod: number,
  ): Array<{ time: Time; value: number }> {
    const N = this.rawBars.length;
    if (N < 2) return [];
    const interval = this.rawBars[N - 1].time - this.rawBars[N - 2].time;
    const lastTime = this.rawBars[N - 1].time;
    const out: Array<{ time: Time; value: number }> = [];

    for (let k = 1; k <= displacement; k++) {
      const srcIdx = N - displacement - 1 + k;
      if (srcIdx < 0 || srcIdx >= N) continue;
      const val = this._donchianAt(srcIdx, laggingPeriod);
      if (val === null) continue;
      out.push({ time: (lastTime + k * interval) as Time, value: val });
    }
    return out;
  }

  /** 특정 바 인덱스에서 Donchian 중선을 계산 */
  private _donchianAt(idx: number, period: number): number | null {
    const start = idx - period + 1;
    if (start < 0) return null;
    let hi = -Infinity, lo = Infinity;
    for (let i = start; i <= idx; i++) {
      if (this.rawBars[i].high > hi) hi = this.rawBars[i].high;
      if (this.rawBars[i].low  < lo) lo = this.rawBars[i].low;
    }
    return (hi + lo) / 2;
  }

  /** 구름 데이터 빌드: 과거(역사 데이터) + 미래 26봉 포함 */
  private _buildIchimokuCloudData(
    spanAPlot: PlotData,
    spanBPlot: PlotData,
    convPlot:  PlotData,
    basePlot:  PlotData,
    displacement: number,
    laggingPeriod: number,
  ): IchimokuCloudPoint[] {
    // 과거 구름
    const bMap = new Map(
      (spanBPlot ?? []).filter(p => p.value != null && !isNaN(p.value)).map(p => [p.time, p.value])
    );
    const historical: IchimokuCloudPoint[] = (spanAPlot ?? [])
      .filter(p => p.value != null && !isNaN(p.value) && bMap.has(p.time))
      .map(p => ({ time: p.time as number, spanA: p.value, spanB: bMap.get(p.time)! }));

    // 미래 구름
    const N = this.rawBars.length;
    if (N < 2) return historical;
    const interval = this.rawBars[N - 1].time - this.rawBars[N - 2].time;
    const lastTime  = this.rawBars[N - 1].time;
    const future: IchimokuCloudPoint[] = [];

    for (let k = 1; k <= displacement; k++) {
      const srcIdx = N - displacement - 1 + k;
      if (srcIdx < 0 || srcIdx >= N) continue;
      const conv = convPlot[srcIdx]?.value;
      const base = basePlot[srcIdx]?.value;
      if (conv == null || isNaN(conv) || base == null || isNaN(base)) continue;
      const spanA = (conv + base) / 2;
      const spanB = this._donchianAt(srcIdx, laggingPeriod);
      if (spanB === null) continue;
      future.push({ time: lastTime + k * interval, spanA, spanB });
    }
    return [...historical, ...future];
  }

  private _getNextSubPane(): number {
    const used = new Set(Array.from(this.indicators.values()).map(e => e.paneIndex).filter(Boolean));
    let pane = 2;
    while (used.has(pane)) pane++;
    return pane;
  }

  /** mergedWith 가 있으면 호스트 pane, 없으면 새 sub-pane */
  private _resolveSubPane(config: IndicatorConfig): number {
    if (config.mergedWith) {
      const host = this.indicators.get(config.mergedWith);
      if (host?.paneIndex != null && host.paneIndex >= 2) {
        return host.paneIndex;
      }
    }
    return this._getNextSubPane();
  }

  // ─── 실시간 바 업데이트 (WebSocket 틱) ────────────────────────────────────
  /**
   * 현재 캔들을 틱 데이터로 갱신.
   * - 메인 캔들 + 볼륨: 즉시 반영 (매 틱)
   * - 보조지표 마지막 포인트: 300ms 스로틀 후 갱신 (과도한 재계산 방지)
   */
  updateBar(bar: OHLCVBar): void {
    if (!this.mainSeries) return;
    const t = getTheme(this.currentTheme);

    // 1) rawBars 업데이트
    let chartTime = bar.time;
    if (this.rawBars.length > 0) {
      const last = this.rawBars[this.rawBars.length - 1];
      if (last.time === bar.time) {
        this.rawBars[this.rawBars.length - 1] = bar;
      } else if (bar.time > last.time) {
        this.rawBars.push(bar);
      } else {
        // STOMP·히스토리 시계 차이 등: 마지막 봉 OHLC만 갱신 (시간축은 유지)
        chartTime = last.time;
        this.rawBars[this.rawBars.length - 1] = { ...bar, time: last.time };
      }
    }

    // 2) 메인 시리즈 즉시 갱신 (캔들 또는 라인/에리어 형식)
    try {
      // eslint-disable-next-line @typescript-eslint/no-explicit-any
      (this.mainSeries as ISeriesApi<any>).update({
        time:  chartTime as Time,
        open:  bar.open, high: bar.high, low: bar.low, close: bar.close,
        value: bar.close,  // line/area/baseline 용 (candlestick은 무시)
      });
    } catch (err) {
      console.error('[ChartManager] updateBar mainSeries.update 오류:', err, bar);
      return;
    }

    // 3) 볼륨 즉시 갱신
    try {
      this.volumeSeries?.update({
        time:  chartTime as Time,
        value: bar.volume,
        color: bar.close >= bar.open ? `${t.upColor}88` : `${t.downColor}88`,
      });
    } catch { /* 볼륨 갱신 실패 무시 */ }

    // 4) 보조지표 마지막 포인트 스로틀 갱신
    this._scheduleIndicatorLastUpdate();
  }

  /**
   * 보조지표 마지막 포인트 갱신 스케줄 (스로틀: 300ms)
   *
   * 동작:
   *   - 계산 실행 중(_indRunning)이거나 타이머 대기 중이면 pending 마킹만 하고 반환
   *   - 타이머 종료 후 _indRunning 가드 아래 실행 → 중복 실행 방지
   *   - 실행 후 pending 이 남아 있으면 즉시 재스케줄 (최신 틱 보장)
   */
  private _scheduleIndicatorLastUpdate(): void {
    // 이미 계산 중이면 pending 마킹 → 완료 후 자동 재실행
    if (this._indRunning) {
      this._pendingIndUpdate = true;
      return;
    }
    // 타이머 대기 중이면 pending 마킹만
    if (this._indUpdateTimer) {
      this._pendingIndUpdate = true;
      return;
    }
    this._pendingIndUpdate = false;
    this._indUpdateTimer = setTimeout(async () => {
      this._indUpdateTimer = null;
      // 계산 중 플래그로 동시 실행 완전 차단
      if (this._indRunning) {
        this._pendingIndUpdate = true;
        return;
      }
      this._indRunning = true;
      try {
        await this._updateIndicatorLastPoints();
        // 실행 중 도착한 틱이 있으면 한 번 더 실행
        if (this._pendingIndUpdate) {
          this._pendingIndUpdate = false;
          await this._updateIndicatorLastPoints();
        }
      } finally {
        this._indRunning = false;
      }
      // 두 번째 실행 중 또 틱이 왔으면 다시 스케줄
      if (this._pendingIndUpdate) {
        this._scheduleIndicatorLastUpdate();
      }
    }, ChartManager.IND_THROTTLE_MS);
  }

  /** 모든 보조지표 시리즈의 마지막 데이터 포인트를 현재 rawBars 기준으로 재계산·갱신 */
  private async _updateIndicatorLastPoints(): Promise<void> {
    if (this.rawBars.length === 0) return;
    const dataGenAtStart = this._dataGeneration;

    // 루프 시작 전 스냅샷 고정: 루프 실행 중 rawBars 가 변경돼도 모든 지표가
    // 동일한 데이터 기준으로 계산되어 지표 간 시각적 불일치를 방지한다.
    const barsSnapshot = this.rawBars.slice();

    for (const [, entry] of this.indicators.entries()) {
      if (dataGenAtStart !== this._dataGeneration) return;
      if (!entry.config || entry.seriesList.length === 0) continue;
      try {
        const { plots } = await calculateIndicator(
          entry.config.type, barsSnapshot, entry.config.params ?? {}
        );
        if (dataGenAtStart !== this._dataGeneration) return;
        // 일목균형표: 선행스팬 미래 구간 포함 전체 setData (틱 갱신 시에도 유지)
        if (entry.type === 'IchimokuCloud') {
          this._refreshIchimokuSeriesData(entry, plots);
          continue;
        }
        this._applyLastPointsToSeries(entry, plots, /* updateFutureSpans */ false);
      } catch { /* 계산 실패 무시 */ }
    }
  }

  /**
   * plots 의 마지막 데이터 포인트를 각 시리즈에 update() 합니다.
   *
   * ★ seriesMeta[i].plotId 를 사용해 plots 에서 정확한 데이터를 조회합니다.
   *   (Object.values(plots) 인덱스 의존 제거 → plot 스킵 시 인덱스 어긋남 방지)
   *
   * - histogram: 색상(상승/하락/양수/음수) 재계산
   * - IchimokuCloud SpanA/SpanB: updateFutureSpans=true 일 때 미래 프로젝션 포함 setData()
   * - null/NaN 값 방어 처리
   */
  private _applyLastPointsToSeries(
    entry:             IndicatorEntry,
    plots:             Record<string, PlotData>,
    updateFutureSpans: boolean,
  ): void {
    for (let i = 0; i < entry.seriesList.length; i++) {
      const meta = entry.seriesMeta[i];
      if (!meta) continue;

      // ★ plotId 로 정확한 플롯 데이터 조회 (인덱스 매핑 오류 방지)
      const plotData = plots[meta.plotId] as PlotData | undefined;
      if (!plotData?.length) continue;

      const lastPoint = plotData[plotData.length - 1];
      if (!lastPoint) continue;

      // ── IchimokuCloud: SpanA/SpanB 미래 프로젝션 갱신 ──────────────────
      if (updateFutureSpans && entry.type === 'IchimokuCloud') {
        const config    = entry.config;
        const disp      = (config.params?.displacement        as number) ?? 26;
        const lagPeriod = (config.params?.laggingSpan2Periods as number) ?? 52;
        const convPlot  = (plots['plot0'] as PlotData) ?? [];
        const basePlot  = (plots['plot1'] as PlotData) ?? [];

        if (meta.plotId === 'plot3') {
          const spanAData: Array<{ time: Time; value: number }> = [
            ...((plots['plot3'] as PlotData) ?? [])
              .filter(p => p.value !== null && !isNaN(p.value))
              .map(p => ({ time: p.time as Time, value: p.value })),
            ...this._ichimokuFutureSpanA(convPlot, basePlot, disp),
          ];
          // eslint-disable-next-line @typescript-eslint/no-explicit-any
          (entry.seriesList[i] as ISeriesApi<any>).setData(spanAData);
          continue;
        }
        if (meta.plotId === 'plot4') {
          const spanBData: Array<{ time: Time; value: number }> = [
            ...((plots['plot4'] as PlotData) ?? [])
              .filter(p => p.value !== null && !isNaN(p.value))
              .map(p => ({ time: p.time as Time, value: p.value })),
            ...this._ichimokuFutureSpanB(disp, lagPeriod),
          ];
          // eslint-disable-next-line @typescript-eslint/no-explicit-any
          (entry.seriesList[i] as ISeriesApi<any>).setData(spanBData);
          continue;
        }
      }

      const curVal = lastPoint.value;
      // null / NaN 방어: 유효하지 않은 값은 update 생략
      if (curVal === null || curVal === undefined || isNaN(curVal as number)) continue;

      // ── Histogram: 색상 재계산 ──────────────────────────────────────────
      if (meta.isHistogram) {
        const prevVal  = plotData.length >= 2 ? plotData[plotData.length - 2]?.value : null;
        const isDown   = prevVal !== null && (curVal as number) < (prevVal as number);
        const isNeg    = (curVal as number) < 0;
        const barColor = lastPoint.color
          ?? (isDown || isNeg ? '#ef535088' : `${meta.color}88`);
        // eslint-disable-next-line @typescript-eslint/no-explicit-any
        (entry.seriesList[i] as ISeriesApi<any>).update({
          time:  lastPoint.time as Time,
          value: curVal as number,
          color: barColor,
        });
        continue;
      }

      // ── Line / 기타 ─────────────────────────────────────────────────────
      // eslint-disable-next-line @typescript-eslint/no-explicit-any
      (entry.seriesList[i] as ISeriesApi<any>).update({
        time:  lastPoint.time as Time,
        value: curVal as number,
      });
    }
    if (entry.type === 'BollingerBands') {
      entry.bbFillPrimitive?.requestRefresh();
    }
  }

  /** 일목균형표 구름 플러그인 — 색상·가시성·데이터 동기화 */
  private _applyIchimokuCloudPlugin(
    plugin: IchimokuCloudPlugin,
    plots: Record<string, PlotData>,
    config: IndicatorConfig,
  ): void {
    const colors = resolveIchimokuCloudColors(config.cloudColors);
    plugin.setColors(colors.bullishColor, colors.bearishColor);

    if (!isIchimokuCloudVisible(config)) {
      plugin.setCloudData([]);
      return;
    }

    const spanAPlot = plots['plot3'] as PlotData | undefined;
    const spanBPlot = plots['plot4'] as PlotData | undefined;
    const convPlot  = plots['plot0'] as PlotData ?? [];
    const basePlot  = plots['plot1'] as PlotData ?? [];
    if (!spanAPlot || !spanBPlot) return;

    const displacement  = (config.params?.displacement        as number) ?? 26;
    const laggingPeriod = (config.params?.laggingSpan2Periods as number) ?? 52;

    const cloud = this._buildIchimokuCloudData(
      spanAPlot, spanBPlot, convPlot, basePlot, displacement, laggingPeriod,
    );
    plugin.setCloudData(cloud.length >= 2 ? cloud : []);
  }

  /**
   * 새 캔들을 기존 차트 우측에만 추가.
   * 전체 차트를 재그리지 않고 각 시리즈에 update(lastPoint)만 호출.
   */
  async appendBar(bar: OHLCVBar): Promise<void> {
    if (!this.mainSeries) return;
    const dataGenAtStart = this._dataGeneration;
    const t = getTheme(this.currentTheme);

    // 새 캔들 추가 시 이 함수 내부에서 모든 지표를 직접 재계산하므로,
    // updateBar 가 예약한 스로틀 타이머를 취소해 중복 계산을 방지합니다.
    this.cancelPendingIndicatorUpdates();

    // rawBars에 신규 바 추가 (중복 방지)
    const last = this.rawBars[this.rawBars.length - 1];
    if (last && last.time === bar.time) {
      this.rawBars[this.rawBars.length - 1] = bar;
    } else {
      this.rawBars.push(bar);
    }

    // 메인 시리즈에 신규 바 추가 (time > last → LWC가 우측에 append)
    try {
      // eslint-disable-next-line @typescript-eslint/no-explicit-any
      (this.mainSeries as ISeriesApi<any>).update({
        time:  bar.time as Time,
        open:  bar.open, high: bar.high, low: bar.low, close: bar.close,
        value: bar.close,  // line/area/baseline 용 (candlestick은 무시)
      });
    } catch (err) {
      console.error('[ChartManager] appendBar mainSeries.update 오류:', err, bar);
      return;
    }

    // 볼륨 시리즈 추가
    try {
      this.volumeSeries?.update({
        time:  bar.time as Time,
        value: bar.volume,
        color: bar.close >= bar.open ? `${t.upColor}88` : `${t.downColor}88`,
      });
    } catch { /* 볼륨 갱신 실패 무시 */ }

    // 각 보조지표의 마지막 포인트 업데이트 + 일목균형표 미래 프로젝션 갱신
    // _indRunning 가드: 이 루프 실행 중 도착한 tick 은 pending 으로 대기
    this._indRunning = true;
    try {
      // rawBars 스냅샷을 찍어 루프 도중 rawBars 가 변경돼도 일관된 계산 보장
      const barsSnapshot = this.rawBars.slice();
      for (const [, entry] of this.indicators.entries()) {
        if (dataGenAtStart !== this._dataGeneration) return;
        if (!entry.config || entry.seriesList.length === 0) continue;
        try {
          const { plots } = await calculateIndicator(
            entry.config.type, barsSnapshot, entry.config.params ?? {}
          );
          if (dataGenAtStart !== this._dataGeneration) return;
          // 일목균형표: 구름 플러그인 + SpanA/B 미래 라인 갱신
          if (entry.type === 'IchimokuCloud' && entry.cloudPlugin) {
            this._applyIchimokuCloudPlugin(entry.cloudPlugin, plots, entry.config);
          }
          // updateFutureSpans=true → 새 캔들 추가 시 SpanA/B setData() 전체 재설정
          this._applyLastPointsToSeries(entry, plots, /* updateFutureSpans */ true);
        } catch { /* 인디케이터 계산 실패 무시 */ }
      }
    } finally {
      this._indRunning = false;
    }
    // appendBar 완료 후에도 tick 이 도착해 pending 이 남아있으면 즉시 갱신 스케줄
    if (this._pendingIndUpdate) {
      this._scheduleIndicatorLastUpdate();
    }
  }

  /**
   * 새 캔들 완성 후 모든 보조지표를 새 데이터로 재계산 (전체 reload).
   * 심볼/타임프레임 변경 시 사용. 실시간 수신 시에는 appendBar()를 사용할 것.
   */
  async recalculateIndicators(newBars: OHLCVBar[]): Promise<void> {
    this.rawBars = newBars;

    const configs = Array.from(this.indicators.values()).map(e => e.config);

    for (const entry of this.indicators.values()) {
      detachBbBandFill(entry);
      for (const s of entry.seriesList) { try { this.chart.removeSeries(s); } catch { /* ok */ } }
    }
    this.indicators.clear();
    this.patternMarkers = [];
    this._reapplyAllPatternMarkers();
    this._removeExtraSubPanes();

    for (const cfg of configs) {
      await this.addIndicator(cfg);
    }
  }

  // ─── Alerts ──────────────────────────────────────────────────────────────
  addAlert(price: number, label = ''): void {
    if (!this.mainSeries) return;
    const line = this.mainSeries.createPriceLine({
      price, color: '#FFB300', lineWidth: 1, lineStyle: LineStyle.Dashed,
      axisLabelVisible: true, title: label || `Alert ${price.toFixed(2)}`,
    });
    this.alertEntries.push({ price, label, line });
  }

  removeAlert(price: number): void {
    const idx = this.alertEntries.findIndex(a => Math.abs(a.price - price) < 0.0001);
    if (idx < 0) return;
    try { this.mainSeries?.removePriceLine(this.alertEntries[idx].line); } catch { /* ok */ }
    this.alertEntries.splice(idx, 1);
  }

  getAlerts(): Array<{ price: number; label: string }> {
    return this.alertEntries.map(({ price, label }) => ({ price, label }));
  }

  // ─── Auto Fibonacci ──────────────────────────────────────────────────────
  drawAutoFib(lookback = 50): void {
    if (!this.mainSeries || this.rawBars.length === 0) return;
    this._clearFibLines();
    const { levels } = calcAutoFib(this.rawBars, lookback);
    const COLORS: Record<string, string> = {
      '0%':     '#EF5350', '23.6%': '#FF9800', '38.2%': '#FFC107',
      '50%':    '#FFFFFF', '61.8%': '#4CAF50', '78.6%': '#2196F3',
      '100%':   '#9C27B0', '127.2%': '#607D8B', '161.8%': '#607D8B',
    };
    for (const l of levels) {
      const line = this.mainSeries.createPriceLine({
        price: l.price, color: COLORS[l.label] ?? '#607D8B',
        lineWidth: l.label === '0%' || l.label === '100%' ? 2 : 1,
        lineStyle: l.label === '50%' ? LineStyle.Dashed : LineStyle.Dotted,
        axisLabelVisible: true, title: `Fib ${l.label}`,
      });
      this.fibLines.push({ price: l.price, label: l.label, line });
    }
  }

  clearFib(): void { this._clearFibLines(); }
  private _clearFibLines(): void {
    for (const f of this.fibLines) { try { this.mainSeries?.removePriceLine(f.line); } catch { /* ok */ } }
    this.fibLines = [];
  }

  // ─── Drawing ─────────────────────────────────────────────────────────────
  setDrawingTool(tool: string): void {
    this.drawingTool = tool;
    this.drawingPoints = [];
    if (this._clickHandler) { this.chart.unsubscribeClick(this._clickHandler); this._clickHandler = null; }
    this.container.style.cursor = tool === 'cursor' ? 'default' : 'crosshair';
    if (tool === 'cursor') return;

    this._clickHandler = (p: MouseEventParams<Time>) => {
      if (!p.time || !p.point || !this.mainSeries) return;
      const price = this.mainSeries.coordinateToPrice(p.point.y);
      if (price === null) return;
      this.drawingPoints.push({ time: p.time, price });

      if (tool === 'hline') {
        const l = this.mainSeries.createPriceLine({ price, color: '#FF6D00', lineWidth: 1, lineStyle: LineStyle.Solid, axisLabelVisible: true, title: '' });
        this.drawingLines.push(l);
        this.drawingPoints = [];
      } else if (tool === 'trendline' && this.drawingPoints.length === 2) {
        const [p1, p2] = this.drawingPoints;
        const l = this.mainSeries.createPriceLine({ price: (p1.price + p2.price) / 2, color: '#2962FF', lineWidth: 1, lineStyle: LineStyle.Solid, axisLabelVisible: false, title: `${p1.price.toFixed(2)}→${p2.price.toFixed(2)}` });
        this.drawingLines.push(l);
        this.drawingPoints = [];
      } else if (tool === 'measure' && this.drawingPoints.length === 2) {
        const [p1, p2] = this.drawingPoints;
        const pct = ((p2.price - p1.price) / p1.price * 100).toFixed(2);
        this.mainSeries.createPriceLine({ price: p1.price, color: '#FFC10788', lineWidth: 1, lineStyle: LineStyle.Dashed, axisLabelVisible: true, title: '' });
        this.mainSeries.createPriceLine({ price: p2.price, color: '#FFC10788', lineWidth: 1, lineStyle: LineStyle.Dashed, axisLabelVisible: true, title: `${pct}%` });
        this.drawingPoints = [];
      }
    };
    this.chart.subscribeClick(this._clickHandler);
  }

  clearDrawings(): void {
    for (const l of this.drawingLines) { try { this.mainSeries?.removePriceLine(l); } catch { /* ok */ } }
    this.drawingLines = [];
    this.drawingPoints = [];
    this._clearFibLines();
  }

  // ─── Crosshair ────────────────────────────────────────────────────────────
  /**
   * 차트 클릭 감지: 시리즈 근처(40px 이내)를 단일/더블 클릭 시 콜백 호출.
   * - entryId: 클릭한 인디케이터 ID ('__main__' = 메인캔들, null = 빈 공간)
   * - point: 차트 컨테이너 기준 픽셀 좌표
   */
  /**
   * 클릭/더블클릭 구독 — 네이티브 DOM 이벤트 기반.
   *
   * LWC click 이벤트의 params.point.y 는 pane 내부 좌표계이므로
   * getPaneLayouts() 의 chart-relative topY 와 혼용하면 패인 위치를 잘못 계산함.
   * 네이티브 MouseEvent.clientY 는 항상 스크린 절대 좌표이므로
   * containerRect 를 빼면 chart-relative Y 를 정확히 얻을 수 있음.
   *
   * 패인 탐색 결과:
   *   pane 0 → '__main__'
   *   pane 1 → null (volume, 무시)
   *   pane 2+ → 해당 indicator ID
   */
  subscribeSeriesClick(
    onSingle: (entryId: string | null, point: { x: number; y: number }) => void,
    onDouble: (entryId: string | null, point: { x: number; y: number }) => void,
  ): () => void {
    let lastClickTime   = 0;
    let lastEntryId     = '';
    let singleTimer: ReturnType<typeof setTimeout> | null = null;

    /** e.clientY → chart-container 상대 Y → pane 탐색 → entryId + paneTopY */
    const resolveEntry = (e: MouseEvent): { entryId: string | null; paneTopY: number } => {
      const rect   = this.container.getBoundingClientRect();
      const clickY = e.clientY - rect.top;
      const layouts = this.getPaneLayouts();
      const pane   = layouts.find(l => clickY >= l.topY && clickY < l.topY + l.height);
      if (!pane) return { entryId: null, paneTopY: 0 };

      if (pane.paneIndex === 0) {
        // 크로스헤어가 오버레이 지표 위에 있으면 해당 지표 반환
        if (this._crosshairEntryId && this._crosshairEntryId !== '__main__') {
          const entry = this.indicators.get(this._crosshairEntryId);
          if (entry && entry.paneIndex === 0) {
            return { entryId: this._crosshairEntryId, paneTopY: 0 };
          }
        }
        return { entryId: '__main__', paneTopY: 0 };
      }
      if (pane.paneIndex === 1) return { entryId: null, paneTopY: pane.topY }; // volume

      // pane 2+ → 해당 패인의 indicator 탐색
      const mapping = this.getIndicatorPaneMapping();
      for (const [id, pIdx] of mapping) {
        if (pIdx === pane.paneIndex) return { entryId: id, paneTopY: pane.topY };
      }
      return { entryId: null, paneTopY: pane.topY };
    };

    const clickHandler = (e: MouseEvent) => {
      const now = Date.now();
      const { entryId, paneTopY } = resolveEntry(e);
      const rect  = this.container.getBoundingClientRect();
      const point = { x: rect.left, y: rect.top + paneTopY };

      const isDouble = now - lastClickTime < 400 && lastEntryId === (entryId ?? '');
      lastClickTime = now;
      lastEntryId   = entryId ?? '';

      if (isDouble) {
        if (singleTimer) { clearTimeout(singleTimer); singleTimer = null; }
        onDouble(entryId, point);
      } else {
        if (singleTimer) clearTimeout(singleTimer);
        singleTimer = setTimeout(() => {
          onSingle(entryId, point);
          singleTimer = null;
        }, 220);
      }
    };

    this.container.addEventListener('click', clickHandler);
    return () => {
      this.container.removeEventListener('click', clickHandler);
      if (singleTimer) { clearTimeout(singleTimer); singleTimer = null; }
    };
  }

  /** @deprecated 내부용: 더 이상 subscribeSeriesClick 에서 호출하지 않음 */
  private _findClickedEntryId(_params: MouseEventParams<Time>): string | null {
    return null;
  }

  /**
   * sub-pane 의 시작 pane index 반환.
   * pane 0 = 메인, pane 1 = 볼륨 (고정), pane 2+ = 보조지표
   */
  getSubPaneStart(): number { return 2; }

  /**
   * 현재 indicators Map 에서 (indicatorId → 실제 paneIndex) 매핑 반환.
   * 타이밍 문제 없이 ChartManager 에 등록된 실제 값 사용.
   */
  getIndicatorPaneMapping(): Map<string, number> {
    const map = new Map<string, number>();
    for (const [id, entry] of this.indicators) {
      if (entry.paneIndex !== undefined && entry.paneIndex !== null) {
        map.set(id, entry.paneIndex);
      }
    }
    return map;
  }

  /** 인디케이터 메타 반환 (컨텍스트 툴바용) */
  getIndicatorMeta(id: string): { type: string; config: IndicatorConfig } | null {
    if (id === '__main__') return null;
    const entry = this.indicators.get(id);
    return entry ? { type: entry.type, config: entry.config } : null;
  }

  /**
   * 지정 pane index 의 차트 컨테이너 기준 상단 Y 좌표 반환.
   * 각 pane height 합산으로 계산. 컨테이너 기준이므로 screen 좌표로
   * 변환하려면 container.getBoundingClientRect().top 을 더해야 함.
   */
  getPaneTopY(paneIndex: number): number {
    const layouts = this.getPaneLayouts();
    return layouts.find(l => l.paneIndex === paneIndex)?.topY ?? 0;
  }

  /** 인디케이터가 속한 pane index 반환 */
  getIndicatorPaneIndex(id: string): number {
    if (id === '__main__') return 0;
    return this.indicators.get(id)?.paneIndex ?? 0;
  }

  /**
   * 차트 컨테이너 기준 각 pane 의 topY 와 height 배열 반환.
   * 크로스헤어 이동 시 호출하여 최신 레이아웃 유지.
   */
  /** pane 레이아웃 변경 알림 (보조지표 추가·제거·높이 재배분 후) */
  private readonly _paneLayoutListeners = new Set<() => void>();

  subscribePaneLayout(cb: () => void): () => void {
    this._paneLayoutListeners.add(cb);
    return () => { this._paneLayoutListeners.delete(cb); };
  }

  private _notifyPaneLayout(): void {
    for (const cb of this._paneLayoutListeners) {
      try { cb(); } catch { /* ok */ }
    }
  }

  /**
   * 차트 컨테이너 기준 각 pane 의 topY 와 height 배열 반환.
   * IPaneApi.getHTMLElement() 로 pane index ↔ 화면 위치를 직접 매칭한다.
   * (tr 행 순서는 orphan 빈 pane 때문에 pane index 와 어긋날 수 있음)
   */
  getPaneLayouts(): Array<{ paneIndex: number; topY: number; height: number }> {
    const panes = this.chart.panes();
    const containerRect = this.container.getBoundingClientRect();
    const result: Array<{ paneIndex: number; topY: number; height: number }> = [];
    let missing = 0;

    for (const pane of panes) {
      const paneIndex = pane.paneIndex();
      const el = pane.getHTMLElement();
      if (el) {
        const rect = el.getBoundingClientRect();
        result.push({
          paneIndex,
          topY:   Math.round(rect.top - containerRect.top),
          height: Math.round(rect.height),
        });
      } else {
        missing++;
        result.push({ paneIndex, topY: 0, height: 0 });
      }
    }

    if (missing > 0) {
      const fallback = this._getPaneLayoutsStretchEstimate();
      for (let i = 0; i < result.length; i++) {
        if (result[i].height <= 0) {
          const fb = fallback.find(f => f.paneIndex === result[i].paneIndex);
          if (fb) result[i] = fb;
        }
      }
    }

    const active = this._activeIndicatorPaneIndices();
    if (active.size > 0) {
      return result.filter(l => l.paneIndex < 2 || active.has(l.paneIndex));
    }
    return result;
  }

  /** stretchFactor 비율로 pane 위치 추정 (getHTMLElement 미준비 시) */
  private _getPaneLayoutsStretchEstimate(): Array<{ paneIndex: number; topY: number; height: number }> {
    const panes = this.chart.panes();
    const paneCount = panes.length;
    const SEP_H = 4;
    const TIME_AXIS_H = 26;
    const sepCount = Math.max(0, paneCount - 1);
    const chartAreaH = Math.max(1, this.container.clientHeight - TIME_AXIS_H - sepCount * SEP_H);
    const totalFactor = panes.reduce((s, p) => s + p.getStretchFactor(), 0) || 1;
    const result: Array<{ paneIndex: number; topY: number; height: number }> = [];
    let y = 0;
    for (let i = 0; i < paneCount; i++) {
      const h = Math.round((panes[i].getStretchFactor() / totalFactor) * chartAreaH);
      result.push({ paneIndex: i, topY: y, height: h });
      y += h + (i < paneCount - 1 ? SEP_H : 0);
    }
    return result;
  }

  /**
   * 보조지표가 실제로 붙어 있는 sub-pane 레이아웃만 (pane 2+).
   * orphan pane·빈 pane 은 제외해 PaneLegend 위치 오차를 방지한다.
   */
  getIndicatorSubPaneLayouts(): Array<{ paneIndex: number; topY: number; height: number }> {
    const MIN_H = 12;
    const active = this._activeIndicatorPaneIndices();
    return this.getPaneLayouts()
      .filter(l => active.has(l.paneIndex) && l.height > MIN_H)
      .sort((a, b) => a.topY - b.topY);
  }

  /**
   * 보조지표 id 기준 화면 pane 위치 (시리즈가 붙은 LWC pane DOM).
   * PaneLegend 레이블 위치 — pane index·tr 순서와 무관.
   */
  getIndicatorPaneScreenLayout(id: string): { topY: number; height: number } | null {
    const entry = this.indicators.get(id);
    if (!entry || entry.paneIndex == null || entry.paneIndex < 2) return null;
    const series = entry.seriesList[0];
    if (!series) return null;
    try {
      const el = series.getPane().getHTMLElement();
      if (!el) return null;
      const rect = el.getBoundingClientRect();
      const containerRect = this.container.getBoundingClientRect();
      const height = Math.round(rect.height);
      if (height < 12) return null;
      return {
        topY:   Math.round(rect.top - containerRect.top),
        height,
      };
    } catch {
      return null;
    }
  }

  /**
   * 서브 pane(비오버레이) 인디케이터 정보 목록 반환.
   * PaneLegend 컴포넌트에서 각 pane 레이블 렌더링에 사용.
   */
  getIndicatorPaneInfo(): Array<{
    id:         string;
    type:       string;
    config:     IndicatorConfig;
    paneIndex:  number;
    plotColors: string[];
  }> {
    const result = [];
    for (const [id, entry] of this.indicators) {
      result.push({
        id,
        type:       entry.type,
        config:     entry.config,
        paneIndex:  entry.paneIndex ?? 0,
        plotColors: entry.seriesMeta.map(m => m.color),
      });
    }
    return result;
  }

  /**
   * 크로스헤어 위치의 각 인디케이터 시리즈 값을
   * indicatorId → number[] (plot 순서) 형태로 반환.
   */
  getIndicatorValuesByIdFromParams(params: MouseEventParams<Time>): Record<string, number[]> {
    const result: Record<string, number[]> = {};
    for (const [id, entry] of this.indicators) {
      const vals: number[] = [];
      for (const series of entry.seriesList) {
        // eslint-disable-next-line @typescript-eslint/no-explicit-any
        const d = params.seriesData.get(series) as any;
        if (!d) { vals.push(NaN); continue; }
        const v = typeof d.value === 'number' ? d.value : NaN;
        vals.push(v);
      }
      if (vals.some(v => !isNaN(v))) result[id] = vals;
    }
    return result;
  }

  /** 인디케이터 전체 가시성 토글 (시리즈 visible 옵션 변경) */
  toggleIndicatorVisible(id: string, visible: boolean): void {
    const entry = this.indicators.get(id);
    if (!entry) return;
    for (const series of entry.seriesList) {
      // eslint-disable-next-line @typescript-eslint/no-explicit-any
      series.applyOptions({ visible } as any);
    }
    if (entry.cloudPlugin) {
      // 일목균형표 구름 플러그인: 가시성은 series 통해 처리됨
    }
  }

  /** 특정 plot 의 색상/선폭 즉시 변경 */
  updateIndicatorPlotStyle(id: string, plotIndex: number, color: string, lineWidth?: number): void {
    const entry = this.indicators.get(id);
    if (!entry) return;
    const series = entry.seriesList[plotIndex];
    if (!series) return;
    const opts: Record<string, unknown> = { color };
    if (lineWidth != null) opts['lineWidth'] = lineWidth;
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    series.applyOptions(opts as any);
  }

  /** 차트 설정: 보조지표 우측 가격축 라벨·설명 on/off */
  setSeriesPriceLabelsEnabled(enabled: boolean): void {
    this._seriesPriceLabelsEnabled = enabled;
    for (const entry of this.indicators.values()) {
      this.applyIndicatorStyle(entry.config);
    }
  }

  getSeriesPriceLabelsEnabled(): boolean {
    return this._seriesPriceLabelsEnabled;
  }

  /** 차트 설정: 거래량 pane 표시 on/off */
  setVolumeVisible(visible: boolean, availableHeight?: number): void {
    this._volumeVisible = visible;
    if (this._candleOnlyLayout) return;
    this.applyVolumeVisibility(visible, availableHeight);
  }

  getVolumeVisible(): boolean {
    return this._volumeVisible;
  }

  private applyVolumeVisibility(visible: boolean, availableHeight?: number): void {
    try {
      this.volumeSeries?.applyOptions({ visible } as Parameters<ISeriesApi<SeriesType>['applyOptions']>[0]);
    } catch { /* ok */ }
    this.resetPaneHeights(availableHeight);
  }

  private shouldShowSeriesPriceLabel(
    config: IndicatorConfig,
    plotAllowsLastValue = true,
  ): boolean {
    return this._seriesPriceLabelsEnabled
      && plotAllowsLastValue
      && config.lastValueVisible !== false;
  }

  private seriesTitleForPlot(
    config: IndicatorConfig,
    plot: PlotDef | undefined,
    plotAllowsLastValue = true,
  ): string {
    if (!this.shouldShowSeriesPriceLabel(config, plotAllowsLastValue)) return '';
    if (config.type === 'IchimokuCloud' && plot?.id) {
      return getIchimokuPlotTitle(plot.id);
    }
    return plot?.title ?? '';
  }

  /**
   * 인디케이터의 스타일(색상·선폭·plotVisibility)을 in-place 로 일괄 적용.
   * 시리즈를 제거·재생성하지 않으므로 pane 재번호 문제가 없음.
   */
  applyIndicatorStyle(config: IndicatorConfig): void {
    config = enrichIndicatorConfig(config);
    const entry = this.indicators.get(config.id);
    if (!entry) return;
    const def = getIndicatorDef(config.type);
    const plotDefs = config.plots ?? def?.plots ?? [];

    const plotById = Object.fromEntries(plotDefs.map((p: PlotDef) => [p.id, p]));

    entry.seriesList.forEach((series, i) => {
      let plot: PlotDef | undefined;
      const pid = entry.seriesMeta[i]?.plotId;
      plot = (pid ? plotById[pid] : undefined) ?? plotDefs[i];
      if (!plot) return;
      const indicatorVisible = config.hidden !== true;
      const visible = indicatorVisible && config.plotVisibility?.[plot.id] !== false;
      // eslint-disable-next-line @typescript-eslint/no-explicit-any
      const opts: Record<string, unknown> = { visible };
      if (!entry.seriesMeta[i]?.isHistogram) {
        opts['color']     = plot.color ?? '#aaa';
        if (plot.lineWidth != null) opts['lineWidth'] = plot.lineWidth;
        opts['lineStyle'] = plotSeriesLineStyle(plot.lineStyle);
        let plotAllows = true;
        if (entry.type === 'IchimokuCloud') {
          plotAllows = entry.seriesMeta[i]?.plotId !== 'plot2';
        }
        const showPriceLabel = this.shouldShowSeriesPriceLabel(config, plotAllows);
        opts['lastValueVisible'] = showPriceLabel;
        opts['title'] = this.seriesTitleForPlot(config, plot, plotAllows);
      } else {
        opts['color'] = plot.color ?? '#aaa';
      }
      // eslint-disable-next-line @typescript-eslint/no-explicit-any
      series.applyOptions(opts as any);
    });

    // ── hlines(과열선/중앙선/침체선) 동기화 ─────────────────────────────────
    if (entry.seriesList.length > 0) {
      const firstSeries = entry.seriesList[0];
      const hlines = config.hlines ?? def?.hlines ?? [];

      const toLineStyle2 = (s?: string): number => {
        if (s === 'solid')  return LineStyle.Solid;
        if (s === 'dotted') return LineStyle.Dotted;
        return LineStyle.Dashed;
      };

      // 기존 price line 제거
      for (const pl of entry.hlineRefs) {
        try { firstSeries.removePriceLine(pl); } catch { /* 이미 제거됨 */ }
      }
      entry.hlineRefs = [];

      if (entry.fillPrimitive) {
        try { firstSeries.detachPrimitive(entry.fillPrimitive); } catch { /* ok */ }
        entry.fillPrimitive = undefined;
      }

      if (config.hidden !== true) {
        for (const hl of hlines) {
          if (hl.visible === false) continue;
          const pl = firstSeries.createPriceLine({
            price:            hl.price,
            color:            hl.color,
            lineWidth:        Math.min(4, hl.lineWidth ?? 1) as 1|2|3|4,
            lineStyle:        toLineStyle2(hl.lineStyle),
            axisLabelVisible: false,
          });
          entry.hlineRefs.push(pl);
        }

        const prices = hlines.filter(h => h.visible !== false).map(h => h.price);
        const hasZone = hlines.some(h => {
          const lbl = h.label ?? getHLineLabel(h.price, prices);
          return (lbl === '과열선' || lbl === '침체선') && h.visible !== false;
        });
        const hasBg = config.hlinesBackground?.visible !== false;
        if (hasZone || hasBg) {
          entry.fillPrimitive = new IndicatorFillPrimitive(hlines, config.hlinesBackground);
          firstSeries.attachPrimitive(entry.fillPrimitive);
        }
      }
    }

    if (entry.type === 'BollingerBands') {
      detachBbBandFill(entry);
      if (config.hidden !== true) {
        attachBbBandFill(entry, config);
        entry.bbFillPrimitive?.requestRefresh();
      }
    }

    if (entry.type === 'IchimokuCloud' && entry.cloudPlugin) {
      void calculateIndicator(
        'IchimokuCloud',
        this.rawBars,
        config.params ?? {},
      ).then(({ plots }) => {
        this._applyIchimokuCloudPlugin(entry.cloudPlugin!, plots, config);
      }).catch(() => { /* ignore */ });
    }

    entry.config = config;
  }

  /** 메인 차트(캔들) 색상 스타일 즉시 업데이트 */
  updateMainSeriesStyle(style: import('../types').MainChartStyle): void {
    if (!this.mainSeries) return;
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    (this.mainSeries as ISeriesApi<any>).applyOptions({
      upColor:         style.upColor,
      downColor:       style.downColor,
      borderUpColor:   style.borderUpColor,
      borderDownColor: style.borderDownColor,
      wickUpColor:     style.wickUpColor,
      wickDownColor:   style.wickDownColor,
    });
    // 볼륨 색상도 동기화
    if (this.volumeSeries) {
      // 볼륨은 개별 bar 색상이어서 전체 재적용이 필요
      // rawBars 가 있을 때만 재색칠
      if (this.rawBars.length > 0) {
        this.volumeSeries.setData(
          this.rawBars.map(b => ({
            time:  b.time as Time,
            value: b.volume,
            color: b.close >= b.open ? `${style.upColor}88` : `${style.downColor}88`,
          }))
        );
      }
    }
  }

  subscribeCrosshair(cb: (p: MouseEventParams<Time>) => void): () => void {
    this.chart.subscribeCrosshairMove(cb);
    return () => this.chart.unsubscribeCrosshairMove(cb);
  }

  /**
   * 크로스헤어 자석모드 전환.
   * - enabled=true  → CrosshairMode.Magnet (캔들 OHLCV에 스냅)
   * - enabled=false → CrosshairMode.Normal  (마우스 위치 자유 이동)
   */
  setCrosshairMagnet(enabled: boolean): void {
    this.chart.applyOptions({
      crosshair: { mode: enabled ? CrosshairMode.Magnet : CrosshairMode.Normal },
    });
  }

  // ─── Screenshot ───────────────────────────────────────────────────────────
  screenshot(): string | null {
    try {
      const canvas = this.chart.takeScreenshot();
      return canvas.toDataURL('image/png');
    } catch { return null; }
  }

  // ─── Layout ───────────────────────────────────────────────────────────────
  fitContent(): void { this.chart.timeScale().fitContent(); }

  /** 시간축 확대 (visible logical range 20% 축소) */
  zoomIn(): void {
    const ts  = this.chart.timeScale();
    const lr  = ts.getVisibleLogicalRange();
    if (!lr) return;
    const span   = lr.to - lr.from;
    const center = (lr.from + lr.to) / 2;
    const newSpan = Math.max(span * 0.8, 5); // 최소 5바
    ts.setVisibleLogicalRange({ from: center - newSpan / 2, to: center + newSpan / 2 });
  }

  /** 시간축 축소 (visible logical range 20% 확대) */
  zoomOut(): void {
    const ts  = this.chart.timeScale();
    const lr  = ts.getVisibleLogicalRange();
    if (!lr) return;
    const span   = lr.to - lr.from;
    const center = (lr.from + lr.to) / 2;
    const newSpan = span * 1.25;
    ts.setVisibleLogicalRange({ from: center - newSpan / 2, to: center + newSpan / 2 });
  }

  /** 시간축 왼쪽(과거)으로 스크롤 — visible 범위의 20%씩 이동 */
  scrollLeft(): void {
    const ts = this.chart.timeScale();
    const lr = ts.getVisibleLogicalRange();
    if (!lr) return;
    const shift = (lr.to - lr.from) * 0.2;
    ts.setVisibleLogicalRange({ from: lr.from - shift, to: lr.to - shift });
  }

  /** 시간축 오른쪽(미래)으로 스크롤 — visible 범위의 20%씩 이동 */
  scrollRight(): void {
    const ts = this.chart.timeScale();
    const lr = ts.getVisibleLogicalRange();
    if (!lr) return;
    const shift = (lr.to - lr.from) * 0.2;
    ts.setVisibleLogicalRange({ from: lr.from + shift, to: lr.to + shift });
  }

  /** 캔들 전용(보조지표·거래량 숨김) 레이아웃 적용 중 여부 */
  private _candleOnlyLayout = false;

  isCandleOnlyLayout(): boolean {
    return this._candleOnlyLayout;
  }

  /**
   * 캔들 pane(0) 플롯 영역(가격·시간축 제외) 여부.
   */
  isInCandlePlotArea(chartX: number, chartY: number): boolean {
    if (this.getPaneIndexAtChartY(chartY) !== 0) return false;
    if (this.isOnPriceAxis(chartX, chartY) || this.isOnTimeAxis(chartY)) return false;
    return true;
  }

  /**
   * chart-container 좌표 기준 클릭 대상 (pane 0 오버레이 지표 / 메인 / 보조지표 pane).
   */
  resolveEntryAtChartPoint(chartX: number, chartY: number): string | null {
    const layouts = this.getPaneLayouts();
    const pane = layouts.find(l => chartY >= l.topY && chartY < l.topY + l.height);
    if (!pane) return null;

    if (pane.paneIndex === 0) {
      if (this._crosshairEntryId && this._crosshairEntryId !== '__main__') {
        const entry = this.indicators.get(this._crosshairEntryId);
        if (entry && entry.paneIndex === 0) return this._crosshairEntryId;
      }
      return '__main__';
    }
    if (pane.paneIndex === 1) return null;

    const mapping = this.getIndicatorPaneMapping();
    for (const [id, pIdx] of mapping) {
      if (pIdx === pane.paneIndex) return id;
    }
    return null;
  }

  /** chart X 좌표에 가장 가까운 캔들 bar (미래 빈 구간은 null) */
  private _barAtChartX(chartX: number): OHLCVBar | undefined {
    const time = this.xToTime(chartX);
    if (time == null || this.rawBars.length === 0) return undefined;

    const exact = this.getBarAtTime(time);
    if (exact) return exact;

    let best = this.rawBars[0];
    let bestDt = Math.abs((best.time as number) - time);
    for (const b of this.rawBars) {
      const dt = Math.abs((b.time as number) - time);
      if (dt < bestDt) {
        bestDt = dt;
        best = b;
      }
    }
    const interval = this._barIntervalSecs();
    return bestDt <= interval * 0.55 ? best : undefined;
  }

  /**
   * 캔들 pane에서 실제 메인 시리즈(캔들·라인 등) 위 더블클릭인지 판별.
   * 빈 플롯 영역이면 false → 전체보기 토글 대상.
   */
  isClickOnMainSeries(chartX: number, chartY: number): boolean {
    if (!this.isInCandlePlotArea(chartX, chartY) || !this.mainSeries) return false;

    const bar = this._barAtChartX(chartX);
    if (!bar) return false;

    const price = this.yToPrice(chartY);
    if (price == null || !Number.isFinite(price)) return false;

    const ct = this.currentChartType;
    if (ct === 'line' || ct === 'area' || ct === 'baseline') {
      const tol = Math.max(Math.abs(bar.close) * 0.008, (bar.high - bar.low) * 0.15, 1e-8);
      return Math.abs(price - bar.close) <= tol;
    }

    const bodyPad = Math.max((bar.high - bar.low) * 0.12, Math.abs(bar.close) * 0.0008, 1e-8);
    return price >= bar.low - bodyPad && price <= bar.high + bodyPad;
  }

  /**
   * 캔들 pane 전체보기 — pane 0(캔들+오버레이 지표)만 전체 높이,
   * 거래량·하단 보조지표 pane 은 접힘. 지표 시리즈는 제거하지 않음(복귀 시 즉시 복원).
   */
  applyCandleOnlyLayout(enabled: boolean, availableHeight?: number): void {
    this._candleOnlyLayout = enabled;
    const panes = this.chart.panes();
    if (panes.length === 0) return;

    if (enabled) {
      try {
        this.volumeSeries?.applyOptions({
          visible: false,
        } as Parameters<ISeriesApi<SeriesType>['applyOptions']>[0]);
      } catch { /* ok */ }
      this.container.style.height = '';
      this._resetPaneHeightsCandleFullscreen(availableHeight);
    } else {
      this.restoreFromCandleFullscreen(availableHeight);
    }
  }

  /**
   * 캔들 전체보기 해제 — 플래그·거래량·pane 비율·지표 데이터를 정상 모드로 복원.
   */
  restoreFromCandleFullscreen(availableHeight?: number): void {
    this._candleOnlyLayout = false;
    this.container.style.height = '';

    try {
      this.volumeSeries?.applyOptions({
        visible: this._volumeVisible,
      } as Parameters<ISeriesApi<SeriesType>['applyOptions']>[0]);
    } catch { /* ok */ }

    this._resetMainPricePan();

    const H = (availableHeight && availableHeight > 0)
      ? availableHeight
      : this.container.clientHeight;

    this.resetPaneHeights(H);

    try {
      const w = this.container.clientWidth;
      if (w > 0 && H > 0) this.chart.resize(w, H);
    } catch { /* ok */ }

    this._scheduleIndicatorLastUpdate();
    this.autoScale();
  }

  /** 실제 보조지표가 붙어 있는 pane index 집합 (2+) */
  private _activeIndicatorPaneIndices(): Set<number> {
    const indices = new Set<number>();
    for (const entry of this.indicators.values()) {
      const p = entry.paneIndex;
      if (p != null && p >= 2) indices.add(p);
    }
    return indices;
  }

  /** pane 2+ 빈 sub-pane 제거 — 제거·재로드 후 고아 pane 이 높이를 나눠 가져가는 것 방지 */
  private _removeExtraSubPanes(): void {
    for (;;) {
      const panes = this.chart.panes();
      if (panes.length <= 2) return;
      const last = panes.length - 1;
      try {
        this.chart.removePane(last);
      } catch {
        return;
      }
    }
  }

  /** chart-container 기준 Y(px) → pane index (0=캔들, 1=거래량, 2+=보조지표) */
  getPaneIndexAtChartY(chartY: number): number {
    const layouts = this.getPaneLayouts();
    const pane = layouts.find(l => chartY >= l.topY && chartY < l.topY + l.height);
    return pane?.paneIndex ?? 0;
  }

  /** 우측 가격축 라벨 영역 클릭 여부 (LWC 축 스케일 드래그에 위임) */
  isOnPriceAxis(chartX: number, chartY: number): boolean {
    const paneIndex = this.getPaneIndexAtChartY(chartY);
    try {
      const ps = paneIndex === 1
        ? this.chart.priceScale('volume', 1)
        : this.chart.priceScale('right', paneIndex);
      const scaleW = ps.width();
      if (scaleW <= 0) return false;
      return chartX >= this.container.clientWidth - scaleW;
    } catch {
      return chartX >= this.container.clientWidth - 56;
    }
  }

  /** 하단 시간축 영역 클릭 여부 */
  isOnTimeAxis(chartY: number): boolean {
    try {
      const h = this.chart.timeScale().height();
      return chartY >= this.container.clientHeight - h;
    } catch {
      return chartY >= this.container.clientHeight - 28;
    }
  }

  /** 캔들 pane(0) 우측 가격축 라벨 영역 */
  isCandlePanePriceAxis(chartX: number, chartY: number): boolean {
    return this.getPaneIndexAtChartY(chartY) === 0 && this.isOnPriceAxis(chartX, chartY);
  }

  /**
   * 캔들 pane(0) 우측 가격축 휠 — pane 높이는 유지, 플롯 내 가격 범위만 확대/축소.
   * wheel up → 줌 인(캔들·그래프가 세로로 커 보임), wheel down → 줌 아웃.
   */
  zoomMainPriceByWheel(deltaY: number, anchorChartY?: number): boolean {
    if (!this.mainSeries || deltaY === 0) return false;

    const main = this.getPaneLayouts().find(l => l.paneIndex === 0);
    if (!main || main.height <= 0) return false;

    let range = this._mainPriceVisibleRange ?? this._readMainPriceRange();
    if (!range) return false;

    const span = range.to - range.from;
    if (!Number.isFinite(span) || span <= 0) return false;

    const ps = this.mainSeries.priceScale();
    if (!this._mainPriceVisibleRange) {
      ps.setAutoScale(false);
      this.mainSeries.applyOptions({ autoscaleInfoProvider: undefined });
      this._mainPriceVisibleRange = { ...range };
    }

    const paneLocalY = anchorChartY != null
      ? Math.max(0, Math.min(main.height, anchorChartY - main.topY))
      : main.height / 2;
    const rawAnchor = this.mainSeries.coordinateToPrice(paneLocalY);
    const anchorPrice = (rawAnchor != null && Number.isFinite(rawAnchor))
      ? rawAnchor
      : (range.from + range.to) / 2;

    const factor = Math.exp(deltaY * 0.0012);
    const newSpan = Math.max(span * 1e-6, span * factor);
    const minSpan = Math.max(Math.abs(anchorPrice) * 1e-6, 1e-8);
    const clampedSpan = Math.max(minSpan, newSpan);

    const ratio = Math.max(0, Math.min(1, (anchorPrice - range.from) / span));
    const next = {
      from: anchorPrice - ratio * clampedSpan,
      to: anchorPrice + (1 - ratio) * clampedSpan,
    };

    this._mainPriceVisibleRange = next;
    try {
      ps.setVisibleRange(next);
      return true;
    } catch {
      return false;
    }
  }

  /**
   * 마우스 드래그(px)만큼 차트를 패닝합니다.
   * - deltaX: 시간축 이동 — 모든 pane(캔들·보조지표) 동시 이동
   * - deltaY: 캔들 플롯 영역 — 가격 범위 이동(그래프 상하 패닝, 축 확대/축소 아님)
   */
  panByPixelDelta(
    deltaX: number,
    deltaY: number,
    options?: { allowVerticalPan?: boolean },
  ): void {
    if (deltaX !== 0) {
      const ts = this.chart.timeScale();
      const lr = ts.getVisibleLogicalRange();
      if (lr) {
        const w = Math.max(1, this.container.clientWidth);
        const span = lr.to - lr.from;
        const dLog = (deltaX / w) * span;
        try {
          ts.setVisibleLogicalRange({ from: lr.from - dLog, to: lr.to - dLog });
        } catch { /* 데이터 로드 전 */ }
      }
    }

    const allowVertical = options?.allowVerticalPan !== false;
    if (deltaY !== 0 && allowVertical) {
      this._panMainPriceVertical(deltaY);
    }
  }

  /** 캔들 pane 수동 가격 범위 (세로 패닝용) */
  private _mainPriceVisibleRange: { from: number; to: number } | null = null;

  private _readMainPriceRange(): { from: number; to: number } | null {
    if (!this.mainSeries) return null;
    const ps = this.mainSeries.priceScale();
    const existing = ps.getVisibleRange();
    if (existing) return { from: existing.from, to: existing.to };
    const main = this.getPaneLayouts().find(l => l.paneIndex === 0);
    if (!main || main.height <= 0) return null;
    const pTop = this.mainSeries.coordinateToPrice(main.topY);
    const pBot = this.mainSeries.coordinateToPrice(main.topY + main.height);
    if (pTop === null || pBot === null) return null;
    return { from: Math.min(pTop, pBot), to: Math.max(pTop, pBot) };
  }

  private _panMainPriceVertical(deltaY: number): void {
    if (!this.mainSeries) return;
    const main = this.getPaneLayouts().find(l => l.paneIndex === 0);
    if (!main || main.height <= 0) return;

    let range = this._mainPriceVisibleRange ?? this._readMainPriceRange();
    if (!range) return;

    const ps = this.mainSeries.priceScale();
    if (!this._mainPriceVisibleRange) {
      ps.setAutoScale(false);
      this.mainSeries.applyOptions({ autoscaleInfoProvider: undefined });
      this._mainPriceVisibleRange = { ...range };
    }

    const span = range.to - range.from;
    const shift = (deltaY / main.height) * span;
    const next = { from: range.from + shift, to: range.to + shift };
    this._mainPriceVisibleRange = next;
    try {
      ps.setVisibleRange(next);
    } catch { /* 범위 미설정 등 */ }
  }

  private _resetMainPricePan(): void {
    this._mainPriceVisibleRange = null;
    if (!this.mainSeries) return;
    this.mainSeries.applyOptions({ autoscaleInfoProvider: undefined });
    try {
      this.mainSeries.priceScale().setAutoScale(true);
    } catch { /* ok */ }
  }

  // ── 멀티차트 동기화 헬퍼 ────────────────────────────────────────────────

  /**
   * Crosshair sync: 특정 time 위치에 크로스헤어를 표시한다.
   * - rawBars 에서 해당 time 의 bar 를 찾아 mid 가격에 수평선을 맞춤
   * - 데이터가 없으면 no-op
   */
  setCrosshairAtTime(time: number): void {
    if (!this.mainSeries) return;
    const bar = this.rawBars?.find(b => (b.time as number) === time);
    if (!bar) return;
    const mid = (bar.high + bar.low) / 2;
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    this.chart.setCrosshairPosition(mid, time as Time, this.mainSeries as ISeriesApi<any>);
  }

  /** Crosshair sync: 크로스헤어 제거 */
  clearCrosshairPosition(): void {
    this.chart.clearCrosshairPosition();
  }

  /** Time sync: 현재 가시 시간 범위(unix sec) 반환 */
  getVisibleTimeRange(): { from: number; to: number } | null {
    const r = this.chart.timeScale().getVisibleRange();
    if (!r) return null;
    return { from: r.from as number, to: r.to as number };
  }

  /** Date range sync: 현재 가시 논리 범위(bar index) 반환 */
  getVisibleLogicalRange(): { from: number; to: number } | null {
    return this.chart.timeScale().getVisibleLogicalRange();
  }

  /**
   * Time sync: 가시 시간 범위 외부 적용.
   * rawBars 가 비어 있거나 LWC 내부 시간 맵이 아직 구축되지 않은 경우
   * (멀티차트 전환 직후 데이터 로드 전 등) setVisibleRange 가 "Value is null" 을
   * throw 하므로 안전하게 무시한다.
   */
  applyVisibleTimeRange(from: number, to: number): void {
    if (this.rawBars.length === 0) return;
    try {
      this.chart.timeScale().setVisibleRange({ from: from as Time, to: to as Time });
    } catch { /* 데이터 로딩 전 호출 무시 */ }
  }

  /**
   * Date range sync: 가시 논리 범위 외부 적용.
   * 데이터 로드 전 호출 시 LWC 가 throw 하므로 안전하게 무시한다.
   */
  applyVisibleLogicalRange(from: number, to: number): void {
    if (this.rawBars.length === 0) return;
    try {
      this.chart.timeScale().setVisibleLogicalRange({ from, to });
    } catch { /* 데이터 로딩 전 호출 무시 */ }
  }

  /** Time sync: 가시 시간 범위 변화 구독. unsubscribe fn 반환 */
  subscribeVisibleTimeRange(
    cb: (range: { from: number; to: number } | null) => void,
  ): () => void {
    const handler = () => cb(this.getVisibleTimeRange());
    this.chart.timeScale().subscribeVisibleTimeRangeChange(handler);
    return () => {
      try { this.chart.timeScale().unsubscribeVisibleTimeRangeChange(handler); } catch { /* ok */ }
    };
  }

  /** Date range sync: 가시 논리 범위 변화 구독. unsubscribe fn 반환 */
  subscribeVisibleLogicalRange(
    cb: (range: { from: number; to: number } | null) => void,
  ): () => void {
    const handler = (lr: LogicalRange | null) => cb(lr ?? null);
    this.chart.timeScale().subscribeVisibleLogicalRangeChange(handler);
    return () => {
      try { this.chart.timeScale().unsubscribeVisibleLogicalRangeChange(handler); } catch { /* ok */ }
    };
  }

  /**
   * LWC 공식 데모의 "Go to realtime" 기능
   * 사용자가 과거 데이터를 탐색하다가 최신 캔들로 즉시 이동할 때 호출
   * (ref: https://tradingview.github.io/lightweight-charts/tutorials/demos/realtime-updates)
   */
  scrollToRealTime(): void { this.chart.timeScale().scrollToRealTime(); }

  /** 줌 툴: 두 X 픽셀 위치(canvas 내부)를 시간 범위로 변환하여 확대 */
  zoomToXRange(x0: number, x1: number): void {
    const t0 = this.xToTime(Math.min(x0, x1));
    const t1 = this.xToTime(Math.max(x0, x1));
    if (t0 === null || t1 === null || t0 >= t1) return;
    this.chart.timeScale().setVisibleRange({ from: t0 as import('lightweight-charts').Time, to: t1 as import('lightweight-charts').Time });
  }

  /**
   * 연속된 바의 중앙값 간격(초)을 계산합니다.
   * 일목균형표 미래 확장 시 사용.
   */
  private _barIntervalSecs(): number {
    const bars = this.rawBars;
    if (!bars || bars.length < 2) return 60;
    const intervals: number[] = [];
    for (let i = Math.max(0, bars.length - 20); i < bars.length - 1; i++) {
      intervals.push((bars[i + 1].time as number) - (bars[i].time as number));
    }
    intervals.sort((a, b) => a - b);
    return intervals[Math.floor(intervals.length / 2)] ?? 60;
  }

  /**
   * 워밍업 바를 제외한 "표시용 마지막 displayCount 바" 가 화면에 꽉 차도록
   * visibleRange를 설정합니다.
   *
   * - rawBars 전체는 차트에 등록되어 있어 왼쪽으로 스크롤 가능
   * - 초기 뷰에서는 워밍업 구간을 숨기고, 지표가 완전히 그려진 영역만 표시
   * - extendFutureBars > 0 이면 우측을 그만큼 미래로 확장 (일목균형표 선행스팬용)
   *
   * @param displayCount    표시할 최신 바 개수 (예: 200)
   * @param extendFutureBars 마지막 바 이후 추가로 보여줄 바 수 (기본 0)
   */
  setInitialVisibleRange(displayCount: number, extendFutureBars = 0): void {
    const bars = this.rawBars;
    if (!bars || bars.length === 0) return;

    const ts = this.chart.timeScale();

    if (bars.length <= displayCount && extendFutureBars === 0) {
      ts.fitContent();
      return;
    }

    const startIdx   = Math.max(0, bars.length - displayCount);
    const from       = bars[startIdx].time as number;
    let   to: number = bars[bars.length - 1].time as number;

    if (extendFutureBars > 0) {
      const interval = this._barIntervalSecs();
      to = to + extendFutureBars * interval;
    }

    ts.setVisibleRange({ from: from as Time, to: to as Time });
  }

  /** 일목균형표가 활성화되어 있는지 여부 */
  hasIchimoku(): boolean {
    for (const [, entry] of this.indicators) {
      if (entry.type === 'IchimokuCloud') return true;
    }
    return false;
  }

  /** 메인 시리즈가 데이터와 함께 초기화되었는지 확인 */
  hasMainSeries(): boolean {
    return this.mainSeries !== null && this.rawBars.length > 0;
  }

  /** 로드된 보조지표 수 (종목 전환 후 캔들만 그려진 상태 감지용) */
  getIndicatorCount(): number {
    return this.indicators.size;
  }

  /** 실제 시리즈·구름이 붙은 보조지표 수 (부분 로드·중단 감지용) */
  getLoadedIndicatorCount(): number {
    let n = 0;
    for (const entry of this.indicators.values()) {
      if (entry.config?.hidden === true) continue;
      if (entry.cloudPlugin || entry.seriesList.length > 0) n += 1;
    }
    return n;
  }

  /** 현재 로드된 rawBars 개수 반환 (데이터 로드 여부 확인용) */
  getRawBarsLength(): number {
    return this.rawBars.length;
  }

  /** 두 시각(초) 사이에 포함된 봉 개수 — 기간(날짜 범위) 측정 라벨용 */
  countBarsBetweenTime(t0: number, t1: number): number {
    const lo = Math.min(t0, t1);
    const hi = Math.max(t0, t1);
    let n = 0;
    for (const b of this.rawBars) {
      if (b.time >= lo && b.time <= hi) n++;
    }
    return n;
  }

  /** 가장 오래된 bar 의 Unix 타임스탬프(초) 반환. 데이터 없으면 null. */
  getOldestBarTime(): number | null {
    return this.rawBars.length > 0 ? this.rawBars[0].time as number : null;
  }

  /**
   * 더 오래된 bar 를 앞에 추가하고 시리즈·보조지표를 갱신합니다.
   *
   * - 이미 존재하는 시각보다 오래된 bar 만 실제로 추가합니다.
   * - 현재 가시 범위(logical range)를 추가된 bar 수만큼 우측으로 이동시켜
   *   사용자가 보고 있던 구간이 유지되도록 합니다.
   * - 모든 보조지표를 전체 데이터 기준으로 재계산합니다.
   */
  async prependBars(olderBars: OHLCVBar[]): Promise<void> {
    if (!this.mainSeries || olderBars.length === 0) return;

    const firstExistingTime = this.rawBars[0]?.time as number | undefined;
    const newBars = firstExistingTime !== undefined
      ? olderBars.filter(b => (b.time as number) < firstExistingTime)
      : olderBars;

    if (newBars.length === 0) return;

    // 현재 가시 논리 범위 저장 (prepend 후 복원용)
    const logicalRange = this.chart.timeScale().getVisibleLogicalRange();

    this.rawBars = [...newBars, ...this.rawBars];

    const t  = getTheme(this.currentTheme);
    const ct = this.currentChartType;

    // 메인 시리즈 전체 데이터 갱신
    if (ct === 'line' || ct === 'area' || ct === 'baseline') {
      // eslint-disable-next-line @typescript-eslint/no-explicit-any
      (this.mainSeries as ISeriesApi<any>).setData(
        this.rawBars.map(b => ({ time: b.time as Time, value: b.close }))
      );
    } else {
      // eslint-disable-next-line @typescript-eslint/no-explicit-any
      (this.mainSeries as ISeriesApi<any>).setData(
        this.rawBars.map(b => ({
          time: b.time as Time,
          open: b.open, high: b.high, low: b.low, close: b.close,
        }))
      );
    }

    // 볼륨 시리즈 전체 데이터 갱신
    this.volumeSeries?.setData(
      this.rawBars.map(b => ({
        time:  b.time as Time,
        value: b.volume,
        color: b.close >= b.open ? `${t.upColor}88` : `${t.downColor}88`,
      }))
    );

    // 가시 논리 범위 복원: 앞에 추가된 bar 수만큼 오른쪽으로 이동
    if (logicalRange) {
      try {
        this.chart.timeScale().setVisibleLogicalRange({
          from: logicalRange.from + newBars.length,
          to:   logicalRange.to  + newBars.length,
        });
      } catch { /* 무시 */ }
    }

    // 보조지표 전체 재계산 (새 데이터가 왼쪽에 추가됐으므로 전체 갱신 필요)
    await this._refreshAllIndicatorsData();
  }

  /** 모든 보조지표를 현재 rawBars 기준으로 전체 재계산하고 시리즈 데이터를 교체합니다. */
  private async _refreshAllIndicatorsData(): Promise<void> {
    if (this.rawBars.length === 0) return;
    const bars = this.rawBars.slice();

    // 패턴 마커 초기화 후 재구성
    this.patternMarkers = [];

    for (const [, entry] of this.indicators.entries()) {
      if (!entry.config) continue;
      try {
        const result = await calculateIndicator(entry.config.type, bars, entry.config.params ?? {});
        const { plots } = result;

        // 패턴 마커 지표 (seriesList 가 비어 있음)
        if (entry.seriesList.length === 0) {
          if (result.markers?.length) {
            this.patternMarkers.push({ id: entry.id, markers: result.markers });
          }
          continue;
        }

        // 일목균형표: 미래 프로젝션 포함 특별 처리
        if (entry.type === 'IchimokuCloud') {
          this._refreshIchimokuSeriesData(entry, plots);
          continue;
        }

        // 일반 지표: 각 시리즈에 전체 데이터 setData
        for (let i = 0; i < entry.seriesList.length; i++) {
          const series = entry.seriesList[i];
          const meta   = entry.seriesMeta[i];
          if (!meta) continue;
          const plotData = plots[meta.plotId] as PlotData | undefined;
          if (!plotData?.length) continue;
          const filtered = plotData.filter(p => p.value !== null && !isNaN(p.value));
          if (filtered.length === 0) continue;

          if (meta.isHistogram) {
            // eslint-disable-next-line @typescript-eslint/no-explicit-any
            (series as ISeriesApi<any>).setData(
              filtered.map((p, idx, arr) => ({
                time:  p.time as Time,
                value: p.value,
                color: p.color ?? (
                  idx > 0 && p.value < arr[idx - 1].value
                    ? '#ef535088'
                    : p.value < 0 ? '#ef535088' : `${meta.color}88`
                ),
              }))
            );
          } else {
            // eslint-disable-next-line @typescript-eslint/no-explicit-any
            (series as ISeriesApi<any>).setData(
              filtered.map(p => ({ time: p.time as Time, value: p.value }))
            );
          }
        }
        if (entry.type === 'BollingerBands') {
          entry.bbFillPrimitive?.requestRefresh();
        }
      } catch { /* 계산 실패 무시 */ }
    }

    this._reapplyAllPatternMarkers();
  }

  /**
   * 일목균형표 시리즈 데이터를 미래 프로젝션 포함하여 전체 갱신합니다.
   * (prependBars / _refreshAllIndicatorsData 전용)
   */
  private _refreshIchimokuSeriesData(
    entry:  IndicatorEntry,
    plots:  Record<string, PlotData>,
  ): void {
    const displacement  = (entry.config?.params?.displacement        as number) ?? 26;
    const laggingPeriod = (entry.config?.params?.laggingSpan2Periods as number) ?? 52;
    const convPlot = (plots['plot0'] as PlotData) ?? [];
    const basePlot = (plots['plot1'] as PlotData) ?? [];

    for (let i = 0; i < entry.seriesList.length; i++) {
      const series = entry.seriesList[i];
      const meta   = entry.seriesMeta[i];
      if (!meta) continue;
      const plotData = (plots[meta.plotId] as PlotData) ?? [];
      const filtered = plotData.filter(p => p.value !== null && !isNaN(p.value));

      let seriesData: Array<{ time: Time; value: number }> =
        filtered.map(p => ({ time: p.time as Time, value: p.value }));

      // 선행스팬A·B: 미래 26봉 프로젝션 추가
      if (meta.plotId === 'plot3') {
        seriesData = seriesData.concat(this._ichimokuFutureSpanA(convPlot, basePlot, displacement));
      } else if (meta.plotId === 'plot4') {
        seriesData = seriesData.concat(this._ichimokuFutureSpanB(displacement, laggingPeriod));
      }

      // eslint-disable-next-line @typescript-eslint/no-explicit-any
      (series as ISeriesApi<any>).setData(seriesData);
    }

    if (entry.cloudPlugin && entry.config) {
      this._applyIchimokuCloudPlugin(entry.cloudPlugin, plots, entry.config);
    }
  }

  /**
   * 특정 time(Unix 초)에 해당하는 bar를 rawBars에서 반환합니다.
   * 실시간 tick/appendBar 로 갱신된 최신 OHLCV 값을 포함합니다.
   * 크로스헤어 레전드 표시 등 외부에서 최신 bar 데이터가 필요할 때 사용합니다.
   */
  getBarAtTime(time: number): OHLCVBar | undefined {
    return this.rawBars.find(b => (b.time as number) === time);
  }
  setLogScale(enabled: boolean): void {
    this.mainSeries?.applyOptions({ priceScale: { mode: enabled ? 1 : 0 } } as Parameters<ISeriesApi<SeriesType>['applyOptions']>[0]);
  }

  /** 퍼센트(%) 스케일 토글 — 가격 대신 % 변동률로 Y축 표시 */
  setPercentScale(enabled: boolean): void {
    // PriceScaleMode: 0=Normal, 1=Logarithmic, 2=Percentage, 3=IndexedTo100
    this.mainSeries?.priceScale().applyOptions({ mode: enabled ? 2 : 0 });
  }

  /** 차트 그리드 라인 표시/숨김 */
  setGrid(enabled: boolean): void {
    this.chart.applyOptions({
      grid: {
        vertLines: { color: enabled ? getTheme(this.currentTheme).gridColor : 'transparent' },
        horzLines: { color: enabled ? getTheme(this.currentTheme).gridColor : 'transparent' },
      },
    });
  }

  /** 가격 스케일 자동(auto) 맞춤 — fitContent와 달리 시간축은 변경하지 않음 */
  autoScale(): void {
    this._resetMainPricePan();
    this.mainSeries?.priceScale().setAutoScale(true);
  }

  /**
   * 크로스헤어 파라미터에서 보조지표 시리즈 값 추출
   * { indicatorType → [plot0 값, plot1 값, ...] }
   */
  getIndicatorValuesFromParams(params: MouseEventParams<Time>): Record<string, number[]> {
    const result: Record<string, number[]> = {};
    for (const [, entry] of this.indicators) {
      const values: number[] = [];
      for (const series of entry.seriesList) {
        // eslint-disable-next-line @typescript-eslint/no-explicit-any
        const d = params.seriesData.get(series) as any;
        const v = d != null
          ? (typeof d.value === 'number' ? d.value : d.close ?? NaN)
          : NaN;
        values.push(v);
      }
      if (values.some(v => !isNaN(v))) {
        result[entry.type] = values;
      }
    }
    return result;
  }

  resize(w: number, h: number): void { this.chart.resize(w, h); }

  // ─── Compare overlay ──────────────────────────────────────────────────────
  addCompareSeries(symbol: string, bars: OHLCVBar[], color: string): void {
    if (this.compareSeriesMap.has(symbol)) return;
    const s = this.chart.addSeries(LineSeries, { color, lineWidth: 2, priceLineVisible: false });
    const base = bars[0]?.close ?? 1;
    s.setData(bars.map(b => ({ time: b.time as Time, value: (b.close / base - 1) * 100 })));
    this.compareSeriesMap.set(symbol, s);
  }

  removeCompareSeries(symbol: string): void {
    const s = this.compareSeriesMap.get(symbol);
    if (s) { try { this.chart.removeSeries(s); } catch { /* ok */ } this.compareSeriesMap.delete(symbol); }
  }

  // ─── Coordinate conversion (for drawing canvas) ───────────────────────────
  timeToX(time: number): number | null {
    const coord = this.chart.timeScale().timeToCoordinate(time as Time);
    return coord === null ? null : coord;
  }

  priceToY(price: number): number | null {
    const coord = this.mainSeries?.priceToCoordinate(price);
    return coord === null || coord === undefined ? null : coord;
  }

  xToTime(x: number): number | null {
    const t = this.chart.timeScale().coordinateToTime(x);
    return t === null ? null : (t as number);
  }

  yToPrice(y: number): number | null {
    const p = this.mainSeries?.coordinateToPrice(y);
    return p === null || p === undefined ? null : p;
  }

  /**
   * 우클릭 매수·매도: 캔들 pane(0) 클릭 좌표 → 해당 Y축 가격.
   * 가격축·시간축·거래량·보조지표 pane 클릭은 null.
   */
  getTradePriceAtChartPoint(chartX: number, chartY: number): number | null {
    if (!this.mainSeries) return null;
    if (this.isOnPriceAxis(chartX, chartY) || this.isOnTimeAxis(chartY)) return null;
    if (this.getPaneIndexAtChartY(chartY) !== 0) return null;
    const layout = this.getPaneLayouts().find(l => l.paneIndex === 0);
    if (!layout) return null;
    const paneLocalY = chartY - layout.topY;
    const p = this.mainSeries.coordinateToPrice(paneLocalY);
    if (p == null || !Number.isFinite(p) || p <= 0) return null;
    return p;
  }

  /** Subscribe to visible range changes (time scale + price scale). Returns unsubscribe fn. */
  subscribeViewport(cb: () => void): () => void {
    this.chart.timeScale().subscribeVisibleTimeRangeChange(cb);
    this.chart.timeScale().subscribeVisibleLogicalRangeChange(cb);
    return () => {
      try { this.chart.timeScale().unsubscribeVisibleTimeRangeChange(cb); } catch { /* ok */ }
      try { this.chart.timeScale().unsubscribeVisibleLogicalRangeChange(cb); } catch { /* ok */ }
    };
  }

  /**
   * Pane 높이를 아래 규칙에 따라 재배분하고 **필요한 총 높이**를 반환합니다.
   *
   * ┌────────────────────────────────────────────────────────────┐
   * │ 보조지표 N개   │ 캔들 차트     │ 보조지표 영역              │
   * ├────────────────┼───────────────┼────────────────────────────┤
   * │ 0              │ H - 60        │ -                          │
   * │ 1 ~ 3          │ H/2           │ H/2 를 N 등분              │
   * │ 4+             │ max(H/3, min) │ 나머지를 N 등분            │
   * │ (화면 초과 시) │     ↑ 고정    │ 각 80px 최소 → 스크롤     │
   * └────────────────────────────────────────────────────────────┘
   *
   * - 거래량 pane (index 1): 항상 60px 고정
   * - 캔들 차트 최소 높이: H / 3 (절대 그 이하로 줄지 않음)
   * - 반환값 > 현재 컨테이너 높이 → 호출자가 스크롤 컨테이너 확장 필요
   */
  /**
   * Pane 높이를 **setStretchFactor** 기반으로 재배분합니다.
   *
   * setHeight() 는 내부적으로 현재 LWC 렌더링 높이 기준의 stretch factor 로 변환되어
   * 시간봉 변경 등 리렌더링 직후에는 비율이 틀어지는 문제가 있습니다.
   * setStretchFactor(0~1) 는 차트 전체 높이를 1 로 놓은 절대 비율이므로 항상 정확합니다.
   *
   * ┌────────────────┬──────────────────┬─────────────────────────────┐
   * │ 보조지표 N 개  │ 캔들 비율        │ 보조지표 비율               │
   * ├────────────────┼──────────────────┼─────────────────────────────┤
   * │ 0              │ 1 - vol_frac     │ -                           │
   * │ 1 ~ 3          │ 0.5              │ (0.5 - vol_frac) / N 각각   │
   * │ 4 +            │ 최소 1/3         │ 나머지 균등                 │
   * └────────────────┴──────────────────┴─────────────────────────────┘
   *
   * @param availableHeight  외부에서 측정한 실제 가용 높이(px). 스크롤 필요 여부 판단에만 사용.
   */
  private _volumeFrac(H: number): number {
    if (!this._volumeVisible || this._candleOnlyLayout) return 0;
    return Math.min(Math.max(60 / H, 0.04), 0.12);
  }

  private _applyPaneStretchFactors(mainFrac: number, availableHeight?: number): number {
    const panes = this.chart.panes();
    if (panes.length === 0) return availableHeight ?? this.container.clientHeight;

    const activeIndPanes = this._activeIndicatorPaneIndices();
    const N = activeIndPanes.size;
    const H = (availableHeight && availableHeight > 0)
      ? availableHeight
      : (this._lastLayoutAvailableHeight && this._lastLayoutAvailableHeight > 0
        ? this._lastLayoutAvailableHeight
        : this.container.clientHeight);

    const volumeShown = this._volumeVisible && !this._candleOnlyLayout;
    const VOL_FRAC = this._volumeFrac(H);
    const MIN_IND_PX = 80;
    const ORPHAN_STRETCH = 0.0001;
    /** 보조지표 pane 은 동일 stretch weight → 항상 같은 높이 */
    const IND_EQUAL_WEIGHT = 1;

    const remaining = Math.max(0, 1 - mainFrac - (volumeShown ? VOL_FRAC : 0));
    const eachIndFrac = N > 0 ? remaining / N : 0;
    const naturalIndPx = eachIndFrac * H;
    const actualIndFrac = naturalIndPx >= MIN_IND_PX
      ? eachIndFrac
      : MIN_IND_PX / H;

    const indWeight = IND_EQUAL_WEIGHT;
    const indUnit = Math.max(actualIndFrac, 1e-9);
    const mainWeight = N > 0 ? mainFrac / indUnit : mainFrac;
    const volWeight = volumeShown
      ? (N > 0 ? VOL_FRAC / indUnit : VOL_FRAC)
      : ORPHAN_STRETCH;

    for (let i = 0; i < panes.length; i++) {
      if (i === 0) {
        panes[i].setStretchFactor(mainWeight);
      } else if (i === 1) {
        panes[i].setStretchFactor(volumeShown ? volWeight : ORPHAN_STRETCH);
      } else if (activeIndPanes.has(i)) {
        panes[i].setStretchFactor(indWeight);
      } else {
        panes[i].setStretchFactor(ORPHAN_STRETCH);
      }
    }

    const totalRequired = Math.round(
      mainFrac * H + (volumeShown ? VOL_FRAC * H : 0) + N * Math.max(naturalIndPx, MIN_IND_PX),
    );
    this._notifyPaneLayout();
    return totalRequired;
  }

  private _paneHeightFractions(
    indicatorPaneCount: number,
    volFrac: number,
  ): { mainFrac: number; eachIndFrac: number } {
    const N = indicatorPaneCount;
    if (N === 0) {
      return { mainFrac: 1 - volFrac, eachIndFrac: 0 };
    }
    if (N <= 3) {
      return { mainFrac: 0.5, eachIndFrac: (0.5 - volFrac) / N };
    }
    const mainFrac = Math.max(1 / 3, volFrac + 0.05);
    return { mainFrac, eachIndFrac: (1 - mainFrac - volFrac) / N };
  }

  /** 캔들 pane 전체보기 — pane 0 만 사용, 하단 pane 접음 */
  private _resetPaneHeightsCandleFullscreen(availableHeight?: number): number {
    const panes = this.chart.panes();
    if (panes.length === 0) return availableHeight ?? this.container.clientHeight;

    const ORPHAN_STRETCH = 0.0001;
    const H = (availableHeight && availableHeight > 0)
      ? availableHeight
      : this.container.clientHeight;

    for (let i = 0; i < panes.length; i++) {
      panes[i].setStretchFactor(i === 0 ? 1 : ORPHAN_STRETCH);
    }

    return H;
  }

  resetPaneHeights(availableHeight?: number): number {
    const panes = this.chart.panes();
    if (panes.length === 0) return availableHeight ?? this.container.clientHeight;

    if (availableHeight != null && availableHeight > 0) {
      this._lastLayoutAvailableHeight = availableHeight;
    }

    if (this._candleOnlyLayout) {
      return this._resetPaneHeightsCandleFullscreen(availableHeight);
    }

    const H = (availableHeight && availableHeight > 0)
      ? availableHeight
      : (this._lastLayoutAvailableHeight && this._lastLayoutAvailableHeight > 0
        ? this._lastLayoutAvailableHeight
        : this.container.clientHeight);
    const N = this._activeIndicatorPaneIndices().size;
    const { mainFrac } = this._paneHeightFractions(N, this._volumeFrac(H));

    return this._applyPaneStretchFactors(mainFrac, availableHeight);
  }

  // ─── Diagnostic ───────────────────────────────────────────────────────────
  /**
   * 현재 rawBars 기준으로 모든 보조지표의 마지막 계산값을 콘솔에 출력합니다.
   * 브라우저 콘솔에서 `chartManager.logIndicatorLastValues()` 로 호출하여
   * 갱신 여부와 실제 값 변화를 수동으로 확인할 수 있습니다.
   *
   * 예: ADX 값이 두 번 호출 사이에 다른지 확인 → 값이 매우 조금씩 변하면
   * 갱신 로직은 정상이며 Wilder's smoothing 의 수학적 특성으로 인해
   * 변화가 작을 뿐임을 확인할 수 있습니다.
   */
  async logIndicatorLastValues(): Promise<void> {
    if (this.rawBars.length === 0) { console.warn('[ChartManager] rawBars empty'); return; }
    const snapshot = this.rawBars.slice();
    const lastBar  = snapshot[snapshot.length - 1];
    console.group(`[ChartManager] Indicator last values @ bar time=${new Date(lastBar.time * 1000).toISOString()}`);
    console.log('Main bar:', lastBar);
    for (const [, entry] of this.indicators.entries()) {
      if (!entry.config || entry.seriesList.length === 0) continue;
      try {
        const { plots } = await calculateIndicator(entry.config.type, snapshot, entry.config.params ?? {});
        const vals: Record<string, number | null> = {};
        for (const meta of entry.seriesMeta) {
          const pd = plots[meta.plotId] as PlotData | undefined;
          vals[meta.plotId] = pd?.length ? (pd[pd.length - 1]?.value ?? null) : null;
        }
        console.log(`  [${entry.config.type}]`, vals);
      } catch (e) {
        console.warn(`  [${entry.config.type}] calc error:`, e);
      }
    }
    console.groupEnd();
  }

  // ─── Destroy ─────────────────────────────────────────────────────────────
  destroy(): void {
    if (this._indUpdateTimer) { clearTimeout(this._indUpdateTimer); this._indUpdateTimer = null; }
    if (this._clickHandler) this.chart.unsubscribeClick(this._clickHandler);
    this.chart.remove();
  }
}
