// Copyright (C) 2023 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 {arrayEquals} from '../../base/array_utils';
import {SortDirection} from '../../base/comparison_utils';
import {isString} from '../../base/object_utils';
import {sqliteString} from '../../base/string_utils';
import {raf} from '../../core/raf_scheduler';
import {EngineProxy} from '../../trace_processor/engine';
import {NUM, Row} from '../../trace_processor/query_result';
import {
  constraintsToQueryPrefix,
  constraintsToQuerySuffix,
  SQLConstraints,
} from '../sql_utils';

import {
  Column,
  columnFromSqlTableColumn,
  formatSqlProjection,
  SqlProjection,
  sqlProjectionsForColumn,
} from './column';
import {SqlTableDescription, startsHidden} from './table_description';

interface ColumnOrderClause {
  // We only allow the table to be sorted by the columns which are displayed to
  // the user to avoid confusion, so we use a reference to the underlying Column
  // here and compare it by reference down the line.
  column: Column;
  direction: SortDirection;
}

const ROW_LIMIT = 100;

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

// In the common case, filter is an expression which evaluates to a boolean.
// However, when filtering args, it's substantially (10x) cheaper to do a
// join with the args table, as it means that trace processor can cache the
// query on the key instead of invoking a function for each row of the entire
// `slice` table.
export type Filter = string|{
  type: 'arg_filter',
  argSetIdColumn: string,
  argName: string,
  op: 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[];
}

export class SqlTableState {
  private readonly engine_: EngineProxy;
  private readonly table_: SqlTableDescription;
  private readonly additionalImports: string[];

  get engine() {
    return this.engine_;
  }
  get table() {
    return this.table_;
  }

  private filters: Filter[];
  private columns: Column[];
  private orderBy: ColumnOrderClause[];
  private offset = 0;
  private data?: Data;
  private rowCount?: RowCount;

  constructor(
      engine: EngineProxy, table: SqlTableDescription, filters?: Filter[],
      imports?: string[]) {
    this.engine_ = engine;
    this.table_ = table;
    this.additionalImports = imports || [];

    this.filters = filters || [];
    this.columns = [];
    for (const column of this.table.columns) {
      if (startsHidden(column)) continue;
      this.columns.push(columnFromSqlTableColumn(column));
    }
    this.orderBy = [];

    this.reload();
  }

  // Compute the actual columns to fetch. Some columns can appear multiple times
  // (e.g. we might need "ts" to be able to show it, as well as a dependency for
  // "slice_id" to be able to jump to it, so this function will deduplicate
  // projections by alias.
  private getSQLProjections(): SqlProjection[] {
    const projections = [];
    const aliases = new Set<string>();
    for (const column of this.columns) {
      for (const p of sqlProjectionsForColumn(column)) {
        if (aliases.has(p.alias)) continue;
        aliases.add(p.alias);
        projections.push(p);
      }
    }
    return projections;
  }

  getQueryConstraints(): SQLConstraints {
    const result: SQLConstraints = {
      commonTableExpressions: {},
      joins: [],
      filters: [],
    };
    let cteId = 0;
    for (const filter of this.filters) {
      if (isString(filter)) {
        result.filters!.push(filter);
      } else {
        const cteName = `arg_sets_${cteId++}`;
        result.commonTableExpressions![cteName] = `
          SELECT DISTINCT arg_set_id
          FROM args
          WHERE key = ${sqliteString(filter.argName)}
            AND display_value ${filter.op}
        `;
        result.joins!.push(`JOIN ${cteName} ON ${cteName}.arg_set_id = ${
            this.table.name}.${filter.argSetIdColumn}`);
      }
    }
    return result;
  }

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

  private getCountRowsSQLQuery(): string {
    const constraints = this.getQueryConstraints();
    return `
      ${this.getSQLImports()}

      ${constraintsToQueryPrefix(constraints)}
      SELECT
        COUNT() AS count
      FROM ${this.table.name}
      ${constraintsToQuerySuffix(constraints)}
    `;
  }

  buildSqlSelectStatement(): {
    selectStatement: string,
    columns: string[],
  } {
    const projections = this.getSQLProjections();
    const orderBy = this.orderBy.map((c) => ({
                                       fieldName: c.column.alias,
                                       direction: c.direction,
                                     }));
    const constraints = this.getQueryConstraints();
    constraints.orderBy = orderBy;
    const statement = `
      ${constraintsToQueryPrefix(constraints)}
      SELECT
        ${projections.map(formatSqlProjection).join(',\n')}
      FROM ${this.table.name}
      ${constraintsToQuerySuffix(constraints)}
    `;
    return {
      selectStatement: statement,
      columns: projections.map((p) => p.alias),
    };
  }

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

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

  getPaginatedSQLQuery(): string {
    // We fetch one more row to determine if we can go forward.
    return `
      ${this.getNonPaginatedSQLQuery()}
      LIMIT ${ROW_LIMIT + 1}
      OFFSET ${this.offset}
    `;
  }

  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.engine.query(this.getCountRowsSQLQuery());
    if (res.error() !== undefined) return undefined;
    return {
      count: res.firstRow({count: NUM}).count,
      filters: filters,
    };
  }

  private async loadData(): Promise<Data> {
    const queryRes = await this.engine.query(this.getPaginatedSQLQuery());
    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 && arrayEquals(newFilters, this.filters);
    this.data = undefined;
    if (!filtersMatch) {
      this.rowCount = undefined;
    }

    // Delay the visual update by 50ms to avoid flickering (if the query returns
    // before the data is loaded.
    setTimeout(() => raf.scheduleFullRedraw(), 50);

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

    raf.scheduleFullRedraw();
  }

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

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

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

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

  // Filters are compared by reference, so the caller is required to pass an
  // object which was previously returned by getFilters.
  removeFilter(filter: Filter) {
    this.filters = this.filters.filter((f) => f !== filter);
    this.reload();
  }

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

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

  sortBy(clause: ColumnOrderClause) {
    // Remove previous sort by the same column.
    this.orderBy = this.orderBy.filter((c) => c.column !== 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: Column): SortDirection|undefined {
    if (this.orderBy.length === 0) return undefined;
    if (this.orderBy[0].column !== column) return undefined;
    return this.orderBy[0].direction;
  }

  addColumn(column: Column, 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) => c.column !== column);
    // TODO(altimin): we can avoid the fetch here if the orderBy hasn't changed.
    this.reload({offset: 'keep'});
  }

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