import {
  aggregationFunctions,
  AggregationTypes,
  applyFilters,
  applySorting,
  getApplicableFilters,
  groupValuesByColumn,
  groupValuesByDateColumn,
  histogram,
  OTHERS_CATEGORY_NAME,
  scatterPlot,
  WidgetSource,
  type AggregationsRequestOptions,
  type AggregationsResponse,
  type CategoryRequestOptions,
  type CategoryResponse,
  type ExtentResponse,
  type FeaturesResponse,
  type Filter,
  type FormulaRequestOptions,
  type FormulaResponse,
  type HistogramRequestOptions,
  type HistogramResponse,
  type RangeRequestOptions,
  type RangeResponse,
  type ScatterRequestOptions,
  type ScatterResponse,
  type SpatialFilter,
  type TableRequestOptions,
  type TableResponse,
  type TimeSeriesRequestOptions,
  type TimeSeriesResponse,
} from '@carto/api-client'
import { geojsonFeatures } from './geojson-features'
import type { WidgetGeoJsonSourceProps } from './widget-geojson-source'
import type { FeatureData } from './types'
import { bbox, booleanIntersects, feature, featureCollection } from '@turf/turf'

// TODO(cleanup): Parameter defaults in source functions and widget API calls are
// currently duplicated and possibly inconsistent. Consider consolidating and
// operating on Required<T> objects. See:
// https://github.com/CartoDB/carto-api-client/issues/39

/**
 * Local (in-memory) implementation of geojson widget calculations. This class
 * may be instantiated by {@link WidgetGeoJsonSource} in a Web Worker when
 * supported, or on the main thread.
 */
export class WidgetGeoJsonSourceImpl extends WidgetSource<WidgetGeoJsonSourceProps> {
  private features: FeatureData[] = []

  protected _filterFeaturesBySpatial(spatialFilter?: SpatialFilter) {
    if (!spatialFilter) {
      return this.features
    }

    return this.features.filter((f) => {
      return booleanIntersects(f, feature(spatialFilter))
    })
  }

  /**
   * Loads features as GeoJSON.
   */
  loadGeoJSON({ geojson }: { geojson: FeatureData[] }) {
    this.features = geojsonFeatures({
      geojson,
    })
  }

  override getFeatures(): Promise<FeaturesResponse> {
    return Promise.reject(new Error('getFeatures not supported for geojson'))
  }

  getFormula({
    column = '*',
    operation = AggregationTypes.Count,
    joinOperation,
    filters,
    filterOwner,
    spatialFilter,
  }: FormulaRequestOptions): Promise<FormulaResponse> {
    const filteredFeatures = this._getFilteredFeatures(
      spatialFilter,
      filters,
      filterOwner,
    )

    if (filteredFeatures.length === 0) {
      return Promise.resolve({ value: null })
    }

    if (operation === AggregationTypes.Custom) {
      throw new Error('Custom aggregation not supported for geojson')
    }

    // Column is required except when operation is 'count'.
    if ((column && column !== '*') || operation !== AggregationTypes.Count) {
      assertColumn(filteredFeatures, column)
    }

    const targetOperation = aggregationFunctions[operation]
    assert(targetOperation, `Unsupported aggregation operation: ${operation}`)

    return Promise.resolve({
      value: targetOperation(filteredFeatures, column, joinOperation),
    })
  }

  override getHistogram({
    operation = AggregationTypes.Count,
    ticks,
    column,
    joinOperation,
    filters,
    filterOwner,
    spatialFilter,
  }: HistogramRequestOptions): Promise<HistogramResponse> {
    const filteredFeatures = this._getFilteredFeatures(
      spatialFilter,
      filters,
      filterOwner,
    )

    if (!filteredFeatures.length) {
      return Promise.resolve([])
    }

    assertColumn(filteredFeatures, column)

    return Promise.resolve(
      histogram({
        data: filteredFeatures,
        valuesColumns: normalizeColumns(column),
        joinOperation,
        ticks,
        operation,
      }),
    )
  }

