import * as Index from "./index.d";
import { MultipleInstanceInterface } from "./multipleInstanceInterface";
import * as RedisType from "ioredis";
import Settings from "./settings";

let Redis = require("ioredis");

export default class RedisStore implements MultipleInstanceInterface {
    private interval: any | number | null = null; 
    private config: Index.RedisOptions;    
    private tag: string;

    protected redis: Index.ExtendedIORedis | null = null;
    private status: string;
    private waitForStatus: boolean = false;
    private autoclose: boolean = false;

    private NO_REDIS_CONNECTION: string = "A Redis connection cannot be established";

    constructor(tag = "tag", autoclose = false) {
        this.config = Settings.Instance.getRedis(tag);
        
        let statusInterval = this.config.statusCheckInterval;
        if(statusInterval == null || typeof statusInterval != "number" || statusInterval < 1){
            statusInterval = 5000;
        }
        this.status = "disconnected";
        this.autoclose = autoclose;
        this.tag = tag;

        this.redisStatusCheck();
        this.interval = setInterval(this.redisStatusCheck.bind(this), statusInterval);
    }

    public destroy(){
        if(this.interval != null)
            clearInterval(this.interval);
        if(this.redis == null) return;
        this.redis.quit();
        this.redis = null;
    }

    public connect() {
        if(this.config.isCluster){ 
            this.redis = new Redis({
                sentinels: this.config.sentinels,
                name: this.config.sentinel_cluster_name,
                password: this.config.password,
                sentinelRetryStrategy: function (times: number) {
                    // allow 3 retries only
                    if (times > 3) {
                        // block reconnecting by returning a non-number value
                        return null;
                    }
    
                    var delay: number = Math.min(times * 50, 2000);
                    return delay;
                }
            });
        }else{
            this.redis = new Redis({
                port: this.config.port,
                host: this.config.host,
                family: this.config.family, //ipv4 or ipv6
                password: this.config.password,
                retryStrategy: function (times: number) {
                    if(times < 3) {
                        return Math.min(times * 100, 2000);
                    }
                    return "";
                  }
            });
        }

         //add custom spop_keyset command
         if(this.redis != null)
         this.redis.defineCommand("spop_keyset", {
             numberOfKeys:3,
             lua: "redis.replicate_commands(); local session=redis.call('SPOP',KEYS[1]); redis.call('SET', KEYS[2]..':'..session, KEYS[3])return session"
         });
    }

    private redisStatusCheck() {
        if (this.waitForStatus) return;
        this.waitForStatus = true;
        let self = this;

        let rediskey_connections = "redis_connection_test";
        if(this.config.keys != undefined && this.config.keys.rediskey_connections != ""){
            rediskey_connections = this.config.keys.rediskey_connections;
        }

        this.set(rediskey_connections + this.tag, "ok", function (isSucces: boolean, result: any) {

            if (isSucces) {
                self.status = "connected";
            }
            else {
                self.status = "disconnected";
            }
            self.waitForStatus = false;
        })
    }

    private ensureIsConnected(): boolean {
        if(this.redis == null || this.redis.status == null || this.redis.status == "end"){
            if(this.config.autoConnect){
                this.connect();
                return true;
            }
        }
        return false;
    }

        
    /**
     * return the status of the inner redis connection
     */
    public getStatus(){
        return this.status;
    }

    public isFunctional(){
        return this.getStatus() == "connected";
    }
    
    /**
     * close the inner redis connection
     */
    public close(){
        if(this.redis)
            this.redis.quit();
    }

    /**
     * update the value of a key in redis
     * THIS IS ONLY HERE TO PROVIDE BACKWARDS COMPATIBILITY
     */
    public set(key: string, value: any, callback?: (isSucces: boolean, result: any) => void) {
        this.set_with_ttl(key, value, undefined, callback);
    }

    /**
     * update the value of a key in redis
     */
    public set_with_ttl(key: string, value: any, ttl?:number, callback?: (isSucces: boolean, result: any) => void) {
        this.ensureIsConnected();
        if(!this.redis) {
            if(typeof callback == "function")
                callback(false, new Error(this.NO_REDIS_CONNECTION));
            return;
        }
        let self = this;
         //callback always last
         let _callback = function (err: Error, result: any) {
            if(typeof callback == "function"){
                if (err != null && err != undefined) {
                    callback(false, err);
                }
                else {
                    callback(true, result);
                }
            }

            if(self.autoclose)
                self.close();
        };
        
        //maybe we have ttl
        if((ttl != undefined) && (typeof ttl == 'number')){
            this.redis.set(key, value, 'EX', ttl, _callback);
        }
        else{
            this.redis.set(key, value, _callback);
        }
    }

