import { _getDefaults } from './defaults.ts';
import { _Lexer } from './Lexer.ts';
import { _Parser } from './Parser.ts';
import { _Hooks } from './Hooks.ts';
import { _Renderer } from './Renderer.ts';
import { _Tokenizer } from './Tokenizer.ts';
import { _TextRenderer } from './TextRenderer.ts';
import { escapeHtmlEntities } from './helpers.ts';
import type { MarkedExtension, MarkedOptions } from './MarkedOptions.ts';
import type { Token, Tokens, TokensList } from './Tokens.ts';

export type MaybePromise = void | Promise<void>;

type UnknownFunction = (...args: unknown[]) => unknown;
type GenericRendererFunction = (...args: unknown[]) => string | false;

export class Marked<ParserOutput = string, RendererOutput = string> {
  defaults = _getDefaults<ParserOutput, RendererOutput>();
  options = this.setOptions;

  parse = this.parseMarkdown(true);
  parseInline = this.parseMarkdown(false);

  Parser = _Parser<ParserOutput, RendererOutput>;
  Renderer = _Renderer<ParserOutput, RendererOutput>;
  TextRenderer = _TextRenderer<RendererOutput>;
  Lexer = _Lexer;
  Tokenizer = _Tokenizer<ParserOutput, RendererOutput>;
  Hooks = _Hooks<ParserOutput, RendererOutput>;

  constructor(...args: MarkedExtension<ParserOutput, RendererOutput>[]) {
    this.use(...args);
  }

  /**
   * Run callback for every token
   */
  walkTokens(tokens: Token[] | TokensList, callback: (token: Token) => MaybePromise | MaybePromise[]) {
    let values: MaybePromise[] = [];
    for (const token of tokens) {
      values = values.concat(callback.call(this, token));
      switch (token.type) {
        case 'table': {
          const tableToken = token as Tokens.Table;
          for (const cell of tableToken.header) {
            values = values.concat(this.walkTokens(cell.tokens, callback));
          }
          for (const row of tableToken.rows) {
            for (const cell of row) {
              values = values.concat(this.walkTokens(cell.tokens, callback));
            }
          }
          break;
        }
        case 'list': {
          const listToken = token as Tokens.List;
          values = values.concat(this.walkTokens(listToken.items, callback));
          break;
        }
        default: {
          const genericToken = token as Tokens.Generic;
          if (this.defaults.extensions?.childTokens?.[genericToken.type]) {
            this.defaults.extensions.childTokens[genericToken.type].forEach((childTokens) => {
              const tokens = genericToken[childTokens].flat(Infinity) as Token[] | TokensList;
              values = values.concat(this.walkTokens(tokens, callback));
            });
          } else if (genericToken.tokens) {
            values = values.concat(this.walkTokens(genericToken.tokens, callback));
          }
        }
      }
    }
    return values;
  }

