import torch
import torch.nn as nn
import torch.nn.functional as F
from torch.autograd import Variable
from collections import deque
import numpy as np

"""## Step 1: We initialize the Experience Replay memory"""

class ReplayBuffer(object):

  def __init__(self, max_size=1e6):
    self.storage = []
    self.max_size = max_size
    self.ptr = 0

  def add(self, transition):
    if len(self.storage) == self.max_size:
      self.storage[int(self.ptr)] = transition
      self.ptr = (self.ptr + 1) % self.max_size
    else:
      self.storage.append(transition)

  def sample(self, batch_size):
    ind = np.random.randint(0, len(self.storage), size=batch_size)
    batch_states, batch_next_states, batch_actions, batch_rewards, batch_dones = [], [], [], [], []
    for i in ind: 
      state, next_state, action, reward, done = self.storage[i]
      batch_states.append(np.array(state, copy=False))
      batch_next_states.append(np.array(next_state, copy=False))
      batch_actions.append(np.array(action, copy=False))
      batch_rewards.append(np.array(reward, copy=False))
      batch_dones.append(np.array(done, copy=False))
    return np.array(batch_states), np.array(batch_next_states), np.array(batch_actions).reshape(-1, 1), np.array(batch_rewards).reshape(-1, 1), np.array(batch_dones).reshape(-1, 1)


"""## Step 2: We build one neural network for the Actor model and one neural network for the Actor target"""

def critic_CNN_Model():
    return nn.Sequential(
        nn.Conv2d(1, 8, 3, 1, 0, bias=False),
        nn.ReLU(True), nn.BatchNorm2d(8),
        nn.Conv2d(8, 16, 3, 1, 0, bias=False),
        nn.ReLU(True), nn.BatchNorm2d(16),
        nn.Conv2d(16, 32, 3, 1, 0, bias=False),
        nn.ReLU(True), nn.BatchNorm2d(32),
        nn.MaxPool2d(kernel_size=2, stride=2),
        nn.Dropout(p=0.8, inplace=True),
        nn.Conv2d(32, 8, 3, 1, 0, bias=False),
        nn.ReLU(True), nn.BatchNorm2d(8),
        nn.Conv2d(8, 16, 3, 1, 0, bias=False),
        nn.ReLU(True), nn.BatchNorm2d(16),
        nn.Conv2d(16, 24, 3, 1, 0, bias=False),
        nn.ReLU(True), nn.BatchNorm2d(24),
        nn.Conv2d(24, 32, 3, 1, 0, bias=False),
        nn.ReLU(True), nn.BatchNorm2d(32),
        nn.Conv2d(32, 48, 3, 1, 0, bias=False),
        nn.ReLU(True), nn.BatchNorm2d(48),
        nn.AvgPool2d(kernel_size = 7),
        nn.Flatten())

class Actor(nn.Module):
  def __init__(self, state_dim, action_dim, max_action):
    super(Actor, self).__init__()
    self.main = nn.Sequential(
        nn.Conv2d(1, 8, 3, 1, 0, bias=False),
        nn.ReLU(True), nn.BatchNorm2d(8),
        nn.Conv2d(8, 16, 3, 1, 0, bias=False),
        nn.ReLU(True), nn.BatchNorm2d(16),
        nn.Conv2d(16, 32, 3, 1, 0, bias=False),
        nn.ReLU(True), nn.BatchNorm2d(32),
        nn.MaxPool2d(kernel_size=2, stride=2),
        nn.Dropout(p=0.8, inplace=True),
        nn.Conv2d(32, 8, 3, 1, 0, bias=False),
        nn.ReLU(True), nn.BatchNorm2d(8),
        nn.Conv2d(8, 16, 3, 1, 0, bias=False),
        nn.ReLU(True), nn.BatchNorm2d(16),
        nn.Conv2d(16, 24, 3, 1, 0, bias=False),
        nn.ReLU(True), nn.BatchNorm2d(24),
        nn.Conv2d(24, 32, 3, 1, 0, bias=False),
        nn.ReLU(True), nn.BatchNorm2d(32),
        nn.Conv2d(32, 48, 3, 1, 0, bias=False),
        nn.ReLU(True), nn.BatchNorm2d(48),
        nn.AvgPool2d(kernel_size = 7),
        nn.Flatten(),nn.Linear(48, 24),
        nn.Linear(24, 1))
    self.max_action = max_action

  def forward(self, x):
    x = self.main(x)
    x = self.max_action * torch.tanh(x)
    return x

"""## Step 3: We build two neural networks for the two Critic models and two neural networks for the two Critic targets"""

class Critic(nn.Module):
  
  def __init__(self, state_dim, action_dim):
    super(Critic, self).__init__()
    self.critic_CNN = critic_CNN_Model()
    # Defining the first Critic neural network
    self.critic_1_L1 = nn.Linear(49, 24)
    self.critic_1_L2 = nn.Linear(24, 1)
    # Defining the second Critic neural network
    self.critic_2_L1 = nn.Linear(49, 24)
    self.critic_2_L2 = nn.Linear(24, 1)


  def forward(self, x, u):
    # Forward-Propagation on the first Critic Neural Network
    x1 = self.critic_CNN(x)
    x1 = torch.cat([x1, u], 1)
    x1 = self.critic_1_L1(x1)
    x1 = self.critic_1_L2(x1)
    # Forward-Propagation on the second Critic Neural Network
    x2 = self.critic_CNN(x)
    x2 = torch.cat([x2, u], 1)
    x2 = self.critic_2_L1(x2)
    x2 = self.critic_2_L2(x2)
    return x1, x2

  def Q1(self, x, u):
    x2 = self.critic_CNN(x)
    x2 = torch.cat([x2, u], 1)
    x2 = self.critic_2_L1(x2)
    x2 = self.critic_2_L2(x2)
    return x1

