import sys
import numpy as np
import random
import re
import time

from utils import *

class Linear:
    def __init__(self):
        pass

    def activate(self, x):
        # Activation function - linear
        return x

    def derivation(self, x):
        # Derivation of linear function
        return 1

class Sigmoid:
    def __init__(self):
        pass

    def activate(self, x):
        # Activation function - logistical sigmoid
        return 1 / (1 + np.exp(-x))

    def derivation(self, x):
        # Derivation of sigmoid function
        return self.activate(x) * (1 - self.activate(x))


class Tahn:
    def __init__(self):
        pass

    def activate(self, x):
        # Activation function - Hyperbolic Tangent
        return ( np.exp(x) - np.exp(-x) ) / ( np.exp(x) + np.exp(-x) )

    def derivation(self, x):
        # Derivation of Hyperbolic Tangent
        return ( 1 + self.activate(x) ) * ( 1 - self.activate(x) ) # 1^2 - tahn^2(x)


class SoftMax:
    def __init__(self):
        pass

    def activate(self, x):
        # Activation function - Softmax
        return ( np.exp(x) - np.exp(-x) ) / ( np.exp(x) + np.exp(-x) )

    def derivation(self, x):
        # Derivation of Softmax function
        return self.activate(x) * (1 - self.activate(x))


class MultiLayerPerceptron:

    def __init__(self, input_dim, output_dim, functions, hidden_layers):
        # Initialize perceptron and data.
        self.functions = functions # array of functions for layers
        self.hidden_layers = hidden_layers # array with numbers of neurons in hidden layers
        self.input_dim = input_dim # dimension of input
        self.output_dim = output_dim # dimension of output
        self.initialize_weights()

    def save(self, file_name='vahy.txt'):
        np.savez("vahy.npz", *[ *self.W, self.W_out ] )

        for w in *self.W, self.W_out:
            print(w.shape)


    def read(self, file_name='vahy.txt'):
        w = []
        l = np.load('vahy.npz')

        for i in range(len(l)):
            w.append(l[f"arr_{i}"])

        self.W, self.W_out = w[:-1], w[-1]

        for w in *self.W, self.W_out:
            print(w.shape)

    def add_bias(self, x):
        # Add bias to input vector x.
        return np.concatenate((x, [1]))

    def initialize_weights(self):
        # Sets all weights to (Gaussian) random values
        self.W = []
        for i in range(len(self.hidden_layers)):
            if i == 0:
                self.W.append( np.random.randn(self.hidden_layers[i], self.input_dim + 1 ) )
            else:
                self.W.append( np.random.randn(self.hidden_layers[i], self.hidden_layers[i-1] ) )

        self.W_out = np.random.randn( self.output_dim , self.hidden_layers[-1]  )

    def evaluate(self, file_path):
        # Loads data from file, return avg error
        data = np.loadtxt(file_path)
        out = np.hsplit(data, [2, 3])[1]
        inp = np.hsplit(data, [2, 3])[0]

        E = self.compute_accuracy(inp, out)
        return E

    def compute_accuracy(self, inputs, targets):
        # Computes average error
        E = 0
        for i in np.random.permutation(len(inputs)):

            x = np.reshape(self.add_bias(inputs[i]), (self.input_dim + 1, 1))
            d = targets[i]
            y = self.compute_output(x)
            e = self.compute_error(d, y)
            E += e
        return  E / len(inputs)

    #(3,)(3, 1)


    def compute_error(self, d, y):
        # Computes square error of output y against desired output d.
        return   np.array(( d - y ) ** 2)[0][0]

    def compute_output(self, x):
        # Computes and stores outputs and net values for every layer of the neural network for given input vector x
        self.outs = []
        self.nets = []
        hidden_out = x

        for i in range(len(self.hidden_layers)):
            hidden_net = self.W[i] @ hidden_out
            self.nets.append(hidden_net)
            hidden_out = self.functions[i].activate(  hidden_net  )
            self.outs.append(hidden_out)


        out_net = self.W_out @ hidden_out  # linear function
        self.nets.append(out_net)
        out_out = self.functions[-1].activate( out_net )
        self.outs.append(out_out)
        # Pepehands

        return out_out


    def train(self, inputs, targets, test_inp, test_out,  num_epochs, alpha=0.1):
        # Trains the neural network, iterating num_epochs times.

        # After every each epoch, per-epoch regression error (E) and classification
        # accuracy are appended into history, that is return for further plotting.  # TODO

        def get_delta_out(d, y): #
            # Computes delta of output layer
            return  np.array(  d - y  ) * self.functions[-1].derivation( self.nets[-1] )


        def get_deltas(d, y):
            # Computes deltas for each layer and return them
            deltas = []
            deltas.append( get_delta_out(d, y) )

            for i in reversed(range(0, len(self.W))):
                delta_before = deltas[-1]

                if i == len(self.W) - 1:
                    delta = ( self.W_out.T @ delta_before ) * self.functions[i].derivation( self.nets[i] )
                else:
                    delta = ( self.W[i+1].T @ delta_before ) * self.functions[i].derivation( self.nets[i] )

                deltas.append(delta)

            return deltas[::-1]

        err_history = []
        accuracy_history = []


        for ep in range(num_epochs):
            E = 0
            for i in np.random.permutation(len(inputs)):


                x = np.reshape ( self.add_bias(inputs[i]), (self.input_dim+1,1))
                d = targets[i]

                y = self.compute_output(x)
                e = self.compute_error(d, y)
                E += e

                deltas = get_deltas(d, y)

                for i in range(len(self.W)):

                    if i == 0:
                        self.W[i] = self.W[i] + alpha * np.outer( deltas[i], x )
                    else:
                        self.W[i] = self.W[i] + alpha * np.outer(deltas[i], self.outs[i-1])

                self.W_out = self.W_out + alpha * np.outer( deltas[-1], self.outs[-2])

            err_history.append(E/len(inputs))

            if (ep+1) % 100 == 0:
                print(f'Epoch {ep}, E_train = {err_history[-1]} ')
            #
            #     acc = self.compute_accuracy(test_inp, test_out)  # TESTING DATA USAGE
            #     accuracy_history.append(acc)
            #     print(f'Epoch {ep}, E_train = {err_history[-1]}, E_test = {acc} ')

        self.save()

        return (err_history, accuracy_history)


