package io.github.jjsimas.chart2d;

import java.awt.BasicStroke;
import java.awt.Color;
import java.awt.Dimension;
import java.awt.Font;
import java.awt.FontMetrics;
import java.awt.Graphics2D;
import java.awt.Insets;
import java.awt.Rectangle;
import java.awt.Shape;
import java.awt.Stroke;
import java.awt.geom.AffineTransform;
import java.awt.geom.Ellipse2D;
import java.awt.geom.Path2D;
import java.awt.geom.Rectangle2D;
import java.awt.image.BufferedImage;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Objects;
import java.util.stream.Collectors;

public final class CartesianChart extends ResponsiveChart {

  private CartesianChartModel model;
  private CartesianLayoutSnapshot lastSnapshot;

  public CartesianChart(CartesianChartModel model) {
    setModel(model);
  }

  public CartesianChartModel getModel() {
    return model;
  }

  public void setModel(CartesianChartModel model) {
    this.model = Objects.requireNonNull(model, "model");
    revalidate();
    repaint();
  }

  public CartesianLayoutSnapshot getLastSnapshot() {
    return lastSnapshot;
  }

  public CartesianLayoutSnapshot layoutSnapshot(Dimension size) {
    Graphics2D g2 = createMeasurementGraphics();
    try {
      return computeLayout(g2, Math.max(1, size.width), Math.max(1, size.height)).snapshot;
    }
    finally {
      g2.dispose();
    }
  }

  @Override
  public Dimension getPreferredSize() {
    Graphics2D g2 = createMeasurementGraphics();
    try {
      Measure measure = measure(g2, FeatureStage.FULL);
      return measure.preferredSize;
    }
    finally {
      g2.dispose();
    }
  }

  @Override
  protected void paintChart(Graphics2D g2, int width, int height) {
    LayoutResult result = computeLayout(g2, Math.max(1, width), Math.max(1, height));
    lastSnapshot = result.snapshot;

    g2.setColor(model.theme().background());
    g2.fillRect(0, 0, width, height);

    if (!result.titleBounds.isEmpty() && result.features.titleVisible) {
      paintCenteredText(g2, model.title(), result.titleBounds, result.titleFont, model.theme().titleColor());
    }

    paintPlotBackground(g2, result);
    if (result.features.gridVisible) {
      paintGrid(g2, result);
    }
    paintData(g2, result);
    if (result.features.axisVisible) {
      paintAxes(g2, result);
    }
    if (!result.legendBounds.isEmpty() && result.features.legendVisible) {
      paintLegend(g2, result);
    }
  }

  private LayoutResult computeLayout(Graphics2D g2, int width, int height) {
    LayoutResult lastResult = null;
    for (FeatureStage stage : FeatureStage.values()) {
      Measure measure = measure(g2, stage);
      float scale = Math.min(
        width / (float) Math.max(1, measure.preferredSize.width),
        height / (float) Math.max(1, measure.preferredSize.height)
      );
      scale = Math.max(scale, 0.01f);
      LayoutResult result = resolveLayout(g2, width, height, scale, measure, stage);
      lastResult = result;
      if (isAcceptable(result)) {
        return result;
      }
    }
    return lastResult;
  }

  private boolean isAcceptable(LayoutResult result) {
    Rectangle plot = result.plotBounds;
    if (plot.width < 32 || plot.height < 32) {
      return result.stage == FeatureStage.PLOT_ONLY;
    }
    ChartTheme theme = model.theme();
    if (result.features.titleVisible && result.titleFont.getSize() < theme.minTitleFontSize()) {
      return false;
    }
    if (result.features.legendVisible && result.legendFont.getSize() < theme.minLegendFontSize()) {
      return false;
    }
    if (result.features.categoryLabelsVisible && result.labelFont.getSize() < theme.minLabelFontSize()) {
      return false;
    }
    if (result.features.valueLabelsVisible && result.labelFont.getSize() < theme.minLabelFontSize()) {
      return false;
    }
    return true;
  }

  private Measure measure(Graphics2D g2, FeatureStage stage) {
    ChartTheme theme = model.theme();
    FeatureSet features = FeatureSet.forStage(model.options(), stage);
    DataRange range = computeRange();

    Font titleFont = theme.titleFont();
    Font labelFont = theme.labelFont();
    Font axisTitleFont = theme.axisTitleFont();
    Font legendFont = theme.legendFont();

    FontMetrics titleMetrics = g2.getFontMetrics(titleFont);
    FontMetrics labelMetrics = g2.getFontMetrics(labelFont);
    FontMetrics axisTitleMetrics = g2.getFontMetrics(axisTitleFont);
    FontMetrics legendMetrics = g2.getFontMetrics(legendFont);

    int categoryCount = getCategoryCount();
    int barSeriesCount = (int) model.series().stream().filter(series -> series.renderer() == SeriesRenderer.BAR).count();
    int valueTickHint = model.options().valueTickCountHint();
    NiceScale.TickSet tickSet = NiceScale.ticks(range.min, range.max, valueTickHint);
    List<String> tickLabels = tickSet.values()
      .stream()
      .map(value -> NiceScale.format(value, tickSet.step()))
      .toList();

    int maxValueLabelWidth = tickLabels.stream()
      .mapToInt(labelMetrics::stringWidth)
      .max()
      .orElse(0);
    int maxCategoryLabelWidth = getCategoryLabels()
      .stream()
      .mapToInt(labelMetrics::stringWidth)
      .max()
      .orElse(0);
    int categoryLabelHeight = labelMetrics.getHeight();
    int tickLength = theme.axisTickLengthModel();
    int gap = theme.elementGapModel();
    int panelGap = theme.panelGapModel();

    int plotWidth = theme.preferredPlotSize().width;
    int plotHeight = theme.preferredPlotSize().height;
    int categorySpan = Math.max(12, model.options().preferredCategorySpanModel());
    int categoryGaps = Math.max(0, categoryCount - 1) * theme.categoryGapModel();
    int barFactor = model.options().stackedBars() ? 1 : Math.max(1, barSeriesCount);
    int desiredSpan = categoryCount * Math.max(10, categorySpan + ((barFactor - 1) * 6)) + categoryGaps;
    if (model.options().orientation() == CartesianOrientation.VERTICAL) {
      plotWidth = Math.max(plotWidth, desiredSpan);
    }
    else {
      plotHeight = Math.max(plotHeight, desiredSpan);
    }

    int valueAxisThickness;
    int categoryAxisThickness;
    if (model.options().orientation() == CartesianOrientation.VERTICAL) {
      valueAxisThickness = features.axisVisible
        ? tickLength
        + (features.valueLabelsVisible ? maxValueLabelWidth + gap : 0)
        + (features.axisTitlesVisible && !model.valueAxisTitle().isBlank() ? axisTitleMetrics.getHeight() + gap : 0)
        : 0;
      categoryAxisThickness = features.axisVisible
        ? tickLength
        + (features.categoryLabelsVisible ? categoryLabelHeight + gap : 0)
        + (features.axisTitlesVisible && !model.categoryAxisTitle().isBlank() ? axisTitleMetrics.getHeight() + gap : 0)
        : 0;
    }
    else {
      valueAxisThickness = features.axisVisible
        ? tickLength
        + (features.valueLabelsVisible ? categoryLabelHeight + gap : 0)
        + (features.axisTitlesVisible && !model.valueAxisTitle().isBlank() ? axisTitleMetrics.getHeight() + gap : 0)
        : 0;
      categoryAxisThickness = features.axisVisible
        ? tickLength
        + (features.categoryLabelsVisible ? maxCategoryLabelWidth + gap : 0)
        + (features.axisTitlesVisible && !model.categoryAxisTitle().isBlank() ? axisTitleMetrics.getHeight() + gap : 0)
        : 0;
    }

    Dimension legendSize = features.legendVisible
      ? measureLegend(legendMetrics, theme.legendSwatchSizeModel(), gap)
      : new Dimension();

    int titleHeight = features.titleVisible && !model.title().isBlank()
      ? titleMetrics.getHeight()
      : 0;

    Insets padding = theme.padding();
    int totalWidth = padding.left + padding.right;
    int totalHeight = padding.top + padding.bottom;

    if (features.titleVisible && titleHeight > 0) {
      totalHeight += titleHeight;
    }

    if (features.titleVisible && titleHeight > 0 && hasMainContent(features, legendSize)) {
      totalHeight += panelGap;
    }

    if (model.options().orientation() == CartesianOrientation.VERTICAL) {
      int mainWidth = valueAxisThickness + plotWidth;
      int mainHeight = plotHeight + categoryAxisThickness;
      if (features.legendVisible && legendSize.width > 0) {
        if (model.options().legendPosition() == LegendPosition.BOTTOM || model.options().legendPosition() == LegendPosition.TOP) {
          totalWidth += Math.max(mainWidth, legendSize.width);
          totalHeight += mainHeight + legendSize.height + panelGap;
        }
        else {
          totalWidth += mainWidth + legendSize.width + panelGap;
          totalHeight += Math.max(mainHeight, legendSize.height);
        }
      }
      else {
        totalWidth += mainWidth;
        totalHeight += mainHeight;
      }
    }
    else {
      int mainWidth = plotWidth + categoryAxisThickness;
      int mainHeight = valueAxisThickness + plotHeight;
      if (features.legendVisible && legendSize.width > 0) {
        if (model.options().legendPosition() == LegendPosition.BOTTOM || model.options().legendPosition() == LegendPosition.TOP) {
          totalWidth += Math.max(mainWidth, legendSize.width);
          totalHeight += mainHeight + legendSize.height + panelGap;
        }
        else {
          totalWidth += mainWidth + legendSize.width + panelGap;
          totalHeight += Math.max(mainHeight, legendSize.height);
        }
      }
      else {
        totalWidth += mainWidth;
        totalHeight += mainHeight;
      }
    }

    return new Measure(
      new Dimension(Math.max(totalWidth, 1), Math.max(totalHeight, 1)),
      new Dimension(valueAxisThickness, categoryAxisThickness),
      new Dimension(plotWidth, plotHeight),
      legendSize,
      titleHeight,
      tickSet,
      tickLabels,
      maxCategoryLabelWidth,
      categoryLabelHeight,
      range
    );
  }

