/**
 * This source code is derived from code from Meta Platforms, Inc.
 * and affiliates, licensed under the MIT license located in the
 * LICENSE file in the /app/soapbox/features/compose/editor directory.
 */

import { DecoratorNode } from 'lexical';
import * as React from 'react';
import { Suspense } from 'react';

import type {
  DOMConversionMap,
  DOMConversionOutput,
  DOMExportOutput,
  EditorConfig,
  LexicalEditor,
  LexicalNode,
  NodeKey,
  SerializedLexicalNode,
  Spread,
} from 'lexical';

export type Cell = {
  colSpan: number
  json: string
  type: 'normal' | 'header'
  id: string
  width: number | null
};

export type Row = {
  cells: Array<Cell>
  height: null | number
  id: string
};

export type Rows = Array<Row>;

export const cellHTMLCache: Map<string, string> = new Map();
export const cellTextContentCache: Map<string, string> = new Map();

const emptyEditorJSON =
  '{"root":{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1}],"direction":null,"format":"","indent":0,"type":"root","version":1}}';

const plainTextEditorJSON = (text: string) =>
  text === ''
    ? emptyEditorJSON
    : `{"root":{"children":[{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":${text},"type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"paragraph","version":1}],"direction":"ltr","format":"","indent":0,"type":"root","version":1}}`;

const TableComponent = React.lazy(
  // @ts-ignore
  () => import('./table-component'),
);

export function createUID(): string {
  return Math.random()
    .toString(36)
    .replace(/[^a-z]+/g, '')
    .substr(0, 5);
}

function createCell(type: 'normal' | 'header'): Cell {
  return {
    colSpan: 1,
    id: createUID(),
    json: emptyEditorJSON,
    type,
    width: null,
  };
}

export function createRow(): Row {
  return {
    cells: [],
    height: null,
    id: createUID(),
  };
}

export type SerializedTableNode = Spread<
  {
    rows: Rows
  },
  SerializedLexicalNode
>;

export function extractRowsFromHTML(tableElem: HTMLTableElement): Rows {
  const rowElems = tableElem.querySelectorAll('tr');
  const rows: Rows = [];
  for (let y = 0; y < rowElems.length; y++) {
    const rowElem = rowElems[y];
    const cellElems = rowElem.querySelectorAll('td,th');
    if (!cellElems || cellElems.length === 0) {
      continue;
    }
    const cells: Array<Cell> = [];
    for (let x = 0; x < cellElems.length; x++) {
      const cellElem = cellElems[x] as HTMLElement;
      const isHeader = cellElem.nodeName === 'TH';
      const cell = createCell(isHeader ? 'header' : 'normal');
      cell.json = plainTextEditorJSON(
        JSON.stringify(cellElem.innerText.replace(/\n/g, ' ')),
      );
      cells.push(cell);
    }
    const row = createRow();
    row.cells = cells;
    rows.push(row);
  }
  return rows;
}

function convertTableElement(domNode: HTMLElement): null | DOMConversionOutput {
  const rowElems = domNode.querySelectorAll('tr');
  if (!rowElems || rowElems.length === 0) {
    return null;
  }
  const rows: Rows = [];
  for (let y = 0; y < rowElems.length; y++) {
    const rowElem = rowElems[y];
    const cellElems = rowElem.querySelectorAll('td,th');
    if (!cellElems || cellElems.length === 0) {
      continue;
    }
    const cells: Array<Cell> = [];
    for (let x = 0; x < cellElems.length; x++) {
      const cellElem = cellElems[x] as HTMLElement;
      const isHeader = cellElem.nodeName === 'TH';
      const cell = createCell(isHeader ? 'header' : 'normal');
      cell.json = plainTextEditorJSON(
        JSON.stringify(cellElem.innerText.replace(/\n/g, ' ')),
      );
      cells.push(cell);
    }
    const row = createRow();
    row.cells = cells;
    rows.push(row);
  }
  return { node: $createTableNode(rows) };
}

