import {
  forEachPreferredTag,
  getPreferredTagName,
  getRegexFromString,
  getTagDescription,
  hasTag,
} from './jsdocUtils.js';
import {
  parseComment,
} from '@es-joy/jsdoccomment';
import * as espree from 'espree';
import {
  decode,
} from 'html-entities';
import {
  readFileSync,
} from 'node:fs';
import {
  join,
} from 'node:path';
/**
 * @import {
 *   Integer,
 *   JsdocBlockWithInline,
 * } from './iterateJsdoc.js';
 * @import {
 *   ESLint,
 *   Linter,
 * } from 'eslint';
 */

const {
  version,
} = JSON.parse(
  readFileSync(join(import.meta.dirname, '../package.json'), 'utf8'),
);

// const zeroBasedLineIndexAdjust = -1;
const likelyNestedJSDocIndentSpace = 1;
const preTagSpaceLength = 1;

// If a space is present, we should ignore it
const firstLinePrefixLength = preTagSpaceLength;

const hasCaptionRegex = /^\s*<caption>([\s\S]*?)<\/caption>/v;

/**
 * @param {string} str
 * @returns {string}
 */
const escapeStringRegexp = (str) => {
  return str.replaceAll(/[.*+?^$\{\}\(\)\|\[\]\\]/gv, '\\$&');
};

/**
 * @param {string} str
 * @param {string} ch
 * @returns {Integer}
 */
const countChars = (str, ch) => {
  return (str.match(new RegExp(escapeStringRegexp(ch), 'gv')) || []).length;
};

/**
 * @param {string} text
 * @returns {[
 *   Integer,
 *   Integer
 * ]}
 */
const getLinesCols = (text) => {
  const matchLines = countChars(text, '\n');

  const colDelta = matchLines ?
    text.slice(text.lastIndexOf('\n') + 1).length :
    text.length;

  return [
    matchLines, colDelta,
  ];
};

/**
 * @typedef {number} Integer
 */
/**
 * @typedef {object} JsdocProcessorOptions
 * @property {boolean} [captionRequired] Require captions for example tags
 * @property {Integer} [paddedIndent] See docs
 * @property {boolean} [checkDefaults] See docs
 * @property {boolean} [checkParams] See docs
 * @property {boolean} [checkExamples] See docs
 * @property {boolean} [checkProperties] See docs
 * @property {string} [matchingFileName] See docs
 * @property {string} [matchingFileNameDefaults] See docs
 * @property {string} [matchingFileNameParams] See docs
 * @property {string} [matchingFileNameProperties] See docs
 * @property {string|RegExp} [exampleCodeRegex] See docs
 * @property {string|RegExp} [rejectExampleCodeRegex] See docs
 * @property {string[]} [allowedLanguagesToProcess] See docs
 * @property {"script"|"module"} [sourceType] See docs
 * @property {import('eslint').Linter.ESTreeParser|import('eslint').Linter.NonESTreeParser} [parser] See docs
 */

/**
 * We use a function for the ability of the user to pass in a config, but
 * without requiring all users of the plugin to do so.
 * @param {JsdocProcessorOptions} [options]
 * @returns {ESLint.Plugin}
 */
