import { CollectionImpl } from '../../collection/index.js'
import {
  Aggregate as AggregateExpr,
  CollectionRef,
  Func as FuncExpr,
  INCLUDES_SCALAR_FIELD,
  IncludesSubquery,
  PropRef,
  QueryRef,
  Value as ValueExpr,
  isExpressionLike,
} from '../ir.js'
import {
  InvalidSourceError,
  InvalidSourceTypeError,
  InvalidWhereExpressionError,
  JoinConditionMustBeEqualityError,
  OnlyOneSourceAllowedError,
  QueryMustHaveFromClauseError,
  SubQueryMustHaveFromClauseError,
} from '../../errors.js'
import {
  createRefProxy,
  createRefProxyWithSelected,
  isRefProxy,
  toExpression,
} from './ref-proxy.js'
import { ConcatToArrayWrapper, ToArrayWrapper } from './functions.js'
import type { NamespacedRow, SingleResult } from '../../types.js'
import type {
  Aggregate,
  BasicExpression,
  IncludesMaterialization,
  JoinClause,
  OrderBy,
  OrderByDirection,
  QueryIR,
  Where,
} from '../ir.js'
import type {
  CompareOptions,
  Context,
  FunctionalHavingRow,
  GetResult,
  GroupByCallback,
  JoinOnCallback,
  MergeContextForJoinCallback,
  MergeContextWithJoinType,
  NonScalarSelectObject,
  OrderByCallback,
  OrderByOptions,
  RefsForContext,
  ResultTypeFromSelect,
  ResultTypeFromSelectValue,
  ScalarSelectValue,
  SchemaFromSource,
  SelectObject,
  Source,
  WhereCallback,
  WithResult,
} from './types.js'

export class BaseQueryBuilder<TContext extends Context = Context> {
  private readonly query: Partial<QueryIR> = {}

  constructor(query: Partial<QueryIR> = {}) {
    this.query = { ...query }
  }

  /**
   * Creates a CollectionRef or QueryRef from a source object
   * @param source - An object with a single key-value pair
   * @param context - Context string for error messages (e.g., "from clause", "join clause")
   * @returns A tuple of [alias, ref] where alias is the source key and ref is the created reference
   */
  private _createRefForSource<TSource extends Source>(
    source: TSource,
    context: string,
  ): [string, CollectionRef | QueryRef] {
    // Validate source is a plain object (not null, array, string, etc.)
    // We use try-catch to handle null/undefined gracefully
    let keys: Array<string>
    try {
      keys = Object.keys(source)
    } catch {
      // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
      const type = source === null ? `null` : `undefined`
      throw new InvalidSourceTypeError(context, type)
    }

    // Check if it's an array (arrays pass Object.keys but aren't valid sources)
    if (Array.isArray(source)) {
      throw new InvalidSourceTypeError(context, `array`)
    }

    // Validate exactly one key
    if (keys.length !== 1) {
      if (keys.length === 0) {
        throw new InvalidSourceTypeError(context, `empty object`)
      }
      // Check if it looks like a string was passed (has numeric keys)
      if (keys.every((k) => !isNaN(Number(k)))) {
        throw new InvalidSourceTypeError(context, `string`)
      }
      throw new OnlyOneSourceAllowedError(context)
    }

    const alias = keys[0]!
    const sourceValue = source[alias]

    // Validate the value is a Collection or QueryBuilder
    let ref: CollectionRef | QueryRef

    if (sourceValue instanceof CollectionImpl) {
      ref = new CollectionRef(sourceValue, alias)
    } else if (sourceValue instanceof BaseQueryBuilder) {
      const subQuery = sourceValue._getQuery()
      if (!(subQuery as Partial<QueryIR>).from) {
        throw new SubQueryMustHaveFromClauseError(context)
      }
      ref = new QueryRef(subQuery, alias)
    } else {
      throw new InvalidSourceError(alias)
    }

    return [alias, ref]
  }

  /**
   * Specify the source table or subquery for the query
   *
   * @param source - An object with a single key-value pair where the key is the table alias and the value is a Collection or subquery
   * @returns A QueryBuilder with the specified source
   *
   * @example
   * ```ts
   * // Query from a collection
   * query.from({ users: usersCollection })
   *
   * // Query from a subquery
   * const activeUsers = query.from({ u: usersCollection }).where(({u}) => u.active)
   * query.from({ activeUsers })
   * ```
   */
  from<TSource extends Source>(
    source: TSource,
  ): QueryBuilder<{
    baseSchema: SchemaFromSource<TSource>
    schema: SchemaFromSource<TSource>
    fromSourceName: keyof TSource & string
    hasJoins: false
  }> {
    const [, from] = this._createRefForSource(source, `from clause`)

    return new BaseQueryBuilder({
      ...this.query,
      from,
    }) as any
  }

