/**
 * Transform based on https://github.com/syavorsky/comment-parser/blob/master/src/transforms/align.ts
 *
 * It contains some customizations to align based on the tags, and some custom options.
 */

import {
  // `comment-parser/primitives` export
  util,
} from 'comment-parser';

/**
 * Detects if a line starts with a markdown list marker
 * Supports: -, *, numbered lists (1., 2., etc.)
 * This explicitly excludes hyphens that are part of JSDoc tag syntax
 * @param {string} text - The text to check
 * @param {boolean} isFirstLineOfTag - True if this is the first line (tag line)
 * @returns {boolean} - True if the text starts with a list marker
 */
const startsWithListMarker = (text, isFirstLineOfTag = false) => {
  // On the first line of a tag, the hyphen is typically the JSDoc separator,
  // not a list marker
  if (isFirstLineOfTag) {
    return false;
  }

  // Match lines that start with optional whitespace, then a list marker
  // - or * followed by a space
  // or a number followed by . or ) and a space
  return /^\s*(?:[\-*]|\d+(?:\.|\)))\s+/v.test(text);
};

/**
 * @typedef {{
 *   hasNoTypes: boolean,
 *   maxNamedTagLength: import('./iterateJsdoc.js').Integer,
 *   maxUnnamedTagLength: import('./iterateJsdoc.js').Integer
 * }} TypelessInfo
 */

const {
  rewireSource,
} = util;

/**
 * @typedef {{
 *   name: import('./iterateJsdoc.js').Integer,
 *   start: import('./iterateJsdoc.js').Integer,
 *   tag: import('./iterateJsdoc.js').Integer,
 *   type: import('./iterateJsdoc.js').Integer
 * }} Width
 */

/** @type {Width} */
const zeroWidth = {
  name: 0,
  start: 0,
  tag: 0,
  type: 0,
};

/**
 * @param {string[]} tags
 * @param {import('./iterateJsdoc.js').Integer} index
 * @param {import('comment-parser').Line[]} source
 * @returns {boolean}
 */
const shouldAlign = (tags, index, source) => {
  const tag = source[index].tokens.tag.replace('@', '');
  const includesTag = tags.includes(tag);

  if (includesTag) {
    return true;
  }

  if (tag !== '') {
    return false;
  }

  for (let iterator = index; iterator >= 0; iterator--) {
    const previousTag = source[iterator].tokens.tag.replace('@', '');

    if (previousTag !== '') {
      if (tags.includes(previousTag)) {
        return true;
      }

      return false;
    }
  }

  return true;
};

/**
 * @param {string[]} tags
 * @returns {(
 *   width: Width,
 *   line: {
 *     tokens: import('comment-parser').Tokens
 *   },
 *   index: import('./iterateJsdoc.js').Integer,
 *   source: import('comment-parser').Line[]
 * ) => Width}
 */
const getWidth = (tags) => {
  return (width, {
    tokens,
  }, index, source) => {
    if (!shouldAlign(tags, index, source)) {
      return width;
    }

    return {
      name: Math.max(width.name, tokens.name.length),
      start: tokens.delimiter === '/**' ? tokens.start.length : width.start,
      tag: Math.max(width.tag, tokens.tag.length),
      type: Math.max(width.type, tokens.type.length),
    };
  };
};

/**
 * @param {{
 *   description: string;
 *   tags: import('comment-parser').Spec[];
 *   problems: import('comment-parser').Problem[];
 * }} fields
 * @returns {TypelessInfo}
 */
const getTypelessInfo = (fields) => {
  const hasNoTypes = fields.tags.every(({
    type,
  }) => {
    return !type;
  });
  const maxNamedTagLength = Math.max(...fields.tags.map(({
    name,
    tag,
  }) => {
    return name.length === 0 ? -1 : tag.length;
  }).filter((length) => {
    return length !== -1;
  })) + 1;
  const maxUnnamedTagLength = Math.max(...fields.tags.map(({
    name,
    tag,
  }) => {
    return name.length === 0 ? tag.length : -1;
  }).filter((length) => {
    return length !== -1;
  })) + 1;
  return {
    hasNoTypes,
    maxNamedTagLength,
    maxUnnamedTagLength,
  };
};

