import { Chart, ChartConfiguration } from "chart.js/auto";
import { MASTER_CHART_HEIGHT } from "../../../../../constants";
import {
  MOVING_AVERAGE_TREND_LINE_XAXIS_ID,
  TREND_LINE_XAXIS_ID,
} from "../../../../../helpers/figures/charts/chart_common";
import { clip, largeMax, largeMin } from "../../../../../helpers/misc";
import { useStore } from "../../../../../store_engine/store_hooks";
import { ZoomStore } from "../../../../../stores/zoom_store";
import { ChartJSRuntime } from "../../../../../types/chart/chart";
import { Store } from "../../../../../types/store_engine";
import { cssPropertiesToCss } from "../../../../helpers/css";
import { ChartJsComponent } from "../chartjs";
import { Boundaries, ZoomableChartStore } from "./zoomable_chart_store";

import { signal } from "@odoo/owl";
export class ZoomableChartJsComponent extends ChartJsComponent {
  static template = "o-spreadsheet-ZoomableChartJsComponent";

  private store!: Store<ZoomableChartStore>;
  private zoomStore!: Store<ZoomStore>;

  private masterChartCanvas = signal.ref(HTMLCanvasElement);
  private masterChart?: Chart;
  private mode?: "selectInMaster" | "moveInMaster";
  private hasLinearScale?: boolean;
  private isBarChart?: boolean;
  private chartId: string = "";
  private datasetBoundaries: Boundaries = { min: 0, max: 0 };
  private removeEventListeners = () => {};
  private isMasterChartAllowed: boolean = false;

  setup() {
    this.store = useStore(ZoomableChartStore);
    this.zoomStore = useStore(ZoomStore);
    super.setup();
  }

  protected unmount() {
    super.unmount();
    this.masterChart?.destroy();
    this.removeEventListeners();
  }

  get containerStyle() {
    return cssPropertiesToCss({
      height: this.sliceable ? `calc(100% - ${MASTER_CHART_HEIGHT}px)` : "100%",
    });
  }

  get masterChartContainerStyle() {
    const runtime = this.env.model.getters.getChartRuntime(this.props.chartId) as ChartJSRuntime;
    if (runtime && !runtime.chartJsConfig.data.datasets.some((ds) => ds.data.length > 1)) {
      return "opacity: 0.3;";
    }
    return "";
  }

  get sliceable(): boolean {
    if (this.props.isFullScreen) {
      return true;
    }
    const definition = this.env.model.getters.getChartDefinition(this.props.chartId);
    return ("zoomable" in definition && definition.zoomable) ?? false;
  }

  get axisOffset(): number {
    return !this.hasLinearScale && this.isBarChart ? 0.5 : 0;
  }

  private getMasterChartConfiguration(chartData: ChartConfiguration<any>): ChartConfiguration<any> {
    const config = chartData;
    return {
      ...config,
      options: {
        ...config.options,
        plugins: {
          ...config.options.plugins,
          zoomWindowPlugin: {
            getLowerBound: () => this.lowerBound,
            getUpperBound: () => this.upperBound,
          },
        },
      },
    };
  }

  private getDetailChartConfiguration(chartData: ChartConfiguration<any>): ChartConfiguration<any> {
    if (!this.sliceable) {
      return chartData;
    }
    let x = chartData.options.scales.x;
    const limits = this.store.currentAxesLimits[this.chartId]?.x;
    if (limits) {
      x = {
        ...x,
        ...this.getStoredBoundaries(),
      };
    }
    return {
      ...chartData,
      options: {
        ...chartData.options,
        scales: {
          ...chartData.options.scales,
          x,
        },
        layout: {
          ...chartData.options.layout,
          padding: {
            ...chartData.options.layout?.padding,
            bottom: 5,
          },
        },
      },
    };
  }

  private getAxisLimitsFromDataset(chartData: ChartConfiguration<any>): Boundaries {
    const data = chartData.data.datasets.map((ds) => ds.data).flat();
    const xValues = data.map((d, i) => (typeof d === "object" && d !== null ? d.x : i));
    const min = largeMin(xValues);
    const max = largeMax(xValues);
    return { min, max };
  }

