import iterateJsdoc from '../iterateJsdoc.js';
import {
  parse,
  stringify,
  traverse,
  tryParse,
} from '@es-joy/jsdoccomment';

const strictNativeTypes = [
  'undefined',
  'null',
  'boolean',
  'number',
  'bigint',
  'string',
  'symbol',
  'object',
  'Array',
  'Function',
  'Date',
  'RegExp',
];

/**
 * Adjusts the parent type node `meta` for generic matches (or type node
 * `type` for `JsdocTypeAny`) and sets the type node `value`.
 * @param {string} type The actual type
 * @param {string} preferred The preferred type
 * @param {boolean} isGenericMatch
 * @param {string} typeNodeName
 * @param {import('jsdoc-type-pratt-parser').NonRootResult} node
 * @param {import('jsdoc-type-pratt-parser').NonRootResult|undefined} parentNode
 * @returns {void}
 */
const adjustNames = (type, preferred, isGenericMatch, typeNodeName, node, parentNode) => {
  let ret = preferred;
  if (isGenericMatch) {
    const parentMeta = /** @type {import('jsdoc-type-pratt-parser').GenericResult} */ (
      parentNode
    ).meta;
    if (preferred === '[]') {
      parentMeta.brackets = 'square';
      parentMeta.dot = false;
      ret = 'Array';
    } else {
      const dotBracketEnd = preferred.match(/\.(?:<>)?$/v);
      if (dotBracketEnd) {
        parentMeta.brackets = 'angle';
        parentMeta.dot = true;
        ret = preferred.slice(0, -dotBracketEnd[0].length);
      } else {
        const bracketEnd = preferred.endsWith('<>');
        if (bracketEnd) {
          parentMeta.brackets = 'angle';
          parentMeta.dot = false;
          ret = preferred.slice(0, -2);
        } else if (
          parentMeta?.brackets === 'square' &&
          (typeNodeName === '[]' || typeNodeName === 'Array')
        ) {
          parentMeta.brackets = 'angle';
          parentMeta.dot = false;
        }
      }
    }
  } else if (type === 'JsdocTypeAny') {
    node.type = 'JsdocTypeName';
  }

  /** @type {import('jsdoc-type-pratt-parser').NameResult} */ (
    node
  ).value = ret.replace(/(?:\.|<>|\.<>|\[\])$/v, '');

  // For bare pseudo-types like `<>`
  if (!ret) {
    /** @type {import('jsdoc-type-pratt-parser').NameResult} */ (
      node
    ).value = typeNodeName;
  }
};

/**
 * @param {boolean} [upperCase]
 * @returns {string}
 */
const getMessage = (upperCase) => {
  return 'Use object shorthand or index signatures instead of ' +
  '`' + (upperCase ? 'O' : 'o') + 'bject`, e.g., `{[key: string]: string}`';
};

