package com.goldenchart.service;

import com.goldenchart.dto.IndicatorResponse;
import com.goldenchart.dto.IndicatorResponse.PlotPoint;
import com.goldenchart.dto.OhlcvBar;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.ta4j.core.*;
import org.ta4j.core.indicators.*;
import org.ta4j.core.indicators.adx.*;
import org.ta4j.core.indicators.aroon.*;
import org.ta4j.core.indicators.averages.*;
import org.ta4j.core.indicators.bollinger.*;
import org.ta4j.core.indicators.donchian.*;
import org.ta4j.core.indicators.helpers.*;
import org.ta4j.core.indicators.ichimoku.*;
import org.ta4j.core.indicators.keltner.*;
import org.ta4j.core.indicators.numeric.NumericIndicator;
import org.ta4j.core.indicators.numeric.UnaryOperationIndicator;
import org.ta4j.core.indicators.statistics.*;
import org.ta4j.core.indicators.volume.*;
import org.ta4j.core.bars.TimeBarBuilderFactory;
import org.ta4j.core.num.DoubleNumFactory;
import org.ta4j.core.num.Num;

import java.time.Duration;
import java.time.ZoneOffset;
import java.time.ZonedDateTime;
import java.util.*;
import java.util.stream.IntStream;

/**
 * Ta4j 기술 지표 계산 서비스.
 * frontend indicatorRegistry.ts 에 등록된 지표를 동일 로직으로 구현.
 */
@Service
@Slf4j
public class IndicatorService {

    // ── Public API ────────────────────────────────────────────────────────────

    public IndicatorResponse calculate(List<OhlcvBar> bars, String type, Map<String, Object> params, String timeframe) {
        BarSeries series = buildSeries(bars, timeframe);
        int n = series.getBarCount();
        Map<String, Object> p = params != null ? params : Map.of();

        Map<String, List<PlotPoint>> result = switch (type) {
            // Moving Averages
            case "SMA"    -> calcSMA(series, p);
            case "EMA"    -> single("plot0", series, new EMAIndicator(src(series, p), intP(p, "length", 21)));
            case "WMA"    -> single("plot0", series, new WMAIndicator(src(series, p), intP(p, "length", 20)));
            case "HMA"    -> single("plot0", series, new HMAIndicator(src(series, p), intP(p, "length", 16)));
            case "VWMA"   -> single("plot0", series, new VWMAIndicator(new ClosePriceIndicator(series), intP(p, "length", 20)));
            case "DEMA"   -> single("plot0", series, new DoubleEMAIndicator(src(series, p), intP(p, "length", 21)));
            case "TEMA"   -> single("plot0", series, new TripleEMAIndicator(src(series, p), intP(p, "length", 21)));
            case "RMA", "SMMA" -> single("plot0", series, new WildersMAIndicator(src(series, p), intP(p, "length", 14)));
            case "LSMA"   -> single("plot0", series, new LSMAIndicator(src(series, p), intP(p, "length", 25)));
            case "MACross" -> calcMACross(series, p);

            // Bands
            case "BollingerBands"   -> calcBB(series, p);
            case "KeltnerChannels"  -> calcKC(series, p);
            case "DonchianChannels" -> calcDC(series, p);
            case "BBPercentB"       -> calcBBPercentB(series, p);
            case "BBBandWidth"      -> calcBBBandWidth(series, p);

            // Oscillators
            case "RSI"    -> calcRSI(series, p);
            case "Stochastic"  -> calcStochastic(series, p);
            case "StochRSI"    -> calcStochRSI(series, p);
            case "CCI"         -> calcCCI(series, p);
            case "WilliamsPercentRange" -> calcWilliamsR(series, p);
            case "AwesomeOscillator"    -> calcAO(series);
            case "DPO"         -> single("plot0", series, new DPOIndicator(src(series, p), intP(p, "length", 21)));

            // Momentum
            case "MACD"     -> calcMACD(series, p);
            case "Momentum" -> calcMomentumInd(series, p);
            case "ROC"      -> calcROC(series, p);
            case "TSI"      -> calcTSI(series, p);
            case "TRIX"     -> calcTRIX(series, p);

            // Trend
            case "ADX"          -> calcADX(series, p);
            case "DMI"          -> calcDMI(series, p);
            case "Aroon"        -> calcAroon(series, p);
            case "IchimokuCloud"-> calcIchimoku(series, p);
            case "ParabolicSAR" -> calcParabolicSAR(series, p);
            case "Choppiness"   -> calcChoppiness(series, p);
            case "MassIndex"    -> calcMassIndex(series, p);

            // Volatility
            case "ATR"              -> single("plot0", series, new ATRIndicator(series, intP(p, "length", 14)));
            case "StandardDeviation"-> single("plot0", series, new StandardDeviationIndicator(src(series, p), intP(p, "length", 20)));
            case "HistoricalVolatility" -> calcHV(series, p);

            // Volume
            case "OBV"      -> calcOBV(series, p);
            case "MFI"      -> single("plot0", series, new MoneyFlowIndexIndicator(series, intP(p, "length", 14)));
            case "ChaikinMF"-> single("plot0", series, new ChaikinMoneyFlowIndicator(series, intP(p, "length", 20)));
            case "VolumeOscillator" -> calcVolumeOscillator(series, p);
            case "VR"                 -> calcVR(series, p);
            case "Disparity"          -> calcDisparity(series, p);
            case "Psychological", "NewPsychological" -> calcPsychological(series, p);
            case "InvestPsychological"-> calcInvestPsychological(series, p);

            default -> throw new IllegalArgumentException("지원하지 않는 지표: " + type);
        };

        List<Long> times = IntStream.range(0, n)
            .mapToObj(i -> {
                Bar bar = series.getBar(i);
                return bar.getEndTime().getEpochSecond() - bar.getTimePeriod().getSeconds();
            })
            .toList();

        return IndicatorResponse.builder()
            .indicatorType(type)
            .times(times)
            .values(result)
            .build();
    }