  private setMasterChartCursor(runtime: ChartJSRuntime) {
    const masterElement = this.masterChartCanvas();
    if (!masterElement) {
      return;
    }
    if (runtime && !runtime.chartJsConfig.data.datasets.some((ds) => ds.data.length > 1)) {
      masterElement.style.cursor = "not-allowed";
      this.isMasterChartAllowed = false;
      return;
    }
    masterElement.style.cursor = "default";
    this.isMasterChartAllowed = true;
  }

  protected createChart(chartRuntime: ChartJSRuntime) {
    if (!globalThis.Chart) {
      throw new Error("Chart.js library is not loaded");
    }
    const chartData = chartRuntime.chartJsConfig as ChartConfiguration<any>;
    this.isBarChart = chartData.type === "bar";
    this.chartId = `${chartData.type}-${this.props.chartId}`;
    this.datasetBoundaries = this.getAxisLimitsFromDataset(chartData);

    if (this.sliceable) {
      const updatedData = this.getDetailChartConfiguration(chartData);
      chartRuntime.chartJsConfig = updatedData;
    }

    super.createChart(chartRuntime);
    this.hasLinearScale = this.chart?.scales?.x?.type === "linear";
    if (!this.sliceable || !("masterChartConfig" in chartRuntime)) {
      this.isMasterChartAllowed = false;
      return;
    }

    this.masterChart?.destroy();
    const masterCanvas = this.masterChartCanvas();
    if (!masterCanvas) {
      return;
    }
    const masterChartCtx = masterCanvas.getContext("2d")!;

    this.setMasterChartCursor(chartRuntime);
    this.masterChart = new globalThis.Chart(
      masterChartCtx,
      this.getMasterChartConfiguration(chartRuntime["masterChartConfig"] as ChartConfiguration<any>)
    );
    this.resetAxesLimits();
    if (this.chart?.options) {
      this.chart.options.animation = false;
    }
  }

  protected updateChartJs(chartRuntime: ChartJSRuntime) {
    if (!globalThis.Chart) {
      throw new Error("Chart.js library is not loaded");
    }
    const chartData = chartRuntime.chartJsConfig as ChartConfiguration<any>;
    const { min, max } = this.getAxisLimitsFromDataset(chartData);
    if (this.datasetBoundaries.min !== min || this.datasetBoundaries.max !== max) {
      this.store.clearAxisLimits(this.chartId);
      this.datasetBoundaries = { min, max };
    }
    this.isBarChart = chartData?.type === "bar";
    this.chartId = `${chartData.type}-${this.props.chartId}`;

    if (this.sliceable) {
      const updatedData = this.getDetailChartConfiguration(chartData);
      chartRuntime.chartJsConfig = updatedData;
    }

    super.updateChartJs(chartRuntime);
    this.hasLinearScale = this.chart?.scales?.x?.type === "linear";
    if (!this.sliceable || !("masterChartConfig" in chartRuntime)) {
      this.masterChart = undefined;
      this.isMasterChartAllowed = false;
    } else {
      const masterChartConfig = this.getMasterChartConfiguration(
        chartRuntime["masterChartConfig"] as ChartConfiguration<any>
      );
      if (!this.masterChart) {
        const masterCanvas = this.masterChartCanvas();
        if (!masterCanvas) {
          return;
        }
        const masterChartCtx = masterCanvas.getContext("2d")!;
        this.masterChart = new globalThis.Chart(masterChartCtx, masterChartConfig);
      } else {
        this.masterChart.data = masterChartConfig.data;
        this.masterChart.config.options = masterChartConfig.options;
        this.masterChart.update();
      }
      this.setMasterChartCursor(chartRuntime);
    }
    this.resetAxesLimits();
    if (this.chart?.options) {
      this.chart.options.animation = false;
    }
  }

