// 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.

/**
 * ECharts integration for Perfetto UI.
 *
 * THEME HANDLING:
 *
 * Theme colors are read from CSS variables and used to build an ECharts theme
 * object at chart initialization time. When the user switches themes, the
 * component detects the color change and reinitializes the chart with the
 * new theme.
 *
 * This approach keeps chart options pure (no embedded theme colors) and
 * leverages ECharts' native theme system.
 */

import m from 'mithril';
import {
  AXIS_LABEL_FONT_SIZE,
  type LabelFormatter,
  collectYAxisLabels,
  formatLabel,
} from './chart_option_builder';
import * as echarts from 'echarts/core';
import {
  BarChart as EBarChart,
  BoxplotChart as EBoxplotChart,
  HeatmapChart as EHeatmapChart,
  LineChart as ELineChart,
  PieChart as EPieChart,
  ScatterChart as EScatterChart,
  TreemapChart as ETreemapChart,
  SankeyChart as ESankeyChart,
  GaugeChart as EGaugeChart,
} from 'echarts/charts';
import {
  GridComponent,
  TooltipComponent,
  LegendComponent,
  DataZoomComponent,
  BrushComponent,
  ToolboxComponent,
  VisualMapComponent,
  MarkAreaComponent,
} from 'echarts/components';
import {CanvasRenderer} from 'echarts/renderers';
import type {EChartsType} from 'echarts/core';
import {assertExists, assertIsInstance} from '../../../base/assert';
import {classNames} from '../../../base/classnames';
import {SimpleResizeObserver} from '../../../base/resize_observer';
import {Spinner} from '../../../widgets/spinner';
import {type ChartThemeColors, getChartThemeColors} from './chart_theme';

// Re-export for backward compatibility
export type {ChartThemeColors as ThemeColors};

let echartsInitialized = false;

function ensureEChartsSetup(): void {
  if (echartsInitialized) return;
  echartsInitialized = true;
  echarts.use([
    EBarChart,
    EBoxplotChart,
    EHeatmapChart,
    ELineChart,
    EPieChart,
    EScatterChart,
    ETreemapChart,
    ESankeyChart,
    EGaugeChart,
    GridComponent,
    TooltipComponent,
    LegendComponent,
    DataZoomComponent,
    BrushComponent,
    ToolboxComponent,
    VisualMapComponent,
    MarkAreaComponent,
    CanvasRenderer,
  ]);
}

/**
 * Build an ECharts theme object from Perfetto theme colors.
 */
function buildEChartsTheme(colors: ChartThemeColors): Record<string, unknown> {
  return {
    color: [...colors.chartColors],
    backgroundColor: 'transparent',
    textStyle: {
      color: colors.textColor,
      fontFamily: colors.fontFamily,
    },
    title: {
      textStyle: {color: colors.textColor},
      subtextStyle: {color: colors.textColor},
    },
    legend: {
      textStyle: {color: colors.textColor},
    },
    tooltip: {
      backgroundColor: colors.backgroundColor,
      borderColor: colors.borderColor,
      textStyle: {color: colors.textColor},
    },
    categoryAxis: {
      axisLine: {lineStyle: {color: colors.borderColor}},
      axisTick: {lineStyle: {color: colors.borderColor}},
      axisLabel: {color: colors.textColor},
      splitLine: {lineStyle: {color: colors.borderColor}},
      nameTextStyle: {color: colors.textColor},
    },
    valueAxis: {
      axisLine: {lineStyle: {color: colors.borderColor}},
      axisTick: {lineStyle: {color: colors.borderColor}},
      axisLabel: {color: colors.textColor},
      splitLine: {lineStyle: {color: colors.borderColor}},
      nameTextStyle: {color: colors.textColor},
    },
    logAxis: {
      axisLine: {lineStyle: {color: colors.borderColor}},
      axisTick: {lineStyle: {color: colors.borderColor}},
      axisLabel: {color: colors.textColor},
      splitLine: {lineStyle: {color: colors.borderColor}},
      nameTextStyle: {color: colors.textColor},
    },
    timeAxis: {
      axisLine: {lineStyle: {color: colors.borderColor}},
      axisTick: {lineStyle: {color: colors.borderColor}},
      axisLabel: {color: colors.textColor},
      splitLine: {lineStyle: {color: colors.borderColor}},
      nameTextStyle: {color: colors.textColor},
    },
    visualMap: {
      textStyle: {color: colors.textColor},
      inRange: {
        color: [colors.chartColors[0] + '22', colors.chartColors[0]],
      },
    },
  };
}

