import type {Observable} from 'rxjs'

import type {ObservableSanityClient, SanityClient} from '../SanityClient'
import type {
  Any,
  BaseMutationOptions,
  IdentifiedSanityDocumentStub,
  MultipleMutationResult,
  Mutation,
  MutationSelection,
  PatchOperations,
  SanityDocument,
  SanityDocumentStub,
  SingleMutationResult,
  TransactionAllDocumentIdsMutationOptions,
  TransactionAllDocumentsMutationOptions,
  TransactionFirstDocumentIdMutationOptions,
  TransactionFirstDocumentMutationOptions,
} from '../types'
import * as validators from '../validators'
import {ObservablePatch, Patch} from './patch'

/** @public */
export type PatchBuilder = (patch: Patch) => Patch
/** @public */
export type ObservablePatchBuilder = (patch: ObservablePatch) => ObservablePatch

const defaultMutateOptions = {returnDocuments: false}

/** @internal */
export class BaseTransaction {
  protected operations: Mutation[]
  protected trxId?: string
  constructor(operations: Mutation[] = [], transactionId?: string) {
    this.operations = operations
    this.trxId = transactionId
  }
  /**
   * Creates a new Sanity document. If `_id` is provided and already exists, the mutation will fail. If no `_id` is given, one will automatically be generated by the database.
   * The operation is added to the current transaction, ready to be commited by `commit()`
   *
   * @param doc - Document to create. Requires a `_type` property.
   */
  create<R extends Record<string, Any> = Record<string, Any>>(doc: SanityDocumentStub<R>): this {
    validators.validateObject('create', doc)
    return this._add({create: doc})
  }

  /**
   * Creates a new Sanity document. If a document with the same `_id` already exists, the create operation will be ignored.
   * The operation is added to the current transaction, ready to be commited by `commit()`
   *
   * @param doc - Document to create if it does not already exist. Requires `_id` and `_type` properties.
   */
  createIfNotExists<R extends Record<string, Any> = Record<string, Any>>(
    doc: IdentifiedSanityDocumentStub<R>,
  ): this {
    const op = 'createIfNotExists'
    validators.validateObject(op, doc)
    validators.requireDocumentId(op, doc)
    return this._add({[op]: doc})
  }

  /**
   * Creates a new Sanity document, or replaces an existing one if the same `_id` is already used.
   * The operation is added to the current transaction, ready to be commited by `commit()`
   *
   * @param doc - Document to create or replace. Requires `_id` and `_type` properties.
   */
  createOrReplace<R extends Record<string, Any> = Record<string, Any>>(
    doc: IdentifiedSanityDocumentStub<R>,
  ): this {
    const op = 'createOrReplace'
    validators.validateObject(op, doc)
    validators.requireDocumentId(op, doc)
    return this._add({[op]: doc})
  }

  /**
   * Deletes the document with the given document ID
   * The operation is added to the current transaction, ready to be commited by `commit()`
   *
   * @param documentId - Document ID to delete
   */
  delete(documentId: string): this {
    validators.validateDocumentId('delete', documentId)
    return this._add({delete: {id: documentId}})
  }

  /**
   * Gets the current transaction ID, if any
   */
  transactionId(): string | undefined
  /**
   * Set the ID of this transaction.
   *
   * @param id - Transaction ID
   */
  transactionId(id: string): this
  transactionId(id?: string): this | string | undefined {
    if (!id) {
      return this.trxId
    }

    this.trxId = id
    return this
  }

  /**
   * Return a plain JSON representation of the transaction
   */
  serialize(): Mutation[] {
    return [...this.operations]
  }

  /**
   * Return a plain JSON representation of the transaction
   */
  toJSON(): Mutation[] {
    return this.serialize()
  }

  /**
   * Clears the transaction of all operations
   */
  reset(): this {
    this.operations = []
    return this
  }

  protected _add(mut: Mutation): this {
    this.operations.push(mut)
    return this
  }
}

/** @public */
export class Transaction extends BaseTransaction {
  #client?: SanityClient
  constructor(operations?: Mutation[], client?: SanityClient, transactionId?: string) {
    super(operations, transactionId)
    this.#client = client
  }

