// Copyright (C) 2026 The Android Open Source Project
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//      http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

import m from 'mithril';
import type {EChartsCoreOption} from 'echarts/core';
import {
  type ChartAggregation,
  extractBrushRange,
  formatNumber,
  percentile,
} from './chart_utils';
import {EChartView, type EChartEventHandler} from './echart_view';
import type {LegendPosition} from './common';
import {
  buildAxisOption,
  buildGridOption,
  buildBrushOption,
  buildLegendOption,
  buildTooltipOption,
  SELECTION_COLOR,
} from './chart_option_builder';

/**
 * A single bar in the bar chart.
 */
export interface BarChartItem {
  /** Label for this bar (displayed on the dimension axis). */
  readonly label: string | number;
  /** Numeric value for this bar. */
  readonly value: number;
}

/**
 * A named series of bars (used for stacked/grouped bar charts).
 */
export interface BarChartSeries {
  /** Display name for this series (shown in legend). */
  readonly name: string;
  /** Bars in this series. */
  readonly items: readonly BarChartItem[];
}

/**
 * Data provided to a BarChart.
 *
 * Use `items` for a simple single-series bar chart, or `series` for
 * stacked/grouped bar charts. When `series` is provided, `items` is ignored.
 */
export interface BarChartData {
  /** The bars to display (single series). */
  readonly items: readonly BarChartItem[];
  /** Multiple named series for stacked bar charts. */
  readonly series?: readonly BarChartSeries[];
}

export interface BarChartAttrs {
  /**
   * Bar chart data to display, or undefined if loading.
   * When undefined, a loading spinner is shown.
   */
  readonly data: BarChartData | undefined;

  /**
   * Height of the chart in pixels. Defaults to 200.
   */
  readonly height?: number;

  /**
   * Label for the dimension axis (the categorical/label axis).
   * Placed on the X axis in vertical mode, Y axis in horizontal mode.
   */
  readonly dimensionLabel?: string;

  /**
   * Label for the measure axis (the numeric value axis).
   * Placed on the Y axis in vertical mode, X axis in horizontal mode.
   */
  readonly measureLabel?: string;

  /**
   * Fill parent container. Defaults to false.
   */
  readonly fillParent?: boolean;

  /**
   * Custom class name for the container.
   */
  readonly className?: string;

  /**
   * Format function for dimension axis tick values (bar labels).
   * When provided, this function is used to format the label of each bar.
   */
  readonly formatDimension?: (value: string | number) => string;

  /**
   * Format function for measure axis tick values.
   */
  readonly formatMeasure?: (value: number) => string;

  /**
   * Bar color. Defaults to theme primary color.
   */
  readonly barColor?: string;

  /**
   * Bar hover color. Defaults to theme accent color.
   */
  readonly barHoverColor?: string;

  /**
   * Use logarithmic scale for the measure axis. Defaults to false.
   */
  readonly logScale?: boolean;

  /**
   * When true, measure axis ticks will be snapped to integer values.
   */
  readonly integerMeasure?: boolean;

  /**
   * Chart orientation. Defaults to 'vertical'.
   * - 'vertical': bars grow upward, dimension on X axis, measure on Y axis.
   * - 'horizontal': bars grow rightward, dimension on Y axis, measure on X.
   */
  readonly orientation?: 'vertical' | 'horizontal';

  /**
   * Show grid lines. 'horizontal' draws lines parallel to the X axis,
   * 'vertical' draws lines parallel to the Y axis, 'both' shows both.
   * Defaults to no grid lines.
   */
  readonly gridLines?: 'horizontal' | 'vertical' | 'both';

  /**
   * Callback when brush selection completes (on mouseup).
   * Called with the labels of all bars in the brushed range.
   */
  readonly onBrush?: (labels: Array<string | number>) => void;

  /**
   * Selection labels to highlight on the chart. Bars whose labels are in
   * this array are drawn with a highlight color. The consumer controls
   * this state — typically by feeding the `onBrush` output back in.
   */
  readonly selection?: ReadonlyArray<string | number>;

  /**
   * Where the legend sits relative to the chart (stacked charts only).
   * Defaults to 'top'.
   */
  readonly legendPosition?: LegendPosition;
}

export class BarChart implements m.ClassComponent<BarChartAttrs> {
  view({attrs}: m.Vnode<BarChartAttrs>) {
    const {data, height, fillParent, className, onBrush, orientation} = attrs;
    const horizontal = orientation === 'horizontal';

    const isStacked = data?.series !== undefined && data.series.length > 0;
    const isEmpty = data !== undefined && !isStacked && data.items.length === 0;
    const option =
      data !== undefined && !isEmpty ? buildBarOption(attrs, data) : undefined;

    return m(EChartView, {
      option,
      height,
      fillParent,
      className,
      empty: isEmpty,
      eventHandlers: buildBarEventHandlers(attrs, data),
      activeBrushType:
        onBrush !== undefined ? (horizontal ? 'lineY' : 'lineX') : undefined,
    });
  }
}

