#define TRACE
#define DEBUG
// EvaPos-API : servidor api, sockets y rest.
using System.Net;
using System.Net.Sockets;
using System.Text;
using System.Xml;
using Serilog;
using Serilog.Events;
using GatewaySCO;
using EvaPosSrvDTO;
using EvaPosSrvResp;
using EvaPosSrvAplicacion;
using EvaPOS_API_FRAME.Comandos;
using gatewaySCO.POSBC;

namespace EvaPosSCOSrv
{
    /// <summary>
    ///  Esta clase <c>ServidorSocket</c>activa servidor socktes.
    ///  Usa por default ip localhost 127.0.0.1 y puerto 11.0000
    ///  La operación del servidor es como sigue:
    ///     
    /// </summary>
    public class ServidorSocket
    {
        ILogger log = Log.ForContext<ServidorSocket>();
        readonly static bool _isDebug = Log.IsEnabled(LogEventLevel.Debug);
        /// <summary>
        /// Longitud máxima de mensaje de entrada.
        /// </summary>
        public const Int32 LongMaxMensaje = 1_024 * 32;
        /// <summary>
        /// Dirección ip para vincular el socket.
        /// </summary>
        public string Direccion { get; private set; } = "127.0.0.1";
        /// <summary>
        /// Puerto a vincular socket.
        /// </summary>
        public Int32 Puerto { get; private set; } = 11_000;
        /// <summary>
        /// Longitud de la cola de conexión al socket.
        /// </summary>
        public int LongColaConexiones { get; private set; } = 10;
        /// <summary>
        /// Fija timeout usado para enviar / recibir em modo sincrónico, en milisegundos.
        /// Si el timeout se excede, se presenta una excepción. 
        /// Default a 5 segundos.
        /// </summary>
        public int TimeOutSincMs { get; private set; } = 3000;
        long _numeroConexionesEntrantes = 0;

        DirectorioAdaptadoresDTO _adaptadores;
        CreaDirectorioCmds _comandos;
        Sesion _sesion;
        Presentacion _presentacion;
        IAplicacion _aplicacion;

        /// <summary>
        /// Constructor servidor socket. Usa ip y puerto defualt.
        /// El método IniciarAync() activa el servidor.
        /// </summary>
        public ServidorSocket() { }

        /// <summary>
        /// Constructor servidor socket. Usa ip y puerto defualt.
        /// El método IniciarAync() activa el servidor.
        /// <param name="direccion">Dirección IP servidor, típicamente '127.0.0.1'.</param>
        /// <param name="puerto">Número de puerto para el socket, default 11.000</param>
        /// <returns>Retorna tipo Task.</returns>
        /// </summary>
        public ServidorSocket(string direccion, Int32 puerto)
        {
            Direccion = direccion;
            Puerto = puerto;
        }

        /// <summary>
        /// Fija IP del servidor.
        /// </summary>
        public ServidorSocket ConIp(string direccion)
        {
            Direccion = direccion;
            return this;
        }

        /// <summary>
        /// Fija puerto del servidor.
        /// </summary>
        public ServidorSocket EnPuerto(int puerto)
        {
            Puerto = puerto;
            return this;
        }

        /// <summary>
        /// Fija directorio de adaptadores.
        /// </summary>
        public ServidorSocket AgregaDispensadorAdaptadores(DirectorioAdaptadoresDTO adaptadores)
        {
            _adaptadores = adaptadores;
            return this;
        }

        /// <summary>
        /// Fija directorio de comandos.
        /// </summary>
        public ServidorSocket AgregaDirectorioCmds(CreaDirectorioCmds comandos)
        {
            _comandos = comandos;
            return this;
        }

        /// <summary>
        /// Fija aplicación a procesar comandos.
        /// </summary>
        public ServidorSocket AgregaProcesadorAplicacion(IAplicacion aplicacion)
        {
            _aplicacion = aplicacion;
            return this;
        }

        // TODO - este metodo no se puede usar en la invocación fluida, estudiar.
        /// <summary>
        /// Pone en marcha el servidor socket, vinculando a la IP y puertos asignados.
        /// </summary>        
        public ServidorSocket Activa()
        {
            _sesion = new Sesion();
            _presentacion = new Presentacion(_adaptadores, _comandos);
            ActivaServidor();
            return this;
        }