    // ── Series Builder ────────────────────────────────────────────────────────

    /**
     * OhlcvBar 목록으로 Ta4j BarSeries 를 생성한다.
     *
     * @param bars      OHLCV 바 데이터
     * @param timeframe 타임프레임 문자열 (1m/5m/15m/30m/1h/4h/1D/1W/1M).
     *                  null 또는 알 수 없는 값이면 1분으로 fallback.
     */
    private BarSeries buildSeries(List<OhlcvBar> bars, String timeframe) {
        BarSeries series = new BaseBarSeriesBuilder()
            .withNumFactory(DoubleNumFactory.getInstance())
            .build();
        TimeBarBuilderFactory factory = new TimeBarBuilderFactory();
        Duration period = timeframeToDuration(timeframe);
        for (OhlcvBar b : bars) {
            // b.getTime() 은 봉 시작 시각(Unix 초).
            // ta4j Bar 의 endTime 은 봉 종료 시각이므로 duration 을 더해야 한다.
            java.time.Instant endInst = java.time.Instant.ofEpochSecond(b.getTime()).plus(period);
            factory.createBarBuilder(series)
                .timePeriod(period)
                .endTime(endInst)
                .openPrice(b.getOpen())
                .highPrice(b.getHigh())
                .lowPrice(b.getLow())
                .closePrice(b.getClose())
                .volume(b.getVolume())
                .add();
        }
        return series;
    }

    /**
     * 타임프레임 문자열을 Duration 으로 변환.
     * frontend Timeframe 타입 (1m/5m/15m/30m/1h/4h/1D/1W/1M)과 1:1 대응.
     */
    private static Duration timeframeToDuration(String tf) {
        if (tf == null) return Duration.ofMinutes(1);
        return switch (tf) {
            case "1m"  -> Duration.ofMinutes(1);
            case "3m"  -> Duration.ofMinutes(3);
            case "5m"  -> Duration.ofMinutes(5);
            case "15m" -> Duration.ofMinutes(15);
            case "30m" -> Duration.ofMinutes(30);
            case "1h"  -> Duration.ofHours(1);
            case "4h"  -> Duration.ofHours(4);
            case "1D"  -> Duration.ofDays(1);
            case "1W"  -> Duration.ofDays(7);
            case "1M"  -> Duration.ofDays(30);
            default    -> Duration.ofMinutes(1);
        };
    }

    // ── Helpers ───────────────────────────────────────────────────────────────

    private int intP(Map<String, Object> p, String k, int def) {
        Object v = p.get(k); return v == null ? def : ((Number) v).intValue();
    }

    private double dblP(Map<String, Object> p, String k, double def) {
        Object v = p.get(k); return v == null ? def : ((Number) v).doubleValue();
    }

    private Indicator<Num> src(BarSeries s, Map<String, Object> p) {
        return switch (p.getOrDefault("src", "close").toString()) {
            case "open"  -> new OpenPriceIndicator(s);
            case "high"  -> new HighPriceIndicator(s);
            case "low"   -> new LowPriceIndicator(s);
            case "hl2"   -> new MedianPriceIndicator(s);
            case "hlc3"  -> new TypicalPriceIndicator(s);
            default      -> new ClosePriceIndicator(s);
        };
    }

    private Double safe(Num n) {
        if (n == null) return null;
        double v = n.doubleValue();
        return Double.isNaN(v) || Double.isInfinite(v) ? null : v;
    }

    private List<PlotPoint> toPlot(BarSeries s, Indicator<Num> ind) {
        int n = s.getBarCount();
        List<PlotPoint> pts = new ArrayList<>(n);
        for (int i = 0; i < n; i++) {
            Bar bar = s.getBar(i);
            // 프론트 차트의 time 축은 봉 시작 시각 기준.
            // endTime - timePeriod = 봉 시작 시각
            long startEpoch = bar.getEndTime().getEpochSecond()
                    - bar.getTimePeriod().getSeconds();
            pts.add(PlotPoint.builder()
                .time(startEpoch)
                .value(safe(ind.getValue(i)))
                .build());
        }
        return pts;
    }