  /**
   * Clones the transaction
   */
  clone(): Transaction {
    return new Transaction([...this.operations], this.#client, this.trxId)
  }

  /**
   * Commit the transaction, returning a promise that resolves to the first mutated document
   *
   * @param options - Options for the mutation operation
   */
  commit<R extends Record<string, Any>>(
    options: TransactionFirstDocumentMutationOptions,
  ): Promise<SanityDocument<R>>
  /**
   * Commit the transaction, returning a promise that resolves to an array of the mutated documents
   *
   * @param options - Options for the mutation operation
   */
  commit<R extends Record<string, Any>>(
    options: TransactionAllDocumentsMutationOptions,
  ): Promise<SanityDocument<R>[]>
  /**
   * Commit the transaction, returning a promise that resolves to a mutation result object
   *
   * @param options - Options for the mutation operation
   */
  commit(options: TransactionFirstDocumentIdMutationOptions): Promise<SingleMutationResult>
  /**
   * Commit the transaction, returning a promise that resolves to a mutation result object
   *
   * @param options - Options for the mutation operation
   */
  commit(options: TransactionAllDocumentIdsMutationOptions): Promise<MultipleMutationResult>
  /**
   * Commit the transaction, returning a promise that resolves to a mutation result object
   *
   * @param options - Options for the mutation operation
   */
  commit(options?: BaseMutationOptions): Promise<MultipleMutationResult>
  commit<R extends Record<string, Any> = Record<string, Any>>(
    options?:
      | TransactionFirstDocumentMutationOptions
      | TransactionAllDocumentsMutationOptions
      | TransactionFirstDocumentIdMutationOptions
      | TransactionAllDocumentIdsMutationOptions
      | BaseMutationOptions,
  ): Promise<
    SanityDocument<R> | SanityDocument<R>[] | SingleMutationResult | MultipleMutationResult
  > {
    if (!this.#client) {
      throw new Error(
        'No `client` passed to transaction, either provide one or pass the ' +
          'transaction to a clients `mutate()` method',
      )
    }

    return this.#client.mutate<R>(
      this.serialize() as Any,
      Object.assign({transactionId: this.trxId}, defaultMutateOptions, options || {}),
    )
  }

