/**
 * Generic utility functions
 */

import { InvalidKeyError, UndefinedKeyError } from './errors'
import type { CompareOptions } from './query/builder/types'

interface TypedArray {
  length: number
  [index: number]: number
  every: (predicate: (value: number, index: number) => boolean) => boolean
}

export const durationFields = [
  `years`,
  `months`,
  `weeks`,
  `days`,
  `hours`,
  `minutes`,
  `seconds`,
  `milliseconds`,
  `microseconds`,
  `nanoseconds`,
] as const
export type DurationFields = Record<(typeof durationFields)[number], number>

/**
 * Deep equality function that compares two values recursively
 * Handles primitives, objects, arrays, Date, RegExp, Map, Set, TypedArrays, and Temporal objects
 * Plain records can compare by keys with keyed class instances and records from other realms.
 *
 * @param a - First value to compare
 * @param b - Second value to compare
 * @returns True if the values are deeply equal, false otherwise
 *
 * @example
 * ```typescript
 * deepEquals({ a: 1, b: 2 }, { b: 2, a: 1 }) // true (property order doesn't matter)
 * deepEquals([1, { x: 2 }], [1, { x: 2 }]) // true
 * deepEquals({ a: 1 }, { a: 2 }) // false
 * deepEquals(new Date('2023-01-01'), new Date('2023-01-01')) // true
 * deepEquals(new Map([['a', 1]]), new Map([['a', 1]])) // true
 * ```
 */
export function deepEquals(a: any, b: any): boolean {
  return deepEqualsInternal(a, b)
}

/**
 * @internal Whether a synced row changed observably: deep equality that
 * tells -0 from 0 and preserves record prototypes, as public row values do.
 * Maintainer decisions: FID-1, 2026-10-29; record shape, 2026-11-03.
 */
export function sameRowValue(a: unknown, b: unknown): boolean {
  return deepEqualsInternal(a, b, ROW)
}

/**
 * @internal Whether an ordered Effect's update repeats its previous
 * publication: row-value equality, except that a class instance equals only
 * itself, since it can hold readable state outside its enumerable fields
 * (maintainer decision 2026-11-02).
 */
export function samePublishedValue(a: unknown, b: unknown): boolean {
  return deepEqualsInternal(a, b, ROW | PUBLICATION)
}

/** @internal Compare persisted public and durable value shapes before skipping a reload. */
export function equalPersistedSnapshotValues(a: unknown, b: unknown): boolean {
  return deepEqualsInternal(a, b, DRAFT | PERSISTED)
}

function isPlainPrototype(prototype: object | null): boolean {
  return prototype === null || Object.getPrototypeOf(prototype) === null
}

function enumerableOwnKeys(value: object): Array<string | symbol> {
  const keys: Array<string | symbol> = Object.keys(value)
  for (const key of Object.getOwnPropertySymbols(value)) {
    if (Object.prototype.propertyIsEnumerable.call(value, key)) keys.push(key)
  }
  return keys
}

// The comparison modes of `deepEqualsInternal`, combined as bits.
/**
 * The stricter equality used by change-tracking drafts: Map and Set contents
 * must match in order, RegExp match position must match, and arrays compare
 * as keyed objects (holes and extra enumerable keys count).
 */
export const DRAFT = 1
/** Persisted snapshots also distinguish prototypes after serialization. */
const PERSISTED = 2
/** Row change detection tells -0 from 0; general equality does not. */
export const ROW = 4
/** An ordered Effect's publication also compares a class instance by identity. */
const PUBLICATION = 8

/**
 * Internal implementation with cycle detection to prevent infinite recursion.
 * Internal callers can seed already-paired roots when comparing their children.
 *
 * `mode` combines the bits above; one recursive call forwards all of them.
 */
