// Copyright IBM Corp. and LoopBack contributors 2017,2020. All Rights Reserved.
// Node module: @loopback/core
// This file is licensed under the MIT License.
// License text available at https://opensource.org/licenses/MIT

import {
  Binding,
  BindingFromClassOptions,
  BindingScope,
  Constructor,
  Context,
  createBindingFromClass,
  DynamicValueProviderClass,
  generateUniqueId,
  Interceptor,
  InterceptorBindingOptions,
  JSONObject,
  Provider,
  registerInterceptor,
  ValueOrPromise,
} from '@loopback/context';
import assert from 'assert';
import debugFactory from 'debug';
import {once} from 'events';
import {Component, mountComponent} from './component';
import {CoreBindings, CoreTags} from './keys';
import {
  asLifeCycleObserver,
  isLifeCycleObserverClass,
  LifeCycleObserver,
} from './lifecycle';
import {LifeCycleObserverRegistry} from './lifecycle-registry';
import {Server} from './server';
import {createServiceBinding, ServiceOptions} from './service';
const debug = debugFactory('loopback:core:application');
const debugShutdown = debugFactory('loopback:core:application:shutdown');
const debugWarning = debugFactory('loopback:core:application:warning');

/**
 * A helper function to build constructor args for `Context`
 * @param configOrParent - Application config or parent context
 * @param parent - Parent context if the first arg is application config
 */
function buildConstructorArgs(
  configOrParent?: ApplicationConfig | Context,
  parent?: Context,
) {
  let name: string | undefined;
  let parentCtx: Context | undefined;

  if (configOrParent instanceof Context) {
    parentCtx = configOrParent;
    name = undefined;
  } else {
    parentCtx = parent;
    name = configOrParent?.name;
  }
  return [parentCtx, name];
}

/**
 * Application is the container for various types of artifacts, such as
 * components, servers, controllers, repositories, datasources, connectors,
 * and models.
 */
export class Application extends Context implements LifeCycleObserver {
  public readonly options: ApplicationConfig;

  /**
   * A flag to indicate that the application is being shut down
   */
  private _isShuttingDown = false;
  private _shutdownOptions: ShutdownOptions;
  private _signalListener: (signal: string) => Promise<void>;

  private _initialized = false;

  /**
   * State of the application
   */
  private _state = 'created';

  /**
   * Get the state of the application. The initial state is `created` and it can
   * transition as follows by `start` and `stop`:
   *
   * 1. start
   *   - !started -> starting -> started
   *   - started -> started (no-op)
   * 2. stop
   *   - (started | initialized) -> stopping -> stopped
   *   - ! (started || initialized) -> stopped (no-op)
   *
   * Two types of states are expected:
   * - stable, such as `started` and `stopped`
   * - in process, such as `booting` and `starting`
   *
   * Operations such as `start` and `stop` can only be called at a stable state.
   * The logic should immediately set the state to a new one indicating work in
   * process, such as `starting` and `stopping`.
   */
  public get state() {
    return this._state;
  }

  /**
   * Create an application with the given parent context
   * @param parent - Parent context
   */
  constructor(parent: Context);
  /**
   * Create an application with the given configuration and parent context
   * @param config - Application configuration
   * @param parent - Parent context
   */
  constructor(config?: ApplicationConfig, parent?: Context);

  constructor(configOrParent?: ApplicationConfig | Context, parent?: Context) {
    // super() has to be first statement for a constructor
    super(...buildConstructorArgs(configOrParent, parent));
    this.scope = BindingScope.APPLICATION;

    this.options =
      configOrParent instanceof Context ? {} : (configOrParent ?? {});

    // Configure debug
    this._debug = debug;

    // Bind the life cycle observer registry
    this.bind(CoreBindings.LIFE_CYCLE_OBSERVER_REGISTRY)
      .toClass(LifeCycleObserverRegistry)
      .inScope(BindingScope.SINGLETON);
    // Bind to self to allow injection of application context in other modules.
    this.bind(CoreBindings.APPLICATION_INSTANCE).to(this);
    // Make options available to other modules as well.
    this.bind(CoreBindings.APPLICATION_CONFIG).to(this.options);

    // Also configure the application instance to allow `@config`
    this.configure(CoreBindings.APPLICATION_INSTANCE).toAlias(
      CoreBindings.APPLICATION_CONFIG,
    );

    this._shutdownOptions = {signals: ['SIGTERM'], ...this.options.shutdown};
  }

