/**
 * @typedef {import('hast').Content} HastContent
 * @typedef {import('hast').Element} HastElement
 * @typedef {import('hast').ElementContent} HastElementContent
 * @typedef {import('hast').Properties} HastProperties
 * @typedef {import('hast').Root} HastRoot
 * @typedef {import('hast').Text} HastText
 *
 * @typedef {import('mdast').Content} MdastContent
 * @typedef {import('mdast').Definition} MdastDefinition
 * @typedef {import('mdast').FootnoteDefinition} MdastFootnoteDefinition
 * @typedef {import('mdast').Parent} MdastParent
 * @typedef {import('mdast').Root} MdastRoot
 */

/**
 * @typedef {HastRoot | HastContent} HastNodes
 * @typedef {MdastRoot | MdastContent} MdastNodes
 * @typedef {Extract<MdastNodes, MdastParent>} MdastParents
 *
 * @typedef EmbeddedHastFields
 *   hast fields.
 * @property {string | null | undefined} [hName]
 *   Generate a specific element with this tag name instead.
 * @property {HastProperties | null | undefined} [hProperties]
 *   Generate an element with these properties instead.
 * @property {Array<HastElementContent> | null | undefined} [hChildren]
 *   Generate an element with this content instead.
 *
 * @typedef {Record<string, unknown> & EmbeddedHastFields} MdastData
 *   mdast data with embedded hast fields.
 *
 * @typedef {MdastNodes & {data?: MdastData | null | undefined}} MdastNodeWithData
 *   mdast node with embedded hast data.
 *
 * @typedef PointLike
 *   Point-like value.
 * @property {number | null | undefined} [line]
 *   Line.
 * @property {number | null | undefined} [column]
 *   Column.
 * @property {number | null | undefined} [offset]
 *   Offset.
 *
 * @typedef PositionLike
 *   Position-like value.
 * @property {PointLike | null | undefined} [start]
 *   Point-like value.
 * @property {PointLike | null | undefined} [end]
 *   Point-like value.
 *
 * @callback Handler
 *   Handle a node.
 * @param {State} state
 *   Info passed around.
 * @param {any} node
 *   mdast node to handle.
 * @param {MdastParents | null | undefined} parent
 *   Parent of `node`.
 * @returns {HastElementContent | Array<HastElementContent> | null | undefined}
 *   hast node.
 *
 * @callback HFunctionProps
 *   Signature of `state` for when props are passed.
 * @param {MdastNodes | PositionLike | null | undefined} node
 *   mdast node or unist position.
 * @param {string} tagName
 *   HTML tag name.
 * @param {HastProperties} props
 *   Properties.
 * @param {Array<HastElementContent> | null | undefined} [children]
 *   hast content.
 * @returns {HastElement}
 *   Compiled element.
 *
 * @callback HFunctionNoProps
 *   Signature of `state` for when no props are passed.
 * @param {MdastNodes | PositionLike | null | undefined} node
 *   mdast node or unist position.
 * @param {string} tagName
 *   HTML tag name.
 * @param {Array<HastElementContent> | null | undefined} [children]
 *   hast content.
 * @returns {HastElement}
 *   Compiled element.
 *
 * @typedef HFields
 *   Info on `state`.
 * @property {boolean} dangerous
 *   Whether HTML is allowed.
 * @property {string} clobberPrefix
 *   Prefix to use to prevent DOM clobbering.
 * @property {string} footnoteLabel
 *   Label to use to introduce the footnote section.
 * @property {string} footnoteLabelTagName
 *   HTML used for the footnote label.
 * @property {HastProperties} footnoteLabelProperties
 *   Properties on the HTML tag used for the footnote label.
 * @property {string} footnoteBackLabel
 *   Label to use from backreferences back to their footnote call.
 * @property {(identifier: string) => MdastDefinition | null} definition
 *   Definition cache.
 * @property {Record<string, MdastFootnoteDefinition>} footnoteById
 *   Footnote definitions by their identifier.
 * @property {Array<string>} footnoteOrder
 *   Identifiers of order when footnote calls first appear in tree order.
 * @property {Record<string, number>} footnoteCounts
 *   Counts for how often the same footnote was called.
 * @property {Handlers} handlers
 *   Applied handlers.
 * @property {Handler} unknownHandler
 *   Handler for any none not in `passThrough` or otherwise handled.
 * @property {(from: MdastNodes, node: HastNodes) => void} patch
 *   Copy a node’s positional info.
 * @property {<Type extends HastNodes>(from: MdastNodes, to: Type) => Type | HastElement} applyData
 *   Honor the `data` of `from`, and generate an element instead of `node`.
 * @property {(node: MdastNodes, parent: MdastParents | null | undefined) => HastElementContent | Array<HastElementContent> | null | undefined} one
 *   Transform an mdast node to hast.
 * @property {(node: MdastNodes) => Array<HastElementContent>} all
 *   Transform the children of an mdast parent to hast.
 * @property {<Type extends HastContent>(nodes: Array<Type>, loose?: boolean | null | undefined) => Array<Type | HastText>} wrap
 *   Wrap `nodes` with line endings between each node, adds initial/final line endings when `loose`.
 * @property {(left: MdastNodeWithData | PositionLike | null | undefined, right: HastElementContent) => HastElementContent} augment
 *   Like `state` but lower-level and usable on non-elements.
 *   Deprecated: use `patch` and `applyData`.
 * @property {Array<string>} passThrough
 *   List of node types to pass through untouched (except for their children).
 *
 * @typedef Options
 *   Configuration (optional).
 * @property {boolean | null | undefined} [allowDangerousHtml=false]
 *   Whether to persist raw HTML in markdown in the hast tree.
 * @property {string | null | undefined} [clobberPrefix='user-content-']
 *   Prefix to use before the `id` attribute on footnotes to prevent it from
 *   *clobbering*.
 * @property {string | null | undefined} [footnoteBackLabel='Back to content']
 *   Label to use from backreferences back to their footnote call (affects
 *   screen readers).
 * @property {string | null | undefined} [footnoteLabel='Footnotes']
 *   Label to use for the footnotes section (affects screen readers).
 * @property {HastProperties | null | undefined} [footnoteLabelProperties={className: ['sr-only']}]
 *   Properties to use on the footnote label (note that `id: 'footnote-label'`
 *   is always added as footnote calls use it with `aria-describedby` to
 *   provide an accessible label).
 * @property {string | null | undefined} [footnoteLabelTagName='h2']
 *   Tag name to use for the footnote label.
 * @property {Handlers | null | undefined} [handlers]
 *   Extra handlers for nodes.
 * @property {Array<string> | null | undefined} [passThrough]
 *   List of custom mdast node types to pass through (keep) in hast (note that
 *   the node itself is passed, but eventual children are transformed).
 * @property {Handler | null | undefined} [unknownHandler]
 *   Handler for all unknown nodes.
 *
 * @typedef {Record<string, Handler>} Handlers
 *   Handle nodes.
 *
 * @typedef {HFunctionProps & HFunctionNoProps & HFields} State
 *   Info passed around.
 */

