using System;
using System.Collections.Concurrent;
using System.IO;
using System.Net;
using System.Threading;
using System.Threading.Tasks;
using EmbedIO.Files.Internal;
using EmbedIO.Internal;
using EmbedIO.Utilities;

namespace EmbedIO.Files
{
    /// <summary>
    /// A module serving files and directory listings from an <see cref="IFileProvider"/>.
    /// </summary>
    /// <seealso cref="WebModuleBase" />
    public class FileModule : WebModuleBase, IDisposable, IMimeTypeCustomizer
    {
        /// <summary>
        /// <para>Default value for <see cref="DefaultDocument"/>.</para>
        /// </summary>
        public const string DefaultDocumentName = "index.html";

        private readonly string _cacheSectionName = UniqueIdGenerator.GetNext();
        private readonly MimeTypeCustomizer _mimeTypeCustomizer = new MimeTypeCustomizer();
        private readonly ConcurrentDictionary<string, MappedResourceInfo>? _mappingCache;

        private FileCache _cache = FileCache.Default;
        private bool _contentCaching = true;
        private string? _defaultDocument = DefaultDocumentName;
        private string? _defaultExtension;
        private IDirectoryLister? _directoryLister;
        private FileRequestHandlerCallback _onMappingFailed = FileRequestHandler.ThrowNotFound;
        private FileRequestHandlerCallback _onDirectoryNotListable = FileRequestHandler.ThrowUnauthorized;
        private FileRequestHandlerCallback _onMethodNotAllowed = FileRequestHandler.ThrowMethodNotAllowed;

        private FileCache.Section? _cacheSection;

        /// <summary>
        /// Initializes a new instance of the <see cref="FileModule"/> class,
        /// using the specified cache.
        /// </summary>
        /// <param name="baseRoute">The base route.</param>
        /// <param name="provider">An <see cref="IFileProvider"/> interface that provides access
        /// to actual files and directories.</param>
        /// <exception cref="ArgumentNullException"><paramref name="provider"/> is <see langword="null"/>.</exception>
        public FileModule(string baseRoute, IFileProvider provider)
            : base(baseRoute)
        {
            Provider = Validate.NotNull(nameof(provider), provider);
            _mappingCache = Provider.IsImmutable
                ? new ConcurrentDictionary<string, MappedResourceInfo>()
                : null;
        }

        /// <summary>
        /// Finalizes an instance of the <see cref="FileModule"/> class.
        /// </summary>
        ~FileModule()
        {
            Dispose(false);
        }

        /// <inheritdoc />
        public override bool IsFinalHandler => true;

        /// <summary>
        /// Gets the <see cref="IFileProvider"/>interface that provides access
        /// to actual files and directories served by this module.
        /// </summary>
        public IFileProvider Provider { get; }

        /// <summary>
        /// Gets or sets the <see cref="FileCache"/> used by this module to store hashes and,
        /// optionally, file contents and rendered directory listings.
        /// </summary>
        /// <exception cref="InvalidOperationException">The module's configuration is locked.</exception>
        /// <exception cref="ArgumentNullException">This property is being set to <see langword="null"/>.</exception>
        public FileCache Cache
        {
            get => _cache;
            set
            {
                EnsureConfigurationNotLocked();
                _cache = Validate.NotNull(nameof(value), value);
            }
        }

        /// <summary>
        /// <para>Gets or sets a value indicating whether this module caches the contents of files
        /// and directory listings.</para>
        /// <para>Note that the actual representations of files are stored in <see cref="FileCache"/>;
        /// thus, for example, if a file is always requested with an <c>Accept-Encoding</c> of <c>gzip</c>,
        /// only the gzipped contents of the file will be cached.</para>
        /// </summary>
        /// <exception cref="InvalidOperationException">The module's configuration is locked.</exception>
        public bool ContentCaching
        {
            get => _contentCaching;
            set
            {
                EnsureConfigurationNotLocked();
                _contentCaching = value;
            }
        }