function buildBarOption(
  attrs: BarChartAttrs,
  data: BarChartData,
): EChartsCoreOption {
  const {
    dimensionLabel,
    measureLabel = 'Value',
    formatDimension,
    formatMeasure,
    barColor,
    barHoverColor,
    logScale = false,
    integerMeasure = false,
    orientation = 'vertical',
    gridLines,
  } = attrs;
  const fmtDimension = formatDimension ?? String;
  const fmtMeasure = formatMeasure ?? formatNumber;
  const horizontal = orientation === 'horizontal';

  const isStacked = data.series !== undefined && data.series.length > 0;

  // Collect unique dimension labels across all series.
  const labels = isStacked
    ? collectStackedLabels(data.series, fmtDimension)
    : data.items.map((item) => fmtDimension(item.label));

  // Map visual grid line direction to axis splitLine settings.
  // Horizontal visual lines come from the Y axis; vertical from the X axis.
  // In horizontal bar orientation the axes are swapped, so the mapping flips.
  const showXAxisGrid = gridLines === 'vertical' || gridLines === 'both';
  const showYAxisGrid = gridLines === 'horizontal' || gridLines === 'both';

  const categoryAxis = buildAxisOption(
    {
      type: 'category',
      data: labels,
      name: dimensionLabel,
      nameGap: horizontal ? 55 : 35,
      labelOverflow: 'truncate',
      labelWidth: horizontal ? 65 : undefined,
      showSplitLine: horizontal ? showYAxisGrid : showXAxisGrid,
    },
    !horizontal,
  );

  const valueAxis = buildAxisOption(
    {
      type: logScale ? 'log' : 'value',
      name: measureLabel,
      nameGap: horizontal ? 25 : undefined,
      formatter:
        formatMeasure !== undefined
          ? (v) => formatMeasure(v as number)
          : undefined,
      minInterval: integerMeasure ? 1 : undefined,
      showSplitLine: horizontal ? showXAxisGrid : showYAxisGrid,
    },
    horizontal,
  );

  // Build ECharts series — one per named series for stacked charts, or a
  // single series for simple bar charts.
  const echartsSeries = isStacked
    ? buildStackedSeries(data.series, labels, fmtDimension)
    : [buildSingleSeries(data, attrs, barColor, barHoverColor)];

  const option: Record<string, unknown> = {
    animation: false,
    grid: buildGridOption(),
    tooltip: buildTooltipOption({
      trigger: 'axis' as const,
      axisPointer: {type: 'shadow' as const},
      formatter: isStacked
        ? undefined
        : (params: Array<{name?: string; value?: number}>) => {
            const p = Array.isArray(params) ? params[0] : params;
            return `${p.name ?? ''}<br>${measureLabel}: ${fmtMeasure(p.value ?? 0)}`;
          },
    }),
    xAxis: horizontal ? valueAxis : categoryAxis,
    yAxis: horizontal ? categoryAxis : valueAxis,
    legend: isStacked ? buildLegendOption(attrs.legendPosition) : {show: false},
    series: echartsSeries,
  };

  if (attrs.onBrush) {
    option.brush = buildBrushOption({
      xAxisIndex: horizontal ? undefined : 0,
      yAxisIndex: horizontal ? 0 : undefined,
      brushType: horizontal ? 'lineY' : 'lineX',
    });
    // Hide the default brush toolbox; we activate brush programmatically.
    option.toolbox = {show: false};
  }

  return option;
}

/** Build a single ECharts bar series (non-stacked). */
function buildSingleSeries(
  data: BarChartData,
  attrs: BarChartAttrs,
  barColor: string | undefined,
  barHoverColor: string | undefined,
): Record<string, unknown> {
  return {
    type: 'bar',
    data: data.items.map((item) => {
      const selected =
        attrs.selection !== undefined && attrs.selection.includes(item.label);
      return {
        value: item.value,
        ...(selected ? {itemStyle: {color: SELECTION_COLOR}} : {}),
      };
    }),
    itemStyle: barColor !== undefined ? {color: barColor} : undefined,
    emphasis:
      barHoverColor !== undefined
        ? {itemStyle: {color: barHoverColor}}
        : undefined,
  };
}

/**
 * Collect unique dimension labels across all series, preserving the order
 * from the first series they appear in.
 */
