import { isRefProxy, toExpression } from './builder/ref-proxy.js'
import { getQueryIR } from './builder/index.js'
import type {
  Aggregate,
  BasicExpression,
  ConditionalSelect,
  From,
  Having,
  IncludesSubquery,
  JoinClause,
  OrderByClause,
  QueryIR,
  Select,
  Where,
} from './ir.js'
import type { InitialQueryBuilder, QueryBuilder } from './builder/index.js'

type StableIdentityValue =
  | null
  | boolean
  | number
  | string
  | Array<StableIdentityValue>
  | { [key: string]: StableIdentityValue }

export class UnhashableQueryIRError extends Error {
  constructor(
    public readonly path: string,
    public readonly reason: string,
  ) {
    super(`Query IR is not stably hashable at ${path}: ${reason}`)
    this.name = `UnhashableQueryIRError`
  }
}

export function getStableQueryIRHash(query: QueryIR): string {
  return JSON.stringify(canonicalizeQueryIR(query))
}

export function getStableQueryBuilderHash(
  query: InitialQueryBuilder | QueryBuilder<any>,
): string {
  return getStableQueryIRHash(getQueryIR(query))
}

export function getStableValueHash(value: unknown, path = `value`): string {
  return JSON.stringify(canonicalizeRuntimeValue(value, path, new WeakSet()))
}

export function canonicalizeQueryIR(query: QueryIR): StableIdentityValue {
  return canonicalizeQuery(query, `query`, new WeakSet<object>())
}

function canonicalizeQuery(
  query: QueryIR,
  path: string,
  seen: WeakSet<object>,
): StableIdentityValue {
  if (query.fnSelect) {
    throw new UnhashableQueryIRError(`${path}.fnSelect`, `function select`)
  }

  if (query.fnWhere?.length) {
    throw new UnhashableQueryIRError(`${path}.fnWhere`, `function where`)
  }

  if (query.fnHaving?.length) {
    throw new UnhashableQueryIRError(`${path}.fnHaving`, `function having`)
  }

  const result: Record<string, StableIdentityValue> = {
    type: `query`,
    from: canonicalizeSource(query.from, `${path}.from`, seen),
  }

  if (query.select) {
    result.select = canonicalizeSelect(query.select, `${path}.select`, seen)
  }

  if (query.join) {
    result.join = query.join.map((join, index) =>
      canonicalizeJoin(join, `${path}.join[${index}]`, seen),
    )
  }

  if (query.where) {
    result.where = query.where.map((where, index) =>
      canonicalizeWhere(where, `${path}.where[${index}]`, seen),
    )
  }

  if (query.groupBy) {
    result.groupBy = query.groupBy.map((expression, index) =>
      canonicalizeExpression(expression, `${path}.groupBy[${index}]`, seen),
    )
  }

  if (query.having) {
    result.having = query.having.map((having, index) =>
      canonicalizeWhere(having, `${path}.having[${index}]`, seen),
    )
  }

  if (query.orderBy) {
    result.orderBy = query.orderBy.map((orderBy, index) =>
      canonicalizeOrderBy(orderBy, `${path}.orderBy[${index}]`, seen),
    )
  }

  if (query.limit !== undefined) {
    result.limit = canonicalizeRuntimeValue(query.limit, `${path}.limit`, seen)
  }

  if (query.offset !== undefined) {
    result.offset = canonicalizeRuntimeValue(
      query.offset,
      `${path}.offset`,
      seen,
    )
  }

  if (query.distinct) {
    result.distinct = true
  }

  if (query.singleResult) {
    result.singleResult = true
  }

  return result
}

function canonicalizeJoin(
  join: JoinClause,
  path: string,
  seen: WeakSet<object>,
): StableIdentityValue {
  return {
    type: join.type,
    from: canonicalizeSource(join.from, `${path}.from`, seen),
    left: canonicalizeExpression(join.left, `${path}.left`, seen),
    right: canonicalizeExpression(join.right, `${path}.right`, seen),
  }
}

function canonicalizeSource(
  source: From,
  path: string,
  seen: WeakSet<object>,
): StableIdentityValue {
  if (source.type === `collectionRef`) {
    return {
      type: `collectionRef`,
      alias: source.alias,
      collectionId: canonicalizeRuntimeValue(
        source.collection.id,
        `${path}.collection.id`,
        seen,
      ),
    }
  }

  if (source.type === `unionFrom`) {
    return {
      type: `unionFrom`,
      sources: source.sources.map((unionSource, index) =>
        canonicalizeSource(unionSource, `${path}.sources[${index}]`, seen),
      ),
    }
  }

  if (source.type === `unionAll`) {
    return {
      type: `unionAll`,
      queries: source.queries.map((query, index) =>
        canonicalizeQuery(query, `${path}.queries[${index}]`, seen),
      ),
    }
  }

  return {
    type: `queryRef`,
    alias: source.alias,
    query: canonicalizeQuery(source.query, `${path}.query`, seen),
  }
}