  /**
   * Join another table or subquery to the current query
   *
   * @param source - An object with a single key-value pair where the key is the table alias and the value is a Collection or subquery
   * @param onCallback - A function that receives table references and returns the join condition
   * @param type - The type of join: 'inner', 'left', 'right', or 'full' (defaults to 'left')
   * @returns A QueryBuilder with the joined table available
   *
   * @example
   * ```ts
   * // Left join users with posts
   * query
   *   .from({ users: usersCollection })
   *   .join({ posts: postsCollection }, ({users, posts}) => eq(users.id, posts.userId))
   *
   * // Inner join with explicit type
   * query
   *   .from({ u: usersCollection })
   *   .join({ p: postsCollection }, ({u, p}) => eq(u.id, p.userId), 'inner')
   * ```
   *
   * // Join with a subquery
   * const activeUsers = query.from({ u: usersCollection }).where(({u}) => u.active)
   * query
   *   .from({ activeUsers })
   *   .join({ p: postsCollection }, ({u, p}) => eq(u.id, p.userId))
   */
  join<
    TSource extends Source,
    TJoinType extends `inner` | `left` | `right` | `full` = `left`,
  >(
    source: TSource,
    onCallback: JoinOnCallback<
      MergeContextForJoinCallback<TContext, SchemaFromSource<TSource>>
    >,
    type: TJoinType = `left` as TJoinType,
  ): QueryBuilder<
    MergeContextWithJoinType<TContext, SchemaFromSource<TSource>, TJoinType>
  > {
    const [alias, from] = this._createRefForSource(source, `join clause`)

    // Create a temporary context for the callback
    const currentAliases = this._getCurrentAliases()
    const newAliases = [...currentAliases, alias]
    const refProxy = createRefProxy(newAliases) as RefsForContext<
      MergeContextForJoinCallback<TContext, SchemaFromSource<TSource>>
    >

    // Get the join condition expression
    const onExpression = onCallback(refProxy)

    // Extract left and right from the expression
    // For now, we'll assume it's an eq function with two arguments
    let left: BasicExpression
    let right: BasicExpression

    if (
      onExpression.type === `func` &&
      onExpression.name === `eq` &&
      onExpression.args.length === 2
    ) {
      left = onExpression.args[0]!
      right = onExpression.args[1]!
    } else {
      throw new JoinConditionMustBeEqualityError()
    }

    const joinClause: JoinClause = {
      from,
      type,
      left,
      right,
    }

    const existingJoins = this.query.join || []

    return new BaseQueryBuilder({
      ...this.query,
      join: [...existingJoins, joinClause],
    }) as any
  }

  /**
   * Perform a LEFT JOIN with another table or subquery
   *
   * @param source - An object with a single key-value pair where the key is the table alias and the value is a Collection or subquery
   * @param onCallback - A function that receives table references and returns the join condition
   * @returns A QueryBuilder with the left joined table available
   *
   * @example
   * ```ts
   * // Left join users with posts
   * query
   *   .from({ users: usersCollection })
   *   .leftJoin({ posts: postsCollection }, ({users, posts}) => eq(users.id, posts.userId))
   * ```
   */
  leftJoin<TSource extends Source>(
    source: TSource,
    onCallback: JoinOnCallback<
      MergeContextForJoinCallback<TContext, SchemaFromSource<TSource>>
    >,
  ): QueryBuilder<
    MergeContextWithJoinType<TContext, SchemaFromSource<TSource>, `left`>
  > {
    return this.join(source, onCallback, `left`)
  }

  /**
   * Perform a RIGHT JOIN with another table or subquery
   *
   * @param source - An object with a single key-value pair where the key is the table alias and the value is a Collection or subquery
   * @param onCallback - A function that receives table references and returns the join condition
   * @returns A QueryBuilder with the right joined table available
   *
   * @example
   * ```ts
   * // Right join users with posts
   * query
   *   .from({ users: usersCollection })
   *   .rightJoin({ posts: postsCollection }, ({users, posts}) => eq(users.id, posts.userId))
   * ```
   */
  rightJoin<TSource extends Source>(
    source: TSource,
    onCallback: JoinOnCallback<
      MergeContextForJoinCallback<TContext, SchemaFromSource<TSource>>
    >,
  ): QueryBuilder<
    MergeContextWithJoinType<TContext, SchemaFromSource<TSource>, `right`>
  > {
    return this.join(source, onCallback, `right`)
  }

  /**
   * Perform an INNER JOIN with another table or subquery
   *
   * @param source - An object with a single key-value pair where the key is the table alias and the value is a Collection or subquery
   * @param onCallback - A function that receives table references and returns the join condition
   * @returns A QueryBuilder with the inner joined table available
   *
   * @example
   * ```ts
   * // Inner join users with posts
   * query
   *   .from({ users: usersCollection })
   *   .innerJoin({ posts: postsCollection }, ({users, posts}) => eq(users.id, posts.userId))
   * ```
   */
  innerJoin<TSource extends Source>(
    source: TSource,
    onCallback: JoinOnCallback<
      MergeContextForJoinCallback<TContext, SchemaFromSource<TSource>>
    >,
  ): QueryBuilder<
    MergeContextWithJoinType<TContext, SchemaFromSource<TSource>, `inner`>
  > {
    return this.join(source, onCallback, `inner`)
  }