function collectStackedLabels(
  series: readonly BarChartSeries[],
  fmtDimension: (v: string | number) => string,
): string[] {
  const seen = new Set<string>();
  const labels: string[] = [];
  for (const s of series) {
    for (const item of s.items) {
      const label = fmtDimension(item.label);
      if (!seen.has(label)) {
        seen.add(label);
        labels.push(label);
      }
    }
  }
  return labels;
}

/**
 * Collect unique items (by label) across all series, preserving order.
 * Used so brush handlers have a flat item list for stacked charts.
 */
function collectAllStackedItems(
  series: readonly BarChartSeries[],
): BarChartItem[] {
  const seen = new Set<string | number>();
  const items: BarChartItem[] = [];
  for (const s of series) {
    for (const item of s.items) {
      if (!seen.has(item.label)) {
        seen.add(item.label);
        items.push(item);
      }
    }
  }
  return items;
}

/** Build ECharts series array for stacked bar charts. */
function buildStackedSeries(
  series: readonly BarChartSeries[],
  labels: readonly string[],
  fmtDimension: (v: string | number) => string,
): Array<Record<string, unknown>> {
  return series.map((s) => {
    // Build a lookup from formatted label → value for this series.
    const valueByLabel = new Map<string, number>();
    for (const item of s.items) {
      valueByLabel.set(fmtDimension(item.label), item.value);
    }
    return {
      type: 'bar',
      name: s.name,
      stack: 'total',
      data: labels.map((label) => valueByLabel.get(label) ?? 0),
      emphasis: {focus: 'series' as const},
    };
  });
}

function buildBarEventHandlers(
  attrs: BarChartAttrs,
  data: BarChartData | undefined,
): ReadonlyArray<EChartEventHandler> {
  const isStacked = data?.series !== undefined && data.series.length > 0;
  if (
    !attrs.onBrush ||
    data === undefined ||
    (!isStacked && data.items.length === 0)
  ) {
    return [];
  }
  const onBrush = attrs.onBrush;
  // For stacked charts, collect all unique labels from all series;
  // for single-series charts, use items directly.
  const items: readonly BarChartItem[] = isStacked
    ? collectAllStackedItems(data.series)
    : data.items;

  return [
    {
      eventName: 'brushEnd',
      handler: (params) => {
        // For category axes, coordRange returns category indices
        const range = extractBrushRange(params);
        if (range !== undefined) {
          const [startIdx, endIdx] = range;
          const minIdx = Math.max(0, startIdx);
          const maxIdx = Math.min(items.length - 1, endIdx);
          if (minIdx <= maxIdx) {
            const labels: Array<string | number> = [];
            for (let i = minIdx; i <= maxIdx; i++) {
              labels.push(items[i].label);
            }
            if (labels.length > 0) {
              onBrush(labels);
            }
          }
        }
      },
    },
  ];
}

/**
 * Aggregate raw data into BarChartData by grouping on a dimension and
 * applying an aggregation function to the measure values.
 *
 * Results are sorted by aggregated value (descending).
 *
 * @param items The raw data items to aggregate.
 * @param dimension Extracts the grouping key (bar label) from each item.
 * @param measure Extracts the numeric value from each item.
 * @param aggregation The aggregation function to apply per group.
 */
export function aggregateBarChartData<T>(
  items: readonly T[],
  dimension: (item: T) => string | number,
  measure: (item: T) => number,
  aggregation: ChartAggregation,
): BarChartData {
  const groups = new Map<string | number, number[]>();
  for (const item of items) {
    const key = dimension(item);
    let values = groups.get(key);
    if (values === undefined) {
      values = [];
      groups.set(key, values);
    }
    values.push(measure(item));
  }

  const result: BarChartItem[] = [];
  for (const [label, values] of groups) {
    result.push({label, value: aggregate(values, aggregation)});
  }

  result.sort((a, b) => b.value - a.value);
  return {items: result};
}

function aggregate(values: number[], agg: ChartAggregation): number {
  switch (agg) {
    case 'ANY':
    case 'MIN':
      return values.reduce((a, b) => Math.min(a, b), Infinity);
    case 'COUNT':
      return values.length;
    case 'SUM':
      return values.reduce((a, b) => a + b, 0);
    case 'AVG':
      return values.reduce((a, b) => a + b, 0) / values.length;
    case 'MAX':
      return values.reduce((a, b) => Math.max(a, b), -Infinity);
    case 'COUNT_DISTINCT':
      return new Set(values).size;
    case 'P25':
    case 'P50':
    case 'P75':
    case 'P90':
    case 'P95':
    case 'P99':
      return percentile(values, Number(agg.slice(1)));
  }
}
