/** @module services */
/** @hidden */
const _ = require('lodash');
const fs = require('fs');

import { IOpenable } from 'pip-services3-commons-node';
import { IConfigurable } from 'pip-services3-commons-node';
import { IReferenceable } from 'pip-services3-commons-node';
import { IReferences } from 'pip-services3-commons-node';
import { ConfigParams } from 'pip-services3-commons-node';
import { CompositeLogger } from 'pip-services3-components-node';
import { CompositeCounters } from 'pip-services3-components-node';
import { ConnectionException } from 'pip-services3-commons-node';
import { Schema } from 'pip-services3-commons-node';

import { HttpResponseSender } from './HttpResponseSender';
import { HttpConnectionResolver } from '../connect/HttpConnectionResolver';
import { IRegisterable } from './IRegisterable';

/**
 * Used for creating HTTP endpoints. An endpoint is a URL, at which a given service can be accessed by a client. 
 * 
 * ### Configuration parameters ###
 * 
 * Parameters to pass to the [[configure]] method for component configuration:
 * 
 * - cors_headers - a comma-separated list of allowed CORS headers
 * - cors_origins - a comma-separated list of allowed CORS origins
 * - connection(s) - the connection resolver's connections:
 *     - "connection.discovery_key" - the key to use for connection resolving in a discovery service;
 *     - "connection.protocol" - the connection's protocol;
 *     - "connection.host" - the target host;
 *     - "connection.port" - the target port;
 *     - "connection.uri" - the target URI.
 * - credential - the HTTPS credentials:
 *     - "credential.ssl_key_file" - the SSL private key in PEM
 *     - "credential.ssl_crt_file" - the SSL certificate in PEM
 *     - "credential.ssl_ca_file" - the certificate authorities (root cerfiticates) in PEM
 * 
 * ### References ###
 * 
 * A logger, counters, and a connection resolver can be referenced by passing the 
 * following references to the object's [[setReferences]] method:
 * 
 * - logger: <code>"\*:logger:\*:\*:1.0"</code>;
 * - counters: <code>"\*:counters:\*:\*:1.0"</code>;
 * - discovery: <code>"\*:discovery:\*:\*:1.0"</code> (for the connection resolver).
 * 
 * ### Examples ###
 * 
 *     public MyMethod(_config: ConfigParams, _references: IReferences) {
 *         let endpoint = new HttpEndpoint();
 *         if (this._config)
 *             endpoint.configure(this._config);
 *         if (this._references)
 *             endpoint.setReferences(this._references);
 *         ...
 * 
 *         this._endpoint.open(correlationId, (err) => {
 *                 this._opened = err == null;
 *                 callback(err);
 *             });
 *         ...
 *     }
 */
export class HttpEndpoint implements IOpenable, IConfigurable, IReferenceable {

    private static readonly _defaultConfig: ConfigParams = ConfigParams.fromTuples(
        "connection.protocol", "http",
        "connection.host", "0.0.0.0",
        "connection.port", 3000,

        "credential.ssl_key_file", null,
        "credential.ssl_crt_file", null,
        "credential.ssl_ca_file", null,

        "options.maintenance_enabled", false,
        "options.request_max_size", 1024*1024,
        "options.file_max_size", 200*1024*1024,
        "options.connect_timeout", 60000,
        "options.debug", true
    );

	private _server: any;
	private _connectionResolver: HttpConnectionResolver = new HttpConnectionResolver();
	private _logger: CompositeLogger = new CompositeLogger();
	private _counters: CompositeCounters = new CompositeCounters();
    private _maintenanceEnabled: boolean = false;
    private _fileMaxSize: number = 200 * 1024 * 1024;
    private _protocolUpgradeEnabled: boolean = false;
    private _uri: string;
    private _registrations: IRegisterable[] = [];
    private _allowedHeaders: string[] = ["correlation_id"];
    private _allowedOrigins: string[] = [];
    
