/**
 * @typedef {import('acorn').Comment} Comment
 * @typedef {import('acorn').Node} AcornNode
 * @typedef {import('acorn').Options} AcornOptions
 * @typedef {import('acorn').Token} Token
 * @typedef {import('estree').Node} EstreeNode
 * @typedef {import('estree').Program} Program
 * @typedef {import('micromark-util-types').Chunk} Chunk
 * @typedef {import('micromark-util-types').Event} Event
 * @typedef {import('micromark-util-types').Point} MicromarkPoint
 * @typedef {import('micromark-util-types').TokenType} TokenType
 * @typedef {import('unist').Point} UnistPoint
 */

/**
 * @typedef Acorn
 *   Acorn-like interface.
 * @property {import('acorn').parse} parse
 *   Parse a program.
 * @property {import('acorn').parseExpressionAt} parseExpressionAt
 *   Parse an expression.
 *
 * @typedef AcornLoc
 * @property {number} line
 * @property {number} column
 *
 * @typedef AcornErrorFields
 * @property {number} raisedAt
 * @property {number} pos
 * @property {AcornLoc} loc
 *
 * @typedef {Error & AcornErrorFields} AcornError
 *
 * @typedef Options
 *   Configuration.
 * @property {Acorn} acorn
 *   Typically `acorn`, object with `parse` and `parseExpressionAt` fields (required).
 * @property {Array<TokenType>} tokenTypes
 *   Names of (void) tokens to consider as data; `'lineEnding'` is always
 *   included (required).
 * @property {AcornOptions | null | undefined} [acornOptions]
 *   Configuration for `acorn` (optional).
 * @property {MicromarkPoint | null | undefined} [start]
 *   Place where events start (optional, required if `allowEmpty`).
 * @property {string | null | undefined} [prefix='']
 *   Text to place before events (default: `''`).
 * @property {string | null | undefined} [suffix='']
 *   Text to place after events (default: `''`).
 * @property {boolean | null | undefined} [expression=false]
 *   Whether this is a program or expression (default: `false`).
 * @property {boolean | null | undefined} [allowEmpty=false]
 *   Whether an empty expression is allowed (programs are always allowed to
 *   be empty) (default: `false`).
 *
 * @typedef Result
 *   Result.
 * @property {Program | undefined} estree
 *   Program.
 * @property {AcornError | undefined} error
 *   Error if unparseable
 * @property {boolean} swallow
 *   Whether the error, if there is one, can be swallowed and more JavaScript
 *   could be valid.
 *
 * @typedef {[number, MicromarkPoint]} Stop
 *
 * @typedef Collection
 * @property {string} value
 * @property {Array<Stop>} stops
 */

import { visit } from 'estree-util-visit';
import { VFileMessage } from 'vfile-message';

/**
 * Parse a list of micromark events with acorn.
 *
 * @param {Array<Event>} events
 *   Events.
 * @param {Options} options
 *   Configuration (required).
 * @returns {Result}
 *   Result.
 */