  override getCategories({
    column,
    operation = AggregationTypes.Count,
    operationColumn,
    joinOperation,
    filters,
    filterOwner,
    spatialFilter,
    othersThreshold,
    orderBy = 'frequency_desc',
    rawResult,
  }: CategoryRequestOptions): Promise<CategoryResponse> {
    const filteredFeatures = this._getFilteredFeatures(
      spatialFilter,
      filters,
      filterOwner,
    )

    if (!filteredFeatures.length) {
      return Promise.resolve([])
    }

    assertColumn(filteredFeatures, column, operationColumn!)

    const result = groupValuesByColumn({
      data: filteredFeatures,
      valuesColumns: normalizeColumns(operationColumn ?? column),
      joinOperation,
      keysColumn: column,
      operation,
      othersThreshold,
      orderBy,
    })

    if (rawResult) {
      return Promise.resolve(result as unknown as CategoryResponse)
    }

    if (!othersThreshold) {
      return Promise.resolve(result?.rows ?? [])
    }

    return Promise.resolve([
      ...(result?.rows ?? []),
      { name: OTHERS_CATEGORY_NAME, value: result?.metadata?.others ?? 0 },
    ])
  }

  override getScatter({
    xAxisColumn,
    yAxisColumn,
    xAxisJoinOperation,
    yAxisJoinOperation,
    filters,
    filterOwner,
    spatialFilter,
  }: ScatterRequestOptions): Promise<ScatterResponse> {
    const filteredFeatures = this._getFilteredFeatures(
      spatialFilter,
      filters,
      filterOwner,
    )

    if (!filteredFeatures.length) {
      return Promise.resolve([])
    }

    assertColumn(filteredFeatures, xAxisColumn, yAxisColumn)

    return Promise.resolve(
      scatterPlot({
        data: filteredFeatures,
        xAxisColumns: normalizeColumns(xAxisColumn),
        xAxisJoinOperation,
        yAxisColumns: normalizeColumns(yAxisColumn),
        yAxisJoinOperation,
      }),
    )
  }

  override getTable({
    columns,
    searchFilterColumn,
    searchFilterText,
    sortBy,
    sortDirection,
    sortByColumnType,
    offset = 0,
    limit = 10,
    filters,
    filterOwner,
    spatialFilter,
  }: TableRequestOptions): Promise<TableResponse> {
    // Filter.
    let filteredFeatures = this._getFilteredFeatures(
      spatialFilter,
      filters,
      filterOwner,
    )

    if (!filteredFeatures.length) {
      return Promise.resolve({ rows: [], totalCount: 0 })
    }

    // Search.
    // TODO(v0.6): Remove "searchFilterText" and "searchFilterColumn".
    if (searchFilterColumn && searchFilterText) {
      // eslint-disable-next-line no-console
      console.warn(
        'WidgetGeoJsonSource: "searchFilterText" is deprecated, use "filters" and FilterType.STRING_SEARCH instead.',
      )
      filteredFeatures = filteredFeatures.filter(
        (row) =>
          row[searchFilterColumn] &&
          String(row[searchFilterColumn] as unknown)
            .toLowerCase()
            .includes(String(searchFilterText).toLowerCase()),
      )
    }

    // Sort.
    let rows = applySorting(filteredFeatures, {
      sortBy,
      sortByDirection: sortDirection,
      sortByColumnType,
    })
    const totalCount = rows.length

    // Offset and limit.
    rows = rows.slice(
      Math.min(offset, totalCount),
      Math.min(offset + limit, totalCount),
    )

    // Select columns.
    rows = rows.map((srcRow: FeatureData['properties']) => {
      const dstRow: FeatureData['properties'] = {}
      if (srcRow) {
        for (const column of columns) {
          dstRow[column] = srcRow[column] as unknown
        }
      }
      return dstRow
    })

    return Promise.resolve({ rows, totalCount } as TableResponse)
  }

