import type {
  ChartDataset,
  ChartTypeRegistry,
  LinearScaleOptions,
  ScaleChartOptions,
  Tick,
} from "chart.js";
import { ChartColorScalePluginOptions } from "../../../../components/figures/chart/chartJs/chartjs_colorscale_plugin";
import {
  CHART_AXIS_TITLE_FONT_SIZE,
  CHART_PADDING,
  CHART_PADDING_BOTTOM,
  CHART_PADDING_TOP,
  DEFAULT_CHART_COLOR_SCALE,
  GRAY_300,
} from "../../../../constants";
import { BarChartDefinition } from "../../../../types/chart/bar_chart";
import { BubbleChartDefinition } from "../../../../types/chart/bubble_chart";
import { CalendarChartDefinition } from "../../../../types/chart/calendar_chart";
import {
  AxisDesign,
  AxisType,
  ChartColorScale,
  ChartRuntimeGenerationArgs,
  ChartWithAxisDefinition,
  GenericDefinition,
} from "../../../../types/chart/chart";
import { LegendPosition } from "../../../../types/chart/common_chart";
import { FunnelChartDefinition } from "../../../../types/chart/funnel_chart";
import {
  GeoChartDefinition,
  GeoChartRuntimeGenerationArgs,
} from "../../../../types/chart/geo_chart";
import { LineChartDefinition } from "../../../../types/chart/line_chart";
import { PyramidChartDefinition } from "../../../../types/chart/pyramid_chart";
import { RadarChartDefinition } from "../../../../types/chart/radar_chart";
import { ScatterChartDefinition } from "../../../../types/chart/scatter_chart";
import { WaterfallChartDefinition } from "../../../../types/chart/waterfall_chart";
import { LocaleFormat } from "../../../../types/format";
import { Color, DeepPartial } from "../../../../types/misc";
import { Range } from "../../../../types/range";
import { isNumberResult } from "../../../cells/cell_evaluation";
import { getChartTimeOptions } from "../../../chart_date";
import { COLORSCHEMES, getColorScale, relativeLuminance } from "../../../color";
import { formatValue, humanizeNumber } from "../../../format/format";
import { isDefined, range, removeFalsyAttributes } from "../../../misc";
import {
  MOVING_AVERAGE_TREND_LINE_XAXIS_ID,
  TREND_LINE_XAXIS_ID,
  chartFontColor,
  formatTickValue,
  getDefinedAxis,
  truncateLabel,
} from "../chart_common";

type ChartScales<T extends keyof ChartTypeRegistry = any> = DeepPartial<
  ScaleChartOptions<T>["scales"]
>;

export function getBarChartScales(
  definition: GenericDefinition<BarChartDefinition>,
  args: ChartRuntimeGenerationArgs
): ChartScales<"line" | "bar"> {
  let scales: ChartScales<"line" | "bar"> = {};
  const { trendDataSetsValues: trendDatasets, locale, axisFormats, axisType } = args;
  const options = { stacked: definition.stacked, locale: locale };
  if (definition.horizontal) {
    scales.x = getChartAxis(definition, "bottom", "values", axisType, {
      ...options,
      format: axisFormats?.x,
    });
    scales.y = {
      ...getChartAxis(definition, "left", "labels", "linear", options),
      grid: { display: false },
    };
  } else {
    scales.x = {
      ...getChartAxis(definition, "bottom", "labels", axisType, options),
      grid: { display: false },
    };
    const leftAxisOptions = { ...options, format: axisFormats?.y };
    scales.y = getChartAxis(definition, "left", "values", "linear", leftAxisOptions);
    const rightAxisOptions = { ...options, format: axisFormats?.y1 };
    scales.y1 = getChartAxis(definition, "right", "values", "linear", rightAxisOptions);
  }
  scales = removeFalsyAttributes(scales);

  if (trendDatasets && trendDatasets.length && trendDatasets.some(isDefined)) {
    /* We add a second x axis here to draw the trend lines, with the labels length being
     * set so that the second axis points match the classical x axis
     */
    const maxLength = Math.max(...trendDatasets.map((trendDataset) => trendDataset?.length || 0));
    scales[TREND_LINE_XAXIS_ID] = {
      ...(scales!.x as any),
      labels: Array(maxLength).fill(""),
      offset: false,
      display: false,
    };
    scales[MOVING_AVERAGE_TREND_LINE_XAXIS_ID] = {
      ...(scales!.x as any),
      offset: true,
      display: false,
    };
  }

  return scales;
}

