import { BaseQueryBuilder } from './query/builder/index.js'
import { collectSourceRefs, requireCollectionSource } from './query/ir.js'
import { isCollection, isFailedStatus } from './live-query-primitives.js'
import {
  DEFAULT_GC_TIME_MS,
  UNSUBSCRIBED_GC_FLOOR_MS,
} from './collection/lifecycle.js'
import { createLiveQueryCollection } from './query/live-query-collection.js'
import { LIVE_QUERY_INTERNAL } from './query/live/internal.js'
import { getBuilderFromConfig } from './query/live/collection-registry.js'
import {
  getQueryIdentity,
  getStableQueryBuilderHash,
  getStableValueHash,
} from './query/ir-stable-identity.js'
import { getStringCollationIdentity } from './query/runtime-reference-identity.js'
import { codedMessage, devBuild } from './error-message.js'
import { InvalidPoolOptionError, UnhashableQueryIRError } from './errors.js'
import type { Collection, CollectionImpl } from './collection/index.js'
import type { CollectionOptions, DbClient } from './client.js'
import type { PooledLiveQueries } from './query/pooled-live-query.js'
import type {
  Context,
  InitialQueryBuilder,
  LiveQueryCollectionConfig,
  LiveQueryHookConfig,
  QueryBuilder,
} from './query/index.js'

/**
 * A hook config's pool: `pooledLiveQueries` or nothing. Any other value, such
 * as the removed boolean form, contradicts the hook contract, so every reader
 * rejects it the same way, whatever the config's other options. An adapter
 * that reads the pool from its own options, as Angular does, checks it here.
 */
export function configPool(config: object): PooledLiveQueries | undefined {
  const { pool } = config as { pool?: unknown }
  if (
    pool !== undefined &&
    typeof (pool as Partial<PooledLiveQueries> | null)
      ?.createPooledLiveQuery !== `function`
  )
    throw new InvalidPoolOptionError()
  return pool as PooledLiveQueries | undefined
}

export type LiveQueryKey = ReadonlyArray<unknown>

export type LiveQueryOptions = LiveQueryCollectionConfig<any> & {
  queryKey?: LiveQueryKey
}

export type DeferredLiveQueryCollections = Set<
  CollectionImpl<any, string | number, any, any, any>
>

/**
 * Release the sync starts a rejected query input deferred, then rethrow its
 * rejection. A framework hook calls this around its own render work, so a
 * query the hook rejects leaves no source deferred and a later reader can
 * start it. A render that throws after the hook returns, such as in a sibling
 * hook, is not a rejection of this input: like any render that never commits,
 * it releases its deferral through `releaseDeferredOnReclaim` when garbage
 * collection reclaims its live query. As for a
 * `DbClient` preload, the rejection wins over a source whose resumed start
 * fails; that source records its own failure.
 */
export function rejectLiveQueryInput(
  collections: DeferredLiveQueryCollections | undefined,
  rejection: unknown,
): never {
  try {
    if (collections) resumeDeferredLiveQueryCollections(collections)
  } catch {
    // Report the rejection, not a later start failure.
  }
  throw rejection
}

/**
 * Release a render's deferred sync starts when the live query it built is
 * reclaimed. A render that commits releases them first, when it subscribes.
 * A render that never commits, because a sibling threw or it suspended, leaves
 * its live query unsubscribed until garbage collection cleans it up; that
 * cleanup releases the starts, so a direct reader's demand, made before or
 * after, then starts the sources. A live query that garbage collection never
 * reclaims, because its config disables GC or does not start its sync,
 * releases after the unsubscribed GC floor instead. Releases only the starts
 * deferred so far, not ones a later render of the same hook defers. Pass only
 * a live query the render built, and call the returned function when the
 * render commits, so a committed hook keeps no listener, timer, or copied
 * sources.
 */