export function deepEqualsInternal(
  a: any,
  b: any,
  mode = 0,
  // Created on the first container that descends into a child.
  visited?: Map<object, object>,
): boolean {
  const draft = mode & DRAFT
  // Handle strict equality (primitives, same reference); a row value tells
  // -0 from 0.
  if ((!(mode & ROW) && a === b) || Object.is(a, b)) return true

  // Handle null/undefined
  if (a == null || b == null) return false

  // Handle different types
  if (typeof a !== typeof b) return false

  // SQLite can turn an authored nested instance into an equal-keyed plain
  // record. A skipped persisted reload must not retain the old public shape.
  if (
    mode & PERSISTED &&
    typeof a === `object` &&
    Object.getPrototypeOf(a) !== Object.getPrototypeOf(b)
  ) {
    return false
  }

  // A container already paired in this comparison closes a cycle: it equals
  // only its partner.
  if (visited?.has(a)) return visited.get(a) === b

  // Handle Date objects
  if (a instanceof Date) {
    if (!(b instanceof Date)) return false
    return Object.is(a.getTime(), b.getTime())
  }
  // Symmetric check: if b is Date but a is not, they're not equal
  if (b instanceof Date) return false

  // Handle RegExp objects
  if (a instanceof RegExp) {
    if (!(b instanceof RegExp)) return false
    return (
      a.source === b.source &&
      a.flags === b.flags &&
      (!draft || a.lastIndex === b.lastIndex)
    )
  }
  // Symmetric check: if b is RegExp but a is not, they're not equal
  if (b instanceof RegExp) return false

  // Handle Map objects - only if both are Maps
  if (a instanceof Map) {
    if (!(b instanceof Map)) return false
    if (a.size !== b.size) return false
    ;(visited ??= new Map()).set(a, b)

    // A draft compares entries in order; general equality looks keys up.
    const bEntries = draft && Array.from(b.entries())
    const result = Array.from(a.entries()).every(([key, val], index) =>
      bEntries
        ? Object.is(key, bEntries[index]![0]) &&
          deepEqualsInternal(val, bEntries[index]![1], mode, visited)
        : b.has(key) && deepEqualsInternal(val, b.get(key), mode, visited),
    )

    visited.delete(a)
    return result
  }
  // Symmetric check: if b is Map but a is not, they're not equal
  if (b instanceof Map) return false

  // Handle Set objects - only if both are Sets
  if (a instanceof Set) {
    if (!(b instanceof Set)) return false
    if (a.size !== b.size) return false
    ;(visited ??= new Map()).set(a, b)

    // Convert to arrays for comparison
    const aValues = Array.from(a)
    const bValues = Array.from(b)

    if (draft) {
      // A draft compares contents in order, as it compares arrays.
      const result = deepEqualsInternal(aValues, bValues, mode, visited)
      visited.delete(a)
      return result
    }

    // Simple comparison for primitive values
    if (aValues.every((val) => typeof val !== `object`)) {
      visited.delete(a)
      return aValues.every((val) => b.has(val))
    }

    // A paired equality is already a bijection. Keep common equal inputs linear.
    if (
      aValues.every((value, index) =>
        deepEqualsInternal(value, bValues[index], mode, visited),
      )
    ) {
      visited.delete(a)
      return true
    }

    // Recursive comparisons remove their own pairs before returning. Reuse the
    // ancestor map; only failed assignments need augmenting-path search.
    const candidates = aValues.map((value) =>
      bValues.flatMap((candidate, index) =>
        deepEqualsInternal(value, candidate, mode, visited) ? [index] : [],
      ),
    )
    const assigned = new Map<number, number>()
    const match = (index: number, seen: Set<number>): boolean => {
      const options = candidates[index]!
      const free = options.find((candidate) => !assigned.has(candidate))
      if (free !== undefined) {
        assigned.set(free, index)
        return true
      }
      return options.some((candidate) => {
        if (seen.has(candidate)) return false
        seen.add(candidate)
        const previous = assigned.get(candidate)
        if (previous !== undefined && !match(previous, seen)) return false
        assigned.set(candidate, index)
        return true
      })
    }
    const result = aValues.every((_, index) => match(index, new Set()))
    visited.delete(a)
    return result
  }
  // Symmetric check: if b is Set but a is not, they're not equal
  if (b instanceof Set) return false

  // Handle TypedArrays. A typed array is its own value class: it equals only
  // another typed array, in both directions.
  const typed = ArrayBuffer.isView(a) && !(a instanceof DataView)
  if (typed !== (ArrayBuffer.isView(b) && !(b instanceof DataView))) {
    return false
  }
  if (typed) {
    const typedA = a as unknown as TypedArray
    const typedB = b as unknown as TypedArray
    // Elements compare as the numbers they are: NaN equals NaN, and -0
    // equals 0 except as a row value. Only a draft treats a change of
    // typed-array class as a change.
    return (
      (!draft || Object.getPrototypeOf(a) === Object.getPrototypeOf(b)) &&
      typedA.length === typedB.length &&
      typedA.every((x, i) => deepEqualsInternal(x, typedB[i], mode))
    )
  }

  // Handle Temporal objects
  // Check if both are Temporal objects of the same type
  if (isTemporal(a) && isTemporal(b)) {
    const aTag = a[Symbol.toStringTag]
    const bTag = b[Symbol.toStringTag]

    // If they're different Temporal types, they're not equal
    if (aTag !== bTag) return false

    // Duration identity preserves its fields, even when toString normalizes
    // different units to the same elapsed time.
    if (aTag === `Temporal.Duration`) {
      const durationA = a as unknown as DurationFields
      const durationB = b as unknown as DurationFields
      return durationFields.every(
        (field) => durationA[field] === durationB[field],
      )
    }

    // Use Temporal's built-in equals method if available
    if (typeof a.equals === `function`) {
      return a.equals(b)
    }

    // Fallback to toString comparison for other types
    return a.toString() === b.toString()
  }
  // Symmetric check: if b is Temporal but a is not, they're not equal
  if (isTemporal(b)) return false

  // Handle arrays
  if (Array.isArray(a) !== Array.isArray(b)) return false
  if (Array.isArray(a) && a.length !== b.length) return false
  if (Array.isArray(a) && !draft) {
    ;(visited ??= new Map()).set(a, b)

    let result = true
    for (let index = 0; index < a.length; index++) {
      if (!deepEqualsInternal(a[index], b[index], mode, visited)) {
        result = false
        break
      }
    }
    visited.delete(a)
    return result
  }
  // Handle objects
  if (typeof a === `object`) {
    // Row publication preserves record prototypes. Generic equality and draft
    // reconstruction keep their established plain-record/class bridge.
    const prototype = Object.getPrototypeOf(a)
    const prototypeB = Object.getPrototypeOf(b)
    const plain = isPlainPrototype(prototype)
    const plainB = isPlainPrototype(prototypeB)
    if (
      prototype !== prototypeB &&
      ((mode & ROW && !draft) || (!plain && !plainB))
    )
      return false
    if (mode & PUBLICATION && !(plain && plainB)) return false

    // Compare enumerable symbol keys as well as string keys. Query results may
    // use user-owned symbols, and a symbol-only update is still a value change.
    const keysA = enumerableOwnKeys(a)
    const keysB = enumerableOwnKeys(b)

    // Check if they have the same number of keys
    if (keysA.length !== keysB.length) return false

    // A class instance without enumerable keys (a File, an object with
    // private fields) keeps its state elsewhere, so it equals only itself.
    // A draft copies a URL by its href, so URLs compare by href.
    if (keysA.length === 0 && !Array.isArray(a) && !(plain && plainB)) {
      return a instanceof URL && a.href === b.href
    }

    // Check if all keys exist in both objects and their values are equal.
    // Register for cycles only before descending, so a flat object
    // allocates no cycle map.
    let registered = false
    let result = true
    for (const key of keysA) {
      const value = a[key]
      if (!registered && value !== null && typeof value === `object`) {
        ;(visited ??= new Map()).set(a, b)
        registered = true
      }
      if (
        !Object.prototype.propertyIsEnumerable.call(b, key) ||
        !deepEqualsInternal(value, b[key], mode, visited)
      ) {
        result = false
        break
      }
    }
    if (registered) visited!.delete(a)
    return result
  }

  // For primitives that aren't strictly equal
  return false
}