        /// <summary>
        /// Activa el servidor en ip y puerto indicado en los parámetros globales.
        /// <returns>Retorna tipo Task.</returns>
        /// </summary>
        private void ActivaServidor()
        {
            IPEndPoint ipEndPoint = new(IPAddress.Parse(Direccion), Puerto);
            int cont = 0;
            // Clave usar 'using' para liberar correctamente recursos.
            using Socket tcpSocket = new(
                ipEndPoint.AddressFamily,
                SocketType.Stream,
                ProtocolType.Tcp);

            // Configuración comportamiento socket tcp.
            // No se permite a otro socket compartir el puerto.
            // TODO - Investigar: cuando servidor y cliente están en la misma instanción de s.o.
            //          no comparten el puerto?.
            //tcpSocket.ExclusiveAddressUse = true;
            // El socket espera los segundos del parémetro para terminar de enviar
            // datos (si hay en el buffer de salida), despues que se llama Socket.Close.
            tcpSocket.LingerState = new LingerOption(true, 3);
            // Desactiva algoritmo Nagle.
            tcpSocket.NoDelay = true;
            // Timeout entrada / salida.
            tcpSocket.ReceiveTimeout = 0;
            tcpSocket.SendTimeout = TimeOutSincMs;
            //tcpSocket.Blocking = false;
            tcpSocket.Bind(ipEndPoint);
            tcpSocket.Listen(LongColaConexiones);

            // Inicializa objetos de procesamiento de los mensajes de comunicación.
            Sesion sesion = new Sesion();
            Presentacion presentacion = new Presentacion(_adaptadores, _comandos);

            // Id proceso. Compilación difiere según .net usado.
            int id = -1;
#if NETFRAMEWORK
                id = Process.GetCurrentProcess().Id;
#elif (NETSTANDARD || NET5_0_OR_GREATER)
            id = Environment.ProcessId;
#endif

            log.Information("EvaPOS servidor socket en {ip} : {puerto}, proceso id {id}", Direccion, Puerto, id);
            if (_isDebug)
            {
                log.Debug("Versión .Net {version}", Environment.Version);
                log.Debug("Longitud máxima mensaje aceptado bytes {long}", LongMaxMensaje);
                Log.Debug("Tcp Socket configuración:");
                Log.Debug($"  Blocking  {tcpSocket.Blocking}");
                Log.Debug($"  Version eva 1.3");
                Log.Debug($"  ExclusiveAddressUse {tcpSocket.ExclusiveAddressUse}");
                Log.Debug($"  LingerState {tcpSocket.LingerState.Enabled}, {tcpSocket.LingerState.LingerTime}");
                Log.Debug($"  NoDelay {tcpSocket.NoDelay}");
                Log.Debug($"  ReceiveBufferSize {tcpSocket.ReceiveBufferSize}");
                Log.Debug($"  ReceiveTimeout {tcpSocket.ReceiveTimeout}");
                Log.Debug($"  SendBufferSize {tcpSocket.SendBufferSize}");
                Log.Debug($"  SendTimeout {tcpSocket.SendTimeout}");
                Log.Debug($"  Ttl {tcpSocket.Ttl}");
                Log.Debug($"  IsBound {tcpSocket.IsBound}");
            }
            // TODO - El socket temporal debe liberar recursos?
            // Socket entrada.
            Socket socketEntrada;
            try
            {
                bool continuar = true;
                while (continuar)
                {
                    log.Information("Esperando conexión...");
                    socketEntrada = tcpSocket.Accept();
                    continuar = ProcesaConexion(socketEntrada, _numeroConexionesEntrantes++);

                    // TODO - Validar lo siguiente.
                    // Manejo de situación en la cual CHEC mantiene abierta la conexión pero
                    // remite un comando TERMINATE, con lo cual, hay que reciclar la conexión:
                    // se cierra el socket y se abre nuevamente.
                    while (!continuar)
                    {
                        tcpSocket.Close();

                        IPEndPoint ipEndPoint2 = new(IPAddress.Parse(Direccion), Puerto);

                        using Socket tcpSocket2 = new(
                            ipEndPoint2.AddressFamily,
                            SocketType.Stream,
                            ProtocolType.Tcp);

                        tcpSocket2.LingerState = new LingerOption(true, 3);
                        tcpSocket2.NoDelay = true;
                        tcpSocket2.ReceiveTimeout = 0;
                        tcpSocket2.SendTimeout = TimeOutSincMs;
                        tcpSocket2.Bind(ipEndPoint);
                        tcpSocket2.Listen(LongColaConexiones);

                        Sesion sesion2 = new Sesion();
                        Presentacion presentacion2 = new Presentacion(_adaptadores, _comandos);
                        socketEntrada = tcpSocket2.Accept();
                        continuar = ProcesaConexion(socketEntrada, _numeroConexionesEntrantes++);
                    }

                }
                if (tcpSocket.Connected)
                    tcpSocket.Shutdown(SocketShutdown.Both);
                log.Information("Conexion Cerrada");
            }
            finally
            {
                tcpSocket.Close();
            }
        }

