// 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 {NUM, Row} from '../../../../trace_processor/query_result';
import {
  tableColumnAlias,
  ColumnOrderClause,
  Filter,
  isSqlColumnEqual,
  SqlColumn,
  sqlColumnId,
  TableColumn,
  tableColumnId,
} from './column';
import {buildSqlQuery} from './query_builder';
import {raf} from '../../../../core/raf_scheduler';
import {SortDirection} from '../../../../base/comparison_utils';
import {assertTrue} from '../../../../base/logging';
import {SqlTableDescription} from './table_description';
import {Trace} from '../../../../public/trace';

const ROW_LIMIT = 100;

interface Request {
  // Select statement, without the includes and the LIMIT and OFFSET clauses.
  selectStatement: string;
  // Query, including the LIMIT and OFFSET clauses.
  query: string;
  // Map of SqlColumn's id to the column name in the query.
  columns: {[key: string]: string};
}

// Result of the execution of the query.
interface Data {
  // Rows to show, including pagination.
  rows: Row[];
  error?: string;
}

interface RowCount {
  // Total number of rows in view, excluding the pagination.
  // Undefined if the query returned an error.
  count: number;
  // Filters which were used to compute this row count.
  // We need to recompute the totalRowCount only when filters change and not
  // when the set of columns / order by changes.
  filters: Filter[];
}

function isFilterEqual(a: Filter, b: Filter) {
  return (
    a.op === b.op &&
    a.columns.length === b.columns.length &&
    a.columns.every((c, i) => isSqlColumnEqual(c, b.columns[i]))
  );
}

function areFiltersEqual(a: Filter[], b: Filter[]) {
  if (a.length !== b.length) return false;
  return a.every((f, i) => isFilterEqual(f, b[i]));
}

export class SqlTableState {
  private readonly additionalImports: string[];

  // Columns currently displayed to the user. All potential columns can be found `this.table.columns`.
  private columns: TableColumn[];
  private filters: Filter[];
  private orderBy: {
    column: TableColumn;
    direction: SortDirection;
  }[];
  private offset = 0;
  private request: Request;
  private data?: Data;
  private rowCount?: RowCount;

  constructor(
    readonly trace: Trace,
    readonly config: SqlTableDescription,
    private readonly args?: {
      initialColumns?: TableColumn[];
      additionalColumns?: TableColumn[];
      imports?: string[];
      filters?: Filter[];
      orderBy?: {
        column: TableColumn;
        direction: SortDirection;
      }[];
    },
  ) {
    this.additionalImports = args?.imports || [];

    this.filters = args?.filters || [];
    this.columns = [];

    if (args?.initialColumns !== undefined) {
      assertTrue(
        args?.additionalColumns === undefined,
        'Only one of `initialColumns` and `additionalColumns` can be set',
      );
      this.columns.push(...args.initialColumns);
    } else {
      for (const column of this.config.columns) {
        if (column instanceof TableColumn) {
          if (column.startsHidden !== true) {
            this.columns.push(column);
          }
        } else {
          const cols = column.initialColumns?.();
          for (const col of cols ?? []) {
            this.columns.push(col);
          }
        }
      }
      if (args?.additionalColumns !== undefined) {
        this.columns.push(...args.additionalColumns);
      }
    }

    this.orderBy = args?.orderBy ?? [];

    this.request = this.buildRequest();
    this.reload();
  }

  clone(): SqlTableState {
    return new SqlTableState(this.trace, this.config, {
      initialColumns: this.columns,
      imports: this.args?.imports,
      filters: this.filters,
      orderBy: this.orderBy,
    });
  }

  private getSQLImports() {
    const tableImports = this.config.imports || [];
    return [...tableImports, ...this.additionalImports]
      .map((i) => `INCLUDE PERFETTO MODULE ${i};`)
      .join('\n');
  }

  private getCountRowsSQLQuery(): string {
    return `
      ${this.getSQLImports()}

      ${this.getSqlQuery({count: 'COUNT()'})}
    `;
  }

  // Return a query which selects the given columns, applying the filters and ordering currently in effect.
  getSqlQuery(columns: {[key: string]: SqlColumn}): string {
    return buildSqlQuery({
      table: this.config.name,
      columns,
      filters: this.filters,
      orderBy: this.getOrderedBy(),
    });
  }