  use(...args: MarkedExtension<ParserOutput, RendererOutput>[]) {
    const extensions: MarkedOptions<ParserOutput, RendererOutput>['extensions'] = this.defaults.extensions || { renderers: {}, childTokens: {} };

    args.forEach((pack) => {
      // copy options to new object
      const opts = { ...pack } as MarkedOptions<ParserOutput, RendererOutput>;

      // set async to true if it was set to true before
      opts.async = this.defaults.async || opts.async || false;

      // ==-- Parse "addon" extensions --== //
      if (pack.extensions) {
        pack.extensions.forEach((ext) => {
          if (!ext.name) {
            throw new Error('extension name required');
          }
          if ('renderer' in ext) { // Renderer extensions
            const prevRenderer = extensions.renderers[ext.name];
            if (prevRenderer) {
              // Replace extension with func to run new extension but fall back if false
              extensions.renderers[ext.name] = function(...args) {
                let ret = ext.renderer.apply(this, args);
                if (ret === false) {
                  ret = prevRenderer.apply(this, args);
                }
                return ret;
              };
            } else {
              extensions.renderers[ext.name] = ext.renderer;
            }
          }
          if ('tokenizer' in ext) { // Tokenizer Extensions
            if (!ext.level || (ext.level !== 'block' && ext.level !== 'inline')) {
              throw new Error("extension level must be 'block' or 'inline'");
            }
            const extLevel = extensions[ext.level];
            if (extLevel) {
              extLevel.unshift(ext.tokenizer);
            } else {
              extensions[ext.level] = [ext.tokenizer];
            }
            if (ext.start) { // Function to check for start of token
              if (ext.level === 'block') {
                if (extensions.startBlock) {
                  extensions.startBlock.push(ext.start);
                } else {
                  extensions.startBlock = [ext.start];
                }
              } else if (ext.level === 'inline') {
                if (extensions.startInline) {
                  extensions.startInline.push(ext.start);
                } else {
                  extensions.startInline = [ext.start];
                }
              }
            }
          }
          if ('childTokens' in ext && ext.childTokens) { // Child tokens to be visited by walkTokens
            extensions.childTokens[ext.name] = ext.childTokens;
          }
        });
        opts.extensions = extensions;
      }

      // ==-- Parse "overwrite" extensions --== //
      if (pack.renderer) {
        const renderer = this.defaults.renderer || new _Renderer<ParserOutput, RendererOutput>(this.defaults);
        for (const prop in pack.renderer) {
          if (!(prop in renderer)) {
            throw new Error(`renderer '${prop}' does not exist`);
          }
          if (['options', 'parser'].includes(prop)) {
            // ignore options property
            continue;
          }
          const rendererProp = prop as Exclude<keyof _Renderer<ParserOutput, RendererOutput>, 'options' | 'parser'>;
          const rendererFunc = pack.renderer[rendererProp] as GenericRendererFunction;
          const prevRenderer = renderer[rendererProp] as GenericRendererFunction;
          // Replace renderer with func to run extension, but fall back if false
          renderer[rendererProp] = (...args: unknown[]) => {
            let ret = rendererFunc.apply(renderer, args);
            if (ret === false) {
              ret = prevRenderer.apply(renderer, args);
            }
            return (ret || '') as RendererOutput;
          };
        }
        opts.renderer = renderer;
      }
      if (pack.tokenizer) {
        const tokenizer = this.defaults.tokenizer || new _Tokenizer<ParserOutput, RendererOutput>(this.defaults);
        for (const prop in pack.tokenizer) {
          if (!(prop in tokenizer)) {
            throw new Error(`tokenizer '${prop}' does not exist`);
          }
          if (['options', 'rules', 'lexer'].includes(prop)) {
            // ignore options, rules, and lexer properties
            continue;
          }
          const tokenizerProp = prop as Exclude<keyof _Tokenizer<ParserOutput, RendererOutput>, 'options' | 'rules' | 'lexer'>;
          const tokenizerFunc = pack.tokenizer[tokenizerProp] as UnknownFunction;
          const prevTokenizer = tokenizer[tokenizerProp] as UnknownFunction;
          // Replace tokenizer with func to run extension, but fall back if false
          // @ts-expect-error cannot type tokenizer function dynamically
          tokenizer[tokenizerProp] = (...args: unknown[]) => {
            let ret = tokenizerFunc.apply(tokenizer, args);
            if (ret === false) {
              ret = prevTokenizer.apply(tokenizer, args);
            }
            return ret;
          };
        }
        opts.tokenizer = tokenizer;
      }

      // ==-- Parse Hooks extensions --== //
      if (pack.hooks) {
        const hooks = this.defaults.hooks || new _Hooks<ParserOutput, RendererOutput>();
        for (const prop in pack.hooks) {
          if (!(prop in hooks)) {
            throw new Error(`hook '${prop}' does not exist`);
          }
          if (['options', 'block'].includes(prop)) {
            // ignore options and block properties
            continue;
          }
          const hooksProp = prop as Exclude<keyof _Hooks<ParserOutput, RendererOutput>, 'options' | 'block'>;
          const hooksFunc = pack.hooks[hooksProp] as UnknownFunction;
          const prevHook = hooks[hooksProp] as UnknownFunction;
          if (_Hooks.passThroughHooks.has(prop)) {
            // @ts-expect-error cannot type hook function dynamically
            hooks[hooksProp] = (arg: unknown) => {
              if (this.defaults.async && _Hooks.passThroughHooksRespectAsync.has(prop)) {
                return (async() => {
                  const ret = await hooksFunc.call(hooks, arg);
                  return prevHook.call(hooks, ret);
                })();
              }

              const ret = hooksFunc.call(hooks, arg);
              return prevHook.call(hooks, ret);
            };
          } else {
            // @ts-expect-error cannot type hook function dynamically
            hooks[hooksProp] = (...args: unknown[]) => {
              if (this.defaults.async) {
                return (async() => {
                  let ret = await hooksFunc.apply(hooks, args);
                  if (ret === false) {
                    ret = await prevHook.apply(hooks, args);
                  }
                  return ret;
                })();
              }

              let ret = hooksFunc.apply(hooks, args);
              if (ret === false) {
                ret = prevHook.apply(hooks, args);
              }
              return ret;
            };
          }
        }
        opts.hooks = hooks;
      }

      // ==-- Parse WalkTokens extensions --== //
      if (pack.walkTokens) {
        const walkTokens = this.defaults.walkTokens;
        const packWalktokens = pack.walkTokens;
        opts.walkTokens = function(token) {
          let values: MaybePromise[] = [];
          values.push(packWalktokens.call(this, token));
          if (walkTokens) {
            values = values.concat(walkTokens.call(this, token));
          }
          return values;
        };
      }

      this.defaults = { ...this.defaults, ...opts };
    });

    return this;
  }

