/**
 * Builds effectful, schema-aware queries for typed browser IndexedDB versions.
 *
 * An `IndexedDbQueryBuilder` is created from an open database and a version's
 * table descriptors, then exposes `from(tableName)` as the entry point for
 * table operations. Query objects can select, count, delete, insert, upsert,
 * clear tables, stream paged reads, react to invalidations, and run multiple
 * effects in a shared `IDBTransaction` with `withTransaction`. Reads decode
 * stored rows with the table schema, and writes encode input values before
 * sending them to IndexedDB.
 *
 * @since 4.0.0
 */
import type { NonEmptyReadonlyArray } from "effect/Array"
import * as Cause from "effect/Cause"
import * as Context from "effect/Context"
import * as Data from "effect/Data"
import * as Effect from "effect/Effect"
import * as Effectable from "effect/Effectable"
import * as Fiber from "effect/Fiber"
import type { Inspectable } from "effect/Inspectable"
import { BaseProto } from "effect/Inspectable"
import type * as MutableRef from "effect/MutableRef"
import * as Option from "effect/Option"
import * as Pipeable from "effect/Pipeable"
import type * as Queue from "effect/Queue"
import type * as Record from "effect/Record"
import * as References from "effect/References"
import * as Schema from "effect/Schema"
import * as SchemaIssue from "effect/SchemaIssue"
import * as SchemaParser from "effect/SchemaParser"
import type * as Scope from "effect/Scope"
import * as Stream from "effect/Stream"
import type * as Reactivity from "effect/unstable/reactivity/Reactivity"
import * as Utils from "effect/Utils"
import type * as IndexedDb from "./IndexedDb.ts"
import type * as IndexedDbDatabase from "./IndexedDbDatabase.ts"
import type * as IndexedDbTable from "./IndexedDbTable.ts"
import type * as IndexedDbVersion from "./IndexedDbVersion.ts"

const ErrorTypeId = "~@effect/platform-browser/IndexedDbQueryBuilder/IndexedDbQueryError"

const CommonProto = {
  [Symbol.iterator]() {
    return new Utils.SingleShotGen(this) as any
  },
  ...Pipeable.Prototype,
  ...BaseProto,
  toJSON(this: any) {
    return {
      _id: "IndexedDbQueryBuilder"
    }
  }
}

/**
 * String union describing IndexedDB query failure categories such as decoding, encoding, and transaction errors.
 *
 * @category errors
 * @since 4.0.0
 */
export type ErrorReason =
  | "UnknownError"
  | "DecodeError"
  | "EncodeError"
  | "TransactionError"

/**
 * Tagged error for IndexedDB query operations, carrying a query error reason and the original cause.
 *
 * **Details**
 *
 * `reason` is the query failure category, `cause` preserves the underlying
 * schema, IndexedDB request, transaction, or user callback failure, and
 * `message` is set to the reason.
 *
 * @see {@link ErrorReason} for the supported failure categories
 *
 * @category errors
 * @since 4.0.0
 */
export class IndexedDbQueryError extends Data.TaggedError(
  "IndexedDbQueryError"
)<{
  reason: ErrorReason
  cause: unknown
}> {
  /**
   * Marks this value as an IndexedDB query builder error for runtime guards.
   *
   * @since 4.0.0
   */
  readonly [ErrorTypeId]: typeof ErrorTypeId = ErrorTypeId

  override readonly message = this.reason
}

/**
 * Typed query builder for an IndexedDB version, with helpers for table queries, database access, clearing data, and running effects in a shared transaction.
 *
 * @category models
 * @since 4.0.0
 */
export interface IndexedDbQueryBuilder<
  Source extends IndexedDbVersion.AnyWithProps
> extends Pipeable.Pipeable, Inspectable {
  readonly tables: ReadonlyMap<string, IndexedDbVersion.Tables<Source>>
  readonly database: MutableRef.MutableRef<globalThis.IDBDatabase>
  readonly reactivity: Reactivity.Reactivity["Service"]
  readonly IDBKeyRange: typeof globalThis.IDBKeyRange
  readonly IDBTransaction: globalThis.IDBTransaction | undefined

  readonly use: <A = unknown>(
    f: (database: globalThis.IDBDatabase) => A
  ) => Effect.Effect<A, IndexedDbQueryError>

  readonly from: <
    const Name extends IndexedDbTable.TableName<
      IndexedDbVersion.Tables<Source>
    >
  >(
    table: Name
  ) => IndexedDbQuery.From<IndexedDbVersion.TableWithName<Source, Name>>

  /** @internal */
  readonly fromCache: Map<string, IndexedDbQuery.From<IndexedDbVersion.TableWithName<Source, any>>>

  readonly clearAll: Effect.Effect<void, IndexedDbQueryError>

  readonly withTransaction: <
    Tables extends NonEmptyReadonlyArray<
      IndexedDbTable.TableName<IndexedDbVersion.Tables<Source>>
    >,
    Mode extends "readonly" | "readwrite"
  >(options: {
    readonly tables: Tables
    readonly mode: Mode
    readonly durability?: IDBTransactionDurability
  }) => <A, E, R>(effect: Effect.Effect<A, E, R>) => Effect.Effect<A, E, Exclude<R, IndexedDbTransaction>>
}

/**
 * Valid key-path type for a table schema, using encoded fields whose values are IndexedDB-valid keys.
 *
 * @category models
 * @since 4.0.0
 */
export type KeyPath<TableSchema extends IndexedDbTable.AnySchemaStruct> =
  | IndexedDbValidKeys<TableSchema>
  | NonEmptyReadonlyArray<IndexedDbValidKeys<TableSchema>>

/**
 * Valid numeric key-path type for a table schema, used for auto-increment key paths.
 *
 * @category models
 * @since 4.0.0
 */
export type KeyPathNumber<TableSchema extends IndexedDbTable.AnySchemaStruct> =
  | IndexedDbValidNumberKeys<TableSchema>
  | NonEmptyReadonlyArray<IndexedDbValidNumberKeys<TableSchema>>

/**
 * Namespace containing the typed IndexedDB query model interfaces and helper types.
 *
 * @since 4.0.0
 */
export declare namespace IndexedDbQuery {
  /**
   * Decoded row type returned by select queries, adding a `key` field when the table does not define a key path.
   *
   * @category models
   * @since 4.0.0
   */
  export type SelectType<
    Table extends IndexedDbTable.AnyWithProps
  > = [IndexedDbTable.KeyPath<Table>] extends [undefined] ? IndexedDbTable.TableSchema<Table>["Type"] & {
      readonly key: (typeof IndexedDb.IDBValidKey)["Type"]
    } :
    IndexedDbTable.TableSchema<Table>["Type"]

  /**
   * Input type for insert and upsert operations, adjusted for auto-increment keys and out-of-line keys.
   *
   * @category models
   * @since 4.0.0
   */
  export type ModifyType<
    Table extends IndexedDbTable.AnyWithProps
  > =
    & (IndexedDbTable.AutoIncrement<Table> extends true ?
        & {
          [
            key in keyof Schema.Struct.MakeIn<
              Omit<
                IndexedDbTable.TableSchema<Table>["fields"],
                IndexedDbTable.KeyPath<Table>
              >
            >
          ]: key extends keyof Schema.Struct.MakeIn<
            IndexedDbTable.TableSchema<Table>["fields"]
          > ? Schema.Struct.MakeIn<
              IndexedDbTable.TableSchema<Table>["fields"]
            >[key]
            : never
        }
        & {
          [key in IndexedDbTable.KeyPath<Table>]?: number | undefined
        }
      : Schema.Struct.MakeIn<IndexedDbTable.TableSchema<Table>["fields"]>)
    & ([IndexedDbTable.KeyPath<Table>] extends [undefined] ? {
        key: IDBValidKey
      }
      : {})

  /**
   * Value type accepted by `equals` comparisons for a table key path or index.
   *
   * @category models
   * @since 4.0.0
   */
  export type EqualsType<
    Table extends IndexedDbTable.AnyWithProps,
    Index extends keyof Table["indexes"],
    KeyPath = [Index] extends [never] ? Table["keyPath"] : Table["indexes"][Index],
    Type = Table["tableSchema"]["Encoded"]
  > = KeyPath extends keyof Type ? Type[KeyPath]
    : { [I in keyof KeyPath]: KeyPath[I] extends keyof Type ? Type[KeyPath[I]] | [] : never }