    public hset(key: string, field: string, value: any, callback: (isSucces: boolean, result: any) => void) {
        this.ensureIsConnected();
        if(!this.redis) {
            if(typeof callback == "function")
                callback(false, new Error(this.NO_REDIS_CONNECTION));
            return;
        }        
        let self = this;
        this.redis.hset(key, field, value, function (err: Error, result: any) {
            if(typeof callback == "function"){
                if (err != null && err != undefined) {
                    callback(false, err);
                }
                else {
                    callback(true, result);
                }
            }

            if(self.autoclose)
                self.close();
        });
    }
    
    /**
     * get the keys from redis
     */
    public keys(key: string, callback: (isSucces: boolean, result: any) => void){
        this.ensureIsConnected();
        if(!this.redis) {
            if(typeof callback == "function")
                callback(false, new Error(this.NO_REDIS_CONNECTION));
            return;
        }        
        let self = this;                
        this.redis.keys(key, function(err: Error, result: any){
            if(err != null && err != undefined){
                callback(false, err);
            }
            else{
                callback(true, result);
            }
           
            if(self.autoclose)
                self.close();
        });
    }
    
    /**
     * get the value of a key stored in redis
     */
    public get(key: string, callback: (isSucces: boolean, result: any) => void){
        this.ensureIsConnected();
        if(!this.redis) {
            if(typeof callback == "function")
                callback(false, new Error(this.NO_REDIS_CONNECTION));
            return;
        }        
        let self = this;                        
        this.redis.get(key, function(err: Error, result: any){
            if(err != null && err != undefined){
                callback(false, err);
            }
            else{
                callback(true, result);
            }

            if(self.autoclose)
                self.close();
        });
    }

    
    public hget(key: string, field: string, callback: (isSucces: boolean, result: any) => void){
        this.ensureIsConnected();
        if(!this.redis) {
            if(typeof callback == "function")
                callback(false, new Error(this.NO_REDIS_CONNECTION));
            return;
        }        
        let self = this;
        this.redis.hget(key, field, function(err: Error, result: any){
            if(err != null && err != undefined){
                callback(false, err);
            }
            else{
                callback(true, result);
            }

            if(self.autoclose)
                self.close();
        });
    }

    public hgetall(key: string, callback: (isSucces: boolean, result: any) => void){
        this.ensureIsConnected();
        if(!this.redis) {
            if(typeof callback == "function")
                callback(false, new Error(this.NO_REDIS_CONNECTION));
            return;
        }        
        let self = this;
        this.redis.hgetall(key, function(err: Error, result: any){
            if(err != null && err != undefined){
                callback(false, err);
            }
            else{
                callback(true, result);
            }

            if(self.autoclose)
                self.close();
        });
    }
    
    /**
     * Callback is optional, it is not necesarylly to send it
     * @param key 
     * @param callback 
     */
    public delete(key: string, callback?: (isSucces: boolean, result: any) => void){
        this.ensureIsConnected();
        if(!this.redis) {
            if(typeof callback == "function")
                callback(false, new Error(this.NO_REDIS_CONNECTION));
            return;
        }        
        let self = this;
        this.redis.del(key, function(err: Error, result: any){
            if(typeof callback == "function"){                
                if(err != null && err != undefined){
                    callback(false, err);
                }
                else{
                    callback(true, result);
                }
            }
            
            if(self.autoclose)
                self.close();
        });
    }

    public hdel(key: string, field: string, callback: (isSucces: boolean, result: any) => void){
        this.ensureIsConnected();
        if(!this.redis) {
            if(typeof callback == "function")
                callback(false, new Error(this.NO_REDIS_CONNECTION));
            return;
        }        
        let self = this;
        this.redis.hdel(key, field, function(err: Error, result: any){
            if(typeof callback == "function"){                
                if(err != null && err != undefined){
                    callback(false, err);
                }
                else{
                    callback(true, result);
                }
            }
            
            if(self.autoclose)
                self.close();
        });
    }
    
    public begin_transaction(): RedisType.Pipeline | null{
        this.ensureIsConnected();  
        if(!this.redis) return null;
        
        return this.redis.multi();
    }
    