/**
 * @param {import('./iterateJsdoc.js').Integer} len
 * @returns {string}
 */
const space = (len) => {
  return ''.padStart(len, ' ');
};

/**
 * Check if a tag or any of its lines contain list markers
 * @param {import('./iterateJsdoc.js').Integer} index - Current line index
 * @param {import('comment-parser').Line[]} source - All source lines
 * @returns {{hasListMarker: boolean, tagStartIndex: import('./iterateJsdoc.js').Integer}}
 */
const checkForListMarkers = (index, source) => {
  let hasListMarker = false;
  let tagStartIndex = index;
  while (tagStartIndex > 0 && source[tagStartIndex].tokens.tag === '') {
    tagStartIndex--;
  }

  for (let idx = tagStartIndex; idx <= index; idx++) {
    const isFirstLine = (idx === tagStartIndex);
    if (source[idx]?.tokens?.description && startsWithListMarker(source[idx].tokens.description, isFirstLine)) {
      hasListMarker = true;
      break;
    }
  }

  return {
    hasListMarker,
    tagStartIndex,
  };
};

/**
 * Calculate extra indentation for list items relative to the first continuation line
 * @param {import('./iterateJsdoc.js').Integer} index - Current line index
 * @param {import('./iterateJsdoc.js').Integer} tagStartIndex - Index of the tag line
 * @param {import('comment-parser').Line[]} source - All source lines
 * @returns {string} - Extra indentation spaces
 */
const calculateListExtraIndent = (index, tagStartIndex, source) => {
  // Find the first continuation line to use as baseline
  let firstContinuationIndent = null;
  for (let idx = tagStartIndex + 1; idx < source.length; idx++) {
    if (source[idx].tokens.description && !source[idx].tokens.tag) {
      firstContinuationIndent = source[idx].tokens.postDelimiter.length;
      break;
    }
  }

  // Calculate the extra indentation of current line relative to the first continuation line
  const currentOriginalIndent = source[index].tokens.postDelimiter.length;
  const extraIndent = firstContinuationIndent !== null && currentOriginalIndent > firstContinuationIndent ?
    ' '.repeat(currentOriginalIndent - firstContinuationIndent) :
    '';

  return extraIndent;
};

/**
 * @param {{
 *   customSpacings: import('../src/rules/checkLineAlignment.js').CustomSpacings,
 *   tags: string[],
 *   indent: string,
 *   preserveMainDescriptionPostDelimiter: boolean,
 *   wrapIndent: string,
 *   disableWrapIndent: boolean,
 * }} cfg
 * @returns {(
 *   block: import('comment-parser').Block
 * ) => import('comment-parser').Block}
 */