// Top padding (grid.top) defaults.
const LEGEND_TOP = 30;
const TICK_LABEL_TOP = 10; // Prevents topmost tick label clipping.

// Horizontal spacing constants.
const NAME_GAP_PADDING = 14; // Extra gap between tick labels and axis name.
const GRID_LEFT_INNER_PAD = 6; // Padding between axis line and grid edge.

// Bottom spacing (grid.bottom) default when the X-axis has a name.
const X_AXIS_NAME_BOTTOM = 30;

// Right spacing (grid.right) reserved for a vertical (right-side) legend.
// Matches the legend's truncation width plus padding for the colour swatch.
const RIGHT_LEGEND_WIDTH = 140;

const AXIS_LABEL_FONT = `${AXIS_LABEL_FONT_SIZE}px sans-serif`;

// Minimal shape we rely on from a CanvasRenderingContext2D-like object, so we
// can substitute a no-op stub when canvas creation fails (e.g. in tests or
// restricted environments) without crashing the chart.
interface TextMeasurer {
  font: string;
  measureText(text: string): {width: number};
}

// Fallback used when canvas 2d context isn't available (e.g. in tests or
// restricted environments). Auto-spacing becomes a no-op but the chart still
// renders.
const NOOP_MEASURER: TextMeasurer = {
  font: '',
  measureText: () => ({width: 0}),
};

// Intentionally cached for the page lifetime — canvas context creation is
// expensive and font metrics don't change between chart renders.
let measureCtx: TextMeasurer | undefined;

function getMeasureCtx(): TextMeasurer {
  if (measureCtx === undefined) {
    const canvas = document.createElement('canvas');
    measureCtx = canvas.getContext('2d') ?? NOOP_MEASURER;
  }
  return measureCtx;
}

function measureTextWidth(text: string, font: string): number {
  const ctx = getMeasureCtx();
  ctx.font = font;
  return ctx.measureText(text).width;
}

/**
 * Auto-compute grid spacing from axis/legend context before rendering.
 * Y-axis nameGap and grid.left are measured from actual tick label width.
 */
function autoAdjustGridSpacing(
  option: echarts.EChartsCoreOption,
): echarts.EChartsCoreOption {
  const opt = option as Record<string, unknown>;
  // Nothing to adjust for charts without axes (e.g. treemap, pie).
  if (opt.xAxis === undefined && opt.yAxis === undefined) return option;
  const grid = {...((opt.grid ?? {}) as Record<string, unknown>)};
  // Skip array yAxis (dual Y-axis) — auto-spacing only handles single axis.
  const yAxis = !Array.isArray(opt.yAxis)
    ? (opt.yAxis as Record<string, unknown> | undefined)
    : undefined;
  const xAxis = opt.xAxis as Record<string, unknown> | undefined;
  const legend = opt.legend as Record<string, unknown> | undefined;
  let yAxisPatch: Record<string, unknown> | undefined;

  const hasLegend = legend !== undefined && legend.show !== false;
  // Horizontal legends (top/bottom) have no `orient: 'vertical'`; we
  // distinguish top vs bottom by which edge the legend is anchored to.
  const legendAtRight = hasLegend && legend?.orient === 'vertical';
  const isHorizontalLegend = hasLegend && !legendAtRight;
  const legendAtBottom = isHorizontalLegend && legend?.bottom !== undefined;
  const legendAtTop = isHorizontalLegend && !legendAtBottom;

  if (grid.top === undefined) {
    grid.top = legendAtTop ? LEGEND_TOP : TICK_LABEL_TOP;
  }

  if (grid.bottom === undefined) {
    const xAxisName = xAxis?.name as string | undefined;
    const hasXAxisName = xAxisName !== undefined && xAxisName !== '';
    if (legendAtBottom) {
      grid.bottom = hasXAxisName ? X_AXIS_NAME_BOTTOM + LEGEND_TOP : LEGEND_TOP;
    } else if (hasXAxisName) {
      grid.bottom = X_AXIS_NAME_BOTTOM;
    }
  }

  if (grid.right === undefined && legendAtRight) {
    grid.right = RIGHT_LEGEND_WIDTH;
  }

  if (yAxis !== undefined) {
    const yName = yAxis.name as string | undefined;
    const nameLocation = yAxis.nameLocation as string | undefined;
    const hasRotatedName =
      yName !== undefined &&
      yName !== '' &&
      (nameLocation === undefined || nameLocation === 'middle');

    if (hasRotatedName && yAxis.nameGap === undefined) {
      const labels = collectYAxisLabels(opt);
      if (labels.length > 0) {
        const axisLabel = yAxis.axisLabel as
          | Record<string, unknown>
          | undefined;
        const formatter = axisLabel?.formatter as
          | LabelFormatter
          | string
          | undefined;
        let maxWidth = 0;
        for (const l of labels) {
          const text = formatLabel(l, formatter);
          const w = measureTextWidth(text, AXIS_LABEL_FONT);
          if (w > maxWidth) maxWidth = w;
        }
        const requiredGap = Math.ceil(maxWidth) + NAME_GAP_PADDING;
        yAxisPatch = {...yAxis, nameGap: requiredGap};
        if (grid.left === undefined) {
          // The grid's left padding holds the rotated axis name (width
          // NAME_GAP_PADDING once trimmed) plus a bit of inner padding.
          grid.left = NAME_GAP_PADDING + GRID_LEFT_INNER_PAD;
        }
      }
    }
  }

  return {
    ...opt,
    grid,
    ...(yAxisPatch !== undefined ? {yAxis: yAxisPatch} : {}),
  };
}