  private boolean hasMainContent(FeatureSet features, Dimension legendSize) {
    return features.axisVisible || features.legendVisible || legendSize.width > 0;
  }

  private Dimension measureLegend(FontMetrics metrics, int swatchSize, int gap) {
    int rows = model.series().size();
    int rowHeight = Math.max(metrics.getHeight(), swatchSize);
    int width = model.series().stream()
      .map(CartesianSeries::name)
      .mapToInt(metrics::stringWidth)
      .max()
      .orElse(0);
    return new Dimension(
      swatchSize + gap + width + (gap * 2),
      (rows * rowHeight) + Math.max(0, rows - 1) * gap + (gap * 2)
    );
  }

  private LayoutResult resolveLayout(
    Graphics2D g2,
    int width,
    int height,
    float scale,
    Measure measure,
    FeatureStage stage
  ) {
    ChartTheme theme = model.theme();
    FeatureSet features = FeatureSet.forStage(model.options(), stage);
    Insets padding = scaleInsets(theme.padding(), scale);
    int panelGap = scaled(theme.panelGapModel(), scale);
    int titleHeight = scaled(measure.titleHeight, scale);
    int titleBandHeight = titleHeight > 0 ? titleHeight + panelGap : 0;

    Font titleFont = theme.scaledTitleFont(scale);
    Font labelFont = theme.scaledLabelFont(scale);
    Font axisTitleFont = theme.scaledAxisTitleFont(scale);
    Font legendFont = theme.scaledLegendFont(scale);

    int valueAxisThickness = scaled(measure.axisThickness.width, scale);
    int categoryAxisThickness = scaled(measure.axisThickness.height, scale);
    int plotWidth = scaled(measure.plotSize.width, scale);
    int plotHeight = scaled(measure.plotSize.height, scale);
    int legendWidth = scaled(measure.legendSize.width, scale) + Math.max(4, panelGap / 2);
    int legendHeight = scaled(measure.legendSize.height, scale);
    Rectangle content = new Rectangle(
      padding.left,
      padding.top,
      Math.max(1, width - padding.left - padding.right),
      Math.max(1, height - padding.top - padding.bottom)
    );

    Rectangle titleBounds = new Rectangle();
    int mainY = content.y;
    int mainHeight = content.height;
    if (features.titleVisible && titleBandHeight > 0 && !model.title().isBlank()) {
      titleBounds.setBounds(content.x, content.y, content.width, titleBandHeight);
      int consumed = Math.min(content.height, titleBandHeight + panelGap);
      mainY += consumed;
      mainHeight -= consumed;
    }
    mainHeight = Math.max(1, mainHeight);

    Rectangle legendBounds = new Rectangle();
    Rectangle plotBounds = new Rectangle();
    Rectangle categoryAxisBounds = new Rectangle();
    Rectangle valueAxisBounds = new Rectangle();

    if (model.options().orientation() == CartesianOrientation.VERTICAL) {
      int left = content.x;
      int top = mainY;
      int availableWidth = content.width;
      int availableHeightForMain = mainHeight;

      if (features.legendVisible && legendWidth > 0) {
        if (model.options().legendPosition() == LegendPosition.TOP || model.options().legendPosition() == LegendPosition.BOTTOM) {
          int legendActualHeight = Math.min(legendHeight, Math.max(0, availableHeightForMain / 3));
          legendActualHeight = Math.max(Math.min(legendHeight, availableHeightForMain), Math.min(legendActualHeight, availableHeightForMain));
          if (model.options().legendPosition() == LegendPosition.TOP) {
            legendBounds.setBounds(left + Math.max(0, (availableWidth - legendWidth) / 2), top, Math.min(legendWidth, availableWidth), legendActualHeight);
            top += legendActualHeight + panelGap;
          }
          availableHeightForMain = Math.max(1, content.y + content.height - top - (model.options().legendPosition() == LegendPosition.BOTTOM ? legendActualHeight + panelGap : 0));
          if (model.options().legendPosition() == LegendPosition.BOTTOM) {
            legendBounds.setBounds(left + Math.max(0, (availableWidth - legendWidth) / 2), content.y + content.height - legendActualHeight, Math.min(legendWidth, availableWidth), legendActualHeight);
          }
        }
        else {
          int legendActualWidth = Math.min(legendWidth, Math.max(0, availableWidth / 3));
          legendActualWidth = Math.max(Math.min(legendWidth, availableWidth), Math.min(legendActualWidth, availableWidth));
          if (model.options().legendPosition() == LegendPosition.LEFT) {
            legendBounds.setBounds(left, top, legendActualWidth, Math.min(legendHeight, availableHeightForMain));
            left += legendActualWidth + panelGap;
          }
          availableWidth = Math.max(1, content.x + content.width - left - (model.options().legendPosition() == LegendPosition.RIGHT ? legendActualWidth + panelGap : 0));
          if (model.options().legendPosition() == LegendPosition.RIGHT) {
            legendBounds.setBounds(content.x + content.width - legendActualWidth, top, legendActualWidth, Math.min(legendHeight, availableHeightForMain));
          }
        }
      }

      int plotActualHeight = Math.max(1, availableHeightForMain - categoryAxisThickness);
      int plotActualWidth = Math.max(1, availableWidth - valueAxisThickness);
      valueAxisBounds.setBounds(left, top, valueAxisThickness, plotActualHeight);
      plotBounds.setBounds(left + valueAxisThickness, top, plotActualWidth, plotActualHeight);
      categoryAxisBounds.setBounds(plotBounds.x, plotBounds.y + plotBounds.height, plotBounds.width, Math.max(0, availableHeightForMain - plotActualHeight));

      if (features.legendVisible && legendWidth > 0) {
        if (model.options().legendPosition() == LegendPosition.LEFT || model.options().legendPosition() == LegendPosition.RIGHT) {
          int legendY = plotBounds.y + Math.max(0, (plotBounds.height - Math.min(legendHeight, plotBounds.height)) / 2);
          legendBounds.y = legendY;
          legendBounds.height = Math.min(legendHeight, plotBounds.height);
        }
      }
    }
    else {
      int left = content.x;
      int top = mainY;
      int availableWidth = content.width;
      int availableHeightForMain = mainHeight;

      if (features.legendVisible && legendWidth > 0) {
        if (model.options().legendPosition() == LegendPosition.TOP || model.options().legendPosition() == LegendPosition.BOTTOM) {
          int legendActualHeight = Math.min(legendHeight, Math.max(0, availableHeightForMain / 3));
          legendActualHeight = Math.max(Math.min(legendHeight, availableHeightForMain), Math.min(legendActualHeight, availableHeightForMain));
          if (model.options().legendPosition() == LegendPosition.TOP) {
            legendBounds.setBounds(left + Math.max(0, (availableWidth - legendWidth) / 2), top, Math.min(legendWidth, availableWidth), legendActualHeight);
            top += legendActualHeight + panelGap;
          }
          availableHeightForMain = Math.max(1, content.y + content.height - top - (model.options().legendPosition() == LegendPosition.BOTTOM ? legendActualHeight + panelGap : 0));
          if (model.options().legendPosition() == LegendPosition.BOTTOM) {
            legendBounds.setBounds(left + Math.max(0, (availableWidth - legendWidth) / 2), content.y + content.height - legendActualHeight, Math.min(legendWidth, availableWidth), legendActualHeight);
          }
        }
        else {
          int legendActualWidth = Math.min(legendWidth, Math.max(0, availableWidth / 3));
          legendActualWidth = Math.max(Math.min(legendWidth, availableWidth), Math.min(legendActualWidth, availableWidth));
          if (model.options().legendPosition() == LegendPosition.LEFT) {
            legendBounds.setBounds(left, top, legendActualWidth, Math.min(legendHeight, availableHeightForMain));
            left += legendActualWidth + panelGap;
          }
          availableWidth = Math.max(1, content.x + content.width - left - (model.options().legendPosition() == LegendPosition.RIGHT ? legendActualWidth + panelGap : 0));
          if (model.options().legendPosition() == LegendPosition.RIGHT) {
            legendBounds.setBounds(content.x + content.width - legendActualWidth, top, legendActualWidth, Math.min(legendHeight, availableHeightForMain));
          }
        }
      }

      int plotActualWidth = Math.max(1, availableWidth - categoryAxisThickness);
      int plotActualHeight = Math.max(1, availableHeightForMain - valueAxisThickness);
      categoryAxisBounds.setBounds(left, top, categoryAxisThickness, plotActualHeight);
      plotBounds.setBounds(left + categoryAxisThickness, top, plotActualWidth, plotActualHeight);
      valueAxisBounds.setBounds(plotBounds.x, plotBounds.y + plotBounds.height, plotBounds.width, Math.max(0, availableHeightForMain - plotActualHeight));

      if (features.legendVisible && legendWidth > 0) {
        if (model.options().legendPosition() == LegendPosition.LEFT || model.options().legendPosition() == LegendPosition.RIGHT) {
          int legendY = plotBounds.y + Math.max(0, (plotBounds.height - Math.min(legendHeight, plotBounds.height)) / 2);
          legendBounds.y = legendY;
          legendBounds.height = Math.min(legendHeight, plotBounds.height);
        }
      }
    }

    int categoryStride = computeCategoryStride(g2, labelFont, plotBounds);
    int visibleCategoryCount = features.categoryLabelsVisible
      ? Math.max(1, (int) Math.ceil(getCategoryCount() / (double) categoryStride))
      : 0;
    NiceScale.TickSet tickSet = computeActualTickSet(g2, labelFont, plotBounds, measure.range);
    List<String> tickLabels = tickSet.values().stream()
      .map(value -> NiceScale.format(value, tickSet.step()))
      .toList();

    return new LayoutResult(
      stage,
      features,
      new CartesianLayoutSnapshot(
        measure.preferredSize,
        scale,
        plotBounds,
        features.titleVisible,
        features.legendVisible,
        features.categoryLabelsVisible,
        features.valueLabelsVisible,
        visibleCategoryCount,
        tickLabels.size(),
        stage.name()
      ),
      measure.range,
      tickSet,
      tickLabels,
      content,
      titleBounds,
      legendBounds,
      plotBounds,
      categoryAxisBounds,
      valueAxisBounds,
      titleFont,
      labelFont,
      axisTitleFont,
      legendFont,
      categoryStride,
      scale
    );
  }