const alignTransform = ({
  customSpacings,
  disableWrapIndent,
  indent,
  preserveMainDescriptionPostDelimiter,
  tags,
  wrapIndent,
}) => {
  let intoTags = false;
  /** @type {Width} */
  let width;

  /**
   * @param {import('comment-parser').Tokens} tokens
   * @param {TypelessInfo} typelessInfo
   * @returns {import('comment-parser').Tokens}
   */
  const alignTokens = (tokens, typelessInfo) => {
    const nothingAfter = {
      delim: false,
      name: false,
      tag: false,
      type: false,
    };

    if (tokens.description === '') {
      nothingAfter.name = true;
      tokens.postName = '';

      if (tokens.name === '') {
        nothingAfter.type = true;
        tokens.postType = '';

        if (tokens.type === '') {
          nothingAfter.tag = true;
          tokens.postTag = '';

          /* c8 ignore next: Never happens because the !intoTags return. But it's here for consistency with the original align transform */
          if (tokens.tag === '') {
            nothingAfter.delim = true;
          }
        }
      }
    }

    let untypedNameAdjustment = 0;
    let untypedTypeAdjustment = 0;
    if (typelessInfo.hasNoTypes) {
      nothingAfter.tag = true;
      tokens.postTag = '';
      if (tokens.name === '') {
        untypedNameAdjustment = typelessInfo.maxNamedTagLength - tokens.tag.length;
      } else {
        untypedNameAdjustment = typelessInfo.maxNamedTagLength > typelessInfo.maxUnnamedTagLength ? 0 :
          Math.max(0, typelessInfo.maxUnnamedTagLength - (tokens.tag.length + tokens.name.length + 1));
        untypedTypeAdjustment = typelessInfo.maxNamedTagLength - tokens.tag.length;
      }
    }

    // Todo: Avoid fixing alignment of blocks with multiline wrapping of type
    if (tokens.tag === '' && tokens.type) {
      return tokens;
    }

    const spacings = {
      postDelimiter: customSpacings?.postDelimiter || 1,
      postName: customSpacings?.postName || 1,
      postTag: customSpacings?.postTag || 1,
      postType: customSpacings?.postType || 1,
    };

    tokens.postDelimiter = nothingAfter.delim ? '' : space(spacings.postDelimiter);

    if (!nothingAfter.tag) {
      tokens.postTag = space(width.tag - tokens.tag.length + spacings.postTag);
    }

    if (!nothingAfter.type) {
      tokens.postType = space(width.type - tokens.type.length + spacings.postType + untypedTypeAdjustment);
    }

    if (!nothingAfter.name) {
      // If post name is empty for all lines (name width 0), don't add post name spacing.
      tokens.postName = width.name === 0 ? '' : space(width.name - tokens.name.length + spacings.postName + untypedNameAdjustment);
    }

    return tokens;
  };

  /**
   * @param {import('comment-parser').Line} line
   * @param {import('./iterateJsdoc.js').Integer} index
   * @param {import('comment-parser').Line[]} source
   * @param {TypelessInfo} typelessInfo
   * @param {string|false} indentTag
   * @returns {import('comment-parser').Line}
   */
  const update = (line, index, source, typelessInfo, indentTag) => {
    /** @type {import('comment-parser').Tokens} */
    const tokens = {
      ...line.tokens,
    };

    if (tokens.tag !== '') {
      intoTags = true;
    }

    const isEmpty =
      tokens.tag === '' &&
      tokens.name === '' &&
      tokens.type === '' &&
      tokens.description === '';

    // dangling '*/'
    if (tokens.end === '*/' && isEmpty) {
      tokens.start = indent + ' ';

      return {
        ...line,
        tokens,
      };
    }

    switch (tokens.delimiter) {
      case '*':
        tokens.start = indent + ' ';
        break;
      case '/**':
        tokens.start = indent;
        break;
      default:
        tokens.delimiter = '';

        // compensate delimiter
        tokens.start = indent + '  ';
    }

    if (!intoTags) {
      if (tokens.description === '') {
        tokens.postDelimiter = '';
      } else if (!preserveMainDescriptionPostDelimiter) {
        tokens.postDelimiter = ' ';
      }

      return {
        ...line,
        tokens,
      };
    }

    const postHyphenSpacing = customSpacings?.postHyphen ?? 1;
    const hyphenSpacing = /^\s*-\s+/v;
    tokens.description = tokens.description.replace(
      hyphenSpacing, '-' + ''.padStart(postHyphenSpacing, ' '),
    );

    // Not align.
    if (shouldAlign(tags, index, source)) {
      alignTokens(tokens, typelessInfo);

      if (!disableWrapIndent && indentTag) {
        const {
          hasListMarker,
          tagStartIndex,
        } = checkForListMarkers(index, source);

        if (hasListMarker && index > tagStartIndex) {
          const extraIndent = calculateListExtraIndent(index, tagStartIndex, source);
          tokens.postDelimiter += wrapIndent + extraIndent;
        } else {
          // Normal case: add wrapIndent after the aligned delimiter
          tokens.postDelimiter += wrapIndent;
        }
      }
    }

    return {
      ...line,
      tokens,
    };
  };

  return ({
    source,
    ...fields
  }) => {
    width = source.reduce(getWidth(tags), {
      ...zeroWidth,
    });

    const typelessInfo = getTypelessInfo(fields);

    let tagIndentMode = false;

    return rewireSource({
      ...fields,
      source: source.map((line, index) => {
        const indentTag = !disableWrapIndent && tagIndentMode && !line.tokens.tag && line.tokens.description;
        const ret = update(line, index, source, typelessInfo, indentTag);

        if (!disableWrapIndent && line.tokens.tag) {
          tagIndentMode = true;
        }

        return ret;
      }),
    });
  };
};

export default alignTransform;