  /**
   * Register a controller class with this application.
   *
   * @param controllerCtor - The controller class
   * (constructor function).
   * @param name - Optional controller name, default to the class name
   * @returns The newly created binding, you can use the reference to
   * further modify the binding, e.g. lock the value to prevent further
   * modifications.
   *
   * @example
   * ```ts
   * class MyController {
   * }
   * app.controller(MyController).lock();
   * ```
   */
  controller<T>(
    controllerCtor: ControllerClass<T>,
    nameOrOptions?: string | BindingFromClassOptions,
  ): Binding<T> {
    this.debug('Adding controller %s', nameOrOptions ?? controllerCtor.name);
    const binding = createBindingFromClass(controllerCtor, {
      namespace: CoreBindings.CONTROLLERS,
      type: CoreTags.CONTROLLER,
      defaultScope: BindingScope.TRANSIENT,
      ...toOptions(nameOrOptions),
    });
    this.add(binding);
    return binding;
  }

  /**
   * Bind a Server constructor to the Application's master context.
   * Each server constructor added in this way must provide a unique prefix
   * to prevent binding overlap.
   *
   * @example
   * ```ts
   * app.server(RestServer);
   * // This server constructor will be bound under "servers.RestServer".
   * app.server(RestServer, "v1API");
   * // This server instance will be bound under "servers.v1API".
   * ```
   *
   * @param server - The server constructor.
   * @param nameOrOptions - Optional override for name or options.
   * @returns Binding for the server class
   *
   */
  public server<T extends Server>(
    ctor: Constructor<T>,
    nameOrOptions?: string | BindingFromClassOptions,
  ): Binding<T> {
    this.debug('Adding server %s', nameOrOptions ?? ctor.name);
    const binding = createBindingFromClass(ctor, {
      namespace: CoreBindings.SERVERS,
      type: CoreTags.SERVER,
      defaultScope: BindingScope.SINGLETON,
      ...toOptions(nameOrOptions),
    }).apply(asLifeCycleObserver);
    this.add(binding);
    return binding;
  }

  /**
   * Bind an array of Server constructors to the Application's master
   * context.
   * Each server added in this way will automatically be named based on the
   * class constructor name with the "servers." prefix.
   *
   * @remarks
   * If you wish to control the binding keys for particular server instances,
   * use the app.server function instead.
   * ```ts
   * app.servers([
   *  RestServer,
   *  GRPCServer,
   * ]);
   * // Creates a binding for "servers.RestServer" and a binding for
   * // "servers.GRPCServer";
   * ```
   *
   * @param ctors - An array of Server constructors.
   * @returns An array of bindings for the registered server classes
   *
   */
  public servers<T extends Server>(ctors: Constructor<T>[]): Binding[] {
    return ctors.map(ctor => this.server(ctor));
  }

  /**
   * Retrieve the singleton instance for a bound server.
   *
   * @typeParam T - Server type
   * @param ctor - The constructor that was used to make the
   * binding.
   * @returns A Promise of server instance
   *
   */
  public async getServer<T extends Server>(
    target: Constructor<T> | string,
  ): Promise<T> {
    let key: string;
    // instanceof check not reliable for string.
    if (typeof target === 'string') {
      key = `${CoreBindings.SERVERS}.${target}`;
    } else {
      const ctor = target as Constructor<T>;
      key = `${CoreBindings.SERVERS}.${ctor.name}`;
    }
    return this.get<T>(key);
  }

  /**
   * Assert there is no other operation is in progress, i.e., the state is not
   * `*ing`, such as `starting` or `stopping`.
   *
   * @param op - The operation name, such as 'boot', 'start', or 'stop'
   */
  protected assertNotInProcess(op: string) {
    assert(
      !this._state.endsWith('ing'),
      `Cannot ${op} the application as it is ${this._state}.`,
    );
  }

  /**
   * Assert current state of the application to be one of the expected values
   * @param op - The operation name, such as 'boot', 'start', or 'stop'
   * @param states - Valid states
   */
  protected assertInStates(op: string, ...states: string[]) {
    assert(
      states.includes(this._state),
      `Cannot ${op} the application as it is ${this._state}. Valid states are ${states}.`,
    );
  }

