using System;
using System.Collections.Generic;
using System.Globalization;
using System.Net;
using System.Threading;
using System.Threading.Tasks;
using MediaBrowser.Common.Net;
using MediaBrowser.Controller;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Plugins;
using MediaBrowser.Model.Dlna;
using MediaBrowser.Model.Events;
using Microsoft.Extensions.Logging;
using Mono.Nat;

namespace Emby.Server.Implementations.EntryPoints
{
    public class ExternalPortForwarding : IServerEntryPoint
    {
        private readonly IServerApplicationHost _appHost;
        private readonly ILogger _logger;
        private readonly IHttpClient _httpClient;
        private readonly IServerConfigurationManager _config;
        private readonly IDeviceDiscovery _deviceDiscovery;

        private Timer _timer;

        private NatManager _natManager;

        public ExternalPortForwarding(ILoggerFactory loggerFactory, IServerApplicationHost appHost, IServerConfigurationManager config, IDeviceDiscovery deviceDiscovery, IHttpClient httpClient)
        {
            _logger = loggerFactory.CreateLogger("PortMapper");
            _appHost = appHost;
            _config = config;
            _deviceDiscovery = deviceDiscovery;
            _httpClient = httpClient;
            _config.ConfigurationUpdated += _config_ConfigurationUpdated1;
        }

        private void _config_ConfigurationUpdated1(object sender, EventArgs e)
        {
            _config_ConfigurationUpdated(sender, e);
        }

        private string _lastConfigIdentifier;
        private string GetConfigIdentifier()
        {
            var values = new List<string>();
            var config = _config.Configuration;

            values.Add(config.EnableUPnP.ToString());
            values.Add(config.PublicPort.ToString(CultureInfo.InvariantCulture));
            values.Add(_appHost.HttpPort.ToString(CultureInfo.InvariantCulture));
            values.Add(_appHost.HttpsPort.ToString(CultureInfo.InvariantCulture));
            values.Add(_appHost.EnableHttps.ToString());
            values.Add((config.EnableRemoteAccess).ToString());

            return string.Join("|", values.ToArray());
        }

        private async void _config_ConfigurationUpdated(object sender, EventArgs e)
        {
            if (!string.Equals(_lastConfigIdentifier, GetConfigIdentifier(), StringComparison.OrdinalIgnoreCase))
            {
                DisposeNat();

                await RunAsync();
            }
        }

        public Task RunAsync()
        {
            if (_config.Configuration.EnableUPnP && _config.Configuration.EnableRemoteAccess)
            {
                Start();
            }

            _config.ConfigurationUpdated -= _config_ConfigurationUpdated;
            _config.ConfigurationUpdated += _config_ConfigurationUpdated;

            return Task.CompletedTask;
        }

        private void Start()
        {
            _logger.LogDebug("Starting NAT discovery");
            if (_natManager == null)
            {
                _natManager = new NatManager(_logger, _httpClient);
                _natManager.DeviceFound += NatUtility_DeviceFound;
                _natManager.StartDiscovery();
            }

            _timer = new Timer(ClearCreatedRules, null, TimeSpan.FromMinutes(10), TimeSpan.FromMinutes(10));

            _deviceDiscovery.DeviceDiscovered += _deviceDiscovery_DeviceDiscovered;

            _lastConfigIdentifier = GetConfigIdentifier();
        }

