// Utilities used for copy/paste handling.
//
// This module handles pasting cell content into tables, or pasting
// anything into a cell selection, as replacing a block of cells with
// the content of the selection. When pasting cells into a cell, that
// involves placing the block of pasted content so that its top left
// aligns with the selection cell, optionally extending the table to
// the right or bottom to make sure it is large enough. Pasting into a
// cell selection is different, here the cells in the selection are
// clipped to the selection's rectangle, optionally repeating the
// pasted cells when they are smaller than the selection.

import { Slice, Fragment } from 'prosemirror-model';
import { Transform } from 'prosemirror-transform';

import { setAttr, removeColSpan } from './util';
import { TableMap } from './tablemap';
import { CellSelection } from './cellselection';
import { tableNodeTypes } from './schema';

// Utilities to help with copying and pasting table cells

// : (Slice) → ?{width: number, height: number, rows: [Fragment]}
// Get a rectangular area of cells from a slice, or null if the outer
// nodes of the slice aren't table cells or rows.
export function pastedCells(slice) {
  if (!slice.size) return null;
  let { content, openStart, openEnd } = slice;
  while (
    content.childCount == 1 &&
    ((openStart > 0 && openEnd > 0) ||
      content.firstChild.type.spec.tableRole == 'table')
  ) {
    openStart--;
    openEnd--;
    content = content.firstChild.content;
  }
  let first = content.firstChild,
    role = first.type.spec.tableRole;
  let schema = first.type.schema,
    rows = [];
  if (role == 'row') {
    for (let i = 0; i < content.childCount; i++) {
      let cells = content.child(i).content;
      let left = i ? 0 : Math.max(0, openStart - 1);
      let right = i < content.childCount - 1 ? 0 : Math.max(0, openEnd - 1);
      if (left || right)
        cells = fitSlice(
          tableNodeTypes(schema).row,
          new Slice(cells, left, right),
        ).content;
      rows.push(cells);
    }
  } else if (role == 'cell' || role == 'header_cell') {
    rows.push(
      openStart || openEnd
        ? fitSlice(
            tableNodeTypes(schema).row,
            new Slice(content, openStart, openEnd),
          ).content
        : content,
    );
  } else {
    return null;
  }
  return ensureRectangular(schema, rows);
}

// : (Schema, [Fragment]) → {width: number, height: number, rows: [Fragment]}
// Compute the width and height of a set of cells, and make sure each
// row has the same number of cells.
function ensureRectangular(schema, rows) {
  let widths = [];
  for (let i = 0; i < rows.length; i++) {
    let row = rows[i];
    for (let j = row.childCount - 1; j >= 0; j--) {
      let { rowspan, colspan } = row.child(j).attrs;
      for (let r = i; r < i + rowspan; r++)
        widths[r] = (widths[r] || 0) + colspan;
    }
  }
  let width = 0;
  for (let r = 0; r < widths.length; r++) width = Math.max(width, widths[r]);
  for (let r = 0; r < widths.length; r++) {
    if (r >= rows.length) rows.push(Fragment.empty);
    if (widths[r] < width) {
      let empty = tableNodeTypes(schema).cell.createAndFill(),
        cells = [];
      for (let i = widths[r]; i < width; i++) cells.push(empty);
      rows[r] = rows[r].append(Fragment.from(cells));
    }
  }
  return { height: rows.length, width, rows };
}

export function fitSlice(nodeType, slice) {
  let node = nodeType.createAndFill();
  let tr = new Transform(node).replace(0, node.content.size, slice);
  return tr.doc;
}

// : ({width: number, height: number, rows: [Fragment]}, number, number) → {width: number, height: number, rows: [Fragment]}
// Clip or extend (repeat) the given set of cells to cover the given
// width and height. Will clip rowspan/colspan cells at the edges when
// they stick out.
export function clipCells({ width, height, rows }, newWidth, newHeight) {
  if (width != newWidth) {
    let added = [],
      newRows = [];
    for (let row = 0; row < rows.length; row++) {
      let frag = rows[row],
        cells = [];
      for (let col = added[row] || 0, i = 0; col < newWidth; i++) {
        let cell = frag.child(i % frag.childCount);
        if (col + cell.attrs.colspan > newWidth)
          cell = cell.type.create(
            removeColSpan(
              cell.attrs,
              cell.attrs.colspan,
              col + cell.attrs.colspan - newWidth,
            ),
            cell.content,
          );
        cells.push(cell);
        col += cell.attrs.colspan;
        for (let j = 1; j < cell.attrs.rowspan; j++)
          added[row + j] = (added[row + j] || 0) + cell.attrs.colspan;
      }
      newRows.push(Fragment.from(cells));
    }
    rows = newRows;
    width = newWidth;
  }

  if (height != newHeight) {
    let newRows = [];
    for (let row = 0, i = 0; row < newHeight; row++, i++) {
      let cells = [],
        source = rows[i % height];
      for (let j = 0; j < source.childCount; j++) {
        let cell = source.child(j);
        if (row + cell.attrs.rowspan > newHeight)
          cell = cell.type.create(
            setAttr(
              cell.attrs,
              'rowspan',
              Math.max(1, newHeight - cell.attrs.rowspan),
            ),
            cell.content,
          );
        cells.push(cell);
      }
      newRows.push(Fragment.from(cells));
    }
    rows = newRows;
    height = newHeight;
  }

  return { width, height, rows };
}