import {visit} from 'unist-util-visit'
import {position, pointStart, pointEnd} from 'unist-util-position'
import {generated} from 'unist-util-generated'
import {definitions} from 'mdast-util-definitions'
import {handlers} from './handlers/index.js'

const own = {}.hasOwnProperty

/**
 * Create `state` from an mdast tree.
 *
 * @param {MdastNodes} tree
 *   mdast node to transform.
 * @param {Options | null | undefined} [options]
 *   Configuration.
 * @returns {State}
 *   `state` function.
 */
export function createState(tree, options) {
  const settings = options || {}
  const dangerous = settings.allowDangerousHtml || false
  /** @type {Record<string, MdastFootnoteDefinition>} */
  const footnoteById = {}

  // To do: next major: add `options` to state, remove:
  // `dangerous`, `clobberPrefix`, `footnoteLabel`, `footnoteLabelTagName`,
  // `footnoteLabelProperties`, `footnoteBackLabel`, `passThrough`,
  // `unknownHandler`.

  // To do: next major: move to `state.options.allowDangerousHtml`.
  state.dangerous = dangerous
  // To do: next major: move to `state.options`.
  state.clobberPrefix =
    settings.clobberPrefix === undefined || settings.clobberPrefix === null
      ? 'user-content-'
      : settings.clobberPrefix
  // To do: next major: move to `state.options`.
  state.footnoteLabel = settings.footnoteLabel || 'Footnotes'
  // To do: next major: move to `state.options`.
  state.footnoteLabelTagName = settings.footnoteLabelTagName || 'h2'
  // To do: next major: move to `state.options`.
  state.footnoteLabelProperties = settings.footnoteLabelProperties || {
    className: ['sr-only']
  }
  // To do: next major: move to `state.options`.
  state.footnoteBackLabel = settings.footnoteBackLabel || 'Back to content'
  // To do: next major: move to `state.options`.
  state.unknownHandler = settings.unknownHandler
  // To do: next major: move to `state.options`.
  state.passThrough = settings.passThrough

  state.handlers = {...handlers, ...settings.handlers}

  // To do: next major: replace utility with `definitionById` object, so we
  // only walk once (as we need footnotes too).
  state.definition = definitions(tree)
  state.footnoteById = footnoteById
  /** @type {Array<string>} */
  state.footnoteOrder = []
  /** @type {Record<string, number>} */
  state.footnoteCounts = {}

  state.patch = patch
  state.applyData = applyData
  state.one = oneBound
  state.all = allBound
  state.wrap = wrap
  // To do: next major: remove `augment`.
  state.augment = augment

  visit(tree, 'footnoteDefinition', (definition) => {
    const id = String(definition.identifier).toUpperCase()

    // Mimick CM behavior of link definitions.
    // See: <https://github.com/syntax-tree/mdast-util-definitions/blob/8290999/index.js#L26>.
    if (!own.call(footnoteById, id)) {
      footnoteById[id] = definition
    }
  })

  // @ts-expect-error Hush, it’s fine!
  return state

  /**
   * Finalise the created `right`, a hast node, from `left`, an mdast node.
   *
   * @param {MdastNodeWithData | PositionLike | null | undefined} left
   * @param {HastElementContent} right
   * @returns {HastElementContent}
   */
  /* c8 ignore start */
  // To do: next major: remove.
  function augment(left, right) {
    // Handle `data.hName`, `data.hProperties, `data.hChildren`.
    if (left && 'data' in left && left.data) {
      /** @type {MdastData} */
      const data = left.data

      if (data.hName) {
        if (right.type !== 'element') {
          right = {
            type: 'element',
            tagName: '',
            properties: {},
            children: []
          }
        }

        right.tagName = data.hName
      }

      if (right.type === 'element' && data.hProperties) {
        right.properties = {...right.properties, ...data.hProperties}
      }

      if ('children' in right && right.children && data.hChildren) {
        right.children = data.hChildren
      }
    }

    if (left) {
      const ctx = 'type' in left ? left : {position: left}

      if (!generated(ctx)) {
        // @ts-expect-error: fine.
        right.position = {start: pointStart(ctx), end: pointEnd(ctx)}
      }
    }

    return right
  }
  /* c8 ignore stop */

  /**
   * Create an element for `node`.
   *
   * @type {HFunctionProps}
   */
  /* c8 ignore start */
  // To do: next major: remove.
  function state(node, tagName, props, children) {
    if (Array.isArray(props)) {
      children = props
      props = {}
    }

    // @ts-expect-error augmenting an element yields an element.
    return augment(node, {
      type: 'element',
      tagName,
      properties: props || {},
      children: children || []
    })
  }
  /* c8 ignore stop */

  /**
   * Transform an mdast node into a hast node.
   *
   * @param {MdastNodes} node
   *   mdast node.
   * @param {MdastParents | null | undefined} [parent]
   *   Parent of `node`.
   * @returns {HastElementContent | Array<HastElementContent> | null | undefined}
   *   Resulting hast node.
   */
  function oneBound(node, parent) {
    // @ts-expect-error: that’s a state :)
    return one(state, node, parent)
  }

  /**
   * Transform the children of an mdast node into hast nodes.
   *
   * @param {MdastNodes} parent
   *   mdast node to compile
   * @returns {Array<HastElementContent>}
   *   Resulting hast nodes.
   */
  function allBound(parent) {
    // @ts-expect-error: that’s a state :)
    return all(state, parent)
  }
}