  /**
   * Perform a FULL JOIN with another table or subquery
   *
   * @param source - An object with a single key-value pair where the key is the table alias and the value is a Collection or subquery
   * @param onCallback - A function that receives table references and returns the join condition
   * @returns A QueryBuilder with the full joined table available
   *
   * @example
   * ```ts
   * // Full join users with posts
   * query
   *   .from({ users: usersCollection })
   *   .fullJoin({ posts: postsCollection }, ({users, posts}) => eq(users.id, posts.userId))
   * ```
   */
  fullJoin<TSource extends Source>(
    source: TSource,
    onCallback: JoinOnCallback<
      MergeContextForJoinCallback<TContext, SchemaFromSource<TSource>>
    >,
  ): QueryBuilder<
    MergeContextWithJoinType<TContext, SchemaFromSource<TSource>, `full`>
  > {
    return this.join(source, onCallback, `full`)
  }

  /**
   * Filter rows based on a condition
   *
   * @param callback - A function that receives table references and returns an expression
   * @returns A QueryBuilder with the where condition applied
   *
   * @example
   * ```ts
   * // Simple condition
   * query
   *   .from({ users: usersCollection })
   *   .where(({users}) => gt(users.age, 18))
   *
   * // Multiple conditions
   * query
   *   .from({ users: usersCollection })
   *   .where(({users}) => and(
   *     gt(users.age, 18),
   *     eq(users.active, true)
   *   ))
   *
   * // Multiple where calls are ANDed together
   * query
   *   .from({ users: usersCollection })
   *   .where(({users}) => gt(users.age, 18))
   *   .where(({users}) => eq(users.active, true))
   * ```
   */
  where(callback: WhereCallback<TContext>): QueryBuilder<TContext> {
    const aliases = this._getCurrentAliases()
    const refProxy = createRefProxy(aliases) as RefsForContext<TContext>
    const rawExpression = callback(refProxy)

    // Allow bare boolean column references like `.where(({ u }) => u.active)`
    // by converting ref proxies to PropRef expressions, the same way helper
    // functions like `not()` and `eq()` do via `toExpression()`.
    const expression = isRefProxy(rawExpression)
      ? toExpression(rawExpression)
      : rawExpression

    // Validate that the callback returned a valid expression
    // This catches common mistakes like using JavaScript comparison operators (===, !==, etc.)
    // which return boolean primitives instead of expression objects
    if (!isExpressionLike(expression)) {
      throw new InvalidWhereExpressionError(getValueTypeName(expression))
    }

    const existingWhere = this.query.where || []

    return new BaseQueryBuilder({
      ...this.query,
      where: [...existingWhere, expression],
    }) as any
  }

  /**
   * Filter grouped rows based on aggregate conditions
   *
   * @param callback - A function that receives table references and returns an expression
   * @returns A QueryBuilder with the having condition applied
   *
   * @example
   * ```ts
   * // Filter groups by count
   * query
   *   .from({ posts: postsCollection })
   *   .groupBy(({posts}) => posts.userId)
   *   .having(({posts}) => gt(count(posts.id), 5))
   *
   * // Filter by average
   * query
   *   .from({ orders: ordersCollection })
   *   .groupBy(({orders}) => orders.customerId)
   *   .having(({orders}) => gt(avg(orders.total), 100))
   *
   * // Multiple having calls are ANDed together
   * query
   *   .from({ orders: ordersCollection })
   *   .groupBy(({orders}) => orders.customerId)
   *   .having(({orders}) => gt(count(orders.id), 5))
   *   .having(({orders}) => gt(avg(orders.total), 100))
   * ```
   */
  having(callback: WhereCallback<TContext>): QueryBuilder<TContext> {
    const aliases = this._getCurrentAliases()
    // Add $selected namespace if SELECT clause exists (either regular or functional)
    const refProxy = (
      this.query.select || this.query.fnSelect
        ? createRefProxyWithSelected(aliases)
        : createRefProxy(aliases)
    ) as RefsForContext<TContext>
    const rawExpression = callback(refProxy)

    // Allow bare boolean column references like `.having(({ $selected }) => $selected.isActive)`
    // by converting ref proxies to PropRef expressions, the same way helper
    // functions like `not()` and `eq()` do via `toExpression()`.
    const expression = isRefProxy(rawExpression)
      ? toExpression(rawExpression)
      : rawExpression

    // Validate that the callback returned a valid expression
    // This catches common mistakes like using JavaScript comparison operators (===, !==, etc.)
    // which return boolean primitives instead of expression objects
    if (!isExpressionLike(expression)) {
      throw new InvalidWhereExpressionError(getValueTypeName(expression))
    }

    const existingHaving = this.query.having || []

    return new BaseQueryBuilder({
      ...this.query,
      having: [...existingHaving, expression],
    }) as any
  }