        /// <summary>
        /// <para>Gets or sets the name of the default document served, if it exists, instead of a directory listing
        /// when the path of a requested URL maps to a directory.</para>
        /// <para>The default value for this property is the <see cref="DefaultDocumentName"/> constant.</para>
        /// </summary>
        /// <exception cref="InvalidOperationException">The module's configuration is locked.</exception>
        public string? DefaultDocument
        {
            get => _defaultDocument;
            set
            {
                EnsureConfigurationNotLocked();
                _defaultDocument = string.IsNullOrEmpty(value) ? null : value;
            }
        }

        /// <summary>
        /// <para>Gets or sets the default extension appended to requested URL paths that do not map
        /// to any file or directory. Defaults to <see langword="null"/>.</para>
        /// </summary>
        /// <exception cref="InvalidOperationException">The module's configuration is locked.</exception>
        /// <exception cref="ArgumentException">This property is being set to a non-<see langword="null"/>,
        /// non-empty string that does not start with a period (<c>.</c>).</exception>
        public string? DefaultExtension
        {
            get => _defaultExtension;
            set
            {
                EnsureConfigurationNotLocked();

                if (string.IsNullOrEmpty(value))
                {
                    _defaultExtension = null;
                }
                else if (value![0] != '.')
                {
                    throw new ArgumentException("Default extension does not start with a period.", nameof(value));
                }
                else
                {
                    _defaultExtension = value;
                }
            }
        }

        /// <summary>
        /// <para>Gets or sets the <see cref="IDirectoryLister"/> interface used to generate
        /// directory listing in this module.</para>
        /// <para>A value of <see langword="null"/> (the default) disables the generation
        /// of directory listings.</para>
        /// </summary>
        /// <exception cref="InvalidOperationException">The module's configuration is locked.</exception>
        public IDirectoryLister? DirectoryLister
        {
            get => _directoryLister;
            set
            {
                EnsureConfigurationNotLocked();
                _directoryLister = value;
            }
        }

        /// <summary>
        /// <para>Gets or sets a <see cref="FileRequestHandlerCallback"/> that is called whenever
        /// the requested URL path could not be mapped to any file or directory.</para>
        /// <para>The default is <see cref="FileRequestHandler.ThrowNotFound"/>.</para>
        /// </summary>
        /// <exception cref="InvalidOperationException">The module's configuration is locked.</exception>
        /// <exception cref="ArgumentNullException">This property is being set to <see langword="null"/>.</exception>
        /// <seealso cref="FileRequestHandler"/>
        public FileRequestHandlerCallback OnMappingFailed
        {
            get => _onMappingFailed;
            set
            {
                EnsureConfigurationNotLocked();
                _onMappingFailed = Validate.NotNull(nameof(value), value);
            }
        }

        /// <summary>
        /// <para>Gets or sets a <see cref="FileRequestHandlerCallback"/> that is called whenever
        /// the requested URL path has been mapped to a directory, but directory listing has been
        /// disabled by setting <see cref="DirectoryLister"/> to <see langword="null"/>.</para>
        /// <para>The default is <see cref="FileRequestHandler.ThrowUnauthorized"/>.</para>
        /// </summary>
        /// <exception cref="InvalidOperationException">The module's configuration is locked.</exception>
        /// <exception cref="ArgumentNullException">This property is being set to <see langword="null"/>.</exception>
        /// <seealso cref="FileRequestHandler"/>
        public FileRequestHandlerCallback OnDirectoryNotListable
        {
            get => _onDirectoryNotListable;
            set
            {
                EnsureConfigurationNotLocked();
                _onDirectoryNotListable = Validate.NotNull(nameof(value), value);
            }
        }