        /// <summary>
        /// Procesa socket entrada de conexión.
        /// </summary>
        private bool ProcesaConexion(Socket socket, long nroConexion)
        {
            Respuestas respuestas = null;
            int contIngreso = 0;
            if (_isDebug)
            {
                Log.Debug("Conexión remota #{nro} ip {ip} en puerto {pto}",
                    nroConexion,
                    IPAddress.Parse(((IPEndPoint)socket.RemoteEndPoint).Address.ToString()),
                    ((IPEndPoint)socket.RemoteEndPoint).Port.ToString());
                contIngreso++;
            }
            bool continua = true;
            while (continua)
            {
                try
                {
                    // Lee mensajes hasta que llegue mensaje de terminación o socket cerrado por el cliente.
                    // Lee longitud mensaje entrante, 4 bytes.
                    Log.Debug("Esperando mensaje...");
                    var bufferLongitud = new byte[4];
                    int bytesLeidos = 0;
                    while (bytesLeidos < 4)
                    {
                        bytesLeidos += socket.Receive(bufferLongitud, bytesLeidos, bufferLongitud.Length, SocketFlags.None);
                    }

                    Log.Debug("Arriba un mensaje.");
                    // Lee porción de datos del mensaje, hasta la longitud indicada en los 4 primeros bytes.
                    int longitudMensaje = Util.LongitudCodificada(bufferLongitud);
                    if (longitudMensaje > LongMaxMensaje) throw new Exception($"Mensaje {longitudMensaje} bytes supera máximo permitido de {LongMaxMensaje} bytes.");
                    var bufferEntrada = new byte[longitudMensaje];
                    bytesLeidos = 0;
                    while (bytesLeidos < longitudMensaje)
                    {
                        bytesLeidos += socket.Receive(bufferEntrada, bytesLeidos, bufferEntrada.Length, SocketFlags.None);
                    }
                    Log.Information("Nuevo mensaje {bytes} bytes.", bytesLeidos);

                    string tipoPOS = Entorno<Config>.Instancia.get().POS;

                    // -------------------------------------------------------------------------------
                    // Procesa mensajes desde CHEC según la modalida de configuración del Gateway
                    // -------------------------------------------------------------------------------

                    // --- Caso de pruebas mdalidad ECO: lo que recibe, 
                    //     lo transmite a POSBC y retorna a CHEC sus respuestas sin cambios.
                    if (tipoPOS == "ECO_POSBC")
                    {
                        // Modo "past-throught": lo que entra se remite sin cambios al POSBC y 
                        // su respeusta se remite sin cambios a CHEC.
                        byte[] mensajeEntrada = Util.ConcatenaArreglosBytes(bufferLongitud, bufferEntrada);
                        // Enviar mensaje de entrada a POSBC y retornar respuestas.

                        // Procesando entrada: se obtiene mensaje, con el cual se 
                        // identifica comando que lo procesa.
                        TramaSCO msj = Sesion.Entrada(bufferEntrada, bytesLeidos);
                        IComando cmd = _presentacion.Entrada(msj);
                        if (cmd.Referencia == "scsns:Terminate")
                        {
                            continua = false;
                        }

                        byte[] mensajeSalida = Entorno<EntornoPOSBC>.Instancia.get().ClientePOSBC.EnviaRecibe(mensajeEntrada);
                        // Remitir respuestas sin cambio a CHEC.
                        socket.Send(mensajeSalida, 0, mensajeSalida.Length, SocketFlags.None);

                    }
                    else
                    {
                        // --- Caso de operación normal, convierte mensajes en comandos para procesamiento.

                        // Procesando entrada: se obtiene mensaje, con el cual se 
                        // identifica comando que lo procesa, se ejecuta el comando
                        // y se retornarn respuestas al cliente que ha emitido el mensaje. 
                        TramaSCO msj = Sesion.Entrada(bufferEntrada, bytesLeidos);
                        IComando cmd = _presentacion.Entrada(msj);

                        respuestas = _aplicacion.Procesar(cmd);
                        Log.Information("Comando '{cmd}'", cmd.Referencia);
                        int i = 1;
                        foreach (var respuesta in respuestas)
                        {
                            // Enviando respuestas.
                            var bufferSalida = Sesion.Salida(respuesta.TramaSCO);
                            socket.Send(bufferSalida, 0, bufferSalida.Length, SocketFlags.None);
                            Log.Information("Respuesta {i}/{total} remitida, {bytes} bytes", i, respuestas.Count, bufferSalida.Length);
                        }

                        if (cmd.Referencia == "scsns:Terminate")
                        {
                            continua = false;
                        }
                    }
                }
                catch (SocketException ex)
                {
                    if (ex.SocketErrorCode == SocketError.ConnectionAborted)
                    {
                        log.Warning("Conexión abortada por el equipo remoto.");
                    }
                    else
                    {
                        log.Error("Error de Socket: {error}", ex);
                    }

                    continua = false;
                }
                catch (Exception e)
                {
                    log.Error("Error : {error}", e);
                    continua = false;
                }

            }
            return continua;
        }
    }