  private NiceScale.TickSet computeActualTickSet(Graphics2D g2, Font labelFont, Rectangle plotBounds, DataRange range) {
    int hint = model.options().valueTickCountHint();
    FontMetrics metrics = g2.getFontMetrics(labelFont);
    int desired;
    if (model.options().orientation() == CartesianOrientation.VERTICAL) {
      desired = Math.max(2, plotBounds.height / Math.max(metrics.getHeight() + 6, 1));
    }
    else {
      int sampleWidth = Math.max(metrics.stringWidth("-000.00"), 28);
      desired = Math.max(2, plotBounds.width / Math.max(sampleWidth + 12, 1));
    }
    desired = Math.min(Math.max(2, desired), Math.max(2, hint));
    return NiceScale.ticks(range.min, range.max, desired);
  }

  private void paintPlotBackground(Graphics2D g2, LayoutResult result) {
    ChartTheme theme = model.theme();
    g2.setColor(theme.plotBackground());
    g2.fill(result.plotBounds);
    if (!model.warningBands().isEmpty()) {
      for (WarningBand band : model.warningBands()) {
        paintWarningBand(g2, result, band);
      }
    }
  }

  private void paintWarningBand(Graphics2D g2, LayoutResult result, WarningBand band) {
    if (result.range.max <= result.range.min) {
      return;
    }
    Rectangle bounds = result.plotBounds;
    if (model.options().orientation() == CartesianOrientation.VERTICAL) {
      int y1 = valueToPixelVertical(band.high(), result.range.min, result.range.max, bounds);
      int y2 = valueToPixelVertical(band.low(), result.range.min, result.range.max, bounds);
      Rectangle fill = new Rectangle(bounds.x, Math.min(y1, y2), bounds.width, Math.abs(y2 - y1));
      g2.setColor(band.fill());
      g2.fill(fill);
    }
    else {
      int x1 = valueToPixelHorizontal(band.low(), result.range.min, result.range.max, bounds);
      int x2 = valueToPixelHorizontal(band.high(), result.range.min, result.range.max, bounds);
      Rectangle fill = new Rectangle(Math.min(x1, x2), bounds.y, Math.abs(x2 - x1), bounds.height);
      g2.setColor(band.fill());
      g2.fill(fill);
    }
  }

