import iterateJsdoc from '../iterateJsdoc.js';
import eslint, {
  ESLint,
} from 'eslint';
import semver from 'semver';

const {
  // @ts-expect-error Older ESLint
  CLIEngine,
} = eslint;

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 {import('../iterateJsdoc.js').Integer}
 */
const countChars = (str, ch) => {
  return (str.match(new RegExp(escapeStringRegexp(ch), 'gv')) || []).length;
};

/** @type {import('eslint').Linter.RulesRecord} */
const defaultMdRules = {
  // "always" newline rule at end unlikely in sample code
  '@stylistic/eol-last': 0,

  // Often wish to start `@example` code after newline; also may use
  //   empty lines for spacing
  '@stylistic/no-multiple-empty-lines': 0,

  // Can generally look nicer to pad a little even if code imposes more stringency
  '@stylistic/padded-blocks': 0,

  '@typescript-eslint/no-unused-vars': 0,

  // "always" newline rule at end unlikely in sample code
  'eol-last': 0,

  // Wouldn't generally expect example paths to resolve relative to JS file
  'import/no-unresolved': 0,

  // Snippets likely too short to always include import/export info
  'import/unambiguous': 0,

  'jsdoc/require-file-overview': 0,

  // The end of a multiline comment would end the comment the example is in.
  'jsdoc/require-jsdoc': 0,

  // See import/no-unresolved
  'n/no-missing-import': 0,
  'n/no-missing-require': 0,

  // Unlikely to have inadvertent debugging within examples
  'no-console': 0,

  // Often wish to start `@example` code after newline; also may use
  //   empty lines for spacing
  'no-multiple-empty-lines': 0,
  // Many variables in examples will be `undefined`
  'no-undef': 0,

  // Common to define variables for clarity without always using them
  'no-unused-vars': 0,

  // See import/no-unresolved
  'node/no-missing-import': 0,

  'node/no-missing-require': 0,

  // Can generally look nicer to pad a little even if code imposes more stringency
  'padded-blocks': 0,
};

/** @type {import('eslint').Linter.RulesRecord} */
const defaultExpressionRules = {
  ...defaultMdRules,
  '@stylistic/quotes': [
    'error', 'double',
  ],
  '@stylistic/semi': [
    'error', 'never',
  ],
  '@typescript-eslint/no-unused-expressions': 'off',
  'chai-friendly/no-unused-expressions': 'off',
  'no-empty-function': 'off',
  'no-new': 'off',
  'no-unused-expressions': 'off',
  quotes: [
    'error', 'double',
  ],
  semi: [
    'error', 'never',
  ],
  strict: 'off',
};

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

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

  return [
    matchLines, colDelta,
  ];
};