    public exec_transaction(redis_transactions: RedisType.Pipeline, callback: (isSucces: boolean, result: any) => void){  
        this.ensureIsConnected();  
        let self = this;                                                
        redis_transactions.exec(function(err: Error, results: any){
            if(err != undefined && err != null){
                callback(false, err);
            }else{
                callback(true, results);
            }
    
            if(self.autoclose)
                self.close();
        });
    }
    
    public countValues(key: string, callback: (isSucces: boolean, result: any) => void){
        this.ensureIsConnected();
        if(!this.redis) {
            if(typeof callback == "function")
                callback(false, new Error(this.NO_REDIS_CONNECTION));
            return;
        }

        let self = this;                                                        
        this.redis.keys(key + ":*", function(err: Error, results: any){
            if(err != undefined && err != null){
                callback(false, err);
            }else{
                callback(true, results);
            }
            
            if(self.autoclose)
                self.close();
        });
    }

    /**
     * get the number of keys found under the used session key
     */
    public count_used_sessions(used_sessions_key: string, callback: (isSucces: boolean, result: any) => void){
        this.countValues(used_sessions_key, function(result, data){
            callback(result, data);
        });
    }
    
    public add_to_set(setName: string, values: string, callback: (isSucces: boolean, result: any) => void){
        this.ensureIsConnected();
        if(!this.redis) {
            if(typeof callback == "function")
                callback(false, new Error(this.NO_REDIS_CONNECTION));
            return;
        }        
        
        let self = this;                                                                
        this.redis.sadd(setName, values, function(err: Error, results: any){
            if(err != undefined && err != null){
                callback(false, err);
            }else{
                callback(true, results);
            }
            
            if(self.autoclose)
                self.close();
        }.bind(this));
    }
    /**
     * sessions can be an array or a single value. eg: [1, 2, 3] or 1, 2, 3 or 2 or [2]
     */
    public add_session(sessions: string, free_sessions_set_key: string, callback: (isSucces: boolean, result: any) => void){
        let self = this;                                                                
        this.add_to_set(free_sessions_set_key, sessions, function(result, data){
            callback(result, data);

            if(self.autoclose)
                self.close();
        });
    }
    
    public remove_from_set(setName: string, member: string, callback: (isSucces: boolean, result: any) => void){
        this.ensureIsConnected();
        if(!this.redis) {
            if(typeof callback == "function")
                callback(false, new Error(this.NO_REDIS_CONNECTION));
            return;
        }        
        let self = this;                                                                
        this.redis.srem(setName, member, function(err: Error, results: any){
            if(err != undefined && err != null){
                callback(false, err);
            }else{
                callback(true, results);
            }
            
            if(self.autoclose)
                self.close();
        });
    }

    public watch_key(key:string){
        this.ensureIsConnected();
        if(!this.redis) return;

        this.redis.watch(key);
    }

    public unwatch_keys(){
        this.ensureIsConnected();
        if(!this.redis) return;

        this.redis.unwatch();
    }
    
    public pop_from_set(setName: string, callback: (isSucces: boolean, result: any) => void){
        this.ensureIsConnected();
        if(!this.redis) {
            if(typeof callback == "function")
                callback(false, new Error(this.NO_REDIS_CONNECTION));
            return;
        }

        let self = this;                                                                
        this.redis.spop(setName, function(err: Error, results: any){
            if(err != undefined && err != null){
                callback(false, err);
            }else{
                callback(true, results);
            }
            
            if(self.autoclose)
                self.close();
        });
    }

    public spop(setName: string, count:number = 1, callback: (isSucces: boolean, result: any) => void){
        this.ensureIsConnected();
        if(!this.redis) {
            if(typeof callback == "function")
                callback(false, new Error(this.NO_REDIS_CONNECTION));
            return;
        }

        let self = this;                                                                
        this.redis.spop(setName, count, function(err: Error, results: any){
            if(err != undefined && err != null){
                callback(false, err);
            }else{
                callback(true, results);
            }
            
            if(self.autoclose)
                self.close();
        });
    }
    
    
    public smembers(key: string, callback: (isSucces: boolean, result: any) => void){
        this.ensureIsConnected(); 
        if(!this.redis) {
            if(typeof callback == "function")
                callback(false, new Error(this.NO_REDIS_CONNECTION));
            return;
        }        

        let self = this;                                                                
        this.redis.smembers(key, function(err: Error, results: any){
            if(err != undefined && err != null){
                callback(false, err);
            }else{
                callback(true, results);
            }
    
            if(self.autoclose)
                self.close();
        });
    } 
    