const temporalTypes = new Set([
  `Temporal.Duration`,
  `Temporal.Instant`,
  `Temporal.PlainDate`,
  `Temporal.PlainDateTime`,
  `Temporal.PlainMonthDay`,
  `Temporal.PlainTime`,
  `Temporal.PlainYearMonth`,
  `Temporal.ZonedDateTime`,
])

export interface TemporalLike {
  [Symbol.toStringTag]: string
  toString: () => string
  equals?: (other: unknown) => boolean
}

/** Checks if the value is a Temporal object by checking for the Temporal brand */
export function isTemporal(a: unknown): a is TemporalLike {
  if (a == null || typeof a !== `object`) return false
  const tag = (a as Record<symbol, unknown>)[Symbol.toStringTag]
  return typeof tag === `string` && temporalTypes.has(tag)
}

export const DEFAULT_COMPARE_OPTIONS: Exclude<
  CompareOptions,
  { stringSort: `custom` }
> = {
  direction: `asc`,
  nulls: `first`,
  stringSort: `locale`,
}

/** Throws unless a key has the Collection key type: a string or a number. */
export function assertCollectionKey(key: unknown, item: unknown): void {
  if (typeof key !== `string` && typeof key !== `number`) {
    throw key === undefined
      ? new UndefinedKeyError(item)
      : new InvalidKeyError(key, item)
  }
}
