"use strict";

const findLast = require("lodash/findLast");

/**
 * Search where is the position of the comment in the token array by
 * using dichotomic search.
 * @param {*} tokens ordered array of tokens
 * @param {*} comment comment token
 * @return the position of the token next to the comment
 */
function findUpperBoundToken(tokens, comment) {
  let diff;
  let i;
  let current;

  let len = tokens.length;
  i = 0;

  while (len) {
    diff = len >>> 1;
    current = i + diff;
    if (tokens[current].startOffset > comment.startOffset) {
      len = diff;
    } else {
      i = current + 1;
      len -= diff + 1;
    }
  }
  return i;
}

function isPrettierIgnoreComment(comment) {
  return comment.image.match(
    /(\/\/(\s*)prettier-ignore(\s*))|(\/\*(\s*)prettier-ignore(\s*)\*\/)/gm
  );
}

function isFormatterOffOnComment(comment) {
  return comment.image.match(
    /(\/\/(\s*)@formatter:(off|on)(\s*))|(\/\*(\s*)@formatter:(off|on)(\s*)\*\/)/gm
  );
}

/**
 * Pre-processing of tokens in order to
 * complete the parser's mostEnclosiveCstNodeByStartOffset and mostEnclosiveCstNodeByEndOffset structures.
 *
 * @param {ITokens[]} tokens - array of tokens
 * @param {{[startOffset: number]: CSTNode}} mostEnclosiveCstNodeByStartOffset
 * @param {{[endOffset: number]: CSTNode}} mostEnclosiveCstNodeByEndOffset
 */
function completeMostEnclosiveCSTNodeByOffset(
  tokens,
  mostEnclosiveCstNodeByStartOffset,
  mostEnclosiveCstNodeByEndOffset
) {
  tokens.forEach(token => {
    if (mostEnclosiveCstNodeByStartOffset[token.startOffset] === undefined) {
      mostEnclosiveCstNodeByStartOffset[token.startOffset] = token;
    }

    if (mostEnclosiveCstNodeByEndOffset[token.endOffset] === undefined) {
      mostEnclosiveCstNodeByEndOffset[token.endOffset] = token;
    }
  });
}

function extendRangeOffset(comments, tokens) {
  let position;
  comments.forEach(comment => {
    position = findUpperBoundToken(tokens, comment);

    const extendedStartOffset =
      position - 1 < 0 ? comment.startOffset : tokens[position - 1].endOffset;
    const extendedEndOffset =
      position == tokens.length
        ? comment.endOffset
        : tokens[position].startOffset;
    comment.extendedOffset = {
      startOffset: extendedStartOffset,
      endOffset: extendedEndOffset
    };
  });
}

/**
 * Create two data structures we use to know at which offset a comment can be attached.
 * - commentsByExtendedStartOffset: map a comment by the endOffset of the previous token.
 * - commentsByExtendedEndOffset: map a comment by the startOffset of the next token
 *
 * @param {ITokens[]} tokens - array of tokens
 *
 * @return {{commentsByExtendedStartOffset: {[extendedStartOffset: number]: Comment[]}, commentsByExtendedEndOffset: {[extendedEndOffset: number]: Comment[]}}}
 */
function mapCommentsByExtendedRange(comments) {
  const commentsByExtendedEndOffset = {};
  const commentsByExtendedStartOffset = {};

  comments.forEach(comment => {
    const extendedStartOffset = comment.extendedOffset.startOffset;
    const extendedEndOffset = comment.extendedOffset.endOffset;

    if (commentsByExtendedEndOffset[extendedEndOffset] === undefined) {
      commentsByExtendedEndOffset[extendedEndOffset] = [comment];
    } else {
      commentsByExtendedEndOffset[extendedEndOffset].push(comment);
    }

    if (commentsByExtendedStartOffset[extendedStartOffset] === undefined) {
      commentsByExtendedStartOffset[extendedStartOffset] = [comment];
    } else {
      commentsByExtendedStartOffset[extendedStartOffset].push(comment);
    }
  });

  return { commentsByExtendedEndOffset, commentsByExtendedStartOffset };
}

/**
 * Determine if a comment should be attached as a trailing comment to a specific node.
 * A comment should be trailing if it is on the same line than the previous token and
 * not on the same line than the next token
 *
 * @param {*} comment
 * @param {CSTNode} node
 * @param {{[startOffset: number]: CSTNode}} mostEnclosiveCstNodeByStartOffset
 */
function shouldAttachTrailingComments(
  comment,
  node,
  mostEnclosiveCstNodeByStartOffset
) {
  if (isPrettierIgnoreComment(comment)) {
    return false;
  }

  const nextNode =
    mostEnclosiveCstNodeByStartOffset[comment.extendedOffset.endOffset];

  // Last node of the file
  if (nextNode === undefined) {
    return true;
  }

  const nodeEndLine =
    node.location !== undefined ? node.location.endLine : node.endLine;

  if (comment.startLine !== nodeEndLine) {
    return false;
  }

  const nextNodeStartLine =
    nextNode.location !== undefined
      ? nextNode.location.startLine
      : nextNode.startLine;
  return comment.endLine !== nextNodeStartLine;
}