  /**
   * Transition the application to a new state and emit an event
   * @param state - The new state
   */
  protected setState(state: string) {
    const oldState = this._state;
    this._state = state;
    if (oldState !== state) {
      this.emit('stateChanged', {from: oldState, to: this._state});
      this.emit(state);
    }
  }

  protected async awaitState(state: string) {
    await once(this, state);
  }

  /**
   * Initialize the application, and all of its registered observers. The
   * application state is checked to ensure the integrity of `initialize`.
   *
   * If the application is already initialized, no operation is performed.
   *
   * This method is automatically invoked by `start()` if the application is not
   * initialized.
   */
  public async init(): Promise<void> {
    if (this._initialized) return;
    if (this._state === 'initializing') return this.awaitState('initialized');
    this.assertNotInProcess('initialize');
    this.setState('initializing');

    const registry = await this.getLifeCycleObserverRegistry();
    await registry.init();
    this._initialized = true;
    this.setState('initialized');
  }

  /**
   * Register a function to be called when the application initializes.
   *
   * This is a shortcut for adding a binding for a LifeCycleObserver
   * implementing a `init()` method.
   *
   * @param fn The function to invoke, it can be synchronous (returning `void`)
   * or asynchronous (returning `Promise<void>`).
   * @returns The LifeCycleObserver binding created.
   */
  public onInit(fn: () => ValueOrPromise<void>): Binding<LifeCycleObserver> {
    const key = [
      CoreBindings.LIFE_CYCLE_OBSERVERS,
      fn.name || '<onInit>',
      generateUniqueId(),
    ].join('.');

    return this.bind<LifeCycleObserver>(key)
      .to({init: fn})
      .apply(asLifeCycleObserver);
  }

  /**
   * Start the application, and all of its registered observers. The application
   * state is checked to ensure the integrity of `start`.
   *
   * If the application is not initialized, it calls first `init()` to
   * initialize the application. This only happens if `start()` is called for
   * the first time.
   *
   * If the application is already started, no operation is performed.
   */
  public async start(): Promise<void> {
    if (!this._initialized) await this.init();
    if (this._state === 'starting') return this.awaitState('started');
    this.assertNotInProcess('start');
    // No-op if it's started
    if (this._state === 'started') return;
    this.setState('starting');
    this.setupShutdown();

    const registry = await this.getLifeCycleObserverRegistry();
    await registry.start();
    this.setState('started');
  }

  /**
   * Register a function to be called when the application starts.
   *
   * This is a shortcut for adding a binding for a LifeCycleObserver
   * implementing a `start()` method.
   *
   * @param fn The function to invoke, it can be synchronous (returning `void`)
   * or asynchronous (returning `Promise<void>`).
   * @returns The LifeCycleObserver binding created.
   */
  public onStart(fn: () => ValueOrPromise<void>): Binding<LifeCycleObserver> {
    const key = [
      CoreBindings.LIFE_CYCLE_OBSERVERS,
      fn.name || '<onStart>',
      generateUniqueId(),
    ].join('.');

    return this.bind<LifeCycleObserver>(key)
      .to({start: fn})
      .apply(asLifeCycleObserver);
  }

  /**
   * Stop the application instance and all of its registered observers. The
   * application state is checked to ensure the integrity of `stop`.
   *
   * If the application is already stopped or not started, no operation is
   * performed.
   */
  public async stop(): Promise<void> {
    if (this._state === 'stopping') return this.awaitState('stopped');
    this.assertNotInProcess('stop');
    // No-op if it's created or stopped
    if (this._state !== 'started' && this._state !== 'initialized') return;
    this.setState('stopping');
    if (!this._isShuttingDown) {
      // Explicit stop is called, let's remove signal listeners to avoid
      // memory leak and max listener warning
      this.removeSignalListener();
    }
    const registry = await this.getLifeCycleObserverRegistry();
    await registry.stop();
    this.setState('stopped');
  }

