import { isColorValid, toHex } from "../../helpers/color";
import {
  AxesDesign,
  ExcelChartDataset,
  ExcelChartDefinition,
  ExcelChartTrendConfiguration,
  ExcelTrendlineType,
  TitleDesign,
} from "../../types/chart/chart";
import { XLSX_CHART_TYPES, XLSXChartType } from "../../types/xlsx";
import { applyLumModOff } from "../conversion/color_conversion";
import {
  CHART_TYPE_CONVERSION_MAP,
  DRAWING_LEGEND_POSITION_CONVERSION_MAP,
} from "../conversion/conversion_maps";
import { removeTagEscapedNamespaces } from "../helpers/xml_helpers";
import { XlsxBaseExtractor } from "./base_extractor";

/**
 * Default colorMap used when no <a:clrMap> is defined.
 * Maps semantic alias names to actual theme color slot names (a:clrScheme children).
 * These aliases are defined in a clrMap element that can be redefined in the xlsx file.
 */
const DEFAULT_COLOR_MAP: Record<string, string> = {
  bg1: "lt1",
  tx1: "dk1",
  bg2: "lt2",
  tx2: "dk2",
};

export class XlsxChartExtractor extends XlsxBaseExtractor {
  extractChart(): ExcelChartDefinition | undefined {
    return this.mapOnElements(
      { parent: this.rootFile.file.xml, query: "c:chartSpace" },
      (rootChartElement): ExcelChartDefinition => {
        const chartType = this.getChartType(rootChartElement);
        if (!CHART_TYPE_CONVERSION_MAP[chartType]) {
          throw new Error(`Unsupported chart type ${chartType}`);
        }
        if (CHART_TYPE_CONVERSION_MAP[chartType] === "combo") {
          return this.extractComboChart(rootChartElement);
        }

        // Title can be separated into multiple xml elements (for styling and such), we only import the text
        const chartTitle = this.mapOnElements(
          { parent: rootChartElement, query: "c:chart > c:title a:t" },
          (textElement): string => {
            return textElement.textContent || "";
          }
        ).join("");
        const chartTitleStyle = this.extractTitleStyle(
          rootChartElement,
          "c:chart > c:title a:p a:pPr a:defRPr"
        );
        const barChartGrouping = this.extractChildAttr(rootChartElement, "c:grouping", "val", {
          default: "clustered",
        }).asString();

        const chartDirection = this.extractChildAttr(rootChartElement, "c:barDir", "val", {
          default: "col",
        }).asString();
        const chartHoleSize = this.extractChildAttr(rootChartElement, "c:holeSize", "val", {
          default: "0",
        }).asNum();
        const showValueNodes = this.querySelectorAll(rootChartElement, "c:chart c:showVal");
        const showValues = [...showValueNodes].some(
          (el) => el.attributes.getNamedItem("val")?.value === "1"
        );

        const slicesColors: string[] = [];
        if (CHART_TYPE_CONVERSION_MAP[chartType] === "pie") {
          // Only the first series is read as in pie charts the corresponding slices across series
          // share the same color in o-spreadsheet.
          const ser = this.querySelector(rootChartElement, "c:ser");
          if (ser) {
            const dPtNodes = Array.from(this.querySelectorAll(ser, "c:dPt"));
            const colorsByIdx: Record<number, string> = {};
            for (const dPt of dPtNodes) {
              const idx = this.extractChildAttr(dPt, "c:idx", "val", { required: true }).asNum();
              const val = this.extractDrawingFillColor(dPt);
              if (typeof idx === "number" && val) {
                colorsByIdx[idx] = val;
              }
            }
            if (Object.keys(colorsByIdx).length) {
              const maxIdx = Math.max(...Object.keys(colorsByIdx).map(Number));
              for (let i = 0; i <= maxIdx; ++i) {
                slicesColors.push(colorsByIdx[i] || "");
              }
            }
          }
        }
        return {
          title: { text: chartTitle, ...chartTitleStyle },
          type: CHART_TYPE_CONVERSION_MAP[chartType]!,
          axesDesign: this.extractAxesDesign(rootChartElement),
          dataSets: this.extractChartDatasets(
            this.querySelectorAll(rootChartElement, `c:${chartType}`)!,
            chartType
          ),
          labelRange: this.extractLabelRange(chartType, rootChartElement),
          backgroundColor: this.extractChildAttr(
            rootChartElement,
            "c:chartSpace > c:spPr a:srgbClr",
            "val",
            {
              default: "ffffff",
            }
          ).asString()!,
          legendPosition:
            DRAWING_LEGEND_POSITION_CONVERSION_MAP[
              this.extractChildAttr(rootChartElement, "c:legendPos", "val", {
                default: "none",
              }).asString()
            ],
          stacked: barChartGrouping === "stacked",
          fontColor: "000000",
          horizontal: chartDirection === "bar",
          isDoughnut: chartHoleSize > 0,
          pieHolePercentage: chartHoleSize,
          showValues,
          slicesColors: slicesColors.length ? slicesColors : undefined,
        };
      }
    )[0];
  }