        private async void _deviceDiscovery_DeviceDiscovered(object sender, GenericEventArgs<UpnpDeviceInfo> e)
        {
            if (_disposed)
            {
                return;
            }

            var info = e.Argument;

            if (!info.Headers.TryGetValue("USN", out string usn)) usn = string.Empty;

            if (!info.Headers.TryGetValue("NT", out string nt)) nt = string.Empty;

            // Filter device type
            if (usn.IndexOf("WANIPConnection:", StringComparison.OrdinalIgnoreCase) == -1 &&
                     nt.IndexOf("WANIPConnection:", StringComparison.OrdinalIgnoreCase) == -1 &&
                     usn.IndexOf("WANPPPConnection:", StringComparison.OrdinalIgnoreCase) == -1 &&
                     nt.IndexOf("WANPPPConnection:", StringComparison.OrdinalIgnoreCase) == -1)
            {
                return;
            }

            var identifier = string.IsNullOrWhiteSpace(usn) ? nt : usn;

            if (info.Location == null)
            {
                return;
            }

            lock (_usnsHandled)
            {
                if (_usnsHandled.Contains(identifier))
                {
                    return;
                }
                _usnsHandled.Add(identifier);
            }

            _logger.LogDebug("Found NAT device: " + identifier);

            if (IPAddress.TryParse(info.Location.Host, out var address))
            {
                // The Handle method doesn't need the port
                var endpoint = new IPEndPoint(address, info.Location.Port);

                IPAddress localAddress = null;

                try
                {
                    var localAddressString = await _appHost.GetLocalApiUrl(CancellationToken.None).ConfigureAwait(false);

                    if (Uri.TryCreate(localAddressString, UriKind.Absolute, out var uri))
                    {
                        localAddressString = uri.Host;

                        if (!IPAddress.TryParse(localAddressString, out localAddress))
                        {
                            return;
                        }
                    }
                }
                catch (Exception ex)
                {
                    _logger.LogError(ex, "Error");
                    return;
                }

                if (_disposed)
                {
                    return;
                }

                // This should never happen, but the Handle method will throw ArgumentNullException if it does
                if (localAddress == null)
                {
                    return;
                }

                var natManager = _natManager;
                if (natManager != null)
                {
                    await natManager.Handle(localAddress, info, endpoint, NatProtocol.Upnp).ConfigureAwait(false);
                }
            }
        }

        private void ClearCreatedRules(object state)
        {
            lock (_createdRules)
            {
                _createdRules.Clear();
            }
            lock (_usnsHandled)
            {
                _usnsHandled.Clear();
            }
        }

        void NatUtility_DeviceFound(object sender, DeviceEventArgs e)
        {
            if (_disposed)
            {
                return;
            }

            try
            {
                var device = e.Device;

                CreateRules(device);
            }
            catch
            {
                // Commenting out because users are reporting problems out of our control
                //_logger.LogError(ex, "Error creating port forwarding rules");
            }
        }

        private List<string> _createdRules = new List<string>();
        private List<string> _usnsHandled = new List<string>();
        private async void CreateRules(INatDevice device)
        {
            if (_disposed)
            {
                throw new ObjectDisposedException(GetType().Name);
            }

            // On some systems the device discovered event seems to fire repeatedly
            // This check will help ensure we're not trying to port map the same device over and over
            var address = device.LocalAddress;

            var addressString = address.ToString();

            lock (_createdRules)
            {
                if (!_createdRules.Contains(addressString))
                {
                    _createdRules.Add(addressString);
                }
                else
                {
                    return;
                }
            }

            try
            {
                await CreatePortMap(device, _appHost.HttpPort, _config.Configuration.PublicPort).ConfigureAwait(false);
            }
            catch (Exception ex)
            {
                _logger.LogError(ex, "Error creating http port map");
                return;
            }

            try
            {
                await CreatePortMap(device, _appHost.HttpsPort, _config.Configuration.PublicHttpsPort).ConfigureAwait(false);
            }
            catch (Exception ex)
            {
                _logger.LogError(ex, "Error creating https port map");
            }
        }

        private Task CreatePortMap(INatDevice device, int privatePort, int publicPort)
        {
            _logger.LogDebug("Creating port map on local port {0} to public port {1} with device {2}", privatePort, publicPort, device.LocalAddress.ToString());

            return device.CreatePortMap(new Mapping(Protocol.Tcp, privatePort, publicPort)
            {
                Description = _appHost.Name
            });
        }

        private bool _disposed = false;
        public void Dispose()
        {
            _disposed = true;
            DisposeNat();
        }

        private void DisposeNat()
        {
            _logger.LogDebug("Stopping NAT discovery");

            if (_timer != null)
            {
                _timer.Dispose();
                _timer = null;
            }

            _deviceDiscovery.DeviceDiscovered -= _deviceDiscovery_DeviceDiscovered;

            var natManager = _natManager;

            if (natManager != null)
            {
                _natManager = null;

                using (natManager)
                {
                    try
                    {
                        natManager.StopDiscovery();
                        natManager.DeviceFound -= NatUtility_DeviceFound;
                    }
                    catch (Exception ex)
                    {
                        _logger.LogError(ex, "Error stopping NAT Discovery");
                    }
                }
            }
        }
    }
}
