using System;
using System.Buffers.Binary;
using System.Collections.Generic;
using System.IO;
using System.IO.Compression;
using System.Net;
using System.Text;
using skyscraper5.Skyscraper.IO;

namespace skyscraper5.Skyscraper.Gps.GeoIp
{
	internal class MaxMindDb : IDisposable
	{
		public MaxMindDb(FileInfo fi)
		{
			FileStream fileStream = fi.OpenRead();
			if (fileStream.ReadUInt8() != 0x1f)
				throw new GeoIpException(GeoIpError.NotGzip, 1000);
			if (fileStream.ReadUInt8() != 0x8b)
				throw new GeoIpException(GeoIpError.NotGzip, 1000);
			
			fileStream.Position = 0;

			ms = new MemoryStream();
			GZipStream gzipReader = new GZipStream(fileStream, CompressionMode.Decompress);
			gzipReader.CopyTo(ms);
			gzipReader.Close();
			fileStream.Close();

			byte[] arrayed = ms.ToArray();
			ms = new MemoryStream(arrayed);
			int metadataOffset = FindMetadataOffset(arrayed);
			ms.Position = metadataOffset;
			Tuple<string, object>[] metadata = (Tuple<string, object>[])ReadObject();
			SanitizeMetadata(metadata);
			ipv4StartNode = FindIpv4StartNode();
		}

		public GpsCoordinate Locate(IPAddress ipAddress)
		{
			int prefixLength;
			uint pointer = FindAddressInTree(ipAddress, out prefixLength);

			uint resolved = pointer + DataPointerOffset;
			if (resolved >= ms.Length)
			{
				throw new GeoIpException(GeoIpError.MmdbError, 1000, "DB seems broken.");
			}

			ms.Position = resolved;
			
			Tuple<string,object>[] packedObject = (Tuple<string, object>[])ReadObject();
			GpsCoordinate result = UnravelObject(packedObject);
			return result;
		}

		private GpsCoordinate UnravelObject(Tuple<string, object>[] raw)
		{
			string cityName = null;
			string countryName = null;
			GpsCoordinate? location = null;
			foreach (Tuple<string, object> tuple in raw)
			{
				switch (tuple.Item1)
				{
					case "city":
						cityName = UnravelCityName((Tuple<string, object> [])tuple.Item2);
						break;
					case "continent":
						//we won't need that
						break;
					case "country":
						countryName = UnravelCountry((Tuple<string, object>[])tuple.Item2);
						break;
					case "location":
						location = UnravelLocation((Tuple<string, object>[])tuple.Item2);
						break;
					case "subdivisions":
						//probably not interesting to us
						break;
					default:
						throw new NotImplementedException(tuple.Item1);
				}
			}

			if (location.HasValue)
				return location.Value;

			if (!string.IsNullOrEmpty(countryName) && !string.IsNullOrEmpty(cityName))
				return GeoDecode(cityName, countryName);

			throw new NotImplementedException(nameof(UnravelObject));
		}

		private GpsCoordinate GeoDecode(string cityName, string countryName)
		{
			//TODO: Hardcode some coordinates
			throw new NotImplementedException();
		}

		private GpsCoordinate? UnravelLocation(Tuple<string, object>[] raw)
		{
			double? lon = null, lat = null;
			foreach (Tuple<string, object> tuple in raw)
			{
				switch (tuple.Item1)
				{
					case "latitude":
						lat = (double)tuple.Item2;
						break;
					case "longitude":
						lon = (double)tuple.Item2;
						break;
					default:
						throw new NotImplementedException(tuple.Item1);
				}
			}

			if (lon.HasValue && lat.HasValue)
				return new GpsCoordinate((float)lat.Value, (float)lon.Value);
			else
				return null;
		}

		private string UnravelCountry(Tuple<string, object>[] raw)
		{
			string isoCode;
			foreach (Tuple<string, object> tuple in raw)
			{
				switch (tuple.Item1)
				{
					case "geoname_id":
					case "is_in_european_union":
						break;  //not interesting to us.
					case "iso_code":
						isoCode = (string)tuple.Item2;
						break;
					case "names":
						string countryName = UnravelLocalizedString((Tuple<string, object>[])tuple.Item2);
						return countryName;
					default:
						throw new NotImplementedException(tuple.Item1);
				}
			}

			throw new NotImplementedException(nameof(UnravelCountry));
		}
		
		private string UnravelCityName(Tuple<string, object>[] raw)
		{
			foreach (Tuple<string, object> tuple in raw)
			{
				switch (tuple.Item1)
				{
					case "names":
						return UnravelLocalizedString((Tuple<string, object>[])tuple.Item2);
					default:
						throw new NotImplementedException(tuple.Item1);
				}
			}

			throw new NotImplementedException(nameof(UnravelCityName));
		}

