import iterateJsdoc from '../iterateJsdoc.js';
import escapeStringRegexp from 'escape-string-regexp';

const otherDescriptiveTags = new Set([
  'classdesc', 'deprecated', 'exception', 'file', 'fileoverview', 'overview',
  // 'copyright' and 'see' might be good addition, but as the former may be
  //   sensitive text, and the latter may have just a link, they are not
  //   included by default
  'summary', 'throws', 'todo', 'yield', 'yields',
]);

/**
 * @param {string} text
 * @returns {string[]}
 */
const extractParagraphs = (text) => {
  return text.split(/(?<![;:])\n\n+/v);
};

/**
 * @param {string} text
 * @param {string|RegExp} abbreviationsRegex
 * @returns {string[]}
 */
const extractSentences = (text, abbreviationsRegex) => {
  const txt = text
    // Remove all {} tags.
    .replaceAll(/(?<!^)\{[\s\S]*?\}\s*/gv, '')

    // Remove custom abbreviations
    .replace(abbreviationsRegex, '');

  const sentenceEndGrouping = /([.?!])(?:\s+|$)/gv;

  const puncts = [
    ...txt.matchAll(sentenceEndGrouping),
  ].map((sentEnd) => {
    return sentEnd[0];
  });

  return txt
    .split(/[.?!](?:\s+|$)/v)

    // Re-add the dot.
    .map((sentence, idx) => {
      return !puncts[idx] && /^\s*$/v.test(sentence) ? sentence : `${sentence}${puncts[idx] || ''}`;
    });
};

/**
 * @param {string} text
 * @returns {boolean}
 */
const isNewLinePrecededByAPeriod = (text) => {
  /** @type {boolean} */
  let lastLineEndsSentence;

  const lines = text.split('\n');

  return !lines.some((line) => {
    if (lastLineEndsSentence === false && /^[A-Z][a-z]/v.test(line)) {
      return true;
    }

    lastLineEndsSentence = /[.:?!\|]$/v.test(line);

    return false;
  });
};

/**
 * @param {string} str
 * @returns {boolean}
 */
const isCapitalized = (str) => {
  return str[0] === str[0].toUpperCase();
};

/**
 * @param {string} str
 * @returns {boolean}
 */
const isTable = (str) => {
  return str.charAt(0) === '|';
};

/**
 * @param {string} str
 * @returns {string}
 */
const capitalize = (str) => {
  return str.charAt(0).toUpperCase() + str.slice(1);
};

/**
 * @param {string} description
 * @param {import('../iterateJsdoc.js').Report} reportOrig
 * @param {import('eslint').Rule.Node} jsdocNode
 * @param {string|RegExp} abbreviationsRegex
 * @param {import('eslint').SourceCode} sourceCode
 * @param {import('comment-parser').Spec|{
 *   line: import('../iterateJsdoc.js').Integer
 * }} tag
 * @param {boolean} newlineBeforeCapsAssumesBadSentenceEnd
 * @returns {boolean}
 */
