import {Serializer} from "./lib/Serializer";
import {EncryptorError} from "./lib/EncryptorError";
import {PhpSerializer} from "./serializers/phpSerializer";
import {JsonSerializer} from "./serializers/jsonSerializer";
import cryptTypes from "crypto";
import {Encryptor} from "./Encryptor";
const valid_aes = [128, 256];
const default_aes = 256;

let crypto;
//Determining if crypto support is unavailable
try {
    crypto = require('crypto');
} catch (e) {
   throw new EncryptorError(e.message);
}

/**
 * Base encryptor Class
 */
export class Base_encryptor {

    /** Cypher type */
    protected algorithm: string;

    /** SECRET KEY Buffer */
    protected readonly secret: any;

    /** AES 128/256 */
    protected aes_mode = default_aes;

    /** valid key length in laravel aes-[128]-cbc aes-[256]-cbc */
    protected  valid_aes_modes =  valid_aes;

    /** Bytes number crypto.randomBytes default 8 */
    protected random_bytes = 8;

    /** serialize driver */
    private serialize_driver: Serializer;

    /** default serialize lib */
    protected default_serialize_mode = 'php';

    /** constructor options */
    protected options: {
        key?: string,
        key_length?: number,
        random_bytes?: number,
        serialize_mode?: 'json'|'php'|'custom'
    };

    /** for test only */
    private raw_decrypted: any;

    /**
     * Return new Encryptor
     *
     * @param options {key: string, key_length?: number }
     * @param driver
     */
    constructor(options, driver?: Serializer) {
        this.options = Object.assign({}, {serialize_mode: this.default_serialize_mode}, options);

        this.setSerializerDriver(driver);
        this.setAlgorithm();

        this.secret = Base_encryptor.prepareAppKey(this.options.key);
        this.random_bytes = this.options.random_bytes ? this.options.random_bytes : this.random_bytes;
    }

    /**
     * encryptIt
     *
     * @return Promise object {iv, value, mac}
     */
    protected encryptIt(data: string): Promise<any> {
        return this
            .generate_iv()
            .then(this.createCypherIv())
            .then(this.cipherIt(data))
            .then(this.generateEncryptedObject())
    }

    /**
     * encryptIt
     *
     * @param data
     * @return object {iv, value, mac}
     */
    protected encryptItSync(data: string): any {
        const iv = this.generate_iv_sync();
        const cipher = this.createCipher(iv);
        const value = Base_encryptor.cryptoUpdate(cipher, data);

        return this.generateEncryptedObject()({iv, value})
    }

    /**
     * decryptIt
     *
     * @param encrypted
     */
    protected decryptIt(encrypted: string): any{
        let payload;

        try {
            payload = JSON.parse(encrypted);
        } catch (e) {
            Base_encryptor.throwError('Encryptor decryptIt cannot parse json')
        }

        //check hmac payload.mac with crypto.timingSafeEqual to prevent timing attacks
        if(! Base_encryptor.validPayload(payload))
            Base_encryptor.throwError('The payload is invalid.');

        if(! this.validMac(payload))
            Base_encryptor.throwError('The MAC is invalid.');

        const decipherIv = this.createDecipheriv(payload.iv);
        const decrypted = Base_encryptor.cryptoDecipher(payload, decipherIv);

        if(process.env.NODE_ENV === 'test')
            this.raw_decrypted = decrypted;

        return this.ifserialized_unserialize(decrypted)
    }

    /**
     * prepareAppKey
     *
     * @param key
     */
    static prepareAppKey(key: string): Buffer{
        if(! key)
            Base_encryptor.throwError('no app key given');

        return Buffer.from(key, 'base64');
    }

    /**
     * set serializer driver
     *
     * @param driver
     */
    setSerializerDriver(driver?: any){
        if(driver) {
            if(! Base_encryptor.validateSerializerDriver(driver))
                Base_encryptor.throwError('validateSerializerDriver');
                this.serialize_driver = new Serializer(new driver);
                this.options.serialize_mode = 'custom';
        }else{
            this.serialize_driver = new Serializer(this.pickSerializeDriver());
        }
    }

    /**
     * validateSerializerDriver
     *
     * @param driver
     */
    static validateSerializerDriver(driver: any){
        try {
            const custom_driver = new driver;
            return Base_encryptor
                .validateSerializerImplementsSerializerInterface(custom_driver)
        }catch (e) {
            Base_encryptor.throwError('validateSerializerDriver')
        }
    }

    /**
     * validateSerializerDriver
     *
     * @param driver
     */
    static validateSerializerImplementsSerializerInterface(driver: any){
        return (typeof driver['serialize'] === 'function')
                && (typeof driver['unSerialize'] === 'function')
    }