  /**
   * Value type accepted by range comparisons for a table key path or index, including partial tuples for compound indexes.
   *
   * @category models
   * @since 4.0.0
   */
  export type ExtractIndexType<
    Table extends IndexedDbTable.AnyWithProps,
    Index extends keyof Table["indexes"],
    KeyPath = [Index] extends [never] ? Table["keyPath"] : Table["indexes"][Index],
    Type = Table["tableSchema"]["Encoded"]
  > = KeyPath extends keyof Type ? Type[KeyPath]
    : KeyPath extends readonly [infer K, ...infer Rest] ? K extends keyof Type ? [
          Type[K],
          ...{ [P in keyof Rest]?: Rest[P] extends keyof Type ? Type[Rest[P]] | [] : never }
        ] :
      never :
    never

  /**
   * Mutation input type for insert and upsert operations, including any required key fields.
   *
   * @category models
   * @since 4.0.0
   */
  export type ModifyWithKey<Table extends IndexedDbTable.AnyWithProps> = ModifyType<Table>

  /**
   * Query entry point for a table, exposing clear, select, count, delete, insert, and upsert operations.
   *
   * @category models
   * @since 4.0.0
   */
  export interface From<Table extends IndexedDbTable.AnyWithProps> {
    readonly table: Table
    readonly database: MutableRef.MutableRef<globalThis.IDBDatabase>
    readonly IDBKeyRange: typeof globalThis.IDBKeyRange
    readonly reactivity: Reactivity.Reactivity["Service"]

    readonly clear: Effect.Effect<void, IndexedDbQueryError>

    readonly select: {
      <Index extends IndexedDbDatabase.IndexFromTable<Table>>(
        index: Index
      ): Select<Table, Index>
      (): Select<Table, never>
    }

    /** @internal */
    readonly selectCache: Map<
      string | undefined,
      IndexedDbQuery.Select<any, never>
    >

    readonly count: {
      <Index extends IndexedDbDatabase.IndexFromTable<Table>>(
        index: Index
      ): Count<Table, Index>
      (): Count<Table, never>
    }

    /** @internal */
    readonly countCache: Map<
      string | undefined,
      IndexedDbQuery.Count<any, never>
    >

    readonly delete: {
      <Index extends IndexedDbDatabase.IndexFromTable<Table>>(
        index: Index
      ): DeletePartial<Table, Index>
      (): DeletePartial<Table, never>
    }

    /** @internal */
    readonly deleteCache: Map<
      string | undefined,
      IndexedDbQuery.DeletePartial<any, never>
    >

    readonly insert: (value: ModifyWithKey<Table>) => Modify<Table>
    readonly insertAll: (
      values: Array<ModifyWithKey<Table>>
    ) => ModifyAll<Table>
    readonly upsert: (value: ModifyWithKey<Table>) => Modify<Table>
    readonly upsertAll: (
      values: Array<ModifyWithKey<Table>>
    ) => ModifyAll<Table>
  }

  /**
   * Effect model for clearing all rows from a table.
   *
   * @category models
   * @since 4.0.0
   */
  export interface Clear<
    Table extends IndexedDbTable.AnyWithProps
  > extends Effect.Effect<void, IndexedDbQueryError> {
    readonly from: From<Table>
  }

  type ComparisonKeys = "equals" | "gte" | "lte" | "gt" | "lt" | "between"

  /**
   * Effect model for counting table rows, optionally constrained by an index and key-range comparisons.
   *
   * @category models
   * @since 4.0.0
   */
  export interface Count<
    Table extends IndexedDbTable.AnyWithProps,
    Index extends IndexedDbDatabase.IndexFromTable<Table>
  > extends Effect.Effect<number, IndexedDbQueryError> {
    readonly from: From<Table>
    readonly index?: Index
    readonly only?: ExtractIndexType<Table, Index>
    readonly lowerBound?: ExtractIndexType<Table, Index>
    readonly upperBound?: ExtractIndexType<Table, Index>
    readonly excludeLowerBound?: boolean
    readonly excludeUpperBound?: boolean

    readonly equals: (
      value: EqualsType<Table, Index>
    ) => Omit<Count<Table, Index>, ComparisonKeys>

    readonly gte: (
      value: ExtractIndexType<Table, Index>
    ) => Omit<Count<Table, Index>, ComparisonKeys>

    readonly lte: (
      value: ExtractIndexType<Table, Index>
    ) => Omit<Count<Table, Index>, ComparisonKeys>

    readonly gt: (
      value: ExtractIndexType<Table, Index>
    ) => Omit<Count<Table, Index>, ComparisonKeys>

    readonly lt: (
      value: ExtractIndexType<Table, Index>
    ) => Omit<Count<Table, Index>, ComparisonKeys>

    readonly between: (
      lowerBound: ExtractIndexType<Table, Index>,
      upperBound: ExtractIndexType<Table, Index>,
      options?: { excludeLowerBound?: boolean; excludeUpperBound?: boolean }
    ) => Omit<Count<Table, Index>, ComparisonKeys>
  }

  /**
   * Intermediate delete builder used to choose a key range or limit before producing an executable delete query.
   *
   * @category models
   * @since 4.0.0
   */
  export interface DeletePartial<
    Table extends IndexedDbTable.AnyWithProps,
    Index extends IndexedDbDatabase.IndexFromTable<Table>
  > {
    readonly from: From<Table>
    readonly index?: Index

    readonly equals: (
      value: EqualsType<Table, Index>
    ) => Delete<Table, Index>

    readonly gte: (
      value: ExtractIndexType<Table, Index>
    ) => Delete<Table, Index>

    readonly lte: (
      value: ExtractIndexType<Table, Index>
    ) => Delete<Table, Index>

    readonly gt: (
      value: ExtractIndexType<Table, Index>
    ) => Delete<Table, Index>

    readonly lt: (
      value: ExtractIndexType<Table, Index>
    ) => Delete<Table, Index>

    readonly between: (
      lowerBound: ExtractIndexType<Table, Index>,
      upperBound: ExtractIndexType<Table, Index>,
      options?: { excludeLowerBound?: boolean; excludeUpperBound?: boolean }
    ) => Delete<Table, Index>

    readonly limit: (
      limit: number
    ) => DeleteWithout<Table, Index, "limit">
  }

  type DeleteWithout<
    Table extends IndexedDbTable.AnyWithProps,
    Index extends IndexedDbDatabase.IndexFromTable<Table>,
    ExcludedKeys extends string
  > = Omit<Delete<Table, Index, ExcludedKeys>, ExcludedKeys>

  /**
   * Effect model for deleting rows from a table, with optional key-range, limit, filter, and reactivity invalidation helpers.
   *
   * @category models
   * @since 4.0.0
   */
  export interface Delete<
    Table extends IndexedDbTable.AnyWithProps,
    Index extends IndexedDbDatabase.IndexFromTable<Table>,
    ExcludedKeys extends string = never
  > extends Effect.Effect<void, IndexedDbQueryError> {
    readonly delete: DeletePartial<Table, Index>
    readonly index?: Index
    readonly limitValue?: number
    readonly only?: ExtractIndexType<Table, Index>
    readonly lowerBound?: ExtractIndexType<Table, Index>
    readonly upperBound?: ExtractIndexType<Table, Index>
    readonly excludeLowerBound?: boolean
    readonly excludeUpperBound?: boolean
    readonly predicate?: (item: IndexedDbTable.Encoded<Table>) => boolean

    readonly limit: (
      limit: number
    ) => DeleteWithout<Table, Index, ExcludedKeys | "limit">

    readonly filter: (
      f: (value: IndexedDbTable.Encoded<Table>) => boolean
    ) => DeleteWithout<Table, Index, ExcludedKeys>

    /**
     * Invalidate any queries using Reactivity service with the provided keys.
     *
     * **Details**
     *
     * If no keys are provided, the table name is used as the reactivity key.
     */
    readonly invalidate: (
      keys?: ReadonlyArray<unknown> | Record.ReadonlyRecord<string, ReadonlyArray<unknown>> | undefined
    ) => Effect.Effect<void, IndexedDbQueryError, IndexedDbTable.Context<Table>>
  }