  /**
   * Select specific columns or computed values from the query
   *
   * @param callback - A function that receives table references and returns an object with selected fields or expressions
   * @returns A QueryBuilder that returns only the selected fields
   *
   * @example
   * ```ts
   * // Select specific columns
   * query
   *   .from({ users: usersCollection })
   *   .select(({users}) => ({
   *     name: users.name,
   *     email: users.email
   *   }))
   *
   * // Select with computed values
   * query
   *   .from({ users: usersCollection })
   *   .select(({users}) => ({
   *     fullName: concat(users.firstName, ' ', users.lastName),
   *     ageInMonths: mul(users.age, 12)
   *   }))
   *
   * // Select with aggregates (requires GROUP BY)
   * query
   *   .from({ posts: postsCollection })
   *   .groupBy(({posts}) => posts.userId)
   *   .select(({posts, count}) => ({
   *     userId: posts.userId,
   *     postCount: count(posts.id)
   *   }))
   * ```
   */
  select<TSelectObject extends SelectObject>(
    callback: (
      refs: RefsForContext<TContext>,
    ) => NonScalarSelectObject<TSelectObject>,
  ): QueryBuilder<WithResult<TContext, ResultTypeFromSelect<TSelectObject>>>
  select<TSelectValue extends ScalarSelectValue>(
    callback: (refs: RefsForContext<TContext>) => TSelectValue,
  ): QueryBuilder<WithResult<TContext, ResultTypeFromSelectValue<TSelectValue>>>
  select(
    callback: (
      refs: RefsForContext<TContext>,
    ) => SelectObject | ScalarSelectValue,
  ) {
    const aliases = this._getCurrentAliases()
    const refProxy = createRefProxy(aliases) as RefsForContext<TContext>
    let selectObject = callback(refProxy)

    // Returning a top-level alias directly is equivalent to spreading it.
    // Leaf refs like `row.name` must remain scalar selections.
    if (isRefProxy(selectObject) && selectObject.__path.length === 1) {
      const sentinelKey = `__SPREAD_SENTINEL__${selectObject.__path[0]}__0`
      selectObject = { [sentinelKey]: true }
    }

    const select = buildNestedSelect(selectObject, aliases)

    return new BaseQueryBuilder({
      ...this.query,
      select: select,
      fnSelect: undefined, // remove the fnSelect clause if it exists
    }) as any
  }

  /**
   * Sort the query results by one or more columns
   *
   * @param callback - A function that receives table references and returns the field to sort by
   * @param direction - Sort direction: 'asc' for ascending, 'desc' for descending (defaults to 'asc')
   * @returns A QueryBuilder with the ordering applied
   *
   * @example
   * ```ts
   * // Sort by a single column
   * query
   *   .from({ users: usersCollection })
   *   .orderBy(({users}) => users.name)
   *
   * // Sort descending
   * query
   *   .from({ users: usersCollection })
   *   .orderBy(({users}) => users.createdAt, 'desc')
   *
   * // Multiple sorts (chain orderBy calls)
   * query
   *   .from({ users: usersCollection })
   *   .orderBy(({users}) => users.lastName)
   *   .orderBy(({users}) => users.firstName)
   * ```
   */
  orderBy(
    callback: OrderByCallback<TContext>,
    options: OrderByDirection | OrderByOptions = `asc`,
  ): QueryBuilder<TContext> {
    const aliases = this._getCurrentAliases()
    // Add $selected namespace if SELECT clause exists (either regular or functional)
    const refProxy = (
      this.query.select || this.query.fnSelect
        ? createRefProxyWithSelected(aliases)
        : createRefProxy(aliases)
    ) as RefsForContext<TContext>
    const result = callback(refProxy)

    const opts: CompareOptions =
      typeof options === `string`
        ? { direction: options, nulls: `first` }
        : {
            direction: options.direction ?? `asc`,
            nulls: options.nulls ?? `first`,
            stringSort: options.stringSort,
            locale:
              options.stringSort === `locale` ? options.locale : undefined,
            localeOptions:
              options.stringSort === `locale`
                ? options.localeOptions
                : undefined,
          }

    const makeOrderByClause = (res: any) => {
      return {
        expression: toExpression(res),
        compareOptions: opts,
      }
    }

    // Create the new OrderBy structure with expression and direction
    const orderByClauses = Array.isArray(result)
      ? result.map((r) => makeOrderByClause(r))
      : [makeOrderByClause(result)]

    const existingOrderBy: OrderBy = this.query.orderBy || []

    return new BaseQueryBuilder({
      ...this.query,
      orderBy: [...existingOrderBy, ...orderByClauses],
    }) as any
  }