        /// <summary>
        /// <para>Gets or sets a <see cref="FileRequestHandlerCallback"/> that is called whenever
        /// the requested URL path has been mapped to a file or directory, but the request's
        /// HTTP method is neither <c>GET</c> nor <c>HEAD</c>.</para>
        /// <para>The default is <see cref="FileRequestHandler.ThrowMethodNotAllowed"/>.</para>
        /// </summary>
        /// <exception cref="InvalidOperationException">The module's configuration is locked.</exception>
        /// <exception cref="ArgumentNullException">This property is being set to <see langword="null"/>.</exception>
        /// <seealso cref="FileRequestHandler"/>
        public FileRequestHandlerCallback OnMethodNotAllowed
        {
            get => _onMethodNotAllowed;
            set
            {
                EnsureConfigurationNotLocked();
                _onMethodNotAllowed = Validate.NotNull(nameof(value), value);
            }
        }

        /// <inheritdoc />
        public void Dispose()
        {
            Dispose(true);
            GC.SuppressFinalize(this);
        }

        string IMimeTypeProvider.GetMimeType(string extension)
            => _mimeTypeCustomizer.GetMimeType(extension);

        bool IMimeTypeProvider.TryDetermineCompression(string mimeType, out bool preferCompression)
            => _mimeTypeCustomizer.TryDetermineCompression(mimeType, out preferCompression);

        /// <inheritdoc />
        public void AddCustomMimeType(string extension, string mimeType)
            => _mimeTypeCustomizer.AddCustomMimeType(extension, mimeType);

        /// <inheritdoc />
        public void PreferCompression(string mimeType, bool preferCompression)
            => _mimeTypeCustomizer.PreferCompression(mimeType, preferCompression);

        /// <summary>
        /// Clears the part of <see cref="Cache"/> used by this module.
        /// </summary>
        public void ClearCache()
        {
            _mappingCache?.Clear();
            _cacheSection?.Clear();
        }

        /// <summary>
        /// Releases unmanaged and - optionally - managed resources.
        /// </summary>
        /// <param name="disposing"><see langword="true"/> to release both managed and unmanaged resources;
        /// <see langword="false"/> to release only unmanaged resources.</param>
        protected virtual void Dispose(bool disposing)
        {
            if (!disposing)
                return;

            if (_cacheSection != null)
                Provider.ResourceChanged -= _cacheSection.Remove;

            if (Provider is IDisposable disposableProvider)
                disposableProvider.Dispose();

            if (_cacheSection != null)
                Cache.RemoveSection(_cacheSectionName);
        }

        /// <inheritdoc />
        protected override void OnBeforeLockConfiguration()
        {
            base.OnBeforeLockConfiguration();

            _mimeTypeCustomizer.Lock();
        }

        /// <inheritdoc />
        protected override void OnStart(CancellationToken cancellationToken)
        {
            base.OnStart(cancellationToken);

            _cacheSection = Cache.AddSection(_cacheSectionName);
            Provider.ResourceChanged += _cacheSection.Remove;
            Provider.Start(cancellationToken);
        }

        /// <inheritdoc />
        protected override async Task OnRequestAsync(IHttpContext context)
        {
            MappedResourceInfo? info;

            var path = context.RequestedPath;

            // Map the URL path to a mapped resource.
            // DefaultDocument and DefaultExtension are handled here.
            // Use the mapping cache if it exists.
            if (_mappingCache == null)
            {
                info = MapUrlPath(path, context);
            }
            else if (!_mappingCache.TryGetValue(path, out info))
            {
                info = MapUrlPath(path, context);
                if (info != null)
                    _ = _mappingCache.AddOrUpdate(path, info, (_, __) => info);
            }

            if (info == null)
            {
                // If mapping failed, send a "404 Not Found" response, or whatever OnMappingFailed chooses to do.
                // For example, it may return a default resource (think a folder of images and an imageNotFound.jpg),
                // or redirect the request.
                await OnMappingFailed(context, null).ConfigureAwait(false);
            }
            else if (!IsHttpMethodAllowed(context.Request, out var sendResponseBody))
            {
                // If there is a mapped resource, check that the HTTP method is either GET or HEAD.
                // Otherwise, send a "405 Method Not Allowed" response, or whatever OnMethodNotAllowed chooses to do.
                await OnMethodNotAllowed(context, info).ConfigureAwait(false);
            }
            else if (info.IsDirectory && DirectoryLister == null)
            {
                // If a directory listing was requested, but there is no DirectoryLister,
                // send a "403 Unauthorized" response, or whatever OnDirectoryNotListable chooses to do.
                // For example, one could prefer to send "404 Not Found" instead.
                await OnDirectoryNotListable(context, info).ConfigureAwait(false);
            }
            else
            {
                await HandleResource(context, info, sendResponseBody).ConfigureAwait(false);
            }
        }