const validateDescription = (
  description, reportOrig, jsdocNode, abbreviationsRegex,
  sourceCode, tag, newlineBeforeCapsAssumesBadSentenceEnd,
) => {
  if (!description || (/^\n+$/v).test(description)) {
    return false;
  }

  const descriptionNoHeadings = description.replaceAll(/^\s*#[^\n]*(\n|$)/gmv, '');

  const paragraphs = extractParagraphs(descriptionNoHeadings).filter(Boolean);

  return paragraphs.some((paragraph, parIdx) => {
    const sentences = extractSentences(paragraph, abbreviationsRegex);

    const fix = /** @type {import('eslint').Rule.ReportFixer} */ (fixer) => {
      let text = sourceCode.getText(jsdocNode);

      if (!/[.:?!]$/v.test(paragraph)) {
        const line = paragraph.split('\n').findLast(Boolean);
        text = text.replace(new RegExp(`${escapeStringRegexp(
          /** @type {string} */
          (line),
        )}$`, 'mv'), `${line}.`);
      }

      for (const sentence of sentences.filter((sentence_) => {
        return !(/^\s*$/v).test(sentence_) && !isCapitalized(sentence_) &&
          !isTable(sentence_);
      })) {
        const beginning = sentence.split('\n')[0];

        if ('tag' in tag && tag.tag) {
          const reg = new RegExp(`(@${escapeStringRegexp(tag.tag)}.*)${escapeStringRegexp(beginning)}`, 'v');

          text = text.replace(reg, (_$0, $1) => {
            return $1 + capitalize(beginning);
          });
        } else {
          text = text.replace(new RegExp('((?:[.?!]|\\*|\\})\\s*)' + escapeStringRegexp(beginning), 'v'), '$1' + capitalize(beginning));
        }
      }

      return fixer.replaceText(jsdocNode, text);
    };

    /**
     * @param {string} msg
     * @param {import('eslint').Rule.ReportFixer | null | undefined} fixer
     * @param {{
     *   line?: number | undefined;
     *   column?: number | undefined;
     * } | (import('comment-parser').Spec & {
     *   line?: number | undefined;
     *   column?: number | undefined;
     * })} tagObj
     * @returns {void}
     */
    const report = (msg, fixer, tagObj) => {
      if ('line' in tagObj) {
        /**
         * @type {{
         *   line: number;
         * }}
         */ (tagObj).line += parIdx * 2;
      } else {
        /** @type {import('comment-parser').Spec} */ (
          tagObj
        ).source[0].number += parIdx * 2;
      }

      // Avoid errors if old column doesn't exist here
      tagObj.column = 0;
      reportOrig(msg, fixer, tagObj);
    };

    if (sentences.some((sentence) => {
      return (/^[.?!]$/v).test(sentence);
    })) {
      report('Sentences must be more than punctuation.', null, tag);
    }

    if (sentences.some((sentence) => {
      return !(/^\s*$/v).test(sentence) && !isCapitalized(sentence) && !isTable(sentence);
    })) {
      report('Sentences should start with an uppercase character.', fix, tag);
    }

    const paragraphNoAbbreviations = paragraph.replace(abbreviationsRegex, '');

    if (!/(?:[.?!\|]|```)\s*$/v.test(paragraphNoAbbreviations)) {
      report('Sentences must end with a period.', fix, tag);
      return true;
    }

    if (newlineBeforeCapsAssumesBadSentenceEnd && !isNewLinePrecededByAPeriod(paragraphNoAbbreviations)) {
      report('A line of text is started with an uppercase character, but the preceding line does not end the sentence.', null, tag);

      return true;
    }

    return false;
  });
};

export default iterateJsdoc(({
  context,
  jsdoc,
  jsdocNode,
  report,
  sourceCode,
  utils,
}) => {
  const /** @type {{abbreviations: string[], newlineBeforeCapsAssumesBadSentenceEnd: boolean}} */ {
    abbreviations = [],
    newlineBeforeCapsAssumesBadSentenceEnd = false,
  } = context.options[0] || {};

  const abbreviationsRegex = abbreviations.length ?
    new RegExp('\\b' + abbreviations.map((abbreviation) => {
      return escapeStringRegexp(abbreviation.replaceAll(/\.$/gv, '') + '.');
    }).join('|') + '(?:$|\\s)', 'gv') :
    '';

  let {
    description,
  } = utils.getDescription();

  const indices = [
    ...description.matchAll(/```[\s\S]*```/gv),
  ].map((match) => {
    const {
      index,
    } = match;
    const [
      {
        length,
      },
    ] = match;
    return {
      index,
      length,
    };
  }).toReversed();

  for (const {
    index,
    length,
  } of indices) {
    description = description.slice(0, index) +
      description.slice(/** @type {import('../iterateJsdoc.js').Integer} */ (
        index
      ) + length);
  }

  if (validateDescription(description, report, jsdocNode, abbreviationsRegex, sourceCode, {
    line: jsdoc.source[0].number + 1,
  }, newlineBeforeCapsAssumesBadSentenceEnd)) {
    return;
  }

  utils.forEachPreferredTag('description', (matchingJsdocTag) => {
    const desc = `${matchingJsdocTag.name} ${utils.getTagDescription(matchingJsdocTag)}`.trim();
    validateDescription(desc, report, jsdocNode, abbreviationsRegex, sourceCode, matchingJsdocTag, newlineBeforeCapsAssumesBadSentenceEnd);
  }, true);

  const {
    tagsWithNames,
  } = utils.getTagsByType(jsdoc.tags);
  const tagsWithoutNames = utils.filterTags(({
    tag: tagName,
  }) => {
    return otherDescriptiveTags.has(tagName) ||
      utils.hasOptionTag(tagName) && !tagsWithNames.some(({
        tag,
      }) => {
        // If user accidentally adds tags with names (or like `returns`
        //  get parsed as having names), do not add to this list
        return tag === tagName;
      });
  });

  tagsWithNames.some((tag) => {
    const desc = /** @type {string} */ (
      utils.getTagDescription(tag)
    ).replace(/^- /v, '').trimEnd();

    return validateDescription(desc, report, jsdocNode, abbreviationsRegex, sourceCode, tag, newlineBeforeCapsAssumesBadSentenceEnd);
  });

  tagsWithoutNames.some((tag) => {
    const desc = `${tag.name} ${utils.getTagDescription(tag)}`.trim();

    return validateDescription(desc, report, jsdocNode, abbreviationsRegex, sourceCode, tag, newlineBeforeCapsAssumesBadSentenceEnd);
  });
}, {
  iterateAllJsdocs: true,
  meta: {
    docs: {
      description: 'Requires that block description, explicit `@description`, and `@param`/`@returns` tag descriptions are written in complete sentences.',
      url: 'https://github.com/gajus/eslint-plugin-jsdoc/blob/main/docs/rules/require-description-complete-sentence.md#repos-sticky-header',
    },
    fixable: 'code',
    schema: [
      {
        additionalProperties: false,
        properties: {
          abbreviations: {
            description: `You can provide an \`abbreviations\` options array to avoid such strings of text
being treated as sentence endings when followed by dots. The \`.\` is not
necessary at the end of the array items.`,
            items: {
              type: 'string',
            },
            type: 'array',
          },
          newlineBeforeCapsAssumesBadSentenceEnd: {
            description: `When \`false\` (the new default), we will not assume capital letters after
newlines are an incorrect way to end the sentence (they may be proper
nouns, for example).`,
            type: 'boolean',
          },
          tags: {
            description: `If you want additional tags to be checked for their descriptions, you may
add them within this option.

\`\`\`js
{
  'jsdoc/require-description-complete-sentence': ['error', {
    tags: ['see', 'copyright']
  }]
}
\`\`\`

The tags \`@param\`/\`@arg\`/\`@argument\` and \`@property\`/\`@prop\` will be properly
parsed to ensure that the checked "description" text includes only the text
after the name.

All other tags will treat the text following the tag name, a space, and
an optional curly-bracketed type expression (and another space) as part of
its "description" (e.g., for \`@returns {someType} some description\`, the
description is \`some description\` while for \`@some-tag xyz\`, the description
is \`xyz\`).`,
            items: {
              type: 'string',
            },
            type: 'array',
          },
        },
        type: 'object',
      },
    ],
    type: 'suggestion',
  },
});