  private resetAxesLimits() {
    if (!this.chart) {
      return;
    }
    const storedLimits = this.store.originalAxisLimits[this.chartId]?.x;
    if (!storedLimits) {
      let scales: { [key: string]: Boundaries } = this.masterChart
        ? this.masterChart.scales
        : this.chart.scales;
      if (!this.hasLinearScale && scales.x) {
        scales = {
          ...scales,
          x: this.adjustBoundaries(scales.x),
        };
      }
      this.store.resetAxisLimits(this.chartId, scales);
      return;
    }
    this.updateTrendingLineAxes();
    this.chart.update();
    if (!this.masterChart) {
      return;
    }
    this.masterChart.update();
  }

  private updateTrendingLineAxes() {
    this.store.updateTrendLineConfiguration(this.chartId);
    const limits = this.store.currentAxesLimits[this.chartId];
    for (const axisId of [TREND_LINE_XAXIS_ID, MOVING_AVERAGE_TREND_LINE_XAXIS_ID]) {
      if (!this.chart?.config?.options?.scales?.[axisId] || !limits?.[axisId]) {
        continue;
      }
      this.chart.config.options.scales[axisId].min = limits[axisId].min;
      this.chart.config.options.scales[axisId].max = limits[axisId].max;
    }
  }

  get upperBound(): number | undefined {
    return this.computePosition(this.store.currentAxesLimits[this.chartId]?.x?.max);
  }

  get lowerBound(): number | undefined {
    return this.computePosition(this.store.currentAxesLimits[this.chartId]?.x?.min);
  }

  private computePosition(value: number | undefined): number | undefined {
    if (value === undefined || !this.masterChart?.scales?.x) {
      return undefined;
    }
    const scale = this.masterChart.scales.x;
    if (this.hasLinearScale) {
      return scale.getPixelForValue(value);
    }
    if (!this.masterChart.chartArea) {
      return undefined;
    }
    const { left, right } = this.masterChart.chartArea;
    const { min, max } = scale;
    const offset = this.axisOffset;
    return left + ((right - left) * (offset + value - min)) / (2 * offset + max - min);
  }

  private computeCoordinate(position: number): number | undefined {
    if (!this.masterChart) {
      return undefined;
    }
    const scale = this.masterChart.scales.x;
    if (this.hasLinearScale) {
      const value = scale.getValueForPixel(position);
      if (value === undefined) {
        return undefined;
      }
      return Math.round(value * 100) / 100;
    }
    const { left, right } = this.masterChart.chartArea;
    const offset = this.axisOffset;
    return (
      scale.min -
      offset +
      ((scale.max + 2 * offset - scale.min) * (position - left)) / (right - left)
    );
  }

  /**
   * Compute min and max from the store, adjusting them if needed for non linear scales.
   * Getting the value from the store, we have to ensure that the values are integers for
   * non linear scales (bar and category). To select a bar in the chart, we have to include
   * the whole bar, which means that for the i-th bar, the selected min should be <= i and
   * the selected max should be >= i, so using the Math.floor and Math.ceil functions is
   * the right way to do it.
   * Sometimes, we can get a minimal value > the maximal value, which arise when the user
   * select a very small area in the master chart, and hasn't selected the middle of a bar
   * or a group of bars (in case of more than one data series).
   * Assuming we have to select the middle of a bar/a groupe of bars, we will reject the
   * coming value afterward. In this case, we do not update the chart because it would lead
   * to an empty chart.
   */

  private getStoredBoundaries(): Boundaries {
    let { min, max } = this.store.currentAxesLimits[this.chartId].x;
    if (!this.hasLinearScale) {
      min = Math.ceil(min);
      max = Math.floor(max);
    }
    return { min, max };
  }

  /**
   * Adjust the min and max values of an axis if needed for non linear scales.
   * Here, after rounding (see docstring of getStoredBoundaries), we adjust the min by
   * substracting the axis offset, and we add it to the max, because when computing from the
   * scale, chartJs use integer values as the limits for non linear scales. If we have a min
   * value of 1, it means we want to start displaying from 0.5, and if we have a max value of
   * 4, it means we want to display until 4.5.
   * Here, we don't have to check if min > max because we are computing from the scale, and
   * chartJs ensures that this won't happen, even after our adjustments.
   */