    private Map<String, List<PlotPoint>> single(String id, BarSeries s, Indicator<Num> ind) {
        return Map.of(id, toPlot(s, ind));
    }

    // ── Moving Averages ───────────────────────────────────────────────────────

    /** MA1~MA11 기본 기간: 5, 12, 16, 20, 26, 34, 50, 60, 72, 90, 120 */
    private static final int[] SMA_DEFAULT_PERIODS = { 5, 12, 16, 20, 26, 34, 50, 60, 72, 90, 120 };

    private Map<String, List<PlotPoint>> calcSMA(BarSeries s, Map<String, Object> p) {
        Indicator<Num> source = src(s, p);
        Map<String, List<PlotPoint>> result = new LinkedHashMap<>();
        for (int i = 0; i < SMA_DEFAULT_PERIODS.length; i++) {
            String periodKey = "period" + (i + 1);
            int period = intP(p, periodKey, SMA_DEFAULT_PERIODS[i]);
            // 레거시 단일 len → MA1
            if (i == 0 && p.containsKey("len")) {
                period = intP(p, "len", SMA_DEFAULT_PERIODS[0]);
            }
            period = Math.max(1, period);
            result.put("plot" + i, toPlot(s, new SMAIndicator(source, period)));
        }
        return result;
    }

    private Map<String, List<PlotPoint>> calcMACross(BarSeries s, Map<String, Object> p) {
        Indicator<Num> src = src(s, p);
        return Map.of(
            "plot0", toPlot(s, new EMAIndicator(src, intP(p, "fastLength", 9))),
            "plot1", toPlot(s, new EMAIndicator(src, intP(p, "slowLength", 21)))
        );
    }

    // ── Bands ─────────────────────────────────────────────────────────────────

    private Map<String, List<PlotPoint>> calcBB(BarSeries s, Map<String, Object> p) {
        int len = intP(p, "length", 20);
        double mult = dblP(p, "mult", 2.0);
        int offset = intP(p, "offset", 0);
        // 업비트 BB: 종가 SMA + 모집단 표준편차 × 곱
        ClosePriceIndicator close = new ClosePriceIndicator(s);
        SMAIndicator basis = new SMAIndicator(close, len);
        StandardDeviationIndicator std = StandardDeviationIndicator.ofPopulation(close, len);
        BollingerBandsMiddleIndicator mid = new BollingerBandsMiddleIndicator(basis);
        Num k = s.numFactory().numOf(mult);
        BollingerBandsUpperIndicator upper = new BollingerBandsUpperIndicator(mid, std, k);
        BollingerBandsLowerIndicator lower = new BollingerBandsLowerIndicator(mid, std, k);
        return Map.of(
            "plot0", shiftPlotOffset(toPlot(s, mid), offset),
            "plot1", shiftPlotOffset(toPlot(s, upper), offset),
            "plot2", shiftPlotOffset(toPlot(s, lower), offset)
        );
    }

    /** TradingView/업비트 오프셋: 양수 = 플롯을 시간축 오른쪽으로 이동 */
    private List<PlotPoint> shiftPlotOffset(List<PlotPoint> pts, int offset) {
        if (offset == 0) return pts;
        int n = pts.size();
        List<PlotPoint> out = new ArrayList<>(n);
        for (int i = 0; i < n; i++) {
            int src = i - offset;
            Double val = (src >= 0 && src < n) ? pts.get(src).getValue() : null;
            out.add(PlotPoint.builder().time(pts.get(i).getTime()).value(val).build());
        }
        return out;
    }

    private Indicator<Num> maOf(Indicator<Num> source, String maType, int len, BarSeries s) {
        return switch (maType) {
            case "EMA"  -> new EMAIndicator(source, len);
            case "WMA"  -> new WMAIndicator(source, len);
            case "SMMA (RMA)", "RMA" -> new WildersMAIndicator(source, len);
            case "VWMA" -> new VWMAIndicator(source, len);
            default     -> new SMAIndicator(source, len);
        };
    }

    private Map<String, List<PlotPoint>> calcKC(BarSeries s, Map<String, Object> p) {
        int len = intP(p, "length", 20);
        double mult = dblP(p, "multiplier", 2.0);
        EMAIndicator ema = new EMAIndicator(new ClosePriceIndicator(s), len);
        KeltnerChannelMiddleIndicator mid = new KeltnerChannelMiddleIndicator(s, len);
        ATRIndicator atr = new ATRIndicator(s, len);
        KeltnerChannelUpperIndicator upper = new KeltnerChannelUpperIndicator(mid, atr, mult);
        KeltnerChannelLowerIndicator lower = new KeltnerChannelLowerIndicator(mid, atr, (double) mult);
        return Map.of(
            "plot0", toPlot(s, upper),
            "plot1", toPlot(s, ema),
            "plot2", toPlot(s, lower)
        );
    }