  /**
   * Register a function to be called when the application starts.
   *
   * This is a shortcut for adding a binding for a LifeCycleObserver
   * implementing a `start()` method.
   *
   * @param fn The function to invoke, it can be synchronous (returning `void`)
   * or asynchronous (returning `Promise<void>`).
   * @returns The LifeCycleObserver binding created.
   */
  public onStop(fn: () => ValueOrPromise<void>): Binding<LifeCycleObserver> {
    const key = [
      CoreBindings.LIFE_CYCLE_OBSERVERS,
      fn.name || '<onStop>',
      generateUniqueId(),
    ].join('.');
    return this.bind<LifeCycleObserver>(key)
      .to({stop: fn})
      .apply(asLifeCycleObserver);
  }

  private async getLifeCycleObserverRegistry() {
    return this.get(CoreBindings.LIFE_CYCLE_OBSERVER_REGISTRY);
  }

  /**
   * Add a component to this application and register extensions such as
   * controllers, providers, and servers from the component.
   *
   * @param componentCtor - The component class to add.
   * @param nameOrOptions - Optional component name or options, default to the
   * class name
   *
   * @example
   * ```ts
   *
   * export class ProductComponent {
   *   controllers = [ProductController];
   *   repositories = [ProductRepo, UserRepo];
   *   providers = {
   *     [AUTHENTICATION_STRATEGY]: AuthStrategy,
   *     [AUTHORIZATION_ROLE]: Role,
   *   };
   * };
   *
   * app.component(ProductComponent);
   * ```
   */
  public component<T extends Component = Component>(
    componentCtor: Constructor<T>,
    nameOrOptions?: string | BindingFromClassOptions,
  ) {
    this.debug('Adding component: %s', nameOrOptions ?? componentCtor.name);
    const binding = createBindingFromClass(componentCtor, {
      namespace: CoreBindings.COMPONENTS,
      type: CoreTags.COMPONENT,
      defaultScope: BindingScope.SINGLETON,
      ...toOptions(nameOrOptions),
    });
    // Check if the component is already bound
    const found = this.registry.get(binding.key);
    if (found?.valueConstructor === binding.valueConstructor) {
      return binding;
    }
    if (isLifeCycleObserverClass(componentCtor)) {
      binding.apply(asLifeCycleObserver);
    }
    this.add(binding);
    // Assuming components can be synchronously instantiated
    const instance = this.getSync<Component>(binding.key);
    mountComponent(this, instance);
    return binding;
  }

  /**
   * Set application metadata. `@loopback/boot` calls this method to populate
   * the metadata from `package.json`.
   *
   * @param metadata - Application metadata
   */
  public setMetadata(metadata: ApplicationMetadata) {
    this.bind(CoreBindings.APPLICATION_METADATA).to(metadata);
  }

  /**
   * Register a life cycle observer class
   * @param ctor - A class implements LifeCycleObserver
   * @param nameOrOptions - Optional name or options for the life cycle observer
   */
  public lifeCycleObserver<T extends LifeCycleObserver>(
    ctor: Constructor<T>,
    nameOrOptions?: string | BindingFromClassOptions,
  ): Binding<T> {
    this.debug('Adding life cycle observer %s', nameOrOptions ?? ctor.name);
    const binding = createBindingFromClass(ctor, {
      namespace: CoreBindings.LIFE_CYCLE_OBSERVERS,
      type: CoreTags.LIFE_CYCLE_OBSERVER,
      defaultScope: BindingScope.SINGLETON,
      ...toOptions(nameOrOptions),
    }).apply(asLifeCycleObserver);
    this.add(binding);
    return binding;
  }

  /**
   * Add a service to this application.
   *
   * @param cls - The service or provider class
   *
   * @example
   *
   * ```ts
   * // Define a class to be bound via ctx.toClass()
   * @injectable({scope: BindingScope.SINGLETON})
   * export class LogService {
   *   log(msg: string) {
   *     console.log(msg);
   *   }
   * }
   *
   * // Define a class to be bound via ctx.toProvider()
   * import {v4 as uuidv4} from 'uuid';
   * export class UuidProvider implements Provider<string> {
   *   value() {
   *     return uuidv4();
   *   }
   * }
   *
   * // Register the local services
   * app.service(LogService);
   * app.service(UuidProvider, 'uuid');
   *
   * export class MyController {
   *   constructor(
   *     @inject('services.uuid') private uuid: string,
   *     @inject('services.LogService') private log: LogService,
   *   ) {
   *   }
   *
   *   greet(name: string) {
   *     this.log(`Greet request ${this.uuid} received: ${name}`);
   *     return `${this.uuid}: ${name}`;
   *   }
   * }
   * ```
   */
  public service<S>(
    cls: ServiceOrProviderClass<S>,
    nameOrOptions?: string | ServiceOptions,
  ): Binding<S> {
    const options = toOptions(nameOrOptions);
    const binding = createServiceBinding(cls, options);
    this.add(binding);
    return binding;
  }