export function getCalendarChartScales(
  definition: GenericDefinition<BarChartDefinition>,
  datasets: ChartDataset[]
): ChartScales<"calendar"> {
  const yLabels = datasets.map((dataset) => dataset.label || "");
  const fontColor = chartFontColor(definition.background);
  return {
    y: {
      title: getChartAxisTitleRuntime(definition.axesDesign?.y),
      stacked: true,
      min: 0,
      max: yLabels.length,
      ticks: {
        // Here we have to use a step of 0.5 and skip every even label to have the labels centered
        // with the bars
        stepSize: 0.5,
        color: fontColor,
        callback: function (label, index, labels) {
          if (index % 2 === 0) {
            return undefined;
          }
          return yLabels[Math.floor((index - 1) / 2)];
        },
      },
      grid: {
        display: false,
      },
      border: { display: false },
    },
    x: {
      title: getChartAxisTitleRuntime(definition.axesDesign?.x),
      stacked: true,
      grid: {
        display: false,
      },
      position: "top",
      ticks: {
        color: fontColor,
      },
      border: { display: false },
    },
  };
}

export function getCalendarColorScale(
  definition: CalendarChartDefinition,
  args: ChartRuntimeGenerationArgs
): ChartColorScalePluginOptions | undefined {
  const { dataSetsValues } = args;
  if (!dataSetsValues.length || definition.legendPosition === "none") {
    return undefined;
  }
  const allValues = dataSetsValues
    .flatMap((ds) => ds.data)
    .filter(isNumberResult)
    .map((cell) => cell.value);
  const minValue = Math.min(...allValues);
  const maxValue = Math.max(...allValues);
  let colorScale: Color[] = [];
  if (typeof definition.colorScale === "object") {
    colorScale = [
      definition.colorScale.minColor,
      definition.colorScale.midColor,
      definition.colorScale.maxColor,
    ].filter(isDefined);
  } else {
    colorScale = [...COLORSCHEMES[definition.colorScale ?? "oranges"]];
  }
  return {
    position: definition.legendPosition === "right" ? "right" : "left",
    colorScale,
    fontColor: chartFontColor(definition.background),
    minValue,
    maxValue,
    locale: args.locale,
  };
}

export function getLineChartScales(
  definition: GenericDefinition<LineChartDefinition>,
  args: ChartRuntimeGenerationArgs
): ChartScales<"line"> {
  const { locale, axisType, trendDataSetsValues: trendDatasets, labels, axisFormats } = args;
  const labelFormat = axisFormats?.x;
  const stacked = definition.stacked;

  let scales: ChartScales<"line"> = {
    x: getChartAxis(definition, "bottom", "labels", axisType, { locale }),
    y: getChartAxis(definition, "left", "values", "linear", {
      locale,
      stacked,
      format: axisFormats?.y,
    }),
    y1: getChartAxis(definition, "right", "values", "linear", {
      locale,
      stacked,
      format: axisFormats?.y1,
    }),
  };
  scales = removeFalsyAttributes(scales);

  if (axisType === "time" && labels && labelFormat) {
    const axis = {
      type: "time",
      time: getChartTimeOptions(labels, labelFormat, locale),
      min: formatValue(definition.axesDesign?.x?.min ?? "", { format: labelFormat, locale }),
      max: formatValue(definition.axesDesign?.x?.max ?? "", {
        format: labelFormat,
        locale,
      }),
    };
    Object.assign(scales!.x!, axis);
    scales!.x!.ticks!.maxTicksLimit = 15;
    delete scales?.x?.ticks?.callback;
  } else if (axisType === "linear") {
    scales!.x!.type = "linear";
    scales!.x!.ticks!.callback = definition.humanize
      ? (value) => humanizeNumber({ value, format: labelFormat }, locale)
      : (value) => formatValue(value, { format: labelFormat, locale });
  }

  if (trendDatasets && trendDatasets.length && trendDatasets.some(isDefined)) {
    /* We add a second x axis here to draw the trend lines, with the labels length being
     * set so that the second axis points match the classical x axis
     */
    scales[TREND_LINE_XAXIS_ID] = {
      ...(scales.x as any),
      display: false,
    };
    scales[MOVING_AVERAGE_TREND_LINE_XAXIS_ID] = {
      ...(scales.x as any),
      display: false,
    };
    if (axisType === "category" || axisType === "time") {
      /* We add a second x axis here to draw the trend lines, with the labels length being
       * set so that the second axis points match the classical x axis
       */
      const maxLength = Math.max(...trendDatasets.map((trendDataset) => trendDataset?.length || 0));
      scales[TREND_LINE_XAXIS_ID]!["type"] = "category";
      scales[TREND_LINE_XAXIS_ID]!["labels"] = range(0, maxLength).map((x) => x.toString());
      scales[TREND_LINE_XAXIS_ID]!["offset"] = false;
      scales[MOVING_AVERAGE_TREND_LINE_XAXIS_ID]!["type"] = "category";
      scales[MOVING_AVERAGE_TREND_LINE_XAXIS_ID]!["offset"] = false;
    }
  }

  return scales;
}

