/*************************************************************
 *
 *  Copyright (c) 2018-2026 The MathJax Consortium
 *
 *  Licensed under the Apache License, Version 2.0 (the "License");
 *  you may not use this file except in compliance with the License.
 *  You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 *  Unless required by applicable law or agreed to in writing, software
 *  distributed under the License is distributed on an "AS IS" BASIS,
 *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 *  See the License for the specific language governing permissions and
 *  limitations under the License.
 */

/**
 * @file  Implements component Package object for handling
 *                dynamic loading of components.
 *
 * @author dpvc@mathjax.org (Davide Cervone)
 */

import { CONFIG, Loader } from './loader.js';
import { context } from '../util/context.js';

/**
 * A map of package names to Package instances
 */
export type PackageMap = Map<string, Package>;

/**
 * An error class that includes the package name
 */
export class PackageError extends Error {
  public package: string;
  constructor(message: string, name: string) {
    super(message);
    this.package = name;
  }
}

/**
 * Types for ready() and failed() functions and for promises
 */
export type PackageReady = (name: string) => string | void;
export type PackageFailed = (message: PackageError) => void;
export type PackagePromise = (
  resolve: PackageReady,
  reject: PackageFailed
) => void;

/**
 * The configuration data for a package
 */
/* prettier-ignore */
export interface PackageConfig {
  ready?: PackageReady;                // Function to call when package is loaded successfully
  failed?: PackageFailed;              // Function to call when package fails to load
  checkReady?: () => Promise<void>;    // Function called to see if package is fully loaded
                                       //   (may cause additional packages to load, for example)
  extraLoads?: string[];               // Extra packages to load after this one
  rendererExtensions?: string[];       // Font extensions to load when renderer changes
}

/**
 * The Package class for handling individual components
 */
export class Package {
  /**
   * The set of packages being used
   */
  public static packages: PackageMap = new Map();

  /**
   * The package name
   */
  public name: string;

  /**
   * True when the package has been loaded successfully
   */
  public isLoaded: boolean = false;

  /**
   * A promise that resolves when the package is loaded successfully and rejects when it fails to load
   */
  public promise: Promise<string>;

  /**
   * The result of loading a module via the custom loader
   */
  public result: any = {};

  /**
   * True when the package is being loaded but hasn't yet finished loading
   */
  protected isLoading: boolean = false;

  /**
   * True if the package has failed to load
   */
  public hasFailed: boolean = false;

  /**
   * True if this package should be loaded automatically (e.g., it was created in reference
   *   to a MathJax.loader.ready() call when the package hasn't been requested to load)
   */
  protected noLoad: boolean;

  /**
   * The function that resolves the package's promise
   */
  protected resolve: PackageReady;

  /**
   * The function that rejects the package's promise
   */
  protected reject: PackageFailed;

  /**
   * The packages that require this one
   */
  protected dependents: Package[] = [];

  /**
   * The packages that this one depends on
   */
  protected dependencies: Package[] = [];

  /**
   * The number of dependencies that haven't yet been loaded
   */
  protected dependencyCount: number = 0;

  /**
   * The sub-packages that this one provides
   */
  protected provided: Package[] = [];

  /**
   * @returns {boolean}  True when the package can be loaded (i.e., its dependencies are all loaded,
   *                    it is allowed to be loaded, isn't already loading, and hasn't failed to load
   *                    in the past)
   */
  get canLoad(): boolean {
    return (
      this.dependencyCount === 0 &&
      !this.noLoad &&
      !this.isLoading &&
      !this.hasFailed
    );
  }

  /**
   * @param {string} name The package configuration to be loaded
   * @returns {Promise<void>} A promise for when extra files and checkReady have been fulfilled
   */
  public static loadPromise(name: string): Promise<void> {
    const config = (CONFIG[name] || {}) as PackageConfig;
    const promise = config.extraLoads
      ? Loader.load(...config.extraLoads)
      : Promise.resolve();
    const checkReady = config.checkReady || (() => Promise.resolve());
    return promise.then(() => checkReady()) as Promise<void>;
  }