    private Map<String, List<PlotPoint>> calcDC(BarSeries s, Map<String, Object> p) {
        int len = intP(p, "length", 20);
        DonchianChannelUpperIndicator upper = new DonchianChannelUpperIndicator(s, len);
        DonchianChannelLowerIndicator lower = new DonchianChannelLowerIndicator(s, len);
        // basis = (upper + lower) / 2
        NumericIndicator basis = NumericIndicator.of(upper).plus(lower).dividedBy(2);
        return Map.of(
            "plot0", toPlot(s, upper),
            "plot1", toPlot(s, basis),
            "plot2", toPlot(s, lower)
        );
    }

    private Map<String, List<PlotPoint>> calcBBPercentB(BarSeries s, Map<String, Object> p) {
        int len = intP(p, "length", 20);
        double mult = dblP(p, "mult", 2.0);
        Indicator<Num> src = src(s, p);
        SMAIndicator sma = new SMAIndicator(src, len);
        StandardDeviationIndicator std = StandardDeviationIndicator.ofSample(src, len);
        BollingerBandsMiddleIndicator mid = new BollingerBandsMiddleIndicator(sma);
        Num k = s.numFactory().numOf(mult);
        BollingerBandsUpperIndicator upper = new BollingerBandsUpperIndicator(mid, std, k);
        BollingerBandsLowerIndicator lower = new BollingerBandsLowerIndicator(mid, std, k);
        // %B = (price - lower) / (upper - lower)
        NumericIndicator pctB = NumericIndicator.of(src)
            .minus(lower)
            .dividedBy(NumericIndicator.of(upper).minus(lower));
        return single("plot0", s, pctB);
    }

    private Map<String, List<PlotPoint>> calcBBBandWidth(BarSeries s, Map<String, Object> p) {
        int len = intP(p, "length", 20);
        double mult = dblP(p, "mult", 2.0);
        Indicator<Num> src = src(s, p);
        SMAIndicator sma = new SMAIndicator(src, len);
        StandardDeviationIndicator std = StandardDeviationIndicator.ofSample(src, len);
        BollingerBandsMiddleIndicator mid = new BollingerBandsMiddleIndicator(sma);
        Num k = s.numFactory().numOf(mult);
        BollingerBandsUpperIndicator upper = new BollingerBandsUpperIndicator(mid, std, k);
        BollingerBandsLowerIndicator lower = new BollingerBandsLowerIndicator(mid, std, k);
        BollingerBandWidthIndicator bw = new BollingerBandWidthIndicator(upper, mid, lower);
        return single("plot0", s, bw);
    }

    // ── Oscillators ───────────────────────────────────────────────────────────

    /**
     * RSI + 선택적 신호선 (SMA / EMA / WMA).
     * maType == "None" 이면 plot1 을 null 로 채워 비활성 처리.
     */
    private Map<String, List<PlotPoint>> calcRSI(BarSeries s, Map<String, Object> p) {
        int    len    = intP(p, "length", 14);
        int    maLen  = intP(p, "maLength", 14);
        String maType = p.getOrDefault("maType", "SMA").toString();

        RSIIndicator rsi = new RSIIndicator(src(s, p), len);

        Indicator<Num> maInd = switch (maType) {
            case "EMA"  -> new EMAIndicator(rsi, maLen);
            case "WMA"  -> new WMAIndicator(rsi, maLen);
            case "None" -> null;
            default     -> new SMAIndicator(rsi, maLen);
        };

        if (maInd == null) {
            int n = s.getBarCount();
            List<PlotPoint> empty = new ArrayList<>(n);
            for (int i = 0; i < n; i++) {
                Bar bar = s.getBar(i);
                long t = bar.getEndTime().getEpochSecond() - bar.getTimePeriod().getSeconds();
                empty.add(PlotPoint.builder().time(t).value(null).build());
            }
            return Map.of("plot0", toPlot(s, rsi), "plot1", empty);
        }

        return Map.of("plot0", toPlot(s, rsi), "plot1", toPlot(s, maInd));
    }

    /**
     * CCI + 선택적 MA 오버레이 (업비트 동일: SMA / EMA / WMA).
     * maType == "None" 이면 MA 라인(plot1) 값을 null 로 채워 빈 시리즈로 반환.
     */
    private Map<String, List<PlotPoint>> calcCCI(BarSeries s, Map<String, Object> p) {
        int    len    = intP(p, "length", 13);
        int    maLen  = intP(p, "maLength", 20);
        String maType = p.getOrDefault("maType", "SMA").toString();

        CCIIndicator cci = new CCIIndicator(s, len);

        Indicator<Num> maInd = switch (maType) {
            case "EMA"  -> new EMAIndicator(cci, maLen);
            case "WMA"  -> new WMAIndicator(cci, maLen);
            case "None" -> null;
            default     -> new SMAIndicator(cci, maLen); // SMA
        };

        if (maInd == null) {
            // MA 비활성 — plot1 을 null 값으로 채워 반환
            int n = s.getBarCount();
            List<PlotPoint> empty = new ArrayList<>(n);
            for (int i = 0; i < n; i++) {
                Bar bar = s.getBar(i);
                long t = bar.getEndTime().getEpochSecond() - bar.getTimePeriod().getSeconds();
                empty.add(PlotPoint.builder().time(t).value(null).build());
            }
            return Map.of("plot0", toPlot(s, cci), "plot1", empty);
        }

        return Map.of("plot0", toPlot(s, cci), "plot1", toPlot(s, maInd));
    }

