using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using NLog;
using NzbDrone.Common.Disk;
using NzbDrone.Common.Extensions;
using NzbDrone.Common.TPL;
using NzbDrone.Core.Configuration;
using NzbDrone.Core.Configuration.Events;
using NzbDrone.Core.Datastore.Events;
using NzbDrone.Core.Lifecycle;
using NzbDrone.Core.MediaFiles.Commands;
using NzbDrone.Core.Messaging.Commands;
using NzbDrone.Core.Messaging.Events;
using NzbDrone.Core.RootFolders;

namespace NzbDrone.Core.MediaFiles
{
    public interface IRootFolderWatchingService
    {
        void ReportFileSystemChangeBeginning(params string[] paths);
    }

    public sealed class RootFolderWatchingService : IRootFolderWatchingService,
        IDisposable,
        IHandle<ModelEvent<RootFolder>>,
        IHandle<ApplicationStartedEvent>,
        IHandle<ConfigSavedEvent>
    {
        private const int DEBOUNCE_TIMEOUT_SECONDS = 30;

        private readonly ConcurrentDictionary<string, FileSystemWatcher> _fileSystemWatchers = new ConcurrentDictionary<string, FileSystemWatcher>();
        private readonly ConcurrentDictionary<string, int> _tempIgnoredPaths = new ConcurrentDictionary<string, int>();
        private readonly ConcurrentDictionary<string, string> _changedPaths = new ConcurrentDictionary<string, string>();

        private readonly IRootFolderService _rootFolderService;
        private readonly IManageCommandQueue _commandQueueManager;
        private readonly IConfigService _configService;
        private readonly Logger _logger;

        private readonly Debouncer _scanDebouncer;
        private bool _watchForChanges;

        public RootFolderWatchingService(IRootFolderService rootFolderService,
                                         IManageCommandQueue commandQueueManager,
                                         IConfigService configService,
                                         Logger logger)
        {
            _rootFolderService = rootFolderService;
            _commandQueueManager = commandQueueManager;
            _configService = configService;
            _logger = logger;

            _scanDebouncer = new Debouncer(ScanPending, TimeSpan.FromSeconds(DEBOUNCE_TIMEOUT_SECONDS), true);
        }

        public void Dispose()
        {
            foreach (var watcher in _fileSystemWatchers.Values)
            {
                DisposeWatcher(watcher, false);
            }
        }

        public void ReportFileSystemChangeBeginning(params string[] paths)
        {
            foreach (var path in paths.Where(x => x.IsNotNullOrWhiteSpace()))
            {
                _logger.Trace($"reporting start of change to {path}");
                _tempIgnoredPaths.AddOrUpdate(path.CleanFilePathBasic(), 1, (key, value) => value + 1);
            }
        }

        public void Handle(ApplicationStartedEvent message)
        {
            _watchForChanges = _configService.WatchLibraryForChanges;

            if (_watchForChanges)
            {
                _rootFolderService.All().ForEach(x => StartWatchingPath(x.Path));
            }
        }

        public void Handle(ConfigSavedEvent message)
        {
            var oldWatch = _watchForChanges;
            _watchForChanges = _configService.WatchLibraryForChanges;

            if (_watchForChanges != oldWatch)
            {
                if (_watchForChanges)
                {
                    _rootFolderService.All().ForEach(x => StartWatchingPath(x.Path));
                }
                else
                {
                    _rootFolderService.All().ForEach(x => StopWatchingPath(x.Path));
                }
            }
        }

        public void Handle(ModelEvent<RootFolder> message)
        {
            if (message.Action == ModelAction.Created && _watchForChanges)
            {
                StartWatchingPath(message.Model.Path);
            }
            else if (message.Action == ModelAction.Deleted)
            {
                StopWatchingPath(message.Model.Path);
            }
        }

        private void StartWatchingPath(string path)
        {
            // Already being watched
            if (_fileSystemWatchers.ContainsKey(path))
            {
                return;
            }

            // Creating a FileSystemWatcher over the LAN can take hundreds of milliseconds, so wrap it in a Task to do them all in parallel
            Task.Run(() =>
            {
                try
                {
                    var newWatcher = new FileSystemWatcher(path, "*")
                    {
                        IncludeSubdirectories = true,
                        InternalBufferSize = 65536,
                        NotifyFilter = NotifyFilters.DirectoryName | NotifyFilters.FileName | NotifyFilters.LastWrite
                    };

                    newWatcher.Created += Watcher_Changed;
                    newWatcher.Deleted += Watcher_Changed;
                    newWatcher.Renamed += Watcher_Changed;
                    newWatcher.Changed += Watcher_Changed;
                    newWatcher.Error += Watcher_Error;

                    if (_fileSystemWatchers.TryAdd(path, newWatcher))
                    {
                        newWatcher.EnableRaisingEvents = true;
                        _logger.Info("Watching directory {0}", path);
                    }
                    else
                    {
                        DisposeWatcher(newWatcher, false);
                    }
                }
                catch (Exception ex)
                {
                    _logger.Error(ex, "Error watching path: {0}", path);
                }
            });
        }

        private void StopWatchingPath(string path)
        {
            if (_fileSystemWatchers.TryGetValue(path, out var watcher))
            {
                DisposeWatcher(watcher, true);
            }
        }

        private void Watcher_Error(object sender, ErrorEventArgs e)
        {
            var ex = e.GetException();
            var dw = (FileSystemWatcher)sender;

            if (ex.GetType() == typeof(InternalBufferOverflowException))
            {
                _logger.Warn(ex, "The file system watcher experienced an internal buffer overflow for: {0}", dw.Path);

                _changedPaths.TryAdd(dw.Path, dw.Path);
                _scanDebouncer.Execute();
            }
            else
            {
                _logger.Error(ex, "Error in Directory watcher for: {0}" + dw.Path);

                DisposeWatcher(dw, true);
            }
        }

        private void Watcher_Changed(object sender, FileSystemEventArgs e)
        {
            try
            {
                var rootFolder = ((FileSystemWatcher)sender).Path;
                var path = e.FullPath;

                if (path.IsNullOrWhiteSpace())
                {
                    throw new ArgumentNullException("path");
                }

                _changedPaths.TryAdd(path, rootFolder);

                _scanDebouncer.Execute();
            }
            catch (Exception ex)
            {
                _logger.Error(ex, "Exception in ReportFileSystemChanged. Path: {0}", e.FullPath);
            }
        }

        private void ScanPending()
        {
            var pairs = _changedPaths.ToArray();
            _changedPaths.Clear();

            var ignored = _tempIgnoredPaths.Keys.ToArray();
            _tempIgnoredPaths.Clear();

            var toScan = new HashSet<string>();

            foreach (var item in pairs)
            {
                var path = item.Key.CleanFilePathBasic();
                var rootFolder = item.Value;

                if (!ShouldIgnoreChange(path, ignored))
                {
                    _logger.Trace("Actioning change to {0}", path);
                    toScan.Add(rootFolder);
                }
                else
                {
                    _logger.Trace("Ignoring change to {0}", path);
                }
            }

            if (toScan.Any())
            {
                _commandQueueManager.Push(new RescanFoldersCommand(toScan.ToList(), FilterFilesType.Known, true, null));
            }
        }

        private bool ShouldIgnoreChange(string cleanPath, string[] ignoredPaths)
        {
            var cleaned = cleanPath.CleanFilePathBasic();

            // Skip partial/backup
            if (cleanPath.EndsWith(".partial~") ||
                cleanPath.EndsWith(".backup~"))
            {
                return true;
            }

            // only proceed for directories and files with music extensions
            var extension = Path.GetExtension(cleaned);
            if (extension.IsNullOrWhiteSpace() && !Directory.Exists(cleaned))
            {
                return true;
            }

            if (extension.IsNotNullOrWhiteSpace() && !MediaFileExtensions.AllExtensions.Contains(extension))
            {
                return true;
            }

            // If the parent of an ignored path has a change event, ignore that too
            // Note that we can't afford to use the PathEquals or IsParentPath functions because
            // these rely on disk access which is too slow when trying to handle many update events
            return ignoredPaths.Any(i => i.Equals(cleaned, DiskProviderBase.PathStringComparison) ||
                                    i.StartsWith(cleaned + Path.DirectorySeparatorChar, DiskProviderBase.PathStringComparison) ||
                                    Path.GetDirectoryName(i).Equals(cleaned, DiskProviderBase.PathStringComparison));
        }

        private void DisposeWatcher(FileSystemWatcher watcher, bool removeFromList)
        {
            try
            {
                using (watcher)
                {
                    _logger.Info("Stopping directory watching for path {0}", watcher.Path);

                    watcher.Created -= Watcher_Changed;
                    watcher.Deleted -= Watcher_Changed;
                    watcher.Renamed -= Watcher_Changed;
                    watcher.Changed -= Watcher_Changed;
                    watcher.Error -= Watcher_Error;

                    try
                    {
                        watcher.EnableRaisingEvents = false;
                    }
                    catch (InvalidOperationException)
                    {
                        // Seeing this under mono on linux sometimes
                        // Collection was modified; enumeration operation may not execute.
                    }
                }
            }
            catch
            {
                // we don't care about exceptions disposing
            }
            finally
            {
                if (removeFromList)
                {
                    _fileSystemWatchers.TryRemove(watcher.Path, out _);
                }
            }
        }
    }
}