  private extractLabelRange(chartType: XLSXChartType, rootChartElement: Element) {
    if (chartType === "scatterChart") {
      return (
        this.extractChildTextContent(rootChartElement, `c:ser c:strRef c:f`) ||
        this.extractChildTextContent(rootChartElement, `c:ser c:numRef c:f`)
      );
    }
    return this.extractChildTextContent(rootChartElement, `c:ser c:cat c:f`);
  }

  private extractComboChart(chartElement: Element): ExcelChartDefinition {
    // Title can be separated into multiple xml elements (for styling and such), we only import the text
    const chartTitle = this.mapOnElements(
      { parent: chartElement, query: "c:title a:t" },
      (textElement): string => {
        return textElement.textContent || "";
      }
    ).join("");
    const chartTitleStyle = this.extractTitleStyle(
      chartElement,
      "c:chart > c:title a:p a:pPr a:defRPr"
    );
    const barChartGrouping = this.extractChildAttr(chartElement, "c:grouping", "val", {
      default: "clustered",
    }).asString();
    const showValueNodes = this.querySelectorAll(chartElement, "c:chart c:showVal");
    const showValues = [...showValueNodes].some(
      (el) => el.attributes.getNamedItem("val")?.value === "1"
    );
    return {
      title: { text: chartTitle, ...chartTitleStyle },
      type: "combo",
      axesDesign: this.extractAxesDesign(chartElement),
      dataSets: [
        ...this.extractChartDatasets(
          this.querySelectorAll(chartElement, `c:barChart`),
          "comboChart"
        ),
        ...this.extractChartDatasets(
          this.querySelectorAll(chartElement, `c:lineChart`)!,
          "comboChart"
        ),
      ],
      labelRange: this.extractChildTextContent(chartElement, "c:ser c:cat c:f"),
      backgroundColor: this.extractChildAttr(
        chartElement,
        "c:chartSpace > c:spPr a:srgbClr",
        "val",
        {
          default: "ffffff",
        }
      ).asString()!,
      legendPosition:
        DRAWING_LEGEND_POSITION_CONVERSION_MAP[
          this.extractChildAttr(chartElement, "c:legendPos", "val", {
            default: "none",
          }).asString()
        ],
      stacked: barChartGrouping === "stacked",
      fontColor: "000000",
      showValues,
    };
  }

  /**
   * Extracts the style properties (bold, italic, font size and color) for a chart or axis title from a `a:defRPr` element in the XML.
   * defRPr stands for "default run properties" and is the element that contains the default style properties for a text run in DrawingML.
   * For more information on the `a:defRPr` element and the style properties it can contain, see §21.1.2.3.2 defRPr (Default Text Run Properties).
   * The `titleQuery` parameter is the query to select the `a:defRPr` element that contains the style properties.
   * Returns an object with the extracted style properties. If a property is not defined in the XML, it will be `undefined` in the returned object.
   */
  private extractTitleStyle(
    element: Element,
    titleQuery: string
  ): Pick<TitleDesign, "bold" | "italic" | "fontSize" | "color"> {
    const defRPr = this.querySelector(element, titleQuery);
    if (!defRPr) {
      return {};
    }
    const bAttr = defRPr.getAttribute("b");
    const bold = bAttr === "1" || bAttr === "true" ? true : undefined;
    const iAttr = defRPr.getAttribute("i");
    const italic = iAttr === "1" || iAttr === "true" ? true : undefined;
    const szAttr = defRPr.getAttribute("sz");
    const fontSize = szAttr ? Math.round(parseInt(szAttr) / 100) : undefined;
    const color = this.extractDrawingFillColor(defRPr);
    return { bold, italic, fontSize, color };
  }