        // Tells whether a request's HTTP method is suitable for processing by FileModule
        // and, if so, whether a response body must be sent.
        private static bool IsHttpMethodAllowed(IHttpRequest request, out bool sendResponseBody)
        {
            switch (request.HttpVerb)
            {
                case HttpVerbs.Head:
                    sendResponseBody = false;
                    return true;
                case HttpVerbs.Get:
                    sendResponseBody = true;
                    return true;
                default:
                    sendResponseBody = default;
                    return false;
            }
        }

        // Prepares response headers for a "200 OK" or "304 Not Modified" response.
        // RFC7232, Section 4.1
        private static void PreparePositiveResponse(IHttpResponse response, MappedResourceInfo info, string contentType, string entityTag, Action<IHttpResponse> setCompression)
        {
            setCompression(response);
            response.ContentType = contentType;
            response.Headers.Set(HttpHeaderNames.ETag, entityTag);
            response.Headers.Set(HttpHeaderNames.LastModified, HttpDate.Format(info.LastModifiedUtc));
            response.Headers.Set(HttpHeaderNames.CacheControl, "max-age=0, must-revalidate");
            response.Headers.Set(HttpHeaderNames.AcceptRanges, "bytes");
        }

        // Attempts to map a module-relative URL path to a mapped resource,
        // handling DefaultDocument and DefaultExtension.
        // Returns null if not found.
        // Directories mus be returned regardless of directory listing being enabled.
        private MappedResourceInfo? MapUrlPath(string urlPath, IMimeTypeProvider mimeTypeProvider)
        {
            var result = Provider.MapUrlPath(urlPath, mimeTypeProvider);

            // If urlPath maps to a file, no further searching is needed.
            if (result?.IsFile ?? false)
                return result;

            // Look for a default document.
            // Don't append an additional slash if the URL path is "/".
            // The default document, if found, must be a file, not a directory.
            if (DefaultDocument != null)
            {
                var defaultDocumentPath = urlPath + (urlPath.Length > 1 ? "/" : string.Empty) + DefaultDocument;
                var defaultDocumentResult = Provider.MapUrlPath(defaultDocumentPath, mimeTypeProvider);
                if (defaultDocumentResult?.IsFile ?? false)
                    return defaultDocumentResult;
            }

            // Try to apply default extension (but not if the URL path is "/",
            // i.e. the only normalized, non-base URL path that ends in a slash).
            // When the default extension is applied, the result must be a file.
            if (DefaultExtension != null && urlPath.Length > 1)
            {
                var defaultExtensionResult = Provider.MapUrlPath(urlPath + DefaultExtension, mimeTypeProvider);
                if (defaultExtensionResult?.IsFile ?? false)
                    return defaultExtensionResult;
            }

            return result;
        }

