// Copyright (C) 2024 The Android Open Source Project
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//      http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

import m from 'mithril';
import {
  filterTitle,
  SqlColumn,
  sqlColumnId,
  TableColumn,
  tableColumnId,
  TableManager,
} from './column';
import {Button} from '../../../../widgets/button';
import {MenuDivider, MenuItem, PopupMenu2} from '../../../../widgets/menu';
import {buildSqlQuery} from './query_builder';
import {Icons} from '../../../../base/semantic_icons';
import {sqliteString} from '../../../../base/string_utils';
import {
  ColumnType,
  Row,
  SqlValue,
} from '../../../../trace_processor/query_result';
import {Anchor} from '../../../../widgets/anchor';
import {BasicTable, ReorderableColumns} from '../../../../widgets/basic_table';
import {Spinner} from '../../../../widgets/spinner';

import {ArgumentSelector} from './argument_selector';
import {FILTER_OPTION_TO_OP, FilterOption} from './render_cell_utils';
import {SqlTableState} from './state';
import {SqlTableDescription} from './table_description';
import {Intent} from '../../../../widgets/common';
import {Form} from '../../../../widgets/form';
import {TextInput} from '../../../../widgets/text_input';

export interface SqlTableConfig {
  readonly state: SqlTableState;
  // For additional menu items to add to the column header menus
  readonly addColumnMenuItems?: (
    column: TableColumn,
    columnAlias: string,
  ) => m.Children;
}

type AdditionalColumnMenuItems = Record<string, m.Children>;

function renderCell(
  column: TableColumn,
  row: Row,
  state: SqlTableState,
): m.Children {
  const {columns} = state.getCurrentRequest();
  const sqlValue = row[columns[sqlColumnId(column.primaryColumn())]];

  const additionalValues: {[key: string]: SqlValue} = {};
  const dependentColumns = column.dependentColumns?.() ?? {};
  for (const [key, col] of Object.entries(dependentColumns)) {
    additionalValues[key] = row[columns[sqlColumnId(col)]];
  }

  return column.renderCell(sqlValue, getTableManager(state), additionalValues);
}

export function columnTitle(column: TableColumn): string {
  if (column.getTitle !== undefined) {
    const title = column.getTitle();
    if (title !== undefined) return title;
  }
  return sqlColumnId(column.primaryColumn());
}

interface AddColumnMenuItemAttrs {
  table: SqlTable;
  state: SqlTableState;
  index: number;
}

// This is separated into a separate class to store the index of the column to be
// added and increment it when multiple columns are added from the same popup menu.
class AddColumnMenuItem implements m.ClassComponent<AddColumnMenuItemAttrs> {
  // Index where the new column should be inserted.
  // In the regular case, a click would close the popup (destroying this class) and
  // the `index` would not change during its lifetime.
  // However, for mod-click, we want to keep adding columns to the right of the recently
  // added column, so to achieve that we keep track of the index and increment it for
  // each new column added.
  index: number;

  constructor({attrs}: m.Vnode<AddColumnMenuItemAttrs>) {
    this.index = attrs.index;
  }

  view({attrs}: m.Vnode<AddColumnMenuItemAttrs>) {
    return m(
      MenuItem,
      {label: 'Add column', icon: Icons.AddColumn},
      attrs.table.renderAddColumnOptions((column) => {
        attrs.state.addColumn(column, this.index++);
      }),
    );
  }
}

interface ColumnFilterAttrs {
  filterOption: FilterOption;
  columns: SqlColumn[];
  state: SqlTableState;
}

// Separating out an individual column filter into a class
// so that we can store the raw input value.
class ColumnFilter implements m.ClassComponent<ColumnFilterAttrs> {
  // Holds the raw string value from the filter text input element
  private inputValue: string;

  constructor() {
    this.inputValue = '';
  }