function getGridColor(background?: string) {
  return relativeLuminance(background || "#ffffff") > 0.5 ? "#cccccc" : "#999999";
}

function getMinorGridColor(background?: string) {
  return relativeLuminance(background || "#ffffff") > 0.5 ? "#e6e6e6" : "#333333";
}

export function getScatterChartScales(
  definition: GenericDefinition<ScatterChartDefinition>,
  args: ChartRuntimeGenerationArgs
): ChartScales<"line"> {
  const lineScales = getLineChartScales(definition, args);
  const xScale = lineScales?.x ?? {};
  const axisDesign = definition.axesDesign?.x;
  const scatterGridDisplay = axisDesign?.gridLines !== "none" && axisDesign?.gridLines !== "minor";
  return {
    ...lineScales,
    x: {
      ...xScale,
      grid: {
        ...xScale.grid,
        color: getGridColor(definition.background),
        display: scatterGridDisplay,
      },
    },
  };
}

export function getBubbleChartScales(
  definition: BubbleChartDefinition<string | Range>,
  args: ChartRuntimeGenerationArgs
): DeepPartial<ScaleChartOptions<"line">["scales"]> {
  const scatterScales = getScatterChartScales(definition, args);
  const position = definition.verticalAxisPosition || "left";
  return {
    ...scatterScales,
    y: {
      ...scatterScales.y,
      position,
    },
  };
}

export function getWaterfallChartScales(
  definition: WaterfallChartDefinition,
  args: ChartRuntimeGenerationArgs
): ChartScales<"bar"> {
  const { locale, axisFormats, axisType } = args;
  const format = axisFormats?.y || axisFormats?.y1;
  const yDesign = definition.axesDesign?.y;
  const scales: ChartScales<"bar"> = {
    x: {
      ...getChartAxis(definition, "bottom", "labels", axisType, { locale }),
      grid: { display: false },
    },
    y: {
      // TODO FIXME: we should probably remove definition.verticalAxisPosition and put everything inside axesDesign/datasets
      // like the other charts. We cannot use helpers like `getChartAxis` here because they look into definition.dataSet
      // which have plain wrong information, eg. the yAxisId of the dataset being "y" when the data is actually displayed
      // on the axis to the right.
      position: definition.verticalAxisPosition,
      ticks: {
        color: chartFontColor(definition.background),
        callback: formatTickValue({ locale, format }, definition.humanize),
      },
      title: getChartAxisTitleRuntime(definition.axesDesign?.y),
      min: yDesign?.min,
      max: yDesign?.max,
      grid: {
        lineWidth: (context) => (context.tick.value === 0 ? 2 : 1),
        display: yDesign?.gridLines !== "none" && yDesign?.gridLines !== "minor",
        color: getGridColor(definition.background),
      },
    },
  };
  if (yDesign?.gridLines === "minor" || yDesign?.gridLines === "both") {
    scales.y!.grid!["minor"] = {
      display: true,
      color: getMinorGridColor(definition.background),
    };
  }

  return scales;
}