  type SelectWithout<
    Table extends IndexedDbTable.AnyWithProps,
    Index extends IndexedDbDatabase.IndexFromTable<Table>,
    ExcludedKeys extends string
  > = Omit<Select<Table, Index, ExcludedKeys>, ExcludedKeys>

  /**
   * Effect model for selecting rows from a table, with chainable range, paging, filtering, streaming, and reactive query helpers.
   *
   * @category models
   * @since 4.0.0
   */
  export interface Select<
    Table extends IndexedDbTable.AnyWithProps,
    Index extends IndexedDbDatabase.IndexFromTable<Table>,
    ExcludedKeys extends string = never
  > extends
    Effect.Effect<
      Array<SelectType<Table>>,
      IndexedDbQueryError,
      IndexedDbTable.Context<Table>
    >
  {
    readonly from: From<Table>
    readonly index?: Index
    readonly limitValue?: number
    readonly offsetValue?: number
    readonly reverseValue?: boolean
    readonly only?: ExtractIndexType<Table, Index>
    readonly lowerBound?: ExtractIndexType<Table, Index>
    readonly upperBound?: ExtractIndexType<Table, Index>
    readonly excludeLowerBound?: boolean
    readonly excludeUpperBound?: boolean
    readonly predicate?: (item: IndexedDbTable.Encoded<Table>) => boolean

    readonly equals: (
      value: EqualsType<Table, Index>
    ) => SelectWithout<Table, Index, ExcludedKeys | ComparisonKeys>

    readonly gte: (
      value: ExtractIndexType<Table, Index>
    ) => SelectWithout<Table, Index, ExcludedKeys | ComparisonKeys>

    readonly lte: (
      value: ExtractIndexType<Table, Index>
    ) => SelectWithout<Table, Index, ExcludedKeys | ComparisonKeys>

    readonly gt: (
      value: ExtractIndexType<Table, Index>
    ) => SelectWithout<Table, Index, ExcludedKeys | ComparisonKeys>

    readonly lt: (
      value: ExtractIndexType<Table, Index>
    ) => SelectWithout<Table, Index, ExcludedKeys | ComparisonKeys>

    readonly between: (
      lowerBound: ExtractIndexType<Table, Index>,
      upperBound: ExtractIndexType<Table, Index>,
      options?: { excludeLowerBound?: boolean; excludeUpperBound?: boolean }
    ) => SelectWithout<Table, Index, ExcludedKeys | ComparisonKeys>

    readonly limit: (
      limit: number
    ) => SelectWithout<Table, Index, ExcludedKeys | "limit" | "first">

    readonly offset: (
      offset: number
    ) => SelectWithout<Table, Index, ExcludedKeys | "offset" | "first">

    readonly reverse: () => SelectWithout<Table, Index, ExcludedKeys | "reverse" | "first">

    readonly filter: (
      f: (value: IndexedDbTable.Encoded<Table>) => boolean
    ) => SelectWithout<Table, Index, ExcludedKeys | "first">

    readonly first: () => First<Table, Index>

    /**
     * Stream the selected data.
     *
     * **Details**
     *
     * The default chunk size is 100.
     */
    readonly stream: (options?: {
      readonly chunkSize?: number | undefined
    }) => Stream.Stream<
      SelectType<Table>,
      IndexedDbQueryError,
      IndexedDbTable.Context<Table>
    >

    /**
     * Use the Reactivity service to react to changes to the selected data.
     *
     * **Details**
     *
     * By default, the table name is used as the reactivity key.
     */
    readonly reactive: (
      keys?: ReadonlyArray<unknown> | Record.ReadonlyRecord<string, ReadonlyArray<unknown>> | undefined
    ) => Stream.Stream<
      Array<SelectType<Table>>,
      IndexedDbQueryError,
      IndexedDbTable.Context<Table>
    >
    readonly reactiveQueue: (
      keys?: ReadonlyArray<unknown> | Record.ReadonlyRecord<string, ReadonlyArray<unknown>> | undefined
    ) => Effect.Effect<
      Queue.Dequeue<Array<SelectType<Table>>, IndexedDbQueryError>,
      never,
      Scope.Scope | IndexedDbTable.Context<Table>
    >
  }

  /**
   * Effect model for selecting the first matching row, failing with `NoSuchElementError` when no row is found.
   *
   * @category models
   * @since 4.0.0
   */
  export interface First<
    Table extends IndexedDbTable.AnyWithProps,
    Index extends IndexedDbDatabase.IndexFromTable<Table>
  > extends
    Effect.Effect<
      SelectType<Table>,
      IndexedDbQueryError | Cause.NoSuchElementError,
      IndexedDbTable.Context<Table>
    >
  {
    readonly select: Select<Table, Index>

    /**
     * Use the Reactivity service to react to changes to the selected data.
     *
     * **Details**
     *
     * By default, the table name is used as the reactivity key.
     */
    readonly reactive: (
      keys?: ReadonlyArray<unknown> | Record.ReadonlyRecord<string, ReadonlyArray<unknown>> | undefined
    ) => Stream.Stream<
      SelectType<Table>,
      IndexedDbQueryError | Cause.NoSuchElementError,
      IndexedDbTable.Context<Table>
    >

    /**
     * Use the Reactivity service to react to changes to the selected data.
     *
     * **Details**
     *
     * By default, the table name is used as the reactivity key.
     */
    readonly reactiveQueue: (
      keys: ReadonlyArray<unknown> | Record.ReadonlyRecord<string, ReadonlyArray<unknown>>
    ) => Effect.Effect<
      Queue.Dequeue<SelectType<Table>, IndexedDbQueryError | Cause.NoSuchElementError>,
      never,
      Scope.Scope | IndexedDbTable.Context<Table>
    >
  }

  /**
   * Effect model for a select query filtered by one or more predicates over encoded table rows.
   *
   * @category models
   * @since 4.0.0
   */
  export interface Filter<
    Table extends IndexedDbTable.AnyWithProps,
    Index extends IndexedDbDatabase.IndexFromTable<Table>
  > extends
    Effect.Effect<
      Array<SelectType<Table>>,
      IndexedDbQueryError,
      IndexedDbTable.Context<Table>
    >
  {
    readonly select: Select<Table, Index>
    readonly predicate: (item: IndexedDbTable.Encoded<Table>) => boolean
    readonly filter: (
      f: (value: IndexedDbTable.Encoded<Table>) => boolean
    ) => Filter<Table, Index>
  }

  /**
   * Effect model for inserting or upserting one row, returning the resulting IndexedDB key and supporting reactivity invalidation.
   *
   * @category models
   * @since 4.0.0
   */
  export interface Modify<
    Table extends IndexedDbTable.AnyWithProps
  > extends
    Effect.Effect<
      globalThis.IDBValidKey,
      IndexedDbQueryError,
      IndexedDbTable.Context<Table>
    >
  {
    readonly operation: "add" | "put"
    readonly from: From<Table>
    readonly value: ModifyWithKey<Table>

    /**
     * Invalidate any queries using Reactivity service with the provided keys.
     *
     * **Details**
     *
     * If no keys are provided, the table name is used as the reactivity key.
     */
    readonly invalidate: (
      keys?: ReadonlyArray<unknown> | Record.ReadonlyRecord<string, ReadonlyArray<unknown>> | undefined
    ) => Effect.Effect<globalThis.IDBValidKey, IndexedDbQueryError, IndexedDbTable.Context<Table>>
  }

  /**
   * Effect model for inserting or upserting multiple rows, returning the resulting IndexedDB keys and supporting reactivity invalidation.
   *
   * @category models
   * @since 4.0.0
   */
  export interface ModifyAll<
    Table extends IndexedDbTable.AnyWithProps
  > extends
    Effect.Effect<
      Array<globalThis.IDBValidKey>,
      IndexedDbQueryError,
      IndexedDbTable.Context<Table>
    >
  {
    readonly operation: "add" | "put"
    readonly from: From<Table>
    readonly values: Array<ModifyWithKey<Table>>

    /**
     * Invalidate any queries using Reactivity service with the provided keys.
     *
     * **Details**
     *
     * If no keys are provided, the table name is used as the reactivity key.
     */
    readonly invalidate: (
      keys?: ReadonlyArray<unknown> | Record.ReadonlyRecord<string, ReadonlyArray<unknown>> | undefined
    ) => Effect.Effect<globalThis.IDBValidKey, IndexedDbQueryError, IndexedDbTable.Context<Table>>
  }
}