  private extractDrawingFillColor(element: Element): string | undefined {
    const srgbClr = this.querySelector(element, "a:solidFill a:srgbClr");
    if (srgbClr) {
      const val = srgbClr.getAttribute("val");
      return val && isColorValid(val) ? toHex(val) : undefined;
    }
    const schemeClr = this.querySelector(element, "a:solidFill a:schemeClr");
    if (schemeClr) {
      return this.resolveSchemeColor(schemeClr);
    }
    return undefined;
  }

  /**
   * Resolve a DrawingML scheme color name (e.g. "accent1", "dk1") to its hex
   * RGB value by looking it up in the theme's `a:clrScheme` element and apply any
   * lumMod or lumOff modifications if specified in the original `a:schemeClr` element.
   * Returns `undefined` if the theme is unavailable or the color cannot be found.
   */
  private resolveSchemeColor(schemeClrEl: Element): string | undefined {
    const rawName = schemeClrEl.getAttribute("val");
    if (!rawName) {
      return undefined;
    }

    const themeFile = this.xlsxFileStructure.theme;
    if (!themeFile) {
      return undefined;
    }

    const schemeName = DEFAULT_COLOR_MAP[rawName] ?? rawName;
    const schemeEl = this.querySelector(themeFile.file.xml, `a:clrScheme a:${schemeName}`);
    if (!schemeEl) {
      return undefined;
    }

    let color: string | undefined;
    const srgbClr = this.querySelector(schemeEl, "a:srgbClr");
    if (srgbClr) {
      const val = srgbClr.getAttribute("val");
      color = val && isColorValid(val) ? toHex(val) : undefined;
    } else {
      const sysClr = this.querySelector(schemeEl, "a:sysClr");
      if (sysClr) {
        const lastClr = sysClr.getAttribute("lastClr");
        color = lastClr && isColorValid(lastClr) ? toHex(lastClr) : undefined;
      }
    }

    if (!color) {
      return undefined;
    }

    const lumModVal = this.querySelector(schemeClrEl, "a:lumMod")?.getAttribute("val");
    const lumOffVal = this.querySelector(schemeClrEl, "a:lumOff")?.getAttribute("val");
    if (lumModVal !== undefined || lumOffVal !== undefined) {
      const lumMod = lumModVal ? parseInt(lumModVal) / 100000 : 1;
      const lumOff = lumOffVal ? parseInt(lumOffVal) / 100000 : 0;
      color = applyLumModOff(color, lumMod, lumOff);
    }

    return color;
  }

  private extractAxisTitleDesign(axElement: Element | null): TitleDesign | undefined {
    if (axElement === null) {
      return undefined;
    }
    const titleText = this.mapOnElements(
      { parent: axElement, query: "c:title a:t" },
      (el) => el.textContent || ""
    ).join("");
    if (!titleText) {
      return undefined;
    }
    const style = this.extractTitleStyle(axElement, "c:title a:p a:pPr a:defRPr");
    return { text: titleText, ...style };
  }

  private extractAxesDesign(chartElement: Element): AxesDesign | undefined {
    const catAx = this.querySelector(chartElement, "c:catAx");
    const valAx = this.querySelector(chartElement, "c:valAx");
    const axPos = catAx ? this.extractChildAttr(catAx, "c:axPos", "val")?.asString() : undefined;
    const isHorizontalChart = axPos === "l" || axPos === "r";
    const xAx = isHorizontalChart ? valAx : catAx;
    const yAx = isHorizontalChart ? catAx : valAx;
    const xTitle = this.extractAxisTitleDesign(xAx);
    const yTitle = this.extractAxisTitleDesign(yAx);
    if (!xTitle && !yTitle) {
      return undefined;
    }
    const axesDesign: AxesDesign = {};
    if (xTitle) {
      axesDesign.x = { title: xTitle };
    }
    if (yTitle) {
      axesDesign.y = { title: yTitle };
    }
    return axesDesign;
  }