if __name__ == "__main__":
    linear = Linear()
    sigmoid = Sigmoid()
    tahn = Tahn()


    #Modeling
    data = np.loadtxt('mlp_train.txt')  # DATA INPUT
    layers = [30, 20, 10]
    functions = [tahn, tahn, tahn,  linear]
    inp_dim = 2
    out_dim = 1

    # inputs, targets, test_inp, test_out = prepare_data(data) # rozdelenie na train a validacne
    model = MultiLayerPerceptron(inp_dim, out_dim, functions , layers)

    out = np.hsplit(data, [2, 3])[1]
    inp = np.hsplit(data, [2, 3])[0]

    ## Train the neural network
    start = time.time()
    training_history = model.train(inp, out, None, None, num_epochs=1500, alpha=0.023)

    # model.read()
    # print(model.evaluate('mlp_train.txt')) # 0.010593032104015661

    end = time.time()
    hours, rem = divmod(end - start, 3600)
    minutes, seconds = divmod(rem, 60)
    print("{:0>2}:{:0>2}:{:05.2f}".format(int(hours), int(minutes), seconds))

    # model.save()
    # model.read()


    """
    data = []
    for i in range(10):
        data.append( np.array([i,i,i]))
    data = np.array(data)

    inputs, targets, test_inp, test_out = prepare_data(data)

    for pole in inputs, targets, test_inp, test_out:
        print(pole)
        print ( '---------------------')

    print(inputs[0].shape)
 
    ## Quick numpy tutorial:
    
    print('Vector:')
    a = np.array([1, 2, 3]) # vector
    b = np.array([4, 5, 6]) # another vector
    print(a, b)
    print(a.shape)   # 'shape'=size of vector is tuple!
    print(a + 100)   # vector + scalar = vector
    print(a * 100)   # vector * scalar = vector
    print(a ** 2)    # vector ** scalar = vector
    print(np.exp(a)) # numpy function applies to every element of vector automatically
    print(a + b)     # element-wise plus
    print(a * b)     # element-wise multiplication
    print(a.dot(b))     # dot product of vectors
    print(np.dot(a, b)) # the same dot product of vectors
    print(a @ b)        # the same dot product of vectors
    print(np.outer(a, b)) # outer product of vectors

    print('Matrix:')
    P = np.array([[1, 2], [3, 4], [5, 6]]) # matrix (of size 3 rows X 2 columns)
    R = np.array([[9,8], [7,6]])
    print('Matrix P:\n{}\nShape of P: {}\n'.format(P, P.shape))
    print('Matrix R:\n{}\nShape of R: {}\n'.format(P, R.shape))
    print(P.dot(R))     # matrix multiplication
    print(np.dot(P, R)) # the same matrix multiplication
    print(P @ R)        # the same matrix multiplication
    # print(np.dot(R, P)) # dimensions do not match, matrix multiplication will raise an error
    print(a @ P)        # vector * matrix (classic dot multiplication)
    """
