/**
 * Copyright (c) Meta Platforms, Inc. and affiliates.
 *
 * This source code is licensed under the MIT license found in the
 * LICENSE file in the root directory of this source tree.
 *
 * @format
 */

'use strict';

const assert = require('assert');

const {
  hasNewline,
  addLeadingComment,
  addDanglingComment,
  addTrailingComment,
} = require('../common/util.js');

const childNodesCache = new WeakMap();
function getSortedChildNodes(node, options, resultArray) {
  if (!node) {
    return;
  }
  const {printer, locStart, locEnd} = options;

  if (resultArray) {
    if (printer.canAttachComment && printer.canAttachComment(node)) {
      // This reverse insertion sort almost always takes constant
      // time because we almost always (maybe always?) append the
      // nodes in order anyway.
      let i;
      for (i = resultArray.length - 1; i >= 0; --i) {
        if (
          locStart(resultArray[i]) <= locStart(node) &&
          locEnd(resultArray[i]) <= locEnd(node)
        ) {
          break;
        }
      }
      resultArray.splice(i + 1, 0, node);
      return;
    }
  } else if (childNodesCache.has(node)) {
    return childNodesCache.get(node);
  }

  const childNodes =
    (printer.getCommentChildNodes &&
      printer.getCommentChildNodes(node, options)) ||
    (typeof node === 'object' &&
      Object.entries(node)
        .filter(
          ([key]) =>
            key !== 'enclosingNode' &&
            key !== 'precedingNode' &&
            key !== 'followingNode' &&
            key !== 'tokens' &&
            key !== 'comments' &&
            key !== 'parent',
        )
        .map(([, value]) => value));

  if (!childNodes) {
    return;
  }

  if (!resultArray) {
    resultArray = [];
    childNodesCache.set(node, resultArray);
  }

  for (const childNode of childNodes) {
    getSortedChildNodes(childNode, options, resultArray);
  }

  return resultArray;
}

// As efficiently as possible, decorate the comment object with
// .precedingNode, .enclosingNode, and/or .followingNode properties, at
// least one of which is guaranteed to be defined.
function decorateComment(node, comment, options, enclosingNode) {
  const {locStart, locEnd} = options;
  const commentStart = locStart(comment);
  const commentEnd = locEnd(comment);

  const childNodes = getSortedChildNodes(node, options);
  let precedingNode;
  let followingNode;
  // Time to dust off the old binary search robes and wizard hat.
  let left = 0;
  let right = childNodes.length;
  while (left < right) {
    const middle = (left + right) >> 1;
    const child = childNodes[middle];
    const start = locStart(child);
    const end = locEnd(child);

    // The comment is completely contained by this child node.
    if (start <= commentStart && commentEnd <= end) {
      // Abandon the binary search at this level.
      return decorateComment(child, comment, options, child);
    }

    if (end <= commentStart) {
      // This child node falls completely before the comment.
      // Because we will never consider this node or any nodes
      // before it again, this node must be the closest preceding
      // node we have encountered so far.
      precedingNode = child;
      left = middle + 1;
      continue;
    }

    if (commentEnd <= start) {
      // This child node falls completely after the comment.
      // Because we will never consider this node or any nodes after
      // it again, this node must be the closest following node we
      // have encountered so far.
      followingNode = child;
      right = middle;
      continue;
    }

    /* istanbul ignore next */
    throw new Error('Comment location overlaps with node location');
  }

  // We don't want comments inside of different expressions inside of the same
  // template literal to move to another expression.
  if (enclosingNode && enclosingNode.type === 'TemplateLiteral') {
    const {quasis} = enclosingNode;
    const commentIndex = findExpressionIndexForComment(
      quasis,
      comment,
      options,
    );

    if (
      precedingNode &&
      findExpressionIndexForComment(quasis, precedingNode, options) !==
        commentIndex
    ) {
      precedingNode = null;
    }
    if (
      followingNode &&
      findExpressionIndexForComment(quasis, followingNode, options) !==
        commentIndex
    ) {
      followingNode = null;
    }
  }

  return {enclosingNode, precedingNode, followingNode};
}