    /**
     * pickSerializeDriver
     */
    pickSerializeDriver(){
        if(! this.options.serialize_mode)
            this.options.serialize_mode ='php';

        switch (this.options.serialize_mode) {
            case 'json': {
                return new JsonSerializer;
            }
            case 'php': {
                return new PhpSerializer;
            }
            default: {
                throw new EncryptorError(
                    `Serializer Encryptor Class unknown option ${this.options.serialize_mode} serialize_mode`
                )
            }
        }
    }
    /**
     * setAlgorithm
     *  will populate this.algorithm with valid one aes-[128]-cbc aes-[256]-cbc
     *  from options.aes_mode or this.key_length
     *
     *  if there is an error will push it to errors (and return as reject at public methods)
     */
    protected setAlgorithm() {
        if (this.options.key_length && this.valid_aes_modes.indexOf(this.options.key_length) < 0)
            Base_encryptor.throwError(
                'The only supported ciphers are AES-128-CBC and AES-256-CBC with the correct key lengths.'
            );

        this.algorithm = this.options.key_length ?
            `aes-${this.options.key_length}-cbc` : `aes-${this.aes_mode}-cbc`;
    }

    /**
     * Prepare Data
     *  will receive data from this.encrypt(data)
     *  and check if is a number to convert to string,
     *  return data serialized if need it
     *
     * @param data
     * @param force_serialize
     */
    protected prepareDataToCipher(data: any, force_serialize?: boolean): string{
        if(force_serialize === true && this.serialize_driver.getDriverName() === 'PhpSerializer') {
            return this.serialize_driver.serialize(data);
        }

        data = Base_encryptor.ifNumberToString(data);

        return this.ifObjectToString(data);
    }

    /**
     * prepareDataToDecipher
     *  will parse base64 to utf8
     * @param data
     */
    protected prepareDataToDecipher(data: any): string{
        return Base_encryptor.base64ToUtf8(data);
    }

    /**
     * validPayload
     *
     * @param payload
     */
    static validPayload(payload: any): boolean{
        return payload.hasOwnProperty('iv') && payload.hasOwnProperty('value') && payload.hasOwnProperty('mac')
                && Buffer.from(payload.iv,'base64').toString('hex').length === 32;
    }

    /**
     * validMac
     *
     * @param payload
     */
     validMac(payload: any): boolean{
         try {
             const calculated = this.hashIt(payload.iv, payload.value);
             return crypto.timingSafeEqual(Buffer.from(calculated), Buffer.from(payload.mac))
         }catch (e) {
             return false;
         }
     }


    /**
     * crypto update + final
     *
     * @param cipher
     * @param data
     */
    static cryptoUpdate(cipher: cryptTypes.Cipher, data: string){
            try{
                return cipher.update(data, 'utf8', 'base64') + cipher.final('base64');
            } catch (e) {
                Base_encryptor.throwError(e.message);
            }
    }

    /**
     * Create node crypto cipher Iv
     *
     * @param iv
     */
    protected createCipher(iv: string): cryptTypes.Cipher{
        try {
            return crypto.createCipheriv(this.algorithm, this.secret, iv);
        } catch (e) {
            Base_encryptor.throwError(e.message);
        }
    }

    /**
     * crypto createCipheriv
     *
     * @return Promise crypto cipher
     */
    protected createCypherIv(): any {
        return (iv) => {
                return {iv, cipher: this.createCipher(iv)};
        }
    }

    /**
     * generate a la Laravel Encrypted Object
     *
     * @param data
     */
    protected cipherIt(data: string): any {
        return ({iv, cipher}: any) => {
            return {
                iv,
                value: Base_encryptor.cryptoUpdate(cipher, data)
            }
        }
    }

    /**
     * Generate 8 bytes IV
     *
     * @return Promise [16 hexadecimal string]
     */
    protected generate_iv(): Promise<string> {
        return new Promise((resolve, reject) => {
            crypto.randomBytes(this.random_bytes, (err, buffer) => {
                if (err) return reject(err);
                resolve(buffer.toString('hex'))
            });
        });
    }

    /**
     * Generate 8 bytes IV
     *
     * @return string [16 hexadecimal string]
     */
    protected generate_iv_sync(): string {
        try {
            const buf = crypto.randomBytes(this.random_bytes);
            return buf.toString('hex');
        }catch (e) {
            Base_encryptor.throwError('generate_iv_sync error generating random bytes');
        }
    }

