using System;
using System.Collections;
using System.IO;
using System.IO.Compression;
using System.Linq;
using System.Net;
using System.Net.Sockets;
using System.Security.Cryptography;
using System.Text;

namespace Gelf4NLog.Target
{
    public class UdpTransport : ITransport
    {
        //UDP datagrams are limited to a size of 8192 bytes.
        private const int MaxMessageSizeInUdp = 8192;
        //Chunk also contains 12 byte prefix, so 8192 - 12.
        private const int MaxMessageSizeInChunk = 8180;
        //Limitation from GrayLog2
        private const int MaxNumberOfChunksAllowed = 128;

        private readonly ITransportClient _transportClient;
        public UdpTransport(ITransportClient transportClient)
        {
            _transportClient = transportClient;
        }

        /// <summary>
        /// Sends a UDP datagram to GrayLog2 server
        /// </summary>
        /// <param name="serverIpAddress">IP address of the target GrayLog2 server</param>
        /// <param name="serverPort">Port number of the target GrayLog2 instance</param>
        /// <param name="message">Message (in JSON) to log</param>
        public void Send(string serverIpAddress, int serverPort, string message)
        {
            var ipAddress = IPAddress.Parse(serverIpAddress);
            var ipEndPoint = new IPEndPoint(ipAddress, serverPort);

            var compressedMessage = CompressMessage(message);

            if (compressedMessage.Length > MaxMessageSizeInUdp)
            {
                //Our compressed message is too big to fit in a single datagram. Need to chunk...
                //https://github.com/Graylog2/graylog2-docs/wiki/GELF "Chunked GELF"

                var numberOfChunksRequired = compressedMessage.Length / MaxMessageSizeInChunk + 1;
                if (numberOfChunksRequired > MaxNumberOfChunksAllowed) return;

                var messageId = GenerateMessageId(compressedMessage);

                for (var i = 0; i < numberOfChunksRequired; i++)
                {
                    var skip = i * MaxMessageSizeInChunk;
                    var messageChunkHeader = ConstructChunkHeader(messageId, i, numberOfChunksRequired);
                    var messageChunkData = compressedMessage.Skip(skip).Take(MaxMessageSizeInChunk).ToArray();

                    var messageChunkFull = new byte[messageChunkHeader.Length + messageChunkData.Length];
                    messageChunkHeader.CopyTo(messageChunkFull, 0);
                    messageChunkData.CopyTo(messageChunkFull, messageChunkHeader.Length);

                    _transportClient.Send(messageChunkFull, messageChunkFull.Length, ipEndPoint);
                }
            }
            else
            {
                _transportClient.Send(compressedMessage, compressedMessage.Length, ipEndPoint);
            }
        }

        /// <summary>
        /// Chunk header structure is:
        /// - Chunked GELF ID: 0x1e 0x0f (identifying this message as a chunked GELF message)
        /// - Message ID: 8 bytes (Must be the same for every chunk of this message. Identifying the whole message itself and is used to reassemble the chunks later.)
        /// - Sequence Number: 1 byte (The sequence number of this chunk)
        /// - Total Number: 1 byte (How many chunks does this message consist of in total)
        /// </summary>
        /// <param name="messageId">Unique identifier of the whole message (not just this chunk)</param>
        /// <param name="chunkSequenceNumber">Sequence number of this chunk</param>
        /// <param name="chunkCount">Total number of chunks whole message consists of</param>
        /// <returns>Chunk header in bytes</returns>
        private static byte[] ConstructChunkHeader(byte[] messageId, int chunkSequenceNumber, int chunkCount)
        {
            var b = new byte[12];

            b[0] = 0x1e;
            b[1] = 0x0f;
            messageId.CopyTo(b, 2);
            b[10] = (byte)chunkSequenceNumber;
            b[11] = (byte)chunkCount;

            return b;
        }

        /// <summary>
        /// Compresses the given message using GZip algorithm
        /// </summary>
        /// <param name="message">Message to be compressed</param>
        /// <returns>Compressed message in bytes</returns>
        private static byte[] CompressMessage(String message)
        {
            var compressedMessageStream = new MemoryStream();
            using (var gzipStream = new GZipStream(compressedMessageStream, CompressionMode.Compress))
            {
                var messageBytes = Encoding.UTF8.GetBytes(message);
                gzipStream.Write(messageBytes, 0, messageBytes.Length);
            }

            return compressedMessageStream.ToArray();
        }

        /// <summary>
        /// Generates a unique identifier for the whole message.
        /// Message id is composed of
        /// - 3rd segment of the IP address - 8 bits
        /// - 4th segment of the IP address - 8 bits
        /// - DateTime.Now.Second - 6 bits
        /// - First 42 bits of MD5 hash of compressed message
        /// </summary>
        /// <returns></returns>
        private static byte[] GenerateMessageId(byte[] compressedMessage)
        {
            //create a bit array to store the entire message id (which is 8 bytes)
            var bitArray = new BitArray(64);

            //Read the server ip address
            var ipAddresses = Dns.GetHostAddresses(Dns.GetHostName());
            var ipAddress =
                (from ip in ipAddresses where ip.AddressFamily == AddressFamily.InterNetwork select ip).FirstOrDefault();

            if (ipAddress == null)
                return null;

            //read bytes of the last 2 segments and insert bits into the bit array
            var addressBytes = ipAddress.GetAddressBytes();
            AddToBitArray(bitArray, 0, addressBytes[2], 0, 8);
            AddToBitArray(bitArray, 8, addressBytes[3], 0, 8);

            //read the current second and insert 6 bits into the bit array
            var second = DateTime.Now.Second;
            AddToBitArray(bitArray, 16, (byte)second, 0, 6);

            //generate the MD5 hash of the compressed message
            byte[] hashOfCompressedMessage;
            using (var md5 = MD5.Create())
            {
                hashOfCompressedMessage = md5.ComputeHash(compressedMessage);
            }

            //insert the first 42 bits into the bit array
            var startIndex = 22;
            for (var hashByteIndex = 0; hashByteIndex < 5; hashByteIndex++)
            {
                var hashByte = hashOfCompressedMessage[hashByteIndex];
                AddToBitArray(bitArray, startIndex, hashByte, 0, 8);
                startIndex += 8;
            }

            //copy all bits from bit array into a byte[]
            var result = new byte[8];
            bitArray.CopyTo(result, 0);

            return result;
        }

        /// <summary>
        /// Inserts bits from the given byte into the given BitArray instance.
        /// </summary>
        /// <param name="bitArray">BitArray instance to be populated with bits</param>
        /// <param name="bitArrayIndex">Index pointer in BitArray to start inserting bits from</param>
        /// <param name="byteData">Byte to extract bits from and insert into the given BitArray instance</param>
        /// <param name="byteDataIndex">Index pointer in byteData to start extracting bits from</param>
        /// <param name="length">Number of bits to extract from byteData</param>
        private static void AddToBitArray(BitArray bitArray, int bitArrayIndex, byte byteData, int byteDataIndex, int length)
        {
            var localBitArray = new BitArray(new[] { byteData });

            for (var i = byteDataIndex + length - 1; i >= byteDataIndex; i--)
            {
                bitArray.Set(bitArrayIndex, localBitArray.Get(i));
                bitArrayIndex++;
            }
        }
    }
}