    /**
     * Configures this HttpEndpoint using the given configuration parameters.
     * 
     * __Configuration parameters:__
     * - cors_headers - a comma-separated list of allowed CORS headers
     * - cors_origins - a comma-separated list of allowed CORS origins
     * - __connection(s)__ - the connection resolver's connections;
     *     - "connection.discovery_key" - the key to use for connection resolving in a discovery service;
     *     - "connection.protocol" - the connection's protocol;
     *     - "connection.host" - the target host;
     *     - "connection.port" - the target port;
     *     - "connection.uri" - the target URI.
     *     - "credential.ssl_key_file" - SSL private key in PEM
     *     - "credential.ssl_crt_file" - SSL certificate in PEM
     *     - "credential.ssl_ca_file" - Certificate authority (root certificate) in PEM
     * 
     * @param config    configuration parameters, containing a "connection(s)" section.
     * 
     * @see [[https://pip-services3-node.github.io/pip-services3-commons-node/classes/config.configparams.html ConfigParams]] (in the PipServices "Commons" package)
     */
	public configure(config: ConfigParams): void {
		config = config.setDefaults(HttpEndpoint._defaultConfig);
		this._connectionResolver.configure(config);

        this._maintenanceEnabled = config.getAsBooleanWithDefault('options.maintenance_enabled', this._maintenanceEnabled);
        this._fileMaxSize = config.getAsLongWithDefault('options.file_max_size', this._fileMaxSize);
        this._protocolUpgradeEnabled = config.getAsBooleanWithDefault('options.protocol_upgrade_enabled', this._protocolUpgradeEnabled);

        let headers = config.getAsStringWithDefault("cors_headers", "").split(",");
        for (let header of headers) {
            header = header.trim();
            if (header != "") {
                this._allowedHeaders = this._allowedHeaders.filter(h => h != header);
                this._allowedHeaders.push(header);                
            }
        }

        let origins = config.getAsStringWithDefault("cors_origins", "").split(",");
        for (let origin of origins) {
            origin = origin.trim();
            if (origin != "") {
                this._allowedOrigins = this._allowedOrigins.filter(h => h != origin);
                this._allowedOrigins.push(origin);                
            }
        }
    }
        
    /**
     * Sets references to this endpoint's logger, counters, and connection resolver.
     * 
     * __References:__
     * - logger: <code>"\*:logger:\*:\*:1.0"</code>
     * - counters: <code>"\*:counters:\*:\*:1.0"</code>
     * - discovery: <code>"\*:discovery:\*:\*:1.0"</code> (for the connection resolver)
     * 
     * @param references    an IReferences object, containing references to a logger, counters, 
     *                      and a connection resolver.
     * 
     * @see [[https://pip-services3-node.github.io/pip-services3-commons-node/interfaces/refer.ireferences.html IReferences]] (in the PipServices "Commons" package)
     */
	public setReferences(references: IReferences): void {
		this._logger.setReferences(references);
		this._counters.setReferences(references);
		this._connectionResolver.setReferences(references);
	}

    /**
     * Gets an HTTP server instance.
     * @returns an HTTP server instance of <code>null</code> if endpoint is closed.
     */
    public getServer(): any {
        return this._server;
    }

    /**
     * @returns whether or not this endpoint is open with an actively listening REST server.
     */
	public isOpen(): boolean {
		return this._server != null;
	}
    