  /**
   * Group rows by one or more columns for aggregation
   *
   * @param callback - A function that receives table references and returns the field(s) to group by
   * @returns A QueryBuilder with grouping applied (enables aggregate functions in SELECT and HAVING)
   *
   * @example
   * ```ts
   * // Group by a single column
   * query
   *   .from({ posts: postsCollection })
   *   .groupBy(({posts}) => posts.userId)
   *   .select(({posts, count}) => ({
   *     userId: posts.userId,
   *     postCount: count()
   *   }))
   *
   * // Group by multiple columns
   * query
   *   .from({ sales: salesCollection })
   *   .groupBy(({sales}) => [sales.region, sales.category])
   *   .select(({sales, sum}) => ({
   *     region: sales.region,
   *     category: sales.category,
   *     totalSales: sum(sales.amount)
   *   }))
   * ```
   */
  groupBy(callback: GroupByCallback<TContext>): QueryBuilder<TContext> {
    const aliases = this._getCurrentAliases()
    const refProxy = createRefProxy(aliases) as RefsForContext<TContext>
    const result = callback(refProxy)

    const newExpressions = Array.isArray(result)
      ? result.map((r) => toExpression(r))
      : [toExpression(result)]

    // Extend existing groupBy expressions (multiple groupBy calls should accumulate)
    const existingGroupBy = this.query.groupBy || []
    return new BaseQueryBuilder({
      ...this.query,
      groupBy: [...existingGroupBy, ...newExpressions],
    }) as any
  }

  /**
   * Limit the number of rows returned by the query
   * `orderBy` is required for `limit`
   *
   * @param count - Maximum number of rows to return
   * @returns A QueryBuilder with the limit applied
   *
   * @example
   * ```ts
   * // Get top 5 posts by likes
   * query
   *   .from({ posts: postsCollection })
   *   .orderBy(({posts}) => posts.likes, 'desc')
   *   .limit(5)
   * ```
   */
  limit(count: number): QueryBuilder<TContext> {
    return new BaseQueryBuilder({
      ...this.query,
      limit: count,
    }) as any
  }

  /**
   * Skip a number of rows before returning results
   * `orderBy` is required for `offset`
   *
   * @param count - Number of rows to skip
   * @returns A QueryBuilder with the offset applied
   *
   * @example
   * ```ts
   * // Get second page of results
   * query
   *   .from({ posts: postsCollection })
   *   .orderBy(({posts}) => posts.createdAt, 'desc')
   *   .offset(page * pageSize)
   *   .limit(pageSize)
   * ```
   */
  offset(count: number): QueryBuilder<TContext> {
    return new BaseQueryBuilder({
      ...this.query,
      offset: count,
    }) as any
  }

  /**
   * Specify that the query should return distinct rows.
   * Deduplicates rows based on the selected columns.
   * @returns A QueryBuilder with distinct enabled
   *
   * @example
   * ```ts
   * // Get countries our users are from
   * query
   *   .from({ users: usersCollection })
   *   .select(({users}) => ({ country: users.country }))
   *   .distinct()
   * ```
   */
  distinct(): QueryBuilder<TContext> {
    return new BaseQueryBuilder({
      ...this.query,
      distinct: true,
    }) as any
  }

  /**
   * Specify that the query should return a single result
   * @returns A QueryBuilder that returns the first result
   *
   * @example
   * ```ts
   * // Get the user matching the query
   * query
   *   .from({ users: usersCollection })
   *   .where(({users}) => eq(users.id, 1))
   *   .findOne()
   *```
   */
  findOne(): QueryBuilder<TContext & SingleResult> {
    return new BaseQueryBuilder({
      ...this.query,
      // TODO: enforcing return only one result with also a default orderBy if none is specified
      // limit: 1,
      singleResult: true,
    }) as any
  }

  // Helper methods
  private _getCurrentAliases(): Array<string> {
    const aliases: Array<string> = []

    // Add the from alias
    if (this.query.from) {
      aliases.push(this.query.from.alias)
    }

    // Add join aliases
    if (this.query.join) {
      for (const join of this.query.join) {
        aliases.push(join.from.alias)
      }
    }

    return aliases
  }