/**
 * Service tag for the active `IDBTransaction` used to share a transaction across IndexedDB query effects.
 *
 * @category models
 * @since 4.0.0
 */
export class IndexedDbTransaction extends Context.Service<IndexedDbTransaction, globalThis.IDBTransaction>()(
  "@effect/platform-browser/IndexedDbQueryBuilder/IndexedDbTransaction"
) {}

// -----------------------------------------------------------------------------
// internal
// -----------------------------------------------------------------------------

type IndexedDbValidKeys<TableSchema extends IndexedDbTable.AnySchemaStruct> = keyof TableSchema["Encoded"] extends
  infer K ? K extends keyof TableSchema["Encoded"] ? TableSchema["Encoded"][K] extends Readonly<IDBValidKey> ? K
    : never
  : never
  : never

type IndexedDbValidNumberKeys<
  TableSchema extends IndexedDbTable.AnySchemaStruct
> = keyof TableSchema["Encoded"] extends infer K
  ? K extends keyof TableSchema["Encoded"] ? [TableSchema["Encoded"][K]] extends [number | undefined] ? K
    : never
  : never
  : never

const applyDelete = (query: IndexedDbQuery.Delete<any, never>) =>
  Effect.callback<any, IndexedDbQueryError>((resume) => {
    const database = query.delete.from.database
    const IDBKeyRange = query.delete.from.IDBKeyRange
    const transaction = getOrCreateTransaction(database.current, [query.delete.from.table.tableName], "readwrite", {
      durability: query.delete.from.table.durability
    })
    const objectStore = transaction.objectStore(query.delete.from.table.tableName)
    const predicate = query.predicate

    let keyRange: globalThis.IDBKeyRange | undefined = undefined

    if (query.only !== undefined) {
      keyRange = IDBKeyRange.only(query.only)
    } else if (
      query.lowerBound !== undefined &&
      query.upperBound !== undefined
    ) {
      keyRange = IDBKeyRange.bound(
        query.lowerBound,
        query.upperBound,
        query.excludeLowerBound,
        query.excludeUpperBound
      )
    } else if (query.lowerBound !== undefined) {
      keyRange = IDBKeyRange.lowerBound(
        query.lowerBound,
        query.excludeLowerBound
      )
    } else if (query.upperBound !== undefined) {
      keyRange = IDBKeyRange.upperBound(
        query.upperBound,
        query.excludeUpperBound
      )
    }

    let request: globalThis.IDBRequest

    if (query.limitValue !== undefined || predicate) {
      const cursorRequest = objectStore.openCursor()
      let count = 0

      cursorRequest.onerror = () => {
        resume(
          Effect.fail(
            new IndexedDbQueryError({
              reason: "TransactionError",
              cause: cursorRequest.error
            })
          )
        )
      }

      cursorRequest.onsuccess = () => {
        const cursor = cursorRequest.result
        if (cursor === null) {
          return resume(Effect.void)
        }

        if (predicate === undefined || predicate(cursor.value)) {
          const deleteRequest = cursor.delete()
          deleteRequest.onerror = () => {
            resume(
              Effect.fail(
                new IndexedDbQueryError({
                  reason: "TransactionError",
                  cause: deleteRequest.error
                })
              )
            )
          }
          count += 1
        }

        if (query.limitValue === undefined || count < query.limitValue) {
          return cursor.continue()
        }

        resume(Effect.void)
      }
    } else if (keyRange !== undefined) {
      request = objectStore.delete(keyRange)

      request.onerror = (event) => {
        resume(
          Effect.fail(
            new IndexedDbQueryError({
              reason: "TransactionError",
              cause: event
            })
          )
        )
      }

      request.onsuccess = () => {
        resume(Effect.succeed(request.result))
      }
    } else {
      resume(
        Effect.die(new Error("No key range provided for delete operation"))
      )
    }
  })

const getReadonlyObjectStore = (
  query: IndexedDbQuery.Select<any, never> | IndexedDbQuery.Count<any, never>
) => {
  const database = query.from.database
  const IDBKeyRange = query.from.IDBKeyRange
  const transaction = getOrCreateTransaction(database.current, [query.from.table.tableName], "readonly", {
    durability: query.from.table.durability
  })
  const objectStore = transaction.objectStore(query.from.table.tableName)

  let keyRange: globalThis.IDBKeyRange | undefined = undefined
  let store: globalThis.IDBObjectStore | globalThis.IDBIndex

  if (query.only !== undefined) {
    keyRange = IDBKeyRange.only(query.only)
  } else if (query.lowerBound !== undefined && query.upperBound !== undefined) {
    keyRange = IDBKeyRange.bound(
      query.lowerBound,
      query.upperBound,
      query.excludeLowerBound,
      query.excludeUpperBound
    )
  } else if (query.lowerBound !== undefined) {
    keyRange = IDBKeyRange.lowerBound(
      query.lowerBound,
      query.excludeLowerBound
    )
  } else if (query.upperBound !== undefined) {
    keyRange = IDBKeyRange.upperBound(
      query.upperBound,
      query.excludeUpperBound
    )
  }

  if (query.index !== undefined) {
    store = objectStore.index(query.index)
  } else {
    store = objectStore
  }

  return { store, keyRange }
}

const applySelect = Effect.fnUntraced(function*(
  query: IndexedDbQuery.Select<any, never, any>
): Effect.fn.Return<Array<any>, IndexedDbQueryError, unknown> {
  const keyPath = query.from.table.keyPath
  const predicate = query.predicate

  const data = predicate || keyPath === undefined || query.offsetValue !== undefined ?
    yield* Effect.callback<Array<any>, IndexedDbQueryError>((resume) => {
      const { keyRange, store } = getReadonlyObjectStore(query)

      const cursorRequest = store.openCursor(keyRange, query.reverseValue ? "prev" : "next")
      const results: Array<any> = []
      let count = 0
      let offsetApplied = false

      cursorRequest.onerror = () => {
        resume(
          Effect.fail(
            new IndexedDbQueryError({
              reason: "TransactionError",
              cause: cursorRequest.error
            })
          )
        )
      }

      cursorRequest.onsuccess = () => {
        const cursor = cursorRequest.result
        if (cursor === null) {
          return resume(Effect.succeed(results))
        }

        if (query.offsetValue && !offsetApplied) {
          offsetApplied = true
          return cursor.advance(query.offsetValue)
        }

        if (predicate === undefined || predicate(cursor.value)) {
          results.push(
            keyPath === undefined
              ? { ...cursor.value, key: cursor.key }
              : cursor.value
          )
          count += 1
        }

        if (query.limitValue === undefined || count < query.limitValue) {
          return cursor.continue()
        }

        resume(Effect.succeed(results))
      }
    }) :
    yield* Effect.callback<Array<any>, IndexedDbQueryError>((resume) => {
      const { keyRange, store } = getReadonlyObjectStore(query)
      const request = store.getAll(keyRange, query.limitValue)
      request.onerror = (event) => {
        resume(
          Effect.fail(
            new IndexedDbQueryError({
              reason: "TransactionError",
              cause: event
            })
          )
        )
      }
      request.onsuccess = () => {
        if (query.reverseValue) {
          request.result.reverse()
        }
        resume(Effect.succeed(request.result))
      }
    })

  const tableSchema = (query.from.table as IndexedDbTable.AnyWithProps).arraySchema

  return yield* Schema.decodeUnknownEffect(tableSchema)(data).pipe(
    Effect.mapError(
      (error) =>
        new IndexedDbQueryError({
          reason: "DecodeError",
          cause: error
        })
    )
  ) as Effect.Effect<Array<any>, IndexedDbQueryError, unknown>
})

