/*
 * SPDX-License-Identifier: MIT
 */
package ta4jexamples.analysis.elliottwave;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;

import java.util.ArrayList;
import java.awt.GraphicsEnvironment;
import java.io.InputStream;
import java.time.Duration;
import java.util.Optional;
import java.time.Instant;
import java.util.List;

import org.jfree.chart.annotations.XYTextAnnotation;
import org.jfree.chart.plot.CombinedDomainXYPlot;
import org.jfree.chart.plot.XYPlot;
import org.jfree.chart.JFreeChart;
import org.junit.Assume;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.ta4j.core.indicators.elliott.ElliottFibonacciValidator;
import org.ta4j.core.indicators.elliott.ElliottSwingIndicator;
import org.ta4j.core.indicators.elliott.ElliottPhaseIndicator;
import org.ta4j.core.indicators.elliott.ElliottScenario;
import org.ta4j.core.indicators.elliott.ElliottDegree;
import org.ta4j.core.indicators.elliott.ElliottSwing;
import org.ta4j.core.indicators.elliott.ElliottPhase;
import org.ta4j.core.BaseBarSeriesBuilder;
import org.ta4j.core.BarSeries;

import ta4jexamples.charting.annotation.BarSeriesLabelIndicator.LabelPlacement;
import ta4jexamples.charting.annotation.BarSeriesLabelIndicator.BarLabel;
import ta4jexamples.charting.annotation.BarSeriesLabelIndicator;
import ta4jexamples.charting.display.SwingChartDisplayer;
import ta4jexamples.charting.workflow.ChartWorkflow;

import ta4jexamples.datasources.JsonFileBarSeriesDataSource;
import ta4jexamples.datasources.BarSeriesDataSource;

class ElliottWaveAnalysisTest {

    private static final String OSSIFIED_OHLCV_RESOURCE = "Coinbase-BTC-USD-PT1D-20230616_20231011.json";
    private static final double FIB_TOLERANCE = 0.25;

    @BeforeEach
    void setUp() {
        // Disable chart display to prevent windows from appearing in tests
        // This allows tests to create charts without failing in headless environments
        System.setProperty(SwingChartDisplayer.DISABLE_DISPLAY_PROPERTY, "true");
    }

    @AfterEach
    void tearDown() {
        // Clear the property after each test
        System.clearProperty(SwingChartDisplayer.DISABLE_DISPLAY_PROPERTY);
    }

    @Test
    @Tag("analysis-demo")
    void ossifiedDatasetProducesImpulseAndCorrection() {
        BarSeries series = loadOssifiedSeries();
        ElliottSwingIndicator swingIndicator = ElliottSwingIndicator.zigZag(series, ElliottDegree.PRIMARY);
        ElliottFibonacciValidator validator = new ElliottFibonacciValidator(series.numFactory(),
                series.numFactory().numOf(FIB_TOLERANCE));
        ElliottPhaseIndicator phaseIndicator = new ElliottPhaseIndicator(swingIndicator, validator);

        int endIndex = series.getEndIndex();
        assertEquals(ElliottPhase.CORRECTIVE_C, phaseIndicator.getValue(endIndex));
        assertTrue(phaseIndicator.isImpulseConfirmed(endIndex));
        assertTrue(phaseIndicator.isCorrectiveConfirmed(endIndex));

        assertEquals(5, phaseIndicator.impulseSwings(endIndex).size());
        assertEquals(3, phaseIndicator.correctiveSwings(endIndex).size());
    }

    @Test
    @Tag("analysis-demo")
    void rendersWaveLabelsOnChart() {
        Assume.assumeFalse("Headless environment", GraphicsEnvironment.isHeadless());
        BarSeries series = loadOssifiedSeries();
        ElliottSwingIndicator swingIndicator = ElliottSwingIndicator.zigZag(series, ElliottDegree.PRIMARY);
        ElliottFibonacciValidator validator = new ElliottFibonacciValidator(series.numFactory(),
                series.numFactory().numOf(FIB_TOLERANCE));
        ElliottPhaseIndicator phaseIndicator = new ElliottPhaseIndicator(swingIndicator, validator);

        BarSeriesLabelIndicator waveLabels = buildWaveLabels(series, phaseIndicator);

        ChartWorkflow workflow = new ChartWorkflow();
        JFreeChart chart = workflow.builder()
                .withSeries(series)
                .withIndicatorOverlay(waveLabels)
                .withLabel("Wave labels")
                .toChart();

        CombinedDomainXYPlot combinedPlot = (CombinedDomainXYPlot) chart.getPlot();
        XYPlot pricePlot = combinedPlot.getSubplots().get(0);

        List<String> annotationTexts = pricePlot.getAnnotations()
                .stream()
                .filter(XYTextAnnotation.class::isInstance)
                .map(annotation -> ((XYTextAnnotation) annotation).getText())
                .toList();

        assertTrue(annotationTexts.containsAll(List.of("1", "2", "3", "4", "5", "A", "B", "C")));
    }