  private void paintGrid(Graphics2D g2, LayoutResult result) {
    ChartTheme theme = model.theme();
    Stroke old = g2.getStroke();
    g2.setColor(theme.gridColor());
    g2.setStroke(new BasicStroke(Math.max(0.5f, theme.gridStrokeWidthModel() * result.scale)));
    Rectangle plot = result.plotBounds;

    if (model.options().orientation() == CartesianOrientation.VERTICAL) {
      for (double value : result.tickSet.values()) {
        int y = valueToPixelVertical(value, result.range.min, result.range.max, plot);
        g2.drawLine(plot.x, y, plot.x + plot.width, y);
      }
      int count = getCategoryCount();
      if (count > 1 && count <= 24) {
        double slot = plot.width / (double) count;
        for (int index = 1; index < count; index++) {
          int x = (int) Math.round(plot.x + (slot * index));
          g2.drawLine(x, plot.y, x, plot.y + plot.height);
        }
      }
    }
    else {
      for (double value : result.tickSet.values()) {
        int x = valueToPixelHorizontal(value, result.range.min, result.range.max, plot);
        g2.drawLine(x, plot.y, x, plot.y + plot.height);
      }
      int count = getCategoryCount();
      if (count > 1 && count <= 24) {
        double slot = plot.height / (double) count;
        for (int index = 1; index < count; index++) {
          int y = (int) Math.round(plot.y + (slot * index));
          g2.drawLine(plot.x, y, plot.x + plot.width, y);
        }
      }
    }
    g2.setStroke(old);
  }

  private void paintData(Graphics2D g2, LayoutResult result) {
    Shape oldClip = g2.getClip();
    if (model.options().clipPlot()) {
      g2.setClip(result.plotBounds);
    }
    if (model.options().orientation() == CartesianOrientation.VERTICAL) {
      paintVerticalData(g2, result);
    }
    else {
      paintHorizontalData(g2, result);
    }
    g2.setClip(oldClip);
  }

  private void paintVerticalData(Graphics2D g2, LayoutResult result) {
    Rectangle plot = result.plotBounds;
    int categoryCount = getCategoryCount();
    if (categoryCount == 0) {
      return;
    }

    double slot = plot.width / (double) categoryCount;
    int barSeriesCount = (int) model.series().stream().filter(series -> series.renderer() == SeriesRenderer.BAR).count();
    int pointDiameter = Math.max(2, scaled(model.theme().pointDiameterModel(), result.scale));
    float strokeWidth = Math.max(1.0f, model.theme().seriesStrokeWidthModel() * result.scale);

    if (model.options().stackedBars() && barSeriesCount > 0) {
      paintStackedBarsVertical(g2, result, slot, strokeWidth);
    }
    else if (barSeriesCount > 0) {
      paintGroupedBarsVertical(g2, result, slot, strokeWidth, barSeriesCount);
    }

    for (CartesianSeries series : model.series()) {
      switch (series.renderer()) {
        case LINE -> paintLineVertical(g2, result, series, slot, strokeWidth, false);
        case AREA -> paintLineVertical(g2, result, series, slot, strokeWidth, true);
        case SCATTER -> paintScatterVertical(g2, result, series, slot, pointDiameter);
        case BAR -> {
        }
      }
    }
  }

  private void paintHorizontalData(Graphics2D g2, LayoutResult result) {
    Rectangle plot = result.plotBounds;
    int categoryCount = getCategoryCount();
    if (categoryCount == 0) {
      return;
    }

    double slot = plot.height / (double) categoryCount;
    int barSeriesCount = (int) model.series().stream().filter(series -> series.renderer() == SeriesRenderer.BAR).count();
    int pointDiameter = Math.max(2, scaled(model.theme().pointDiameterModel(), result.scale));
    float strokeWidth = Math.max(1.0f, model.theme().seriesStrokeWidthModel() * result.scale);

    if (model.options().stackedBars() && barSeriesCount > 0) {
      paintStackedBarsHorizontal(g2, result, slot, strokeWidth);
    }
    else if (barSeriesCount > 0) {
      paintGroupedBarsHorizontal(g2, result, slot, strokeWidth, barSeriesCount);
    }

    for (CartesianSeries series : model.series()) {
      switch (series.renderer()) {
        case LINE -> paintLineHorizontal(g2, result, series, slot, strokeWidth, false);
        case AREA -> paintLineHorizontal(g2, result, series, slot, strokeWidth, true);
        case SCATTER -> paintScatterHorizontal(g2, result, series, slot, pointDiameter);
        case BAR -> {
        }
      }
    }
  }

  private void paintGroupedBarsVertical(Graphics2D g2, LayoutResult result, double slot, float strokeWidth, int barSeriesCount) {
    Rectangle plot = result.plotBounds;
    double baseline = Math.max(result.range.min, Math.min(0.0, result.range.max));
    int baseY = valueToPixelVertical(baseline, result.range.min, result.range.max, plot);
    int indexInBars = 0;
    int categoryGap = Math.max(2, scaled(model.theme().categoryGapModel(), result.scale));
    double barBand = Math.max(1.0, slot - categoryGap);

    for (CartesianSeries series : model.series()) {
      if (series.renderer() != SeriesRenderer.BAR) {
        continue;
      }
      double barWidth = Math.max(1.0, barBand / barSeriesCount);
      g2.setColor(series.color());
      for (int category = 0; category < getCategoryCount(); category++) {
        double value = getValue(series, category);
        int y = valueToPixelVertical(value, result.range.min, result.range.max, plot);
        int left = (int) Math.round(plot.x + (category * slot) + (categoryGap / 2.0) + (indexInBars * barWidth));
        int top = Math.min(baseY, y);
        int height = Math.max(1, Math.abs(baseY - y));
        int width = Math.max(1, (int) Math.round(barWidth - 1));
        Rectangle2D bar = new Rectangle2D.Double(left, top, width, height);
        g2.fill(bar);
        g2.setColor(series.color().darker());
        g2.setStroke(new BasicStroke(Math.max(0.75f, strokeWidth * 0.5f)));
        g2.draw(bar);
        g2.setColor(series.color());
      }
      indexInBars++;
    }
  }

  private void paintStackedBarsVertical(Graphics2D g2, LayoutResult result, double slot, float strokeWidth) {
    Rectangle plot = result.plotBounds;
    int categoryGap = Math.max(2, scaled(model.theme().categoryGapModel(), result.scale));
    double barWidth = Math.max(1.0, slot - categoryGap);
    List<CartesianSeries> bars = model.series().stream()
      .filter(series -> series.renderer() == SeriesRenderer.BAR)
      .toList();
    for (int category = 0; category < getCategoryCount(); category++) {
      double positiveBase = 0.0;
      double negativeBase = 0.0;
      int left = (int) Math.round(plot.x + (category * slot) + (categoryGap / 2.0));
      for (CartesianSeries series : bars) {
        double value = getValue(series, category);
        double start = value >= 0 ? positiveBase : negativeBase;
        double end = start + value;
        int y1 = valueToPixelVertical(start, result.range.min, result.range.max, plot);
        int y2 = valueToPixelVertical(end, result.range.min, result.range.max, plot);
        Rectangle2D bar = new Rectangle2D.Double(left, Math.min(y1, y2), Math.max(1, (int) Math.round(barWidth)), Math.max(1, Math.abs(y2 - y1)));
        g2.setColor(series.color());
        g2.fill(bar);
        g2.setColor(series.color().darker());
        g2.setStroke(new BasicStroke(Math.max(0.75f, strokeWidth * 0.5f)));
        g2.draw(bar);
        if (value >= 0) {
          positiveBase = end;
        }
        else {
          negativeBase = end;
        }
      }
    }
  }