  /**
   * Functional variants of the query builder
   * These are imperative function that are called for ery row.
   * Warning: that these cannot be optimized by the query compiler, and may prevent
   * some type of optimizations being possible.
   * @example
   * ```ts
   * q.fn.select((row) => ({
   *   name: row.user.name.toUpperCase(),
   *   age: row.user.age + 1,
   * }))
   * ```
   */
  get fn() {
    const builder = this
    return {
      /**
       * Select fields using a function that operates on each row
       * Warning: This cannot be optimized by the query compiler
       *
       * @param callback - A function that receives a row and returns the selected value
       * @returns A QueryBuilder with functional selection applied
       *
       * @example
       * ```ts
       * // Functional select (not optimized)
       * query
       *   .from({ users: usersCollection })
       *   .fn.select(row => ({
       *     name: row.users.name.toUpperCase(),
       *     age: row.users.age + 1,
       *   }))
       * ```
       */
      select<TFuncSelectResult>(
        callback: (row: TContext[`schema`]) => TFuncSelectResult,
      ): QueryBuilder<WithResult<TContext, TFuncSelectResult>> {
        return new BaseQueryBuilder({
          ...builder.query,
          select: undefined, // remove the select clause if it exists
          fnSelect: callback,
        }) as any
      },
      /**
       * Filter rows using a function that operates on each row
       * Warning: This cannot be optimized by the query compiler
       *
       * @param callback - A function that receives a row and returns a boolean
       * @returns A QueryBuilder with functional filtering applied
       *
       * @example
       * ```ts
       * // Functional where (not optimized)
       * query
       *   .from({ users: usersCollection })
       *   .fn.where(row => row.users.name.startsWith('A'))
       * ```
       */
      where(
        callback: (row: TContext[`schema`]) => any,
      ): QueryBuilder<TContext> {
        return new BaseQueryBuilder({
          ...builder.query,
          fnWhere: [
            ...(builder.query.fnWhere || []),
            callback as (row: NamespacedRow) => any,
          ],
        })
      },
      /**
       * Filter grouped rows using a function that operates on each aggregated row
       * Warning: This cannot be optimized by the query compiler
       *
       * @param callback - A function that receives an aggregated row (with $selected when select() was called) and returns a boolean
       * @returns A QueryBuilder with functional having filter applied
       *
       * @example
       * ```ts
       * // Functional having (not optimized)
       * query
       *   .from({ posts: postsCollection })
       *   .groupBy(({posts}) => posts.userId)
       *   .select(({posts}) => ({ userId: posts.userId, count: count(posts.id) }))
       *   .fn.having(({ $selected }) => $selected.count > 5)
       * ```
       */
      having(
        callback: (row: FunctionalHavingRow<TContext>) => any,
      ): QueryBuilder<TContext> {
        return new BaseQueryBuilder({
          ...builder.query,
          fnHaving: [
            ...(builder.query.fnHaving || []),
            callback as (row: NamespacedRow) => any,
          ],
        })
      },
    }
  }

  _getQuery(): QueryIR {
    if (!this.query.from) {
      throw new QueryMustHaveFromClauseError()
    }
    return this.query as QueryIR
  }
}

// Helper to get a descriptive type name for error messages
function getValueTypeName(value: unknown): string {
  if (value === null) return `null`
  if (value === undefined) return `undefined`
  if (typeof value === `object`) return `object`
  return typeof value
}

// Helper to ensure we have a BasicExpression/Aggregate for a value
function toExpr(value: any): BasicExpression | Aggregate {
  if (value === undefined) return toExpression(null)
  if (
    value instanceof AggregateExpr ||
    value instanceof FuncExpr ||
    value instanceof PropRef ||
    value instanceof ValueExpr
  ) {
    return value as BasicExpression | Aggregate
  }
  return toExpression(value)
}

function isPlainObject(value: any): value is Record<string, any> {
  return (
    value !== null &&
    typeof value === `object` &&
    !isExpressionLike(value) &&
    !value.__refProxy
  )
}

function buildNestedSelect(obj: any, parentAliases: Array<string> = []): any {
  if (!isPlainObject(obj)) return toExpr(obj)
  const out: Record<string, any> = {}
  for (const [k, v] of Object.entries(obj)) {
    if (typeof k === `string` && k.startsWith(`__SPREAD_SENTINEL__`)) {
      // Preserve sentinel key and its value (value is unimportant at compile time)
      out[k] = v
      continue
    }
    if (v instanceof BaseQueryBuilder) {
      out[k] = buildIncludesSubquery(v, k, parentAliases, `collection`)
      continue
    }
    if (v instanceof ToArrayWrapper) {
      if (!(v.query instanceof BaseQueryBuilder)) {
        throw new Error(`toArray() must wrap a subquery builder`)
      }
      out[k] = buildIncludesSubquery(v.query, k, parentAliases, `array`)
      continue
    }
    if (v instanceof ConcatToArrayWrapper) {
      if (!(v.query instanceof BaseQueryBuilder)) {
        throw new Error(`concat(toArray(...)) must wrap a subquery builder`)
      }
      out[k] = buildIncludesSubquery(v.query, k, parentAliases, `concat`)
      continue
    }
    out[k] = buildNestedSelect(v, parentAliases)
  }
  return out
}

/**
 * Recursively collects all PropRef nodes from an expression tree.
 */
function collectRefsFromExpression(expr: BasicExpression): Array<PropRef> {
  const refs: Array<PropRef> = []
  switch (expr.type) {
    case `ref`:
      refs.push(expr)
      break
    case `func`:
      for (const arg of (expr as any).args ?? []) {
        refs.push(...collectRefsFromExpression(arg))
      }
      break
    default:
      break
  }
  return refs
}