export const getJsdocProcessorPlugin = (options = {}) => {
  /**
   * @typedef {{
   *   text: string,
   *   filename: string|null|undefined
   * }} TextAndFileName
   */

  const {
    allowedLanguagesToProcess = [
      'js', 'ts', 'javascript', 'typescript',
    ],
    captionRequired = false,
    checkDefaults = false,
    checkExamples = true,
    checkParams = false,
    checkProperties = false,
    exampleCodeRegex = null,
    matchingFileName = null,
    matchingFileNameDefaults = null,
    matchingFileNameParams = null,
    matchingFileNameProperties = null,
    paddedIndent = 0,
    parser = undefined,
    rejectExampleCodeRegex = null,
    sourceType = 'module',
  } = options;

  /** @type {RegExp} */
  let exampleCodeRegExp;
  /** @type {RegExp} */
  let rejectExampleCodeRegExp;

  if (exampleCodeRegex) {
    exampleCodeRegExp = typeof exampleCodeRegex === 'string' ?
      getRegexFromString(exampleCodeRegex) :
      exampleCodeRegex;
  }

  if (rejectExampleCodeRegex) {
    rejectExampleCodeRegExp = typeof rejectExampleCodeRegex === 'string' ?
      getRegexFromString(rejectExampleCodeRegex) :
      rejectExampleCodeRegex;
  }

  /**
   * @type {{
   *   targetTagName: string,
   *   ext: string,
   *   codeStartLine: number,
   *   codeStartCol: number,
   *   nonJSPrefacingCols: number,
   *   commentLineCols: [number, number]
   * }[]}
   */
  const otherInfo = [];

  /** @type {import('eslint').Linter.LintMessage[]} */
  let extraMessages = [];

  /**
   * @param {JsdocBlockWithInline} jsdoc
   * @param {string} jsFileName
   * @param {[number, number]} commentLineCols
   */
  const getTextsAndFileNames = (jsdoc, jsFileName, commentLineCols) => {
    /**
     * @type {TextAndFileName[]}
     */
    const textsAndFileNames = [];

    /**
     * @param {{
     *   filename: string|null,
     *   defaultFileName: string|undefined,
     *   source: string,
     *   targetTagName: string,
     *   rules?: import('eslint').Linter.RulesRecord|undefined,
     *   lines?: Integer,
     *   cols?: Integer,
     *   skipInit?: boolean,
     *   ext: string,
     *   sources?: {
     *     nonJSPrefacingCols: Integer,
     *     nonJSPrefacingLines: Integer,
     *     string: string,
     *   }[],
     *   tag?: import('comment-parser').Spec & {
     *     line?: Integer,
     *   }|{
     *     line: Integer,
     *   }
     * }} cfg
     */
    const checkSource = ({
      cols = 0,
      defaultFileName,
      ext,
      filename,
      lines = 0,
      skipInit,
      source,
      sources = [],
      tag = {
        line: 0,
      },
      targetTagName,
    }) => {
      if (!skipInit) {
        sources.push({
          nonJSPrefacingCols: cols,
          nonJSPrefacingLines: lines,
          string: source,
        });
      }

      /**
       * @param {{
       *   nonJSPrefacingCols: Integer,
       *   nonJSPrefacingLines: Integer,
       *   string: string
       * }} cfg
       */
      const addSourceInfo = function ({
        nonJSPrefacingCols,
        nonJSPrefacingLines,
        string,
      }) {
        const src = paddedIndent ?
          string.replaceAll(new RegExp(`(^|\n) {${paddedIndent}}(?!$)`, 'gv'), '\n') :
          string;

        // Programmatic ESLint API: https://eslint.org/docs/developer-guide/nodejs-api
        const file = filename || defaultFileName;

        if (!('line' in tag)) {
          tag.line = tag.source[0].number;
        }

        // NOTE: `tag.line` can be 0 if of form `/** @tag ... */`
        const codeStartLine = /**
                               * @type {import('comment-parser').Spec & {
                               *     line: Integer,
                               * }}
                               */ (tag).line + nonJSPrefacingLines;
        const codeStartCol = likelyNestedJSDocIndentSpace;

        textsAndFileNames.push({
          filename: file,
          // See https://github.com/gajus/eslint-plugin-jsdoc/issues/710
          text: src.replaceAll(/(?<=\*)\\(?=\\*\/)/gv, '').replaceAll(/&([^\s;]+);/gv, (_, code) => {
            // Dec
            if ((/^#\d+$/v).test(code)) {
              return String.fromCodePoint(Number.parseInt(code.slice(1), 10));
            }

            // Hex
            if ((/^#x\d+$/v).test(code)) {
              return String.fromCodePoint(Number.parseInt(code.slice(2), 16));
            }

            return decode(_, {
              level: 'html5',
            });
          }),
        });
        otherInfo.push({
          codeStartCol,
          codeStartLine,
          commentLineCols,
          ext,
          nonJSPrefacingCols,
          targetTagName,
        });
      };

      for (const targetSource of sources) {
        addSourceInfo(targetSource);
      }
    };

    /**
     *
     * @param {string|null} filename
     * @param {string} [ext] Since `eslint-plugin-markdown` v2, and
     *   ESLint 7, this is the default which other JS-fenced rules will used.
     *   Formerly "md" was the default.
     * @returns {{
     *   defaultFileName: string|undefined,
     *   filename: string|null,
     *   ext: string
     * }}
     */
    const getFilenameInfo = (filename, ext = 'md/*.js') => {
      let defaultFileName;
      if (!filename) {
        if (typeof jsFileName === 'string' && jsFileName.includes('.')) {
          defaultFileName = jsFileName.replace(/\.[^.]*$/v, `.${ext}`);
        } else {
          defaultFileName = `dummy.${ext}`;
        }
      }

      return {
        defaultFileName,
        ext,
        filename,
      };
    };

    if (checkDefaults) {
      const filenameInfo = getFilenameInfo(matchingFileNameDefaults, 'jsdoc-defaults');
      forEachPreferredTag(jsdoc, 'default', (tag, targetTagName) => {
        if (!tag.description.trim()) {
          return;
        }

        checkSource({
          source: `(${getTagDescription(tag)})`,
          targetTagName,
          ...filenameInfo,
        });
      });
    }

    if (checkParams) {
      const filenameInfo = getFilenameInfo(matchingFileNameParams, 'jsdoc-params');
      forEachPreferredTag(jsdoc, 'param', (tag, targetTagName) => {
        if (!tag.default || !tag.default.trim()) {
          return;
        }

        checkSource({
          source: `(${tag.default})`,
          targetTagName,
          ...filenameInfo,
        });
      });
    }

    if (checkProperties) {
      const filenameInfo = getFilenameInfo(matchingFileNameProperties, 'jsdoc-properties');
      forEachPreferredTag(jsdoc, 'property', (tag, targetTagName) => {
        if (!tag.default || !tag.default.trim()) {
          return;
        }

        checkSource({
          source: `(${tag.default})`,
          targetTagName,
          ...filenameInfo,
        });
      });
    }

    if (!checkExamples) {
      return textsAndFileNames;
    }

    const tagName = /** @type {string} */ (getPreferredTagName(jsdoc, {
      tagName: 'example',
    }));
    if (!hasTag(jsdoc, tagName)) {
      return textsAndFileNames;
    }

    const matchingFilenameInfo = getFilenameInfo(matchingFileName);

    forEachPreferredTag(jsdoc, 'example', (tag, targetTagName) => {
      let source = /** @type {string} */ (getTagDescription(tag));
      const match = source.match(hasCaptionRegex);

      if (captionRequired && (!match || !match[1].trim())) {
        extraMessages.push({
          column: commentLineCols[1] + 1,
          line: 1 + commentLineCols[0] + (tag.line ?? tag.source[0].number),
          message: `@${targetTagName} error - Caption is expected for examples.`,
          ruleId: 'jsdoc/example-missing-caption',
          severity: 2,
        });
        return;
      }

      source = source.replace(hasCaptionRegex, '');
      const [
        lines,
        cols,
      ] = match ? getLinesCols(match[0]) : [
        0, 0,
      ];

      if (exampleCodeRegex && !exampleCodeRegExp.test(source) ||
        rejectExampleCodeRegex && rejectExampleCodeRegExp.test(source)
      ) {
        return;
      }

      // If `allowedLanguagesToProcess` is falsy, all languages should be processed.
      if (allowedLanguagesToProcess) {
        const matches = (/^\s*```(?<language>\S+)([\s\S]*)```\s*$/v).exec(source);
        if (matches?.groups && !allowedLanguagesToProcess.includes(
          matches.groups.language.toLowerCase(),
        )) {
          return;
        }
      }

      const sources = [];
      let skipInit = false;
      if (exampleCodeRegex) {
        let nonJSPrefacingCols = 0;
        let nonJSPrefacingLines = 0;

        let startingIndex = 0;
        let lastStringCount = 0;

        let exampleCode;
        exampleCodeRegExp.lastIndex = 0;
        while ((exampleCode = exampleCodeRegExp.exec(source)) !== null) {
          const {
            '0': n0,
            '1': n1,
            index,
          } = exampleCode;

          // Count anything preceding user regex match (can affect line numbering)
          const preMatch = source.slice(startingIndex, index);

          const [
            preMatchLines,
            colDelta,
          ] = getLinesCols(preMatch);

          let nonJSPreface;
          let nonJSPrefaceLineCount;
          if (n1) {
            const idx = n0.indexOf(n1);
            nonJSPreface = n0.slice(0, idx);
            nonJSPrefaceLineCount = countChars(nonJSPreface, '\n');
          } else {
            nonJSPreface = '';
            nonJSPrefaceLineCount = 0;
          }

          nonJSPrefacingLines += lastStringCount + preMatchLines + nonJSPrefaceLineCount;

          // Ignore `preMatch` delta if newlines here
          if (nonJSPrefaceLineCount) {
            const charsInLastLine = nonJSPreface.slice(nonJSPreface.lastIndexOf('\n') + 1).length;

            nonJSPrefacingCols += charsInLastLine;
          } else {
            nonJSPrefacingCols += colDelta + nonJSPreface.length;
          }

          const string = n1 || n0;
          sources.push({
            nonJSPrefacingCols,
            nonJSPrefacingLines,
            string,
          });
          startingIndex = exampleCodeRegExp.lastIndex;
          lastStringCount = countChars(string, '\n');
          if (!exampleCodeRegExp.global) {
            break;
          }
        }

        skipInit = true;
      }

      checkSource({
        cols,
        lines,
        skipInit,
        source,
        sources,
        tag,
        targetTagName,
        ...matchingFilenameInfo,
      });
    });

    return textsAndFileNames;
  };

  // See https://eslint.org/docs/latest/extend/plugins#processors-in-plugins
  // See https://eslint.org/docs/latest/extend/custom-processors
  // From https://github.com/eslint/eslint/issues/14745#issuecomment-869457265
  /*
    {
      "files": ["*.js", "*.ts"],
      "processor": "jsdoc/example" // a pretended value here
    },
    {
      "files": [
        "*.js/*_jsdoc-example.js",
        "*.ts/*_jsdoc-example.js",
        "*.js/*_jsdoc-example.ts"
      ],
      "rules": {
        // specific rules for examples in jsdoc only here
        // And other rules for `.js` and `.ts` will also be enabled for them
      }
    }
  */
  return {
    meta: {
      name: 'eslint-plugin-jsdoc/processor',
      version,
    },
    processors: {
      examples: {
        meta: {
          name: 'eslint-plugin-jsdoc/preprocessor',
          version,
        },
        /**
         * @param {import('eslint').Linter.LintMessage[][]} messages
         * @param {string} filename
         */
        postprocess ([
          jsMessages,
          ...messages
        // eslint-disable-next-line no-unused-vars -- Placeholder
        ], filename) {
          for (const [
            idx,
            message,
          ] of messages.entries()) {
            const {
              codeStartCol,
              codeStartLine,
              commentLineCols,
              nonJSPrefacingCols,
              targetTagName,
            } = otherInfo[idx];

            for (const msg of message) {
              const {
                column,
                endColumn,
                endLine,
                fatal,
                line,
                message: messageText,
                ruleId,
                severity,

                // Todo: Make fixable
                // fix
                // fix: {range: [number, number], text: string}
                // suggestions: {desc: , messageId:, fix: }[],
              } = msg;
              delete msg.fix;

              const [
                codeCtxLine,
                codeCtxColumn,
              ] = commentLineCols;
              const startLine = codeCtxLine + codeStartLine + line;

              // Seems to need one more now
              const startCol = 1 +
                codeCtxColumn + codeStartCol + (
              // This might not work for line 0, but line 0 is unlikely for examples
                line <= 1 ? nonJSPrefacingCols + firstLinePrefixLength : preTagSpaceLength
              ) + column;

              msg.message = '@' + targetTagName + ' ' + (severity === 2 ? 'error' : 'warning') +
                (ruleId ? ' (' + ruleId + ')' : '') + ': ' +
                (fatal ? 'Fatal: ' : '') +
                messageText;
              msg.line = startLine;
              msg.column = startCol;
              msg.endLine = endLine ? startLine + endLine : startLine;
              // added `- column` to offset what `endColumn` already seemed to include
              msg.endColumn = endColumn ? startCol - column + endColumn : startCol;
            }
          }

          const ret = [
            ...jsMessages,
          ].concat(...messages, ...extraMessages);
          extraMessages = [];
          return ret;
        },

        /**
         * @param {string} text
         * @param {string} filename
         * @returns {(string | Linter.ProcessorFile)[]}
         */
        preprocess (text, filename) {
          try {
            let ast;

            // May be running a second time so catch and ignore
            try {
              ast = parser ?
                // @ts-expect-error Should be present
                parser.parseForESLint(text, {
                  comment: true,
                  ecmaVersion: 'latest',
                  sourceType,
                }).ast :
                espree.parse(text, {
                  comment: true,
                  ecmaVersion: 'latest',
                  sourceType,
                });
            } catch {
              return [
                text,
              ];
            }

            /** @type {[number, number][]} */
            const commentLineCols = [];
            const jsdocComments = /** @type {import('estree').Comment[]} */ (
              /**
               * @type {import('estree').Program & {
               *   comments?: import('estree').Comment[]
               * }}
               */
              (ast).comments
            ).filter((comment) => {
              return (/^\*\s/v).test(comment.value);
            }).map((comment) => {
              const [
                start,
                /* c8 ignore next -- Unsupporting processors only? */
              ] = comment.range ?? [];
              const textToStart = text.slice(0, start);

              const [
                lines,
                cols,
              ] = getLinesCols(textToStart);

              // const lines = [...textToStart.matchAll(/\n/gv)].length
              // const lastLinePos = textToStart.lastIndexOf('\n');
              // const cols = lastLinePos === -1
              //   ? 0
              //   : textToStart.slice(lastLinePos).length;
              commentLineCols.push([
                lines, cols,
              ]);
              return parseComment(comment);
            });

            return [
              text,
              ...jsdocComments.flatMap((jsdoc, idx) => {
                return getTextsAndFileNames(
                  jsdoc,
                  filename,
                  commentLineCols[idx],
                );
              }).filter(
                /**
                 * @param {TextAndFileName} file
                 * @returns {file is Linter.ProcessorFile}
                 */
                (file) => {
                  return file !== null && file !== undefined;
                },
              ),
            ];
          /* c8 ignore next 6 */
          } catch (error) {
            // eslint-disable-next-line no-console -- Debugging
            console.log('err', filename, error);
          }

          return [];
        },
        supportsAutofix: true,
      },
    },
  };
};