// eslint-disable-next-line complexity
export function eventsToAcorn(events, options) {
  const prefix = options.prefix || '';
  const suffix = options.suffix || '';
  const acornOptions = Object.assign({}, options.acornOptions);
  /** @type {Array<Comment>} */
  const comments = [];
  /** @type {Array<Token>} */
  const tokens = [];
  const onComment = acornOptions.onComment;
  const onToken = acornOptions.onToken;
  let swallow = false;
  /** @type {AcornNode | undefined} */
  let estree;
  /** @type {AcornError | undefined} */
  let exception;
  /** @type {AcornOptions} */
  const acornConfig = Object.assign({}, acornOptions, {
    onComment: comments,
    preserveParens: true
  });
  if (onToken) {
    acornConfig.onToken = tokens;
  }
  const collection = collect(events, options.tokenTypes);
  const source = collection.value;
  const value = prefix + source + suffix;
  const isEmptyExpression = options.expression && empty(source);
  if (isEmptyExpression && !options.allowEmpty) {
    throw new VFileMessage('Unexpected empty expression', {
      place: parseOffsetToUnistPoint(0),
      ruleId: 'unexpected-empty-expression',
      source: 'micromark-extension-mdx-expression'
    });
  }
  try {
    estree = options.expression && !isEmptyExpression ? options.acorn.parseExpressionAt(value, 0, acornConfig) : options.acorn.parse(value, acornConfig);
  } catch (error_) {
    const error = /** @type {AcornError} */error_;
    const point = parseOffsetToUnistPoint(error.pos);
    error.message = String(error.message).replace(/ \(\d+:\d+\)$/, '');
    // Always defined in our unist points that come from micromark.

    error.pos = point.offset;
    error.loc = {
      line: point.line,
      column: point.column - 1
    };
    exception = error;
    swallow = error.raisedAt >= prefix.length + source.length ||
    // Broken comments are raised at their start, not their end.
    error.message === 'Unterminated comment';
  }
  if (estree && options.expression && !isEmptyExpression) {
    if (empty(value.slice(estree.end, value.length - suffix.length))) {
      estree = {
        type: 'Program',
        start: 0,
        end: prefix.length + source.length,
        // @ts-expect-error: It’s good.
        body: [{
          type: 'ExpressionStatement',
          expression: estree,
          start: 0,
          end: prefix.length + source.length
        }],
        sourceType: 'module',
        comments: []
      };
    } else {
      const point = parseOffsetToUnistPoint(estree.end);
      const error = /** @type {AcornError} */
      new Error('Unexpected content after expression');
      // Always defined in our unist points that come from micromark.

      error.pos = point.offset;
      error.loc = {
        line: point.line,
        column: point.column - 1
      };
      exception = error;
      estree = undefined;
    }
  }
  if (estree) {
    // @ts-expect-error: acorn *does* allow comments
    estree.comments = comments;

    // @ts-expect-error: acorn looks enough like estree.
    visit(estree, function (esnode, field, index, parents) {
      let context = /** @type {AcornNode | Array<AcornNode>} */
      parents[parents.length - 1];
      /** @type {number | string | undefined} */
      let prop = field;

      // Remove non-standard `ParenthesizedExpression`.
      // @ts-expect-error: included in acorn.
      if (esnode.type === 'ParenthesizedExpression' && context && prop) {
        /* c8 ignore next 5 */
        if (typeof index === 'number') {
          // @ts-expect-error: indexable.
          context = context[prop];
          prop = index;
        }

        // @ts-expect-error: indexable.
        context[prop] = esnode.expression;
      }
      fixPosition(esnode);
    });

    // Comment positions are fixed by `visit` because they’re in the tree.
    if (Array.isArray(onComment)) {
      onComment.push(...comments);
    } else if (typeof onComment === 'function') {
      for (const comment of comments) {
        onComment(comment.type === 'Block', comment.value, comment.start, comment.end, comment.loc.start, comment.loc.end);
      }
    }
    for (const token of tokens) {
      // Ignore tokens that ends in prefix or start in suffix:
      if (token.end <= prefix.length || token.start - prefix.length >= source.length) {
        continue;
      }
      fixPosition(token);
      if (Array.isArray(onToken)) {
        onToken.push(token);
      } else {
        // `tokens` are not added if `onToken` is not defined, so it must be a
        // function.

        onToken(token);
      }
    }
  }

  // @ts-expect-error: It’s a program now.
  return {
    estree,
    error: exception,
    swallow
  };

  /**
   * Update the position of a node.
   *
   * @param {AcornNode | EstreeNode | Token} nodeOrToken
   * @returns {undefined}
   */
  function fixPosition(nodeOrToken) {
    const pointStart = parseOffsetToUnistPoint(nodeOrToken.start);
    const pointEnd = parseOffsetToUnistPoint(nodeOrToken.end);
    // Always defined in our unist points that come from micromark.

    nodeOrToken.start = pointStart.offset;
    nodeOrToken.end = pointEnd.offset;
    nodeOrToken.loc = {
      start: {
        line: pointStart.line,
        column: pointStart.column - 1,
        offset: pointStart.offset
      },
      end: {
        line: pointEnd.line,
        column: pointEnd.column - 1,
        offset: pointEnd.offset
      }
    };
    nodeOrToken.range = [nodeOrToken.start, nodeOrToken.end];
  }

  /**
   * Turn an arbitrary offset into the parsed value, into a point in the source
   * value.
   *
   * @param {number} acornOffset
   * @returns {UnistPoint}
   */
  function parseOffsetToUnistPoint(acornOffset) {
    let sourceOffset = acornOffset - prefix.length;
    if (sourceOffset < 0) {
      sourceOffset = 0;
    } else if (sourceOffset > source.length) {
      sourceOffset = source.length;
    }
    let point = relativeToPoint(collection.stops, sourceOffset);
    if (!point) {
      point = {
        line: options.start.line,
        column: options.start.column,
        offset: options.start.offset
      };
    }
    return point;
  }
}