"""## Steps 4 to 15: Training Process"""

# Selecting the device (CPU or GPU)
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")

# Building the whole Training Process into a class

class TD3(object):
  def __init__(self, state_dim, action_dim, max_action):
    self.actor = Actor(state_dim, action_dim, max_action).to(device)
    self.actor_target = Actor(state_dim, action_dim, max_action).to(device)
    self.actor_target.load_state_dict(self.actor.state_dict())
    self.actor_optimizer = torch.optim.Adam(self.actor.parameters())
    self.critic = Critic(state_dim, action_dim).to(device)
    self.critic_target = Critic(state_dim, action_dim).to(device)
    self.critic_target.load_state_dict(self.critic.state_dict())
    self.critic_optimizer = torch.optim.Adam(self.critic.parameters())
    self.max_action = max_action

  def select_action(self, state):
    state = torch.tensor(state).float().unsqueeze(0).to(device)
    return int(self.actor(state).cpu().data.numpy().flatten().clip(-self.max_action, self.max_action))


  def train(self, replay_buffer, iterations, batch_size=100, discount=0.99, tau=0.005, policy_noise=0.2, noise_clip=0.5, policy_freq=2):
    
    for it in range(iterations):
      
      # Step 4: We sample a batch of transitions (s, s’, a, r) from the memory
      batch_states, batch_next_states, batch_actions, batch_rewards, batch_dones = replay_buffer.sample(batch_size)
      state = torch.Tensor(batch_states).to(device)
      next_state = torch.Tensor(batch_next_states).to(device)
      action = torch.Tensor(batch_actions).to(device)
      reward = torch.Tensor(batch_rewards).to(device)
      done = torch.Tensor(batch_dones).to(device)
      
      # Step 5: From the next state s’, the Actor target plays the next action a’
      next_action = self.actor_target(next_state)
      
      # Step 6: We add Gaussian noise to this next action a’ and we clamp it in a range of values supported by the environment
      noise = torch.Tensor(batch_actions).data.normal_(0, policy_noise).to(device)
      noise = noise.clamp(-noise_clip, noise_clip)
      next_action = (next_action + noise).clamp(-self.max_action, self.max_action)
      
      # Step 7: The two Critic targets take each the couple (s’, a’) as input and return two Q-values Qt1(s’,a’) and Qt2(s’,a’) as outputs
      target_Q1, target_Q2 = self.critic_target(next_state, next_action)
      
      # Step 8: We keep the minimum of these two Q-values: min(Qt1, Qt2)
      target_Q = torch.min(target_Q1, target_Q2)
      
      # Step 9: We get the final target of the two Critic models, which is: Qt = r + γ * min(Qt1, Qt2), where γ is the discount factor
      target_Q = reward + ((1 - done) * discount * target_Q).detach()
      
      # Step 10: The two Critic models take each the couple (s, a) as input and return two Q-values Q1(s,a) and Q2(s,a) as outputs
      current_Q1, current_Q2 = self.critic(state, action)
      
      # Step 11: We compute the loss coming from the two Critic models: Critic Loss = MSE_Loss(Q1(s,a), Qt) + MSE_Loss(Q2(s,a), Qt)
      critic_loss = F.mse_loss(current_Q1, target_Q) + F.mse_loss(current_Q2, target_Q)
      
      # Step 12: We backpropagate this Critic loss and update the parameters of the two Critic models with a SGD optimizer
      self.critic_optimizer.zero_grad()
      critic_loss.backward()
      self.critic_optimizer.step()
      
      # Step 13: Once every two iterations, we update our Actor model by performing gradient ascent on the output of the first Critic model
      if it % policy_freq == 0:
        actor_loss = -self.critic.Q1(state, self.actor(state)).mean()
        self.actor_optimizer.zero_grad()
        actor_loss.backward()
        self.actor_optimizer.step()
        
        # Step 14: Still once every two iterations, we update the weights of the Actor target by polyak averaging
        for param, target_param in zip(self.actor.parameters(), self.actor_target.parameters()):
          target_param.data.copy_(tau * param.data + (1 - tau) * target_param.data)
        
        # Step 15: Still once every two iterations, we update the weights of the Critic target by polyak averaging
        for param, target_param in zip(self.critic.parameters(), self.critic_target.parameters()):
          target_param.data.copy_(tau * param.data + (1 - tau) * target_param.data)
  
  # Making a save method to save a trained model
  def save(self, filename, directory):
    torch.save(self.actor.state_dict(), '%s/%s_actor.pth' % (directory, filename))
    torch.save(self.critic.state_dict(), '%s/%s_critic.pth' % (directory, filename))
  
  # Making a load method to load a pre-trained model
  def load(self, filename, directory):
    self.actor.load_state_dict(torch.load('%s/%s_actor.pth' % (directory, filename)))
    self.critic.load_state_dict(torch.load('%s/%s_critic.pth' % (directory, filename)))