    //TODO: check for correct understanding.
    /**
     * Opens a connection using the parameters resolved by the referenced connection
     * resolver and creates a REST server (service) using the set options and parameters.
     * 
     * @param correlationId     (optional) transaction id to trace execution through call chain.
     * @param callback          (optional) the function to call once the opening process is complete.
     *                          Will be called with an error if one is raised.
     */
	public open(correlationId: string, callback?: (err: any) => void): void {
    	if (this.isOpen()) {
            callback(null);
            return;
        }
    	
		this._connectionResolver.resolve(correlationId, (err, connection, credential) => {
            if (err != null) {
                callback(err);
                return;
            }

            this._uri = connection.getUri();

            try {
                let options: any = {};

                if (connection.getProtocolWithDefault('http') == 'https') {
                    let sslKeyFile = credential.getAsNullableString('ssl_key_file');
                    let privateKey = fs.readFileSync(sslKeyFile).toString();
        
                    let sslCrtFile = credential.getAsNullableString('ssl_crt_file');
                    let certificate = fs.readFileSync(sslCrtFile).toString();
        
                    let ca = [];
                    let sslCaFile = credential.getAsNullableString('ssl_ca_file');
                    if (sslCaFile != null) {
                        let caText = fs.readFileSync(sslCaFile).toString();
                        while (caText != null && caText.trim().length > 0) {
                            let crtIndex = caText.lastIndexOf('-----BEGIN CERTIFICATE-----');
                            if (crtIndex > -1) {
                                ca.push(caText.substring(crtIndex));
                                caText = caText.substring(0, crtIndex);
                            }
                        }
                    }
        
                    options.key = privateKey;
                    options.certificate = certificate;
                    //options.ca = ca;
                }
                options.handleUpgrades = this._protocolUpgradeEnabled;
                         
                // Create instance of restify application   
                let restify = require('restify'); 
                this._server = restify.createServer(options);
                
                // Configure restify application
                this._server.use(restify.plugins.acceptParser(this._server.acceptable));
                //this._server.use(restify.authorizationParser());
                //this._server.use(restify.CORS());
                this._server.use(restify.plugins.dateParser());
                this._server.use(restify.plugins.queryParser());
                this._server.use(restify.plugins.jsonp());
                this._server.use(restify.plugins.gzipResponse());
                this._server.use(restify.plugins.jsonBodyParser());
                // this._server.use(restify.plugins.bodyParser({ 
                //     maxFileSize: this._fileMaxSize
                // }));
                this._server.use(restify.plugins.conditionalRequest());
                //this._server.use(restify.plugins.requestExpiry());
                //if (options.get("throttle") != null)
                //     this._server.use(restify.plugins.throttle(options.get("throttle")));
                
                // Configure CORS requests
                let corsMiddleware = require('restify-cors-middleware2');

                let origins = this._allowedOrigins;
                if (origins.length == 0) {
                    origins = ["*"];
                }

                let cors = corsMiddleware({
                    preflightMaxAge: 5, //Optional
                    origins: origins,
                    allowHeaders: this._allowedHeaders,
                    exposeHeaders: this._allowedHeaders
                  });
                this._server.pre(cors.preflight);
                this._server.use(cors.actual);

                this._server.use((req, res, next) => { this.addCompatibility(req, res, next); });
                this._server.use((req, res, next) => { this.noCache(req, res, next); });
                this._server.use((req, res, next) => { this.doMaintenance(req, res, next); });
        
                this.performRegistrations();

                this._server.listen(
                    connection.getPort(), 
                    connection.getHost(),
                    (err) => {
                        if (err == null) {
                            // Register the service URI
                            this._connectionResolver.register(correlationId, (err) => {
                                this._logger.debug(correlationId, "Opened REST service at %s", this._uri);
                                
                                if (callback) callback(err);
                            });
                        } else {
                            // Todo: Hack!!!
                            console.error(err);

                            err = new ConnectionException(correlationId, "CANNOT_CONNECT", "Opening REST service failed")
                                .wrap(err).withDetails("url", this._uri);

                            if (callback) callback(err);
                        }
                    }
                );
            } catch (ex) {
                this._server = null;
                let err = new ConnectionException(correlationId, "CANNOT_CONNECT", "Opening REST service failed")
                    .wrap(ex).withDetails("url", this._uri);
                if (callback) callback(err);
            }
        });
		
    }

    private addCompatibility(req: any, res: any, next: () => void): void {
        req.param = (name) => {
            if (req.query) {
                let param = req.query[name];
                if (param) return param;
            }
            if (req.body) {
                let param = req.body[name];
                if (param) return param;
            }
            if (req.params) {
                let param = req.params[name];
                if (param) return param;
            }
            return null;
        }

        req.route.params = req.params;

        next();
    }

    // Prevents IE from caching REST requests
    private noCache(req: any, res: any, next: () => void): void {
        res.header('Cache-Control', 'no-cache, no-store, must-revalidate');
        res.header('Pragma', 'no-cache');
        res.header('Expires', 0);
        next();
    }

    // Returns maintenance error code
    private doMaintenance(req: any, res: any, next: () => void): void {
        // Make this more sophisticated
        if (this._maintenanceEnabled) {
            res.header('Retry-After', 3600);
            res.json(503);
        } else next();
    }
    