  /**
   * Register an interceptor
   * @param interceptor - An interceptor function or provider class
   * @param nameOrOptions - Binding name or options
   */
  public interceptor(
    interceptor: Interceptor | Constructor<Provider<Interceptor>>,
    nameOrOptions?: string | InterceptorBindingOptions,
  ) {
    const options = toOptions(nameOrOptions);
    return registerInterceptor(this, interceptor, options);
  }

  /**
   * Set up signals that are captured to shutdown the application
   */
  protected setupShutdown() {
    if (this._signalListener != null) {
      this.registerSignalListener();
      return this._signalListener;
    }
    const gracePeriod = this._shutdownOptions.gracePeriod;
    this._signalListener = async (signal: string) => {
      const kill = () => {
        this.removeSignalListener();
        process.kill(process.pid, signal);
      };
      debugShutdown(
        '[%s] Signal %s received for process %d',
        this.name,
        signal,
        process.pid,
      );
      if (!this._isShuttingDown) {
        this._isShuttingDown = true;
        let timer;
        if (typeof gracePeriod === 'number' && !isNaN(gracePeriod)) {
          timer = setTimeout(kill, gracePeriod);
        }
        try {
          await this.stop();
        } finally {
          if (timer != null) clearTimeout(timer);
          kill();
        }
      }
    };
    this.registerSignalListener();
    return this._signalListener;
  }

  private registerSignalListener() {
    const {signals = []} = this._shutdownOptions;
    debugShutdown(
      '[%s] Registering signal listeners on the process %d',
      this.name,
      process.pid,
      signals,
    );
    signals.forEach(sig => {
      if (process.getMaxListeners() <= process.listenerCount(sig)) {
        if (debugWarning.enabled) {
          debugWarning(
            '[%s] %d %s listeners are added to process %d',
            this.name,
            process.listenerCount(sig),
            sig,
            process.pid,
            new Error('MaxListenersExceededWarning'),
          );
        }
      }
      // eslint-disable-next-line @typescript-eslint/no-misused-promises
      process.on(sig, this._signalListener);
    });
  }

  private removeSignalListener() {
    if (this._signalListener == null) return;
    const {signals = []} = this._shutdownOptions;
    debugShutdown(
      '[%s] Removing signal listeners on the process %d',
      this.name,
      process.pid,
      signals,
    );
    signals.forEach(sig =>
      // eslint-disable-next-line @typescript-eslint/no-misused-promises
      process.removeListener(sig, this._signalListener),
    );
  }
}

/**
 * Normalize name or options to `BindingFromClassOptions`
 * @param nameOrOptions - Name or options for binding from class
 */
function toOptions(nameOrOptions?: string | BindingFromClassOptions) {
  if (typeof nameOrOptions === 'string') {
    return {name: nameOrOptions};
  }
  return nameOrOptions ?? {};
}

/**
 * Options to set up application shutdown
 */
export type ShutdownOptions = {
  /**
   * An array of signals to be trapped for graceful shutdown
   */
  signals?: NodeJS.Signals[];
  /**
   * Period in milliseconds to wait for the grace shutdown to finish before
   * exiting the process
   */
  gracePeriod?: number;
};

/**
 * Configuration for application
 */
export interface ApplicationConfig {
  /**
   * Name of the application context
   */
  name?: string;
  /**
   * Configuration for signals that shut down the application
   */
  shutdown?: ShutdownOptions;

  /**
   * Other properties
   */
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  [prop: string]: any;
}

// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type ControllerClass<T = any> = Constructor<T>;

// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type ServiceOrProviderClass<T = any> =
  | Constructor<T | Provider<T>>
  | DynamicValueProviderClass<T>;

/**
 * Type description for `package.json`
 */
export interface ApplicationMetadata extends JSONObject {
  name: string;
  version: string;
  description: string;
}