    private static BarSeries loadOssifiedSeries() {
        try (InputStream stream = ElliottWaveAnalysisTest.class.getClassLoader()
                .getResourceAsStream(OSSIFIED_OHLCV_RESOURCE)) {
            assertNotNull(stream, "Missing resource: " + OSSIFIED_OHLCV_RESOURCE);

            BarSeries loaded = JsonFileBarSeriesDataSource.DEFAULT_INSTANCE.loadSeries(stream);
            assertNotNull(loaded, "Failed to deserialize BarSeries from resource");

            BarSeries series = new BaseBarSeriesBuilder().withName("BTC-USD_PT1D@Coinbase (ossified)").build();
            for (int i = 0; i < loaded.getBarCount(); i++) {
                series.addBar(loaded.getBar(i));
            }
            return series;
        } catch (Exception ex) {
            throw new AssertionError("Failed to load dataset", ex);
        }
    }

    private static BarSeries syntheticSeries(String name, int barCount) {
        BarSeries series = new BaseBarSeriesBuilder().withName(name).build();
        Duration period = Duration.ofDays(1);
        Instant start = Instant.parse("2023-01-01T00:00:00Z");
        for (int index = 0; index < barCount; index++) {
            series.barBuilder()
                    .timePeriod(period)
                    .endTime(start.plus(period.multipliedBy(index + 1L)))
                    .openPrice(100 + index)
                    .highPrice(103 + index)
                    .lowPrice(97 + index)
                    .closePrice(101 + index)
                    .volume(1_000 + index)
                    .add();
        }
        return series;
    }

    private static BarSeriesLabelIndicator buildWaveLabels(BarSeries series, ElliottPhaseIndicator phaseIndicator) {
        int endIndex = series.getEndIndex();
        List<ElliottSwing> impulse = phaseIndicator.impulseSwings(endIndex);
        List<ElliottSwing> correction = phaseIndicator.correctiveSwings(endIndex);

        List<BarLabel> labels = new ArrayList<>();
        if (!impulse.isEmpty()) {
            ElliottSwing first = impulse.get(0);
            labels.add(new BarLabel(first.fromIndex(), first.fromPrice(), "", placementForPivot(!first.isRising())));
        }

        for (int i = 0; i < impulse.size(); i++) {
            ElliottSwing swing = impulse.get(i);
            labels.add(new BarLabel(swing.toIndex(), swing.toPrice(), String.valueOf(i + 1),
                    placementForPivot(swing.isRising())));
        }

        for (int i = 0; i < correction.size(); i++) {
            ElliottSwing swing = correction.get(i);
            String label = switch (i) {
            case 0 -> "A";
            case 1 -> "B";
            default -> "C";
            };
            labels.add(new BarLabel(swing.toIndex(), swing.toPrice(), label, placementForPivot(swing.isRising())));
        }

        return new BarSeriesLabelIndicator(series, labels);
    }

    private static LabelPlacement placementForPivot(boolean isHighPivot) {
        return isHighPivot ? LabelPlacement.ABOVE : LabelPlacement.BELOW;
    }

    private static final class StubBarSeriesDataSource implements BarSeriesDataSource {
        private final boolean throwOnLoad;
        private final String sourceName;
        private final BarSeries series;

        private StubBarSeriesDataSource(String sourceName, BarSeries series, boolean throwOnLoad) {
            this.throwOnLoad = throwOnLoad;
            this.sourceName = sourceName;
            this.series = series;
        }