  private adjustBoundaries({ min, max }: Boundaries): Boundaries {
    if (!this.hasLinearScale) {
      min = Math.ceil(min) - this.axisOffset;
      max = Math.floor(max) + this.axisOffset;
    }
    return { min, max };
  }

  private updateAxisLimits(xMin: number, xMax: number) {
    if (xMin === xMax) {
      return;
    }
    if (!this.chart) {
      return;
    }
    this.store.updateAxisLimits(this.chartId, { min: xMin, max: xMax });
    const { min, max } = this.getStoredBoundaries();
    if (max > min || (this.isBarChart && max === min)) {
      this.chart.config.options!.scales!.x!.min = min;
      this.chart.config.options!.scales!.x!.max = max;
      this.updateTrendingLineAxes();
      this.chart.update();
    }
    this.masterChart?.update();
  }

  onMasterChartPointerDown(ev: PointerEvent) {
    this.removeEventListeners();
    const zoomedEvent = this.zoomStore.getZoomedEvent(
      ev,
      this.masterChartCanvas()?.getBoundingClientRect()
    );
    const position = zoomedEvent.offsetX;
    if (!this.masterChart?.chartArea || !this.chart?.scales?.x) {
      return;
    }
    const { left, right, top, bottom } = this.masterChart.chartArea;
    const upperBound = this.upperBound ?? right;
    const lowerBound = this.lowerBound ?? left;
    if (
      position < left - 5 ||
      position > right + 5 ||
      zoomedEvent.offsetY < top ||
      zoomedEvent.offsetY > bottom
    ) {
      return;
    }
    ev.preventDefault();
    ev.stopPropagation();
    let startingPositionOnChart: number, windowSize: number, startX: number | undefined;
    const startingEventPosition = position;
    if (
      (lowerBound !== left || upperBound !== right) &&
      position > lowerBound + 5 &&
      position < upperBound - 5
    ) {
      startingPositionOnChart = zoomedEvent.offsetX - lowerBound;
      this.mode = "moveInMaster";
      const currentLimits = this.store.currentAxesLimits[this.chartId]?.x;
      windowSize =
        (currentLimits?.max ?? this.chart.scales.x.max) -
        (currentLimits?.min ?? this.chart.scales.x.min);
    } else {
      this.mode = "selectInMaster";
      if (Math.abs(position - lowerBound) < 5) {
        startingPositionOnChart = upperBound;
      } else if (Math.abs(position - upperBound) < 5) {
        startingPositionOnChart = lowerBound;
      } else {
        startingPositionOnChart = clip(position, left, right);
      }
      startX = this.computeCoordinate(startingPositionOnChart);
    }
    const storedMin = this.store.originalAxisLimits[this.chartId].x.min;
    const storedMax = this.store.originalAxisLimits[this.chartId].x.max;

    const computeNewAxisLimits = (position: number) => {
      if (this.mode === "moveInMaster") {
        let min = this.computeCoordinate(position - startingPositionOnChart)!;
        if (min < storedMin) {
          min = storedMin;
        } else if (min > storedMax - windowSize) {
          min = storedMax - windowSize;
        }
        return { min, max: min + windowSize };
      } else if (this.mode === "selectInMaster") {
        const upperBound = clip(position, left, right);
        if (Math.abs(startingPositionOnChart - upperBound) > 5) {
          const endX = this.computeCoordinate(upperBound);
          if (startX === undefined || endX === undefined) {
            return {};
          }
          return {
            min: Math.min(startX, endX),
            max: Math.max(startX, endX),
          };
        }
      }
      return {};
    };

    const onMasterChartDrag = (ev: PointerEvent) => {
      const zoomedEvent = this.zoomStore.getZoomedEvent(
        ev,
        this.masterChartCanvas()?.getBoundingClientRect()
      );
      const position = zoomedEvent.offsetX;
      if (Math.abs(position - startingEventPosition) < 5) {
        return;
      }
      const { min, max } = computeNewAxisLimits(position);
      if (min !== undefined && max !== undefined) {
        this.updateAxisLimits(min, max);
      }
    };

    const onMasterChartPointerUp = (ev: PointerEvent) => {
      this.removeEventListeners();
      let { min, max } = this.chart!.scales.x;
      if (!this.hasLinearScale) {
        if (this.mode === "moveInMaster") {
          min = Math.round(min) - this.axisOffset;
          max = min + windowSize;
        } else {
          ({ min, max } = this.adjustBoundaries({ min, max }));
        }
      }
      this.updateAxisLimits(min, max);
      this.mode = undefined;
      this.updateMasterChartCursor(ev);
    };
    this.removeEventListeners = () => {
      window.removeEventListener("pointermove", onMasterChartDrag, true);
      window.removeEventListener("pointerup", onMasterChartPointerUp, true);
    };

    window.addEventListener("pointermove", onMasterChartDrag, true);
    window.addEventListener("pointerup", onMasterChartPointerUp, true);
  }

