// CREDIT: 

//  The vast majority of this code came right from Ben Nadel's post:
//  http://www.bennadel.com/blog/3047-creating-specialized-http-clients-in-angular-2-beta-8.htm
//
// My updates are mostly adapting it for Typescript:
//  1. Importing required modules
//  2. Adding type notations
//  3. Using the 'fat-arrow' syntax to properly scope in-line functions
//

/*
apiGateway.Service.ts
this service shall track http requests and errors as well

*/


// Vendor Imports
import {Injectable} from "@angular/core";
import {Http, BaseRequestOptions, Request, Response, RequestOptions, RequestMethod, Headers, URLSearchParams} from "@angular/http";
import {Observable} from "rxjs/Observable";
import {Subject} from "rxjs/Subject";
import 'rxjs/Rx';

// uuid
var uuid = require('uuid');
// import moment library
var moment = require('moment');


export class ApiCommand {
    uuid: string;
    moment: Date;
    method: RequestMethod;
    url: string;
    headers: Headers;
    params: {};
    data: {};
    
    constructor() {
        this.uuid = new uuid();
        this.moment = new moment().valueOf;
    }
    setup(method: RequestMethod, url: string, params: {}, data: {}) {
        this.method =  method;
        this.url = url;
        this.params = params;
        this.data = data;
    }
}

export interface ApiListResult<T> {
    items: T[]
    total: number
}

export class ApiResponse {
    uuid: string;
    moment: Date;
    method: RequestMethod;
    url: string;
    headers: Headers;
    params: {};
    data: {};
    errors: {};
    status: string;    
    
    constructor() {
        this.uuid = new uuid();
        this.moment = new moment().valueOf;
    }
    
    setup(method: RequestMethod, url: string, params: {}, data: {}) {
        this.method =  method;
        this.url = url;
        this.params = params;
        this.data = data;
    }
}

@Injectable()
export class ApiGateway {
   
    // Define the internal Subject we'll use to push errors
    private errorsSubject = new Subject<any>();
    
    // Define the internal Subject we'll use to push the command count
    private allCommandsSubject: Subject<ApiCommand[]> = new Subject<ApiCommand[]>();
    private pendingCommandsSubject: Subject<ApiCommand[]> = new Subject<ApiCommand[]>();
    private lastCommandSubject: Subject<ApiCommand> = new Subject<ApiCommand>();
    private newCommandsSubject: Subject<ApiCommand> = new Subject<ApiCommand>();
    
    // Provide the *public* Observable that clients can subscribe to
    errors$: Observable<any>;
    
    pendingCommands$: Observable<ApiCommand[]>;
    allCommands$: Observable<ApiCommand[]>;
    lastCommand$: Observable<ApiCommand>;
    newCommands$: Observable<ApiCommand>;
    
    // public properties
    apiEndpointURL: string;
    
    constructor(
        private http: Http
    ) {
        
        // Create our observables from the subjects
        this.errors$ = this.errorsSubject.asObservable(); 
               
        this.pendingCommands$ = this.pendingCommandsSubject.asObservable();
        this.allCommands$ = this.allCommandsSubject.asObservable();  
        this.lastCommand$ = this.lastCommandSubject.asObservable(); 
        this.newCommands$ = this.newCommandsSubject.asObservable();
        
        this.apiEndpointURL = "";
    }

    // I perform a GET request to the API, appending the given params
    // as URL search parameters. Returns a stream.
    get(url: string, params: any): Observable<any> {
        let command = this.createNewCommand(RequestMethod.Get,url,params,{});
        console.debug("APIGateway.get()",JSON.stringify(command));
        return this.request(command);
    }

    // I perform a POST request to the API. If both the params and data
    // are present, the params will be appended as URL search parameters
    // and the data will be serialized as a JSON payload. If only the
    // data is present, it will be serialized as a JSON payload. Returns
    // a stream.
    post(url: string, params: any, data: any): Observable<any> {
        if (!data) {
            data = params;
            params = {};
        }
        let command = this.createNewCommand(RequestMethod.Post,url,params,data);
        console.debug("APIGateway - post request:",command);
        return this.request(command);
    }
    
    // I perform a DELETE request to the API. If both the params and data
    // are present, the params will be appended as URL search parameters
    // and the data will be serialized as a JSON payload. If only the
    // data is present, it will be serialized as a JSON payload. Returns
    // a stream.
    delete(url: string, params: any, data: any): Observable<any> {
        if (!data) {
            data = params;
            params = {};
        }
        let command = this.createNewCommand(RequestMethod.Delete,url,params,data);
        console.debug("APIGateway - delete request:",command);        
        return this.request(command);
    }