    private Map<String, List<PlotPoint>> calcStochastic(BarSeries s, Map<String, Object> p) {
        int kLen    = intP(p, "kLength", 14);
        int smooth  = intP(p, "smooth", 3);
        int dSmooth = intP(p, "dSmoothing", 3);
        StochasticOscillatorKIndicator k = new StochasticOscillatorKIndicator(s, kLen);
        SMAIndicator kSmooth = new SMAIndicator(k, smooth);
        SMAIndicator d = new SMAIndicator(kSmooth, dSmooth);
        return Map.of("plot0", toPlot(s, kSmooth), "plot1", toPlot(s, d));
    }

    private Map<String, List<PlotPoint>> calcStochRSI(BarSeries s, Map<String, Object> p) {
        int rsiLen   = intP(p, "lengthRSI", 14);
        int stochLen = intP(p, "lengthStoch", 14);
        int smoothK  = intP(p, "smoothK", 3);
        int smoothD  = intP(p, "smoothD", 3);
        RSIIndicator rsi = new RSIIndicator(src(s, p), rsiLen);
        StochasticRSIIndicator stochRsi = new StochasticRSIIndicator(rsi, stochLen);
        SMAIndicator k = new SMAIndicator(stochRsi, smoothK);
        SMAIndicator d = new SMAIndicator(k, smoothD);
        return Map.of("plot0", toPlot(s, k), "plot1", toPlot(s, d));
    }

    private Map<String, List<PlotPoint>> calcAO(BarSeries s) {
        // AO = SMA(median,5) - SMA(median,34)
        MedianPriceIndicator median = new MedianPriceIndicator(s);
        NumericIndicator ao = NumericIndicator.of(new SMAIndicator(median, 5))
            .minus(new SMAIndicator(median, 34));
        return single("plot0", s, ao);
    }

    // ── Momentum ──────────────────────────────────────────────────────────────

    private Map<String, List<PlotPoint>> calcMACD(BarSeries s, Map<String, Object> p) {
        int fast   = intP(p, "fastLength", 12);
        int slow   = intP(p, "slowLength", 26);
        int signal = intP(p, "signalLength", 9);
        Indicator<Num> src = src(s, p);
        MACDIndicator macd = new MACDIndicator(src, fast, slow);
        EMAIndicator sigLine = new EMAIndicator(macd, signal);
        NumericIndicator histogram = NumericIndicator.of(macd).minus(sigLine);
        return Map.of(
            "plot0", toPlot(s, histogram),
            "plot1", toPlot(s, macd),
            "plot2", toPlot(s, sigLine)
        );
    }

    private Map<String, List<PlotPoint>> calcMomentumInd(BarSeries s, Map<String, Object> p) {
        int len = intP(p, "length", 10);
        return single("plot0", s, new ROCIndicator(src(s, p), len));
    }

    private Map<String, List<PlotPoint>> calcROC(BarSeries s, Map<String, Object> p) {
        int len = intP(p, "length", 9);
        NumericIndicator rocPct = NumericIndicator.of(new ROCIndicator(src(s, p), len))
            .multipliedBy(100);
        return single("plot0", s, rocPct);
    }

    /**
     * TSI (True Strength Index) — 수동 구현 (Ta4j에 없음).
     * TSI = 100 * EMA(EMA(diff, longLen), shortLen) / EMA(EMA(absDiff, longLen), shortLen)
     */
    private Map<String, List<PlotPoint>> calcTSI(BarSeries s, Map<String, Object> p) {
        int longLen  = intP(p, "longLength", 25);
        int shortLen = intP(p, "shortLength", 13);
        int sigLen   = intP(p, "signalLength", 13);
        int n = s.getBarCount();

        ClosePriceIndicator close = new ClosePriceIndicator(s);

        // momentum = close - close[1]
        List<PlotPoint> tsiPts = new ArrayList<>(n);
        List<PlotPoint> sigPts = new ArrayList<>(n);

        double[] diff    = new double[n];
        double[] absDiff = new double[n];
        for (int i = 0; i < n; i++) {
            if (i == 0) { diff[i] = 0; absDiff[i] = 0; }
            else {
                Double c1 = safe(close.getValue(i));
                Double c0 = safe(close.getValue(i - 1));
                diff[i]    = (c1 != null && c0 != null) ? c1 - c0 : 0;
                absDiff[i] = Math.abs(diff[i]);
            }
        }

        double[] ema1d  = ema(diff,    longLen,  n);
        double[] ema2d  = ema(ema1d,   shortLen, n);
        double[] ema1a  = ema(absDiff, longLen,  n);
        double[] ema2a  = ema(ema1a,   shortLen, n);
        double[] tsi    = new double[n];
        for (int i = 0; i < n; i++) {
            tsi[i] = ema2a[i] == 0 ? 0 : 100.0 * ema2d[i] / ema2a[i];
        }
        double[] sig = ema(tsi, sigLen, n);

        for (int i = 0; i < n; i++) {
            Bar bar = s.getBar(i);
            long t = bar.getEndTime().getEpochSecond() - bar.getTimePeriod().getSeconds();
            tsiPts.add(new PlotPoint(t, tsi[i], null));
            sigPts.add(new PlotPoint(t, sig[i], null));
        }
        return Map.of("plot0", tsiPts, "plot1", sigPts);
    }