  /**
   * Compute the path for a package using the loader's path filters
   *
   * @param {string} name            The name of the package to resolve
   * @param {boolean} addExtension   True if .js should be added automatically
   * @returns {string}                The path (file or URL) for this package
   */
  public static resolvePath(
    name: string,
    addExtension: boolean = true
  ): string {
    const data = { name, original: name, addExtension };
    Loader.pathFilters.execute(data);
    return data.name;
  }

  /**
   * Attempt to load all packages that are ready to be loaded
   * (i.e., that have no unloaded dependencies, and that haven't
   *  already been loaded, and that aren't in process of being
   *  loaded, and that aren't marked as noLoad).
   */
  public static loadAll() {
    for (const extension of this.packages.values()) {
      if (extension.canLoad) {
        extension.load();
      }
    }
  }

  /**
   * @param {string} name        The name of the package
   * @param {boolean} noLoad     True when the package is just for reference, not loading
   */
  constructor(name: string, noLoad: boolean = false) {
    this.name = name;
    this.noLoad = noLoad;
    Package.packages.set(name, this);
    this.promise = this.makePromise(this.makeDependencies());
  }

  /**
   * @returns {Promise<string>[]}   The array of promises that must be resolved before this package
   *                                 can be loaded
   */
  protected makeDependencies(): Promise<string>[] {
    const promises = [] as Promise<string>[];
    const map = Package.packages;
    const noLoad = this.noLoad;
    const name = this.name;
    //
    //  Get the dependencies for this package
    //
    const dependencies = [] as string[];
    if (Object.hasOwn(CONFIG.dependencies, name)) {
      dependencies.push(...CONFIG.dependencies[name]);
    } else if (name !== 'core') {
      dependencies.push('core'); //  Add 'core' dependency by default
    }
    //
    //  Add all the dependencies (creating them, if needed)
    //    and record the promises of unloaded ones
    //
    for (const dependent of dependencies) {
      const extension = map.get(dependent) || new Package(dependent, noLoad);
      if (!this.dependencies.includes(extension)) {
        extension.addDependent(this, noLoad);
        this.dependencies.push(extension);
        if (!extension.isLoaded) {
          this.dependencyCount++;
          promises.push(extension.promise);
        }
      }
    }
    //
    //  Return the collected promises
    //
    return promises;
  }

  /**
   * @param {Promise<string>[]} promises  The array or promises that must be resolved before
   *                                        this package can load
   * @returns {Promise<string>} The promise indicating when this file is loaded
   */
  protected makePromise(promises: Promise<string>[]): Promise<string> {
    //
    //  Make a promise and save its resolve/reject functions
    //
    let promise = new Promise<string>(((resolve, reject) => {
      this.resolve = resolve;
      this.reject = reject;
    }) as PackagePromise);
    //
    //  If there is a ready() function in the configuration for this package,
    //    Add running that to the promise
    //
    const config = (CONFIG[this.name] || {}) as PackageConfig;
    if (config.ready) {
      promise = promise.then((_name: string) =>
        config.ready(this.name)
      ) as Promise<string>;
    }
    //
    //  If there are promises for dependencies,
    //    Add the one for loading this package and create a promise for all of them
    //      (That way, if any of them fail to load, our promise will reject automatically)
    //
    if (promises.length) {
      promises.push(promise);
      promise = Promise.all(promises).then((names: string[]) =>
        names.join(', ')
      );
    }
    //
    //  If there is a failed() function in the configuration for this package,
    //    Add a catch to handle the error
    //
    if (config.failed) {
      promise.catch((message: string) =>
        config.failed(new PackageError(message, this.name))
      );
    }
    //
    //  Return the promise that represents when this file is loaded
    //
    return promise;
  }

  /**
   * Attempt to load this package
   */
  public load() {
    if (!this.isLoaded && !this.isLoading && !this.noLoad) {
      this.isLoading = true;
      const url = Package.resolvePath(this.name);
      if (CONFIG.require) {
        this.loadCustom(url);
      } else {
        this.loadScript(url);
      }
    }
  }