  // We need column names to pass to the debug track creation logic.
  private buildSqlSelectStatement(): {
    selectStatement: string;
    columns: {[key: string]: string};
  } {
    const columns: {[key: string]: SqlColumn} = {};
    // A set of columnIds for quick lookup.
    const sqlColumnIds: Set<string> = new Set();
    // We want to use the shortest posible name for each column, but we also need to mindful of potential collisions.
    // To avoid collisions, we append a number to the column name if there are multiple columns with the same name.
    const columnNameCount: {[key: string]: number} = {};

    const tableColumns: {column: TableColumn; name: string; alias: string}[] =
      [];

    for (const column of this.columns) {
      // If TableColumn has an alias, use it. Otherwise, use the column name.
      const name = tableColumnAlias(column);
      if (!(name in columnNameCount)) {
        columnNameCount[name] = 0;
      }

      // Note: this can break if the user specifies a column which ends with `__<number>`.
      // We intentionally use two underscores to avoid collisions and will fix it down the line if it turns out to be a problem.
      const alias = `${name}__${++columnNameCount[name]}`;
      tableColumns.push({column, name, alias});
    }

    for (const column of tableColumns) {
      const sqlColumn = column.column.primaryColumn();
      // If we have only one column with this name, we don't need to disambiguate it.
      if (columnNameCount[column.name] === 1) {
        columns[column.name] = sqlColumn;
      } else {
        columns[column.alias] = sqlColumn;
      }
      sqlColumnIds.add(sqlColumnId(sqlColumn));
    }

    // We are going to be less fancy for the dependendent columns can just always suffix them with a unique integer.
    let dependentColumnCount = 0;
    for (const column of tableColumns) {
      const dependentColumns =
        column.column.dependentColumns !== undefined
          ? column.column.dependentColumns()
          : {};
      for (const col of Object.values(dependentColumns)) {
        if (sqlColumnIds.has(sqlColumnId(col))) continue;
        const name = typeof col === 'string' ? col : col.column;
        const alias = `__${name}_${dependentColumnCount++}`;
        columns[alias] = col;
        sqlColumnIds.add(sqlColumnId(col));
      }
    }

    return {
      selectStatement: this.getSqlQuery(columns),
      columns: Object.fromEntries(
        Object.entries(columns).map(([key, value]) => [
          sqlColumnId(value),
          key,
        ]),
      ),
    };
  }

  getNonPaginatedSQLQuery(): string {
    return `
      ${this.getSQLImports()}

      ${this.buildSqlSelectStatement().selectStatement}
    `;
  }

  getPaginatedSQLQuery(): Request {
    return this.request;
  }

  canGoForward(): boolean {
    if (this.data === undefined) return false;
    return this.data.rows.length > ROW_LIMIT;
  }

  canGoBack(): boolean {
    if (this.data === undefined) return false;
    return this.offset > 0;
  }

  goForward() {
    if (!this.canGoForward()) return;
    this.offset += ROW_LIMIT;
    this.reload({offset: 'keep'});
  }

  goBack() {
    if (!this.canGoBack()) return;
    this.offset -= ROW_LIMIT;
    this.reload({offset: 'keep'});
  }

  getDisplayedRange(): {from: number; to: number} | undefined {
    if (this.data === undefined) return undefined;
    return {
      from: this.offset + 1,
      to: this.offset + Math.min(this.data.rows.length, ROW_LIMIT),
    };
  }

  private async loadRowCount(): Promise<RowCount | undefined> {
    const filters = Array.from(this.filters);
    const res = await this.trace.engine.query(this.getCountRowsSQLQuery());
    if (res.error() !== undefined) return undefined;
    return {
      count: res.firstRow({count: NUM}).count,
      filters: filters,
    };
  }

  private buildRequest(): Request {
    const {selectStatement, columns} = this.buildSqlSelectStatement();
    // We fetch one more row to determine if we can go forward.
    const query = `
      ${this.getSQLImports()}
      ${selectStatement}
      LIMIT ${ROW_LIMIT + 1}
      OFFSET ${this.offset}
    `;
    return {selectStatement, query, columns};
  }

  private async loadData(): Promise<Data> {
    const queryRes = await this.trace.engine.query(this.request.query);
    const rows: Row[] = [];
    for (const it = queryRes.iter({}); it.valid(); it.next()) {
      const row: Row = {};
      for (const column of queryRes.columns()) {
        row[column] = it.get(column);
      }
      rows.push(row);
    }

    return {
      rows,
      error: queryRes.error(),
    };
  }