/**
 * @param {string} value
 * @returns {boolean}
 */
function empty(value) {
  return /^\s*$/.test(value
  // Multiline comments.
  .replace(/\/\*[\s\S]*?\*\//g, '')
  // Line comments.
  // EOF instead of EOL is specifically not allowed, because that would
  // mean the closing brace is on the commented-out line
  .replace(/\/\/[^\r\n]*(\r\n|\n|\r)/g, ''));
}

// Port from <https://github.com/wooorm/markdown-rs/blob/e692ab0/src/util/mdx_collect.rs#L15>.
/**
 * @param {Array<Event>} events
 * @param {Array<TokenType>} tokenTypes
 * @returns {Collection}
 */
function collect(events, tokenTypes) {
  /** @type {Collection} */
  const result = {
    value: '',
    stops: []
  };
  let index = -1;
  while (++index < events.length) {
    const event = events[index];

    // Assume void.
    if (event[0] === 'enter') {
      const type = event[1].type;
      if (type === "lineEnding" || tokenTypes.includes(type)) {
        const chunks = event[2].sliceStream(event[1]);

        // Drop virtual spaces.
        while (chunks.length > 0 && chunks[0] === -1) {
          chunks.shift();
        }
        const value = serializeChunks(chunks);
        result.stops.push([result.value.length, event[1].start]);
        result.value += value;
        result.stops.push([result.value.length, event[1].end]);
      }
    }
  }
  return result;
}

// Port from <https://github.com/wooorm/markdown-rs/blob/e692ab0/src/util/location.rs#L91>.
/**
 * Turn a relative offset into an absolute offset.
 *
 * @param {Array<Stop>} stops
 * @param {number} relative
 * @returns {UnistPoint | undefined}
 */
function relativeToPoint(stops, relative) {
  let index = 0;
  while (index < stops.length && stops[index][0] <= relative) {
    index += 1;
  }

  // There are no points: that only occurs if there was an empty string.
  if (index === 0) {
    return undefined;
  }
  const [stopRelative, stopAbsolute] = stops[index - 1];
  const rest = relative - stopRelative;
  return {
    line: stopAbsolute.line,
    column: stopAbsolute.column + rest,
    offset: stopAbsolute.offset + rest
  };
}

// Copy from <https://github.com/micromark/micromark/blob/ce3593a/packages/micromark/dev/lib/create-tokenizer.js#L595>
// To do: expose that?
/**
 * Get the string value of a slice of chunks.
 *
 * @param {Array<Chunk>} chunks
 * @returns {string}
 */
function serializeChunks(chunks) {
  let index = -1;
  /** @type {Array<string>} */
  const result = [];
  /** @type {boolean | undefined} */
  let atTab;
  while (++index < chunks.length) {
    const chunk = chunks[index];
    /** @type {string} */
    let value;
    if (typeof chunk === 'string') {
      value = chunk;
    } else switch (chunk) {
      case -5:
        {
          value = "\r";
          break;
        }
      case -4:
        {
          value = "\n";
          break;
        }
      case -3:
        {
          value = "\r" + "\n";
          break;
        }
      case -2:
        {
          value = "\t";
          break;
        }

      /* c8 ignore next 6 */
      case -1:
        {
          if (atTab) continue;
          value = " ";
          break;
        }
      default:
        {
          // Currently only replacement character.
          // eslint-disable-next-line unicorn/prefer-code-point
          value = String.fromCharCode(chunk);
        }
    }
    atTab = chunk === -2;
    result.push(value);
  }
  return result.join('');
}