  view({attrs}: m.Vnode<ColumnFilterAttrs>) {
    const {filterOption, columns, state} = attrs;

    const {op, requiresParam} = FILTER_OPTION_TO_OP[filterOption];

    return m(
      MenuItem,
      {
        label: filterOption,
        // Filter options that do not need an input value will filter the
        // table directly when clicking on the menu item
        // (ex: IS NULL or IS NOT NULL)
        onclick: !requiresParam
          ? () => {
              state.addFilter({
                op: (cols) => `${cols[0]} ${op}`,
                columns,
              });
            }
          : undefined,
      },
      // All non-null filter options will have a submenu that allows
      // the user to enter a value into textfield and filter using
      // the Filter button.
      requiresParam &&
        m(
          Form,
          {
            onSubmit: () => {
              // Convert the string extracted from
              // the input text field into the correct data type for
              // filtering. The order in which each data type is
              // checked matters: string, number (floating), and bigint.
              if (this.inputValue === '') return;

              let filterValue: ColumnType;

              if (Number.isNaN(Number.parseFloat(this.inputValue))) {
                filterValue = sqliteString(this.inputValue);
              } else if (
                !Number.isInteger(Number.parseFloat(this.inputValue))
              ) {
                filterValue = Number(this.inputValue);
              } else {
                filterValue = BigInt(this.inputValue);
              }

              state.addFilter({
                op: (cols) => `${cols[0]} ${op} ${filterValue}`,
                columns,
              });
            },
            submitLabel: 'Filter',
          },
          m(TextInput, {
            id: 'column_filter_value',
            ref: 'COLUMN_FILTER_VALUE',
            autofocus: true,
            oninput: (e: KeyboardEvent) => {
              if (!e.target) return;

              this.inputValue = (e.target as HTMLInputElement).value;
            },
          }),
        ),
    );
  }
}

export class SqlTable implements m.ClassComponent<SqlTableConfig> {
  private readonly table: SqlTableDescription;

  private state: SqlTableState;

  constructor(vnode: m.Vnode<SqlTableConfig>) {
    this.state = vnode.attrs.state;
    this.table = this.state.config;
  }

  renderFilters(): m.Children {
    const filters: m.Child[] = [];
    for (const filter of this.state.getFilters()) {
      const label = filterTitle(filter);
      filters.push(
        m(Button, {
          label,
          icon: 'close',
          intent: Intent.Primary,
          onclick: () => {
            this.state.removeFilter(filter);
          },
        }),
      );
    }
    return filters;
  }

  renderAddColumnOptions(addColumn: (column: TableColumn) => void): m.Children {
    // We do not want to add columns which already exist, so we track the
    // columns which we are already showing here.
    // TODO(altimin): Theoretically a single table can have two different
    // arg_set_ids, so we should track (arg_set_id_column, arg_name) pairs here.
    const existingColumnIds = new Set<string>();

    for (const column of this.state.getSelectedColumns()) {
      existingColumnIds.add(tableColumnId(column));
    }

    const result = [];
    for (const column of this.table.columns) {
      if (column instanceof TableColumn) {
        if (existingColumnIds.has(tableColumnId(column))) continue;
        result.push(
          m(MenuItem, {
            label: columnTitle(column),
            onclick: () => addColumn(column),
          }),
        );
      } else {
        result.push(
          m(
            MenuItem,
            {
              label: column.getTitle(),
            },
            m(ArgumentSelector, {
              alreadySelectedColumnIds: existingColumnIds,
              tableManager: getTableManager(this.state),
              columnSet: column,
              onArgumentSelected: (column: TableColumn) => {
                addColumn(column);
              },
            }),
          ),
        );
        continue;
      }
    }
    return result;
  }

  renderColumnFilterOptions(
    c: TableColumn,
  ): m.Vnode<ColumnFilterAttrs, unknown>[] {
    return Object.values(FilterOption).map((filterOption) =>
      m(ColumnFilter, {
        filterOption,
        columns: [c.primaryColumn()],
        state: this.state,
      }),
    );
  }