/**
 * Compute a simple hash of theme colors to detect changes.
 */
function themeHash(colors: ChartThemeColors): string {
  return `${colors.textColor}|${colors.borderColor}|${colors.backgroundColor}|${colors.chartColors.join(',')}`;
}

/**
 * Typed params for the ECharts `brushEnd` event.
 * Used by chart brush handlers to extract selected ranges.
 *
 * coordRange is [min, max] for 1-D brushes (lineX / lineY) and
 * [[xMin, xMax], [yMin, yMax]] for 2-D rect brushes.
 */
export interface EChartBrushEndParams {
  readonly areas?: ReadonlyArray<{
    readonly coordRange?:
      | [number, number]
      | [[number, number], [number, number]];
  }>;
}

/**
 * Typed params for ECharts click/interaction events.
 */
export interface EChartClickParams {
  readonly name?: string;
  readonly seriesName?: string;
  readonly dataIndex?: number;
  readonly value?: unknown;
  readonly data?: unknown;
  readonly marker?: string;
  readonly color?: string;
  readonly percent?: number;
}

/**
 * Event handler binding for an ECharts instance.
 * Handlers are wrapped by EChartView to call `m.redraw()` automatically
 * after each invocation, so callers do not need to trigger redraws.
 */
export interface EChartEventHandler {
  readonly eventName: string;
  readonly handler: (...args: unknown[]) => void;
}

export interface EChartViewAttrs {
  /**
   * ECharts option to render. When undefined, a loading spinner is shown.
   */
  readonly option: echarts.EChartsCoreOption | undefined;

  /**
   * Optional callback to transform the option with theme colors.
   * Called after theme colors are read from the DOM, before the option
   * is applied to the ECharts instance. Useful for series-level color
   * overrides (e.g. gauge axis tracks) that the ECharts theme system
   * does not cover.
   */
  readonly resolveOption?: (
    option: echarts.EChartsCoreOption,
    colors: ChartThemeColors,
  ) => echarts.EChartsCoreOption;

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

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

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

  /**
   * Show empty state instead of loading. Defaults to false.
   */
  readonly empty?: boolean;

  /**
   * Event handlers to attach to the ECharts instance.
   */
  readonly eventHandlers?: ReadonlyArray<EChartEventHandler>;

  /**
   * Brush type to activate immediately. When set, brush mode is enabled
   * automatically without requiring user to click a toolbox button.
   */
  readonly activeBrushType?: 'rect' | 'lineX' | 'lineY';
}

const DEFAULT_HEIGHT = 200;

// Shared theme name for all EChartView instances
const THEME_NAME = 'perfetto';

export class EChartView implements m.ClassComponent<EChartViewAttrs> {
  private chart?: EChartsType;
  private resizeObs?: Disposable;
  private prevHandlers: ReadonlyArray<EChartEventHandler> = [];
  private prevOptionJson?: string;
  private prevThemeHash?: string;

  oncreate({dom, attrs}: m.CVnodeDOM<EChartViewAttrs>) {
    ensureEChartsSetup();

    // Only init ECharts when we have an option to render (the canvas
    // is display:none during loading, so init would get 0×0 dimensions).
    if (attrs.option !== undefined) {
      this.initChart(attrs, dom);
    }

    // Defer resize to the next frame so that a layout change caused by
    // chart.resize() doesn't re-trigger the observer in the same frame.
    this.resizeObs = new SimpleResizeObserver(dom, () => {
      requestAnimationFrame(() => this.chart?.resize());
    });
  }

