import { findIndex } from 'lodash';

import { Column, TableData, QueryResultMeta } from '@grafana/data';

/**
 * Extends the standard Column class with variables that get
 * mutated in the angular table panel.
 */
export interface MutableColumn extends Column {
  title?: string;
  sort?: boolean;
  desc?: boolean;
  type?: string;
}

export default class TableModel implements TableData {
  columns: MutableColumn[];
  rows: any[];
  type: string;
  columnMap: Record<string, Column>;
  refId?: string;
  meta?: QueryResultMeta;

  constructor(table?: any) {
    this.columns = [];
    this.columnMap = {};
    this.rows = [];
    this.type = 'table';

    if (table) {
      if (table.columns) {
        for (const col of table.columns) {
          this.addColumn(col);
        }
      }
      if (table.rows) {
        for (const row of table.rows) {
          this.addRow(row);
        }
      }
    }
  }

  sort(options: { col: number; desc: boolean }) {
    // Since 8.3.0 col property can be also undefined, https://github.com/grafana/grafana/issues/44127
    if (options.col === null || options.col === undefined || this.columns.length <= options.col) {
      return;
    }

    this.rows.sort((a, b) => {
      a = a[options.col];
      b = b[options.col];
      // Sort null or undefined separately from comparable values
      return +(a == null) - +(b == null) || +(a > b) || -(a < b);
    });

    if (options.desc) {
      this.rows.reverse();
    }

    this.columns[options.col].sort = true;
    this.columns[options.col].desc = options.desc;
  }

  addColumn(col: Column) {
    if (!this.columnMap[col.text]) {
      this.columns.push(col);
      this.columnMap[col.text] = col;
    }
  }

  addRow(row: unknown[]) {
    this.rows.push(row);
  }
}

// Returns true if both rows have matching non-empty fields as well as matching
// indexes where one field is empty and the other is not
function areRowsMatching(columns: Column[], row: unknown[], otherRow: unknown[]) {
  let foundFieldToMatch = false;
  for (let columnIndex = 0; columnIndex < columns.length; columnIndex++) {
    if (row[columnIndex] !== undefined && otherRow[columnIndex] !== undefined) {
      if (row[columnIndex] !== otherRow[columnIndex]) {
        return false;
      }
    } else if (row[columnIndex] === undefined || otherRow[columnIndex] === undefined) {
      foundFieldToMatch = true;
    }
  }
  return foundFieldToMatch;
}

export function mergeTablesIntoModel(dst?: TableModel, ...tables: TableModel[]): TableModel {
  const model = dst || new TableModel();

  if (arguments.length === 1) {
    return model;
  }
  // Single query returns data columns and rows as is
  if (arguments.length === 2) {
    model.columns = tables[0].hasOwnProperty('columns') ? [...tables[0].columns] : [];
    model.rows = tables[0].hasOwnProperty('rows') ? [...tables[0].rows] : [];
    return model;
  }

  // Filter out any tables that are not of TableData format
  const tableDataTables = tables.filter((table) => !!table.columns);

  // Track column indexes of union: name -> index
  const columnNames: { [key: string]: number } = {};

  // Union of all non-value columns
  const columnsUnion = tableDataTables.slice().reduce<MutableColumn[]>((acc, series) => {
    series.columns.forEach((col) => {
      const { text } = col;
      if (columnNames[text] === undefined) {
        columnNames[text] = acc.length;
        acc.push(col);
      }
    });
    return acc;
  }, []);

  // Map old column index to union index per series, e.g.,
  // given columnNames {A: 0, B: 1} and
  // data [{columns: [{ text: 'A' }]}, {columns: [{ text: 'B' }]}] => [[0], [1]]
  const columnIndexMapper = tableDataTables.map((series) => series.columns.map((col) => columnNames[col.text]));

  // Flatten rows of all series and adjust new column indexes
  const flattenedRows = tableDataTables.reduce<MutableColumn[][]>((acc, series, seriesIndex) => {
    const mapper = columnIndexMapper[seriesIndex];
    series.rows.forEach((row) => {
      const alteredRow: MutableColumn[] = [];
      // Shifting entries according to index mapper
      mapper.forEach((to, from) => {
        alteredRow[to] = row[from];
      });
      acc.push(alteredRow);
    });
    return acc;
  }, []);

  // Merge rows that have same values for columns
  const mergedRows: Record<number, MutableColumn[]> = {};

  const compactedRows = flattenedRows.reduce<MutableColumn[][]>((acc, row, rowIndex) => {
    if (!mergedRows[rowIndex]) {
      // Look from current row onwards
      let offset = rowIndex + 1;
      // More than one row can be merged into current row
      while (offset < flattenedRows.length) {
        // Find next row that could be merged
        const match = findIndex(flattenedRows, (otherRow) => areRowsMatching(columnsUnion, row, otherRow), offset);
        if (match > -1) {
          const matchedRow = flattenedRows[match];
          // Merge values from match into current row if there is a gap in the current row
          for (let columnIndex = 0; columnIndex < columnsUnion.length; columnIndex++) {
            if (row[columnIndex] === undefined && matchedRow[columnIndex] !== undefined) {
              row[columnIndex] = matchedRow[columnIndex];
            }
          }
          // Don't visit this row again
          mergedRows[match] = matchedRow;
          // Keep looking for more rows to merge
          offset = match + 1;
        } else {
          // No match found, stop looking
          break;
        }
      }
      acc.push(row);
    }
    return acc;
  }, []);

  model.columns = columnsUnion;
  model.rows = compactedRows;
  return model;
}