export function releaseDeferredOnReclaim(
  liveQuery: Pick<Collection<any, any, any>, `once` | `config` | `status`>,
  deferredCollections: DeferredLiveQueryCollections,
): (() => void) | undefined {
  if (deferredCollections.size === 0) return undefined
  const pending: DeferredLiveQueryCollections = new Set(deferredCollections)
  const release = () => {
    try {
      resumeDeferredLiveQueryCollections(pending)
    } catch {
      // A source whose resumed start fails records its own failure.
    }
  }
  // The lifecycle's own rule: GC reclaims only a started collection with a
  // finite, positive gcTime.
  const gcTime = liveQuery.config.gcTime ?? DEFAULT_GC_TIME_MS
  if (gcTime > 0 && Number.isFinite(gcTime) && liveQuery.status !== `idle`) {
    return liveQuery.once(`status:cleaned-up`, release)
  }
  const timer = setTimeout(release, UNSUBSCRIBED_GC_FLOOR_MS)
  return () => clearTimeout(timer)
}

/** Release every deferred source, then report the first startup failure. */
export function resumeDeferredLiveQueryCollections(
  collections: DeferredLiveQueryCollections,
  reportSourceErrors = false,
): void {
  const pending = Array.from(collections)
  collections.clear()
  let firstFailure: { error: unknown } | undefined
  for (const collection of pending) {
    try {
      collection._resumeSyncStart()
      // An explicit preload owns source failure reporting even when graph
      // demand already released the gate. Ordinary hooks read the status.
      if (reportSourceErrors && isFailedStatus(collection.status)) {
        throw collection._lifecycle.getSyncError()
      }
    } catch (error) {
      firstFailure ??= { error }
    }
  }
  if (firstFailure) throw firstFailure.error
}

type PreparedLiveQueryConfigInput = Omit<
  LiveQueryHookConfig<Context>,
  `query`
> & {
  query:
    | QueryBuilder<Context>
    | ((q: InitialQueryBuilder) => QueryBuilder<Context> | undefined | null)
  queryKey?: LiveQueryKey
  client?: DbClient
}

export function prepareLiveQueryValue(
  value: unknown,
  dbClient: DbClient | undefined,
  deferredCollections?: DeferredLiveQueryCollections,
): unknown {
  if (typeof value === `function`) {
    return prepareLiveQueryValue(
      value(new BaseQueryBuilder() as InitialQueryBuilder),
      dbClient,
      deferredCollections,
    )
  }

  if (value instanceof BaseQueryBuilder) {
    return value._bindCollectionSources(
      dbClient
        ? (options) => {
            const config = options as CollectionOptions<
              any,
              string | number,
              any,
              any
            >
            const collection = (
              deferredCollections
                ? dbClient._materializeCollectionForRender(config)
                : dbClient.collection(config)
            ) as CollectionImpl<any, string | number, any, any, any>
            if (deferredCollections && collection._deferSyncStart())
              deferredCollections.add(collection)
            return collection
          }
        : undefined,
    )
  }

  if (
    value &&
    typeof value === `object` &&
    !isCollection(value) &&
    `query` in value
  ) {
    const pool = configPool(value)
    const {
      query,
      queryKey: _queryKey,
      client: _client,
      pool: _pool,
      ...config
    } = value as PreparedLiveQueryConfigInput

    const preparedQuery = prepareLiveQueryValue(
      query,
      dbClient,
      deferredCollections,
    )

    if (preparedQuery === undefined || preparedQuery === null) {
      return preparedQuery
    }

    return {
      ...config,
      // A DbClient query never pools, so its identity must not depend on
      // pool: a server preload and a client hook must hash alike.
      ...(pool && !dbClient ? { pool } : {}),
      query: preparedQuery,
    }
  }

  return value
}

/** Concrete source objects in query-position order for a prepared value. */
export function getPreparedLiveQuerySources(
  value: unknown,
  onUnboundDescriptor?: (alias: string) => never,
): Array<Collection> {
  if (isCollection(value)) return [value]
  const query = queryConfig(value)?.query ?? value
  if (query instanceof BaseQueryBuilder) {
    return collectSourceRefs(query._getQuery()).map((source) =>
      source.type === `descriptorRef` && onUnboundDescriptor
        ? onUnboundDescriptor(source.alias)
        : requireCollectionSource(source),
    )
  }
  return isCollection(query) ? [query] : []
}

/** Local row behavior, kept separate from the portable query identity. */
export type LiveQueryRowOptions = readonly [getKey: unknown, schema: unknown]