export function exportTableCellsToHTML(
  rows: Rows,
  rect?: {startX: number, endX: number, startY: number, endY: number},
): HTMLElement {
  const table = document.createElement('table');
  const colGroup = document.createElement('colgroup');
  const tBody = document.createElement('tbody');
  const firstRow = rows[0];

  for (
    let x = rect ? rect.startX : 0;
    x < (rect ? rect.endX + 1 : firstRow.cells.length);
    x++
  ) {
    const col = document.createElement('col');
    colGroup.append(col);
  }

  for (
    let y = rect ? rect.startY : 0;
    y < (rect ? rect.endY + 1 : rows.length);
    y++
  ) {
    const row = rows[y];
    const cells = row.cells;
    const rowElem = document.createElement('tr');

    for (
      let x = rect ? rect.startX : 0;
      x < (rect ? rect.endX + 1 : cells.length);
      x++
    ) {
      const cell = cells[x];
      const cellElem = document.createElement(
        cell.type === 'header' ? 'th' : 'td',
      );
      cellElem.innerHTML = cellHTMLCache.get(cell.json) || '';
      rowElem.appendChild(cellElem);
    }
    tBody.appendChild(rowElem);
  }

  table.appendChild(colGroup);
  table.appendChild(tBody);
  return table;
}

export class TableNode extends DecoratorNode<JSX.Element> {

  __rows: Rows;

  static getType(): string {
    return 'tablesheet';
  }

  static clone(node: TableNode): TableNode {
    return new TableNode(Array.from(node.__rows), node.__key);
  }

  static importJSON(serializedNode: SerializedTableNode): TableNode {
    return $createTableNode(serializedNode.rows);
  }

  exportJSON(): SerializedTableNode {
    return {
      rows: this.__rows,
      type: 'tablesheet',
      version: 1,
    };
  }

  static importDOM(): DOMConversionMap | null {
    return {
      table: (_node: Node) => ({
        conversion: convertTableElement,
        priority: 0,
      }),
    };
  }

  exportDOM(): DOMExportOutput {
    return { element: exportTableCellsToHTML(this.__rows) };
  }

  constructor(rows?: Rows, key?: NodeKey) {
    super(key);
    this.__rows = rows || [];
  }

  createDOM(): HTMLElement {
    return document.createElement('div');
  }

  updateDOM(): false {
    return false;
  }

  mergeRows(startX: number, startY: number, mergeRows: Rows): void {
    const self = this.getWritable();
    const rows = self.__rows;
    const endY = Math.min(rows.length, startY + mergeRows.length);
    for (let y = startY; y < endY; y++) {
      const row = rows[y];
      const mergeRow = mergeRows[y - startY];
      const cells = row.cells;
      const cellsClone = Array.from(cells);
      const rowClone = { ...row, cells: cellsClone };
      const mergeCells = mergeRow.cells;
      const endX = Math.min(cells.length, startX + mergeCells.length);
      for (let x = startX; x < endX; x++) {
        const cell = cells[x];
        const mergeCell = mergeCells[x - startX];
        const cellClone = { ...cell, json: mergeCell.json, type: mergeCell.type };
        cellsClone[x] = cellClone;
      }
      rows[y] = rowClone;
    }
  }

  updateCellJSON(x: number, y: number, json: string): void {
    const self = this.getWritable();
    const rows = self.__rows;
    const row = rows[y];
    const cells = row.cells;
    const cell = cells[x];
    const cellsClone = Array.from(cells);
    const cellClone = { ...cell, json };
    const rowClone = { ...row, cells: cellsClone };
    cellsClone[x] = cellClone;
    rows[y] = rowClone;
  }

  updateCellType(x: number, y: number, type: 'header' | 'normal'): void {
    const self = this.getWritable();
    const rows = self.__rows;
    const row = rows[y];
    const cells = row.cells;
    const cell = cells[x];
    const cellsClone = Array.from(cells);
    const cellClone = { ...cell, type };
    const rowClone = { ...row, cells: cellsClone };
    cellsClone[x] = cellClone;
    rows[y] = rowClone;
  }

