// Copyright (C) 2026 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 {assertUnreachable} from '../../../base/assert';
import {QuerySlot} from '../../../base/query_slot';
import type {Engine} from '../../../trace_processor/engine';
import type {QueryResult as TPQueryResult} from '../../../trace_processor/query_result';
import type {Filter} from '../datagrid/model';
import {filterToSql, sqlAggregateExpr} from '../datagrid/sql_utils';
import {type ChartAggregation, validateColumnName} from './chart_utils';

/** Default column alias for the first measure in aggregated queries. */
export const DEFAULT_MEASURE_ALIAS = '_value';

// ---------------------------------------------------------------------------
// Schema types
// ---------------------------------------------------------------------------

/**
 * Column type in the source query.
 * 'text' columns are cast to TEXT (dimensions, breakdowns).
 * 'real' columns are cast to REAL (measures, x/y axes).
 */
export type ColumnType = 'text' | 'real';

/**
 * Schema definition mapping column names to their types.
 * Currently used only for column existence validation; the actual cast
 * direction is determined by the query config (dimensions → TEXT,
 * measures → aggregation, point columns → explicit cast spec).
 */
export type ColumnSchema = Readonly<Record<string, ColumnType>>;

// ---------------------------------------------------------------------------
// Query configuration types
// ---------------------------------------------------------------------------

/**
 * Dimension: a column used for grouping (GROUP BY), cast to TEXT.
 */
export interface DimensionSpec {
  readonly column: string;
  /** Output alias. Defaults to `_dim` for the first, `_dim_1`, `_dim_2`... */
  readonly alias?: string;
}

/**
 * Measure: a column aggregated numerically.
 */
export interface MeasureSpec {
  readonly column: string;
  readonly aggregation: ChartAggregation;
  /** Output alias. Defaults to `_value` for the first, `_value_1`, `_value_2`... */
  readonly alias?: string;
}

/**
 * Point column: a raw column selected in a points query.
 * When `column` is omitted, produces `NULL AS alias` in the SQL.
 */
export interface PointColumnSpec {
  readonly column?: string;
  readonly alias: string;
  readonly cast: 'real' | 'text';
}

// ---------------------------------------------------------------------------
// Three query mode configs (discriminated union)
// ---------------------------------------------------------------------------

/**
 * Aggregated query (bar, pie, treemap).
 *
 * Produces: `SELECT dims, AGG(measures) FROM source [WHERE] GROUP BY dims`
 */
export interface AggregatedQueryConfig {
  readonly type: 'aggregated';

  /** Grouping dimensions (at least 1). */
  readonly dimensions: ReadonlyArray<DimensionSpec>;

  /** Measures to compute (at least 1). */
  readonly measures: ReadonlyArray<MeasureSpec>;

  /** Filters applied before aggregation. */
  readonly filters?: ReadonlyArray<Filter>;

  /** Sort direction for the first measure. Defaults to 'desc'. */
  readonly orderDirection?: 'asc' | 'desc';

  /** Limit number of output rows. */
  readonly limit?: number;

  /**
   * When true and `limit` is set, remaining rows beyond the top-N are
   * collapsed into a single "(Other)" row with summed measure values.
   */
  readonly includeOther?: boolean;

  /**
   * Limit results per group (first dimension). Uses ROW_NUMBER() OVER
   * (PARTITION BY first_dim ORDER BY value DESC). Only valid when
   * dimensions.length >= 2.
   */
  readonly limitPerGroup?: number;
}

/**
 * Points/raw query (line, scatter).
 *
 * Produces: `SELECT columns [, breakdown] FROM source [WHERE] [ORDER BY]`
 */
export interface PointsQueryConfig {
  readonly type: 'points';

  /** Columns to select. */
  readonly columns: ReadonlyArray<PointColumnSpec>;

  /** Optional breakdown/series column (cast to TEXT, aliased as `_series`). */
  readonly breakdown?: string;

  /** Filters to apply. */
  readonly filters?: ReadonlyArray<Filter>;

  /** Sort specification. */
  readonly orderBy?: ReadonlyArray<{
    readonly column: string;
    readonly direction?: 'asc' | 'desc';
  }>;