		private string UnravelLocalizedString(Tuple<string, object>[] raw)
		{
			string otherLanguage = null;
			foreach (Tuple<string, object> tuple in raw)
			{
				switch (tuple.Item1)
				{
					case "en":
						return (string)tuple.Item2;
					default:
						otherLanguage = (string)tuple.Item2;
						break;
				}
			}

			return string.IsNullOrEmpty(otherLanguage) ? null : otherLanguage;
		}
		
		private uint DataPointerOffset
		{
			get
			{
				return SearchTreeSectionSize - nodeCount;
			}
		}
		
		private void SanitizeMetadata(Tuple<string, object>[] metadata)
		{
			ushort? binary_format_major_version = null, binary_format_minor_version = null;
			foreach (Tuple<string, object> tuple in metadata)
			{
				switch (tuple.Item1)
				{
					case "node_count":
						nodeCount = (uint)tuple.Item2;
						break;
					case "record_size":
						recordSize = (ushort)tuple.Item2;
						break;
					case "ip_version":
						ipVersion = (ushort)tuple.Item2;
						break;
					case "database_type":
						databaseType = (string)tuple.Item2;
						break;
					case "languages":
						object[] language2 = (object[])tuple.Item2;
						languages = new string[language2.Length];
						for (int i = 0; i < language2.Length; i++)
						{
							languages[i] = (string)language2[i];
						}
						break;
					case "binary_format_major_version":
						binary_format_major_version = (ushort)tuple.Item2;
						break;
					case "binary_format_minor_version":
						binary_format_minor_version = (ushort)tuple.Item2;
						break;
					case "build_epoch":
						ulong unixTimeStamp64 = (ulong)tuple.Item2;
						build = new DateTime(1970, 1, 1, 0, 0, 0, 0, DateTimeKind.Utc);
						build = build.AddSeconds(unixTimeStamp64).ToLocalTime();
						break;
					case "description":
						Tuple<string, object>[] descs = (Tuple<string, object>[])tuple.Item2;
						description = new Dictionary<string, string>();
						foreach (var (item1, item2) in descs)
						{
							description.Add(item1, (string)item2);
						}
						break;
				}
			}
			if (binary_format_major_version.HasValue && binary_format_minor_version.HasValue)
				binaryFormatVersion = new Version((int)binary_format_major_version, (int)binary_format_minor_version);
		}

		private MemoryStream ms;
		private uint nodeCount;
		private ushort recordSize;
		private string databaseType;
		private string[] languages;
		private DateTime build;
		private Dictionary<string,string> description;
		private Version binaryFormatVersion;
		private ushort ipVersion;
		private uint ipv4StartNode;

		private int NodeByteSize
		{
			get
			{
				return recordSize / 4;
			}
		}
		private uint SearchTreeSectionSize
		{
			get
			{
				return (uint)(((recordSize * 2) / 8) * nodeCount);
			}
		}
		public void Dispose()
		{
			ms.Dispose();
		}

		private int FindMetadataOffset(byte[] buffer)
		{
			int offset = buffer.Length - (128 * 1024);
			for (; offset < buffer.Length; offset++)
			{
				if (buffer[offset + 0] == 0xab)
					if (buffer[offset + 1] == 0xcd)
						if (buffer[offset + 2] == 0xef)
							if (buffer[offset + 3] == 'M')
								if (buffer[offset + 4] == 'a')
									if (buffer[offset + 5] == 'x')
										if (buffer[offset + 6] == 'M')
											if (buffer[offset + 7] == 'i')
												if (buffer[offset + 8] == 'n')
													if (buffer[offset + 9] == 'd')
														if (buffer[offset + 10] == '.')
															if (buffer[offset + 11] == 'c')
																if (buffer[offset + 12] == 'o')
																	if (buffer[offset + 13] == 'm')
																		return offset + 14;
			}

			throw new GeoIpException(GeoIpError.InvalidMmdb, 1000);
		}
		
		private object ReadObject()
		{
			byte controlByte = ms.ReadUInt8();

			int type = controlByte >> 5;
			int size = (controlByte & 0x01f);

			if (type == 0)
			{
				type = ms.ReadUInt8() + 7;
			}

			if (size == 29)
			{
				size += ms.ReadUInt8();
			}
			else if (size == 30)
			{
				throw new NotImplementedException();
			}
			else if (size == 31)
			{
				throw new NotImplementedException();
			}