  renderColumnHeader(
    column: TableColumn,
    index: number,
    additionalColumnHeaderMenuItems?: m.Children,
  ) {
    const sorted = this.state.isSortedBy(column);
    const icon =
      sorted === 'ASC'
        ? Icons.SortedAsc
        : sorted === 'DESC'
          ? Icons.SortedDesc
          : Icons.ContextMenu;

    return m(
      PopupMenu2,
      {
        trigger: m(Anchor, {icon}, columnTitle(column)),
      },
      sorted !== 'DESC' &&
        m(MenuItem, {
          label: 'Sort: highest first',
          icon: Icons.SortedDesc,
          onclick: () => {
            this.state.sortBy({
              column: column,
              direction: 'DESC',
            });
          },
        }),
      sorted !== 'ASC' &&
        m(MenuItem, {
          label: 'Sort: lowest first',
          icon: Icons.SortedAsc,
          onclick: () => {
            this.state.sortBy({
              column: column,
              direction: 'ASC',
            });
          },
        }),
      sorted !== undefined &&
        m(MenuItem, {
          label: 'Unsort',
          icon: Icons.Close,
          onclick: () => this.state.unsort(),
        }),
      this.state.getSelectedColumns().length > 1 &&
        m(MenuItem, {
          label: 'Hide',
          icon: Icons.Hide,
          onclick: () => this.state.hideColumnAtIndex(index),
        }),
      m(
        MenuItem,
        {label: 'Add filter', icon: Icons.Filter},
        this.renderColumnFilterOptions(column),
      ),
      additionalColumnHeaderMenuItems,
      // Menu items before divider apply to selected column
      m(MenuDivider),
      // Menu items after divider apply to entire table
      m(AddColumnMenuItem, {table: this, state: this.state, index}),
    );
  }

  getAdditionalColumnMenuItems(
    addColumnMenuItems?: (
      column: TableColumn,
      columnAlias: string,
    ) => m.Children,
  ) {
    if (addColumnMenuItems === undefined) return;

    const additionalColumnMenuItems: AdditionalColumnMenuItems = {};
    this.state.getSelectedColumns().forEach((column) => {
      const columnAlias =
        this.state.getCurrentRequest().columns[
          sqlColumnId(column.primaryColumn())
        ];

      additionalColumnMenuItems[columnAlias] = addColumnMenuItems(
        column,
        columnAlias,
      );
    });

    return additionalColumnMenuItems;
  }

  view({attrs}: m.Vnode<SqlTableConfig>) {
    const rows = this.state.getDisplayedRows();
    const additionalColumnMenuItems = this.getAdditionalColumnMenuItems(
      attrs.addColumnMenuItems,
    );

    const columns = this.state.getSelectedColumns();
    const columnDescriptors = columns.map((column, i) => {
      return {
        title: this.renderColumnHeader(
          column,
          i,
          additionalColumnMenuItems &&
            additionalColumnMenuItems[
              this.state.getCurrentRequest().columns[
                sqlColumnId(column.primaryColumn())
              ]
            ],
        ),
        render: (row: Row) => renderCell(column, row, this.state),
      };
    });

    return [
      m('div', this.renderFilters()),
      m(
        BasicTable<Row>,
        {
          data: rows,
          columns: [
            new ReorderableColumns(
              columnDescriptors,
              (from: number, to: number) => this.state.moveColumn(from, to),
            ),
          ],
        },
        this.state.isLoading() && m(Spinner),
        this.state.getQueryError() !== undefined &&
          m('.query-error', this.state.getQueryError()),
      ),
    ];
  }
}

function getTableManager(state: SqlTableState): TableManager {
  return {
    addFilter: (filter) => {
      state.addFilter(filter);
    },
    trace: state.trace,
    getSqlQuery: (columns: {[key: string]: SqlColumn}) =>
      buildSqlQuery({
        table: state.config.name,
        columns,
        filters: state.getFilters(),
        orderBy: state.getOrderedBy(),
      }),
  };
}
