import deepmerge from 'deepmerge'
import {
  addFilter,
  type AddFilterOptions,
  type Filter,
  type Filters,
} from '@carto/api-client'
import { useSourceStore } from '../../providers/sources/store'
import type { Source } from '../../providers/sources/types'
import { buildQueryKey } from './build-query-key'
import {
  resolveColumnForSource,
  resolvePropertyForSource,
} from './resolve-property-for-source'

type SourceWidgets = NonNullable<Source['widgets']>

/**
 * A pre-read slice of `useSourceStore` for one source id. Threaded through by
 * the hook path so the values that drive the query key come from a real
 * subscription (re-rendering the caller when they change), while the
 * imperative path can omit it and fall back to a `getState()` read.
 */
export interface SourceStateSlice {
  source: Source | undefined
  filters: Filters | undefined
}

/**
 * Function that, given a source's widget model, returns the request method
 * for a particular widget kind (categories, formula, …).
 *
 * Identical shape to `ModelConfig['getWidgetMethod']` in `create-model.tsx` so
 * the same lambda can power both the imperative factory and the hook.
 */
export type GetWidgetMethod<TWidgetProps extends object, TResponse> = (
  widgetModel: SourceWidgets,
) => (
  params: TWidgetProps & { filters?: unknown; signal?: AbortSignal },
) => Promise<TResponse>

/**
 * React Query options object produced by per-model factories like
 * `getCategories`, `getFormula`, etc. Directly consumable by
 * `queryClient.prefetchQuery`, `queryClient.fetchQuery`,
 * `queryClient.ensureQueryData`, and `useQuery`.
 */
export interface ModelQueryOptions<TResponse> {
  queryKey: unknown[]
  queryFn: (ctx: { signal: AbortSignal }) => Promise<TResponse>
  enabled: boolean
}

export interface GetModelQueryOptionsArgs<
  TWidgetProps extends object,
  TResponse,
> {
  queryKeyPrefix: string[]
  getWidgetMethod: GetWidgetMethod<TWidgetProps, TResponse>
  accessToken: string
  sourceId: string
  widgetProps: TWidgetProps
  additionalFilter?: AddFilterOptions[]
  /**
   * Position of this source within a multi-source call. Drives per-source
   * column / operation / operationExp / operationColumn / spatialFilter
   * resolution when the corresponding prop is an array. Defaults to 0
   * (single-source).
   */
  sourceIndex?: number
  /**
   * Total number of sources in the call. Validated against array-prop lengths
   * by `resolvePropertyForSource`. Defaults to 1 (single-source).
   */
  totalSources?: number
  /**
   * Pre-read store slice for this source. When provided, it is used as the
   * source of truth for `source` and `filters`; otherwise the function reads
   * `useSourceStore.getState()` directly. The hook path (`createModel`) passes
   * this so the values come from a real `useSourceStore` subscription
   * (`useSourceSlices`) — which is what makes `setSource(...)` and filter
   * mutations re-trigger query-key construction. The imperative path
   * (`queryClient.prefetchQuery(getXxx(...))`) omits it and falls back to a
   * fresh `getState()` read.
   */
  sourceState?: SourceStateSlice
}

/**
 * Builds the React Query options for a single source.
 *
 * The `queryKey` shape is identical to what the `useXxx` hook (via
 * `createModel`) produces for the same inputs, which guarantees the
 * imperative path (e.g. `queryClient.prefetchQuery(getCategories(...))`)
 * and the reactive hook share the same cache entry.
 *
 * @remarks
 * The widget model's access token is mutated (`widgetModel.props.accessToken
 * = accessToken`) just before each request, mirroring `useWidgetModel`.
 * Concurrent calls for the same `sourceId` with **different** tokens may
 * race — the underlying `WidgetClient` is shared singleton state on the
 * source. Use distinct `sourceId`s if you need token isolation.
 */