  override getTimeSeries({
    column,
    stepSize,
    operation,
    operationColumn,
    joinOperation,
    filters,
    filterOwner,
    spatialFilter,
  }: TimeSeriesRequestOptions): Promise<TimeSeriesResponse> {
    const filteredFeatures = this._getFilteredFeatures(
      spatialFilter,
      filters,
      filterOwner,
    )

    if (!filteredFeatures.length) {
      return Promise.resolve({ rows: [] })
    }

    assertColumn(filteredFeatures, column, operationColumn!)
    assert(operation !== 'custom', 'Custom operation not supported for geojson')

    const rows =
      groupValuesByDateColumn({
        data: filteredFeatures,
        valuesColumns: normalizeColumns(operationColumn ?? column),
        keysColumn: column,
        groupType: stepSize,
        operation,
        joinOperation,
      }) ?? []

    return Promise.resolve({ rows })
  }

  override getRange({
    column,
    filters,
    filterOwner,
    spatialFilter,
  }: RangeRequestOptions): Promise<RangeResponse> {
    const filteredFeatures = this._getFilteredFeatures(
      spatialFilter,
      filters,
      filterOwner,
    )

    if (!filteredFeatures.length) {
      // TODO: Is this the only nullable response in the Widgets API? If so,
      // can we do something more consistent?
      return Promise.resolve(null)
    }

    assertColumn(filteredFeatures, column)

    return Promise.resolve({
      min: aggregationFunctions.min(filteredFeatures, column),
      max: aggregationFunctions.max(filteredFeatures, column),
    })
  }

  getAggregations({
    aggregations,
    filters,
    filterOwner,
    spatialFilter,
  }: AggregationsRequestOptions): Promise<AggregationsResponse> {
    const filteredFeatures = this._getFilteredFeatures(
      spatialFilter,
      filters,
      filterOwner,
    )

    if (!filteredFeatures.length) {
      return Promise.resolve({ rows: [] })
    }

    // SQL aggregations require remote execution, and are not supported for geojson.
    assert(
      typeof aggregations !== 'string',
      'Unsupported geojson SQL aggregation',
    )

    // Handle array-based aggregations
    const result: Record<string, number> = {}
    const usedAliases = new Set<string>()

    for (const { column, operation, alias } of aggregations) {
      // Column is required except when operation is 'count'.
      if ((column && column !== '*') || operation !== AggregationTypes.Count) {
        assertColumn(filteredFeatures, column)
      }

      const aliasKey = alias.toLowerCase()
      assert(!usedAliases.has(aliasKey), `Duplicate alias: ${aliasKey}`)
      usedAliases.add(aliasKey)

      const targetOperation = aggregationFunctions[operation]
      assert(targetOperation, `Unsupported operation: ${operation}`)

      result[alias] = targetOperation(filteredFeatures, column)
    }

    return Promise.resolve({ rows: [result] })
  }

  /** @experimental */
  getExtent(): Promise<ExtentResponse> {
    const _bbox = bbox(featureCollection(this.features))
    return Promise.resolve({ bbox: _bbox })
  }

  /****************************************************************************
   * INTERNAL
   */

  private _getFilteredFeatures(
    spatialFilter?: SpatialFilter,
    filters?: Record<string, Filter>,
    filterOwner?: string,
  ): NonNullable<FeatureData['properties']>[] {
    const _features = this._filterFeaturesBySpatial(spatialFilter)
    return applyFilters(
      _features.map((f) => f.properties ?? {}),
      getApplicableFilters(filterOwner, filters ?? this.props.filters),
      this.props.filtersLogicalOperator ?? 'and',
    )
  }
}

function assert(condition: unknown, message: string): asserts condition {
  if (!condition) {
    throw new Error(message)
  }
}

function assertColumn(
  features: FeatureData['properties'][],
  ...columnArgs: string[] | string[][]
) {
  // Due to the multiple column shape, we normalise it as an array with normalizeColumns
  const columns = Array.from(new Set(columnArgs.map(normalizeColumns).flat()))

  const featureKeys = Object.keys(features[0] ?? {})

  // For backward compatibility, '' should pass column validation. For example,
  // operation='count',operationColumn='' is accepted.
  const invalidColumns = columns.filter(
    (column) => column && !featureKeys.includes(column),
  )

  if (invalidColumns.length) {
    throw new Error(`Missing column(s): ${invalidColumns.join(', ')}`)
  }
}

function normalizeColumns(columns: string | string[]): string[] {
  return Array.isArray(columns)
    ? columns
    : typeof columns === 'string'
      ? [columns]
      : []
}