/**
 * Checks whether a WHERE clause references any parent alias.
 */
function referencesParent(where: Where, parentAliases: Array<string>): boolean {
  const expr =
    typeof where === `object` && `expression` in where
      ? where.expression
      : where
  return collectRefsFromExpression(expr).some(
    (ref) => ref.path[0] != null && parentAliases.includes(ref.path[0]),
  )
}

/**
 * Builds an IncludesSubquery IR node from a child query builder.
 * Extracts the correlation condition from the child's WHERE clauses by finding
 * an eq() predicate that references both a parent alias and a child alias.
 */
function buildIncludesSubquery(
  childBuilder: BaseQueryBuilder,
  fieldName: string,
  parentAliases: Array<string>,
  materialization: IncludesMaterialization,
): IncludesSubquery {
  const childQuery = childBuilder._getQuery()

  // Collect child's own aliases
  const childAliases: Array<string> = [childQuery.from.alias]
  if (childQuery.join) {
    for (const j of childQuery.join) {
      childAliases.push(j.from.alias)
    }
  }

  // Walk child's WHERE clauses to find the correlation condition.
  // The correlation eq() may be a standalone WHERE or nested inside a top-level and().
  let parentRef: PropRef | undefined
  let childRef: PropRef | undefined
  let correlationWhereIndex = -1
  let correlationAndArgIndex = -1 // >= 0 when found inside an and()

  if (childQuery.where) {
    for (let i = 0; i < childQuery.where.length; i++) {
      const where = childQuery.where[i]!
      const expr =
        typeof where === `object` && `expression` in where
          ? where.expression
          : where

      // Try standalone eq()
      if (
        expr.type === `func` &&
        expr.name === `eq` &&
        expr.args.length === 2
      ) {
        const result = extractCorrelation(
          expr.args[0]!,
          expr.args[1]!,
          parentAliases,
          childAliases,
        )
        if (result) {
          parentRef = result.parentRef
          childRef = result.childRef
          correlationWhereIndex = i
          break
        }
      }

      // Try inside top-level and()
      if (
        expr.type === `func` &&
        expr.name === `and` &&
        expr.args.length >= 2
      ) {
        for (let j = 0; j < expr.args.length; j++) {
          const arg = expr.args[j]!
          if (
            arg.type === `func` &&
            arg.name === `eq` &&
            arg.args.length === 2
          ) {
            const result = extractCorrelation(
              arg.args[0]!,
              arg.args[1]!,
              parentAliases,
              childAliases,
            )
            if (result) {
              parentRef = result.parentRef
              childRef = result.childRef
              correlationWhereIndex = i
              correlationAndArgIndex = j
              break
            }
          }
        }
        if (parentRef) break
      }
    }
  }

  if (!parentRef || !childRef || correlationWhereIndex === -1) {
    throw new Error(
      `Includes subquery for "${fieldName}" must have a WHERE clause with an eq() condition ` +
        `that correlates a parent field with a child field. ` +
        `Example: .where(({child}) => eq(child.parentId, parent.id))`,
    )
  }

  // Remove the correlation eq() from the child query's WHERE clauses.
  // If it was inside an and(), remove just that arg (collapsing the and() if needed).
  const modifiedWhere = [...childQuery.where!]
  if (correlationAndArgIndex >= 0) {
    const where = modifiedWhere[correlationWhereIndex]!
    const expr =
      typeof where === `object` && `expression` in where
        ? where.expression
        : where
    const remainingArgs = (expr as any).args.filter(
      (_: any, idx: number) => idx !== correlationAndArgIndex,
    )
    if (remainingArgs.length === 1) {
      // Collapse and() with single remaining arg to just that expression
      const isResidual =
        typeof where === `object` && `expression` in where && where.residual
      modifiedWhere[correlationWhereIndex] = isResidual
        ? { expression: remainingArgs[0], residual: true }
        : remainingArgs[0]
    } else {
      // Rebuild and() without the extracted arg
      const newAnd = new FuncExpr(`and`, remainingArgs)
      const isResidual =
        typeof where === `object` && `expression` in where && where.residual
      modifiedWhere[correlationWhereIndex] = isResidual
        ? { expression: newAnd, residual: true }
        : newAnd
    }
  } else {
    modifiedWhere.splice(correlationWhereIndex, 1)
  }

  // Separate remaining WHEREs into pure-child vs parent-referencing
  const pureChildWhere: Array<Where> = []
  const parentFilters: Array<Where> = []
  for (const w of modifiedWhere) {
    if (referencesParent(w, parentAliases)) {
      parentFilters.push(w)
    } else {
      pureChildWhere.push(w)
    }
  }

  // Collect distinct parent PropRefs from parent-referencing filters
  let parentProjection: Array<PropRef> | undefined
  if (parentFilters.length > 0) {
    const seen = new Set<string>()
    parentProjection = []
    for (const w of parentFilters) {
      const expr = typeof w === `object` && `expression` in w ? w.expression : w
      for (const ref of collectRefsFromExpression(expr)) {
        if (
          ref.path[0] != null &&
          parentAliases.includes(ref.path[0]) &&
          !seen.has(ref.path.join(`.`))
        ) {
          seen.add(ref.path.join(`.`))
          parentProjection.push(ref)
        }
      }
    }
  }

  const modifiedQuery: QueryIR = {
    ...childQuery,
    where: pureChildWhere.length > 0 ? pureChildWhere : undefined,
  }

  const rawChildSelect = modifiedQuery.select as any
  const hasObjectSelect =
    rawChildSelect === undefined || isPlainObject(rawChildSelect)
  let includesQuery = modifiedQuery
  let scalarField: string | undefined

  if (materialization === `concat`) {
    if (rawChildSelect === undefined || hasObjectSelect) {
      throw new Error(
        `concat(toArray(...)) for "${fieldName}" requires the subquery to select a scalar value`,
      )
    }
  }

  if (!hasObjectSelect) {
    if (materialization === `collection`) {
      throw new Error(
        `Includes subquery for "${fieldName}" must select an object when materializing as a Collection`,
      )
    }

    scalarField = INCLUDES_SCALAR_FIELD
    includesQuery = {
      ...modifiedQuery,
      select: {
        [scalarField]: rawChildSelect,
      },
    }
  }

  return new IncludesSubquery(
    includesQuery,
    parentRef,
    childRef,
    fieldName,
    parentFilters.length > 0 ? parentFilters : undefined,
    parentProjection,
    materialization,
    scalarField,
  )
}