  /**
   * When set, stride-samples each series (PARTITION BY _series) down to at
   * most this many rows in SQL using ROW_NUMBER window functions. This avoids
   * fetching millions of rows into JS when only a representative subset is
   * needed (e.g., scatter charts).
   */
  readonly maxPointsPerSeries?: number;
}

/**
 * Histogram query.
 *
 * Produces a CTE that buckets values and returns counts per bucket along
 * with min/max/total metadata.
 */
export interface HistogramQueryConfig {
  readonly type: 'histogram';

  /** Column to bucket. */
  readonly valueColumn: string;

  /** Number of buckets. */
  readonly bucketCount: number;

  /**
   * If provided, a fixed bucket size is used (rather than auto-binning the
   * data range into `bucketCount` buckets). Must be paired with `minValue`.
   * The histogram covers [minValue, minValue + bucketCount * bucketSize];
   * values outside this range are clamped to the edge buckets.
   */
  readonly bucketSize?: number;

  /** Lower bound of bucket 0. Required when `bucketSize` is provided. */
  readonly minValue?: number;

  /** Filters to apply before bucketing. */
  readonly filters?: ReadonlyArray<Filter>;
}

/** Union of all query config types. */
export type QueryConfig =
  | AggregatedQueryConfig
  | PointsQueryConfig
  | HistogramQueryConfig;

// ---------------------------------------------------------------------------
// ChartSource
// ---------------------------------------------------------------------------

/**
 * Configuration for creating a ChartSource.
 */
export interface ChartSourceConfig {
  /** Base SQL query (used as a subquery). */
  readonly query: string;

  /** Column schema mapping column names to types. */
  readonly schema: ColumnSchema;
}

/**
 * Unified SQL query builder for chart loaders.
 *
 * Wraps a base SQL query + typed column schema and generates well-formed
 * SQL for aggregated, points, and histogram queries. Reuses the datagrid's
 * `Filter` type and `filterToSql()` for WHERE clause generation.
 *
 * Usage:
 * ```typescript
 * const source = new ChartSource({
 *   query: 'SELECT process_name, dur FROM slice WHERE dur > 0',
 *   schema: {process_name: 'text', dur: 'real'},
 * });
 *
 * const sql = source.buildQuery({
 *   type: 'aggregated',
 *   dimensions: [{column: 'process_name'}],
 *   measures: [{column: 'dur', aggregation: 'SUM'}],
 *   limit: 10,
 * });
 * ```
 */
export class ChartSource {
  readonly query: string;
  readonly schema: ColumnSchema;

  constructor(config: ChartSourceConfig) {
    for (const col of Object.keys(config.schema)) {
      validateColumnName(col);
    }
    this.query = config.query;
    this.schema = config.schema;
  }

  /**
   * Build a SQL query string from the given configuration.
   * Validates that all referenced columns exist in the schema.
   */
  buildQuery(config: QueryConfig): string {
    switch (config.type) {
      case 'aggregated':
        return this.buildAggregated(config);
      case 'points':
        return this.buildPoints(config);
      case 'histogram':
        return this.buildHistogram(config);
      default:
        assertUnreachable(config);
    }
  }

  // -------------------------------------------------------------------------
  // Aggregated queries
  // -------------------------------------------------------------------------

  private buildAggregated(config: AggregatedQueryConfig): string {
    for (const dim of config.dimensions) {
      this.assertColumn(dim.column);
    }
    for (const meas of config.measures) {
      this.assertColumn(meas.column);
    }

    // Decide which variant to use
    if (config.includeOther && config.limit !== undefined) {
      return this.buildTopNWithOther(config);
    }
    if (config.limitPerGroup !== undefined && config.dimensions.length >= 2) {
      return this.buildHierarchical(config);
    }
    return this.buildSimpleAggregated(config);
  }