        @Override
        public BarSeries loadSeries(String ticker, Duration interval, Instant start, Instant end) {
            if (throwOnLoad) {
                throw new IllegalStateException("Stubbed load failure");
            }
            return series;
        }

        @Override
        public BarSeries loadSeries(String source) {
            return series;
        }

        @Override
        public String getSourceName() {
            return sourceName;
        }
    }

    @Test
    void loadSeriesFromDataSource_withUnsupportedDataSource_returnsNull() {
        String unsupportedDataSource = "UnsupportedSource";
        String ticker = "BTC-USD";
        Duration barDuration = Duration.ofDays(1);
        Instant startTime = Instant.parse("2023-01-01T00:00:00Z");
        Instant endTime = Instant.parse("2023-01-31T23:59:59Z");

        BarSeries result = ElliottWaveIndicatorSuiteDemo.loadSeriesFromDataSource(unsupportedDataSource, ticker,
                barDuration, startTime, endTime);

        assertNull(result, "Should return null for unsupported data source");
    }

    @Test
    void loadSeriesFromDataSource_withNullDataSource_returnsNull() {
        String ticker = "BTC-USD";
        Duration barDuration = Duration.ofDays(1);
        Instant startTime = Instant.parse("2023-01-01T00:00:00Z");
        Instant endTime = Instant.parse("2023-01-31T23:59:59Z");

        BarSeries result = ElliottWaveIndicatorSuiteDemo.loadSeriesFromDataSource((String) null, ticker, barDuration,
                startTime, endTime);

        assertNull(result, "Should return null for null data source");
    }

    @Test
    void loadSeriesFromDataSource_withStubSeries_returnsSeries() {
        BarSeries series = syntheticSeries("Stub", 8);
        BarSeriesDataSource source = new StubBarSeriesDataSource("Stub", series, false);

        BarSeries result = ElliottWaveIndicatorSuiteDemo.loadSeriesFromDataSource(source, "BTC-USD", Duration.ofDays(1),
                Instant.parse("2023-01-01T00:00:00Z"), Instant.parse("2023-01-31T23:59:59Z"));

        assertEquals(series, result, "Should return the stubbed series");
    }

    @Test
    void loadSeriesFromDataSource_withStubReturningNull_returnsNull() {
        BarSeriesDataSource source = new StubBarSeriesDataSource("Stub", null, false);

        BarSeries result = ElliottWaveIndicatorSuiteDemo.loadSeriesFromDataSource(source, "BTC-USD", Duration.ofDays(1),
                Instant.parse("2023-01-01T00:00:00Z"), Instant.parse("2023-01-31T23:59:59Z"));

        assertNull(result, "Should return null when the data source returns null");
    }

    @Test
    void loadSeriesFromDataSource_withStubReturningEmpty_returnsNull() {
        BarSeries emptySeries = new BaseBarSeriesBuilder().withName("Empty").build();
        BarSeriesDataSource source = new StubBarSeriesDataSource("Stub", emptySeries, false);

        BarSeries result = ElliottWaveIndicatorSuiteDemo.loadSeriesFromDataSource(source, "BTC-USD", Duration.ofDays(1),
                Instant.parse("2023-01-01T00:00:00Z"), Instant.parse("2023-01-31T23:59:59Z"));

        assertNull(result, "Should return null when the data source returns an empty series");
    }

    @Test
    void loadSeriesFromDataSource_withStubThrowingException_returnsNull() {
        BarSeriesDataSource source = new StubBarSeriesDataSource("Stub", null, true);

        BarSeries result = ElliottWaveIndicatorSuiteDemo.loadSeriesFromDataSource(source, "BTC-USD", Duration.ofDays(1),
                Instant.parse("2023-01-01T00:00:00Z"), Instant.parse("2023-01-31T23:59:59Z"));

        assertNull(result, "Should return null when the data source throws");
    }

    @Test
    void parseBarSeriesRequest_withDayBasedDuration_convertsToHours() {
        String[] args = { "Coinbase", "BTC-USD", "PT1D", "1686960000", "1697040000" };

        Optional<ElliottWaveIndicatorSuiteDemo.BarSeriesRequest> request = ElliottWaveIndicatorSuiteDemo
                .parseBarSeriesRequest(args);

        assertTrue(request.isPresent(), "Request should parse successfully");
        assertEquals(Duration.ofDays(1), request.get().barDuration(), "Duration should normalize to 24 hours");
    }