  private void paintGroupedBarsHorizontal(Graphics2D g2, LayoutResult result, double slot, float strokeWidth, int barSeriesCount) {
    Rectangle plot = result.plotBounds;
    double baseline = Math.max(result.range.min, Math.min(0.0, result.range.max));
    int baseX = valueToPixelHorizontal(baseline, result.range.min, result.range.max, plot);
    int indexInBars = 0;
    int categoryGap = Math.max(2, scaled(model.theme().categoryGapModel(), result.scale));
    double barBand = Math.max(1.0, slot - categoryGap);

    for (CartesianSeries series : model.series()) {
      if (series.renderer() != SeriesRenderer.BAR) {
        continue;
      }
      double barHeight = Math.max(1.0, barBand / barSeriesCount);
      g2.setColor(series.color());
      for (int category = 0; category < getCategoryCount(); category++) {
        double value = getValue(series, category);
        int x = valueToPixelHorizontal(value, result.range.min, result.range.max, plot);
        int top = (int) Math.round(plot.y + (category * slot) + (categoryGap / 2.0) + (indexInBars * barHeight));
        int left = Math.min(baseX, x);
        int width = Math.max(1, Math.abs(baseX - x));
        int height = Math.max(1, (int) Math.round(barHeight - 1));
        Rectangle2D bar = new Rectangle2D.Double(left, top, width, height);
        g2.fill(bar);
        g2.setColor(series.color().darker());
        g2.setStroke(new BasicStroke(Math.max(0.75f, strokeWidth * 0.5f)));
        g2.draw(bar);
        g2.setColor(series.color());
      }
      indexInBars++;
    }
  }

  private void paintStackedBarsHorizontal(Graphics2D g2, LayoutResult result, double slot, float strokeWidth) {
    Rectangle plot = result.plotBounds;
    int categoryGap = Math.max(2, scaled(model.theme().categoryGapModel(), result.scale));
    double barHeight = Math.max(1.0, slot - categoryGap);
    List<CartesianSeries> bars = model.series().stream()
      .filter(series -> series.renderer() == SeriesRenderer.BAR)
      .toList();
    for (int category = 0; category < getCategoryCount(); category++) {
      double positiveBase = 0.0;
      double negativeBase = 0.0;
      int top = (int) Math.round(plot.y + (category * slot) + (categoryGap / 2.0));
      for (CartesianSeries series : bars) {
        double value = getValue(series, category);
        double start = value >= 0 ? positiveBase : negativeBase;
        double end = start + value;
        int x1 = valueToPixelHorizontal(start, result.range.min, result.range.max, plot);
        int x2 = valueToPixelHorizontal(end, result.range.min, result.range.max, plot);
        Rectangle2D bar = new Rectangle2D.Double(Math.min(x1, x2), top, Math.max(1, Math.abs(x2 - x1)), Math.max(1, (int) Math.round(barHeight)));
        g2.setColor(series.color());
        g2.fill(bar);
        g2.setColor(series.color().darker());
        g2.setStroke(new BasicStroke(Math.max(0.75f, strokeWidth * 0.5f)));
        g2.draw(bar);
        if (value >= 0) {
          positiveBase = end;
        }
        else {
          negativeBase = end;
        }
      }
    }
  }

  private void paintLineVertical(Graphics2D g2, LayoutResult result, CartesianSeries series, double slot, float strokeWidth, boolean fillArea) {
    Rectangle plot = result.plotBounds;
    int baseY = zeroBaselineY(result);
    Path2D path = new Path2D.Double();
    Path2D fill = new Path2D.Double();
    boolean moved = false;
    for (int category = 0; category < getCategoryCount(); category++) {
      double value = getValue(series, category);
      double centerX = plot.x + (category * slot) + (slot / 2.0);
      int x = (int) Math.round(centerX);
      int y = valueToPixelVertical(value, result.range.min, result.range.max, plot);
      if (!moved) {
        path.moveTo(x, y);
        fill.moveTo(x, baseY);
        fill.lineTo(x, y);
        moved = true;
      }
      else {
        path.lineTo(x, y);
        fill.lineTo(x, y);
      }
    }
    if (!moved) {
      return;
    }
    if (fillArea) {
      fill.lineTo(plot.x + ((getCategoryCount() - 1) * slot) + (slot / 2.0), baseY);
      fill.closePath();
      g2.setColor(withAlpha(series.color(), 110));
      g2.fill(fill);
    }
    Stroke old = g2.getStroke();
    g2.setStroke(new BasicStroke(strokeWidth, BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND));
    g2.setColor(series.color());
    g2.draw(path);
    g2.setStroke(old);
  }

  private void paintLineHorizontal(Graphics2D g2, LayoutResult result, CartesianSeries series, double slot, float strokeWidth, boolean fillArea) {
    Rectangle plot = result.plotBounds;
    int baseX = zeroBaselineX(result);
    Path2D path = new Path2D.Double();
    Path2D fill = new Path2D.Double();
    boolean moved = false;
    for (int category = 0; category < getCategoryCount(); category++) {
      double value = getValue(series, category);
      double centerY = plot.y + (category * slot) + (slot / 2.0);
      int x = valueToPixelHorizontal(value, result.range.min, result.range.max, plot);
      int y = (int) Math.round(centerY);
      if (!moved) {
        path.moveTo(x, y);
        fill.moveTo(baseX, y);
        fill.lineTo(x, y);
        moved = true;
      }
      else {
        path.lineTo(x, y);
        fill.lineTo(x, y);
      }
    }
    if (!moved) {
      return;
    }
    if (fillArea) {
      fill.lineTo(baseX, plot.y + ((getCategoryCount() - 1) * slot) + (slot / 2.0));
      fill.closePath();
      g2.setColor(withAlpha(series.color(), 110));
      g2.fill(fill);
    }
    Stroke old = g2.getStroke();
    g2.setStroke(new BasicStroke(strokeWidth, BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND));
    g2.setColor(series.color());
    g2.draw(path);
    g2.setStroke(old);
  }

  private void paintScatterVertical(Graphics2D g2, LayoutResult result, CartesianSeries series, double slot, int pointDiameter) {
    Rectangle plot = result.plotBounds;
    g2.setColor(series.color());
    for (int category = 0; category < getCategoryCount(); category++) {
      double value = getValue(series, category);
      int x = (int) Math.round(plot.x + (category * slot) + (slot / 2.0) - (pointDiameter / 2.0));
      int y = valueToPixelVertical(value, result.range.min, result.range.max, plot) - (pointDiameter / 2);
      Ellipse2D dot = new Ellipse2D.Double(x, y, pointDiameter, pointDiameter);
      g2.fill(dot);
    }
  }

  private void paintScatterHorizontal(Graphics2D g2, LayoutResult result, CartesianSeries series, double slot, int pointDiameter) {
    Rectangle plot = result.plotBounds;
    g2.setColor(series.color());
    for (int category = 0; category < getCategoryCount(); category++) {
      double value = getValue(series, category);
      int x = valueToPixelHorizontal(value, result.range.min, result.range.max, plot) - (pointDiameter / 2);
      int y = (int) Math.round(plot.y + (category * slot) + (slot / 2.0) - (pointDiameter / 2.0));
      Ellipse2D dot = new Ellipse2D.Double(x, y, pointDiameter, pointDiameter);
      g2.fill(dot);
    }
  }