  /**
   * Load using a custom require method (usually the one from node.js)
   *
   * @param {string} url The URL to load from
   */
  protected loadCustom(url: string) {
    try {
      const result = CONFIG.require(url);
      if (result instanceof Promise) {
        result
          .then((result) => (this.result = result))
          .then(() => this.checkLoad())
          .catch((err) =>
            this.failed('Can\'t load "' + url + '"\n' + err.message.trim())
          );
      } else {
        this.result = result;
        this.checkLoad();
      }
    } catch (err) {
      this.failed(err.message);
    }
  }

  /**
   * Load in a browser by inserting a script to load the proper URL
   *
   * @param {string} url The URL to load from
   */
  protected loadScript(url: string) {
    const script = context.document.createElement('script');
    script.src = url;
    script.charset = 'UTF-8';
    script.onload = (_event) => this.checkLoad();
    script.onerror = (_event) => this.failed('Can\'t load "' + url + '"');
    // FIXME: Should there be a timeout failure as well?
    context.document.head.appendChild(script);
  }

  /**
   * Called when the package is loaded.
   *
   * Mark it as loaded, and tell its dependents that this package
   *   has been loaded (may cause dependents to load themselves).
   *   Mark any provided packages as loaded.
   * Resolve the promise that says this package is loaded.
   */
  public loaded() {
    this.isLoaded = true;
    this.isLoading = false;
    for (const dependent of this.dependents) {
      dependent.requirementSatisfied();
    }
    for (const provided of this.provided) {
      provided.loaded();
    }
    this.resolve(this.name);
  }

  /**
   * Called when the package fails to load for some reason
   *
   * Mark it as failed to load
   * Reject the promise for this package with an error
   *
   * @param {string} message   The error message for a load failure
   */
  protected failed(message: string) {
    this.hasFailed = true;
    this.isLoading = false;
    this.reject(new PackageError(message, this.name));
  }

  /**
   * Check if a package is really ready to be marked as loaded
   * (When it is loaded, it may set its own checkReady() function
   *  or extraLoads array as a means of loading additional packages.
   *  E.g., an output jax may load a font package, dependent on its
   *  configuration.)
   *
   *  The configuration's checkReady() function returns a promise
   *  that allows the loader to wait for addition actions to finish
   *  before marking the file as loaded (or failing to load).
   */
  protected checkLoad() {
    Package.loadPromise(this.name)
      .then(() => this.loaded())
      .catch((message) => this.failed(message));
  }

  /**
   * This is called when a dependency loads.
   *
   * Decrease the dependency count, and try to load this package
   * when the dependencies are all loaded.
   */
  public requirementSatisfied() {
    if (this.dependencyCount) {
      this.dependencyCount--;
      if (this.canLoad) {
        this.load();
      }
    }
  }

  /**
   * @param {string[]} names    The names of the packages that this package provides
   */
  public provides(names: string[] = []) {
    for (const name of names) {
      let provided = Package.packages.get(name);
      if (!provided) {
        if (!CONFIG.dependencies[name]) {
          CONFIG.dependencies[name] = [];
        }
        CONFIG.dependencies[name].push(name);
        provided = new Package(name, true);
        provided.isLoading = true;
      }
      this.provided.push(provided);
    }
  }

  /**
   * Add a package as a dependent, and if it is not just for reference,
   *   check if we need to change our noLoad status.
   *
   * @param {Package} extension   The package to add as a dependent
   * @param {boolean} noLoad      The noLoad status of the dependent
   */
  public addDependent(extension: Package, noLoad: boolean) {
    this.dependents.push(extension);
    if (!noLoad) {
      this.checkNoLoad();
    }
  }

  /**
   * If this package is marked as noLoad, change that and check all
   *   our dependencies to see if they need to change their noLoad
   *   status as well.
   *
   *  I.e., if there are dependencies that were set up for reference
   *  and a leaf node needs to be loaded, make sure all parent nodes
   *  are marked as needing to be loaded as well.
   */
  public checkNoLoad() {
    if (this.noLoad) {
      this.noLoad = false;
      for (const dependency of this.dependencies) {
        dependency.checkNoLoad();
      }
    }
  }
}