    @Test
    void parseBarSeriesRequest_withInvalidEpoch_returnsEmpty() {
        String[] args = { "Coinbase", "BTC-USD", "PT1D", "invalid-epoch" };

        Optional<ElliottWaveIndicatorSuiteDemo.BarSeriesRequest> request = ElliottWaveIndicatorSuiteDemo
                .parseBarSeriesRequest(args);

        assertTrue(request.isEmpty(), "Invalid epoch should return empty request");
    }

    @Test
    void parseBarSeriesRequest_withInvalidDataSource_returnsEmpty() {
        String[] args = { "InvalidSource", "BTC-USD", "PT1D", "1686960000", "1697040000" };

        Optional<ElliottWaveIndicatorSuiteDemo.BarSeriesRequest> request = ElliottWaveIndicatorSuiteDemo
                .parseBarSeriesRequest(args);

        assertTrue(request.isEmpty(), "Invalid data source should return empty request");
    }

    @Test
    void parseBarSeriesRequest_withInvalidTimeRange_returnsEmpty() {
        String[] args = { "Coinbase", "BTC-USD", "PT1D", "1697040000", "1686960000" };

        Optional<ElliottWaveIndicatorSuiteDemo.BarSeriesRequest> request = ElliottWaveIndicatorSuiteDemo
                .parseBarSeriesRequest(args);

        assertTrue(request.isEmpty(), "Invalid time range should return empty request");
    }

    @Test
    void resolveDegree_withExplicitDegree_usesProvidedDegree() {
        BarSeries series = syntheticSeries("Degree", 90);
        String[] args = { "Coinbase", "BTC-USD", "PT1D", "PRIMARY", "1686960000", "1697040000" };

        ElliottDegree resolved = ElliottWaveIndicatorSuiteDemo.resolveDegree(args, series);

        assertEquals(ElliottDegree.PRIMARY, resolved, "Explicit degree should be used");
    }

    @Test
    void resolveDegree_withCaseInsensitiveDegree_usesProvidedDegree() {
        BarSeries series = syntheticSeries("Degree", 90);
        String[] args = { "Coinbase", "BTC-USD", "PT1D", "primary", "1686960000", "1697040000" };

        ElliottDegree resolved = ElliottWaveIndicatorSuiteDemo.resolveDegree(args, series);

        assertEquals(ElliottDegree.PRIMARY, resolved, "Degree should be parsed case-insensitively");
    }

    @Test
    void resolveDegree_withoutExplicitDegree_usesRecommendation() {
        BarSeries series = syntheticSeries("Degree", 90);
        String[] args = { "Coinbase", "BTC-USD", "PT1D", "1686960000", "1697040000" };

        List<ElliottDegree> recommendations = ElliottDegree.getRecommendedDegrees(series.getFirstBar().getTimePeriod(),
                series.getBarCount());
        ElliottDegree resolved = ElliottWaveIndicatorSuiteDemo.resolveDegree(args, series);

        if (recommendations.isEmpty()) {
            assertEquals(ElliottDegree.PRIMARY, resolved, "Default degree should be used when none recommended");
        } else {
            assertEquals(recommendations.get(0), resolved, "Recommended degree should be used");
        }
    }

    @Test
    @Tag("analysis-demo")
    void analyze_withValidSeries_completesSuccessfully() {
        // Test that analyze() completes successfully and generates wave labels
        // This tests buildWaveLabelsFromScenario and placementForPivot indirectly
        BarSeries series = loadOssifiedSeries();
        ElliottWaveIndicatorSuiteDemo analysis = new ElliottWaveIndicatorSuiteDemo();
        // Should not throw exception
        analysis.analyze(series, ElliottDegree.PRIMARY, FIB_TOLERANCE);
        // The analyze method internally calls buildWaveLabelsFromScenario
        // We can verify it worked by checking that charts were generated
        // (though we can't directly access the labels, we know they were created)
    }