/**
 * Copy a node’s positional info.
 *
 * @param {MdastNodes} from
 *   mdast node to copy from.
 * @param {HastNodes} to
 *   hast node to copy into.
 * @returns {void}
 *   Nothing.
 */
function patch(from, to) {
  if (from.position) to.position = position(from)
}

/**
 * Honor the `data` of `from` and maybe generate an element instead of `to`.
 *
 * @template {HastNodes} Type
 *   Node type.
 * @param {MdastNodes} from
 *   mdast node to use data from.
 * @param {Type} to
 *   hast node to change.
 * @returns {Type | HastElement}
 *   Nothing.
 */
function applyData(from, to) {
  /** @type {Type | HastElement} */
  let result = to

  // Handle `data.hName`, `data.hProperties, `data.hChildren`.
  if (from && from.data) {
    const hName = from.data.hName
    const hChildren = from.data.hChildren
    const hProperties = from.data.hProperties

    if (typeof hName === 'string') {
      // Transforming the node resulted in an element with a different name
      // than wanted:
      if (result.type === 'element') {
        result.tagName = hName
      }
      // Transforming the node resulted in a non-element, which happens for
      // raw, text, and root nodes (unless custom handlers are passed).
      // The intent is likely to keep the content around (otherwise: pass
      // `hChildren`).
      else {
        result = {
          type: 'element',
          tagName: hName,
          properties: {},
          children: []
        }

        // To do: next major: take the children from the `root`, or inject the
        // raw/text/comment or so into the element?
        // if ('children' in node) {
        //   // @ts-expect-error: assume `children` are allowed in elements.
        //   result.children = node.children
        // } else {
        //   // @ts-expect-error: assume `node` is allowed in elements.
        //   result.children.push(node)
        // }
      }
    }

    if (result.type === 'element' && hProperties) {
      result.properties = {...result.properties, ...hProperties}
    }

    if (
      'children' in result &&
      result.children &&
      hChildren !== null &&
      hChildren !== undefined
    ) {
      // @ts-expect-error: assume valid children are defined.
      result.children = hChildren
    }
  }

  return result
}