  private void paintAxes(Graphics2D g2, LayoutResult result) {
    ChartTheme theme = model.theme();
    Stroke oldStroke = g2.getStroke();
    g2.setStroke(new BasicStroke(Math.max(0.75f, theme.axisStrokeWidthModel() * result.scale)));
    g2.setColor(theme.axisColor());

    if (model.options().orientation() == CartesianOrientation.VERTICAL) {
      paintAxesVertical(g2, result);
    }
    else {
      paintAxesHorizontal(g2, result);
    }
    g2.setStroke(oldStroke);
  }

  private void paintAxesVertical(Graphics2D g2, LayoutResult result) {
    Rectangle plot = result.plotBounds;
    Rectangle categoryAxis = result.categoryAxisBounds;
    Rectangle valueAxis = result.valueAxisBounds;
    int categoryAxisY = zeroBaselineY(result);
    int tickLength = Math.max(3, scaled(model.theme().axisTickLengthModel(), result.scale));
    int gap = Math.max(2, scaled(model.theme().elementGapModel(), result.scale));
    FontMetrics labelMetrics = g2.getFontMetrics(result.labelFont);
    FontMetrics axisTitleMetrics = g2.getFontMetrics(result.axisTitleFont);
    int valueTitleBandWidth =
      result.features.axisTitlesVisible && !model.valueAxisTitle().isBlank()
        ? axisTitleMetrics.getHeight() + gap
        : 0;
    int categoryTitleBandHeight =
      result.features.axisTitlesVisible && !model.categoryAxisTitle().isBlank()
        ? axisTitleMetrics.getHeight() + gap
        : 0;
    Rectangle valueLabelsBounds = new Rectangle(
      valueAxis.x + valueTitleBandWidth,
      valueAxis.y,
      Math.max(1, valueAxis.width - valueTitleBandWidth),
      valueAxis.height
    );
    Rectangle valueTitleBounds = new Rectangle(
      valueAxis.x,
      valueAxis.y,
      Math.max(0, valueTitleBandWidth),
      valueAxis.height
    );
    Rectangle categoryLabelsBounds = new Rectangle(
      categoryAxis.x,
      categoryAxis.y,
      categoryAxis.width,
      Math.max(1, categoryAxis.height - categoryTitleBandHeight)
    );
    Rectangle categoryTitleBounds = new Rectangle(
      categoryAxis.x,
      categoryLabelsBounds.y + categoryLabelsBounds.height,
      categoryAxis.width,
      Math.max(0, categoryTitleBandHeight)
    );

    g2.drawLine(plot.x, plot.y, plot.x, plot.y + plot.height);
    g2.drawLine(plot.x, categoryAxisY, plot.x + plot.width, categoryAxisY);

    if (result.features.valueLabelsVisible) {
      g2.setFont(result.labelFont);
      int lastTick = result.tickSet.values().size() - 1;
      for (int i = 0; i < result.tickSet.values().size(); i++) {
        double value = result.tickSet.values().get(i);
        String label = ellipsize(result.tickLabels.get(i), labelMetrics, Math.max(10, valueLabelsBounds.width - tickLength - gap));
        int y = valueToPixelVertical(value, result.range.min, result.range.max, plot);
        g2.drawLine(plot.x - tickLength, y, plot.x, y);
        int textWidth = labelMetrics.stringWidth(label);
        int baseline = centeredBaseline(y, labelMetrics);
        if (i == 0) {
          baseline = bottomAlignedBaseline(y, labelMetrics);
        }
        else if (i == lastTick) {
          baseline = topAlignedBaseline(y, labelMetrics);
        }
        baseline = clamp(baseline, valueLabelsBounds.y + labelMetrics.getAscent(), valueLabelsBounds.y + valueLabelsBounds.height - labelMetrics.getDescent());
        g2.drawString(label, valueLabelsBounds.x + valueLabelsBounds.width - tickLength - gap - textWidth, baseline);
      }
    }

    if (result.features.categoryLabelsVisible) {
      g2.setFont(result.labelFont);
      int count = getCategoryCount();
      double slot = plot.width / (double) count;
      for (int category = 0; category < count; category += result.categoryStride) {
        int x = (int) Math.round(plot.x + (category * slot) + (slot / 2.0));
        g2.drawLine(x, categoryAxisY, x, categoryAxisY + tickLength);
        String label = ellipsize(getCategoryLabel(category), labelMetrics, Math.max(10, (int) slot - 4));
        int textWidth = labelMetrics.stringWidth(label);
        int baseline = categoryLabelsBounds.y + tickLength + gap + labelMetrics.getAscent();
        int textX = clamp(x - (textWidth / 2), categoryLabelsBounds.x, categoryLabelsBounds.x + categoryLabelsBounds.width - textWidth);
        baseline = clamp(baseline, categoryLabelsBounds.y + labelMetrics.getAscent(), categoryLabelsBounds.y + categoryLabelsBounds.height - labelMetrics.getDescent());
        g2.drawString(label, textX, baseline);
      }
    }

    if (result.features.axisTitlesVisible) {
      if (!model.categoryAxisTitle().isBlank() && categoryTitleBounds.height > 0) {
        paintCenteredText(g2, model.categoryAxisTitle(), categoryTitleBounds, result.axisTitleFont, model.theme().textColor());
      }
      if (!model.valueAxisTitle().isBlank() && valueTitleBounds.width > 0) {
        paintRotatedCenteredText(g2, model.valueAxisTitle(), valueTitleBounds, result.axisTitleFont, model.theme().textColor(), -Math.PI / 2.0);
      }
    }
  }