export function getLiveQueryRowOptions(value: unknown): LiveQueryRowOptions {
  if (isCollection(value)) {
    const { config } = value
    const internal = config.utils?.[LIVE_QUERY_INTERNAL] as
      { hasCustomGetKey?: boolean } | undefined
    return [
      internal?.hasCustomGetKey ? config.getKey : undefined,
      config.schema,
    ]
  }
  const config = value as
    { getKey?: unknown; schema?: unknown } | null | undefined
  return [config?.getKey, config?.schema]
}

export function getPreparedLiveQueryIdentity(
  value: unknown,
  pool?: PooledLiveQueries,
): unknown {
  if (isCollection(value)) return [`collection`, value.id]
  if (value instanceof BaseQueryBuilder) {
    // A pooled query's fields and literals identify its rows without
    // canonicalizing its whole IR on every render.
    const pooled = pool?.getPooledQueryIdentity(value)
    return pooled === undefined
      ? [`query`, getStableQueryBuilderHash(value)]
      : [`pooled`, pooled]
  }
  if (value && typeof value === `object` && `query` in value) {
    const config = value as LiveQueryCollectionConfig<any>
    return [
      // getKey and schema bind for this query identity. Inline options must
      // not turn a normal framework render into a new Collection.
      `config`,
      getPreparedLiveQueryIdentity(config.query, configPool(config)),
      [`singleResult`, config.singleResult === true],
      [
        `defaultStringCollation`,
        getStringCollationIdentity(config.defaultStringCollation),
      ],
    ]
  }
  if (value === undefined || value === null) return [`disabled`]
  return [`value`, value]
}

export function getLiveQueryHash(
  preparedValue: unknown,
  queryKey?: LiveQueryKey,
): string {
  if (queryKey?.length) {
    const query = queryConfig(preparedValue)?.query
    const ir = isCollection(preparedValue)
      ? getBuilderFromConfig(preparedValue.config)?.getWindow()
      : query instanceof BaseQueryBuilder
        ? query._getQuery()
        : undefined
    const identity: Array<unknown> = [`queryKey`, queryKey]
    if (ir && (ir.limit !== undefined || (ir.offset ?? 0) !== 0))
      identity.push({ limit: ir.limit, offset: ir.offset ?? 0 })
    return getStableValueHash(identity, `queryKey`)
  }
  const identity = isCollection(preparedValue)
    ? [`collection`, preparedValue.id]
    : [`derived`, getPreparedLiveQueryIdentity(preparedValue)]

  return getStableValueHash(identity, `queryKey`)
}

// Only adapter-owned Collections enter this table. Cache- and Suspense-owned
// Collections retain their result identity and are never movable by a peer.
const resolvedQueryValues = new WeakMap<
  object,
  { collection: object; config: NonNullable<ReturnType<typeof queryConfig>> }
>()

function queryConfig(
  value: unknown,
): (Record<string, unknown> & { query?: unknown }) | undefined {
  return value instanceof BaseQueryBuilder
    ? { query: value }
    : value && typeof value === `object`
      ? (value as Record<string, unknown>)
      : undefined
}

function moveExistingWindow(
  after: ReturnType<typeof queryConfig>,
  previous: Collection<object, string | number, {}> | null | undefined,
  owner: object | undefined,
) {
  if (
    !previous ||
    (previous.status !== `ready` && previous.status !== `loading`)
  )
    return
  const retained = owner && resolvedQueryValues.get(owner)
  if (!retained || retained.collection !== previous) return
  const before = retained.config
  if (
    !(before.query instanceof BaseQueryBuilder) ||
    !(after?.query instanceof BaseQueryBuilder)
  )
    return
  const oldQuery = before.query._getQuery()
  const query = after.query._getQuery()
  const limit = query.limit
  const offset = query.offset ?? 0
  // Adding/removing the outer limit, findOne, and opaque plans keep their
  // ordinary compilation path. A move needs a finite ordered target window.
  if (
    after.singleResult === true ||
    query.singleResult ||
    !query.orderBy?.length ||
    oldQuery.limit === undefined ||
    limit === undefined ||
    !Number.isSafeInteger(limit) ||
    limit < 0 ||
    !Number.isSafeInteger(offset) ||
    offset < 0
  )
    return
  const keys = Object.keys(before)
  if (
    keys.length !== Object.keys(after).length ||
    keys.some((key) => key !== `query` && before[key] !== after[key])
  )
    return
  // IR source IDs establish semantic identity, but a keyed query may legally
  // swap the object under an ID. Reuse must preserve the exact source objects.
  const sources = getPreparedLiveQuerySources(before)
  const nextSources = getPreparedLiveQuerySources(after)
  if (
    sources.length !== nextSources.length ||
    sources.some((source, index) => source !== nextSources[index])
  )
    return
  try {
    if (
      getQueryIdentity({ ...oldQuery, limit: undefined, offset: undefined }) !==
      getQueryIdentity({ ...query, limit: undefined, offset: undefined })
    )
      return
  } catch (error) {
    if (error instanceof UnhashableQueryIRError) return
    throw error
  }
  const builder = getBuilderFromConfig(previous.config)
  if (!builder) return
  return () => {
    // A framework can commit synchronously from a Collection publication
    // callback. Move after that publication, while this remains the binding's
    // last resolved result and a consumer still asks for it.
    queueMicrotask(() => {
      if (
        resolvedQueryValues.get(owner)?.collection !== previous ||
        previous.subscriberCount === 0
      )
        return
      const result = builder.setWindow({ offset, limit })
      // The Collection reports request failures through its health tree. A
      // superseded move or unmount can reject its waiter without a hook caller.
      if (result !== true) void result.catch(() => {})
    })
  }
}