export default iterateJsdoc(({
  context,
  globalState,
  report,
  utils,
}) => {
  if (semver.gte(ESLint.version, '8.0.0')) {
    report(
      'This rule does not work for ESLint 8+; you should disable this rule and use' +
        'the processor mentioned in the docs.',
      null,
      {
        column: 1,
        line: 1,
      },
    );

    return;
  }

  if (!globalState.has('checkExamples-matchingFileName')) {
    globalState.set('checkExamples-matchingFileName', new Map());
  }

  const matchingFileNameMap = /** @type {Map<string, string>} */ (
    globalState.get('checkExamples-matchingFileName')
  );

  const options = context.options[0] || {};
  let {
    exampleCodeRegex = null,
    rejectExampleCodeRegex = null,
  } = options;
  const {
    allowInlineConfig = true,
    baseConfig = {},
    captionRequired = false,
    checkDefaults = false,
    checkEslintrc = true,
    checkParams = false,
    checkProperties = false,
    configFile,
    matchingFileName = null,
    matchingFileNameDefaults = null,
    matchingFileNameParams = null,
    matchingFileNameProperties = null,
    noDefaultExampleRules = false,
    paddedIndent = 0,
    reportUnusedDisableDirectives = true,
  } = options;

  // Make this configurable?
  /**
   * @type {never[]}
   */
  const rulePaths = [];

  const mdRules = noDefaultExampleRules ? undefined : defaultMdRules;

  const expressionRules = noDefaultExampleRules ? undefined : defaultExpressionRules;

  if (exampleCodeRegex) {
    exampleCodeRegex = utils.getRegexFromString(exampleCodeRegex);
  }

  if (rejectExampleCodeRegex) {
    rejectExampleCodeRegex = utils.getRegexFromString(rejectExampleCodeRegex);
  }

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

    /**
     * @param {{
     *   nonJSPrefacingCols: import('../iterateJsdoc.js').Integer,
     *   nonJSPrefacingLines: import('../iterateJsdoc.js').Integer,
     *   string: string
     * }} cfg
     */
    const checkRules = function ({
      nonJSPrefacingCols,
      nonJSPrefacingLines,
      string,
    }) {
      const cliConfig = {
        allowInlineConfig,
        baseConfig,
        configFile,
        reportUnusedDisableDirectives,
        rulePaths,
        rules,
        useEslintrc: checkEslintrc,
      };
      const cliConfigStr = JSON.stringify(cliConfig);

      const src = paddedIndent ?
        string.replaceAll(new RegExp(`(^|\n) {${paddedIndent}}(?!$)`, 'gv'), '\n') :
        string;

      // Programmatic ESLint API: https://eslint.org/docs/developer-guide/nodejs-api
      const fileNameMapKey = filename ?
        'a' + cliConfigStr + filename :
        'b' + cliConfigStr + defaultFileName;
      const file = filename || defaultFileName;
      let cliFile;
      if (matchingFileNameMap.has(fileNameMapKey)) {
        cliFile = matchingFileNameMap.get(fileNameMapKey);
      } else {
        const cli = new CLIEngine(cliConfig);
        let config;
        if (filename || checkEslintrc) {
          config = cli.getConfigForFile(file);
        }

        // We need a new instance to ensure that the rules that may only
        //  be available to `file` (if it has its own `.eslintrc`),
        //  will be defined.
        cliFile = new CLIEngine({
          allowInlineConfig,
          baseConfig: {
            ...baseConfig,
            ...config,
          },
          configFile,
          reportUnusedDisableDirectives,
          rulePaths,
          rules,
          useEslintrc: false,
        });
        matchingFileNameMap.set(fileNameMapKey, cliFile);
      }

      const {
        results: [
          {
            messages,
          },
        ],
      } = cliFile.executeOnText(src);

      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: import('../iterateJsdoc.js').Integer,
                             * }}
                             */ (tag).line + nonJSPrefacingLines;
      const codeStartCol = likelyNestedJSDocIndentSpace;

      for (const {
        column,
        line,
        message,
        ruleId,
        severity,
      } of messages) {
        const startLine = codeStartLine + line + zeroBasedLineIndexAdjust;
        const startCol = codeStartCol + (

          // This might not work for line 0, but line 0 is unlikely for examples
          line <= 1 ? nonJSPrefacingCols + firstLinePrefixLength : preTagSpaceLength
        ) + column;

        report(
          '@' + targetTagName + ' ' + (severity === 2 ? 'error' : 'warning') +
            (ruleId ? ' (' + ruleId + ')' : '') + ': ' +
            message,
          null,
          {
            column: startCol,
            line: startLine,
          },
        );
      }
    };

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

  /**
   *
   * @param {string} 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}}
   */
  const getFilenameInfo = (filename, ext = 'md/*.js') => {
    let defaultFileName;
    if (!filename) {
      // @ts-expect-error ESLint < 10
      const jsFileName = context.getFilename();
      if (typeof jsFileName === 'string' && jsFileName.includes('.')) {
        defaultFileName = jsFileName.replace(/\.[^.]*$/v, `.${ext}`);
      } else {
        defaultFileName = `dummy.${ext}`;
      }
    }

    return {
      defaultFileName,
      filename,
    };
  };

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

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

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

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

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

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

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

  const matchingFilenameInfo = getFilenameInfo(matchingFileName);

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

    if (captionRequired && (!match || !match[1].trim())) {
      report('Caption is expected for examples.', null, tag);
    }

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

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

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

      let startingIndex = 0;
      let lastStringCount = 0;

      let exampleCode;
      exampleCodeRegex.lastIndex = 0;
      while ((exampleCode = exampleCodeRegex.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 = exampleCodeRegex.lastIndex;
        lastStringCount = countChars(string, '\n');
        if (!exampleCodeRegex.global) {
          break;
        }
      }

      skipInit = true;
    }

    checkSource({
      cols,
      lines,
      rules: mdRules,
      skipInit,
      source,
      sources,
      tag,
      targetTagName,
      ...matchingFilenameInfo,
    });
  });
}, {
  iterateAllJsdocs: true,
  meta: {
    docs: {
      description: '@deprecated - Use `getJsdocProcessorPlugin` processor; ensures that (JavaScript) samples within `@example` tags adhere to ESLint rules.',
      url: 'https://github.com/gajus/eslint-plugin-jsdoc/blob/main/docs/rules/check-examples.md#repos-sticky-header',
    },
    schema: [
      {
        additionalProperties: false,
        properties: {
          allowInlineConfig: {
            default: true,
            type: 'boolean',
          },
          baseConfig: {
            type: 'object',
          },
          captionRequired: {
            default: false,
            type: 'boolean',
          },
          checkDefaults: {
            default: false,
            type: 'boolean',
          },
          checkEslintrc: {
            default: true,
            type: 'boolean',
          },
          checkParams: {
            default: false,
            type: 'boolean',
          },
          checkProperties: {
            default: false,
            type: 'boolean',
          },
          configFile: {
            type: 'string',
          },
          exampleCodeRegex: {
            type: 'string',
          },
          matchingFileName: {
            type: 'string',
          },
          matchingFileNameDefaults: {
            type: 'string',
          },
          matchingFileNameParams: {
            type: 'string',
          },
          matchingFileNameProperties: {
            type: 'string',
          },
          noDefaultExampleRules: {
            default: false,
            type: 'boolean',
          },
          paddedIndent: {
            default: 0,
            type: 'integer',
          },
          rejectExampleCodeRegex: {
            type: 'string',
          },
          reportUnusedDisableDirectives: {
            default: true,
            type: 'boolean',
          },
        },
        type: 'object',
      },
    ],
    type: 'suggestion',
  },
});