const applyFirst = Effect.fnUntraced(function*(
  query: IndexedDbQuery.First<any, never>
) {
  const keyPath = query.select.from.table.keyPath

  const data = yield* Effect.callback<any, IndexedDbQueryError | Cause.NoSuchElementError>((resume) => {
    const { keyRange, store } = getReadonlyObjectStore(query.select)

    if (keyRange !== undefined) {
      const request = store.get(keyRange)

      request.onerror = (event) => {
        resume(
          Effect.fail(
            new IndexedDbQueryError({
              reason: "TransactionError",
              cause: event
            })
          )
        )
      }

      request.onsuccess = () => {
        resume(Effect.succeed(request.result))
      }
    } else {
      const request = store.openCursor()

      request.onerror = (event) => {
        resume(
          Effect.fail(
            new IndexedDbQueryError({
              reason: "TransactionError",
              cause: event
            })
          )
        )
      }

      request.onsuccess = () => {
        const value = request.result?.value
        const key = request.result?.key

        if (value === undefined) {
          resume(
            Effect.fail(new Cause.NoSuchElementError(`No such element in table ${query.select.from.table.tableName}`))
          )
        } else {
          resume(
            Effect.succeed(keyPath === undefined ? { ...value, key } : value)
          )
        }
      }
    }
  })

  return yield* Schema.decodeUnknownEffect(query.select.from.table.readSchema)(
    data
  ).pipe(
    Effect.mapError(
      (error) =>
        new IndexedDbQueryError({
          reason: "DecodeError",
          cause: error
        })
    )
  )
})

const applyModify = Effect.fnUntraced(function*({
  query,
  value
}: {
  query: IndexedDbQuery.Modify<any>
  value: any
}) {
  const autoIncrement = query.from.table.autoIncrement as boolean
  const keyPath = query.from.table.keyPath
  const table = query.from.table
  const schema: Schema.Top = autoIncrement && value[keyPath] === undefined
    ? table.autoincrementSchema
    : table.tableSchema

  const encodedValue = yield* schema.makeEffect(value).pipe(
    Effect.flatMap(Schema.encodeUnknownEffect(schema)),
    Effect.mapError(
      (error) =>
        new IndexedDbQueryError({
          reason: "EncodeError",
          cause: error
        })
    )
  )

  return yield* Effect.callback<any, IndexedDbQueryError>((resume) => {
    const database = query.from.database
    const transaction = getOrCreateTransaction(database.current, [query.from.table.tableName], "readwrite", {
      durability: query.from.table.durability
    })
    const objectStore = transaction.objectStore(query.from.table.tableName)

    let request: globalThis.IDBRequest<IDBValidKey>

    if (query.operation === "add") {
      request = objectStore.add(
        encodedValue,
        keyPath === undefined ? value["key"] : undefined
      )
    } else if (query.operation === "put") {
      request = objectStore.put(
        encodedValue,
        keyPath === undefined ? value["key"] : undefined
      )
    } else {
      return resume(Effect.die(new Error("Invalid modify operation")))
    }

    request.onerror = (event) => {
      resume(
        Effect.fail(
          new IndexedDbQueryError({
            reason: "TransactionError",
            cause: event
          })
        )
      )
    }

    request.onsuccess = () => {
      resume(Effect.succeed(request.result))
    }
  })
})

const applyModifyAll = Effect.fnUntraced(
  function*({
    query,
    values
  }: {
    query: IndexedDbQuery.ModifyAll<any>
    values: Array<any>
  }) {
    const autoIncrement = query.from.table.autoIncrement as boolean
    const keyPath = query.from.table.keyPath
    const schema = query.from.table.tableSchema
    const encodedValues = new Array(values.length)
    const makeValue = SchemaParser.makeEffect(schema)
    const encodeValue = SchemaParser.encodeUnknownEffect(schema)
    const makeValueAutoincrement = SchemaParser.makeEffect(query.from.table.autoincrementSchema)
    const encodeValueAutoincrement = SchemaParser.encodeUnknownEffect(query.from.table.autoincrementSchema)

    for (let i = 0; i < values.length; i++) {
      const value = values[i]
      if (autoIncrement && value[keyPath] === undefined) {
        encodedValues[i] = yield* encodeValueAutoincrement(yield* makeValueAutoincrement(value))
      } else {
        encodedValues[i] = yield* encodeValue(yield* makeValue(value))
      }
    }

    return yield* Effect.callback<
      Array<globalThis.IDBValidKey>,
      IndexedDbQueryError
    >((resume) => {
      if (encodedValues.length === 0) {
        return resume(Effect.succeed([]))
      }

      const database = query.from.database
      const transaction = getOrCreateTransaction(database.current, [query.from.table.tableName], "readwrite", {
        durability: query.from.table.durability
      })
      const objectStore = transaction.objectStore(query.from.table.tableName)

      const results: Array<globalThis.IDBValidKey> = new Array(encodedValues.length)
      let remaining = encodedValues.length

      if (query.operation === "add") {
        for (let i = 0; i < encodedValues.length; i++) {
          const request = objectStore.add(
            encodedValues[i],
            keyPath === undefined ? values[i]["key"] : undefined
          )

          request.onerror = () => {
            resume(
              Effect.fail(
                new IndexedDbQueryError({
                  reason: "TransactionError",
                  cause: request.error
                })
              )
            )
          }

          request.onsuccess = () => {
            results[i] = request.result
            remaining -= 1
            if (remaining === 0) {
              resume(Effect.succeed(results))
            }
          }
        }
      } else if (query.operation === "put") {
        for (let i = 0; i < encodedValues.length; i++) {
          const request = objectStore.put(
            encodedValues[i],
            keyPath === undefined ? values[i]["key"] : undefined
          )

          request.onerror = () => {
            resume(
              Effect.fail(
                new IndexedDbQueryError({
                  reason: "TransactionError",
                  cause: request.error
                })
              )
            )
          }

          request.onsuccess = () => {
            results[i] = request.result
            remaining -= 1
            if (remaining === 0) {
              resume(Effect.succeed(results))
            }
          }
        }
      } else {
        return resume(Effect.die(new Error("Invalid modify all operation")))
      }

      objectStore.transaction.onerror = () => {
        resume(
          Effect.fail(
            new IndexedDbQueryError({
              reason: "TransactionError",
              cause: objectStore.transaction.error
            })
          )
        )
      }
    })
  },
  Effect.catchIf(
    SchemaIssue.isIssue,
    (issue) => Effect.fail(new IndexedDbQueryError({ reason: "EncodeError", cause: new Schema.SchemaError(issue) }))
  )
)

const applyClear = (options: {
  readonly database: globalThis.IDBDatabase
  readonly table: IndexedDbTable.AnyWithProps
}) =>
  Effect.callback<void, IndexedDbQueryError>((resume) => {
    const database = options.database
    const transaction = getOrCreateTransaction(database, [options.table.tableName], "readwrite", {
      durability: options.table.durability
    })
    const objectStore = transaction.objectStore(options.table.tableName)

    const request = objectStore.clear()

    request.onerror = (event) => {
      resume(
        Effect.fail(
          new IndexedDbQueryError({
            reason: "TransactionError",
            cause: event
          })
        )
      )
    }

    request.onsuccess = () => {
      resume(Effect.void)
    }
  })

const applyClearAll = (options: {
  readonly database: globalThis.IDBDatabase
}) =>
  Effect.callback<void, IndexedDbQueryError>((resume) => {
    const database = options.database
    const tables = database.objectStoreNames
    const transaction = getOrCreateTransaction(database, [...tables], "readwrite")

    for (let t = 0; t < tables.length; t++) {
      const objectStore = transaction.objectStore(tables[t])
      const request = objectStore.clear()

      request.onerror = () => {
        resume(
          Effect.fail(
            new IndexedDbQueryError({
              reason: "TransactionError",
              cause: request.error
            })
          )
        )
      }
    }

    transaction.onerror = () => {
      resume(
        Effect.fail(
          new IndexedDbQueryError({
            reason: "TransactionError",
            cause: transaction.error
          })
        )
      )
    }

    transaction.oncomplete = () => {
      resume(Effect.void)
    }
  })