  insertColumnAt(x: number): void {
    const self = this.getWritable();
    const rows = self.__rows;
    for (let y = 0; y < rows.length; y++) {
      const row = rows[y];
      const cells = row.cells;
      const cellsClone = Array.from(cells);
      const rowClone = { ...row, cells: cellsClone };
      const type = (cells[x] || cells[x - 1]).type;
      cellsClone.splice(x, 0, createCell(type));
      rows[y] = rowClone;
    }
  }

  deleteColumnAt(x: number): void {
    const self = this.getWritable();
    const rows = self.__rows;
    for (let y = 0; y < rows.length; y++) {
      const row = rows[y];
      const cells = row.cells;
      const cellsClone = Array.from(cells);
      const rowClone = { ...row, cells: cellsClone };
      cellsClone.splice(x, 1);
      rows[y] = rowClone;
    }
  }

  addColumns(count: number): void {
    const self = this.getWritable();
    const rows = self.__rows;
    for (let y = 0; y < rows.length; y++) {
      const row = rows[y];
      const cells = row.cells;
      const cellsClone = Array.from(cells);
      const rowClone = { ...row, cells: cellsClone };
      const type = cells[cells.length - 1].type;
      for (let x = 0; x < count; x++) {
        cellsClone.push(createCell(type));
      }
      rows[y] = rowClone;
    }
  }

  insertRowAt(y: number): void {
    const self = this.getWritable();
    const rows = self.__rows;
    const prevRow = rows[y] || rows[y - 1];
    const cellCount = prevRow.cells.length;
    const row = createRow();
    for (let x = 0; x < cellCount; x++) {
      const cell = createCell(prevRow.cells[x].type);
      row.cells.push(cell);
    }
    rows.splice(y, 0, row);
  }

  deleteRowAt(y: number): void {
    const self = this.getWritable();
    const rows = self.__rows;
    rows.splice(y, 1);
  }

  addRows(count: number): void {
    const self = this.getWritable();
    const rows = self.__rows;
    const prevRow = rows[rows.length - 1];
    const cellCount = prevRow.cells.length;

    for (let y = 0; y < count; y++) {
      const row = createRow();
      for (let x = 0; x < cellCount; x++) {
        const cell = createCell(prevRow.cells[x].type);
        row.cells.push(cell);
      }
      rows.push(row);
    }
  }

  updateColumnWidth(x: number, width: number): void {
    const self = this.getWritable();
    const rows = self.__rows;
    for (let y = 0; y < rows.length; y++) {
      const row = rows[y];
      const cells = row.cells;
      const cellsClone = Array.from(cells);
      const rowClone = { ...row, cells: cellsClone };
      cellsClone[x].width = width;
      rows[y] = rowClone;
    }
  }

  decorate(_: LexicalEditor, config: EditorConfig): JSX.Element {
    return (
      <Suspense>
        <TableComponent
          nodeKey={this.__key}
          theme={config.theme}
          rows={this.__rows}
        />
      </Suspense>
    );
  }

  isInline(): false {
    return false;
  }

}

export function $isTableNode(
  node: LexicalNode | null | undefined,
): node is TableNode {
  return node instanceof TableNode;
}

export function $createTableNode(rows: Rows): TableNode {
  return new TableNode(rows);
}

export function $createTableNodeWithDimensions(
  rowCount: number,
  columnCount: number,
  includeHeaders = true,
): TableNode {
  const rows: Rows = [];
  for (let y = 0; y < columnCount; y++) {
    const row: Row = createRow();
    rows.push(row);
    for (let x = 0; x < rowCount; x++) {
      row.cells.push(
        createCell(
          includeHeaders === true && (y === 0 || x === 0) ? 'header' : 'normal',
        ),
      );
    }
  }
  return new TableNode(rows);
}
