import exportParser from '../exportParser.js';
import {
  getSettings,
} from '../iterateJsdoc.js';
import {
  enforcedContexts,
  exemptSpeciaMethods,
  getContextObject,
  getFunctionParameterNames,
  getIndent,
  hasReturnValue,
  isConstructor,
} from '../jsdocUtils.js';
import {
  getDecorator,
  getJSDocComment,
  getReducedASTNode,
} from '@es-joy/jsdoccomment';

/**
 * @typedef {{
 *   ancestorsOnly: boolean,
 *   esm: boolean,
 *   initModuleExports: boolean,
 *   initWindow: boolean
 * }} RequireJsdocOpts
 */

/**
 * @typedef {import('eslint').Rule.Node|
 *   import('@typescript-eslint/types').TSESTree.Node} ESLintOrTSNode
 */

/** @type {import('json-schema').JSONSchema4} */
const OPTIONS_SCHEMA = {
  additionalProperties: false,
  properties: {
    checkConstructors: {
      default: true,
      type: 'boolean',
    },
    checkGetters: {
      anyOf: [
        {
          type: 'boolean',
        },
        {
          enum: [
            'no-setter',
          ],
          type: 'string',
        },
      ],
      default: true,
    },
    checkSetters: {
      anyOf: [
        {
          type: 'boolean',
        },
        {
          enum: [
            'no-getter',
          ],
          type: 'string',
        },
      ],
      default: true,
    },
    contexts: {
      items: {
        anyOf: [
          {
            type: 'string',
          },
          {
            additionalProperties: false,
            properties: {
              context: {
                type: 'string',
              },
              inlineCommentBlock: {
                type: 'boolean',
              },
              minLineCount: {
                type: 'integer',
              },
            },
            type: 'object',
          },
        ],
      },
      type: 'array',
    },
    enableFixer: {
      default: true,
      type: 'boolean',
    },
    exemptEmptyConstructors: {
      default: false,
      type: 'boolean',
    },
    exemptEmptyFunctions: {
      default: false,
      type: 'boolean',
    },
    fixerMessage: {
      default: '',
      type: 'string',
    },
    minLineCount: {
      type: 'integer',
    },
    publicOnly: {
      oneOf: [
        {
          default: false,
          type: 'boolean',
        },
        {
          additionalProperties: false,
          default: {},
          properties: {
            ancestorsOnly: {
              type: 'boolean',
            },
            cjs: {
              type: 'boolean',
            },
            esm: {
              type: 'boolean',
            },
            window: {
              type: 'boolean',
            },
          },
          type: 'object',
        },
      ],
    },
    require: {
      additionalProperties: false,
      default: {},
      properties: {
        ArrowFunctionExpression: {
          default: false,
          type: 'boolean',
        },
        ClassDeclaration: {
          default: false,
          type: 'boolean',
        },
        ClassExpression: {
          default: false,
          type: 'boolean',
        },
        FunctionDeclaration: {
          default: true,
          type: 'boolean',
        },
        FunctionExpression: {
          default: false,
          type: 'boolean',
        },
        MethodDefinition: {
          default: false,
          type: 'boolean',
        },
      },
      type: 'object',
    },
  },
  type: 'object',
};

/**
 * @param {import('eslint').Rule.RuleContext} context
 * @param {import('json-schema').JSONSchema4Object} baseObject
 * @param {string} option
 * @param {string} key
 * @returns {boolean|undefined}
 */
const getOption = (context, baseObject, option, key) => {
  if (context.options[0] && option in context.options[0] &&
    // Todo: boolean shouldn't be returning property, but
    //   tests currently require
    (typeof context.options[0][option] === 'boolean' ||
    key in context.options[0][option])
  ) {
    return context.options[0][option][key];
  }

  return /** @type {{[key: string]: {default?: boolean|undefined}}} */ (
    baseObject.properties
  )[key].default;
};

/**
 * @param {import('eslint').Rule.RuleContext} context
 * @param {import('../iterateJsdoc.js').Settings} settings
 * @returns {{
 *   contexts: (string|{
 *     context: string,
 *     inlineCommentBlock: boolean,
 *     minLineCount: import('../iterateJsdoc.js').Integer
 *   })[],
 *   enableFixer: boolean,
 *   exemptEmptyConstructors: boolean,
 *   exemptEmptyFunctions: boolean,
 *   fixerMessage: string,
 *   minLineCount: undefined|import('../iterateJsdoc.js').Integer,
 *   publicOnly: boolean|{[key: string]: boolean|undefined}
 *   require: {[key: string]: boolean|undefined}
 * }}
 */