			switch (type)
			{
				case 1: //Pointer 
					int pointerSizeType = size >> 3;
					pointerSizeType &= 0x03;
					uint pointerValue = (uint)(size & 0x07);
					if (pointerSizeType == 0)
					{
						byte a = ms.ReadUInt8();
						pointerValue <<= 8;
						pointerValue += a;
					}
					else if (pointerSizeType == 1)
					{
						pointerValue <<= 16;
						pointerValue += (uint)(ms.ReadUInt8() << 8);
						pointerValue += (uint)(ms.ReadUInt8());
						pointerValue += 2048;
					}
					else if (pointerSizeType == 2)
					{
						pointerValue <<= 24;
						pointerValue += (uint)(ms.ReadUInt8() << 16);
						pointerValue += (uint)(ms.ReadUInt8() << 8);
						pointerValue += (uint)(ms.ReadUInt8());
						pointerValue += 526336;
					}
					else if (pointerSizeType == 3)
						throw new NotImplementedException("ptrSizeType 3");
					else
						throw new AccessViolationException();

					long stackedPosition = ms.Position;
					pointerValue += SearchTreeSectionSize;
					pointerValue += 16;
					ms.Position = pointerValue;
					object result = ReadObject();
					ms.Position = stackedPosition;
					return result;
				case 2: //UTF-8 String
					byte[] utf8Bytes = ms.ReadBytes(size);
					string utf8String = Encoding.UTF8.GetString(utf8Bytes);
					return utf8String;
				case 3:
					byte[] doubleBufferBacking = ms.ReadBytes(8);
					ReadOnlySpan<Byte> doubleBuffer = new ReadOnlySpan<byte>(doubleBufferBacking);
					double doubleResult = BinaryPrimitives.ReadDoubleBigEndian(doubleBuffer);
					return doubleResult;
				case 4: //bytes
					byte[] bytesBytes = ms.ReadBytes(size);
					return bytesBytes;
				case 5: //uint16
					ushort uint16Result;
					if (size == 1)
						uint16Result = ms.ReadUInt8();
					else if (size == 0)
						uint16Result = 0;
					else
						throw new NotImplementedException(String.Format("uint16 with {0} bytes", size));
					return uint16Result;
				case 6: //uint32
					uint uint32Result;
					if (size == 4)
						uint32Result = ms.ReadUInt32BE();
					else if (size == 3)
					{
						uint32Result = ms.ReadUInt16BE();
						uint32Result <<= 8;
						uint32Result += ms.ReadUInt8();
					}
					else
						throw new NotImplementedException(String.Format("uint32 with {0} bytes", size));
					return uint32Result;
				case 7: //map
					Tuple<string, object>[] resultMap = new Tuple<string, object>[size];
					for (int i = 0; i < size; i++)
					{
						string key = (string)ReadObject();
						object value = ReadObject();
						resultMap[i] = new Tuple<string, object>(key, value);
					}
					return resultMap;
				case 9: //uint64
					ulong uint64Result;
					if (size == 8)
						uint64Result = ms.ReadUInt64BE();
					else if (size == 4)
						uint64Result = ms.ReadUInt32BE();
					else
						throw new NotImplementedException(String.Format("uint64 with {0} bytes", size));
					return uint64Result;
				case 11: //array
					object[] resultArray = new object[size];
					for (int i = 0; i < size; i++)
					{
						resultArray[i] = ReadObject();
					}

					return resultArray;
				case 14: //boolean
					bool boolResult;
					if (size == 1)
						boolResult = true;
					else if (size == 0)
						boolResult = false;
					else
						throw new GeoIpException(GeoIpError.MmdbError, 1000, "Bad boolean data");
					return boolResult;
				default:
					//https://maxmind.github.io/MaxMind-DB/
					throw new NotImplementedException(String.Format("type {0}", type));
			}
		}

		private uint FindAddressInTree(IPAddress address, out int prefixLength)
		{
			byte[] rawAddress = address.GetAddressBytes();

			int bitLength = rawAddress.Length * 8;
			uint record = ipVersion == 6 && bitLength == 32 ? ipv4StartNode : 0;
			uint nodeCount = this.nodeCount;

			var i = 0;
			for (; i < bitLength && record < nodeCount; i++)
			{
				int bit = 1 & (rawAddress[i >> 3] >> (7 - (i % 8)));
				record = ReadNode(record, bit);
			}
			prefixLength = i;
			if (record == nodeCount)
			{
				// record is empty
				return 0;
			}
			if (record > nodeCount)
			{
				// record is a data pointer
				return record;
			}
			throw new GeoIpException(GeoIpError.MmdbError, 1000, "Something bad happened");
		}

		private uint FindIpv4StartNode()
		{
			if (ipVersion == 4)
				return 0;

			uint node = 0;
			for (int i = 0; i < 96 && node < this.nodeCount; i++)
			{
				node = ReadNode(node);
			}
			return node;
		}

		private uint ReadNode(uint nodeNumber, int index = 0)
		{
			ms.Position = nodeNumber * NodeByteSize;
			ushort size = recordSize;

			switch (size)
			{
				case 24:
				{
					ms.Position = ms.Position + (index * 3);
					//return _database.ReadVarInt(offset, 3);
					throw new NotImplementedException();
				}
				case 28:
				{
					if (index == 0)
					{
						uint v = ms.ReadUInt32BE();
						return (v & 0xF0) << 20 | (0xFFFFFF & (v >> 8));
					}

					ms.Position += 3;
					return ms.ReadUInt32BE() & 0x0FFFFFFF;
				}
				case 32:
				{
					ms.Position = ms.Position + (index * 4);
					//return _database.ReadInt(offset);
					throw new NotImplementedException();
				}
			}

			throw new NotImplementedException($"Unknown record size: {size}");
		}
	}
}