/**
 * Attach comments to the most enclosive CSTNode (node or token)
 *
 * @param {ITokens[]} tokens
 * @param {*} comments
 * @param {{[startOffset: number]: CSTNode}} mostEnclosiveCstNodeByStartOffset
 * @param {{[endOffset: number]: CSTNode}} mostEnclosiveCstNodeByEndOffset
 */
function attachComments(
  tokens,
  comments,
  mostEnclosiveCstNodeByStartOffset,
  mostEnclosiveCstNodeByEndOffset
) {
  // Edge case: only comments in the file
  if (tokens.length === 0) {
    mostEnclosiveCstNodeByStartOffset[NaN].leadingComments = comments;
    return;
  }

  // Pre-processing phase to complete the data structures we need to attach
  // a comment to the right place
  completeMostEnclosiveCSTNodeByOffset(
    tokens,
    mostEnclosiveCstNodeByStartOffset,
    mostEnclosiveCstNodeByEndOffset
  );

  extendRangeOffset(comments, tokens);
  const { commentsByExtendedStartOffset, commentsByExtendedEndOffset } =
    mapCommentsByExtendedRange(comments);

  /*
    This set is here to ensure that we attach comments only once
    If a comment is attached to a node or token, we remove it from this set
  */
  const commentsToAttach = new Set(comments);

  // Attach comments as trailing comments if desirable
  Object.keys(mostEnclosiveCstNodeByEndOffset).forEach(endOffset => {
    // We look if some comments is directly following this node/token
    if (commentsByExtendedStartOffset[endOffset] !== undefined) {
      const nodeTrailingComments = commentsByExtendedStartOffset[
        endOffset
      ].filter(comment => {
        return (
          shouldAttachTrailingComments(
            comment,
            mostEnclosiveCstNodeByEndOffset[endOffset],
            mostEnclosiveCstNodeByStartOffset
          ) && commentsToAttach.has(comment)
        );
      });

      if (nodeTrailingComments.length > 0) {
        mostEnclosiveCstNodeByEndOffset[endOffset].trailingComments =
          nodeTrailingComments;
      }

      nodeTrailingComments.forEach(comment => {
        commentsToAttach.delete(comment);
      });
    }
  });

  // Attach rest of comments as leading comments
  Object.keys(mostEnclosiveCstNodeByStartOffset).forEach(startOffset => {
    // We look if some comments is directly preceding this node/token
    if (commentsByExtendedEndOffset[startOffset] !== undefined) {
      const nodeLeadingComments = commentsByExtendedEndOffset[
        startOffset
      ].filter(comment => commentsToAttach.has(comment));

      if (nodeLeadingComments.length > 0) {
        mostEnclosiveCstNodeByStartOffset[startOffset].leadingComments =
          nodeLeadingComments;
      }

      // prettier ignore support
      for (let i = 0; i < nodeLeadingComments.length; i++) {
        if (isPrettierIgnoreComment(nodeLeadingComments[i])) {
          mostEnclosiveCstNodeByStartOffset[startOffset].ignore = true;
          break;
        }
      }
    }
  });
}

/**
 * Create pairs of formatter:off and formatter:on
 * @param comments
 * @returns pairs of formatter:off and formatter:on
 */
function matchFormatterOffOnPairs(comments) {
  const onOffComments = comments.filter(comment =>
    isFormatterOffOnComment(comment)
  );

  let isPreviousCommentOff = false;
  let isCurrentCommentOff = true;
  const pairs = [];
  let paired = {};
  onOffComments.forEach(comment => {
    isCurrentCommentOff = comment.image.slice(-3) === "off";

    if (!isPreviousCommentOff) {
      if (isCurrentCommentOff) {
        paired.off = comment;
      }
    } else {
      if (!isCurrentCommentOff) {
        paired.on = comment;
        pairs.push(paired);
        paired = {};
      }
    }
    isPreviousCommentOff = isCurrentCommentOff;
  });

  if (onOffComments.length > 0 && isCurrentCommentOff) {
    paired.on = undefined;
    pairs.push(paired);
  }

  return pairs;
}

/**
 * Check if the node is between formatter:off and formatter:on and change his ignore state
 * @param node
 * @param commentPairs
 */
function shouldNotFormat(node, commentPairs) {
  const matchingPair = findLast(
    commentPairs,
    comment => comment.off.endOffset < node.location.startOffset
  );
  if (
    matchingPair !== undefined &&
    (matchingPair.on === undefined ||
      matchingPair.on.startOffset > node.location.endOffset)
  ) {
    node.ignore = true;
  }
}

module.exports = {
  matchFormatterOffOnPairs,
  shouldNotFormat,
  attachComments
};