  setOptions(opt: MarkedOptions<ParserOutput, RendererOutput>) {
    this.defaults = { ...this.defaults, ...opt };
    return this;
  }

  lexer(src: string, options?: MarkedOptions<ParserOutput, RendererOutput>) {
    return _Lexer.lex(src, options ?? this.defaults);
  }

  parser(tokens: Token[], options?: MarkedOptions<ParserOutput, RendererOutput>) {
    return _Parser.parse<ParserOutput, RendererOutput>(tokens, options ?? this.defaults);
  }

  private parseMarkdown(blockType: boolean) {
    type overloadedParse = {
      (src: string, options: MarkedOptions<ParserOutput, RendererOutput> & { async: true }): Promise<ParserOutput>;
      (src: string, options: MarkedOptions<ParserOutput, RendererOutput> & { async: false }): ParserOutput;
      (src: string, options?: MarkedOptions<ParserOutput, RendererOutput> | null): ParserOutput | Promise<ParserOutput>;
    };

    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    const parse: overloadedParse = (src: string, options?: MarkedOptions<ParserOutput, RendererOutput> | null): any => {
      const origOpt = { ...options };
      const opt = { ...this.defaults, ...origOpt };

      const throwError = this.onError(!!opt.silent, !!opt.async);

      // throw error if an extension set async to true but parse was called with async: false
      if (this.defaults.async === true && origOpt.async === false) {
        return throwError(new Error('marked(): The async option was set to true by an extension. Remove async: false from the parse options object to return a Promise.'));
      }

      // throw error in case of non string input
      if (typeof src === 'undefined' || src === null) {
        return throwError(new Error('marked(): input parameter is undefined or null'));
      }
      if (typeof src !== 'string') {
        return throwError(new Error('marked(): input parameter is of type '
          + Object.prototype.toString.call(src) + ', string expected'));
      }

      if (opt.hooks) {
        opt.hooks.options = opt;
        opt.hooks.block = blockType;
      }

      if (opt.async) {
        return (async() => {
          const processedSrc = opt.hooks ? await opt.hooks.preprocess(src) : src;
          const lexer = opt.hooks ? await opt.hooks.provideLexer(blockType) : (blockType ? _Lexer.lex : _Lexer.lexInline);
          const tokens = await lexer(processedSrc, opt);
          const processedTokens = opt.hooks ? await opt.hooks.processAllTokens(tokens) : tokens;
          if (opt.walkTokens) {
            await Promise.all(this.walkTokens(processedTokens, opt.walkTokens));
          }
          const parser = opt.hooks ? await opt.hooks.provideParser(blockType) : (blockType ? _Parser.parse : _Parser.parseInline);
          const html = await parser(processedTokens, opt);
          return opt.hooks ? await opt.hooks.postprocess(html) : html;
        })().catch(throwError);
      }

      try {
        if (opt.hooks) {
          src = opt.hooks.preprocess(src) as string;
        }
        const lexer = opt.hooks ? opt.hooks.provideLexer(blockType) : (blockType ? _Lexer.lex : _Lexer.lexInline);
        let tokens = lexer(src, opt);
        if (opt.hooks) {
          tokens = opt.hooks.processAllTokens(tokens);
        }
        if (opt.walkTokens) {
          this.walkTokens(tokens, opt.walkTokens);
        }
        const parser = opt.hooks ? opt.hooks.provideParser(blockType) : (blockType ? _Parser.parse : _Parser.parseInline);
        let html = parser(tokens, opt);
        if (opt.hooks) {
          html = opt.hooks.postprocess(html);
        }
        return html;
      } catch(e) {
        return throwError(e as Error);
      }
    };

    return parse;
  }

  private onError(silent: boolean, async: boolean) {
    return (e: Error): string | Promise<string> => {
      e.message += '\nPlease report this to https://github.com/markedjs/marked.';

      if (silent) {
        const msg = '<p>An error occurred:</p><pre>'
          + escapeHtmlEntities(e.message + '', true)
          + '</pre>';
        if (async) {
          return Promise.resolve(msg);
        }
        return msg;
      }

      if (async) {
        return Promise.reject(e);
      }
      throw e;
    };
  }
}