        private async Task HandleResource(IHttpContext context, MappedResourceInfo info, bool sendResponseBody)
        {
            // Try to extract resource information from cache.
            var cachingThreshold = 1024L * Cache.MaxFileSizeKb;
            if (!_cacheSection!.TryGet(info.Path, out var cacheItem))
            {
                // Resource information not yet cached
                cacheItem = new FileCacheItem(_cacheSection, info.LastModifiedUtc, info.Length);
                _cacheSection.Add(info.Path, cacheItem);
            }
            else if (!Provider.IsImmutable)
            {
                // Check whether the resource has changed.
                // If so, discard the cache item and create a new one.
                if (cacheItem.LastModifiedUtc != info.LastModifiedUtc || cacheItem.Length != info.Length)
                {
                    _cacheSection.Remove(info.Path);
                    cacheItem = new FileCacheItem(_cacheSection, info.LastModifiedUtc, info.Length);
                    _cacheSection.Add(info.Path, cacheItem);
                }
            }

            /*
             * Now we have a cacheItem for the resource.
             * It may have been just created, or it may or may not have a cached content,
             * depending upon the value of the ContentCaching property,
             * the size of the resource, and the value of the
             * MaxFileSizeKb of our Cache.
             */

            // If the content type is not a valid MIME type, assume the default.
            var contentType = info.ContentType ?? DirectoryLister?.ContentType ?? MimeType.Default;
            var mimeType = MimeType.StripParameters(contentType);
            if (!MimeType.IsMimeType(mimeType, false))
                contentType = mimeType = MimeType.Default;

            // Next we're going to apply proactive negotiation
            // to determine whether we agree with the client upon the compression
            // (or lack of it) to use for the resource.
            //
            // The combination of partial responses and entity compression
            // is not really standardized and could lead to a world of pain.
            // Thus, if there is a Range header in the request, try to negotiate for no compression.
            // Later, if there is compression anyway, we will ignore the Range header.
            if (!context.TryDetermineCompression(mimeType, out var preferCompression))
                preferCompression = true;
            preferCompression &= context.Request.Headers.Get(HttpHeaderNames.Range) == null;
            if (!context.Request.TryNegotiateContentEncoding(preferCompression, out var compressionMethod, out var setCompressionInResponse))
            {
                // If negotiation failed, the returned callback will do the right thing.
                setCompressionInResponse(context.Response);
                return;
            }

            var entityTag = info.GetEntityTag(compressionMethod);

            // Send a "304 Not Modified" response if applicable.
            //
            // RFC7232, Section 3.3: "A recipient MUST ignore If-Modified-Since
            //                       if the request contains an If-None-Match header field."
            if (context.Request.CheckIfNoneMatch(entityTag, out var ifNoneMatchExists)
             || (!ifNoneMatchExists && context.Request.CheckIfModifiedSince(info.LastModifiedUtc, out _)))
            {
                context.Response.StatusCode = (int)HttpStatusCode.NotModified;
                PreparePositiveResponse(context.Response, info, contentType, entityTag, setCompressionInResponse);
                return;
            }

            /*
             * At this point we know the response is "200 OK",
             * unless the request is a range request.
             *
             * RFC7233, Section 3.1: "The Range header field is evaluated after evaluating the precondition
             *                       header fields defined in RFC7232, and only if the result in absence
             *                       of the Range header field would be a 200 (OK) response.  In other
             *                       words, Range is ignored when a conditional GET would result in a 304
             *                       (Not Modified) response."
             */

            // Before evaluating ranges, we must know the content length.
            // This is easy for files, as it is stored in info.Length.
            // Directories always have info.Length == 0; therefore,
            // unless the directory listing is cached, we must generate it now
            // (and cache it while we're there, if applicable).
            var content = cacheItem.GetContent(compressionMethod);
            if (info.IsDirectory && content == null)
            {
                long uncompressedLength;
                (content, uncompressedLength) = await GenerateDirectoryListingAsync(context, info, compressionMethod)
                    .ConfigureAwait(false);
                if (ContentCaching && uncompressedLength <= cachingThreshold)
                    _ = cacheItem.SetContent(compressionMethod, content);
            }

            var contentLength = content?.Length ?? info.Length;

            // Ignore range request is compression is enabled
            // (or should I say forced, since negotiation has tried not to use it).
            var partialStart = 0L;
            var partialUpperBound = contentLength - 1;
            var isPartial = compressionMethod == CompressionMethod.None
                         && context.Request.IsRangeRequest(contentLength, entityTag, info.LastModifiedUtc, out partialStart, out partialUpperBound);
            var responseContentLength = contentLength;

            if (isPartial)
            {
                // Prepare a "206 Partial Content" response.
                responseContentLength = partialUpperBound - partialStart + 1;
                context.Response.StatusCode = (int)HttpStatusCode.PartialContent;
                PreparePositiveResponse(context.Response, info, contentType, entityTag, setCompressionInResponse);
                context.Response.Headers.Set(HttpHeaderNames.ContentRange, $"bytes {partialStart}-{partialUpperBound}/{contentLength}");
            }
            else
            {
                // Prepare a "200 OK" response.
                PreparePositiveResponse(context.Response, info, contentType, entityTag, setCompressionInResponse);
            }

            // If it's a HEAD request, we're done.
            if (!sendResponseBody)
                return;

            // If content must be sent AND cached, first read it and store it.
            // If the requested resource is a directory, we have already listed it by now,
            // so it must be a file for content to be null.
            if (content == null && ContentCaching && contentLength <= cachingThreshold)
            {
                using (var memoryStream = new MemoryStream())
                {
                    using (var compressor = new CompressionStream(memoryStream, compressionMethod))
                    {
                        using var source = Provider.OpenFile(info.Path);
                        await source.CopyToAsync(compressor, WebServer.StreamCopyBufferSize, context.CancellationToken)
                            .ConfigureAwait(false);
                    }

                    content = memoryStream.ToArray();
                    responseContentLength = content.Length;
                }

                _ = cacheItem.SetContent(compressionMethod, content);
            }

            // Transfer cached content if present.
            if (content != null)
            {
                context.Response.ContentLength64 = responseContentLength;
                var offset = isPartial ? (int) partialStart : 0;
                await context.Response.OutputStream.WriteAsync(content, offset, (int)responseContentLength, context.CancellationToken)
                    .ConfigureAwait(false);

                return;
            }

            // Read and transfer content without caching.
            using (var source = Provider.OpenFile(info.Path))
            {
                context.Response.SendChunked = true;

                if (isPartial)
                {
                    var buffer = new byte[WebServer.StreamCopyBufferSize];
                    if (source.CanSeek)
                    {
                        source.Position = partialStart;
                    }
                    else
                    {
                        var skipLength = (int)partialStart;
                        while (skipLength > 0)
                        {
                            var read = await source.ReadAsync(buffer, 0, Math.Min(skipLength, buffer.Length), context.CancellationToken)
                                .ConfigureAwait(false);

                            skipLength -= read;
                        }
                    }

                    var transferSize = responseContentLength;
                    while (transferSize >= WebServer.StreamCopyBufferSize)
                    {
                        var read = await source.ReadAsync(buffer, 0, WebServer.StreamCopyBufferSize, context.CancellationToken)
                            .ConfigureAwait(false);

                        await context.Response.OutputStream.WriteAsync(buffer, 0, read, context.CancellationToken)
                            .ConfigureAwait(false);

                        transferSize -= read;
                    }

                    if (transferSize > 0)
                    {
                        var read = await source.ReadAsync(buffer, 0, (int)transferSize, context.CancellationToken)
                            .ConfigureAwait(false);

                        await context.Response.OutputStream.WriteAsync(buffer, 0, read, context.CancellationToken)
                            .ConfigureAwait(false);
                    }
                }
                else
                {
                    using var compressor = new CompressionStream(context.Response.OutputStream, compressionMethod);
                    await source.CopyToAsync(compressor, WebServer.StreamCopyBufferSize, context.CancellationToken)
                        .ConfigureAwait(false);
                }
            }
        }

        // Uses DirectoryLister to generate a directory listing asynchronously.
        // Returns a tuple of the generated content and its *uncompressed* length
        // (useful to decide whether it can be cached).
        private async Task<(byte[], long)> GenerateDirectoryListingAsync(
            IHttpContext context,
            MappedResourceInfo info,
            CompressionMethod compressionMethod)
        {
            using var memoryStream = new MemoryStream();
            using var stream = new CompressionStream(memoryStream, compressionMethod);

            await DirectoryLister!.ListDirectoryAsync(
                info,
                context.Request.Url.AbsolutePath,
                Provider.GetDirectoryEntries(info.Path, context),
                stream,
                context.CancellationToken).ConfigureAwait(false);

            return (memoryStream.ToArray(), stream.UncompressedLength);
        }
    }
}