const getOptions = (context, settings) => {
  const {
    contexts = settings.contexts || [],
    enableFixer = true,
    exemptEmptyConstructors = true,
    exemptEmptyFunctions = false,
    fixerMessage = '',
    minLineCount = undefined,
    publicOnly,
  } = context.options[0] || {};

  return {
    contexts,
    enableFixer,
    exemptEmptyConstructors,
    exemptEmptyFunctions,
    fixerMessage,
    minLineCount,
    publicOnly: ((baseObj) => {
      if (!publicOnly) {
        return false;
      }

      /** @type {{[key: string]: boolean|undefined}} */
      const properties = {};
      for (const prop of Object.keys(
        /** @type {import('json-schema').JSONSchema4Object} */ (
        /** @type {import('json-schema').JSONSchema4Object} */ (
            baseObj
          ).properties),
      )) {
        const opt = getOption(
          context,
          /** @type {import('json-schema').JSONSchema4Object} */ (baseObj),
          'publicOnly',
          prop,
        );

        properties[prop] = opt;
      }

      return properties;
    })(
      /** @type {import('json-schema').JSONSchema4Object} */
      (
        /** @type {import('json-schema').JSONSchema4Object} */
        (
          /** @type {import('json-schema').JSONSchema4Object} */
          (
            OPTIONS_SCHEMA.properties
          ).publicOnly
        ).oneOf
      )[1],
    ),
    require: ((baseObj) => {
      /** @type {{[key: string]: boolean|undefined}} */
      const properties = {};
      for (const prop of Object.keys(
        /** @type {import('json-schema').JSONSchema4Object} */ (
        /** @type {import('json-schema').JSONSchema4Object} */ (
            baseObj
          ).properties),
      )) {
        const opt = getOption(
          context,
          /** @type {import('json-schema').JSONSchema4Object} */
          (baseObj),
          'require',
          prop,
        );
        properties[prop] = opt;
      }

      return properties;
    })(
      /** @type {import('json-schema').JSONSchema4Object} */
      (OPTIONS_SCHEMA.properties).require,
    ),
  };
};