export function getPyramidChartScales(
  definition: PyramidChartDefinition,
  args: ChartRuntimeGenerationArgs
): ChartScales<"bar"> {
  const { dataSetsValues } = args;
  const scales = getBarChartScales(definition, args);
  const scalesXCallback = scales!.x!.ticks!.callback as (value: number) => string;
  scales!.x!.ticks!.callback = (value: number) => scalesXCallback(Math.abs(value));

  const maxValue = Math.max(
    ...dataSetsValues.map((dataSet) =>
      Math.max(...dataSet.data.filter(isNumberResult).map((x) => Math.abs(x.value)))
    )
  );
  scales!.x!.suggestedMin = -maxValue;
  scales!.x!.suggestedMax = maxValue;

  return scales;
}

export function getRadarChartScales(
  definition: GenericDefinition<RadarChartDefinition>,
  args: ChartRuntimeGenerationArgs
): ChartScales<"radar"> {
  const { locale, axisFormats, dataSetsValues } = args;
  const minValue = Math.min(
    ...dataSetsValues.map((ds) =>
      Math.min(...ds.data.filter(isNumberResult).map((x) => x.value as number))
    )
  );
  return {
    r: {
      beginAtZero: true,
      ticks: {
        callback: formatTickValue({ format: axisFormats?.r, locale }, definition.humanize),
        backdropColor: definition.background || "#FFFFFF",
      },
      pointLabels: {
        color: chartFontColor(definition.background),
        callback: (label: string) => truncateLabel(label),
      },
      suggestedMin: minValue < 0 ? minValue - 1 : 0,
    },
  };
}

export function getGeoChartScales(
  definition: GeoChartDefinition,
  args: GeoChartRuntimeGenerationArgs
): ChartScales<"choropleth"> {
  const { locale, axisFormats, availableRegions } = args;

  const geoLegendPosition = legendPositionToGeoLegendPosition(definition.legendPosition);
  const region = definition.region
    ? availableRegions.find((r) => r.id === definition.region)
    : availableRegions[0];

  const format = axisFormats?.y || axisFormats?.y1;
  return {
    projection: {
      projection: region?.defaultProjection || "mercator",
      axis: "x" as const,
    },
    color: {
      axis: "x",
      display: definition.legendPosition !== "none",
      border: { color: GRAY_300 },
      grid: { color: GRAY_300 },
      ticks: {
        color: chartFontColor(definition.background),
        callback: formatTickValue({ locale, format }, definition.humanize),
      },
      legend: {
        position: geoLegendPosition,
        align: geoLegendPosition.includes("right") ? "left" : "right",
        margin: getLegendMargin(definition),
      },
      interpolate: getRuntimeColorScale(definition.colorScale ?? DEFAULT_CHART_COLOR_SCALE),
      missing: definition.missingValueColor || "#ffffff",
    },
  };
}

export function getFunnelChartScales(
  definition: FunnelChartDefinition,
  args: ChartRuntimeGenerationArgs
): ChartScales<"funnel"> {
  const dataSet = args.dataSetsValues[0];
  return {
    x: {
      display: false,
    },
    y: {
      grid: { offset: false }, // bar charts grid is offset by default
      ticks: {
        callback: function (tickValue: number) {
          return truncateLabel(this.getLabelForValue(tickValue));
        },
      },
      border: { display: false },
    },
    percentages: {
      position: "right",
      border: { display: false },
      ticks: {
        callback: function (tickValue, index, ticks) {
          const valueCell = dataSet.data?.[index];
          const baseValueCell = dataSet.data?.[0];
          if (
            !baseValueCell?.value ||
            valueCell?.value === null ||
            !isNumberResult(valueCell) ||
            !isNumberResult(baseValueCell)
          ) {
            return "";
          }
          return formatValue(valueCell.value / baseValueCell.value, {
            format: "0%",
            locale: args.locale,
          });
        },
      },
      grid: { display: false },
    },
  };
}

function getChartAxisTitleRuntime(design?: AxisDesign):
  | {
      display: boolean;
      text: string;
      color?: string;
      font: {
        style: "italic" | "normal";
        weight: "bold" | "normal";
        size: number;
      };
      align: "start" | "center" | "end";
    }
  | undefined {
  if (design?.title?.text) {
    const { text, color, align, italic, bold } = design.title;
    return {
      display: true,
      text,
      color,
      font: {
        style: italic ? "italic" : "normal",
        weight: bold ? "bold" : "normal",
        size: design.title.fontSize ?? CHART_AXIS_TITLE_FONT_SIZE,
      },
      align: align === "left" ? "start" : align === "right" ? "end" : "center",
    };
  }
  return;
}