const returnFalse = () => false;
function attach(comments, ast, text, options) {
  if (!Array.isArray(comments)) {
    return;
  }

  const tiesToBreak = [];
  const {
    locStart,
    locEnd,
    printer: {handleComments = {}},
  } = options;
  // TODO: Make this as default behavior
  const {
    avoidAstMutation,
    ownLine: handleOwnLineComment = returnFalse,
    endOfLine: handleEndOfLineComment = returnFalse,
    remaining: handleRemainingComment = returnFalse,
  } = handleComments;

  const decoratedComments = comments.map((comment, index) => ({
    ...decorateComment(ast, comment, options),
    comment,
    text,
    options,
    ast,
    isLastComment: comments.length - 1 === index,
  }));

  for (const [index, context] of decoratedComments.entries()) {
    const {
      comment,
      precedingNode,
      enclosingNode,
      followingNode,
      text,
      options,
      ast,
      isLastComment,
    } = context;

    if (
      options.parser === 'json' ||
      options.parser === 'json5' ||
      options.parser === '__js_expression' ||
      options.parser === '__vue_expression'
    ) {
      if (locStart(comment) - locStart(ast) <= 0) {
        addLeadingComment(ast, comment);
        continue;
      }
      if (locEnd(comment) - locEnd(ast) >= 0) {
        addTrailingComment(ast, comment);
        continue;
      }
    }

    let args;
    if (avoidAstMutation) {
      args = [context];
    } else {
      comment.enclosingNode = enclosingNode;
      comment.precedingNode = precedingNode;
      comment.followingNode = followingNode;
      args = [comment, text, options, ast, isLastComment];
    }

    if (isOwnLineComment(text, options, decoratedComments, index)) {
      comment.placement = 'ownLine';
      // If a comment exists on its own line, prefer a leading comment.
      // We also need to check if it's the first line of the file.
      if (handleOwnLineComment(...args)) {
        // We're good
      } else if (followingNode) {
        // Always a leading comment.
        addLeadingComment(followingNode, comment);
      } else if (precedingNode) {
        addTrailingComment(precedingNode, comment);
      } else if (enclosingNode) {
        addDanglingComment(enclosingNode, comment);
      } else {
        // There are no nodes, let's attach it to the root of the ast
        /* istanbul ignore next */
        addDanglingComment(ast, comment);
      }
    } else if (isEndOfLineComment(text, options, decoratedComments, index)) {
      comment.placement = 'endOfLine';
      if (handleEndOfLineComment(...args)) {
        // We're good
      } else if (precedingNode) {
        // There is content before this comment on the same line, but
        // none after it, so prefer a trailing comment of the previous node.
        addTrailingComment(precedingNode, comment);
      } else if (followingNode) {
        addLeadingComment(followingNode, comment);
      } else if (enclosingNode) {
        addDanglingComment(enclosingNode, comment);
      } else {
        // There are no nodes, let's attach it to the root of the ast
        /* istanbul ignore next */
        addDanglingComment(ast, comment);
      }
    } else {
      comment.placement = 'remaining';
      if (handleRemainingComment(...args)) {
        // We're good
      } else if (precedingNode && followingNode) {
        // Otherwise, text exists both before and after the comment on
        // the same line. If there is both a preceding and following
        // node, use a tie-breaking algorithm to determine if it should
        // be attached to the next or previous node. In the last case,
        // simply attach the right node;
        const tieCount = tiesToBreak.length;
        if (tieCount > 0) {
          const lastTie = tiesToBreak[tieCount - 1];
          if (lastTie.followingNode !== followingNode) {
            breakTies(tiesToBreak, text, options);
          }
        }
        tiesToBreak.push(context);
      } else if (precedingNode) {
        addTrailingComment(precedingNode, comment);
      } else if (followingNode) {
        addLeadingComment(followingNode, comment);
      } else if (enclosingNode) {
        addDanglingComment(enclosingNode, comment);
      } else {
        // There are no nodes, let's attach it to the root of the ast
        /* istanbul ignore next */
        addDanglingComment(ast, comment);
      }
    }
  }

  breakTies(tiesToBreak, text, options);

  if (!avoidAstMutation) {
    for (const comment of comments) {
      // These node references were useful for breaking ties, but we
      // don't need them anymore, and they create cycles in the AST that
      // may lead to infinite recursion if we don't delete them here.
      delete comment.precedingNode;
      delete comment.enclosingNode;
      delete comment.followingNode;
    }
  }
}

