/**
 * This plugin will make the editor support undo/redo on the local state only.
 * The undo/redo steps are rebased against incoming patches since the step occurred.
 */

import type {Patch} from '@portabletext/patches'
import {
  DIFF_DELETE,
  DIFF_EQUAL,
  DIFF_INSERT,
  parsePatch,
} from '@sanity/diff-match-patch'
import type {PortableTextBlock} from '@sanity/types'
import {flatten, isEqual} from 'lodash'
import {
  Editor,
  Operation,
  Path,
  Transforms,
  type Descendant,
  type SelectionOperation,
} from 'slate'
import {debugWithName} from '../../internal-utils/debug'
import {fromSlateValue} from '../../internal-utils/values'
import {isChangingRemotely} from '../../internal-utils/withChanges'
import {
  isRedoing,
  isUndoing,
  setIsRedoing,
  setIsUndoing,
  withRedoing,
  withUndoing,
} from '../../internal-utils/withUndoRedo'
import type {BehaviorOperationImplementation} from '../../operations/behavior.operations'
import type {PortableTextSlateEditor} from '../../types/editor'
import type {EditorActor} from '../editor-machine'
import {getCurrentUndoStepId} from '../with-undo-step'

const debug = debugWithName('plugin:withUndoRedo')
const debugVerbose = debug.enabled && false

const SAVING = new WeakMap<Editor, boolean | undefined>()
const REMOTE_PATCHES = new WeakMap<
  Editor,
  {
    patch: Patch
    time: Date
    snapshot: PortableTextBlock[] | undefined
    previousSnapshot: PortableTextBlock[] | undefined
  }[]
>()
const UNDO_STEP_LIMIT = 1000

const isSaving = (editor: Editor): boolean | undefined => {
  const state = SAVING.get(editor)
  return state === undefined ? true : state
}

export interface Options {
  editorActor: EditorActor
  subscriptions: Array<() => () => void>
}

const getRemotePatches = (editor: Editor) => {
  if (!REMOTE_PATCHES.get(editor)) {
    REMOTE_PATCHES.set(editor, [])
  }
  return REMOTE_PATCHES.get(editor) || []
}

export function createWithUndoRedo(
  options: Options,
): (editor: PortableTextSlateEditor) => PortableTextSlateEditor {
  const {editorActor} = options

  return (editor: PortableTextSlateEditor) => {
    let previousSnapshot: PortableTextBlock[] | undefined = fromSlateValue(
      editor.children,
      editorActor.getSnapshot().context.schema.block.name,
    )
    const remotePatches = getRemotePatches(editor)
    let previousUndoStepId = getCurrentUndoStepId(editor)

    options.subscriptions.push(() => {
      debug('Subscribing to patches')
      const sub = editorActor.on('patches', ({patches, snapshot}) => {
        let reset = false
        patches.forEach((patch) => {
          if (!reset && patch.origin !== 'local' && remotePatches) {
            if (patch.type === 'unset' && patch.path.length === 0) {
              debug(
                'Someone else cleared the content, resetting undo/redo history',
              )
              editor.history = {undos: [], redos: []}
              remotePatches.splice(0, remotePatches.length)
              SAVING.set(editor, true)
              reset = true
              return
            }
            remotePatches.push({
              patch,
              time: new Date(),
              snapshot,
              previousSnapshot,
            })
          }
        })
        previousSnapshot = snapshot
      })
      return () => {
        debug('Unsubscribing to patches')
        sub.unsubscribe()
      }
    })

    editor.history = {undos: [], redos: []}

    const {apply} = editor

    editor.apply = (op: Operation) => {
      if (editorActor.getSnapshot().matches({'edit mode': 'read only'})) {
        apply(op)
        return
      }

      /**
       * We don't want to run any side effects when the editor is processing
       * remote changes.
       */
      if (isChangingRemotely(editor)) {
        apply(op)
        return
      }

      /**
       * We don't want to run any side effects when the editor is undoing or
       * redoing operations.
       */
      if (isUndoing(editor) || isRedoing(editor)) {
        apply(op)
        return
      }

      const {operations, history} = editor
      const {undos} = history
      const step = undos[undos.length - 1]
      const lastOp =
        step && step.operations && step.operations[step.operations.length - 1]
      const overwrite = shouldOverwrite(op, lastOp)
      const save = isSaving(editor)

      const currentUndoStepId = getCurrentUndoStepId(editor)

      let merge = currentUndoStepId === previousUndoStepId

      if (save) {
        if (!step) {
          merge = false
        } else if (operations.length === 0) {
          merge =
            currentUndoStepId === undefined && previousUndoStepId === undefined
              ? shouldMerge(op, lastOp) || overwrite
              : merge
        }

        if (step && merge) {
          step.operations.push(op)
        } else {
          const newStep = {
            operations: [
              ...(editor.selection === null
                ? []
                : [createSelectOperation(editor)]),
              op,
            ],
            timestamp: new Date(),
          }
          undos.push(newStep)
          debug('Created new undo step', step)
        }

        while (undos.length > UNDO_STEP_LIMIT) {
          undos.shift()
        }

        if (shouldClear(op)) {
          history.redos = []
        }
      }

      previousUndoStepId = currentUndoStepId

      apply(op)
    }

    // Plugin return
    return editor
  }
}