/** @type {import('eslint').Rule.RuleModule} */
export default {
  create (context) {
    /* c8 ignore next -- Fallback to deprecated method */
    const {
      sourceCode = context.getSourceCode(),
    } = context;
    const settings = getSettings(context);
    if (!settings) {
      return {};
    }

    const opts = getOptions(context, settings);

    const {
      contexts,
      enableFixer,
      exemptEmptyConstructors,
      exemptEmptyFunctions,
      fixerMessage,
      minLineCount,
      require: requireOption,
    } = opts;

    const publicOnly =

      /**
       * @type {{
       *   [key: string]: boolean | undefined;
       * }}
       */ (
        opts.publicOnly
      );

    /**
     * @type {import('../iterateJsdoc.js').CheckJsdoc}
     */
    const checkJsDoc = (info, _handler, node) => {
      if (
        // Optimize
        minLineCount !== undefined || contexts.some((ctxt) => {
          if (typeof ctxt === 'string') {
            return false;
          }

          const {
            minLineCount: count,
          } = ctxt;
          return count !== undefined;
        })
      ) {
        /**
         * @param {undefined|import('../iterateJsdoc.js').Integer} count
         */
        const underMinLine = (count) => {
          return count !== undefined && count >
            (sourceCode.getText(node).match(/\n/gu)?.length ?? 0) + 1;
        };

        if (underMinLine(minLineCount)) {
          return;
        }

        const {
          minLineCount: contextMinLineCount,
        } =
          /**
           * @type {{
           *   context: string;
           *   inlineCommentBlock: boolean;
           *   minLineCount: number;
           * }}
           */ (contexts.find((ctxt) => {
            if (typeof ctxt === 'string') {
              return false;
            }

            const {
              context: ctx,
            } = ctxt;
            return ctx === (info.selector || node.type);
          })) || {};
        if (underMinLine(contextMinLineCount)) {
          return;
        }
      }

      const jsDocNode = getJSDocComment(sourceCode, node, settings);

      if (jsDocNode) {
        return;
      }

      // For those who have options configured against ANY constructors (or
      //  setters or getters) being reported
      if (exemptSpeciaMethods(
        {
          description: '',
          inlineTags: [],
          problems: [],
          source: [],
          tags: [],
        },
        node,
        context,
        [
          OPTIONS_SCHEMA,
        ],
      )) {
        return;
      }

      if (
        // Avoid reporting param-less, return-less functions (when
        //  `exemptEmptyFunctions` option is set)
        exemptEmptyFunctions && info.isFunctionContext ||

        // Avoid reporting  param-less, return-less constructor methods (when
        //  `exemptEmptyConstructors` option is set)
        exemptEmptyConstructors && isConstructor(node)
      ) {
        const functionParameterNames = getFunctionParameterNames(node);
        if (!functionParameterNames.length && !hasReturnValue(node)) {
          return;
        }
      }

      const fix = /** @type {import('eslint').Rule.ReportFixer} */ (fixer) => {
        // Default to one line break if the `minLines`/`maxLines` settings allow
        const lines = settings.minLines === 0 && settings.maxLines >= 1 ? 1 : settings.minLines;
        /** @type {ESLintOrTSNode|import('@typescript-eslint/types').TSESTree.Decorator} */
        let baseNode = getReducedASTNode(node, sourceCode);

        const decorator = getDecorator(
          /** @type {import('eslint').Rule.Node} */
          (baseNode),
        );
        if (decorator) {
          baseNode = decorator;
        }

        const indent = getIndent({
          text: sourceCode.getText(
            /** @type {import('eslint').Rule.Node} */ (baseNode),
            /** @type {import('eslint').AST.SourceLocation} */
            (
              /** @type {import('eslint').Rule.Node} */ (baseNode).loc
            ).start.column,
          ),
        });

        const {
          inlineCommentBlock,
        } =
          /**
           * @type {{
           *     context: string,
           *     inlineCommentBlock: boolean,
           *     minLineCount: import('../iterateJsdoc.js').Integer
           *   }}
           */ (contexts.find((contxt) => {
            if (typeof contxt === 'string') {
              return false;
            }

            const {
              context: ctxt,
            } = contxt;
            return ctxt === node.type;
          })) || {};
        const insertion = (inlineCommentBlock ?
          `/** ${fixerMessage}` :
          `/**\n${indent}*${fixerMessage}\n${indent}`) +
            `*/${'\n'.repeat(lines)}${indent.slice(0, -1)}`;

        return fixer.insertTextBefore(
          /** @type {import('eslint').Rule.Node} */
          (baseNode),
          insertion,
        );
      };

      const report = () => {
        const {
          start,
        } = /** @type {import('eslint').AST.SourceLocation} */ (node.loc);
        const loc = {
          end: {
            column: 0,
            line: start.line + 1,
          },
          start,
        };
        context.report({
          fix: enableFixer ? fix : null,
          loc,
          messageId: 'missingJsDoc',
          node,
        });
      };

      if (publicOnly) {
        /** @type {RequireJsdocOpts} */
        const opt = {
          ancestorsOnly: Boolean(publicOnly?.ancestorsOnly ?? false),
          esm: Boolean(publicOnly?.esm ?? true),
          initModuleExports: Boolean(publicOnly?.cjs ?? true),
          initWindow: Boolean(publicOnly?.window ?? false),
        };
        const exported = exportParser.isUncommentedExport(node, sourceCode, opt, settings);

        if (exported) {
          report();
        }
      } else {
        report();
      }
    };

    /**
     * @param {string} prop
     * @returns {boolean}
     */
    const hasOption = (prop) => {
      return requireOption[prop] || contexts.some((ctxt) => {
        return typeof ctxt === 'object' ? ctxt.context === prop : ctxt === prop;
      });
    };

    return {
      ...getContextObject(
        enforcedContexts(context, [], settings),
        checkJsDoc,
      ),
      ArrowFunctionExpression (node) {
        if (!hasOption('ArrowFunctionExpression')) {
          return;
        }

        if (
          [
            'AssignmentExpression', 'ExportDefaultDeclaration', 'VariableDeclarator',
          ].includes(node.parent.type) ||
          [
            'ClassProperty', 'ObjectProperty', 'Property', 'PropertyDefinition',
          ].includes(node.parent.type) &&
            node ===
              /**
               * @type {import('@typescript-eslint/types').TSESTree.Property|
               *   import('@typescript-eslint/types').TSESTree.PropertyDefinition
               * }
               */
              (node.parent).value
        ) {
          checkJsDoc({
            isFunctionContext: true,
          }, null, node);
        }
      },

      ClassDeclaration (node) {
        if (!hasOption('ClassDeclaration')) {
          return;
        }

        checkJsDoc({
          isFunctionContext: false,
        }, null, node);
      },

      ClassExpression (node) {
        if (!hasOption('ClassExpression')) {
          return;
        }

        checkJsDoc({
          isFunctionContext: false,
        }, null, node);
      },

      FunctionDeclaration (node) {
        if (!hasOption('FunctionDeclaration')) {
          return;
        }

        checkJsDoc({
          isFunctionContext: true,
        }, null, node);
      },

      FunctionExpression (node) {
        if (!hasOption('FunctionExpression')) {
          return;
        }

        if (
          [
            'AssignmentExpression', 'ExportDefaultDeclaration', 'VariableDeclarator',
          ].includes(node.parent.type) ||
          [
            'ClassProperty', 'ObjectProperty', 'Property', 'PropertyDefinition',
          ].includes(node.parent.type) &&
            node ===
              /**
               * @type {import('@typescript-eslint/types').TSESTree.Property|
               *   import('@typescript-eslint/types').TSESTree.PropertyDefinition
               * }
               */
              (node.parent).value
        ) {
          checkJsDoc({
            isFunctionContext: true,
          }, null, node);
        }
      },

      MethodDefinition (node) {
        if (!hasOption('MethodDefinition')) {
          return;
        }

        checkJsDoc({
          isFunctionContext: true,
          selector: 'MethodDefinition',
        }, null, /** @type {import('eslint').Rule.Node} */ (node.value));
      },
    };
  },
  meta: {
    docs: {
      category: 'Stylistic Issues',
      description: 'Require JSDoc comments',
      recommended: true,
      url: 'https://github.com/gajus/eslint-plugin-jsdoc/blob/main/docs/rules/require-jsdoc.md#repos-sticky-header',
    },

    fixable: 'code',

    messages: {
      missingJsDoc: 'Missing JSDoc comment.',
    },

    schema: [
      OPTIONS_SCHEMA,
    ],

    type: 'suggestion',
  },
};