  updateMasterChartCursor(ev: PointerEvent) {
    const target = ev.target;
    const { offsetX: x, offsetY: y } = this.zoomStore.getZoomedEvent(
      ev,
      (target as HTMLElement)?.getBoundingClientRect()
    );
    if (!target || !this.isMasterChartAllowed) {
      return;
    }
    if (this.mode === undefined) {
      if (!this.masterChart?.chartArea) {
        target["style"].cursor = "default";
        return;
      }
      const { left, right, top, bottom } = this.masterChart.chartArea;
      const start = this.lowerBound ?? left;
      const end = this.upperBound ?? right;
      if (y < top || y > bottom) {
        target["style"].cursor = "default";
      } else if (Math.abs(start - x) < 5 || Math.abs(end - x) < 5) {
        target["style"].cursor = "ew-resize";
      } else if (start < x && x < end && (start !== left || end !== right)) {
        target["style"].cursor = "grab";
      } else {
        target["style"].cursor = "crosshair";
      }
    }
  }

  onMasterChartMouseLeave(ev: PointerEvent) {
    const target = ev.target;
    if (!target || !this.isMasterChartAllowed) {
      return;
    }
    target["style"].cursor = "default";
  }

  onMasterChartDoubleClick(ev: PointerEvent) {
    this.mode = undefined;
    const zoomedEvent = this.zoomStore.getZoomedEvent(
      ev,
      this.masterChartCanvas()?.getBoundingClientRect()
    );
    const position = zoomedEvent.offsetX;
    if (!this.masterChart?.chartArea || !this.chart?.scales.x) {
      return;
    }
    const { left, right, top, bottom } = this.masterChart.chartArea;
    let upperBound = this.upperBound ?? right;
    let lowerBound = this.lowerBound ?? left;
    if (upperBound < lowerBound) {
      [upperBound, lowerBound] = [lowerBound, upperBound];
    }
    if (
      position < left - 5 ||
      position > right + 5 ||
      zoomedEvent.offsetY < top ||
      zoomedEvent.offsetY > bottom
    ) {
      return;
    }
    ev.preventDefault();
    ev.stopPropagation();
    let { min, max } = this.store.currentAxesLimits[this.chartId]?.x ?? this.chart.scales.x;
    const originalAxisLimits = this.store.originalAxisLimits[this.chartId].x;
    if (!originalAxisLimits) {
      return;
    }
    if (Math.abs(position - lowerBound) < 5) {
      min = originalAxisLimits.min;
    } else if (Math.abs(position - upperBound) < 5) {
      max = originalAxisLimits.max;
    } else if (lowerBound < position && position < upperBound) {
      min = originalAxisLimits.min;
      max = originalAxisLimits.max;
    } else {
      return;
    }
    this.updateAxisLimits(min, max);
  }
}