    // ── Trend ─────────────────────────────────────────────────────────────────

    private Map<String, List<PlotPoint>> calcADX(BarSeries s, Map<String, Object> p) {
        int adxSmoothing = intP(p, "adxSmoothing", 14);
        int diLen        = intP(p, "diLength", 14);
        return Map.of(
            "plot0", toPlot(s, new ADXIndicator(s, diLen, adxSmoothing)),
            "plot1", toPlot(s, new PlusDIIndicator(s, diLen)),
            "plot2", toPlot(s, new MinusDIIndicator(s, diLen))
        );
    }

    private Map<String, List<PlotPoint>> calcDMI(BarSeries s, Map<String, Object> p) {
        int diLen = intP(p, "diLength", intP(p, "length", 14));
        int adxSmooth = intP(p, "adxSmoothing", 14);
        PlusDIIndicator plusDI = new PlusDIIndicator(s, diLen);
        MinusDIIndicator minusDI = new MinusDIIndicator(s, diLen);
        ADXIndicator adx = new ADXIndicator(s, diLen, adxSmooth);
        NumericIndicator dx = NumericIndicator.of(plusDI).minus(minusDI).abs()
            .dividedBy(NumericIndicator.of(plusDI).plus(minusDI))
            .multipliedBy(100);
        List<PlotPoint> adxPlot = toPlot(s, adx);
        return Map.of(
            "plot0", toPlot(s, plusDI),
            "plot1", toPlot(s, minusDI),
            "plot2", toPlot(s, dx),
            "plot3", adxPlot,
            "plot4", calcAdxrFromAdx(adxPlot, adxSmooth)
        );
    }

    /** ADXR = (ADX + ADX[n]) / 2 — Wilder·업비트 */
    private List<PlotPoint> calcAdxrFromAdx(List<PlotPoint> adxPlot, int lag) {
        List<PlotPoint> out = new ArrayList<>(adxPlot.size());
        for (int i = 0; i < adxPlot.size(); i++) {
            PlotPoint cur = adxPlot.get(i);
            if (i < lag) {
                out.add(PlotPoint.builder().time(cur.getTime()).value(Double.NaN).build());
                continue;
            }
            Double a = cur.getValue();
            Double b = adxPlot.get(i - lag).getValue();
            Double v = (a != null && b != null && !a.isNaN() && !b.isNaN()) ? (a + b) / 2.0 : Double.NaN;
            out.add(PlotPoint.builder().time(cur.getTime()).value(v).build());
        }
        return out;
    }

    private Map<String, List<PlotPoint>> calcAroon(BarSeries s, Map<String, Object> p) {
        int len = intP(p, "length", 14);
        return Map.of(
            "plot0", toPlot(s, new AroonUpIndicator(s, len)),
            "plot1", toPlot(s, new AroonDownIndicator(s, len))
        );
    }

    private Map<String, List<PlotPoint>> calcIchimoku(BarSeries s, Map<String, Object> p) {
        int conv = intP(p, "conversionPeriods", 9);
        int base = intP(p, "basePeriods", 26);
        int span2 = intP(p, "laggingSpan2Periods", 52);
        int disp = intP(p, "displacement", 26);
        IchimokuTenkanSenIndicator tenkan = new IchimokuTenkanSenIndicator(s, conv);
        IchimokuKijunSenIndicator  kijun  = new IchimokuKijunSenIndicator(s, base);
        IchimokuSenkouSpanAIndicator spanA = new IchimokuSenkouSpanAIndicator(s, tenkan, kijun, disp);
        // 선행스팬B displacement 도 spanA·chikou 와 동일한 disp 를 적용해야 한다
        IchimokuSenkouSpanBIndicator spanB = new IchimokuSenkouSpanBIndicator(s, span2, disp);
        IchimokuChikouSpanIndicator chikou = new IchimokuChikouSpanIndicator(s, disp);
        return Map.of(
            "plot0", toPlot(s, tenkan),
            "plot1", toPlot(s, kijun),
            "plot2", toPlot(s, spanA),
            "plot3", toPlot(s, spanB),
            "plot4", toPlot(s, chikou)
        );
    }

    private Map<String, List<PlotPoint>> calcParabolicSAR(BarSeries s, Map<String, Object> p) {
        double start = dblP(p, "start", 0.02);
        double inc   = dblP(p, "increment", 0.02);
        double max   = dblP(p, "maximum", 0.2);
        return single("plot0", s, new ParabolicSarIndicator(s,
            s.numFactory().numOf(start), s.numFactory().numOf(max), s.numFactory().numOf(inc)));
    }

