# --------------------------------------------------------------------------- #
# Includes
# --------------------------------------------------------------------------- #
import os, time
import django
import threading
import datetime

#from firmware.endpoints.constants import INF_INTERVAL, SUP_INTERVAL
from .timerlib import RepeatedTimer

from Main.firmware.endpoints import motor, laser,hardware,track,ads

from Main.Modbus.modbus_poll import Ito_modbus

import numpy as np

import pandas as pd

### Hilo guardar mediciones
import threading
from queue import Queue

### Paquetes para validacion de conexion DB externa
from django.db import connections, OperationalError, transaction

# --------------------------------------------------------------------------- #
# IMPORT MODBUS COM
# --------------------------------------------------------------------------- #
#from modbus_api import ModbusAPI

data_queue = Queue()

# --------------------------------------------------------------------------- #
# Interface Tracker Class
# --------------------------------------------------------------------------- #

os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'IT_GUI.settings')
django.setup()
from Main.models import *
#from django.db.models import Q
from ..Modbus.adios_plc import Bomba_class

## Clase que almacena la logica de control del puente, lectura de laseres y guardado en DB.
class ITO_class():
    def __init__(self):
        ## Bool que indica que el puente se halla en la interfaz
        self.at_interface = False
        ## Ancho de banda para "apretar" la interfaz respecto al gap = abs(laser1 - laser 3)
        self.bandwidth = 0.20
        ## Factor de corrección para mediciones
        self.corr_fact = [1, 1, 1]
        ## Entrada de DB del actual experimento
        self.current_exp_db = None
        ## Estado de la ejecución de un experimento; True si se esta realizando un experimento
        self.current_state = False
        ## Dataframe en la cual se alamacena la data
        self.data_measurements = pd.DataFrame(columns = ["time", "height", "sensor1",
                                                        "sensor2", "sensor3"])
        ## Largo salto para movimiento del puente (mm)
        self.dh = 0.5
        ## Intervalo de tiempo para RepeatedTimer
        self.dt = 0.2
        ## Usado para medir tiempo de lectura
        self.elapsed_time = None
        ## Frecuencia para señales PWM de laseres
        self.lasers_frequencies = [86, 86, 86]

        ## Definición para el sistema de altura maxima del puente (mm)
        self.max_height = 280
        ## Diccionario de datos para iniciar experimento
        self.metadata = {}
        ## Usado para medir tiempo de lectura
        self.start_time = None
        ## Timer que se incrementa cuando el puente se alinea con la interfaz y esta no se mueve.
        self.steady_timer = 0
        ## Tiempo de espera maximo en segundos para detener el experimento al encontrarse con la interfaz
        self.max_espera = 500
        ## Umbral v_mth a ser superado si se considera que el puente está en la zona turbia: min(laser1, laser2, laser3) > v_mth
        self.threshold_meas = 85
        ## Umbral v_tth a ser superado para que el puente baje si |laser1 - laser3| > v_tth
        self.threshold_tracker = 25
        ## Bool para indicar si de debe guardar un dato
        self.record_data = False
        ## Variable que almacena la fecha y hora para el experimento.
        self.f_inicio_exp = datetime.datetime.now()

        ## Bool que indica si se activa o no la medición automatica.
        self.automatico_var = False

        ## Inicializa clase para activar laseres
        self.lasers = [ laser.Laser(6, 1.0, self.lasers_frequencies[0]),
                        laser.Laser(12, 1.0, self.lasers_frequencies[1]),
                        laser.Laser(13, 1.0, self.lasers_frequencies[2])]
        ## Inicializa clase para control de las bombas
        self.bombas = Bomba_class()
        ## Inicializa clase para movimiento del puente
        self.puente = motor.Puente()
        ## Inicializa clase para leer datos de los sensores
        self.light_sensors = ads.Ads()

        if self.automatico_var:
            ## Declaración y ejecución del hilo para la parada de emergencia
            self.tt_emergencia = threading.Thread(target=self.check_emergencia).start()

            ## Declaración y ejecución del hilo para medición automatica
            self.automatico = threading.Thread(target=self.medicion_automatica).start()

    ## Función que sobreescribe el valor del humbral para el laser.
    def ajustar_umbral(self, nuevoUmbral):
        self.threshold_tracker = nuevoUmbral
    
    ## Función que crea un hilo de fondo que va a estar leyendo constantemente los datos de la cola y guardándolos en la base de datos.
    def background_save_data(self):
        while self.current_state:
            data = data_queue.get()     # Leer los datos de la cola
            self.save_data_queue_to_db(data) # Guardar los datos en la base de datos
            data_queue.task_done()      # Indicar que se ha procesado un elemento de la cola

    ## Función que retorna los valores de reducción o aumento de las lecturas de sensores.
    def get_sensors_meas(self):
        return self.light_sensors.sense(self.corr_fact)

    ## Función que transforma los valores temporales STR en datetime
    def correct_time_format(self, horas, minutos, segundos):
        if int(horas) == 0 and int(minutos) == 0 and int(segundos) == 0:
            return None
        else:
            return datetime.timedelta(hours=int(horas),minutes=int(minutos),seconds=int(segundos))

    ## Función que mueve el puente a set_point milimetros medidos desde la altura 0 en la parte inferior.
    def set_height(self, new_height): 
        if self.puente.move_puente(new_height):
            self.puente.height = new_height
        """
        if set_point > self.max_height:
            print("Error: La altura escogida excede limites permitidos, puente bajando a 0.")
            self.height = motor.height_setup(0)
            return True
        else:
            motor.set_altura_proceso(set_point)
            self.height = set_point
            print("Nueva altura:", set_point)
            return True                    
        """

    ## Función que inicia un experimento.
    def start_exp(self, metadata, debug = False):
        if self.current_state: # Solo comienza un experimento si no hay un thread de experimento activo
            print("El dispositivo ya está realizando un experimento.")
            return False
        else:
            self.current_state = True

        self.start_time = time.perf_counter()
        self.metadata = metadata # Crea metadata con datos basicos del experimento
         
        laser.turn_all_laser_on()
        self.exp_max_time = self.correct_time_format(self.metadata['horas'],
                                                     self.metadata['minutos'],
                                                     self.metadata['segundos'])
        #self.save_experiment_to_db(metadata) # Guarda instancia del experimento a la base de datos
        self.colbun_entrada_exp_db()
        #Sin esta linea no se genera el ID para el guardado final y falla
        #Incluso, esto puede estar retrasando un monton las ejecuciones de codigo
        #Ya que, guarda en la DB tick a tick y no un bulk de data.
        #Deberia de almacenar la data en un metodo y solictarse desde otro, más no desde la DB.
        time.sleep(2) # Esperamos 15 segundos antes de empezar a medir
        ###### Hilo save_data
        threading.Thread(target=self.background_save_data, daemon=True).start() # Comienza thread de guardado de datos
        ###### Timers de medición
        self.steady_timer = 0
        #self.rt = RepeatedTimer(self.dt, self.tracking) # Comienza threads de tracking del puente
        #self.rt.start()

        self.tt_lectura = threading.Thread(target=self.colb_track).start() # Comienza threads de tracking del puente
        return True

    ## Función que guarda la data obtenida desde la medición.
    def save_data_queue_to_db(self, data):
        measurement = Measurements( time=data['time'],
                                    height=data['height'],
                                    sensor1=data['sensor1'],
                                    sensor2=data['sensor2'],
                                    sensor3=data['sensor3'],
                                    experiment = self.current_exp_db)
        measurement.save()

    ## Función que crea una entrada de DB para la configuración de especifica de Colbun.
    def colbun_entrada_exp_db(self):
        exp = Experimentos_Colbun()
        exp.duracion = datetime.timedelta(0, 0, 0)
        exp.save()
        self.current_exp_db = exp

    ## Función que crea una entrada de DB para el experimento a realizar.
    # @dict metadata Diccionario con detalles del experimento.
    def save_experiment_to_db(self, metadata):
        exp = Experiments()
        exp.operador = metadata['operador']
        exp.experimento = metadata['experimento']
        exp.alturaInicial = metadata['alturaInicial']
        exp.alturaMaxima = metadata['alturaMaxima']
        exp.duracion = datetime.timedelta(0, 0, 0)
        if self.exp_max_time:#Si esta vacio, el ORM lo rellena con un delta en 0 por defecto
            exp.duracionMaxima = self.exp_max_time
        exp.temperatura = metadata['temperatura']
        exp.comentarios = metadata['comentarios']
        exp.save()
        self.current_exp_db = exp

    ## Función que detiene el experimento en curso.
    # @str flag string que indica la razon de detención del experimento.
    def stop_exp(self, flag):
        if self.current_state:

            self.current_exp_db.duracion = datetime.timedelta(seconds=(datetime.datetime.now()-self.f_inicio_exp).seconds)
            self.current_exp_db.save()
            self.stop_threads()
            self.current_state = False
            self.elapsed_time = 0
            print('Experimento parado en fase %s.' % flag)
            self.guardar_db_externa()
            return True
        else:
            return False
    
    ## Función que detiene el hilo de ejecución automatica.
    def stop_threads(self):
        try:
            self.tt_lectura.stop()
        except:
            pass

    ## Funcion llamada por thread creado en start_exp para realizar lectura, movimiento y guardado de data.
    def tracking(self):
        self.f_inicio_exp = datetime.datetime.now()
        while self.current_state:
            samples = self.light_sensors.sense(self.corr_fact) # Toma self.light_sensors.nsamples mediciones y calcula el máximo de cada una de las 3 componentes
            
            if self.at_interface:
                track_state = track.track_ec(   samples,
                                                self.threshold_tracker,
                                                self.bandwidth)
                # Función para mover el puente que ya está en la interfaz
                if track_state:
                    print(self.puente.height, samples)
                    if self.puente.move_puente(self.puente.height - self.dh):# and (self.height < self.max_height): # El motor se mueve si track.track_ec(...) es True y la altura maxima no se ha excedido
                        #self.puente.height -= self.dh
                        self.record_data = True # Solo cuando el motor se acaba de mover y la siguiente instancia del thread de tracking retorna track_state = False, se guarda un dato
                        self.steady_timer = 0
                    else:
                        self.stop_exp("Fin de carrera")
                    if np.abs(self.puente.height) < self.dh / 2: # Esta linea si bien, no deberia de estar. Sirve, dado que extrañamente en el proceso automatico la altura en valor logico cerca del final se presenta en 0.0 (pero no toca el fin de carrera) y la altura fisica son 5 mm. En otras palabras, hay un desface de 5 mm fisicos que deja el experimento en un loop hasta que se termine por el elapsed time
                        self.stop_exp("Fin de carrera")
                else:
                    if self.record_data: # Solo cuando el motor se acaba de mover y la siguiente instancia del thread de tracking retorna track_state = False, se guarda un dato
                        new_row = { 'time': self.elapsed_time, 
                                    'height': self.puente.height, 
                                    'sensor1': samples[0], 
                                    'sensor2': samples[1], 
                                    'sensor3': samples[2]}
                        self.data_measurements.loc[len(self.data_measurements)] = \
                                new_row
                        ### Agrega a la cola nueva medición
                        data_queue.put(new_row)
                        """print("Laser en la interfaz: %.4f %.4f" % \
                                (self.elapsed_time, self.puente.height))
                        print("(l,m,u) = (%i,%i,%i)" % \
                            (samples[0], samples[1], samples[2]))"""
                        self.record_data = False
                        self.steady_timer = 0
                    else:
                        self.steady_timer += self.dt
                        #print('steady_timer=' + str(self.steady_timer))

                
            else:
                track_state = track.track_gs(samples, self.threshold_meas)
                if track_state:
                    if self.puente.move_puente(self.puente.height - self.dh):
                        self.steady_timer = 0
                    else:
                        self.stop_exp("Fin de carrera")
                else:
                    self.at_interface = True
                # Función para mover el puente hacia la interfaz
            
            
            self.elapsed_time = round(time.perf_counter() - self.start_time, 7)
            # Tiempo transcurrido desde inicio del experimento
            
            current_data = [    self.puente.height,
                                samples] # Fila a ser guardada en la base de datos
            
            if self.steady_timer > self.max_espera:
                self.stop_exp('Finalizacion por puente fijo a los ' \
                + str(self.current_exp_db.duracion.seconds) + ' segundos')
            if self.exp_max_time is not None:
                if self.elapsed_time >= self.exp_max_time.seconds:
                    self.stop_exp("Finalizacion por tiempo")
        #else:
            #self.rt.stop()

    ## Funcion llamada por thread creado en start_exp para realizar lectura, movimiento y guardado de data con configuración especifica de Colbun.
    def colb_track(self):
        self.f_inicio_exp = datetime.datetime.now()
        while self.current_state:
            t_in = time.perf_counter()
            hs = [5,75,130,260]
            for _ in range(20):
            #hs = [280,140,70,5]
                for v in hs:
                    self.set_height(v)
                    measure = int(self.light_sensors.sense_cb(500, 2))
                    t = round(time.perf_counter() - t_in,2)
                    print(v, t, measure)
                    muestra = Mediciones(
                        altura = v,
                        tiempo = t,
                        v_lectura = measure,
                        experimento = self.current_exp_db
                    )
                    muestra.save()
                self.set_height(5)
            self.stop_exp("Finalizacion por tiempo")


    ## Función que enciende n° laser.
    # @int laser_num ID del laser.
    def turn_laser_on(self, laser_num):
        laser.turn_laser_on(laser_num) # Prende laser i, i=0,1,2.

    ## Función que apaga n° laser.
    # @int laser_num ID del laser.
    def turn_laser_off(self, laser_num):
        laser.turn_laser_off(laser_num) # Apaga laser i, i=0,1,2.
    
    ## Función destinada a preparar el llenado de la probeta.
    # @dict metadata contiene la altura a la que se debe posicionar el puente.
    def preparar_medicion(self, metadata):
        #self.puente.Auto_movimiento(metadata['alturaInicial'])
        if self.bombas.Estado()[0] == 2:
            print('Avisar y detener todo')
            return

        self.bombas.Activar_bomba(self.bombas.id_vaciado,direccion=1) # Vacia la probeta si es que tiene contenido
        time.sleep(26)
        self.bombas.Detener_bomba(self.bombas.id_vaciado)
        time.sleep(1)
        self.bombas.Activar_bomba(self.bombas.id_vaciado,direccion=0) # Llena la probeta
        time.sleep(23)
        self.bombas.Detener_bomba(self.bombas.id_vaciado)

    ## Función que lee desde el bus del PLC-Raspberry (RS-485) y frena todo el sistema del Genko al accionar la parada de emergencia.
    def check_emergencia(self):
        import signal
        self.PLC_Modbus = Ito_modbus()
        while True:
            if self.PLC_Modbus.modbus_context_read(register=0x03, address=0x00, count=16)[0] != 0:
                self.current_state = False
        
                self.bombas.Detener_bomba(29)
                break

            time.sleep(0.5)
        print('Parada de emergencia detectada')
        os.kill(os.getpid(), signal.SIGUSR1)

    ## Función que ejecuta indefinidamente los experimentos del Genko.
    def medicion_automatica(self):
        time.sleep(25) # Esperar a que el puente termine el ciclo de establecer su posicion
        while True:
            metadata = {'operador': 'API',
                    'experimento': 'exp_API',
                    'alturaInicial': 270, # Cambiado a 250
                    'alturaMaxima': 300,
                    'temperatura': 0,
                    'duracion': datetime.timedelta(0, 0, 0),
                    'horas': 0,
                    'minutos': 0,
                    'segundos': 0,
                    'comentarios': '',
                    }
            
            self.preparar_medicion(metadata=metadata)
            self.start_exp(metadata=metadata)
            while self.current_state:
                time.sleep(2)

    ## Función que valida la conexión con la DB externa.
    def validar_conexion_remota(self):
        db_con = connections['external_db']
        try:
            db_con.ensure_connection()
            return True
        except OperationalError:
            return False

    ## Función que guarda la data del experimento finalizado en bulk a la DB externa.
    def guardar_db_externa(self):
        if self.validar_conexion_remota():

            #Crear entrada de experimento para la DB remota
            exp_r = Experimentos_Colbun(
                fecha = self.current_exp_db.fecha,
                duracion = self.current_exp_db.duracion
            )
            with transaction.atomic(using='external_db'):
                exp_r.save(using='external_db')

            #Desde DB con la id del ultimo experimento, obtener mediciones y mandarlas en un bulk
            data = Mediciones.objects.filter(experimento__id=self.current_exp_db.id)
            if len(data) > 0:
                listado_bulk = []
                for medicion in data:
                    listado_bulk.append(
                        Mediciones(
                            altura = medicion.altura,
                            tiempo = medicion.tiempo,
                            v_lectura = medicion.v_lectura,
                            experimento = exp_r
                        )
                    )
                
                #Guardar data en la DB externa utilizando "atomic". Esto hace que si falla el guardado revierta la ultima accion. (Tambien funciona para hacer una cola de acciones asegurando que si uno falla. Revierta todos los cambios)
                with transaction.atomic(using='external_db'):
                    Mediciones.objects.using('external_db').bulk_create(listado_bulk) 

    ## Función que retorna la sumatoria del estado de los modulos del Genko.
    def estado_modulos(self):
        estados = {
            'bombas':self.bombas.Estado(),
            'motor':self.puente.Estado(),
            'sensores':self.light_sensors.Estado(),
            'laseres':[x for x in [self.lasers[0].Estado(),self.lasers[1].Estado(),self.lasers[2].Estado()]],
            'medicion': self.automatico_var,
        }
        return estados
    
    ## Funcion para envio de correos
    def enviar_email(error):
        import smtplib
        from email.mime.multipart import MIMEMultipart
        from email.mime.text import MIMEText
        
        mensaje = MIMEMultipart()
        mensaje['From'] = 'aviso.caidas@hibring.cl'
        mensaje['To'] = 'gsantana@hibring.com'
        mensaje['Subject'] = 'Aviso de error en Genko'
        texto = f'El modulo {error} no ha respodido o iniciado correctamente.'

        mensaje.attach(MIMEText(texto,'plain'))

        session = smtplib.SMTP('main.hibring.cl',25)
        session.starttls()
        session.login('aviso.caidas@hibring.cl','k4?)*CoZP,fQ')
        session.sendmail('aviso.caidas@hibring.cl','gsantana@hibring.com',mensaje.as_string())
        session.quit()