    /// <summary>
    ///  Clase que controla el nivel básico de entrada y salida de mensajes.
    ///  Extrae la información del mensaje, limpiando la información de control.
    /// </summary>
    class Sesion
    {
        static ILogger log = Log.ForContext<Sesion>();
        readonly static bool _isDebug = Log.IsEnabled(LogEventLevel.Debug);

        /// <summary>
        ///  Interpreta arreglo de bytes en mensaje, extrayendo del arreglo de bytes
        ///  y copiando el contenido en un objeto tipo Trama.
        /// </summary>
        public static TramaSCO Entrada(byte[] buffer, int nroBytes)
        {
            if (_isDebug)
            {
                log.Debug("Buffer entrada: >>{subBuffer}<<", buffer);
            }

            TramaSCO trama = new()
            {
                Longitud = Convert.ToUInt32(nroBytes)
            };

            // Extrae encabezado. String con patron soeps~<texto>~ 
            string datos = Encoding.UTF8.GetString(buffer, 0, nroBytes);
            int inicioXML = datos.IndexOf("<");
            string parteEncabezado = datos.Substring(0, inicioXML);
            log.Debug("Encabezado: {encabezado}", parteEncabezado);

            // Extrae valor campo Session-Id en string. 
            int inicioSessionId = parteEncabezado.IndexOf("Session-Id");
            int finSessionId = parteEncabezado[inicioSessionId..].IndexOf("|");
            string sessionId = parteEncabezado.Substring(inicioSessionId, finSessionId);
            string valorSessionId = sessionId.Split('=')[1];
            trama.IdSesion = Int32.Parse(valorSessionId);

            // Extrae valor campo Message-Type en string.
            int inicioMessageType = parteEncabezado.IndexOf("Message-Type");
            int finMessageType = parteEncabezado.Substring(inicioMessageType).IndexOf("|");
            string messageType = parteEncabezado.Substring(inicioMessageType, finMessageType);
            string valorMessageType = messageType.Split('=')[1];
            trama.TipoMensaje = TipoMensaje.FijaTipoMensaje(valorMessageType);

            // Extraer contenido.
            trama.TextoXML = datos.Substring(inicioXML);
            log.Information("{contenido}", trama.TextoXML);
            return trama;
        }