export const historyUndoOperationImplementation: BehaviorOperationImplementation<
  'history.undo'
> = ({operation}) => {
  const editor = operation.editor
  const {undos} = editor.history
  const remotePatches = getRemotePatches(editor)

  if (undos.length > 0) {
    const step = undos[undos.length - 1]
    debug('Undoing', step)
    if (step.operations.length > 0) {
      const otherPatches = remotePatches.filter(
        (item) => item.time >= step.timestamp,
      )
      let transformedOperations = step.operations
      otherPatches.forEach((item) => {
        transformedOperations = flatten(
          transformedOperations.map((op) =>
            transformOperation(
              editor,
              item.patch,
              op,
              item.snapshot,
              item.previousSnapshot,
            ),
          ),
        )
      })
      const reversedOperations = transformedOperations
        .map(Operation.inverse)
        .reverse()

      try {
        Editor.withoutNormalizing(editor, () => {
          withUndoing(editor, () => {
            withoutSaving(editor, () => {
              reversedOperations.forEach((op) => {
                editor.apply(op)
              })
            })
          })
        })
      } catch (err) {
        debug('Could not perform undo step', err)
        remotePatches.splice(0, remotePatches.length)
        Transforms.deselect(editor)
        editor.history = {undos: [], redos: []}
        SAVING.set(editor, true)
        setIsUndoing(editor, false)
        editor.onChange()
        return
      }
      editor.history.redos.push(step)
      editor.history.undos.pop()
    }
  }
}

export const historyRedoOperationImplementation: BehaviorOperationImplementation<
  'history.redo'
> = ({operation}) => {
  const editor = operation.editor
  const {redos} = editor.history
  const remotePatches = getRemotePatches(editor)

  if (redos.length > 0) {
    const step = redos[redos.length - 1]
    debug('Redoing', step)
    if (step.operations.length > 0) {
      const otherPatches = remotePatches.filter(
        (item) => item.time >= step.timestamp,
      )
      let transformedOperations = step.operations
      otherPatches.forEach((item) => {
        transformedOperations = flatten(
          transformedOperations.map((op) =>
            transformOperation(
              editor,
              item.patch,
              op,
              item.snapshot,
              item.previousSnapshot,
            ),
          ),
        )
      })
      try {
        Editor.withoutNormalizing(editor, () => {
          withRedoing(editor, () => {
            withoutSaving(editor, () => {
              transformedOperations.forEach((op) => {
                editor.apply(op)
              })
            })
          })
        })
      } catch (err) {
        debug('Could not perform redo step', err)
        remotePatches.splice(0, remotePatches.length)
        Transforms.deselect(editor)
        editor.history = {undos: [], redos: []}
        SAVING.set(editor, true)
        setIsRedoing(editor, false)
        editor.onChange()
        return
      }
      editor.history.undos.push(step)
      editor.history.redos.pop()
    }
  }
}

/**
 * This will adjust the operation paths and offsets according to the
 * remote patches by other editors since the step operations was performed.
 */