export default iterateJsdoc(({
  context,
  jsdocNode,
  report,
  settings,
  sourceCode,
  utils,
}) => {
  const jsdocTagsWithPossibleType = utils.filterTags((tag) => {
    return Boolean(utils.tagMightHaveTypePosition(tag.tag));
  });

  const
    /**
     * @type {{
     *   preferredTypes: import('../iterateJsdoc.js').PreferredTypes,
     *   structuredTags: import('../iterateJsdoc.js').StructuredTags,
     *   mode: import('../jsdocUtils.js').ParserMode
     * }}
     */
    {
      mode,
      preferredTypes: preferredTypesOriginal,
      structuredTags,
    } = settings;

  const injectObjectPreferredTypes = !('Object' in preferredTypesOriginal ||
    'object' in preferredTypesOriginal ||
    'object.<>' in preferredTypesOriginal ||
    'Object.<>' in preferredTypesOriginal ||
    'object<>' in preferredTypesOriginal);

  /**
   * @type {{
   *   message: string,
   *   replacement: false
   * }}
   */
  const info = {
    message: getMessage(),
    replacement: false,
  };

  /**
   * @type {{
   *   message: string,
   *   replacement: false
   * }}
   */
  const infoUC = {
    message: getMessage(true),
    replacement: false,
  };

  /** @type {import('../iterateJsdoc.js').PreferredTypes} */
  const typeToInject = mode === 'typescript' ?
    {
      Object: 'object',
      'object.<>': info,
      'Object.<>': infoUC,
      'object<>': info,
      'Object<>': infoUC,
    } :
    {
      Object: 'object',
      'object.<>': 'Object<>',
      'Object.<>': 'Object<>',
      'object<>': 'Object<>',
    };

  /** @type {import('../iterateJsdoc.js').PreferredTypes} */
  const preferredTypes = {
    ...injectObjectPreferredTypes ?
      typeToInject :
      {},
    ...preferredTypesOriginal,
  };

  const
    /**
     * @type {{
     *   noDefaults: boolean,
     *   unifyParentAndChildTypeChecks: boolean,
     *   exemptTagContexts: ({
     *     tag: string,
     *     types: true|string[]
     *   })[]
     * }}
     */ {
      exemptTagContexts = [],
      noDefaults,
      unifyParentAndChildTypeChecks,
    } = context.options[0] || {};

  /**
   * Gets information about the preferred type: whether there is a matching
   * preferred type, what the type is, and whether it is a match to a generic.
   * @param {string} _type Not currently in use
   * @param {string} typeNodeName
   * @param {import('jsdoc-type-pratt-parser').NonRootResult|undefined} parentNode
   * @param {string|undefined} property
   * @returns {[hasMatchingPreferredType: boolean, typeName: string, isGenericMatch: boolean]}
   */
  const getPreferredTypeInfo = (_type, typeNodeName, parentNode, property) => {
    let hasMatchingPreferredType = false;
    let isGenericMatch = false;
    let typeName = typeNodeName;

    const isNameOfGeneric = parentNode !== undefined && parentNode.type === 'JsdocTypeGeneric' && property === 'left';
    if (unifyParentAndChildTypeChecks || isNameOfGeneric) {
      const brackets = /** @type {import('jsdoc-type-pratt-parser').GenericResult} */ (
        parentNode
      )?.meta?.brackets;
      const dot = /** @type {import('jsdoc-type-pratt-parser').GenericResult} */ (
        parentNode
      )?.meta?.dot;

      if (brackets === 'angle') {
        const checkPostFixes = dot ? [
          '.', '.<>',
        ] : [
          '<>',
        ];
        isGenericMatch = checkPostFixes.some((checkPostFix) => {
          if (preferredTypes?.[typeNodeName + checkPostFix] !== undefined) {
            typeName += checkPostFix;

            return true;
          }

          return false;
        });
      }

      if (
        !isGenericMatch && property &&
        /** @type {import('jsdoc-type-pratt-parser').NonRootResult} */ (
          parentNode
        ).type === 'JsdocTypeGeneric'
      ) {
        const checkPostFixes = dot ? [
          '.', '.<>',
        ] : [
          brackets === 'angle' ? '<>' : '[]',
        ];

        isGenericMatch = checkPostFixes.some((checkPostFix) => {
          if (preferredTypes?.[checkPostFix] !== undefined) {
            typeName = checkPostFix;

            return true;
          }

          return false;
        });
      }
    }

    const directNameMatch = preferredTypes?.[typeNodeName] !== undefined &&
      !Object.values(preferredTypes).includes(typeNodeName);
    const unifiedSyntaxParentMatch = property && directNameMatch && unifyParentAndChildTypeChecks;
    isGenericMatch = isGenericMatch || Boolean(unifiedSyntaxParentMatch);

    hasMatchingPreferredType = isGenericMatch ||
      directNameMatch && !property;

    return [
      hasMatchingPreferredType, typeName, isGenericMatch,
    ];
  };

  /**
   * Iterates strict types to see if any should be added to `invalidTypes` (and
   * the the relevant strict type returned as the new preferred type).
   * @param {string} typeNodeName
   * @param {string|undefined} preferred
   * @param {import('jsdoc-type-pratt-parser').NonRootResult|undefined} parentNode
   * @param {(string|false|undefined)[][]} invalidTypes
   * @returns {string|undefined} The `preferred` type string, optionally changed
   */
  const checkNativeTypes = (typeNodeName, preferred, parentNode, invalidTypes) => {
    let changedPreferred = preferred;
    for (const strictNativeType of strictNativeTypes) {
      if (
        strictNativeType === 'object' &&
        (
          // This is not set to remap with exact type match (e.g.,
          //   `object: 'Object'`), so can ignore (including if circular)
          !preferredTypes?.[typeNodeName] ||
          // Although present on `preferredTypes` for remapping, this is a
          //   parent object without a parent match (and not
          //   `unifyParentAndChildTypeChecks`) and we don't want
          //   `object<>` given TypeScript issue https://github.com/microsoft/TypeScript/issues/20555
          /**
           * @type {import('jsdoc-type-pratt-parser').GenericResult}
           */
          (
            parentNode
          )?.elements?.length && (
          /**
           * @type {import('jsdoc-type-pratt-parser').GenericResult}
           */
            (
              parentNode
            )?.left?.type === 'JsdocTypeName' &&
            /**
             * @type {import('jsdoc-type-pratt-parser').GenericResult}
             */
            (parentNode)?.left?.value === 'Object'
          )
        )
      ) {
        continue;
      }

      if (strictNativeType !== typeNodeName &&
        strictNativeType.toLowerCase() === typeNodeName.toLowerCase() &&

        // Don't report if user has own map for a strict native type
        (!preferredTypes || preferredTypes?.[strictNativeType] === undefined)
      ) {
        changedPreferred = strictNativeType;
        invalidTypes.push([
          typeNodeName, changedPreferred,
        ]);
        break;
      }
    }

    return changedPreferred;
  };

  /**
   * Collect invalid type info.
   * @param {string} type
   * @param {string} value
   * @param {string} tagName
   * @param {string} nameInTag
   * @param {number} idx
   * @param {string|undefined} property
   * @param {import('jsdoc-type-pratt-parser').NonRootResult} node
   * @param {import('jsdoc-type-pratt-parser').NonRootResult|undefined} parentNode
   * @param {(string|false|undefined)[][]} invalidTypes
   * @returns {void}
   */
  const getInvalidTypes = (type, value, tagName, nameInTag, idx, property, node, parentNode, invalidTypes) => {
    let typeNodeName = type === 'JsdocTypeAny' ? '*' : value;

    const [
      hasMatchingPreferredType,
      typeName,
      isGenericMatch,
    ] = getPreferredTypeInfo(type, typeNodeName, parentNode, property);

    let preferred;
    let types;
    if (hasMatchingPreferredType) {
      const preferredSetting = preferredTypes[typeName];
      typeNodeName = typeName === '[]' ? typeName : typeNodeName;

      if (!preferredSetting) {
        invalidTypes.push([
          typeNodeName,
        ]);
      } else if (typeof preferredSetting === 'string') {
        preferred = preferredSetting;
        invalidTypes.push([
          typeNodeName, preferred,
        ]);
      } else if (preferredSetting && typeof preferredSetting === 'object') {
        const nextItem = preferredSetting.skipRootChecking && jsdocTagsWithPossibleType[idx + 1];

        if (!nextItem || !nextItem.name.startsWith(`${nameInTag}.`)) {
          preferred = preferredSetting.replacement;
          invalidTypes.push([
            typeNodeName,
            preferred,
            preferredSetting.message,
          ]);
        }
      } else {
        utils.reportSettings(
          'Invalid `settings.jsdoc.preferredTypes`. Values must be falsy, a string, or an object.',
        );

        return;
      }
    } else if (Object.entries(structuredTags).some(([
      tag,
      {
        type: typs,
      },
    ]) => {
      types = typs;

      return tag === tagName &&
        Array.isArray(types) &&
        !types.includes(typeNodeName);
    })) {
      invalidTypes.push([
        typeNodeName, types,
      ]);
    } else if (!noDefaults && type === 'JsdocTypeName') {
      preferred = checkNativeTypes(typeNodeName, preferred, parentNode, invalidTypes);
    }

    // For fixer
    if (preferred) {
      adjustNames(type, preferred, isGenericMatch, typeNodeName, node, parentNode);
    }
  };

  for (const [
    idx,
    jsdocTag,
  ] of jsdocTagsWithPossibleType.entries()) {
    /** @type {(string|false|undefined)[][]} */
    const invalidTypes = [];
    let typeAst;

    try {
      typeAst = mode === 'permissive' ? tryParse(jsdocTag.type) : parse(jsdocTag.type, mode);
    } catch {
      continue;
    }

    const {
      name: nameInTag,
      tag: tagName,
    } = jsdocTag;

    traverse(typeAst, (node, parentNode, property) => {
      const {
        type,
        value,
      } =
        /**
         * @type {import('jsdoc-type-pratt-parser').NameResult}
         */ (node);
      if (![
        'JsdocTypeAny', 'JsdocTypeName',
      ].includes(type)) {
        return;
      }

      getInvalidTypes(type, value, tagName, nameInTag, idx, property, node, parentNode, invalidTypes);
    });

    if (invalidTypes.length) {
      const fixedType = stringify(typeAst);

      /**
       * @type {import('eslint').Rule.ReportFixer}
       */
      const fix = (fixer) => {
        return fixer.replaceText(
          jsdocNode,
          sourceCode.getText(jsdocNode).replace(
            `{${jsdocTag.type}}`,
            `{${fixedType}}`,
          ),
        );
      };

      for (const [
        badType,
        preferredType = '',
        msg,
      ] of invalidTypes) {
        const tagValue = jsdocTag.name ? ` "${jsdocTag.name}"` : '';
        if (exemptTagContexts.some(({
          tag,
          types,
        }) => {
          return tag === tagName &&
            (types === true || types.includes(jsdocTag.type));
        })) {
          continue;
        }

        report(
          msg ||
            `Invalid JSDoc @${tagName}${tagValue} type "${badType}"` +
            (preferredType ? '; ' : '.') +
            (preferredType ? `prefer: ${JSON.stringify(preferredType)}.` : ''),
          preferredType ? fix : null,
          jsdocTag,
          msg ? {
            tagName,
            tagValue,
          } : undefined,
        );
      }
    }
  }
}, {
  iterateAllJsdocs: true,
  meta: {
    docs: {
      description: 'Reports invalid types.',
      url: 'https://github.com/gajus/eslint-plugin-jsdoc/blob/main/docs/rules/check-types.md#repos-sticky-header',
    },
    fixable: 'code',
    schema: [
      {
        additionalProperties: false,
        properties: {
          exemptTagContexts: {
            items: {
              additionalProperties: false,
              properties: {
                tag: {
                  type: 'string',
                },
                types: {
                  oneOf: [
                    {
                      type: 'boolean',
                    },
                    {
                      items: {
                        type: 'string',
                      },
                      type: 'array',
                    },
                  ],
                },
              },
              type: 'object',
            },
            type: 'array',
          },
          noDefaults: {
            type: 'boolean',
          },
          unifyParentAndChildTypeChecks: {
            type: 'boolean',
          },
        },
        type: 'object',
      },
    ],
    type: 'suggestion',
  },
});