    /**
     * Closes this endpoint and the REST server (service) that was opened earlier.
     * 
     * @param correlationId     (optional) transaction id to trace execution through call chain.
     * @param callback          (optional) the function to call once the closing process is complete.
     *                          Will be called with an error if one is raised.
     */
    public close(correlationId: string, callback?: (err: any) => void): void {
        if (this._server != null) {
            // Eat exceptions
            try {
                this._server.close();
                this._logger.debug(correlationId, "Closed REST service at %s", this._uri);
            } catch (ex) {
                this._logger.warn(correlationId, "Failed while closing REST service: %s", ex);
            }

            this._server = null;
            this._uri = null;
        }

        callback(null);
    }

    /**
     * Registers a registerable object for dynamic endpoint discovery.
     * 
     * @param registration      the registration to add. 
     * 
     * @see [[IRegisterable]]
     */
    public register(registration: IRegisterable): void {
        this._registrations.push(registration);
    }

    /**
     * Unregisters a registerable object, so that it is no longer used in dynamic 
     * endpoint discovery.
     * 
     * @param registration      the registration to remove. 
     * 
     * @see [[IRegisterable]]
     */
    public unregister(registration: IRegisterable): void {
        this._registrations = _.remove(this._registrations, r => r == registration);
    }

    private performRegistrations(): void {
        for (let registration of this._registrations) {
            registration.register();
        }
    }

    private fixRoute(route: string): string {
        if (route && route.length > 0 && !route.startsWith("/"))
            route = "/" + route;
        return route;
    }

    /**
     * Returns correlationId from request
     * @param req -  http request
     * @return Returns correlationId from request
     */
    public getCorrelationId(req: any): string {
        let correlationId = req.query.correlation_id;
        if (_.isEmpty(correlationId)) {
            correlationId = req.headers['correlation_id']
        }
        return correlationId
    }

    /**
     * Registers an action in this objects REST server (service) by the given method and route.
     * 
     * @param method        the HTTP method of the route.
     * @param route         the route to register in this object's REST server (service).
     * @param schema        the schema to use for parameter validation.
     * @param action        the action to perform at the given route.
     */
    public registerRoute(method: string, route: string, schema: Schema,
        action: (req: any, res: any) => void): void {
        method = method.toLowerCase();
        if (method == 'delete') method = 'del';

        route = this.fixRoute(route);

        // Hack!!! Wrapping action to preserve prototyping context
        let actionCurl = (req, res) => { 
            // Perform validation
            if (schema != null) {
                let params = _.extend({}, req.params, req.query, { body: req.body });
                let correlationId = this.getCorrelationId(req);
                let err = schema.validateAndReturnException(correlationId, params, false);
                if (err != null) {
                    HttpResponseSender.sendError(req, res, err);
                    return;
                }
            }

            // Todo: perform verification?
            action(req, res); 
        };

        // Wrapping to preserve "this"
        let self = this;
        this._server[method](route, actionCurl);
    }   
    
    /**
     * Registers an action with authorization in this objects REST server (service)
     * by the given method and route.
     * 
     * @param method        the HTTP method of the route.
     * @param route         the route to register in this object's REST server (service).
     * @param schema        the schema to use for parameter validation.
     * @param authorize     the authorization interceptor
     * @param action        the action to perform at the given route.
     */
    public registerRouteWithAuth(method: string, route: string, schema: Schema,
        authorize: (req: any, res: any, next: () => void) => void,
        action: (req: any, res: any) => void): void {
            
        if (authorize) {
            let nextAction = action;
            action = (req, res) => {
                authorize(req, res, () => { nextAction(req, res); });
            }
        }

        this.registerRoute(method, route, schema, action);
    }   

    /**
     * Registers a middleware action for the given route.
     * 
     * @param route         the route to register in this object's REST server (service).
     * @param action        the middleware action to perform at the given route.
     */
    public registerInterceptor(route: string,
        action: (req: any, res: any, next: () => void) => void): void {

        route = this.fixRoute(route);

        this._server.use((req, res, next) => {
            let match = (req.url.match(route) || []).length > 0;
            if (route != null && route != "" && !match)
                next();
            else action(req, res, next);
        });
    }

}