const isAllEmptyAndNoLineBreak = text => !/[\S\n\u2028\u2029]/.test(text);
function isOwnLineComment(text, options, decoratedComments, commentIndex) {
  const {comment, precedingNode} = decoratedComments[commentIndex];
  const {locStart, locEnd} = options;
  let start = locStart(comment);

  if (precedingNode) {
    // Find first comment on the same line
    for (let index = commentIndex - 1; index >= 0; index--) {
      const {comment, precedingNode: currentCommentPrecedingNode} =
        decoratedComments[index];
      if (
        currentCommentPrecedingNode !== precedingNode ||
        !isAllEmptyAndNoLineBreak(text.slice(locEnd(comment), start))
      ) {
        break;
      }
      start = locStart(comment);
    }
  }

  return hasNewline(text, start, {backwards: true});
}

function isEndOfLineComment(text, options, decoratedComments, commentIndex) {
  const {comment, followingNode} = decoratedComments[commentIndex];
  const {locStart, locEnd} = options;
  let end = locEnd(comment);

  if (followingNode) {
    // Find last comment on the same line
    for (
      let index = commentIndex + 1;
      index < decoratedComments.length;
      index++
    ) {
      const {comment, followingNode: currentCommentFollowingNode} =
        decoratedComments[index];
      if (
        currentCommentFollowingNode !== followingNode ||
        !isAllEmptyAndNoLineBreak(text.slice(end, locStart(comment)))
      ) {
        break;
      }
      end = locEnd(comment);
    }
  }

  return hasNewline(text, end);
}

function breakTies(tiesToBreak, text, options) {
  const tieCount = tiesToBreak.length;
  if (tieCount === 0) {
    return;
  }
  const {precedingNode, followingNode, enclosingNode} = tiesToBreak[0];

  const gapRegExp =
    (options.printer.getGapRegex &&
      options.printer.getGapRegex(enclosingNode)) ||
    /^[\s(]*$/;

  let gapEndPos = options.locStart(followingNode);

  // Iterate backwards through tiesToBreak, examining the gaps
  // between the tied comments. In order to qualify as leading, a
  // comment must be separated from followingNode by an unbroken series of
  // gaps (or other comments). Gaps should only contain whitespace or open
  // parentheses.
  let indexOfFirstLeadingComment;
  for (
    indexOfFirstLeadingComment = tieCount;
    indexOfFirstLeadingComment > 0;
    --indexOfFirstLeadingComment
  ) {
    const {
      comment,
      precedingNode: currentCommentPrecedingNode,
      followingNode: currentCommentFollowingNode,
    } = tiesToBreak[indexOfFirstLeadingComment - 1];
    assert.strictEqual(currentCommentPrecedingNode, precedingNode);
    assert.strictEqual(currentCommentFollowingNode, followingNode);

    const gap = text.slice(options.locEnd(comment), gapEndPos);

    if (gapRegExp.test(gap)) {
      gapEndPos = options.locStart(comment);
    } else {
      // The gap string contained something other than whitespace or open
      // parentheses.
      break;
    }
  }

  for (const [i, {comment}] of tiesToBreak.entries()) {
    if (i < indexOfFirstLeadingComment) {
      addTrailingComment(precedingNode, comment);
    } else {
      addLeadingComment(followingNode, comment);
    }
  }

  for (const node of [precedingNode, followingNode]) {
    if (node.comments && node.comments.length > 1) {
      node.comments.sort((a, b) => options.locStart(a) - options.locStart(b));
    }
  }

  tiesToBreak.length = 0;
}

function findExpressionIndexForComment(quasis, comment, options) {
  const startPos = options.locStart(comment) - 1;

  for (let i = 1; i < quasis.length; ++i) {
    if (startPos < options.locStart(quasis[i])) {
      return i - 1;
    }
  }

  // We haven't found it, it probably means that some of the locations are off.
  // Let's just return the first one.
  /* istanbul ignore next */
  return 0;
}

module.exports = {
  attach,
};