const getCount = (query: IndexedDbQuery.Count<any, never>) =>
  Effect.callback<number, IndexedDbQueryError>((resume) => {
    const { keyRange, store } = getReadonlyObjectStore(query)

    const request = store.count(keyRange)

    request.onerror = (event) => {
      resume(
        Effect.fail(
          new IndexedDbQueryError({
            reason: "TransactionError",
            cause: event
          })
        )
      )
    }

    request.onsuccess = () => {
      resume(Effect.succeed(request.result))
    }
  })

const FromProto: Omit<
  IndexedDbQuery.From<any>,
  | "table"
  | "database"
  | "IDBKeyRange"
  | "transaction"
  | "reactivity"
  | "selectCache"
  | "countCache"
  | "deleteCache"
> = {
  ...CommonProto,
  select<Index extends IndexedDbDatabase.IndexFromTable<any>>(
    this: IndexedDbQuery.From<any>,
    index?: Index
  ) {
    let select = this.selectCache.get(index)
    if (select === undefined) {
      select = makeSelect({
        from: this,
        index
      })
      this.selectCache.set(index, select)
    }
    return select
  },
  count<Index extends IndexedDbDatabase.IndexFromTable<any>>(
    this: IndexedDbQuery.From<any>,
    index?: Index
  ) {
    let count = this.countCache.get(index)
    if (count === undefined) {
      count = makeCount({
        from: this,
        index
      })
      this.countCache.set(index, count)
    }
    return count
  },
  delete<Index extends IndexedDbDatabase.IndexFromTable<any>>(
    this: IndexedDbQuery.From<any>,
    index?: Index
  ) {
    let cached = this.deleteCache.get(index)
    if (cached === undefined) {
      cached = makeDeletePartial({
        from: this,
        index
      })
      this.deleteCache.set(index, cached)
    }
    return cached
  },
  insert(this: IndexedDbQuery.From<any>, value: any) {
    return makeModify({ from: this, value, operation: "add" })
  },
  upsert(this: IndexedDbQuery.From<any>, value: any) {
    return makeModify({ from: this, value, operation: "put" })
  },
  insertAll(this: IndexedDbQuery.From<any>, values: Array<any>) {
    return makeModifyAll({ from: this, values, operation: "add" })
  },
  upsertAll(this: IndexedDbQuery.From<any>, values: Array<any>) {
    return makeModifyAll({ from: this, values, operation: "put" })
  },
  get clear() {
    const self = this as IndexedDbQuery.From<any>
    return applyClear({
      database: self.database.current,
      table: self.table
    })
  }
}

const makeFrom = <
  const Table extends IndexedDbTable.AnyWithProps
>(options: {
  readonly table: Table
  readonly database: MutableRef.MutableRef<globalThis.IDBDatabase>
  readonly IDBKeyRange: typeof globalThis.IDBKeyRange
  readonly reactivity: Reactivity.Reactivity["Service"]
}): IndexedDbQuery.From<Table> => {
  const self = Object.create(FromProto)
  self.table = options.table
  self.database = options.database
  self.IDBKeyRange = options.IDBKeyRange
  self.reactivity = options.reactivity
  self.selectCache = new Map()
  self.countCache = new Map()
  self.deleteCache = new Map()
  return self
}

const DeletePartialProto: Omit<
  IndexedDbQuery.DeletePartial<any, never>,
  | "from"
  | "index"
> = {
  ...CommonProto,
  limit(this: IndexedDbQuery.DeletePartial<any, never>, limit: number) {
    return makeDelete({
      delete: this as any,
      limitValue: limit
    })
  },
  equals(this: IndexedDbQuery.DeletePartial<any, never>, value: IndexedDbQuery.ExtractIndexType<any, never>) {
    return makeDelete({
      delete: this as any,
      only: value
    })
  },
  gte(this: IndexedDbQuery.DeletePartial<any, never>, value: IndexedDbQuery.ExtractIndexType<any, never>) {
    return makeDelete({
      delete: this as any,
      lowerBound: value,
      excludeLowerBound: false
    })
  },
  lte(this: IndexedDbQuery.DeletePartial<any, never>, value: IndexedDbQuery.ExtractIndexType<any, never>) {
    return makeDelete({
      delete: this as any,
      upperBound: value,
      excludeUpperBound: false
    })
  },
  gt(this: IndexedDbQuery.DeletePartial<any, never>, value: IndexedDbQuery.ExtractIndexType<any, never>) {
    return makeDelete({
      delete: this as any,
      lowerBound: value,
      excludeLowerBound: true
    })
  },
  lt(this: IndexedDbQuery.DeletePartial<any, never>, value: IndexedDbQuery.ExtractIndexType<any, never>) {
    return makeDelete({
      delete: this as any,
      upperBound: value,
      excludeUpperBound: true
    })
  },
  between(
    this: IndexedDbQuery.DeletePartial<any, never>,
    lowerBound: IndexedDbQuery.ExtractIndexType<any, never>,
    upperBound: IndexedDbQuery.ExtractIndexType<any, never>,
    queryOptions?: { excludeLowerBound?: boolean; excludeUpperBound?: boolean }
  ) {
    return makeDelete({
      delete: this as any,
      lowerBound,
      upperBound,
      excludeLowerBound: queryOptions?.excludeLowerBound ?? false,
      excludeUpperBound: queryOptions?.excludeUpperBound ?? false
    })
  }
}

const makeDeletePartial = <
  Table extends IndexedDbTable.AnyWithProps,
  Index extends IndexedDbDatabase.IndexFromTable<Table>
>(options: {
  readonly from: IndexedDbQuery.From<Table>
  readonly index: Index | undefined
}): IndexedDbQuery.DeletePartial<Table, Index> => {
  const self = Object.create(DeletePartialProto)
  self.from = options.from
  self.index = options.index
  return self as any
}

const DeleteProto: Omit<
  IndexedDbQuery.Delete<any, never>,
  | "delete"
  | "limitValue"
  | "only"
  | "lowerBound"
  | "upperBound"
  | "excludeLowerBound"
  | "excludeUpperBound"
  | "predicate"
> = {
  ...CommonProto,
  ...Effectable.Prototype<IndexedDbQuery.Delete<any, never>>({
    label: "IndexedDbQuery.Delete",
    evaluate() {
      return applyDelete(this)
    }
  }),
  limit(this: IndexedDbQuery.Delete<any, never>, limit: number) {
    return makeDelete({
      ...this,
      limitValue: limit
    })
  },
  filter(this: IndexedDbQuery.Delete<any, never>, filter: (value: IndexedDbTable.Encoded<any>) => boolean) {
    const prev = this.predicate
    return makeDelete({
      delete: this.delete,
      predicate: prev ? (item) => prev(item) && filter(item) : filter
    })
  },
  invalidate(
    this: IndexedDbQuery.Delete<any, never>,
    keys?: ReadonlyArray<unknown> | Record.ReadonlyRecord<string, ReadonlyArray<unknown>> | undefined
  ) {
    keys ??= this.only !== undefined
      ? { [this.delete.from.table.tableName]: [this.only] }
      : [this.delete.from.table.tableName]
    return this.delete.from.reactivity.mutation(keys, this)
  }
}

const makeDelete = <
  Table extends IndexedDbTable.AnyWithProps,
  Index extends IndexedDbDatabase.IndexFromTable<Table>
>(options: {
  readonly delete: IndexedDbQuery.DeletePartial<Table, Index>
  readonly limitValue?: number | undefined
  readonly only?: IndexedDbQuery.ExtractIndexType<Table, Index> | undefined
  readonly lowerBound?:
    | IndexedDbQuery.ExtractIndexType<Table, Index>
    | undefined
  readonly upperBound?:
    | IndexedDbQuery.ExtractIndexType<Table, Index>
    | undefined
  readonly excludeLowerBound?: boolean | undefined
  readonly excludeUpperBound?: boolean | undefined
  readonly predicate?: ((item: IndexedDbTable.Encoded<Table>) => boolean) | undefined
}): IndexedDbQuery.Delete<Table, Index> => {
  const self = Object.create(DeleteProto)
  self.delete = options.delete
  self.limitValue = options.limitValue
  self.only = options.only
  self.lowerBound = options.lowerBound
  self.upperBound = options.upperBound
  self.excludeLowerBound = options.excludeLowerBound ?? false
  self.excludeUpperBound = options.excludeUpperBound ?? false
  self.predicate = options.predicate
  return self
}