// Make sure a table has at least the given width and height. Return
// true if something was changed.
function growTable(tr, map, table, start, width, height, mapFrom) {
  let schema = tr.doc.type.schema,
    types = tableNodeTypes(schema),
    empty,
    emptyHead;
  if (width > map.width) {
    for (let row = 0, rowEnd = 0; row < map.height; row++) {
      let rowNode = table.child(row);
      rowEnd += rowNode.nodeSize;
      let cells = [],
        add;
      if (rowNode.lastChild == null || rowNode.lastChild.type == types.cell)
        add = empty || (empty = types.cell.createAndFill());
      else add = emptyHead || (emptyHead = types.header_cell.createAndFill());
      for (let i = map.width; i < width; i++) cells.push(add);
      tr.insert(tr.mapping.slice(mapFrom).map(rowEnd - 1 + start), cells);
    }
  }
  if (height > map.height) {
    let cells = [];
    for (
      let i = 0, start = (map.height - 1) * map.width;
      i < Math.max(map.width, width);
      i++
    ) {
      let header =
        i >= map.width
          ? false
          : table.nodeAt(map.map[start + i]).type == types.header_cell;
      cells.push(
        header
          ? emptyHead || (emptyHead = types.header_cell.createAndFill())
          : empty || (empty = types.cell.createAndFill()),
      );
    }

    let emptyRow = types.row.create(null, Fragment.from(cells)),
      rows = [];
    for (let i = map.height; i < height; i++) rows.push(emptyRow);
    tr.insert(tr.mapping.slice(mapFrom).map(start + table.nodeSize - 2), rows);
  }
  return !!(empty || emptyHead);
}

// Make sure the given line (left, top) to (right, top) doesn't cross
// any rowspan cells by splitting cells that cross it. Return true if
// something changed.
function isolateHorizontal(tr, map, table, start, left, right, top, mapFrom) {
  if (top == 0 || top == map.height) return false;
  let found = false;
  for (let col = left; col < right; col++) {
    let index = top * map.width + col,
      pos = map.map[index];
    if (map.map[index - map.width] == pos) {
      found = true;
      let cell = table.nodeAt(pos);
      let { top: cellTop, left: cellLeft } = map.findCell(pos);
      tr.setNodeMarkup(
        tr.mapping.slice(mapFrom).map(pos + start),
        null,
        setAttr(cell.attrs, 'rowspan', top - cellTop),
      );
      tr.insert(
        tr.mapping.slice(mapFrom).map(map.positionAt(top, cellLeft, table)),
        cell.type.createAndFill(
          setAttr(cell.attrs, 'rowspan', cellTop + cell.attrs.rowspan - top),
        ),
      );
      col += cell.attrs.colspan - 1;
    }
  }
  return found;
}

// Make sure the given line (left, top) to (left, bottom) doesn't
// cross any colspan cells by splitting cells that cross it. Return
// true if something changed.
function isolateVertical(tr, map, table, start, top, bottom, left, mapFrom) {
  if (left == 0 || left == map.width) return false;
  let found = false;
  for (let row = top; row < bottom; row++) {
    let index = row * map.width + left,
      pos = map.map[index];
    if (map.map[index - 1] == pos) {
      found = true;
      let cell = table.nodeAt(pos),
        cellLeft = map.colCount(pos);
      let updatePos = tr.mapping.slice(mapFrom).map(pos + start);
      tr.setNodeMarkup(
        updatePos,
        null,
        removeColSpan(
          cell.attrs,
          left - cellLeft,
          cell.attrs.colspan - (left - cellLeft),
        ),
      );
      tr.insert(
        updatePos + cell.nodeSize,
        cell.type.createAndFill(removeColSpan(cell.attrs, 0, left - cellLeft)),
      );
      row += cell.attrs.rowspan - 1;
    }
  }
  return found;
}

// Insert the given set of cells (as returned by `pastedCells`) into a
// table, at the position pointed at by rect.
export function insertCells(state, dispatch, tableStart, rect, cells) {
  let table = tableStart ? state.doc.nodeAt(tableStart - 1) : state.doc,
    map = TableMap.get(table);
  let { top, left } = rect;
  let right = left + cells.width,
    bottom = top + cells.height;
  let tr = state.tr,
    mapFrom = 0;
  function recomp() {
    table = tableStart ? tr.doc.nodeAt(tableStart - 1) : tr.doc;
    map = TableMap.get(table);
    mapFrom = tr.mapping.maps.length;
  }
  // Prepare the table to be large enough and not have any cells
  // crossing the boundaries of the rectangle that we want to
  // insert into. If anything about it changes, recompute the table
  // map so that subsequent operations can see the current shape.
  if (growTable(tr, map, table, tableStart, right, bottom, mapFrom)) recomp();
  if (isolateHorizontal(tr, map, table, tableStart, left, right, top, mapFrom))
    recomp();
  if (
    isolateHorizontal(tr, map, table, tableStart, left, right, bottom, mapFrom)
  )
    recomp();
  if (isolateVertical(tr, map, table, tableStart, top, bottom, left, mapFrom))
    recomp();
  if (isolateVertical(tr, map, table, tableStart, top, bottom, right, mapFrom))
    recomp();

  for (let row = top; row < bottom; row++) {
    let from = map.positionAt(row, left, table),
      to = map.positionAt(row, right, table);
    tr.replace(
      tr.mapping.slice(mapFrom).map(from + tableStart),
      tr.mapping.slice(mapFrom).map(to + tableStart),
      new Slice(cells.rows[row - top], 0, 0),
    );
  }
  recomp();
  tr.setSelection(
    new CellSelection(
      tr.doc.resolve(tableStart + map.positionAt(top, left, table)),
      tr.doc.resolve(tableStart + map.positionAt(bottom - 1, right - 1, table)),
    ),
  );
  dispatch(tr);
}