/**
 * Resolve an adapter's query value to what its observer watches: `null` for a
 * disabled query, an existing Collection with sync started, or a live query.
 * A query builder whose shape a shared partition can serve gets a pooled view
 * instead of its own live-query Collection.
 */
export function resolveLiveQueryValue(
  value: unknown,
  {
    gcTime,
    pool,
    startSync = true,
    previousCollection,
    windowOwner,
    onWindowChange,
  }: {
    gcTime?: number
    // `false` keeps a hook that cannot pool, such as a Suspense or DbClient
    // query, from pooling even when its config passes `pooledLiveQueries`.
    // A pool here has passed `configPool`.
    pool?: PooledLiveQueries | false
    startSync?: boolean
    /** @internal Previous adapter-owned result; absent for shared results. */
    previousCollection?: Collection<object, string | number, {}> | null
    /** @internal Identity of one framework binding; never shared by peers. */
    windowOwner?: object
    /** @internal React/Preact defer mutation until their render commits. */
    onWindowChange?: (move: () => void) => void
  } = {},
): Collection<any, any, any> | null {
  if (previousCollection !== undefined)
    value = prepareLiveQueryValue(value, undefined)
  const input = queryConfig(value)
  const move = moveExistingWindow(input, previousCollection, windowOwner)
  if (move) {
    if (onWindowChange) onWindowChange(move)
    else move()
    return previousCollection!
  }
  if (value === undefined || value === null) return null
  if (isCollection(value)) {
    value.startSyncImmediate()
    return value
  }
  if (input) {
    const requested = configPool(input)
    const { pool: _pool, ...given } = input as LiveQueryHookConfig<any>
    const pooling = pool === false ? undefined : (requested ?? pool)
    // A pooled view needs the built query. Build a query function once and
    // compile the same builder if the partition cannot serve it.
    const built =
      pooling && typeof given.query === `function`
        ? given.query(new BaseQueryBuilder() as InitialQueryBuilder)
        : undefined
    const config =
      built instanceof BaseQueryBuilder ? { ...given, query: built } : given
    // A query and lifetime alone can pool; other options shape a Collection.
    const pooled =
      pooling &&
      config.query instanceof BaseQueryBuilder &&
      Object.keys(config).every((key) => key === `query` || key === `gcTime`)
        ? pooling.createPooledLiveQuery(config.query, {
            gcTime: config.gcTime ?? gcTime,
          })
        : undefined
    const collection =
      pooled ??
      createLiveQueryCollection({
        ...config,
        startSync: config.startSync ?? startSync,
        gcTime: config.gcTime ?? gcTime,
      })
    if (previousCollection !== undefined && windowOwner)
      resolvedQueryValues.set(windowOwner, { collection, config: input })
    return collection
  }
  throw new Error(
    devBuild() && process.env.NODE_ENV !== `production`
      ? `A live query must be a QueryBuilder, LiveQueryCollectionConfig, Collection, undefined, or null. Got: ${typeof value}`
      : codedMessage(102, { type: typeof value }),
  )
}