const CountProto: Omit<
  IndexedDbQuery.Count<any, never>,
  | "from"
  | "index"
  | "only"
  | "lowerBound"
  | "upperBound"
  | "excludeLowerBound"
  | "excludeUpperBound"
> = {
  ...CommonProto,
  ...Effectable.Prototype<IndexedDbQuery.Count<any, never>>({
    label: "IndexedDbQuery.Count",
    evaluate() {
      return getCount(this)
    }
  }),
  equals(this: IndexedDbQuery.Count<any, never>, value: IndexedDbQuery.ExtractIndexType<any, never>) {
    return makeCount({
      from: this.from,
      index: this.index,
      only: value
    })
  },
  gte(this: IndexedDbQuery.Count<any, never>, value: IndexedDbQuery.ExtractIndexType<any, never>) {
    return makeCount({
      from: this.from,
      index: this.index,
      lowerBound: value,
      excludeLowerBound: false
    })
  },
  lte(this: IndexedDbQuery.Count<any, never>, value: IndexedDbQuery.ExtractIndexType<any, never>) {
    return makeCount({
      from: this.from,
      index: this.index,
      upperBound: value,
      excludeUpperBound: false
    })
  },
  gt(this: IndexedDbQuery.Count<any, never>, value: IndexedDbQuery.ExtractIndexType<any, never>) {
    return makeCount({
      from: this.from,
      index: this.index,
      lowerBound: value,
      excludeLowerBound: true
    })
  },
  lt(this: IndexedDbQuery.Count<any, never>, value: IndexedDbQuery.ExtractIndexType<any, never>) {
    return makeCount({
      from: this.from,
      index: this.index,
      upperBound: value,
      excludeUpperBound: true
    })
  },
  between(
    this: IndexedDbQuery.Count<any, never>,
    lowerBound: IndexedDbQuery.ExtractIndexType<any, never>,
    upperBound: IndexedDbQuery.ExtractIndexType<any, never>,
    queryOptions?: { excludeLowerBound?: boolean; excludeUpperBound?: boolean }
  ) {
    return makeCount({
      from: this.from,
      index: this.index,
      lowerBound,
      upperBound,
      excludeLowerBound: queryOptions?.excludeLowerBound ?? false,
      excludeUpperBound: queryOptions?.excludeUpperBound ?? false
    })
  }
}

const makeCount = <
  Table extends IndexedDbTable.AnyWithProps,
  Index extends IndexedDbDatabase.IndexFromTable<Table>
>(options: {
  readonly from: IndexedDbQuery.From<Table>
  readonly index: Index | undefined
  readonly only?: IndexedDbQuery.ExtractIndexType<Table, Index> | undefined
  readonly lowerBound?:
    | IndexedDbQuery.ExtractIndexType<Table, Index>
    | undefined
  readonly upperBound?:
    | IndexedDbQuery.ExtractIndexType<Table, Index>
    | undefined
  readonly excludeLowerBound?: boolean | undefined
  readonly excludeUpperBound?: boolean | undefined
}): IndexedDbQuery.Count<Table, Index> => {
  const self = Object.create(CountProto)
  self.from = options.from
  self.index = options.index
  self.only = options.only
  self.lowerBound = options.lowerBound
  self.upperBound = options.upperBound
  self.excludeLowerBound = options.excludeLowerBound
  self.excludeUpperBound = options.excludeUpperBound
  return self
}

const SelectProto: Omit<
  IndexedDbQuery.Select<any, never>,
  | "from"
  | "index"
  | "limitValue"
  | "reverseValue"
  | "only"
  | "lowerBound"
  | "upperBound"
  | "excludeLowerBound"
  | "excludeUpperBound"
> = {
  ...CommonProto,
  ...Effectable.Prototype<IndexedDbQuery.Select<any, never>>({
    label: "IndexedDbQuery.Select",
    evaluate() {
      return applySelect(this)
    }
  }),
  limit(this: IndexedDbQuery.Select<any, never>, limit: number) {
    return makeSelect({
      ...this,
      limitValue: limit
    })
  },
  offset(this: IndexedDbQuery.Select<any, never>, offset: number) {
    return makeSelect({
      ...this,
      offsetValue: offset
    })
  },
  equals(this: IndexedDbQuery.Select<any, never>, value: IndexedDbQuery.ExtractIndexType<any, never>) {
    return makeSelect({
      ...this,
      only: value
    })
  },
  gte(this: IndexedDbQuery.Select<any, never>, value: IndexedDbQuery.ExtractIndexType<any, never>) {
    return makeSelect({
      ...this,
      lowerBound: value,
      excludeLowerBound: false
    })
  },
  lte(this: IndexedDbQuery.Select<any, never>, value: IndexedDbQuery.ExtractIndexType<any, never>) {
    return makeSelect({
      ...this,
      upperBound: value,
      excludeUpperBound: false
    })
  },
  gt(this: IndexedDbQuery.Select<any, never>, value: IndexedDbQuery.ExtractIndexType<any, never>) {
    return makeSelect({
      ...this,
      lowerBound: value,
      excludeLowerBound: true
    })
  },
  lt(this: IndexedDbQuery.Select<any, never>, value: IndexedDbQuery.ExtractIndexType<any, never>) {
    return makeSelect({
      ...this,
      upperBound: value,
      excludeUpperBound: true
    })
  },
  between(
    this: IndexedDbQuery.Select<any, never>,
    lowerBound: IndexedDbQuery.ExtractIndexType<any, never>,
    upperBound: IndexedDbQuery.ExtractIndexType<any, never>,
    queryOptions?: { excludeLowerBound?: boolean; excludeUpperBound?: boolean }
  ) {
    return makeSelect({
      ...this,
      lowerBound,
      upperBound,
      excludeLowerBound: queryOptions?.excludeLowerBound ?? false,
      excludeUpperBound: queryOptions?.excludeUpperBound ?? false
    })
  },
  reverse(this: IndexedDbQuery.Select<any, never>) {
    return makeSelect({
      ...this,
      reverseValue: true
    })
  },
  first(this: IndexedDbQuery.Select<any, never>) {
    return makeFirst({ select: this })
  },
  filter(this: IndexedDbQuery.Select<any, never>, filter: (value: IndexedDbTable.Encoded<any>) => boolean) {
    const prev = this.predicate
    return makeSelect({
      ...this,
      predicate: prev ? (item) => prev(item) && filter(item) : filter
    })
  },
  stream(this: IndexedDbQuery.Select<any, never>, options?: {
    readonly chunkSize?: number | undefined
  }) {
    const limit = this.limitValue
    const chunkSize = Math.min(options?.chunkSize ?? 100, limit ?? Number.MAX_SAFE_INTEGER)
    const initial = this.limit(chunkSize)
    return Stream.suspend(() => {
      let total = 0
      return Stream.paginate(initial, (select) =>
        Effect.map(
          applySelect(select as any),
          (data) => {
            total += data.length
            ;(select as any).offsetValue = total
            const reachedLimit = limit && total >= limit
            const isPartial = data.length < chunkSize
            return [data, isPartial || reachedLimit ? Option.none() : Option.some(select)] as const
          }
        ))
    })
  },
  reactive(
    this: IndexedDbQuery.Select<any, never>,
    keys?: ReadonlyArray<unknown> | Record.ReadonlyRecord<string, ReadonlyArray<unknown>> | undefined
  ) {
    keys ??= [this.from.table.tableName]
    return this.from.reactivity.stream(keys, this)
  },
  reactiveQueue(
    this: IndexedDbQuery.Select<any, never>,
    keys?: ReadonlyArray<unknown> | Record.ReadonlyRecord<string, ReadonlyArray<unknown>> | undefined
  ) {
    keys ??= [this.from.table.tableName]
    return this.from.reactivity.query(keys, this)
  }
}

const makeSelect = <
  Table extends IndexedDbTable.AnyWithProps,
  Index extends IndexedDbDatabase.IndexFromTable<Table>