    private request(command: ApiCommand): Observable<any> {
        command.method = (command.method || RequestMethod.Get);
        command.headers = (command.headers || new Headers());
        command.params = (command.params || {});
        command.data = (command.data || {});
        
        // interpolate final api endpoint for commands reqeust
        command.url = (command.url || "");

        this.interpolateUrl(command);
        this.addXsrfToken(command);
        this.addContentType(command);
        let stream: Observable<any> = new Observable(null);
        
        let requestOptions: RequestOptions = new RequestOptions();
        requestOptions.method = command.method;
        requestOptions.url = command.url;
        requestOptions.headers = command.headers;
        requestOptions.search = this.buildUrlSearchParams(command.params);
        requestOptions.body = JSON.stringify(command.data);

        // NOT A GET request.. it's a command to track
        let isCommand = (command.method !== RequestMethod.Get);
        if (isCommand){
           // this.pendingCommandsSubject.next(command); // emit the command
        }
        
        switch(RequestMethod[command.method]) {
            case "Get":     // Explicit GET 
                console.debug("ApiGateway.request()->GET - request=",[command.url,RequestMethod[command.method],requestOptions])
                stream = this.http.get(command.url);
                break;
            case "Delete":  // Explicit DELETE
                console.debug("ApiGateway.request()->DELETE - request=",[command.url,RequestMethod[command.method],requestOptions])
                stream = this.http.delete(command.url,requestOptions);
                break;
            case "Post":    // Explicit POST
                console.debug("ApiGateway.request()->POST - request=",[command.url,RequestMethod[command.method],requestOptions])
                stream = this.http.post(command.url,requestOptions.body,requestOptions);
                break;      // Explicit PUT
            case "Put":
                console.debug("ApiGateway.request()->PUT - request=",[command.url,RequestMethod[command.method],requestOptions])
                stream = this.http.put(command.url,requestOptions.body,requestOptions);
                break;
            case "Head":    // Explicit HEAD
                console.debug("ApiGateway.request()->HEAD - request=",[command.url,RequestMethod[command.method],requestOptions])
                stream = this.http.head(command.url,requestOptions);
                break;
            default:        // Any other | Default HTTP Request
                let options = new BaseRequestOptions();
                let req = new Request(options.merge(requestOptions));
                console.debug("ApiGateway.request()->DEFAULT - request=",[command.url,req]);
                stream = this.http.request(req);
                break;
        }
        
        stream.catch((error: any) => {
                    this.errorsSubject.next(error);
                    return Observable.throw(error);
                })
                .map(this.unwrapHttpValue)
                .catch((error: any) => {
                    return Observable.throw(this.unwrapHttpError(error));
                })
                .finally(() => {
                    this.newCommandsSubject.next(command); // emit the command
                });
        
        return stream;
    }


    private createNewCommand(method: RequestMethod, url: string, params: {}, data: {}) : ApiCommand {
        var command = new ApiCommand();
        command.setup(method, url, params, data);
        return command;
    }
    
    private addContentType(command: ApiCommand): ApiCommand {
        // so long as it's not a get, we want json.    
        if (command.method !== RequestMethod.Get) {
            command.headers.append("Content-Type","application/json; charset=UTF-8");
        }
        return command;
    }

    private extractValue(collection: any, key: string): any {
        var value = collection[key];
        delete (collection[key]);
        return value;
    }

    private addXsrfToken(command: ApiCommand): ApiCommand {
        var xsrfToken = this.getXsrfCookie();
        if (xsrfToken) {
            command.headers.append("X-XSRF-TOKEN",xsrfToken);
        }
        return command;
    }

    private getXsrfCookie(): string {
        var matches = document.cookie.match(/\bXSRF-TOKEN=([^\s;]+)/);
        try {
            return (matches && decodeURIComponent(matches[1]));
        } catch (decodeError) {
            return ("");
        }
    }

    private buildUrlSearchParams(params: any): URLSearchParams {
        var searchParams = new URLSearchParams();
        for (var key in params) {
            searchParams.append(key, params[key])
        }
        return searchParams;
    }

    private interpolateUrl(command: ApiCommand): ApiCommand {
        command.url = command.url.replace(
            /:([a-zA-Z]+[\w-]*)/g,
            ($0, token) => {
                // Try to move matching token from the params collection.
                if (command.params.hasOwnProperty(token)) {
                    return (this.extractValue(command.params, token));
                }
                // Try to move matching token from the data collection.
                if (command.data.hasOwnProperty(token)) {
                    return (this.extractValue(command.data, token));
                }
                // If a matching value couldn't be found, just replace
                // the token with the empty string.
                return ("");
            }
        );
        
        // Clean up any repeating slashes. 
        // (TODO: ADD http(s):// regex exception and uncomment this url string replacement, 
        // We don't want to strip the :// protocol segment when a full(CORS) API endpoint URL is passed to this gateway..
        //command.url = command.url.replace(/\/{2,}/g, "/");
        
        // Clean up any trailing slashes.
        command.url = command.url.replace(/\/+$/g, "");

        // prefix the api endpoint url to the interpolated command url for this gateway unless we assume [localhost]
        // TODO: ensure CORS is enabled and security configured properly
        if (this.apiEndpointURL && this.apiEndpointURL.length > 2){
           command.url = this.apiEndpointURL + command.url; 
        }
        console.debug("ApiGateway.interpolateUrl()",command.url);
        return command;
    }

    private unwrapHttpError(error: any): any {
        try {
            return (error.json());
        } catch (jsonError) {
            return ({
                code: -1,
                message: "An unexpected error occurred.",
                details: JSON.stringify(error)
            });
        }
    }
    
    private unwrapHttpValue(value: Response): any {
        console.debug("ApiGateway.unwrapHttpValue()",value);
        return (value.json());
    }
}