  private void paintAxesHorizontal(Graphics2D g2, LayoutResult result) {
    Rectangle plot = result.plotBounds;
    Rectangle categoryAxis = result.categoryAxisBounds;
    Rectangle valueAxis = result.valueAxisBounds;
    int categoryAxisX = zeroBaselineX(result);
    int tickLength = Math.max(3, scaled(model.theme().axisTickLengthModel(), result.scale));
    int gap = Math.max(2, scaled(model.theme().elementGapModel(), result.scale));
    FontMetrics labelMetrics = g2.getFontMetrics(result.labelFont);
    FontMetrics axisTitleMetrics = g2.getFontMetrics(result.axisTitleFont);
    int valueTitleBandHeight =
      result.features.axisTitlesVisible && !model.valueAxisTitle().isBlank()
        ? axisTitleMetrics.getHeight() + gap
        : 0;
    int categoryTitleBandWidth =
      result.features.axisTitlesVisible && !model.categoryAxisTitle().isBlank()
        ? axisTitleMetrics.getHeight() + gap
        : 0;
    Rectangle valueLabelsBounds = new Rectangle(
      valueAxis.x,
      valueAxis.y,
      valueAxis.width,
      Math.max(1, valueAxis.height - valueTitleBandHeight)
    );
    Rectangle valueTitleBounds = new Rectangle(
      valueAxis.x,
      valueLabelsBounds.y + valueLabelsBounds.height,
      valueAxis.width,
      Math.max(0, valueTitleBandHeight)
    );
    Rectangle categoryTitleBounds = new Rectangle(
      categoryAxis.x,
      categoryAxis.y,
      Math.max(0, categoryTitleBandWidth),
      categoryAxis.height
    );
    Rectangle categoryLabelsBounds = new Rectangle(
      categoryAxis.x + categoryTitleBandWidth,
      categoryAxis.y,
      Math.max(1, categoryAxis.width - categoryTitleBandWidth),
      categoryAxis.height
    );

    g2.drawLine(plot.x, plot.y + plot.height, plot.x + plot.width, plot.y + plot.height);
    g2.drawLine(categoryAxisX, plot.y, categoryAxisX, plot.y + plot.height);

    if (result.features.valueLabelsVisible) {
      g2.setFont(result.labelFont);
      for (int i = 0; i < result.tickSet.values().size(); i++) {
        double value = result.tickSet.values().get(i);
        String label = ellipsize(result.tickLabels.get(i), labelMetrics, Math.max(10, plot.width / Math.max(1, result.tickSet.values().size())));
        int x = valueToPixelHorizontal(value, result.range.min, result.range.max, plot);
        g2.drawLine(x, plot.y + plot.height, x, plot.y + plot.height + tickLength);
        int textWidth = labelMetrics.stringWidth(label);
        int textX = clamp(x - (textWidth / 2), valueLabelsBounds.x, valueLabelsBounds.x + valueLabelsBounds.width - textWidth);
        g2.drawString(label, textX, valueLabelsBounds.y + tickLength + gap + labelMetrics.getAscent());
      }
    }

    if (result.features.categoryLabelsVisible) {
      g2.setFont(result.labelFont);
      int count = getCategoryCount();
      double slot = plot.height / (double) count;
      for (int category = 0; category < count; category += result.categoryStride) {
        int y = (int) Math.round(plot.y + (category * slot) + (slot / 2.0));
        g2.drawLine(categoryAxisX - tickLength, y, categoryAxisX, y);
        String label = ellipsize(getCategoryLabel(category), labelMetrics, Math.max(10, categoryLabelsBounds.width - tickLength - gap));
        int baseline = clamp(centeredBaseline(y, labelMetrics), categoryLabelsBounds.y + labelMetrics.getAscent(), categoryLabelsBounds.y + categoryLabelsBounds.height - labelMetrics.getDescent());
        g2.drawString(label, categoryLabelsBounds.x + gap, baseline);
      }
    }

    if (result.features.axisTitlesVisible) {
      if (!model.valueAxisTitle().isBlank() && valueTitleBounds.height > 0) {
        paintCenteredText(g2, model.valueAxisTitle(), valueTitleBounds, result.axisTitleFont, model.theme().textColor());
      }
      if (!model.categoryAxisTitle().isBlank() && categoryTitleBounds.width > 0) {
        paintRotatedCenteredText(g2, model.categoryAxisTitle(), categoryTitleBounds, result.axisTitleFont, model.theme().textColor(), -Math.PI / 2.0);
      }
    }
  }

  private void paintLegend(Graphics2D g2, LayoutResult result) {
    Rectangle legend = result.legendBounds;
    ChartTheme theme = model.theme();
    int gap = Math.max(2, scaled(theme.elementGapModel(), result.scale));
    int swatch = Math.max(6, scaled(theme.legendSwatchSizeModel(), result.scale));

    g2.setColor(theme.legendBackground());
    g2.fillRoundRect(legend.x, legend.y, legend.width, legend.height, gap * 2, gap * 2);
    g2.setColor(theme.legendBorderColor());
    g2.drawRoundRect(legend.x, legend.y, legend.width, legend.height, gap * 2, gap * 2);

    g2.setFont(result.legendFont);
    FontMetrics metrics = g2.getFontMetrics();
    int rowHeight = Math.max(metrics.getHeight(), swatch);
    int y = legend.y + gap;
    for (CartesianSeries series : model.series()) {
      int swatchY = y + ((rowHeight - swatch) / 2);
      int swatchX = legend.x + gap;
      paintLegendSwatch(g2, series, swatchX, swatchY, swatch, swatch / 2.0);
      g2.setColor(theme.textColor());
      int textBaseline = y + ((rowHeight - metrics.getHeight()) / 2) + metrics.getAscent();
      g2.drawString(ellipsize(series.name(), metrics, legend.width - (gap * 3) - swatch), swatchX + swatch + gap, textBaseline);
      y += rowHeight + gap;
    }
  }

  private void paintLegendSwatch(Graphics2D g2, CartesianSeries series, int x, int y, int size, double half) {
    g2.setColor(series.color());
    switch (series.renderer()) {
      case LINE -> {
        Stroke old = g2.getStroke();
        g2.setStroke(new BasicStroke(Math.max(1.2f, size / 5.0f), BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND));
        g2.drawLine(x, (int) (y + half), x + size, (int) (y + half));
        g2.setStroke(old);
      }
      case SCATTER -> g2.fill(new Ellipse2D.Double(x, y, size, size));
      case AREA -> {
        Path2D shape = new Path2D.Double();
        shape.moveTo(x, y + size);
        shape.lineTo(x, y + (size * 0.35));
        shape.lineTo(x + half, y + (size * 0.6));
        shape.lineTo(x + size, y + (size * 0.15));
        shape.lineTo(x + size, y + size);
        shape.closePath();
        g2.fill(shape);
      }
      case BAR -> g2.fill(new Rectangle2D.Double(x, y, size, size));
    }
  }

  private void paintCenteredText(Graphics2D g2, String text, Rectangle bounds, Font font, Color color) {
    g2.setFont(font);
    g2.setColor(color);
    FontMetrics metrics = g2.getFontMetrics();
    String clipped = ellipsize(text, metrics, bounds.width);
    int textWidth = metrics.stringWidth(clipped);
    int x = bounds.x + Math.max(0, (bounds.width - textWidth) / 2);
    int y = bounds.y + Math.max(metrics.getAscent(), (bounds.height - metrics.getHeight()) / 2 + metrics.getAscent());
    g2.drawString(clipped, x, y);
  }

  private void paintRotatedCenteredText(Graphics2D g2, String text, Rectangle bounds, Font font, Color color, double radians) {
    AffineTransform old = g2.getTransform();
    g2.setFont(font);
    g2.setColor(color);
    FontMetrics metrics = g2.getFontMetrics();
    String clipped = ellipsize(text, metrics, Math.max(bounds.width, bounds.height));
    int textWidth = metrics.stringWidth(clipped);
    int centerX = bounds.x + (bounds.width / 2);
    int centerY = bounds.y + (bounds.height / 2);
    g2.rotate(radians, centerX, centerY);
    g2.drawString(clipped, centerX - (textWidth / 2), centerY + (metrics.getAscent() / 2));
    g2.setTransform(old);
  }

  private int computeCategoryStride(Graphics2D g2, Font font, Rectangle plotBounds) {
    if (!FeatureSet.forStage(model.options(), FeatureStage.FULL).categoryLabelsVisible) {
      return 1;
    }
    g2.setFont(font);
    FontMetrics metrics = g2.getFontMetrics();
    int count = getCategoryCount();
    if (count == 0) {
      return 1;
    }
    if (model.options().orientation() == CartesianOrientation.VERTICAL) {
      int maxLabelWidth = getCategoryLabels().stream().mapToInt(metrics::stringWidth).max().orElse(0);
      double slot = plotBounds.width / (double) count;
      return Math.max(1, (int) Math.ceil((maxLabelWidth + 6.0) / Math.max(1.0, slot)));
    }
    double slot = plotBounds.height / (double) count;
    return Math.max(1, (int) Math.ceil((metrics.getHeight() + 4.0) / Math.max(1.0, slot)));
  }