  private buildSimpleAggregated(config: AggregatedQueryConfig): string {
    const selectParts = [
      ...this.dimSelectExprs(config.dimensions),
      ...this.measureSelectExprs(config.measures),
    ];

    const whereClause = this.buildWhereClause(config.filters);
    const havingClause = this.buildHavingClause(config.measures);

    // Scalar aggregation (no dimensions) — single row, no GROUP BY.
    if (config.dimensions.length === 0 && config.measures.length > 0) {
      return `
SELECT
  ${selectParts.join(',\n  ')}
FROM (${this.query})
${whereClause}
${havingClause}`.trim();
    }

    const groupByExprs = config.dimensions.map((d) => d.column);
    const direction = config.orderDirection ?? 'desc';
    const orderAlias = this.measureAlias(config.measures, 0);
    const limitClause =
      config.limit !== undefined ? `LIMIT ${config.limit}` : '';

    return `
SELECT
  ${selectParts.join(',\n  ')}
FROM (${this.query})
${whereClause}
GROUP BY ${groupByExprs.join(', ')}
${havingClause}
ORDER BY ${orderAlias} ${direction.toUpperCase()}
${limitClause}`.trim();
  }

  private buildTopNWithOther(config: AggregatedQueryConfig): string {
    const selectParts = [
      ...this.dimSelectExprs(config.dimensions),
      ...this.measureSelectExprs(config.measures),
    ];
    const whereClause = this.buildWhereClause(config.filters);
    const groupByExprs = config.dimensions.map((d) => d.column);
    const direction = config.orderDirection ?? 'desc';
    const orderAlias = this.measureAlias(config.measures, 0);
    // Caller guarantees config.limit is defined (checked in buildAggregated).
    const limit = config.limit ?? 0;

    // Collect output column aliases
    const dimAliases = config.dimensions.map((d, i) => this.dimAlias(d, i));
    const measAliases = config.measures.map((_, i) =>
      this.measureAlias(config.measures, i),
    );
    const allAliases = [...dimAliases, ...measAliases];
    const aliasList = allAliases.join(', ');

    // First dim alias for the "Other" label
    const firstDimAlias = dimAliases[0];

    // Build "(Other)" select: first dim = '(Other)', rest of dims = NULL,
    // each measure = SUM(measure_alias)
    const otherDimExprs = dimAliases.map((alias, i) =>
      i === 0 ? `'(Other)' AS ${alias}` : `NULL AS ${alias}`,
    );
    const otherMeasExprs = measAliases.map(
      (alias) => `SUM(${alias}) AS ${alias}`,
    );
    const otherSelectParts = [...otherDimExprs, ...otherMeasExprs];

    return `
WITH _agg AS (
  SELECT
    ${selectParts.join(',\n    ')}
  FROM (${this.query})
  ${whereClause}
  GROUP BY ${groupByExprs.join(', ')}
  ${this.buildHavingClause(config.measures)}
  ORDER BY ${orderAlias} ${direction.toUpperCase()}
),
_top AS (
  SELECT ${aliasList} FROM _agg ORDER BY ${orderAlias} ${direction.toUpperCase()} LIMIT ${limit}
),
_other AS (
  SELECT
    ${otherSelectParts.join(',\n    ')}
  FROM _agg
  WHERE ${firstDimAlias} NOT IN (SELECT ${firstDimAlias} FROM _top)
)
SELECT ${aliasList} FROM _top
UNION ALL
SELECT ${aliasList} FROM _other WHERE ${measAliases[0]} > 0`.trim();
  }

  private buildHierarchical(config: AggregatedQueryConfig): string {
    const selectParts = [
      ...this.dimSelectExprs(config.dimensions),
      ...this.measureSelectExprs(config.measures),
    ];
    const whereClause = this.buildWhereClause(config.filters);
    const groupByExprs = config.dimensions.map((d) => d.column);
    const direction = config.orderDirection ?? 'desc';
    const firstDimAlias = this.dimAlias(config.dimensions[0], 0);
    const orderAlias = this.measureAlias(config.measures, 0);

    const dimAliases = config.dimensions.map((d, i) => this.dimAlias(d, i));
    const measAliases = config.measures.map((_, i) =>
      this.measureAlias(config.measures, i),
    );
    const allAliases = [...dimAliases, ...measAliases];
    const aliasList = allAliases.join(', ');
    // Caller guarantees config.limitPerGroup is defined (checked in
    // buildAggregated).
    const limitPerGroup = config.limitPerGroup ?? 0;

    return `
WITH _agg AS (
  SELECT
    ${selectParts.join(',\n    ')}
  FROM (${this.query})
  ${whereClause}
  GROUP BY ${groupByExprs.join(', ')}
  ${this.buildHavingClause(config.measures)}
),
_ranked AS (
  SELECT
    ${aliasList},
    ROW_NUMBER() OVER (PARTITION BY ${firstDimAlias} ORDER BY ${orderAlias} ${direction.toUpperCase()}) AS _rank
  FROM _agg
)
SELECT ${aliasList}
FROM _ranked
WHERE _rank <= ${limitPerGroup}
ORDER BY ${firstDimAlias}, ${orderAlias} ${direction.toUpperCase()}`.trim();
  }