        /// <summary>
        ///  Empaca string de entrada en arreglo de bytes. 
        ///  Agrega encabeado con longitud del mensaje (4 bytes big-endian). 
        ///  No interpreta string de mensaje.
        /// <returns>Arreglo bytes con mensaje y cabecera con su longitud en bytes. Mensaje codificado UTF-8.</returns>
        /// </summary>
        public static byte[] Salida(TramaSCO trama)
        {
            // Codifica longitud del mensaje en los 4 primeros bytes.
            byte[] bytesConLongMensaje = BitConverter.GetBytes(trama.Longitud);
            // Bytes mas significativos deben ir primero, usa 'big-endian'.
            if (BitConverter.IsLittleEndian)
                Array.Reverse(bytesConLongMensaje);
            Log.Debug("Longitud mensaje: {long} - bytes con longitud: {bytes}", trama.Longitud, bytesConLongMensaje);
            // Codifica en bytes texto del mensaje.
            byte[] bytesConMensaje = Encoding.UTF8.GetBytes(trama.TextoEncabezado + trama.TextoXML);
            Log.Debug("Texto mensaje: '{texto}'", trama.TextoEncabezado + trama.TextoXML);
            // Copia los 2 arreglos de bytes en un arreglo unificado.
            byte[] bytes = new byte[bytesConLongMensaje.Length + bytesConMensaje.Length];
            Buffer.BlockCopy(bytesConLongMensaje, 0, bytes, 0, bytesConLongMensaje.Length);
            Buffer.BlockCopy(bytesConMensaje, 0, bytes, bytesConLongMensaje.Length, bytesConMensaje.Length);
            if (_isDebug) log.Debug("Buffer salida: >>{bytes}<<", bytes);
            Log.Information("Mensaje {long} bytes", trama.Longitud);
            Log.Information(".. encabezado\n{encabezado}", trama.TextoEncabezado);
            Log.Information(".. contenido\n{contenido}", trama.TextoXML);
            return bytes;
        }
    }

    /// <summary>
    ///  Clase que interpreta los mensajes y los transforma en objetos DTO.
    /// </summary>
    class Presentacion
    {
        DirectorioAdaptadoresDTO _adaptadores;
        CreaDirectorioCmds _comandos;

        public Presentacion(DirectorioAdaptadoresDTO adaptadores, CreaDirectorioCmds comandos)
        {
            _comandos = comandos;
            _adaptadores = adaptadores;
        }
        /// <summary>
        ///  Retorna DTO con los datos del mensaje.
        /// </summary>
        public IComando Entrada(TramaSCO mensaje)
        {
            IComando cmd;
            try
            {
                XmlElement? docXml = mensaje.ContenidoXML.DocumentElement ?? throw new Exception("Contenido XML vacío.");
                XmlNode? nodoRaiz = docXml.SelectSingleNode(".") ?? throw new Exception("Contenido XML vacío.");
                Log.Debug("Mensaje, contenido xml: '{nodoInicial}'", Util.ContenidoXmlComoString(nodoRaiz));

                // Según el elemento XML raíz, se determina el comando y dto adecuados.
                // Se puede considerar incluir en el comando pasar los datos de mensaje
                // directamente, sin embargo, esto no se hace así: el comando solo debe
                // depender de los datos pasados en una DTO. De esta manera, el comando
                // es independizado del formato del mensaje. 
                // Al adaptador se encarga de tranformar los datos del mensaje en 
                // valores inicializados en el DTO.
                cmd = _comandos.ObtieneComando(nodoRaiz.Name);
                IAdaptadorDTO adaptador = _adaptadores.ObtieneAdaptador(nodoRaiz.Name);
                DTOBase dto = adaptador.ObtieneDTO(mensaje.IdSesion, mensaje.TipoMensaje, docXml);

                cmd.CargaDTO(dto);
            }
            catch (Exception e)
            {
                Log.Error("Excepción procesando Mensaje XML: {e}", e.Message);
                ErrorDTO dto = new(mensaje.IdSesion, mensaje.TipoMensaje, $"Mensaje XML con valor nulo o no reconocido: '{e.Message}'");
                cmd = new ErrorCmd();
                cmd.CargaDTO(dto);
            }
            return cmd;
        }
    }
}