function canonicalizeSelect(
  select: Select,
  path: string,
  seen: WeakSet<object>,
): StableIdentityValue {
  return {
    type: `select`,
    fields: Object.keys(select)
      .sort()
      .map((key) => [
        key,
        canonicalizeSelectValue(select[key]!, `${path}.${key}`, seen),
      ]),
  }
}

function canonicalizeSelectValue(
  value: unknown,
  path: string,
  seen: WeakSet<object>,
): StableIdentityValue {
  if (isRefProxy(value)) {
    return canonicalizeExpression(toExpression(value), path, seen)
  }

  if (isExpression(value)) {
    return canonicalizeExpression(value, path, seen)
  }

  if (isPlainObject(value)) {
    return canonicalizeSelect(value as Select, path, seen)
  }

  return canonicalizeRuntimeValue(value, path, seen)
}

function canonicalizeWhere(
  where: Where | Having,
  path: string,
  seen: WeakSet<object>,
): StableIdentityValue {
  if (isWhereObject(where)) {
    const result: Record<string, StableIdentityValue> = {
      type: `where`,
      expression: canonicalizeExpression(
        where.expression,
        `${path}.expression`,
        seen,
      ),
    }

    if (where.residual === true) {
      result.residual = true
    }

    return result
  }

  return canonicalizeExpression(where, path, seen)
}

function canonicalizeOrderBy(
  orderBy: OrderByClause,
  path: string,
  seen: WeakSet<object>,
): StableIdentityValue {
  return {
    expression: canonicalizeExpression(
      orderBy.expression,
      `${path}.expression`,
      seen,
    ),
    compareOptions: canonicalizeRuntimeValue(
      orderBy.compareOptions,
      `${path}.compareOptions`,
      seen,
    ),
  }
}

function canonicalizeExpression(
  expression:
    | BasicExpression
    | Aggregate
    | IncludesSubquery
    | ConditionalSelect,
  path: string,
  seen: WeakSet<object>,
): StableIdentityValue {
  if (expression.type === `ref`) {
    return {
      type: `ref`,
      path: expression.path.map((segment, index) =>
        canonicalizeRuntimeValue(segment, `${path}.path[${index}]`, seen),
      ),
    }
  }

  if (expression.type === `val`) {
    return {
      type: `val`,
      value: canonicalizeRuntimeValue(expression.value, `${path}.value`, seen),
    }
  }

  if (expression.type === `func`) {
    return {
      type: `func`,
      name: expression.name,
      args: expression.args.map((arg, index) =>
        canonicalizeExpression(arg, `${path}.args[${index}]`, seen),
      ),
    }
  }

  if (expression.type === `agg`) {
    return {
      type: `agg`,
      name: expression.name,
      args: expression.args.map((arg, index) =>
        canonicalizeExpression(arg, `${path}.args[${index}]`, seen),
      ),
    }
  }

  if (expression.type === `conditionalSelect`) {
    const result: Record<string, StableIdentityValue> = {
      type: `conditionalSelect`,
      branches: expression.branches.map((branch, index) => ({
        condition: canonicalizeExpression(
          branch.condition,
          `${path}.branches[${index}].condition`,
          seen,
        ),
        value: canonicalizeSelectValue(
          branch.value,
          `${path}.branches[${index}].value`,
          seen,
        ),
      })),
    }

    if (expression.defaultValue !== undefined) {
      result.defaultValue = canonicalizeSelectValue(
        expression.defaultValue,
        `${path}.defaultValue`,
        seen,
      )
    }

    return result
  }

  const result: Record<string, StableIdentityValue> = {
    type: `includesSubquery`,
    query: canonicalizeQuery(expression.query, `${path}.query`, seen),
    correlationField: canonicalizeExpression(
      expression.correlationField,
      `${path}.correlationField`,
      seen,
    ),
    childCorrelationField: canonicalizeExpression(
      expression.childCorrelationField,
      `${path}.childCorrelationField`,
      seen,
    ),
    fieldName: expression.fieldName,
    materialization: expression.materialization,
  }

  if (expression.parentFilters) {
    result.parentFilters = expression.parentFilters.map((where, index) =>
      canonicalizeWhere(where, `${path}.parentFilters[${index}]`, seen),
    )
  }

  if (expression.parentProjection) {
    result.parentProjection = expression.parentProjection.map(
      (projection, index) =>
        canonicalizeExpression(
          projection,
          `${path}.parentProjection[${index}]`,
          seen,
        ),
    )
  }

  if (expression.scalarField !== undefined) {
    result.scalarField = expression.scalarField
  }

  return result
}