/**
 * Checks if two eq() arguments form a parent-child correlation.
 * Returns the parent and child PropRefs if found, undefined otherwise.
 */
function extractCorrelation(
  argA: BasicExpression,
  argB: BasicExpression,
  parentAliases: Array<string>,
  childAliases: Array<string>,
): { parentRef: PropRef; childRef: PropRef } | undefined {
  if (argA.type === `ref` && argB.type === `ref`) {
    const aAlias = argA.path[0]
    const bAlias = argB.path[0]

    if (
      aAlias &&
      bAlias &&
      parentAliases.includes(aAlias) &&
      childAliases.includes(bAlias)
    ) {
      return { parentRef: argA, childRef: argB }
    }

    if (
      aAlias &&
      bAlias &&
      parentAliases.includes(bAlias) &&
      childAliases.includes(aAlias)
    ) {
      return { parentRef: argB, childRef: argA }
    }
  }

  return undefined
}

// Internal function to build a query from a callback
// used by liveQueryCollectionOptions.query
export function buildQuery<TContext extends Context>(
  fn: (builder: InitialQueryBuilder) => QueryBuilder<TContext>,
): QueryIR {
  const result = fn(new BaseQueryBuilder())
  return getQueryIR(result)
}

// Internal function to get the QueryIR from a builder
export function getQueryIR(
  builder: BaseQueryBuilder | QueryBuilder<any> | InitialQueryBuilder,
): QueryIR {
  return (builder as unknown as BaseQueryBuilder)._getQuery()
}

// Type-only exports for the query builder
export type InitialQueryBuilder = Pick<BaseQueryBuilder<Context>, `from`>

export type InitialQueryBuilderConstructor = new () => InitialQueryBuilder

export type QueryBuilder<TContext extends Context> = Omit<
  BaseQueryBuilder<TContext>,
  `from` | `_getQuery`
>

// Main query builder class alias with the constructor type modified to hide all
// but the from method on the initial instance
export const Query: InitialQueryBuilderConstructor = BaseQueryBuilder

// Helper type to extract context from a QueryBuilder
export type ExtractContext<T> =
  T extends BaseQueryBuilder<infer TContext>
    ? TContext
    : T extends QueryBuilder<infer TContext>
      ? TContext
      : never

// Helper type to extract the result type from a QueryBuilder (similar to Zod's z.infer)
export type QueryResult<T> = GetResult<ExtractContext<T>>

// Export the types from types.ts for convenience
export type {
  Context,
  ContextSchema,
  Source,
  GetResult,
  RefLeaf as Ref,
  InferResultType,
  // Types used in public method signatures that must be exported
  // for declaration emit to work (see https://github.com/TanStack/db/issues/1012)
  SchemaFromSource,
  InferCollectionType,
  MergeContextWithJoinType,
  MergeContextForJoinCallback,
  ApplyJoinOptionalityToMergedSchema,
  ResultTypeFromSelect,
  WithResult,
  JoinOnCallback,
  RefsForContext,
  WhereCallback,
  OrderByCallback,
  GroupByCallback,
  SelectObject,
  FunctionalHavingRow,
  Prettify,
} from './types.js'