  private extractChartDatasets(
    chartElements: NodeListOf<Element>,
    chartType: XLSXChartType
  ): ExcelChartDataset[] {
    return Array.from(chartElements)
      .map((element) => {
        if (chartType === "scatterChart") {
          return this.extractScatterChartDatasets(element);
        }
        return this.mapOnElements(
          { parent: element, query: "c:ser" },
          (chartDataElement): ExcelChartDataset => {
            let label = {};
            const reference = this.extractChildTextContent(chartDataElement, "c:tx c:f");
            if (reference) {
              label = { reference };
            } else {
              const text = this.extractChildTextContent(chartDataElement, "c:tx c:v");
              if (text) {
                label = { text };
              }
            }
            const colorElements = this.querySelectorAll(chartDataElement, "c:spPr");
            const backgroundColor = colorElements.length
              ? this.extractDrawingFillColor(colorElements[0])
              : undefined;
            return {
              label,
              range: this.extractChildTextContent(chartDataElement, "c:val c:f", {
                required: true,
              })!,
              backgroundColor,
              trend: this.extractChartTrendline(chartDataElement),
            };
          }
        );
      })
      .flat();
  }

  private extractChartTrendline(
    chartDataElement: Element
  ): ExcelChartTrendConfiguration | undefined {
    const trendlineElement = this.querySelector(chartDataElement, "c:trendline");
    if (!trendlineElement) {
      return undefined;
    }
    const trendlineType = this.extractChildAttr(trendlineElement, "c:trendlineType", "val");
    const color = this.extractDrawingFillColor(trendlineElement);
    return {
      type: trendlineType ? (trendlineType.asString() as ExcelTrendlineType) : undefined,
      order: this.extractChildAttr(trendlineElement, "c:order", "val")?.asNum(),
      window: this.extractChildAttr(trendlineElement, "c:period", "val")?.asNum(),
      color,
    };
  }

  private extractScatterChartDatasets(chartElement: Element): ExcelChartDataset[] {
    return this.mapOnElements(
      { parent: chartElement, query: "c:ser" },
      (chartDataElement): ExcelChartDataset => {
        let label = {};
        const colorElements = this.querySelectorAll(chartDataElement, "c:spPr");
        const backgroundColor = colorElements.length
          ? this.extractDrawingFillColor(colorElements[0])
          : undefined;
        const reference = this.extractChildTextContent(chartDataElement, "c:tx c:f");
        if (reference) {
          label = { reference };
        } else {
          const text = this.extractChildTextContent(chartDataElement, "c:tx c:v");
          if (text) {
            label = { text };
          }
        }
        return {
          label,
          range: this.extractChildTextContent(chartDataElement, "c:yVal c:f", { required: true })!,
          trend: this.extractChartTrendline(chartDataElement),
          backgroundColor,
        };
      }
    );
  }

  /**
   * The chart type in the XML isn't explicitly defined, but there is an XML element that define the
   * chart, and this element tag name tells us which type of chart it is. We just need to find this XML element.
   */
  private getChartType(chartElement: Element): XLSXChartType {
    const plotAreaElement = this.querySelector(chartElement, "c:plotArea");
    if (!plotAreaElement) {
      throw new Error("Missing plot area in the chart definition.");
    }
    let globalTag: XLSXChartType | undefined = undefined;
    for (const child of plotAreaElement.children) {
      const tag = removeTagEscapedNamespaces(child.tagName);
      if (XLSX_CHART_TYPES.some((chartType) => chartType === tag)) {
        if (!globalTag) {
          globalTag = tag as XLSXChartType;
        } else if (globalTag !== tag) {
          globalTag = "comboChart";
        }
      }
    }
    if (globalTag) {
      return globalTag;
    }
    throw new Error("Unknown chart type");
  }
}