function transformOperation(
  editor: PortableTextSlateEditor,
  patch: Patch,
  operation: Operation,
  snapshot: PortableTextBlock[] | undefined,
  previousSnapshot: PortableTextBlock[] | undefined,
): Operation[] {
  if (debugVerbose) {
    debug(
      `Adjusting '${operation.type}' operation paths for '${patch.type}' patch`,
    )
    debug(`Operation ${JSON.stringify(operation)}`)
    debug(`Patch ${JSON.stringify(patch)}`)
  }

  const transformedOperation = {...operation}

  if (patch.type === 'insert' && patch.path.length === 1) {
    const insertBlockIndex = (snapshot || []).findIndex((blk) =>
      isEqual({_key: blk._key}, patch.path[0]),
    )
    debug(
      `Adjusting block path (+${patch.items.length}) for '${transformedOperation.type}' operation and patch '${patch.type}'`,
    )
    return [
      adjustBlockPath(
        transformedOperation,
        patch.items.length,
        insertBlockIndex,
      ),
    ]
  }

  if (patch.type === 'unset' && patch.path.length === 1) {
    const unsetBlockIndex = (previousSnapshot || []).findIndex((blk) =>
      isEqual({_key: blk._key}, patch.path[0]),
    )
    // If this operation is targeting the same block that got removed, return empty
    if (
      'path' in transformedOperation &&
      Array.isArray(transformedOperation.path) &&
      transformedOperation.path[0] === unsetBlockIndex
    ) {
      debug('Skipping transformation that targeted removed block')
      return []
    }
    if (debugVerbose) {
      debug(`Selection ${JSON.stringify(editor.selection)}`)
      debug(
        `Adjusting block path (-1) for '${transformedOperation.type}' operation and patch '${patch.type}'`,
      )
    }
    return [adjustBlockPath(transformedOperation, -1, unsetBlockIndex)]
  }

  // Someone reset the whole value
  if (patch.type === 'unset' && patch.path.length === 0) {
    debug(
      `Adjusting selection for unset everything patch and ${operation.type} operation`,
    )
    return []
  }

  if (patch.type === 'diffMatchPatch') {
    const operationTargetBlock = findOperationTargetBlock(
      editor,
      transformedOperation,
    )
    if (
      !operationTargetBlock ||
      !isEqual({_key: operationTargetBlock._key}, patch.path[0])
    ) {
      return [transformedOperation]
    }
    const diffPatches = parsePatch(patch.value)
    diffPatches.forEach((diffPatch) => {
      let adjustOffsetBy = 0
      let changedOffset = diffPatch.utf8Start1
      const {diffs} = diffPatch
      diffs.forEach((diff, index) => {
        const [diffType, text] = diff
        if (diffType === DIFF_INSERT) {
          adjustOffsetBy += text.length
          changedOffset += text.length
        } else if (diffType === DIFF_DELETE) {
          adjustOffsetBy -= text.length
          changedOffset -= text.length
        } else if (diffType === DIFF_EQUAL) {
          // Only up to the point where there are no other changes
          if (!diffs.slice(index).every(([dType]) => dType === DIFF_EQUAL)) {
            changedOffset += text.length
          }
        }
      })
      // Adjust accordingly if someone inserted text in the same node before us
      if (transformedOperation.type === 'insert_text') {
        if (changedOffset < transformedOperation.offset) {
          transformedOperation.offset += adjustOffsetBy
        }
      }
      // Adjust accordingly if someone removed text in the same node before us
      if (transformedOperation.type === 'remove_text') {
        if (
          changedOffset <=
          transformedOperation.offset - transformedOperation.text.length
        ) {
          transformedOperation.offset += adjustOffsetBy
        }
      }
      // Adjust set_selection operation's points to new offset
      if (transformedOperation.type === 'set_selection') {
        const currentFocus = transformedOperation.properties?.focus
          ? {...transformedOperation.properties.focus}
          : undefined
        const currentAnchor = transformedOperation?.properties?.anchor
          ? {...transformedOperation.properties.anchor}
          : undefined
        const newFocus = transformedOperation?.newProperties?.focus
          ? {...transformedOperation.newProperties.focus}
          : undefined
        const newAnchor = transformedOperation?.newProperties?.anchor
          ? {...transformedOperation.newProperties.anchor}
          : undefined
        if ((currentFocus && currentAnchor) || (newFocus && newAnchor)) {
          const points = [currentFocus, currentAnchor, newFocus, newAnchor]
          points.forEach((point) => {
            if (point && changedOffset < point.offset) {
              point.offset += adjustOffsetBy
            }
          })
          if (currentFocus && currentAnchor) {
            transformedOperation.properties = {
              focus: currentFocus,
              anchor: currentAnchor,
            }
          }
          if (newFocus && newAnchor) {
            transformedOperation.newProperties = {
              focus: newFocus,
              anchor: newAnchor,
            }
          }
        }
      }
    })
    return [transformedOperation]
  }
  return [transformedOperation]
}
/**
 * Adjust the block path for a operation
 */
