import {Transform, Step} from "prosemirror-transform"
import {Mark, MarkType, Node, Slice} from "prosemirror-model"
import {type EditorView} from "prosemirror-view"
import {Selection} from "./selection"
import {Plugin, PluginKey} from "./plugin"
import {EditorState} from "./state"

/// Commands are functions that take a state and a an optional
/// transaction dispatch function and...
///
///  - determine whether they apply to this state
///  - if not, return false
///  - if `dispatch` was passed, perform their effect, possibly by
///    passing a transaction to `dispatch`
///  - return true
///
/// In some cases, the editor view is passed as a third argument.
export type Command = (state: EditorState, dispatch?: (tr: Transaction) => void, view?: EditorView) => boolean

const UPDATED_SEL = 1, UPDATED_MARKS = 2, UPDATED_SCROLL = 4

/// An editor state transaction, which can be applied to a state to
/// create an updated state. Use
/// [`EditorState.tr`](#state.EditorState.tr) to create an instance.
///
/// Transactions track changes to the document (they are a subclass of
/// [`Transform`](#transform.Transform)), but also other state changes,
/// like selection updates and adjustments of the set of [stored
/// marks](#state.EditorState.storedMarks). In addition, you can store
/// metadata properties in a transaction, which are extra pieces of
/// information that client code or plugins can use to describe what a
/// transaction represents, so that they can update their [own
/// state](#state.StateField) accordingly.
///
/// The [editor view](#view.EditorView) uses a few metadata
/// properties: it will attach a property `"pointer"` with the value
/// `true` to selection transactions directly caused by mouse or touch
/// input, a `"composition"` property holding an ID identifying the
/// composition that caused it to transactions caused by composed DOM
/// input, and a `"uiEvent"` property of that may be `"paste"`,
/// `"cut"`, or `"drop"`.
export class Transaction extends Transform {
  /// The timestamp associated with this transaction, in the same
  /// format as `Date.now()`.
  time: number

  private curSelection: Selection
  // The step count for which the current selection is valid.
  private curSelectionFor = 0
  // Bitfield to track which aspects of the state were updated by
  // this transaction.
  private updated = 0
  // Object used to store metadata properties for the transaction.
  private meta: {[name: string]: any} = Object.create(null)

  /// The stored marks set by this transaction, if any.
  storedMarks: readonly Mark[] | null

  /// @internal
  constructor(state: EditorState) {
    super(state.doc)
    this.time = Date.now()
    this.curSelection = state.selection
    this.storedMarks = state.storedMarks
  }

  /// The transaction's current selection. This defaults to the editor
  /// selection [mapped](#state.Selection.map) through the steps in the
  /// transaction, but can be overwritten with
  /// [`setSelection`](#state.Transaction.setSelection).
  get selection(): Selection {
    if (this.curSelectionFor < this.steps.length) {
      this.curSelection = this.curSelection.map(this.doc, this.mapping.slice(this.curSelectionFor))
      this.curSelectionFor = this.steps.length
    }
    return this.curSelection
  }

  /// Update the transaction's current selection. Will determine the
  /// selection that the editor gets when the transaction is applied.
  setSelection(selection: Selection): this {
    if (selection.$from.doc != this.doc)
      throw new RangeError("Selection passed to setSelection must point at the current document")
    this.curSelection = selection
    this.curSelectionFor = this.steps.length
    this.updated = (this.updated | UPDATED_SEL) & ~UPDATED_MARKS
    this.storedMarks = null
    return this
  }

  /// Whether the selection was explicitly updated by this transaction.
  get selectionSet() {
    return (this.updated & UPDATED_SEL) > 0
  }

  /// Set the current stored marks.
  setStoredMarks(marks: readonly Mark[] | null): this {
    this.storedMarks = marks
    this.updated |= UPDATED_MARKS
    return this
  }

  /// Make sure the current stored marks or, if that is null, the marks
  /// at the selection, match the given set of marks. Does nothing if
  /// this is already the case.
  ensureMarks(marks: readonly Mark[]): this {
    if (!Mark.sameSet(this.storedMarks || this.selection.$from.marks(), marks))
      this.setStoredMarks(marks)
    return this
  }

  /// Add a mark to the set of stored marks.
  addStoredMark(mark: Mark): this {
    return this.ensureMarks(mark.addToSet(this.storedMarks || this.selection.$head.marks()))
  }

  /// Remove a mark or mark type from the set of stored marks.
  removeStoredMark(mark: Mark | MarkType): this {
    return this.ensureMarks(mark.removeFromSet(this.storedMarks || this.selection.$head.marks()))
  }

  /// Whether the stored marks were explicitly set for this transaction.
  get storedMarksSet() {
    return (this.updated & UPDATED_MARKS) > 0
  }

  /// @internal
  addStep(step: Step, doc: Node) {
    super.addStep(step, doc)
    this.updated = this.updated & ~UPDATED_MARKS
    this.storedMarks = null
  }

  /// Update the timestamp for the transaction.
  setTime(time: number): this {
    this.time = time
    return this
  }

  /// Replace the current selection with the given slice.
  replaceSelection(slice: Slice): this {
    this.selection.replace(this, slice)
    return this
  }

  /// Replace the selection with the given node. When `inheritMarks` is
  /// true and the content is inline, it inherits the marks from the
  /// place where it is inserted.
  replaceSelectionWith(node: Node, inheritMarks = true): this {
    let selection = this.selection
    if (inheritMarks)
      node = node.mark(this.storedMarks || (selection.empty ? selection.$from.marks() : (selection.$from.marksAcross(selection.$to) || Mark.none)))
    selection.replaceWith(this, node)
    return this
  }

  /// Delete the selection.
  deleteSelection(): this {
    this.selection.replace(this)
    return this
  }

  /// Replace the given range, or the selection if no range is given,
  /// with a text node containing the given string.
  insertText(text: string, from?: number, to?: number): this {
    let schema = this.doc.type.schema
    if (from == null) {
      if (!text) return this.deleteSelection()
      return this.replaceSelectionWith(schema.text(text), true)
    } else {
      if (to == null) to = from
      if (!text) return this.deleteRange(from, to)
      let marks = this.storedMarks
      if (!marks) {
        let $from = this.doc.resolve(from)
        marks = to == from ? $from.marks() : $from.marksAcross(this.doc.resolve(to))
      }
      this.replaceRangeWith(from, to, schema.text(text, marks))
      if (!this.selection.empty && this.selection.to == from + text.length)
        this.setSelection(Selection.near(this.selection.$to))
      return this
    }
  }

  /// Store a metadata property in this transaction, keyed either by
  /// name or by plugin.
  setMeta(key: string | Plugin | PluginKey, value: any): this {
    this.meta[typeof key == "string" ? key : key.key] = value
    return this
  }

  /// Retrieve a metadata property for a given name or plugin.
  getMeta(key: string | Plugin | PluginKey) {
    return this.meta[typeof key == "string" ? key : key.key]
  }

  /// Returns true if this transaction doesn't contain any metadata,
  /// and can thus safely be extended.
  get isGeneric() {
    for (let _ in this.meta) return false
    return true
  }

  /// Indicate that the editor should scroll the selection into view
  /// when updated to the state produced by this transaction.
  scrollIntoView(): this {
    this.updated |= UPDATED_SCROLL
    return this
  }

  /// True when this transaction has had `scrollIntoView` called on it.
  get scrolledIntoView() {
    return (this.updated & UPDATED_SCROLL) > 0
  }
}
