/*
 * Copyright (C) 2019-2022 Open StApps
 * This program is free software: you can redistribute it and/or modify it
 * under the terms of the GNU General Public License as published by the Free
 * Software Foundation, version 3.
 *
 * This program is distributed in the hope that it will be useful, but WITHOUT
 * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
 * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
 * more details.
 *
 * You should have received a copy of the GNU General Public License along with
 * this program. If not, see <https://www.gnu.org/licenses/>.
 */
import {Converter} from '@openstapps/core-tools';
import {Logger} from '@openstapps/logger';
import bodyParser from 'body-parser';
import express from 'express';
import * as http from 'http';
import * as http2 from 'http2';
import {JSONSchema7} from 'json-schema';
import morgan from 'morgan';
import ErrnoException = NodeJS.ErrnoException;

/**
 * The Plugin for creating HTTP backend plugins
 *
 * It contains a lot of the boilerplate for creating plugins, and thus simplifies the creation of such.
 * To create your own plugin, you need to extend this class and implement the [[Plugin.onRouteInvoke]] method
 */
export abstract class Plugin {
  /**
   * The express instance
   */
  private readonly app = express();

  /**
   * The HTTP server
   */
  private readonly server: http.Server;

  /**
   * Whether the server is active or not
   *
   * When active is false, it will return 404 on all routes.
   */
  protected active = false;

  /**
   * The full URL of the plugin
   *
   * The full URL of the plugin consists out of URL:PORT
   */
  public get fullUrl() {
    return `${this.url}:${this.port}`;
  }

  /**
   * The port on which the plugin will listen on
   */
  public port: string | number | false;

  /**
   * The schema of the request interfaces defined by the user
   */
  public readonly requestSchema: JSONSchema7 = {};

  /**
   * The schema of the response interfaces defined by the user
   */
  public readonly responseSchema: JSONSchema7 = {};

  /**
   * Normalize a port into a number, string, or false.
   * @param value the port you want to normalize
   */
  protected static normalizePort(value: string) {
    const portNumber = Number.parseInt(value, 10);

    /* istanbul ignore next */
    if (Number.isNaN(portNumber)) {
      // named pipe
      /* istanbul ignore next */
      return value;
    }

    /* istanbul ignore next */
    if (portNumber >= 0) {
      // port number
      return portNumber;
    }

    /* istanbul ignore next */
    return false;
  }

  /**
   * Create an instance of the PluginClient
   *
   * Don't forget to call [[PluginClient.registerPlugin]]!
   * Refer to the examples for how to use the schemas. TODO: examples
   * @param port The port of the plugin
   * @param name The name of the plugin
   * @param url The url of the plugin without the port or anything else, for example `http://localhost`
   * @param route The desired route that will be registered in the backend
   * @param backendUrl The url of the backend
   * @param converter If you want to use an already existing converter, you can pass it here
   * @param requestName the name of the request schema
   * @param responseName the name of the response schema
   * @param version the version. You should retrieve it from the package.json
   */
  constructor(
    port: number,
    public name: string,
    public url: string,
    public route: string,
    protected backendUrl: string,
    converter: Converter,
    requestName: string,
    responseName: string,
    version: string,
  ) {
    this.app.use(bodyParser.json());
    this.port = Plugin.normalizePort(
      /* istanbul ignore next */
      process.env.PORT === undefined ? port.toString() : process.env.PORT,
    );
    this.app.set('port', this.port);

    // setup express
    this.server = http.createServer(this.app);
    this.server.listen(this.port);
    /* istanbul ignore next */
    this.server.on('error', error => {
      /* istanbul ignore next */
      this.onError(error);
    });
    this.server.on('listening', () => {
      this.onListening();
    });

    this.requestSchema = converter.getSchema(requestName, version);
    this.responseSchema = converter.getSchema(responseName, version);

    this.app.use(morgan('dev'));

    this.app.set('env', process.env.NODE_ENV);

    this.app.all('*', async (request: express.Request, response: express.Response) => {
      if (this.active) {
        await this.onRouteInvoke(request, response);
      } else {
        response.status(http2.constants.HTTP_STATUS_NOT_FOUND);
        response.send();
      }
    });
  }

  /**
   * Event listener for HTTP server "error" event.
   * @param error The error that occurred
   */

  /* istanbul ignore next */
  private onError(error: ErrnoException) {
    if (error.syscall !== 'listen') {
      throw error;
    }

    const bind = typeof this.port === 'string' ? `Pipe ${this.port}` : `Port ${this.port}`;

    // handle specific listen errors with friendly messages
    switch (error.code) {
      case 'EACCES': {
        // tslint:disable-next-line:no-floating-promises
        Logger.error(`${bind} requires elevated privileges`);
        process.exit(1);
        break;
      }
      case 'EADDRINUSE': {
        // tslint:disable-next-line:no-floating-promises
        Logger.error(`${bind} is already in use`);
        process.exit(1);
        break;
      }
      default: {
        throw error;
      }
    }
  }

  /**
   * Event listener for HTTP server "listening" event.
   */
  private onListening() {
    const addr = this.server.address();
    /* istanbul ignore next */
    const bind = typeof addr === 'string' ? `pipe ${addr}` : addr === null ? 'null' : `port ${addr.port}`;
    Logger.ok(`Listening on ${bind}`);
  }

  /**
   * When the route gets invoked
   *
   * Override this method for your own plugin
   * @param request An express Request from the backend
   * @param response An express Response to the backend for you to send back data
   */
  protected abstract onRouteInvoke(request: express.Request, response: express.Response): Promise<void>;

  /**
   * Closes the server
   *
   * This will stop the plugin from listening to any requests at all, and is currently an irreversible process.
   * This means, that the instantiated plugin is basically useless afterwards.
   */
  public async close() {
    return new Promise((resolve, reject) => {
      this.server.close(error => {
        /* istanbul ignore next */
        if (error !== undefined) {
          /* istanbul ignore next */
          reject(error);
        }
        resolve(undefined);
      });
    });
  }

  /**
   * Start the plugin
   *
   * **THIS METHOD GETS CALLED AUTOMATICALLY WITH [[PluginClient.registerPlugin]]**
   * If the plugin is not started, it will return 404 on any route
   */
  public start() {
    this.active = true;
  }

  /**
   * Stop the plugin
   *
   * **THIS METHOD GETS CALLED AUTOMATICALLY WITH [[PluginClient.unregisterPlugin]]**
   * If the plugin is not started, it will return 404 on any route
   */
  public stop() {
    // You can't unregister routes from express. So this is a workaround.
    this.active = false;
  }
}