/**
 * Transform an mdast node into a hast node.
 *
 * @param {State} state
 *   Info passed around.
 * @param {MdastNodes} node
 *   mdast node.
 * @param {MdastParents | null | undefined} [parent]
 *   Parent of `node`.
 * @returns {HastElementContent | Array<HastElementContent> | null | undefined}
 *   Resulting hast node.
 */
// To do: next major: do not expose, keep bound.
export function one(state, node, parent) {
  const type = node && node.type

  // Fail on non-nodes.
  if (!type) {
    throw new Error('Expected node, got `' + node + '`')
  }

  if (own.call(state.handlers, type)) {
    return state.handlers[type](state, node, parent)
  }

  if (state.passThrough && state.passThrough.includes(type)) {
    // To do: next major: deep clone.
    // @ts-expect-error: types of passed through nodes are expected to be added manually.
    return 'children' in node ? {...node, children: all(state, node)} : node
  }

  if (state.unknownHandler) {
    return state.unknownHandler(state, node, parent)
  }

  return defaultUnknownHandler(state, node)
}

/**
 * Transform the children of an mdast node into hast nodes.
 *
 * @param {State} state
 *   Info passed around.
 * @param {MdastNodes} parent
 *   mdast node to compile
 * @returns {Array<HastElementContent>}
 *   Resulting hast nodes.
 */
// To do: next major: do not expose, keep bound.
export function all(state, parent) {
  /** @type {Array<HastElementContent>} */
  const values = []

  if ('children' in parent) {
    const nodes = parent.children
    let index = -1
    while (++index < nodes.length) {
      const result = one(state, nodes[index], parent)

      // To do: see if we van clean this? Can we merge texts?
      if (result) {
        if (index && nodes[index - 1].type === 'break') {
          if (!Array.isArray(result) && result.type === 'text') {
            result.value = result.value.replace(/^\s+/, '')
          }

          if (!Array.isArray(result) && result.type === 'element') {
            const head = result.children[0]

            if (head && head.type === 'text') {
              head.value = head.value.replace(/^\s+/, '')
            }
          }
        }

        if (Array.isArray(result)) {
          values.push(...result)
        } else {
          values.push(result)
        }
      }
    }
  }

  return values
}

/**
 * Transform an unknown node.
 *
 * @param {State} state
 *   Info passed around.
 * @param {MdastNodes} node
 *   Unknown mdast node.
 * @returns {HastText | HastElement}
 *   Resulting hast node.
 */
function defaultUnknownHandler(state, node) {
  const data = node.data || {}
  /** @type {HastText | HastElement} */
  const result =
    'value' in node &&
    !(own.call(data, 'hProperties') || own.call(data, 'hChildren'))
      ? {type: 'text', value: node.value}
      : {
          type: 'element',
          tagName: 'div',
          properties: {},
          children: all(state, node)
        }

  state.patch(node, result)
  return state.applyData(node, result)
}

/**
 * Wrap `nodes` with line endings between each node.
 *
 * @template {HastContent} Type
 *   Node type.
 * @param {Array<Type>} nodes
 *   List of nodes to wrap.
 * @param {boolean | null | undefined} [loose=false]
 *   Whether to add line endings at start and end.
 * @returns {Array<Type | HastText>}
 *   Wrapped nodes.
 */
export function wrap(nodes, loose) {
  /** @type {Array<Type | HastText>} */
  const result = []
  let index = -1

  if (loose) {
    result.push({type: 'text', value: '\n'})
  }

  while (++index < nodes.length) {
    if (index) result.push({type: 'text', value: '\n'})
    result.push(nodes[index])
  }

  if (loose && nodes.length > 0) {
    result.push({type: 'text', value: '\n'})
  }

  return result
}