    @Test
    @Tag("analysis-demo")
    void analyze_withDifferentScenarioTypes_generatesAppropriateLabels() {
        // Test that analyze() handles different scenario types
        // This indirectly tests buildWaveLabelsFromScenario with different types
        BarSeries series = loadOssifiedSeries();
        ElliottWaveIndicatorSuiteDemo analysis = new ElliottWaveIndicatorSuiteDemo();

        // Test with different degrees which may produce different scenario types
        analysis.analyze(series, ElliottDegree.PRIMARY, FIB_TOLERANCE);
        analysis.analyze(series, ElliottDegree.INTERMEDIATE, FIB_TOLERANCE);
        analysis.analyze(series, ElliottDegree.MINOR, FIB_TOLERANCE);

        // Should complete without exception, indicating label generation worked
    }

    @Test
    @Tag("analysis-demo")
    void analyze_withDifferentDegrees_completesSuccessfully() {
        BarSeries series = loadOssifiedSeries();
        ElliottWaveIndicatorSuiteDemo analysis = new ElliottWaveIndicatorSuiteDemo();
        // Test with different degrees
        analysis.analyze(series, ElliottDegree.INTERMEDIATE, FIB_TOLERANCE);
        analysis.analyze(series, ElliottDegree.MINOR, FIB_TOLERANCE);
    }

    @Test
    @Tag("analysis-demo")
    void analyze_withDifferentFibTolerances_completesSuccessfully() {
        BarSeries series = loadOssifiedSeries();
        ElliottWaveIndicatorSuiteDemo analysis = new ElliottWaveIndicatorSuiteDemo();
        // Test with different tolerances
        analysis.analyze(series, ElliottDegree.PRIMARY, 0.1);
        analysis.analyze(series, ElliottDegree.PRIMARY, 0.5);
    }

    @Test
    @Tag("analysis-demo")
    void analyze_returnsAnalysisResult() {
        BarSeries series = loadOssifiedSeries();
        ElliottWaveIndicatorSuiteDemo analysis = new ElliottWaveIndicatorSuiteDemo();
        ElliottWaveIndicatorSuiteDemo.AnalysisResult result = analysis.analyze(series, ElliottDegree.PRIMARY,
                FIB_TOLERANCE);

        assertNotNull(result, "Analysis result should not be null");
        assertEquals(series, result.series(), "Series should match");
        assertEquals(ElliottDegree.PRIMARY, result.degree(), "Degree should match");
        assertNotNull(result.phaseIndicator(), "Phase indicator should not be null");
        assertNotNull(result.invalidationIndicator(), "Invalidation indicator should not be null");
        assertNotNull(result.channelIndicator(), "Channel indicator should not be null");
        assertNotNull(result.ratioIndicator(), "Ratio indicator should not be null");
        assertNotNull(result.confluenceIndicator(), "Confluence indicator should not be null");
        assertNotNull(result.swingCount(), "Swing count indicator should not be null");
        assertNotNull(result.filteredSwingCount(), "Filtered swing count indicator should not be null");
        assertNotNull(result.scenarioIndicator(), "Scenario indicator should not be null");
        assertNotNull(result.swingMetadata(), "Swing metadata should not be null");
        assertNotNull(result.scenarioSet(), "Scenario set should not be null");
        assertNotNull(result.ratioValue(), "Ratio value indicator should not be null");
        assertNotNull(result.swingCountAsNum(), "Swing count as num indicator should not be null");
        assertNotNull(result.filteredSwingCountAsNum(), "Filtered swing count as num indicator should not be null");
        assertNotNull(result.baseCaseChartPlan(), "Base case chart plan optional should not be null");
        assertNotNull(result.alternativeChartPlans(), "Alternative chart plans list should not be null");
        assertNotNull(result.structuredResult(), "Structured result should not be null");
        assertNotNull(result.structuredResult().swingSnapshot(), "Structured result swing snapshot should not be null");
        assertNotNull(result.structuredResult().latestAnalysis(),
                "Structured result latest analysis should not be null");
        assertNotNull(result.structuredResult().scenarioSummary(),
                "Structured result scenario summary should not be null");
    }

    @Test
    @Tag("analysis-demo")
    void analyze_withBaseCaseScenario_createsBaseCaseChartPlan() {
        BarSeries series = loadOssifiedSeries();
        ElliottWaveIndicatorSuiteDemo analysis = new ElliottWaveIndicatorSuiteDemo();
        ElliottWaveIndicatorSuiteDemo.AnalysisResult result = analysis.analyze(series, ElliottDegree.PRIMARY,
                FIB_TOLERANCE);

        assertTrue(result.baseCaseChartPlan().isPresent(),
                "Base case chart plan should be present when base case scenario exists");
        assertNotNull(result.baseCaseChartPlan().get(), "Base case chart plan should not be null");
        assertNotNull(result.scenarioSet().base(), "Base case scenario should exist");
    }