function canonicalizeRuntimeValue(
  value: unknown,
  path: string,
  seen: WeakSet<object>,
): StableIdentityValue {
  if (value === null) return [`null`]

  if (typeof value === `string`) {
    return [`string`, value]
  }

  if (typeof value === `boolean`) {
    return [`boolean`, value]
  }

  if (typeof value === `number`) {
    if (Number.isNaN(value)) {
      return [`number`, `NaN`]
    }

    if (value === Infinity) {
      return [`number`, `Infinity`]
    }

    if (value === -Infinity) {
      return [`number`, `-Infinity`]
    }

    if (Object.is(value, -0)) {
      return [`number`, `-0`]
    }

    return [`number`, value]
  }

  if (typeof value === `undefined`) {
    return [`undefined`]
  }

  if (typeof value === `bigint`) {
    return [`bigint`, value.toString()]
  }

  if (typeof value === `function`) {
    throw new UnhashableQueryIRError(path, `function value`)
  }

  if (typeof value === `symbol`) {
    throw new UnhashableQueryIRError(path, `symbol value`)
  }

  if (isRefProxy(value)) {
    return canonicalizeExpression(toExpression(value), path, seen)
  }

  if (Array.isArray(value)) {
    return withCircularGuard(value, path, seen, () => [
      `array`,
      value.map((item, index) =>
        canonicalizeRuntimeValue(item, `${path}[${index}]`, seen),
      ),
    ])
  }

  if (value instanceof Date) {
    const timestamp = value.getTime()
    if (Number.isNaN(timestamp)) {
      throw new UnhashableQueryIRError(path, `invalid Date`)
    }

    return [`Date`, value.toISOString()]
  }

  if (value instanceof ArrayBuffer) {
    return [`binary`, `ArrayBuffer`, Array.from(new Uint8Array(value))]
  }

  if (ArrayBuffer.isView(value)) {
    return [
      `binary`,
      value.constructor.name,
      Array.from(
        new Uint8Array(value.buffer, value.byteOffset, value.byteLength),
      ),
    ]
  }

  if (value instanceof Map) {
    return withCircularGuard(value, path, seen, () => {
      const entries = Array.from(
        value.entries(),
        ([key, entryValue], index) => [
          canonicalizeRuntimeValue(key, `${path}.key[${index}]`, seen),
          canonicalizeRuntimeValue(entryValue, `${path}.value[${index}]`, seen),
        ],
      )
      entries.sort(compareStableIdentityValues)
      return [`Map`, entries]
    })
  }

  if (value instanceof Set) {
    return withCircularGuard(value, path, seen, () => {
      const entries = Array.from(value, (entry, index) =>
        canonicalizeRuntimeValue(entry, `${path}[${index}]`, seen),
      )
      entries.sort(compareStableIdentityValues)
      return [`Set`, entries]
    })
  }

  if (isPlainObject(value)) {
    return canonicalizeObject(value, path, seen)
  }

  throw new UnhashableQueryIRError(path, `non-plain object value`)
}

function compareStableIdentityValues(
  left: StableIdentityValue,
  right: StableIdentityValue,
): number {
  const serializedLeft = JSON.stringify(left)
  const serializedRight = JSON.stringify(right)
  return serializedLeft < serializedRight
    ? -1
    : serializedLeft > serializedRight
      ? 1
      : 0
}

function canonicalizeObject(
  value: Record<string, unknown>,
  path: string,
  seen: WeakSet<object>,
): StableIdentityValue {
  return withCircularGuard(value, path, seen, () => [
    `object`,
    Object.keys(value)
      .sort()
      .map((key) => [
        key,
        canonicalizeRuntimeValue(value[key], `${path}.${key}`, seen),
      ]),
  ])
}

function withCircularGuard<T>(
  value: object,
  path: string,
  seen: WeakSet<object>,
  callback: () => T,
): T {
  if (seen.has(value)) {
    throw new UnhashableQueryIRError(path, `circular value`)
  }

  seen.add(value)
  try {
    return callback()
  } finally {
    seen.delete(value)
  }
}

function isWhereObject(
  where: Where | Having,
): where is { expression: BasicExpression<boolean>; residual?: boolean } {
  return `expression` in where
}

function isExpression(
  value: unknown,
): value is BasicExpression | Aggregate | IncludesSubquery {
  if (value === null || typeof value !== `object`) {
    return false
  }

  const expressionType = (value as { type?: unknown }).type
  return (
    expressionType === `agg` ||
    expressionType === `conditionalSelect` ||
    expressionType === `func` ||
    expressionType === `ref` ||
    expressionType === `val` ||
    expressionType === `includesSubquery`
  )
}

function isPlainObject(value: unknown): value is Record<string, unknown> {
  if (value === null || typeof value !== `object`) return false

  const prototype = Object.getPrototypeOf(value)
  return prototype === Object.prototype || prototype === null
}