  /**
   * Performs a patch on the given document ID. Can either be a builder function or an object of patch operations.
   * The operation is added to the current transaction, ready to be commited by `commit()`
   *
   * @param documentId - Document ID to perform the patch operation on
   * @param patchOps - Operations to perform, or a builder function
   */
  patch(documentId: string, patchOps?: PatchBuilder | PatchOperations): this
  /**
   * Performs a patch on the given selection. Can either be a builder function or an object of patch operations.
   *
   * @param selection - An object with `query` and optional `params`, defining which document(s) to patch
   * @param patchOps - Operations to perform, or a builder function
   */
  patch(patch: MutationSelection, patchOps?: PatchBuilder | PatchOperations): this
  /**
   * Adds the given patch instance to the transaction.
   * The operation is added to the current transaction, ready to be commited by `commit()`
   *
   * @param patch - Patch to execute
   */
  patch(patch: Patch): this
  patch(
    patchOrDocumentId: Patch | MutationSelection | string,
    patchOps?: PatchBuilder | PatchOperations,
  ): this {
    const isBuilder = typeof patchOps === 'function'
    const isPatch = typeof patchOrDocumentId !== 'string' && patchOrDocumentId instanceof Patch
    const isMutationSelection =
      typeof patchOrDocumentId === 'object' &&
      ('query' in patchOrDocumentId || 'id' in patchOrDocumentId)

    // transaction.patch(client.patch('documentId').inc({visits: 1}))
    if (isPatch) {
      return this._add({patch: patchOrDocumentId.serialize()})
    }

    // patch => patch.inc({visits: 1}).set({foo: 'bar'})
    if (isBuilder) {
      const patch = patchOps(new Patch(patchOrDocumentId, {}, this.#client))
      if (!(patch instanceof Patch)) {
        throw new Error('function passed to `patch()` must return the patch')
      }

      return this._add({patch: patch.serialize()})
    }

    /*
     * transaction.patch(
     *   {query: "*[_type == 'person' && points >= $threshold]", params: { threshold: 100 }},
     *   {dec: { points: 100 }, inc: { bonuses: 1 }}
     * )
     */
    if (isMutationSelection) {
      const patch = new Patch(patchOrDocumentId, patchOps || {}, this.#client)
      return this._add({patch: patch.serialize()})
    }

    return this._add({patch: {id: patchOrDocumentId, ...patchOps}})
  }
}

/** @public */
export class ObservableTransaction extends BaseTransaction {
  #client?: ObservableSanityClient
  constructor(operations?: Mutation[], client?: ObservableSanityClient, transactionId?: string) {
    super(operations, transactionId)
    this.#client = client
  }

  /**
   * Clones the transaction
   */
  clone(): ObservableTransaction {
    return new ObservableTransaction([...this.operations], this.#client, this.trxId)
  }

  /**
   * Commit the transaction, returning an observable that produces the first mutated document
   *
   * @param options - Options for the mutation operation
   */
  commit<R extends Record<string, Any>>(
    options: TransactionFirstDocumentMutationOptions,
  ): Observable<SanityDocument<R>>
  /**
   * Commit the transaction, returning an observable that produces an array of the mutated documents
   *
   * @param options - Options for the mutation operation
   */
  commit<R extends Record<string, Any>>(
    options: TransactionAllDocumentsMutationOptions,
  ): Observable<SanityDocument<R>[]>
  /**
   * Commit the transaction, returning an observable that produces a mutation result object
   *
   * @param options - Options for the mutation operation
   */
  commit(options: TransactionFirstDocumentIdMutationOptions): Observable<SingleMutationResult>
  /**
   * Commit the transaction, returning an observable that produces a mutation result object
   *
   * @param options - Options for the mutation operation
   */
  commit(options: TransactionAllDocumentIdsMutationOptions): Observable<MultipleMutationResult>
  /**
   * Commit the transaction, returning an observable that produces a mutation result object
   *
   * @param options - Options for the mutation operation
   */
  commit(options?: BaseMutationOptions): Observable<MultipleMutationResult>
  commit<R extends Record<string, Any> = Record<string, Any>>(
    options?:
      | TransactionFirstDocumentMutationOptions
      | TransactionAllDocumentsMutationOptions
      | TransactionFirstDocumentIdMutationOptions
      | TransactionAllDocumentIdsMutationOptions
      | BaseMutationOptions,
  ): Observable<
    SanityDocument<R> | SanityDocument<R>[] | SingleMutationResult | MultipleMutationResult
  > {
    if (!this.#client) {
      throw new Error(
        'No `client` passed to transaction, either provide one or pass the ' +
          'transaction to a clients `mutate()` method',
      )
    }

    return this.#client.mutate<R>(
      this.serialize() as Any,
      Object.assign({transactionId: this.trxId}, defaultMutateOptions, options || {}),
    )
  }

  /**
   * Performs a patch on the given document ID. Can either be a builder function or an object of patch operations.
   * The operation is added to the current transaction, ready to be commited by `commit()`
   *
   * @param documentId - Document ID to perform the patch operation on
   * @param patchOps - Operations to perform, or a builder function
   */
  patch(documentId: string, patchOps?: ObservablePatchBuilder | PatchOperations): this
  /**
   * Adds the given patch instance to the transaction.
   * The operation is added to the current transaction, ready to be commited by `commit()`
   *
   * @param patch - ObservablePatch to execute
   */
  patch(patch: ObservablePatch): this
  patch(
    patchOrDocumentId: ObservablePatch | string,
    patchOps?: ObservablePatchBuilder | PatchOperations,
  ): this {
    const isBuilder = typeof patchOps === 'function'
    const isPatch =
      typeof patchOrDocumentId !== 'string' && patchOrDocumentId instanceof ObservablePatch

    // transaction.patch(client.patch('documentId').inc({visits: 1}))
    if (isPatch) {
      return this._add({patch: patchOrDocumentId.serialize()})
    }

    // patch => patch.inc({visits: 1}).set({foo: 'bar'})
    if (isBuilder) {
      const patch = patchOps(new ObservablePatch(patchOrDocumentId, {}, this.#client))
      if (!(patch instanceof ObservablePatch)) {
        throw new Error('function passed to `patch()` must return the patch')
      }

      return this._add({patch: patch.serialize()})
    }

    return this._add({patch: {id: patchOrDocumentId, ...patchOps}})
  }
}