  private DataRange computeRange() {
    double min = Double.POSITIVE_INFINITY;
    double max = Double.NEGATIVE_INFINITY;

    if (model.options().stackedBars()) {
      List<CartesianSeries> bars = model.series().stream()
        .filter(series -> series.renderer() == SeriesRenderer.BAR)
        .toList();
      for (int category = 0; category < getCategoryCount(); category++) {
        double positive = 0.0;
        double negative = 0.0;
        for (CartesianSeries bar : bars) {
          double value = getValue(bar, category);
          if (value >= 0.0) {
            positive += value;
          }
          else {
            negative += value;
          }
        }
        min = Math.min(min, negative);
        max = Math.max(max, positive);
      }
    }

    for (CartesianSeries series : model.series()) {
      for (double value : series.values()) {
        min = Math.min(min, value);
        max = Math.max(max, value);
      }
    }

    for (WarningBand band : model.warningBands()) {
      min = Math.min(min, band.low());
      max = Math.max(max, band.high());
    }

    if (!Double.isFinite(min) || !Double.isFinite(max)) {
      min = 0.0;
      max = 1.0;
    }

    min = Math.min(min, 0.0);
    max = Math.max(max, 0.0);
    if (min == max) {
      max = min + 1.0;
    }
    return new DataRange(min, max);
  }

  private List<String> getCategoryLabels() {
    List<String> labels = new ArrayList<>(model.categoryLabels());
    while (labels.size() < getCategoryCount()) {
      labels.add("");
    }
    return labels;
  }

  private String getCategoryLabel(int index) {
    List<String> labels = model.categoryLabels();
    if (index >= 0 && index < labels.size()) {
      return labels.get(index);
    }
    return "";
  }

  private int getCategoryCount() {
    int seriesMax = model.series().stream().mapToInt(series -> series.values().size()).max().orElse(0);
    return Math.max(model.categoryLabels().size(), seriesMax);
  }

  private double getValue(CartesianSeries series, int category) {
    return category < series.values().size() ? series.values().get(category) : 0.0;
  }

  private int valueToPixelVertical(double value, double min, double max, Rectangle plot) {
    double fraction = (value - min) / Math.max(1.0e-9, max - min);
    return (int) Math.round(plot.y + plot.height - (fraction * plot.height));
  }

  private int valueToPixelHorizontal(double value, double min, double max, Rectangle plot) {
    double fraction = (value - min) / Math.max(1.0e-9, max - min);
    return (int) Math.round(plot.x + (fraction * plot.width));
  }

  private int zeroBaselineY(LayoutResult result) {
    double baseline = Math.max(result.range.min, Math.min(0.0, result.range.max));
    return valueToPixelVertical(baseline, result.range.min, result.range.max, result.plotBounds);
  }

  private int zeroBaselineX(LayoutResult result) {
    double baseline = Math.max(result.range.min, Math.min(0.0, result.range.max));
    return valueToPixelHorizontal(baseline, result.range.min, result.range.max, result.plotBounds);
  }

  private static String ellipsize(String text, FontMetrics metrics, int maxWidth) {
    if (text == null || text.isEmpty() || metrics.stringWidth(text) <= maxWidth) {
      return text == null ? "" : text;
    }
    String ellipsis = "…";
    int ellipsisWidth = metrics.stringWidth(ellipsis);
    StringBuilder builder = new StringBuilder();
    for (int i = 0; i < text.length(); i++) {
      String candidate = builder + text.substring(i, i + 1);
      if (metrics.stringWidth(candidate) + ellipsisWidth > maxWidth) {
        break;
      }
      builder.append(text.charAt(i));
    }
    return builder.append(ellipsis).toString();
  }

  private static Insets scaleInsets(Insets insets, float scale) {
    return new Insets(
      scaled(insets.top, scale),
      scaled(insets.left, scale),
      scaled(insets.bottom, scale),
      scaled(insets.right, scale)
    );
  }

  private static int scaled(int value, float scale) {
    return Math.max(0, Math.round(value * scale));
  }

  private static int centeredBaseline(int centerY, FontMetrics metrics) {
    return centerY + ((metrics.getAscent() - metrics.getDescent()) / 2);
  }

  private static int topAlignedBaseline(int topY, FontMetrics metrics) {
    return topY + metrics.getAscent();
  }

  private static int bottomAlignedBaseline(int bottomY, FontMetrics metrics) {
    return bottomY - metrics.getDescent();
  }

  private static int clamp(int value, int min, int max) {
    if (max < min) {
      return min;
    }
    return Math.max(min, Math.min(max, value));
  }

  private static Color withAlpha(Color color, int alpha) {
    return new Color(color.getRed(), color.getGreen(), color.getBlue(), Math.max(0, Math.min(alpha, 255)));
  }

  private enum FeatureStage {
    FULL,
    NO_LEGEND,
    NO_AXIS_TITLES,
    NO_TITLE,
    NO_GRID,
    NO_CATEGORY_LABELS,
    NO_VALUE_LABELS,
    PLOT_ONLY
  }

  private record FeatureSet(
    boolean legendVisible,
    boolean titleVisible,
    boolean axisTitlesVisible,
    boolean gridVisible,
    boolean categoryLabelsVisible,
    boolean valueLabelsVisible,
    boolean axisVisible
  ) {

    private static FeatureSet forStage(CartesianChartOptions options, FeatureStage stage) {
      return switch (stage) {
        case FULL -> new FeatureSet(
          options.showLegend(),
          options.showTitle(),
          options.showAxisTitles(),
          options.showGrid(),
          options.showCategoryAxis(),
          options.showValueAxis(),
          options.showCategoryAxis() || options.showValueAxis()
        );
        case NO_LEGEND -> new FeatureSet(
          false,
          options.showTitle(),
          options.showAxisTitles(),
          options.showGrid(),
          options.showCategoryAxis(),
          options.showValueAxis(),
          options.showCategoryAxis() || options.showValueAxis()
        );
        case NO_AXIS_TITLES -> new FeatureSet(
          false,
          options.showTitle(),
          false,
          options.showGrid(),
          options.showCategoryAxis(),
          options.showValueAxis(),
          options.showCategoryAxis() || options.showValueAxis()
        );
        case NO_TITLE -> new FeatureSet(
          false,
          false,
          false,
          options.showGrid(),
          options.showCategoryAxis(),
          options.showValueAxis(),
          options.showCategoryAxis() || options.showValueAxis()
        );
        case NO_GRID -> new FeatureSet(
          false,
          false,
          false,
          false,
          options.showCategoryAxis(),
          options.showValueAxis(),
          options.showCategoryAxis() || options.showValueAxis()
        );
        case NO_CATEGORY_LABELS -> new FeatureSet(
          false,
          false,
          false,
          false,
          false,
          options.showValueAxis(),
          options.showCategoryAxis() || options.showValueAxis()
        );
        case NO_VALUE_LABELS -> new FeatureSet(
          false,
          false,
          false,
          false,
          false,
          false,
          options.showCategoryAxis() || options.showValueAxis()
        );
        case PLOT_ONLY -> new FeatureSet(false, false, false, false, false, false, false);
      };
    }
  }

  private record Measure(
    Dimension preferredSize,
    Dimension axisThickness,
    Dimension plotSize,
    Dimension legendSize,
    int titleHeight,
    NiceScale.TickSet tickSet,
    List<String> tickLabels,
    int maxCategoryLabelWidth,
    int categoryLabelHeight,
    DataRange range
  ) {
  }

  private record DataRange(double min, double max) {
  }

  private record LayoutResult(
    FeatureStage stage,
    FeatureSet features,
    CartesianLayoutSnapshot snapshot,
    DataRange range,
    NiceScale.TickSet tickSet,
    List<String> tickLabels,
    Rectangle contentBounds,
    Rectangle titleBounds,
    Rectangle legendBounds,
    Rectangle plotBounds,
    Rectangle categoryAxisBounds,
    Rectangle valueAxisBounds,
    Font titleFont,
    Font labelFont,
    Font axisTitleFont,
    Font legendFont,
    int categoryStride,
    float scale
  ) {
  }
}