export function getModelQueryOptions<TWidgetProps extends object, TResponse>({
  queryKeyPrefix,
  getWidgetMethod,
  accessToken,
  sourceId,
  widgetProps,
  additionalFilter,
  sourceIndex = 0,
  totalSources = 1,
  sourceState,
}: GetModelQueryOptionsArgs<
  TWidgetProps,
  TResponse
>): ModelQueryOptions<TResponse> {
  const slice: SourceStateSlice = sourceState ?? readSliceFromStore(sourceId)
  const source = slice.source
  const widgetModel = source?.widgets ?? null

  const resolvedColumn =
    'column' in widgetProps
      ? resolveColumnForSource(
          (widgetProps as { column?: string | string[] }).column,
          sourceIndex,
          totalSources,
        )
      : undefined

  const resolvedOperation =
    'operation' in widgetProps
      ? resolvePropertyForSource(
          (widgetProps as { operation?: unknown }).operation,
          sourceIndex,
          totalSources,
          'operation',
        )
      : undefined

  const resolvedOperationExp =
    'operationExp' in widgetProps
      ? resolvePropertyForSource(
          (widgetProps as { operationExp?: unknown }).operationExp,
          sourceIndex,
          totalSources,
          'operationExp',
        )
      : undefined

  const resolvedOperationColumn =
    'operationColumn' in widgetProps
      ? resolvePropertyForSource(
          (widgetProps as { operationColumn?: unknown }).operationColumn,
          sourceIndex,
          totalSources,
          'operationColumn',
        )
      : undefined

  const resolvedSpatialFilter =
    'spatialFilter' in widgetProps
      ? resolvePropertyForSource(
          (widgetProps as { spatialFilter?: unknown }).spatialFilter,
          sourceIndex,
          totalSources,
          'spatialFilter',
        )
      : undefined

  // Widget props baked into the query key (mirrors use-common-model.ts:128-140
  // — only column/operation/operationExp/operationColumn are projected into the
  // key, spatialFilter is applied at request time only). Gated on key-presence
  // (not value-presence) so the resolved per-source scalar always replaces the
  // raw array spread from `...widgetProps`; `undefined` values are dropped by
  // JSON.stringify, so a masked source contributes nothing to the key.
  const resolvedWidgetPropsForKey: Record<string, unknown> = {
    ...widgetProps,
    ...('column' in widgetProps && { column: resolvedColumn }),
    ...('operation' in widgetProps && { operation: resolvedOperation }),
    ...('operationExp' in widgetProps && {
      operationExp: resolvedOperationExp,
    }),
    ...('operationColumn' in widgetProps && {
      operationColumn: resolvedOperationColumn,
    }),
  }

  const baseFilters = slice.filters
  const mergedFilter = mergeFilters(baseFilters, additionalFilter)

  const queryKey: unknown[] = [
    ...buildQueryKey(queryKeyPrefix, sourceId, source),
    JSON.stringify(resolvedWidgetPropsForKey),
  ]
  if (mergedFilter) {
    queryKey.push(JSON.stringify(mergedFilter))
  }

  const enabled = !!widgetModel

  const queryFn = async ({ signal }: { signal: AbortSignal }) => {
    if (!widgetModel) {
      throw new Error(
        `getModelQueryOptions: source "${sourceId}" has no widget model — register it via useSourceStore.getState().setSource() first`,
      )
    }
    widgetModel.props.accessToken = accessToken

    const widgetMethod = getWidgetMethod(widgetModel)
    // Gated on key-presence (not value-presence): when a prop is passed as a
    // per-source array, the resolved scalar must overwrite the raw array left
    // by `...widgetProps`, even when it resolves to `undefined` (e.g. a masked
    // source's spatialFilter). Value-presence gating would skip the override
    // and leak the raw array into the request body — which the Maps API rejects
    // with HTTP 400.
    const requestParams = {
      ...widgetProps,
      ...('column' in widgetProps && { column: resolvedColumn }),
      ...('operation' in widgetProps && { operation: resolvedOperation }),
      ...('operationExp' in widgetProps && {
        operationExp: resolvedOperationExp,
      }),
      ...('operationColumn' in widgetProps && {
        operationColumn: resolvedOperationColumn,
      }),
      ...('spatialFilter' in widgetProps && {
        spatialFilter: resolvedSpatialFilter,
      }),
    } as TWidgetProps

    return widgetMethod({
      ...requestParams,
      filters: mergedFilter,
      signal,
    })
  }

  return { queryKey, queryFn, enabled }
}

function readSliceFromStore(sourceId: string): SourceStateSlice {
  const state = useSourceStore.getState()
  return {
    source: state.sources[sourceId],
    filters: state.filters[sourceId] as Filters | undefined,
  }
}

function mergeFilters(
  baseFilters: Filters | undefined,
  additionalFilter: AddFilterOptions[] | undefined,
): Filter | undefined {
  if (!additionalFilter?.length) {
    return baseFilters as Filter | undefined
  }

  const computed = additionalFilter.reduce<Filters>(
    (acc, opt) => addFilter(acc, opt),
    {},
  )

  if (!baseFilters) return computed as Filter
  return deepmerge(baseFilters, computed) as Filter
}