function getChartAxis(
  definition: Partial<ChartWithAxisDefinition>,
  position: "left" | "right" | "bottom",
  type: "values" | "labels",
  axisType: AxisType | undefined,
  options: LocaleFormat & { stacked?: boolean }
): DeepPartial<LinearScaleOptions> | undefined {
  const { useLeftAxis, useRightAxis } = getDefinedAxis(definition);
  if ((position === "left" && !useLeftAxis) || (position === "right" && !useRightAxis)) {
    return undefined;
  }

  const fontColor = chartFontColor(definition.background);
  let design: AxisDesign | undefined;
  if (position === "bottom") {
    design = definition.axesDesign?.x;
  } else if (position === "left") {
    design = definition.axesDesign?.y;
  } else {
    design = definition.axesDesign?.y1;
  }

  if (type === "values") {
    const displayGridLines = !(position === "right" && useLeftAxis);
    const majorGridEnabled = design?.gridLines
      ? design?.gridLines === "major" || design?.gridLines === "both"
      : displayGridLines;
    const isCategoricalAxis = axisType === "category" || axisType === undefined;
    const minorGridEnabled =
      !isCategoricalAxis && (design?.gridLines === "minor" || design?.gridLines === "both");
    const callback = formatTickValue(options, definition.humanize);

    const scale: DeepPartial<LinearScaleOptions> = {
      position: position,
      title: getChartAxisTitleRuntime(design),
      beginAtZero: !design?.min,
      stacked: options?.stacked,
      ticks: {
        color: fontColor,
        callback,
      },
      min: design?.min,
      max: design?.max,
      grid: {
        display: majorGridEnabled,
        color: getGridColor(definition.background),
      },
    };
    if (minorGridEnabled) {
      scale.grid!["minor"] = {
        display: true,
        color: getMinorGridColor(definition.background),
      };
    }
    return scale;
  } else {
    const scale: DeepPartial<LinearScaleOptions> = {
      ticks: {
        padding: 5,
        color: fontColor,
        callback: function (tickValue: number, index: number, ticks: Tick[]) {
          // Category axis callback's internal tick value is the index of the label
          // https://www.chartjs.org/docs/latest/axes/labelling.html#creating-custom-tick-formats
          return truncateLabel(this.getLabelForValue(tickValue));
        },
      },
      min: design?.min,
      max: design?.max,
      grid: {
        display: design?.gridLines === "major" || design?.gridLines === "both",
        color: getGridColor(definition.background),
      },
      stacked: options?.stacked,
      title: getChartAxisTitleRuntime(design),
    };
    if (design?.gridLines === "minor" || design?.gridLines === "both") {
      scale.grid!["minor"] = {
        display: true,
        color: getMinorGridColor(definition.background),
      };
    }
    return scale;
  }
}

export function getRuntimeColorScale(colorScale: ChartColorScale, minValue = 0, maxValue = 1) {
  if (minValue === maxValue) {
    const color = colorScale.midColor ?? colorScale.minColor;
    return (_: number) => color;
  }
  const scaleColors = [{ value: minValue, color: colorScale.minColor }];
  if (colorScale.midColor) {
    scaleColors.push({ value: (minValue + maxValue) / 2, color: colorScale.midColor });
  }
  scaleColors.push({ value: maxValue, color: colorScale.maxColor });
  return getColorScale(scaleColors);
}

function getLegendMargin(definition: GeoChartDefinition) {
  switch (definition.legendPosition) {
    case "top":
    case "right":
      const hasTitle = !!definition.title.text;
      const topMargin = hasTitle ? CHART_PADDING_TOP + 30 : CHART_PADDING_TOP;
      return { top: topMargin, left: CHART_PADDING, right: CHART_PADDING };
    case "bottom":
    case "left":
    case "none":
      return { left: CHART_PADDING, right: CHART_PADDING, bottom: CHART_PADDING_BOTTOM };
  }
}

function legendPositionToGeoLegendPosition(position: LegendPosition) {
  switch (position) {
    case "top":
      return "top-left";
    case "right":
      return "top-right";
    case "bottom":
      return "bottom-right";
    case "left":
      return "bottom-left";
    case "none":
      return "bottom-left";
  }
}