    @Test
    @Tag("analysis-demo")
    void analyze_createsChartPlansForAllScenarios() {
        BarSeries series = loadOssifiedSeries();
        ElliottWaveIndicatorSuiteDemo analysis = new ElliottWaveIndicatorSuiteDemo();
        ElliottWaveIndicatorSuiteDemo.AnalysisResult result = analysis.analyze(series, ElliottDegree.PRIMARY,
                FIB_TOLERANCE);

        // Verify base case chart plan exists if base case scenario exists
        if (result.scenarioSet().base().isPresent()) {
            assertTrue(result.baseCaseChartPlan().isPresent(),
                    "Base case chart plan should exist when base case scenario exists");
        }

        // Verify alternative chart plans match alternative scenarios
        int alternativeCount = result.scenarioSet().alternatives().size();
        assertEquals(alternativeCount, result.alternativeChartPlans().size(),
                "Number of alternative chart plans should match number of alternative scenarios");
    }

    @Test
    @Tag("analysis-demo")
    void visualizeAnalysisResult_withValidResult_completesSuccessfully() {
        BarSeries series = loadOssifiedSeries();
        ElliottWaveIndicatorSuiteDemo analysis = new ElliottWaveIndicatorSuiteDemo();
        ElliottWaveIndicatorSuiteDemo.AnalysisResult result = analysis.analyze(series, ElliottDegree.PRIMARY,
                FIB_TOLERANCE);

        // Should not throw exception
        analysis.visualizeAnalysisResult(result);
    }

    @Test
    void visualizeAnalysisResult_withNullResult_throwsNullPointerException() {
        ElliottWaveIndicatorSuiteDemo analysis = new ElliottWaveIndicatorSuiteDemo();
        NullPointerException exception = assertThrows(NullPointerException.class,
                () -> analysis.visualizeAnalysisResult(null));
        assertNotNull(exception.getMessage(), "Exception message should not be null");
        assertTrue(exception.getMessage().contains("Analysis result cannot be null"),
                "Exception message should indicate null result");
    }

    @Test
    @Tag("analysis-demo")
    void visualizeAnalysisResult_withDifferentDegrees_formatsWindowTitlesCorrectly() {
        BarSeries series = loadOssifiedSeries();
        ElliottWaveIndicatorSuiteDemo analysis = new ElliottWaveIndicatorSuiteDemo();

        // Test with PRIMARY degree
        ElliottWaveIndicatorSuiteDemo.AnalysisResult primaryResult = analysis.analyze(series, ElliottDegree.PRIMARY,
                FIB_TOLERANCE);
        if (primaryResult.baseCaseChartPlan().isPresent() && primaryResult.scenarioSet().base().isPresent()) {
            ElliottScenario baseCase = primaryResult.scenarioSet().base().get();
            String expectedBaseCaseTitle = String.format("%s - BASE CASE: %s (%s) - %.1f%% - %s", ElliottDegree.PRIMARY,
                    baseCase.currentPhase(), baseCase.type(), baseCase.confidence().asPercentage(), series.getName());
            assertTrue(expectedBaseCaseTitle.startsWith("PRIMARY -"),
                    "Base case window title should start with degree");
            assertTrue(expectedBaseCaseTitle.contains("BASE CASE:"),
                    "Base case window title should contain BASE CASE:");
        }

        // Test with INTERMEDIATE degree
        ElliottWaveIndicatorSuiteDemo.AnalysisResult intermediateResult = analysis.analyze(series,
                ElliottDegree.INTERMEDIATE, FIB_TOLERANCE);
        if (intermediateResult.baseCaseChartPlan().isPresent() && intermediateResult.scenarioSet().base().isPresent()) {
            ElliottScenario baseCase = intermediateResult.scenarioSet().base().get();
            String expectedIntermediateTitle = String.format("%s - BASE CASE: %s (%s) - %.1f%% - %s",
                    ElliottDegree.INTERMEDIATE, baseCase.currentPhase(), baseCase.type(),
                    baseCase.confidence().asPercentage(), series.getName());
            assertTrue(expectedIntermediateTitle.startsWith("INTERMEDIATE -"),
                    "Intermediate window title should start with degree");
        }

        // Test with MINOR degree
        ElliottWaveIndicatorSuiteDemo.AnalysisResult minorResult = analysis.analyze(series, ElliottDegree.MINOR,
                FIB_TOLERANCE);
        if (minorResult.baseCaseChartPlan().isPresent() && minorResult.scenarioSet().base().isPresent()) {
            ElliottScenario baseCase = minorResult.scenarioSet().base().get();
            String expectedMinorTitle = String.format("%s - BASE CASE: %s (%s) - %.1f%% - %s", ElliottDegree.MINOR,
                    baseCase.currentPhase(), baseCase.type(), baseCase.confidence().asPercentage(), series.getName());
            assertTrue(expectedMinorTitle.startsWith("MINOR -"), "Minor window title should start with degree");
        }
    }