function adjustBlockPath(
  operation: Operation,
  level: number,
  blockIndex: number,
): Operation {
  const transformedOperation = {...operation}
  if (
    blockIndex >= 0 &&
    transformedOperation.type !== 'set_selection' &&
    Array.isArray(transformedOperation.path) &&
    transformedOperation.path[0] >= blockIndex + level &&
    transformedOperation.path[0] + level > -1
  ) {
    const newPath = [
      transformedOperation.path[0] + level,
      ...transformedOperation.path.slice(1),
    ]
    transformedOperation.path = newPath
  }
  if (transformedOperation.type === 'set_selection') {
    const currentFocus = transformedOperation.properties?.focus
      ? {...transformedOperation.properties.focus}
      : undefined
    const currentAnchor = transformedOperation?.properties?.anchor
      ? {...transformedOperation.properties.anchor}
      : undefined
    const newFocus = transformedOperation?.newProperties?.focus
      ? {...transformedOperation.newProperties.focus}
      : undefined
    const newAnchor = transformedOperation?.newProperties?.anchor
      ? {...transformedOperation.newProperties.anchor}
      : undefined
    if ((currentFocus && currentAnchor) || (newFocus && newAnchor)) {
      const points = [currentFocus, currentAnchor, newFocus, newAnchor]
      points.forEach((point) => {
        if (
          point &&
          point.path[0] >= blockIndex + level &&
          point.path[0] + level > -1
        ) {
          point.path = [point.path[0] + level, ...point.path.slice(1)]
        }
      })
      if (currentFocus && currentAnchor) {
        transformedOperation.properties = {
          focus: currentFocus,
          anchor: currentAnchor,
        }
      }
      if (newFocus && newAnchor) {
        transformedOperation.newProperties = {
          focus: newFocus,
          anchor: newAnchor,
        }
      }
    }
  }
  //   // Assign fresh point objects (we don't want to mutate the original ones)
  return transformedOperation
}

// Helper functions for editor.apply above

const shouldMerge = (op: Operation, prev: Operation | undefined): boolean => {
  if (op.type === 'set_selection') {
    return true
  }

  // Text input
  if (
    prev &&
    op.type === 'insert_text' &&
    prev.type === 'insert_text' &&
    op.offset === prev.offset + prev.text.length &&
    Path.equals(op.path, prev.path) &&
    op.text !== ' ' // Tokenize between words
  ) {
    return true
  }

  // Text deletion
  if (
    prev &&
    op.type === 'remove_text' &&
    prev.type === 'remove_text' &&
    op.offset + op.text.length === prev.offset &&
    Path.equals(op.path, prev.path)
  ) {
    return true
  }

  // Don't merge
  return false
}

const shouldOverwrite = (
  op: Operation,
  prev: Operation | undefined,
): boolean => {
  if (prev && op.type === 'set_selection' && prev.type === 'set_selection') {
    return true
  }

  return false
}

const shouldClear = (op: Operation): boolean => {
  if (op.type === 'set_selection') {
    return false
  }

  return true
}

export function withoutSaving(editor: Editor, fn: () => void): void {
  const prev = isSaving(editor)
  SAVING.set(editor, false)
  fn()
  SAVING.set(editor, prev)
}

function createSelectOperation(editor: Editor): SelectionOperation {
  return {
    type: 'set_selection',
    properties: {...editor.selection},
    newProperties: {...editor.selection},
  }
}

function findOperationTargetBlock(
  editor: PortableTextSlateEditor,
  operation: Operation,
): Descendant | undefined {
  let block: Descendant | undefined
  if (operation.type === 'set_selection' && editor.selection) {
    block = editor.children[editor.selection.focus.path[0]]
  } else if ('path' in operation) {
    block = editor.children[operation.path[0]]
  }
  return block
}