  // -------------------------------------------------------------------------
  // Points queries
  // -------------------------------------------------------------------------

  private buildPoints(config: PointsQueryConfig): string {
    for (const col of config.columns) {
      if (col.column !== undefined) {
        this.assertColumn(col.column);
      }
      validateColumnName(col.alias);
    }
    if (config.breakdown !== undefined) {
      this.assertColumn(config.breakdown);
    }

    const selectParts = config.columns.map((col) =>
      col.column !== undefined
        ? `CAST(${col.column} AS ${sqlCastType(col.cast)}) AS ${col.alias}`
        : `NULL AS ${col.alias}`,
    );

    if (config.breakdown !== undefined) {
      selectParts.push(`CAST(${config.breakdown} AS TEXT) AS _series`);
    }

    const whereClause = this.buildWhereClause(config.filters);

    let orderByClause = '';
    if (config.orderBy !== undefined && config.orderBy.length > 0) {
      const orderParts = config.orderBy.map(
        (o) => `${o.column} ${(o.direction ?? 'asc').toUpperCase()}`,
      );
      orderByClause = `ORDER BY ${orderParts.join(', ')}`;
    }

    if (config.maxPointsPerSeries !== undefined) {
      return this.buildStrideSampledPoints(
        selectParts,
        whereClause,
        orderByClause,
        config,
      );
    }

    return `
SELECT
  ${selectParts.join(',\n  ')}
FROM (${this.query})
${whereClause}
${orderByClause}`.trim();
  }

  /**
   * Wraps the base points query in a subquery that uses ROW_NUMBER() and
   * COUNT() window functions to stride-sample each series down to at most
   * `maxPointsPerSeries` rows. This keeps large datasets from being fully
   * transferred into JS memory.
   */
  private buildStrideSampledPoints(
    selectParts: string[],
    whereClause: string,
    orderByClause: string,
    config: PointsQueryConfig,
  ): string {
    const maxPts = config.maxPointsPerSeries ?? 0;
    const aliases = config.columns.map((col) => col.alias);
    if (config.breakdown !== undefined) {
      aliases.push('_series');
    }
    const aliasList = aliases.join(', ');

    // Use the full expression (not the alias) because SQLite cannot
    // resolve column aliases inside OVER clauses of the same SELECT.
    const partitionExpr =
      config.breakdown !== undefined
        ? `PARTITION BY CAST(${config.breakdown} AS TEXT)`
        : 'PARTITION BY 1';

    return `
SELECT ${aliasList}
FROM (
  SELECT
    ${selectParts.join(',\n    ')},
    ROW_NUMBER() OVER (${partitionExpr}) AS _rn,
    COUNT(*) OVER (${partitionExpr}) AS _cnt
  FROM (${this.query})
  ${whereClause}
)
WHERE (_rn - 1) % MAX(1, (_cnt + ${maxPts} - 1) / ${maxPts}) = 0
${orderByClause}`.trim();
  }

  // -------------------------------------------------------------------------
  // Histogram queries
  // -------------------------------------------------------------------------