    private Map<String, List<PlotPoint>> calcChoppiness(BarSeries s, Map<String, Object> p) {
        int len = intP(p, "length", 14);
        // Ta4j ChopIndicator(BarSeries, ciTimeFrame, scaleTo=100)
        ChopIndicator chop = new ChopIndicator(s, len, 100);
        return single("plot0", s, chop);
    }

    private Map<String, List<PlotPoint>> calcMassIndex(BarSeries s, Map<String, Object> p) {
        int emaLen = intP(p, "length2", 9);    // inner EMA
        int total  = intP(p, "length", 25);    // total period
        return single("plot0", s, new MassIndexIndicator(s, emaLen, total));
    }

    // ── Volatility ────────────────────────────────────────────────────────────

    private Map<String, List<PlotPoint>> calcHV(BarSeries s, Map<String, Object> p) {
        int len       = intP(p, "length", 20);
        int annualBars = intP(p, "annualBars", 252);
        int n = s.getBarCount();
        ClosePriceIndicator close = new ClosePriceIndicator(s);
        List<PlotPoint> pts = new ArrayList<>(n);
        double[] logRet = new double[n];
        for (int i = 0; i < n; i++) {
            if (i == 0) { logRet[i] = 0; continue; }
            Double c0 = safe(close.getValue(i - 1));
            Double c1 = safe(close.getValue(i));
            logRet[i] = (c0 != null && c0 > 0 && c1 != null) ? Math.log(c1 / c0) : 0;
        }
        double factor = Math.sqrt(annualBars) * 100;
        for (int i = 0; i < n; i++) {
            Bar bar = s.getBar(i);
            long t = bar.getEndTime().getEpochSecond() - bar.getTimePeriod().getSeconds();
            if (i < len) { pts.add(new PlotPoint(t, null, null)); continue; }
            double mean = 0;
            for (int j = i - len + 1; j <= i; j++) mean += logRet[j];
            mean /= len;
            double var = 0;
            for (int j = i - len + 1; j <= i; j++) var += Math.pow(logRet[j] - mean, 2);
            double hv = Math.sqrt(var / (len - 1)) * factor;
            pts.add(new PlotPoint(t, hv, null));
        }
        return Map.of("plot0", pts);
    }

    // ── Williams %R / OBV / TRIX / Volume Osc / VR / 이격도 / 심리도 ─────────

    private Map<String, List<PlotPoint>> calcWilliamsR(BarSeries s, Map<String, Object> p) {
        int len = intP(p, "length", 14);
        // ta4j WilliamsR는 시고저 종가 기준 — 업비트와 동일
        return single("plot0", s, new WilliamsRIndicator(s, len));
    }

    /** 업비트 OBV: OBV + 신호선(SMA/EMA/WMA, 기본 SMA 9) */
    private Map<String, List<PlotPoint>> calcOBV(BarSeries s, Map<String, Object> p) {
        OnBalanceVolumeIndicator obv = new OnBalanceVolumeIndicator(s);
        String maType = p.getOrDefault("maType", "SMA").toString();
        int maLen = intP(p, "maLength", 9);
        Indicator<Num> maInd = switch (maType) {
            case "EMA" -> new EMAIndicator(obv, maLen);
            case "WMA" -> new WMAIndicator(obv, maLen);
            default    -> new SMAIndicator(obv, maLen);
        };
        return Map.of("plot0", toPlot(s, obv), "plot1", toPlot(s, maInd));
    }

    /** 업비트 TRIX: ln(종가) → 3×EMA → 1봉 차분 × 10000, 신호선 = TRIX EMA */
    private Map<String, List<PlotPoint>> calcTRIX(BarSeries s, Map<String, Object> p) {
        int len = intP(p, "length", 12);
        int sigLen = intP(p, "signalLength", 9);
        Indicator<Num> logClose = UnaryOperationIndicator.log(new ClosePriceIndicator(s));
        EMAIndicator e3 = new EMAIndicator(new EMAIndicator(new EMAIndicator(logClose, len), len), len);
        PreviousValueIndicator prev3 = new PreviousValueIndicator(e3);
        NumericIndicator trix = NumericIndicator.of(e3).minus(prev3).multipliedBy(10_000);
        EMAIndicator signal = new EMAIndicator(trix, sigLen);
        return Map.of("plot0", toPlot(s, trix), "plot1", toPlot(s, signal));
    }

    private Map<String, List<PlotPoint>> calcVolumeOscillator(BarSeries s, Map<String, Object> p) {
        int shortLen = intP(p, "shortLength", 5);
        int longLen  = intP(p, "longLength", 10);
        VolumeIndicator vol = new VolumeIndicator(s, 1);
        EMAIndicator shortEma = new EMAIndicator(vol, shortLen);
        EMAIndicator longEma  = new EMAIndicator(vol, longLen);
        NumericIndicator osc = NumericIndicator.of(shortEma).minus(longEma)
            .dividedBy(longEma).multipliedBy(100);
        return single("plot0", s, osc);
    }