  onupdate({dom, attrs}: m.CVnodeDOM<EChartViewAttrs>) {
    if (attrs.option === undefined) return;

    // Lazy init: first option arrived after a loading state.
    if (this.chart === undefined) {
      this.initChart(attrs, dom);
      return;
    }

    // Check if theme changed - if so, reinitialize the chart
    const colors = getChartThemeColors(dom);
    const currentThemeHash = themeHash(colors);

    if (currentThemeHash !== this.prevThemeHash) {
      // Theme changed - dispose and reinit with new theme
      this.disposeChart();
      this.initChart(attrs, dom);
      return;
    }

    // Check if option changed
    const resolvedOption = this.applyResolveOption(attrs.option, attrs, colors);
    const optionJson = JSON.stringify(resolvedOption);
    if (optionJson !== this.prevOptionJson) {
      this.prevOptionJson = optionJson;
      this.chart.setOption(resolvedOption, {notMerge: true});
      // The canvas may have been display:none (loading state) since the
      // last option, so ECharts' cached dimensions could be stale.
      this.chart.resize();
      this.activateBrush(attrs.activeBrushType);
    }
    this.syncHandlers(attrs.eventHandlers ?? []);
  }

  private initChart(attrs: EChartViewAttrs, dom: Element): void {
    if (attrs.option === undefined) return;

    const container = assertIsInstance(
      assertExists(dom.querySelector('.pf-echart-view__canvas')),
      HTMLElement,
    );

    // Read theme colors and register/update the ECharts theme
    const colors = getChartThemeColors(container);
    const theme = buildEChartsTheme(colors);
    echarts.registerTheme(THEME_NAME, theme);

    const resolvedOption = this.applyResolveOption(attrs.option, attrs, colors);

    // Initialize chart with the theme
    this.chart = echarts.init(container, THEME_NAME);
    this.chart.setOption(resolvedOption);

    this.prevOptionJson = JSON.stringify(resolvedOption);
    this.prevThemeHash = themeHash(colors);
    this.syncHandlers(attrs.eventHandlers ?? []);
    this.activateBrush(attrs.activeBrushType);
  }

  private applyResolveOption(
    option: echarts.EChartsCoreOption,
    attrs: EChartViewAttrs,
    colors: ChartThemeColors,
  ): echarts.EChartsCoreOption {
    const resolved = attrs.resolveOption
      ? attrs.resolveOption(option, colors)
      : option;
    return autoAdjustGridSpacing(resolved);
  }

  private disposeChart(): void {
    this.detachAllHandlers();
    if (this.chart) {
      this.chart.dispose();
      this.chart = undefined;
    }
  }

  private activateBrush(brushType: string | undefined): void {
    if (this.chart === undefined || brushType === undefined) return;
    this.chart.dispatchAction({
      type: 'takeGlobalCursor',
      key: 'brush',
      brushOption: {
        brushType,
        brushMode: 'single',
      },
    });
  }

  onremove() {
    if (this.resizeObs) {
      this.resizeObs[Symbol.dispose]();
      this.resizeObs = undefined;
    }
    this.disposeChart();
  }

  view({attrs}: m.Vnode<EChartViewAttrs>) {
    const height = attrs.height ?? DEFAULT_HEIGHT;
    const isLoading = attrs.option === undefined && !attrs.empty;
    const isEmpty = attrs.empty === true;

    return m(
      '.pf-echart-view',
      {
        className: classNames(
          attrs.fillParent && 'pf-echart-view--fill-parent',
          attrs.className,
        ),
        // When fillParent is set, let the CSS class control height (100%)
        // instead of setting an explicit pixel height via inline style.
        style: attrs.fillParent ? undefined : {height: `${height}px`},
      },
      m('.pf-echart-view__canvas', {
        className: classNames(
          (isLoading || isEmpty) && 'pf-echart-view__canvas--hidden',
        ),
      }),
      isLoading && m('.pf-echart-view__loading', m(Spinner)),
      isEmpty && m('.pf-echart-view__empty', 'No data to display'),
    );
  }

  private syncHandlers(handlers: ReadonlyArray<EChartEventHandler>): void {
    this.detachAllHandlers();
    const wrapped: EChartEventHandler[] = [];
    for (const h of handlers) {
      // Wrap each handler to trigger a Mithril redraw after it executes,
      // so chart event handlers don't need to call m.redraw() manually.
      const wrappedHandler = (...args: unknown[]) => {
        h.handler(...args);
        m.redraw();
      };
      this.chart?.on(h.eventName, wrappedHandler);
      wrapped.push({eventName: h.eventName, handler: wrappedHandler});
    }
    this.prevHandlers = wrapped;
  }

  private detachAllHandlers(): void {
    for (const h of this.prevHandlers) {
      this.chart?.off(h.eventName, h.handler);
    }
    this.prevHandlers = [];
  }
}