  private buildHistogram(config: HistogramQueryConfig): string {
    this.assertColumn(config.valueColumn);

    const col = config.valueColumn;
    const bucketCount = config.bucketCount;
    const whereClause = this.buildWhereClause(config.filters);

    // Fixed-bucketization variant: callers compute "nice" boundaries (e.g.,
    // rounded to 1/2/5 * 10^n) externally and pass them in. The histogram
    // covers [minValue, minValue + bucketCount * bucketSize]; values outside
    // are clamped to the edge buckets so the tail isn't lost.
    if (config.bucketSize !== undefined && config.minValue !== undefined) {
      const min = config.minValue;
      const size = config.bucketSize;
      const max = min + bucketCount * size;
      return `
WITH _data AS (
  SELECT ${col} AS _value
  FROM (${this.query})
  ${whereClause}
)
SELECT
  ${min} AS _min,
  ${max} AS _max,
  (SELECT COUNT(*) FROM _data) AS _total,
  MIN(${bucketCount - 1}, MAX(0, CAST((_value - ${min}) / CAST(${size} AS REAL) AS INT))) AS _bucket_idx,
  COUNT(*) AS _count
FROM _data
GROUP BY _bucket_idx
ORDER BY _bucket_idx`.trim();
    }

    return `
WITH _data AS (
  SELECT ${col} AS _value
  FROM (${this.query})
  ${whereClause}
)
SELECT
  (SELECT MIN(_value) FROM _data) AS _min,
  (SELECT MAX(_value) FROM _data) AS _max,
  (SELECT COUNT(*) FROM _data) AS _total,
  CASE
    WHEN (SELECT MAX(_value) FROM _data) = (SELECT MIN(_value) FROM _data) THEN 0
    WHEN _value = (SELECT MAX(_value) FROM _data) THEN ${bucketCount - 1}
    ELSE MIN(${bucketCount - 1}, CAST(
      (_value - (SELECT MIN(_value) FROM _data)) /
      (((SELECT MAX(_value) FROM _data) - (SELECT MIN(_value) FROM _data)) / ${bucketCount}.0)
    AS INT))
  END AS _bucket_idx,
  COUNT(*) AS _count
FROM _data
GROUP BY _bucket_idx
ORDER BY _bucket_idx`.trim();
  }

  // -------------------------------------------------------------------------
  // Helpers
  // -------------------------------------------------------------------------

  private assertColumn(name: string): void {
    if (!(name in this.schema)) {
      throw new Error(
        `Column '${name}' not found in schema. ` +
          `Available: ${Object.keys(this.schema).join(', ')}`,
      );
    }
  }

  private buildWhereClause(filters: ReadonlyArray<Filter> | undefined): string {
    if (filters === undefined || filters.length === 0) return '';
    const conditions = filters.map((f) => `(${chartFilterToSql(f)})`);
    return `WHERE ${conditions.join(' AND ')}`;
  }

  /**
   * Build a HAVING clause that excludes groups where any non-COUNT measure
   * aggregated to NULL (i.e. all values in the group were NULL).
   */
  private buildHavingClause(measures: ReadonlyArray<MeasureSpec>): string {
    const conditions: string[] = [];
    for (const m of measures) {
      if (m.aggregation === 'COUNT' || m.aggregation === 'COUNT_DISTINCT') {
        continue;
      }
      conditions.push(
        `${sqlAggregateExpr(m.aggregation, m.column)} IS NOT NULL`,
      );
    }
    if (conditions.length === 0) return '';
    return `HAVING ${conditions.join(' AND ')}`;
  }

  private dimAlias(dim: DimensionSpec, index: number): string {
    if (dim.alias !== undefined) {
      validateColumnName(dim.alias);
      return dim.alias;
    }
    return index === 0 ? '_dim' : `_dim_${index}`;
  }

  private measureAlias(
    measures: ReadonlyArray<MeasureSpec>,
    index: number,
  ): string {
    const meas = measures[index];
    if (meas.alias !== undefined) {
      validateColumnName(meas.alias);
      return meas.alias;
    }
    return index === 0 ? DEFAULT_MEASURE_ALIAS : `_value_${index}`;
  }

  private dimSelectExprs(dims: ReadonlyArray<DimensionSpec>): string[] {
    return dims.map(
      (dim, i) => `CAST(${dim.column} AS TEXT) AS ${this.dimAlias(dim, i)}`,
    );
  }

  private measureSelectExprs(measures: ReadonlyArray<MeasureSpec>): string[] {
    return measures.map((meas, i) => {
      // COUNT is field-independent (counts all rows), so we use COUNT(*) rather
      // than COUNT(column) which would exclude NULLs. All other aggregations
      // delegate to sqlAggregateExpr which references the column directly.
      const aggExpr =
        meas.aggregation === 'COUNT'
          ? 'COUNT(*)'
          : sqlAggregateExpr(meas.aggregation, meas.column);
      return `${aggExpr} AS ${this.measureAlias(measures, i)}`;
    });
  }
}

function sqlCastType(cast: 'real' | 'text'): string {
  return cast === 'real' ? 'REAL' : 'TEXT';
}