    private Map<String, List<PlotPoint>> calcVR(BarSeries s, Map<String, Object> p) {
        int period = intP(p, "length", 10);
        int n = s.getBarCount();
        List<PlotPoint> pts = new ArrayList<>(n);
        ClosePriceIndicator close = new ClosePriceIndicator(s);
        VolumeIndicator vol = new VolumeIndicator(s, 1);
        for (int i = 0; i < n; i++) {
            Bar bar = s.getBar(i);
            long t = bar.getEndTime().getEpochSecond() - bar.getTimePeriod().getSeconds();
            if (i < period) {
                pts.add(PlotPoint.builder().time(t).value(null).build());
                continue;
            }
            double upVol = 0, downVol = 0;
            for (int j = i - period + 1; j <= i; j++) {
                Double c = safe(close.getValue(j));
                Double cPrev = safe(close.getValue(j - 1));
                Double v = safe(vol.getValue(j));
                if (c == null || cPrev == null || v == null) continue;
                if (c > cPrev) upVol += v;
                else if (c < cPrev) downVol += v;
            }
            Double val = downVol == 0 ? null : (upVol / downVol) * 100.0;
            pts.add(PlotPoint.builder().time(t).value(val).build());
        }
        return Map.of("plot0", pts);
    }

    /** 업비트 이격도: 종가 ÷ SMA × 100 */
    private Map<String, List<PlotPoint>> calcDisparity(BarSeries s, Map<String, Object> p) {
        Indicator<Num> source = src(s, p);
        Map<String, List<PlotPoint>> result = new LinkedHashMap<>();
        result.put("plot0", disparityLine(s, source, intP(p, "ultraLength", 5)));
        result.put("plot1", disparityLine(s, source, intP(p, "shortLength", 10)));
        result.put("plot2", disparityLine(s, source, intP(p, "midLength", 20)));
        result.put("plot3", disparityLine(s, source, intP(p, "longLength", 60)));
        return result;
    }

    private List<PlotPoint> disparityLine(BarSeries s, Indicator<Num> source, int period) {
        SMAIndicator sma = new SMAIndicator(source, period);
        int n = s.getBarCount();
        List<PlotPoint> pts = new ArrayList<>(n);
        for (int i = 0; i < n; i++) {
            Bar bar = s.getBar(i);
            long t = bar.getEndTime().getEpochSecond() - bar.getTimePeriod().getSeconds();
            Double c = safe(source.getValue(i));
            Double m = safe(sma.getValue(i));
            if (c == null || m == null || m == 0) {
                pts.add(PlotPoint.builder().time(t).value(null).build());
            } else {
                pts.add(PlotPoint.builder().time(t).value(c / m * 100.0).build());
            }
        }
        return pts;
    }

    private Map<String, List<PlotPoint>> calcPsychological(BarSeries s, Map<String, Object> p) {
        int period = intP(p, "length", 12);
        return Map.of("plot0", psychologicalPoints(s, period, false));
    }

    private Map<String, List<PlotPoint>> calcInvestPsychological(BarSeries s, Map<String, Object> p) {
        int period = intP(p, "length", 10);
        return Map.of("plot0", psychologicalPoints(s, period, true));
    }

    private List<PlotPoint> psychologicalPoints(BarSeries s, int period, boolean volumeWeighted) {
        int n = s.getBarCount();
        ClosePriceIndicator close = new ClosePriceIndicator(s);
        VolumeIndicator vol = new VolumeIndicator(s, 1);
        List<PlotPoint> pts = new ArrayList<>(n);
        for (int i = 0; i < n; i++) {
            Bar bar = s.getBar(i);
            long t = bar.getEndTime().getEpochSecond() - bar.getTimePeriod().getSeconds();
            if (i < period) {
                pts.add(PlotPoint.builder().time(t).value(null).build());
                continue;
            }
            double val;
            if (!volumeWeighted) {
                int up = 0;
                for (int j = i - period + 1; j <= i; j++) {
                    Double c = safe(close.getValue(j));
                    Double cPrev = safe(close.getValue(j - 1));
                    if (c != null && cPrev != null && c > cPrev) up++;
                }
                val = (double) up / period * 100.0;
            } else {
                double upVol = 0, total = 0;
                for (int j = i - period + 1; j <= i; j++) {
                    Double c = safe(close.getValue(j));
                    Double cPrev = safe(close.getValue(j - 1));
                    Double v = safe(vol.getValue(j));
                    if (v == null) continue;
                    total += v;
                    if (c != null && cPrev != null && c > cPrev) upVol += v;
                }
                val = total == 0 ? Double.NaN : upVol / total * 100.0;
            }
            pts.add(PlotPoint.builder().time(t).value(Double.isNaN(val) ? null : val).build());
        }
        return pts;
    }

    // ── EMA helper (double[], for TSI manual calc) ────────────────────────────

    private double[] ema(double[] src, int len, int n) {
        double[] out = new double[n];
        double k = 2.0 / (len + 1);
        out[0] = src[0];
        for (int i = 1; i < n; i++) {
            out[i] = src[i] * k + out[i - 1] * (1 - k);
        }
        return out;
    }
}