    /**
     * get the number of elements in the given set
     */
    public setCount(key: string, callback: (isSucces: boolean, result: any) => void){
        this.ensureIsConnected(); 
        if(!this.redis) {
            if(typeof callback == "function")
                callback(false, new Error(this.NO_REDIS_CONNECTION));
            return;
        }        
        let self = this;                                                                
        this.redis.scard(key, function(err: Error, results: any){
            if(err != undefined && err != null){
                callback(false, err);
            }else{
                callback(true, results);
            }
    
            if(self.autoclose)
                self.close();
        });
    } 

    public add_to_list(listName: string, values: string, callback: (isSucces: boolean, result: any) => void){
        this.ensureIsConnected();
        if(!this.redis) {
            if(typeof callback == "function")
                callback(false, new Error(this.NO_REDIS_CONNECTION));
            return;
        }

        let self = this;                                                                
        this.redis.lpush(listName, values, function(err: Error, results: any){
            if(err != undefined && err != null){
                callback(false, err);
            }else{
                callback(true, results);
            }
    
            if(self.autoclose)
                self.close();
        });
    }
    
    public remove_from_list(listName: string, callback: (isSucces: boolean, result: any) => void){
        this.ensureIsConnected(); 
        if(!this.redis) {
            if(typeof callback == "function")
                callback(false, new Error(this.NO_REDIS_CONNECTION));
            return;
        }    

        let self = this;                                                                
        this.redis.rpop(listName, function(err: Error, results: any){
            if(err != undefined && err != null){
                callback(false, err);
            }else{
                callback(true, results);
            }
    
            if(self.autoclose)
                self.close();
        });
    }
    
    /**
     * moves the tail element from list 'sourceList' into the list 'destinationList'
     * @param sourceList 
     * @param destinationList 
     * @param callback 
     */
    public move(sourceList: string, destinationList: string, callback: (isSucces: boolean, result: any) => void){
        this.ensureIsConnected(); 
        if(!this.redis) {
            if(typeof callback == "function")
                callback(false, new Error(this.NO_REDIS_CONNECTION));
            return;
        }     

        let self = this;                                                                
        this.redis.rpoplpush(sourceList, destinationList, function(err: Error, results: any){
            if(err != undefined && err != null){
                callback(false, err);
            }else{
                callback(true, results);
            }
    
            if(self.autoclose)
                self.close();
        });
    }
    
    public countList(listName: string, callback: (isSucces: boolean, result: any) => void){
        this.ensureIsConnected();
        if(!this.redis) {
            if(typeof callback == "function")
                callback(false, new Error(this.NO_REDIS_CONNECTION));
            return;
        }    

        let self = this;                                                                
        this.redis.llen(listName, function(err: Error, results: any){
            if(err != undefined && err != null){
                callback(false, err);
            }else{
                callback(true, results);
            }
    
            if(self.autoclose)
                self.close();
        });
    }
    
    public rPoplpush(sourceList: string, destinationList: string, callback: (isSucces: boolean, result: any) => void){
        this.ensureIsConnected(); 
        if(!this.redis) {
            if(typeof callback == "function")
                callback(false, new Error(this.NO_REDIS_CONNECTION));
            return;
        }        
        
        let self = this;                                                                
        this.redis.rpoplpush(sourceList, destinationList, function(err: Error, results: any){
            if(err != undefined && err != null){
                callback(false, err);
            }else{
                callback(true, results);
            }
    
            if(self.autoclose)
                self.close();
        });
    }

    /**
     * get a session randomly from the free session set and move it to the used sessions
     * 
     * Implemented Optimistic locking using check-and-set : https://redis.io/topics/transactions
     * This implementation in theory is safer but could introduce new situations, use it with caution as it is still experimental
     */
    public lock_session_with_watch(free_sessions_set_key: string, used_sessions_key: string, lock_duration:number = 10*60*1000, callback: (isSucces: boolean, result: any) => void){
        let self = this;                                                                
        let timestamp = new Date().getTime() + lock_duration; //add 10 minutes to current time of the server
        
        //lock key//DONT forget to unlock
        this.watch_key(free_sessions_set_key);
        
        //pop a session from the set
        this.pop_from_set(free_sessions_set_key, function(status, session){
            if(status){

                let transaction = self.begin_transaction();
                
                //failed to create a transaction
                if(!transaction){
                    self.unwatch_keys();
                    callback(false, new Error("Unable to start transaction"));
                    return;
                }     

                //check if we got a session
                if(session){
                    
                    //set up the transaction
                    transaction
                    .set(used_sessions_key + ":" + session, timestamp);
                    
                    //execute it
                    self.exec_transaction(transaction, function(status: any, result: any){
                        //return session if status is true else return the result which should contain the error
                        callback(status, status ? session : result); 
                    });
                } 
                //failed returning a session  
                else{
                    self.unwatch_keys();
                    callback(false, new Error('no free session available')) 
                }
            }
            //failed status branch
            else
            {
                self.unwatch_keys();
                callback(status, session);
            }
        });
    }