/**
 * Convert a chart filter to SQL. Extends the base `filterToSql` with
 * proper NULL handling for IN / NOT IN filters: SQL `IN (...)` never
 * matches NULL, so null values are split into a separate IS NULL / IS NOT
 * NULL clause joined with OR / AND respectively.
 */
function chartFilterToSql(filter: Filter): string {
  if (
    (filter.op === 'in' || filter.op === 'not in') &&
    filter.value.some((v) => v === null)
  ) {
    const nonNull = filter.value.filter((v) => v !== null);
    const negated = filter.op === 'not in';
    const parts: string[] = [];
    if (nonNull.length > 0) {
      parts.push(filterToSql({...filter, value: nonNull}, filter.field));
    }
    parts.push(
      filterToSql(
        {field: filter.field, op: negated ? 'is not null' : 'is null'},
        filter.field,
      ),
    );
    if (parts.length === 1) return parts[0];
    const joiner = negated ? ' AND ' : ' OR ';
    return `(${parts.join(joiner)})`;
  }
  return filterToSql(filter, filter.field);
}

// ---------------------------------------------------------------------------
// SQLChartLoader base class
// ---------------------------------------------------------------------------

/**
 * Result returned by SQL chart loaders.
 */
export interface ChartLoaderResult<TData> {
  readonly data: TData | undefined;
  readonly isPending: boolean;
}

/**
 * Abstract base for SQL-backed chart loaders.
 *
 * Handles QuerySlot lifecycle, query execution via Engine, and caching.
 * Subclasses implement:
 * - `buildQueryConfig()` to turn per-use config into a QueryConfig
 * - `parseResult()` to turn query rows into chart-specific data
 */
export abstract class SQLChartLoader<TConfig, TData> {
  private readonly engine: Engine;
  protected readonly source: ChartSource;
  private readonly querySlot = new QuerySlot<TData>();

  constructor(engine: Engine, source: ChartSource) {
    this.engine = engine;
    this.source = source;
  }

  use(config: TConfig): ChartLoaderResult<TData> {
    const queryConfig = this.buildQueryConfig(config);
    const sql = this.source.buildQuery(queryConfig);
    const extra = this.extraCacheKey(config);
    const key = {sql, ...extra};
    const result = this.querySlot.use({
      key,
      queryFn: async () => {
        const queryResult = await this.engine.query(sql);
        return this.parseResult(queryResult, config);
      },
      // Retain stale chart data while new queries are in flight (e.g., during
      // brush/filter changes) to avoid flashing a loading spinner.
      retainOn: Object.keys(key) as (keyof typeof key)[],
    });
    return {data: result.data, isPending: result.isPending};
  }

  dispose(): void {
    this.querySlot.dispose();
  }

  /** Build the QueryConfig from the per-use config. */
  protected abstract buildQueryConfig(config: TConfig): QueryConfig;

  /** Parse query result rows into chart-specific data. */
  protected abstract parseResult(
    queryResult: TPQueryResult,
    config: TConfig,
  ): TData;

  /**
   * Override to add extra fields to the cache key for post-processing
   * params that don't affect the SQL but do affect the output.
   */
  protected extraCacheKey(
    _config: TConfig,
  ): Record<string, string | number | boolean | undefined> {
    return {};
  }
}

// ---------------------------------------------------------------------------
// Filter construction helpers
// ---------------------------------------------------------------------------

/**
 * Create an IN filter if values are provided, otherwise returns empty array.
 * NULL values in the array are supported — `buildWhereClause` generates
 * the proper `(col IN (...) OR col IS NULL)` SQL since SQL `IN (...)`
 * does not match NULLs on its own.
 */
export function inFilter(
  field: string,
  values: ReadonlyArray<string | number | null> | undefined,
): Filter[] {
  if (values === undefined || values.length === 0) return [];
  return [{field, op: 'in', value: [...values]}];
}

/**
 * Create range filters (>= min, <= max) if range is provided.
 */
export function rangeFilters(
  field: string,
  range: {readonly min: number; readonly max: number} | undefined,
): Filter[] {
  if (range === undefined) return [];
  return [
    {field, op: '>=', value: range.min},
    {field, op: '<=', value: range.max},
  ];
}