>(options: {
  readonly from: IndexedDbQuery.From<Table>
  readonly index?: Index | undefined
  readonly limitValue?: number | undefined
  readonly offsetValue?: number | undefined
  readonly reverseValue?: boolean | undefined
  readonly only?: IndexedDbQuery.ExtractIndexType<Table, Index> | undefined
  readonly lowerBound?:
    | IndexedDbQuery.ExtractIndexType<Table, Index>
    | undefined
  readonly upperBound?:
    | IndexedDbQuery.ExtractIndexType<Table, Index>
    | undefined
  readonly excludeLowerBound?: boolean | undefined
  readonly excludeUpperBound?: boolean | undefined
  readonly predicate?: ((item: IndexedDbTable.Encoded<Table>) => boolean) | undefined
}): IndexedDbQuery.Select<Table, Index> => {
  const self = Object.create(SelectProto)
  self.from = options.from
  self.index = options.index
  self.only = options.only
  self.limitValue = options.limitValue
  self.offsetValue = options.offsetValue
  self.reverseValue = options.reverseValue
  self.lowerBound = options.lowerBound
  self.upperBound = options.upperBound
  self.excludeLowerBound = options.excludeLowerBound
  self.excludeUpperBound = options.excludeUpperBound
  self.predicate = options.predicate
  return self as any
}

const FirstProto: Omit<
  IndexedDbQuery.First<any, never>,
  "select"
> = {
  ...CommonProto,
  ...Effectable.Prototype<IndexedDbQuery.First<any, never>>({
    label: "IndexedDbQuery.First",
    evaluate() {
      return applyFirst(this)
    }
  }),
  reactive(
    this: IndexedDbQuery.First<any, never>,
    keys?: ReadonlyArray<unknown> | Record.ReadonlyRecord<string, ReadonlyArray<unknown>> | undefined
  ) {
    keys ??= this.select.only !== undefined
      ? [`${this.select.from.table.tableName}:${String(this.select.only)}`]
      : [this.select.from.table.tableName]
    return this.select.from.reactivity.stream(keys, this)
  },
  reactiveQueue(
    this: IndexedDbQuery.First<any, never>,
    keys?: ReadonlyArray<unknown> | Record.ReadonlyRecord<string, ReadonlyArray<unknown>> | undefined
  ) {
    keys ??= this.select.only !== undefined
      ? [`${this.select.from.table.tableName}:${this.select.only}`]
      : [this.select.from.table.tableName]
    return this.select.from.reactivity.query(keys, this)
  }
}

const makeFirst = <
  Table extends IndexedDbTable.AnyWithProps,
  Index extends IndexedDbDatabase.IndexFromTable<Table>
>(options: {
  readonly select: IndexedDbQuery.Select<Table, Index>
}): IndexedDbQuery.First<Table, Index> => {
  const self = Object.create(FirstProto)
  self.select = options.select
  return self as any
}

const ModifyProto: Omit<
  IndexedDbQuery.Modify<any>,
  | "from"
  | "value"
  | "operation"
> = {
  ...CommonProto,
  ...Effectable.Prototype<IndexedDbQuery.Modify<any>>({
    label: "IndexedDbQuery.Modify",
    evaluate() {
      return applyModify({ query: this, value: this.value })
    }
  }),
  invalidate(
    this: IndexedDbQuery.Modify<any>,
    keys?: ReadonlyArray<unknown> | Record.ReadonlyRecord<string, ReadonlyArray<unknown>> | undefined
  ) {
    const keyPath = this.from.table.keyPath
    keys ??= typeof keyPath === "string" && this.value[keyPath] !== undefined
      ? { [this.from.table.tableName]: [this.value[keyPath]] }
      : [this.from.table.tableName]
    return this.from.reactivity.mutation(keys, this)
  }
}

const makeModify = <Table extends IndexedDbTable.AnyWithProps>(options: {
  readonly from: IndexedDbQuery.From<Table>
  readonly value: IndexedDbTable.TableSchema<Table>["Type"]
  readonly operation: "add" | "put"
}): IndexedDbQuery.Modify<Table> => {
  const self = Object.create(ModifyProto)
  self.from = options.from
  self.value = options.value
  self.operation = options.operation
  return self as any
}

const ModifyAllProto: Omit<
  IndexedDbQuery.ModifyAll<any>,
  | "from"
  | "values"
  | "operation"
> = {
  ...CommonProto,
  ...Effectable.Prototype<IndexedDbQuery.ModifyAll<any>>({
    label: "IndexedDbQuery.ModifyAll",
    evaluate() {
      return applyModifyAll({ query: this, values: this.values })
    }
  }),
  invalidate(
    this: IndexedDbQuery.ModifyAll<any>,
    keys?: ReadonlyArray<unknown> | Record.ReadonlyRecord<string, ReadonlyArray<unknown>> | undefined
  ) {
    keys ??= [this.from.table.tableName]
    return this.from.reactivity.mutation(keys, this)
  }
}

const makeModifyAll = <
  Table extends IndexedDbTable.AnyWithProps
>(options: {
  readonly from: IndexedDbQuery.From<Table>
  readonly values: Array<IndexedDbTable.TableSchema<Table>["Type"]>
  readonly operation: "add" | "put"
}): IndexedDbQuery.ModifyAll<Table> => {
  const self = Object.create(ModifyAllProto)
  self.from = options.from
  self.values = options.values
  self.operation = options.operation
  return self as any
}

const QueryBuilderProto: Omit<
  IndexedDbQueryBuilder<any>,
  | "fromCache"
  | "tables"
  | "database"
  | "IDBKeyRange"
  | "IDBTransaction"
  | "reactivity"
> = {
  ...CommonProto,
  use(this: IndexedDbQueryBuilder<any>, f: (database: globalThis.IDBDatabase) => any) {
    return Effect.try({
      try: () => f(this.database.current),
      catch: (error) =>
        new IndexedDbQueryError({
          reason: "UnknownError",
          cause: error
        })
    })
  },
  from(this: IndexedDbQueryBuilder<any>, table: any) {
    let cached = this.fromCache.get(table)
    if (cached === undefined) {
      cached = makeFrom({
        database: this.database,
        IDBKeyRange: this.IDBKeyRange,
        table: this.tables.get(table)!,
        reactivity: this.reactivity
      })
      this.fromCache.set(table, cached)
    }
    return cached as any
  },
  get clearAll() {
    const self = this as IndexedDbQueryBuilder<any>
    return applyClearAll({ database: self.database.current })
  },
  withTransaction(this: IndexedDbQueryBuilder<any>, options: {
    readonly tables: NonEmptyReadonlyArray<any>
    readonly mode: globalThis.IDBTransactionMode
    readonly durability?: IDBTransactionDurability
  }) {
    return (effect) =>
      Effect.suspend(() => {
        const transaction = this.database.current.transaction(options.tables, options.mode, options)
        return Effect.provideService(effect, IndexedDbTransaction, transaction)
      }).pipe(
        // To prevent async gaps between transaction queries
        Effect.provideService(References.PreventSchedulerYield, true)
      )
  }
}

/**
 * Creates an `IndexedDbQueryBuilder` from an open database reference, key-range constructor, table map, and reactivity service.
 *
 * @category constructors
 * @since 4.0.0
 */
export const make = <Source extends IndexedDbVersion.AnyWithProps>({
  IDBKeyRange,
  database,
  tables,
  reactivity
}: {
  readonly database: MutableRef.MutableRef<globalThis.IDBDatabase>
  readonly IDBKeyRange: typeof globalThis.IDBKeyRange
  readonly tables: ReadonlyMap<string, IndexedDbVersion.Tables<Source>>
  readonly reactivity: Reactivity.Reactivity["Service"]
}): IndexedDbQueryBuilder<Source> => {
  const self = Object.create(QueryBuilderProto)
  self.tables = tables
  self.database = database
  self.reactivity = reactivity
  self.IDBKeyRange = IDBKeyRange
  self.fromCache = new Map()
  return self
}

const getOrCreateTransaction = (
  database: globalThis.IDBDatabase,
  tables: ReadonlyArray<string>,
  mode: globalThis.IDBTransactionMode,
  options?: IDBTransactionOptions
) => {
  const fiber = Fiber.getCurrent()!
  return Context.getOrUndefined(fiber.context, IndexedDbTransaction) ?? database.transaction(tables, mode, options)
}