    /**
     * get a session randomly from the free session set and move it to the used sessions
     * @deprecated use lock_session_lua instead
     */
    public lock_session(free_sessions_set_key: string, used_sessions_key: string, lock_duration:number = 10*60*1000, callback: (isSucces: boolean, result: any) => void){
        let self = this;                                                                
        let timestamp = new Date().getTime() + lock_duration; //add 10 minutes to current time of the server
        this.pop_from_set(free_sessions_set_key, function(status, session){
            if(status){
                if(session)
                    self.set(used_sessions_key + ":" + session, timestamp, function(status: any, result: any){
                        //return session if status is true else return the result which should contain the error
                        callback(status, status ? session : result); 
                    });
                else
                    callback(false, new Error('no free session available')) 
            }
            else
            {
                callback(status, session);
            }
        });
    }

     /**
     * get a session randomly from the free session set and move it to the used sessions
     */
    public lock_session_lua(free_sessions_set_key: string, used_sessions_key: string, lock_duration:number = 10*60*1000, callback: (isSucces: boolean, result: any) => void){
        
        let self = this;                                                                
        let timestamp = new Date().getTime() + lock_duration; //add 10 minutes to current time of the server

        this.ensureIsConnected();
        if(!this.redis) {
            if(typeof callback == "function")
                callback(false, new Error(this.NO_REDIS_CONNECTION));
            return;
        }

        if(self.redis && self.redis.spop_keyset ){
            self.redis.spop_keyset(free_sessions_set_key, used_sessions_key, timestamp).then(function(result:any){
                callback(true, result); 
            }).catch(function(err:any){
                callback(false, new Error('error selecting a session')); 
            })
        }else{
            callback(false, new Error('error accessing redis')); 
        }
    }

    /**
     * move a session from the used section the free or error sets
     */
    public unlock_session(session: string, isErr: boolean, free_sessions_set_key: string, used_sessions_key: string, error_sessions_set_key: string, callback: (isSucces: boolean, result: any) => void){
        let transaction = this.begin_transaction();

        if(!transaction){
            callback(false, new Error("Unable to start transaction"));
            return;
        }

        transaction
            .del(used_sessions_key + ":" + session)
            .sadd((isErr ? error_sessions_set_key : free_sessions_set_key), session);  

        this.exec_transaction(transaction, function(result, data){

            //return session if status is true else return the result which should contain the error
            callback(result, result ? session : data);
        })      
    }


    public rpoplpush_with_counter(destination_list: string, value: string, counter: number, callback: (isSucces: boolean, result: any) => void){
        let self = this;                                                                
        this.countList(destination_list, function(result, data){
            if(result){
                if(data < counter)
                self.add_to_list(destination_list, value, function(result, data){
                        callback(result, data);
                    })
                else{
                    // now perform the transaction
                    let transaction = self.begin_transaction();
                    
                    if(!transaction){
                        callback(false, new Error("Unable to start transaction"));
                        return;
                    }

                    transaction
                        .lpush(destination_list, value)
                        .rpop(destination_list);

                    self.exec_transaction(transaction, function(result, data){
                    if(result){
                        callback(true, data);
                    } else {
                        callback(false, data);
                    }
                    });
                }
            } else
                callback(false, data);
        })
    };
    
    /**
     * wrap a method in promisefy
     * 
     * @param method 
     * @param args 
     */
    public async promisefy(method:any, ...args: any[]): Promise<any> {
        let self = this;
        
        //i hope this is not called with both callbacks and promisefy
        // if(args.length > 0){
        //     let lastArg = args[args.length - 1];
        //     if(typeof lastArg == "function"){
        //        args.splice(-1,1)
        //     }
        // }
        
        return new Promise(function(resolve, reject) {
            method.bind(self)(...args, function(succes:boolean, data:any){
                if (succes) resolve(data);
                else reject(data);
            }); 
        });
    }
}