    /**
     * generate Laravel Encrypted Object
     */
    protected generateEncryptedObject() {
        return ({iv, value}: any) => {
            iv = Base_encryptor.toBase64(iv);
            return {
                iv,
                value,
                mac: this.hashIt(iv, value)
            };
        }
    }

    /**
     * crypto createDecipheriv
     *
     * @param iv
     * @return crypto decipher
     */
    protected createDecipheriv(iv: string): cryptTypes.Decipher {
        try {
            return crypto.createDecipheriv(this.algorithm, this.secret, Buffer.from(iv, 'base64'));
        }catch (e) {
            Base_encryptor.throwError(e.message);
        }
    }

    /**
     * cryptoDecipher
     *
     * @param payload
     * @param decipher
     */
    static cryptoDecipher(payload: {iv,value,mac}, decipher: cryptTypes.Decipher) {
        try {
            return decipher.update(payload.value, 'base64', 'utf8') + decipher.final('utf8');
        }catch (e) {
            Base_encryptor.throwError(e.message);
        }
    }

    /**
     * ifserialized_unserialize
     *
     * @param decrypted
     */
    protected ifserialized_unserialize(decrypted: string): any {
        return this.serialize_driver.unSerialize(decrypted)
    }

    /**
     * Create HMAC hash a la laravel
     *
     * @param iv
     * @param encrypted
     * @return hex string
     */
    protected hashIt(iv: string, encrypted: string): string {
        try{
            const hmac = Base_encryptor.createHmac("sha256", this.secret);
            return hmac
                .update(Base_encryptor.setHmacPayload(iv, encrypted))
                .digest("hex");
        }catch (e) {
            Base_encryptor.throwError(e.message);
        }
    }

    /**
     * serialize data
     *
     * @param data
     * @return serialized data
     */
    protected serialize(data: any): string {
        return this.serialize_driver.serialize(data)
    }

    /**
     * Unserialize data
     *
     * @param data
     * @return unserialized data
     */
    protected unserialize(data: string): any {
        return this.serialize_driver.unSerialize(data)
    }

    /**
     * Convert data to base64
     *
     * @param data
     * @return base64 string
     */
    static toBase64(data): string {
        return Buffer.from(data).toString('base64');
    }

    /**
     * Parse base64 to utf8
     *
     * @param data
     * @return utf8 string
     */
    static base64ToUtf8(data: string): string {
        if(typeof data !== 'string')
            throw new EncryptorError('base64ToUtf8 Error data arg not a string');

        return Buffer.from(data, 'base64').toString('utf8');
    }

    /**
     * Create crypto Hmac
     *
     * @param alg
     * @param secret
     */
    static createHmac(alg: string, secret: Buffer): cryptTypes.Hmac {
        try{
            return crypto.createHmac(alg, secret);
        } catch (e) {
            Base_encryptor.throwError(e.message);
        }

    }

    /**
     * Set hmac payload
     *
     * @param iv
     * @param encrypted
     */
    static setHmacPayload(iv: string, encrypted: string): Buffer {
        return Buffer.from(iv + encrypted, 'utf-8')
    }

    /**
     * stringifyAndBase64
     *  will json.stringify object {iv, value, mac} and base64 it
     *
     * @param encrypted {iv, value, mac}
     * @return string base64
     */
    static stringifyAndBase64(encrypted: {iv, value, mac}): string {
        const payload = JSON.stringify(encrypted);
        return Buffer.from(payload).toString('base64');
    }

    /**
     * ifObjectToString serialize object
     *
     * @param data
     */
    protected ifObjectToString(data: any): string{
        return (typeof data === 'object') ?  this.serialize(data) : data;
    }

    /**
     * number To String
     *  if data is a number convert to string
     *
     * @param data
     */
    static ifNumberToString(data: any): string{
        return (typeof data === 'number') ?  data + '' : data;
    }

    /**
     * Throw Error.
     *
     * @param error
     */
    static throwError(error) {
        if(error.name === 'EncryptorError')
            throw error;

        throw new EncryptorError(error);
    }

    /**
     * Generate a random key for the application.
     *
     * @return string
     */
    static generateRandomKey(length?: number): string {
        length = length ? length : default_aes;

        //laravel supports 128/256
        if(valid_aes.indexOf(length) < 0){
            console.error('valid options are: ', valid_aes);
            return;
        }

        try{
            const buf = crypto.randomBytes(length/8);
            return buf.toString('base64');
        }catch (e) {
            Base_encryptor.throwError(e.message);
        }
    }

    /**
     * For testing only
     */
    getRawDecrypted(){
        return this.raw_decrypted
    }
}