  private async reload(params?: {offset: 'reset' | 'keep'}) {
    if ((params?.offset ?? 'reset') === 'reset') {
      this.offset = 0;
    }

    const newFilters = this.rowCount?.filters;
    const filtersMatch =
      newFilters && areFiltersEqual(newFilters, this.filters);
    this.data = undefined;
    const request = this.buildRequest();
    this.request = request;
    if (!filtersMatch) {
      this.rowCount = undefined;
    }

    // Schedule a full redraw to happen after a short delay (50 ms).
    // This is done to prevent flickering / visual noise and allow the UI to fetch
    // the initial data from the Trace Processor.
    // There is a chance that someone else schedules a full redraw in the
    // meantime, forcing the flicker, but in practice it works quite well and
    // avoids a lot of complexity for the callers.
    // 50ms is half of the responsiveness threshold (100ms):
    // https://web.dev/rail/#response-process-events-in-under-50ms
    setTimeout(() => raf.scheduleFullRedraw(), 50);

    if (!filtersMatch) {
      this.rowCount = await this.loadRowCount();
    }

    const data = await this.loadData();

    // If the request has changed since we started loading the data, do not update the state.
    if (this.request !== request) return;
    this.data = data;

    raf.scheduleFullRedraw();
  }

  getTotalRowCount(): number | undefined {
    return this.rowCount?.count;
  }

  getCurrentRequest(): Request {
    return this.request;
  }

  getDisplayedRows(): Row[] {
    return this.data?.rows || [];
  }

  getQueryError(): string | undefined {
    return this.data?.error;
  }

  isLoading() {
    return this.data === undefined;
  }

  addFilter(filter: Filter) {
    this.filters.push(filter);
    this.reload();
  }

  removeFilter(filter: Filter) {
    this.filters = this.filters.filter((f) => !isFilterEqual(f, filter));
    this.reload();
  }

  getFilters(): Filter[] {
    return this.filters;
  }

  sortBy(clause: {column: TableColumn; direction: SortDirection}) {
    // Remove previous sort by the same column.
    this.orderBy = this.orderBy.filter(
      (c) => tableColumnId(c.column) != tableColumnId(clause.column),
    );
    // Add the new sort clause to the front, so we effectively stable-sort the
    // data currently displayed to the user.
    this.orderBy.unshift(clause);
    this.reload();
  }

  unsort() {
    this.orderBy = [];
    this.reload();
  }

  isSortedBy(column: TableColumn): SortDirection | undefined {
    if (this.orderBy.length === 0) return undefined;
    if (tableColumnId(this.orderBy[0].column) !== tableColumnId(column)) {
      return undefined;
    }
    return this.orderBy[0].direction;
  }

  getOrderedBy(): ColumnOrderClause[] {
    const result: ColumnOrderClause[] = [];
    for (const orderBy of this.orderBy) {
      const sortColumns = orderBy.column.sortColumns?.() ?? [
        orderBy.column.primaryColumn(),
      ];
      for (const column of sortColumns) {
        result.push({column, direction: orderBy.direction});
      }
    }
    return result;
  }

  addColumn(column: TableColumn, index: number) {
    this.columns.splice(index + 1, 0, column);
    this.reload({offset: 'keep'});
  }

  hideColumnAtIndex(index: number) {
    const column = this.columns[index];
    this.columns.splice(index, 1);
    // We can only filter by the visibile columns to avoid confusing the user,
    // so we remove order by clauses that refer to the hidden column.
    this.orderBy = this.orderBy.filter(
      (c) => tableColumnId(c.column) !== tableColumnId(column),
    );
    // TODO(altimin): we can avoid the fetch here if the orderBy hasn't changed.
    this.reload({offset: 'keep'});
  }

  moveColumn(fromIndex: number, toIndex: number) {
    if (fromIndex === toIndex) return;
    const column = this.columns[fromIndex];
    this.columns.splice(fromIndex, 1);
    if (fromIndex < toIndex) {
      // We have deleted a column, therefore we need to adjust the target index.
      --toIndex;
    }
    this.columns.splice(toIndex, 0, column);
    raf.scheduleFullRedraw();
  }

  getSelectedColumns(): TableColumn[] {
    return this.columns;
  }
}