    @Test
    @Tag("analysis-demo")
    void visualizeAnalysisResult_withAlternativeScenarios_formatsWindowTitlesCorrectly() {
        BarSeries series = loadOssifiedSeries();
        ElliottWaveIndicatorSuiteDemo analysis = new ElliottWaveIndicatorSuiteDemo();
        ElliottWaveIndicatorSuiteDemo.AnalysisResult result = analysis.analyze(series, ElliottDegree.PRIMARY,
                FIB_TOLERANCE);

        List<ElliottScenario> alternatives = result.scenarioSet().alternatives();
        for (int i = 0; i < alternatives.size() && i < result.alternativeChartPlans().size(); i++) {
            ElliottScenario alt = alternatives.get(i);
            String expectedAltTitle = String.format("%s - ALTERNATIVE %d: %s (%s) - %.1f%% - %s", result.degree(),
                    i + 1, alt.currentPhase(), alt.type(), alt.confidence().asPercentage(), series.getName());
            assertTrue(expectedAltTitle.startsWith(result.degree().toString() + " -"),
                    "Alternative window title should start with degree");
            assertTrue(expectedAltTitle.contains("ALTERNATIVE " + (i + 1) + ":"),
                    "Alternative window title should contain alternative number");
        }
    }

    @Test
    void analyze_withEmptySeries_throwsIllegalArgumentException() {
        BarSeries emptySeries = new BaseBarSeriesBuilder().withName("Empty").build();
        ElliottWaveIndicatorSuiteDemo analysis = new ElliottWaveIndicatorSuiteDemo();

        IllegalArgumentException exception = assertThrows(IllegalArgumentException.class,
                () -> analysis.analyze(emptySeries, ElliottDegree.PRIMARY, FIB_TOLERANCE));
        assertNotNull(exception.getMessage(), "Exception message should not be null");
        assertTrue(exception.getMessage().contains("Series cannot be empty"),
                "Exception message should indicate empty series");
    }

    @Test
    void analyze_withNullSeries_throwsNullPointerException() {
        ElliottWaveIndicatorSuiteDemo analysis = new ElliottWaveIndicatorSuiteDemo();

        NullPointerException exception = assertThrows(NullPointerException.class,
                () -> analysis.analyze(null, ElliottDegree.PRIMARY, FIB_TOLERANCE));
        assertNotNull(exception.getMessage(), "Exception message should not be null");
        assertTrue(exception.getMessage().contains("Series cannot be null"),
                "Exception message should indicate null series");
    }

    @Test
    @Tag("analysis-demo")
    void analyze_separatesAnalysisFromVisualization() {
        BarSeries series = loadOssifiedSeries();
        ElliottWaveIndicatorSuiteDemo analysis = new ElliottWaveIndicatorSuiteDemo();

        // Analyze without visualization
        ElliottWaveIndicatorSuiteDemo.AnalysisResult result = analysis.analyze(series, ElliottDegree.PRIMARY,
                FIB_TOLERANCE);

        // Verify analysis completed
        assertNotNull(result);
        assertNotNull(result.scenarioSet());

        // Now visualize separately
        analysis.visualizeAnalysisResult(result);

        // Both should complete without exception
    }

}
