using System.Globalization;
using System.Text;
using System.Xml;
using Sunfish.Config;
using Sunfish.Http;
using Sunfish.Shares;

namespace Sunfish.Web;

/// <summary>
/// WebDAV (clase 1 y LOCK simulado, se anuncia clase 2) sobre el mismo puerto y arbol que la web,
/// para montar los shares desde el Explorador, Finder o GVfs, y para que un Sunfish explore a otro.
/// GET, HEAD y PUT son los de la web. Los bloqueos devuelven tokens validos pero no bloquean
/// nada: el Explorador de Windows y Finder los exigen para escribir.
/// </summary>
public sealed partial class WebApp
{
    private const string DavNs = "DAV:";
    private const string AllowedMethods = "OPTIONS, GET, HEAD, POST, PUT, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK";
    private const int MaxXmlBytes = 1 << 20;

    private static bool IsDavMethod(string method) =>
        method is "PROPFIND" or "PROPPATCH" or "MKCOL" or "DELETE" or "COPY" or "MOVE" or "LOCK" or "UNLOCK";

    /// <summary>Un recurso WebDAV: real (fichero o carpeta de un share) o carpeta virtual.</summary>
    private sealed record DavNode(List<string> Segments, ShareConfig? Share, IReadOnlyList<string> Relative,
        string? Physical, IReadOnlyList<string> VirtualChildren)
    {
        public bool IsFile => Physical is not null && File.Exists(Physical);
        public bool IsRealDirectory => Physical is not null && Directory.Exists(Physical);
        public bool IsCollection => IsRealDirectory || (!IsFile && (VirtualChildren.Count > 0 || Segments.Count == 0));
        public bool Exists => IsFile || IsCollection;

        /// <summary>Se puede cambiar: dentro de un share, y no es su raiz.</summary>
        public bool InsideShare => Share is not null && Relative.Count > 0;
    }

    private static DavNode NodeOf(ServerState st, List<string> segments)
    {
        var r = st.Tree.Resolve(segments);
        string? physical = r.Share is null ? null : ShareFileSystem.TryGetPhysicalPath(r.Share, r.Relative);
        return new DavNode(segments, r.Share, r.Relative, physical, r.VirtualChildren);
    }

    private static async Task DavOptionsAsync(HttpContext ctx)
    {
        ctx.Response.Headers["DAV"] = "1, 2";
        ctx.Response.Headers["MS-Author-Via"] = "DAV";
        ctx.Response.Headers["Allow"] = AllowedMethods;
        await ctx.Response.StartAsync(0, ctx.Aborted);
    }

    private async Task DavAsync(HttpContext ctx, ServerState st, ClientProfile profile, List<string> segments, Resolution resolution)
    {
        var node = NodeOf(st, segments);
        if (node.Share is { } share)
        {
            if (!await AuthorizeAsync(ctx, st, profile, share))
                return;
            if (!share.WebDav)
            {
                ctx.Response.Headers["Allow"] = "GET, HEAD, POST, PUT";
                await SendTextAsync(ctx, 405, "WebDAV is disabled for this share");
                return;
            }
        }
        ctx.Response.Headers["DAV"] = "1, 2";
        ctx.Response.Headers["MS-Author-Via"] = "DAV";
        switch (ctx.Request.Method)
        {
            case "PROPFIND": await PropFindAsync(ctx, st, node); break;
            case "PROPPATCH": await PropPatchAsync(ctx, node); break;
            case "MKCOL": await MkColAsync(ctx, node); break;
            case "DELETE": await DavDeleteAsync(ctx, node); break;
            case "COPY": await CopyMoveAsync(ctx, st, node, move: false); break;
            case "MOVE": await CopyMoveAsync(ctx, st, node, move: true); break;
            case "LOCK": await LockAsync(ctx, node); break;
            case "UNLOCK":
                ctx.Response.StatusCode = 204;
                await ctx.Response.StartAsync(0, ctx.Aborted);
                break;
        }
        _ = resolution;
    }

    // ------------------------------------------------------------------ PROPFIND

    private static readonly string[] AllProps =
        ["displayname", "resourcetype", "getcontentlength", "getcontenttype", "getlastmodified", "creationdate", "getetag", "supportedlock", "lockdiscovery"];

    private async Task PropFindAsync(HttpContext ctx, ServerState st, DavNode node)
    {
        if (!node.Exists)
        {
            await SendTextAsync(ctx, 404, "Not found");
            return;
        }
        string depth = (ctx.Request.Headers["Depth"] ?? "1").Trim();
        if (depth.Equals("infinity", StringComparison.OrdinalIgnoreCase))
        {
            ctx.Response.StatusCode = 403;
            await SendXmlAsync(ctx, 403, w =>
            {
                w.WriteStartElement("D", "error", DavNs);
                w.WriteElementString("D", "propfind-finite-depth", DavNs, "");
                w.WriteEndElement();
            });
            return;
        }
        // Sin cuerpo = allprop. Con <prop>, solo las pedidas; con <propname>, solo los nombres.
        var (requested, namesOnly) = ParsePropFind(await ReadBodyAsync(ctx));
        if (node.Share is { Listing: false } && node.IsRealDirectory && depth == "1")
            depth = "0"; // sin Listing no se enumera el contenido

        var entries = new List<(string Href, FileEntry Entry)>();
        string baseHref = DavHref(node.Segments, node.IsCollection);
        entries.Add((baseHref, EntryOf(node)));
        if (depth == "1" && node.IsCollection)
        {
            foreach (var child in node.VirtualChildren)
                entries.Add((DavHref([.. node.Segments, child], true), new FileEntry(child, true, 0, default, IsVirtual: true)));
            if (node.IsRealDirectory)
            {
                foreach (var e in ShareFileSystem.List(node.Physical!))
                {
                    if (node.VirtualChildren.Contains(e.Name, StringComparer.OrdinalIgnoreCase))
                        continue;
                    entries.Add((DavHref([.. node.Segments, e.Name], e.IsDirectory), e));
                }
            }
        }

        await SendXmlAsync(ctx, 207, w =>
        {
            w.WriteStartElement("D", "multistatus", DavNs);
            foreach (var (href, entry) in entries)
                WriteResponse(w, href, entry, requested, namesOnly);
            w.WriteEndElement();
        });
    }

    private static FileEntry EntryOf(DavNode node)
    {
        string name = node.Segments.Count == 0 ? "/" : node.Segments[^1];
        if (node.IsFile)
        {
            var info = new FileInfo(node.Physical!);
            return new FileEntry(name, false, info.Length, info.LastWriteTime);
        }
        if (node.IsRealDirectory)
            return new FileEntry(name, true, 0, Directory.GetLastWriteTime(node.Physical!));
        return new FileEntry(name, true, 0, default, IsVirtual: true);
    }

    private static void WriteResponse(XmlWriter w, string href, FileEntry e, List<(string Ns, string Name)>? requested, bool namesOnly)
    {
        var found = new List<(string Ns, string Name)>();
        var missing = new List<(string Ns, string Name)>();
        foreach (var p in requested ?? AllProps.Select(n => (DavNs, n)).ToList())
        {
            if (p.Ns == DavNs && AllProps.Contains(p.Name) && (p.Name != "getcontentlength" && p.Name != "getcontenttype" || !e.IsDirectory))
                found.Add(p);
            else
                missing.Add(p);
        }

        w.WriteStartElement("D", "response", DavNs);
        w.WriteElementString("D", "href", DavNs, href);
        w.WriteStartElement("D", "propstat", DavNs);
        w.WriteStartElement("D", "prop", DavNs);
        foreach (var (_, name) in found)
        {
            w.WriteStartElement("D", name, DavNs);
            if (!namesOnly)
                WritePropValue(w, name, e);
            w.WriteEndElement();
        }
        w.WriteEndElement();
        w.WriteElementString("D", "status", DavNs, "HTTP/1.1 200 OK");
        w.WriteEndElement();
        if (missing.Count > 0)
        {
            w.WriteStartElement("D", "propstat", DavNs);
            w.WriteStartElement("D", "prop", DavNs);
            foreach (var (ns, name) in missing)
                w.WriteElementString(name, ns, "");
            w.WriteEndElement();
            w.WriteElementString("D", "status", DavNs, "HTTP/1.1 404 Not Found");
            w.WriteEndElement();
        }
        w.WriteEndElement();
    }

    private static void WritePropValue(XmlWriter w, string name, FileEntry e)
    {
        var modified = e.IsVirtual ? DateTime.UtcNow : e.Modified.ToUniversalTime();
        switch (name)
        {
            case "displayname": w.WriteString(e.Name); break;
            case "resourcetype":
                if (e.IsDirectory)
                    w.WriteElementString("D", "collection", DavNs, "");
                break;
            case "getcontentlength": w.WriteString(e.Size.ToString(CultureInfo.InvariantCulture)); break;
            case "getcontenttype": w.WriteString(MimeTypes.For(e.Name)); break;
            case "getlastmodified": w.WriteString(HttpResponse.FormatDate(modified)); break;
            case "creationdate": w.WriteString(modified.ToString("yyyy-MM-dd'T'HH:mm:ss'Z'", CultureInfo.InvariantCulture)); break;
            case "getetag": w.WriteString($"\"{modified.Ticks:x}-{e.Size:x}\""); break;
            case "supportedlock":
                w.WriteStartElement("D", "lockentry", DavNs);
                w.WriteStartElement("D", "lockscope", DavNs);
                w.WriteElementString("D", "exclusive", DavNs, "");
                w.WriteEndElement();
                w.WriteStartElement("D", "locktype", DavNs);
                w.WriteElementString("D", "write", DavNs, "");
                w.WriteEndElement();
                w.WriteEndElement();
                break;
        }
    }

    /// <summary>Propiedades pedidas (null = todas) y si solo se quieren los nombres.</summary>
    internal static (List<(string Ns, string Name)>? Props, bool NamesOnly) ParsePropFind(byte[] body)
    {
        if (body.Length == 0)
            return (null, false);
        List<(string, string)>? props = null;
        bool namesOnly = false;
        bool inProp = false;
        int propDepth = 0;
        using var reader = XmlReader.Create(new MemoryStream(body), SafeXml);
        try
        {
            while (reader.Read())
            {
                if (reader.NodeType == XmlNodeType.Element)
                {
                    if (inProp && reader.Depth == propDepth + 1)
                        props!.Add((reader.NamespaceURI, reader.LocalName));
                    else if (reader.NamespaceURI == DavNs && reader.LocalName == "prop")
                    {
                        inProp = true;
                        propDepth = reader.Depth;
                        props = new();
                    }
                    else if (reader.NamespaceURI == DavNs && reader.LocalName == "propname")
                        namesOnly = true;
                }
            }
        }
        catch (XmlException)
        {
            return (null, false);
        }
        return (namesOnly ? null : props, namesOnly);
    }

    // ------------------------------------------------------------------ PROPPATCH

    /// <summary>Se acepta y se responde que se aplico (el Explorador de Windows insiste en fijar sus atributos).</summary>
    private async Task PropPatchAsync(HttpContext ctx, DavNode node)
    {
        if (!node.Exists)
        {
            await SendTextAsync(ctx, 404, "Not found");
            return;
        }
        var body = await ReadBodyAsync(ctx);
        var names = new List<(string Ns, string Name)>();
        try
        {
            using var reader = XmlReader.Create(new MemoryStream(body), SafeXml);
            int propDepth = -1;
            while (reader.Read())
            {
                if (reader.NodeType != XmlNodeType.Element)
                    continue;
                if (reader.NamespaceURI == DavNs && reader.LocalName == "prop")
                    propDepth = reader.Depth;
                else if (propDepth >= 0 && reader.Depth == propDepth + 1)
                    names.Add((reader.NamespaceURI, reader.LocalName));
            }
        }
        catch (XmlException)
        {
            await SendTextAsync(ctx, 400, "Bad XML");
            return;
        }
        string href = DavHref(node.Segments, node.IsCollection);
        await SendXmlAsync(ctx, 207, w =>
        {
            w.WriteStartElement("D", "multistatus", DavNs);
            w.WriteStartElement("D", "response", DavNs);
            w.WriteElementString("D", "href", DavNs, href);
            w.WriteStartElement("D", "propstat", DavNs);
            w.WriteStartElement("D", "prop", DavNs);
            foreach (var (ns, name) in names)
                w.WriteElementString(name, ns, "");
            w.WriteEndElement();
            w.WriteElementString("D", "status", DavNs, "HTTP/1.1 200 OK");
            w.WriteEndElement();
            w.WriteEndElement();
            w.WriteEndElement();
        });
    }

    // ------------------------------------------------------------------ cambios

    private async Task MkColAsync(HttpContext ctx, DavNode node)
    {
        if (ctx.Request.Body is RequestBodyStream body && !body.Completed && ctx.Request.Headers["Content-Length"] is not (null or "0"))
        {
            await SendTextAsync(ctx, 415, "MKCOL with a body is not supported");
            return;
        }
        if (node.Exists)
        {
            await SendTextAsync(ctx, 405, "Already exists");
            return;
        }
        if (!node.InsideShare || !node.Share!.Upload)
        {
            await SendTextAsync(ctx, 403, Strings.ErrorText(403));
            return;
        }
        string? parent = Path.GetDirectoryName(node.Physical!);
        if (node.Physical is null || parent is null || !Directory.Exists(parent))
        {
            await SendTextAsync(ctx, 409, "Parent does not exist");
            return;
        }
        var result = FileOperations.CreateFolder(parent, node.Segments[^1]);
        if (result != FileOpResult.Ok)
        {
            await SendTextAsync(ctx, result == FileOpResult.InvalidName ? 400 : 409, Strings.OpResult(result, node.Segments[^1]));
            return;
        }
        log.Info($"{ctx.Request.RemoteEndPoint?.Address} DAV MKCOL /{string.Join('/', node.Segments)}");
        ctx.Response.StatusCode = 201;
        await ctx.Response.StartAsync(0, ctx.Aborted);
    }

    private async Task DavDeleteAsync(HttpContext ctx, DavNode node)
    {
        if (!node.Exists)
        {
            await SendTextAsync(ctx, 404, "Not found");
            return;
        }
        if (!node.InsideShare || !node.Share!.Delete || !(node.IsFile || node.IsRealDirectory))
        {
            await SendTextAsync(ctx, 403, Strings.ErrorText(403));
            return;
        }
        var result = FileOperations.Delete(node.Physical!);
        if (result != FileOpResult.Ok)
        {
            await SendTextAsync(ctx, 409, Strings.OpResult(result, node.Segments[^1]));
            return;
        }
        // FileOperations.Delete ya se lleva el AppleDouble ("._nombre") del fichero.
        log.Info($"{ctx.Request.RemoteEndPoint?.Address} DAV DELETE /{string.Join('/', node.Segments)}");
        ctx.Response.StatusCode = 204;
        await ctx.Response.StartAsync(0, ctx.Aborted);
    }

    private async Task CopyMoveAsync(HttpContext ctx, ServerState st, DavNode source, bool move)
    {
        if (!source.Exists)
        {
            await SendTextAsync(ctx, 404, "Not found");
            return;
        }
        if (!TryDestination(ctx.Request.Headers["Destination"], out var destSegments))
        {
            await SendTextAsync(ctx, 400, "Bad Destination");
            return;
        }
        var dest = NodeOf(st, destSegments);
        if (dest.Share != source.Share)
        {
            await SendTextAsync(ctx, 502, "Destination is in another share");
            return;
        }
        var share = source.Share;
        bool allowed = share is not null && source.InsideShare && dest.InsideShare &&
                       (source.IsFile || source.IsRealDirectory) &&
                       (move ? share.Rename : share.Upload);
        if (!allowed || dest.Physical is null)
        {
            await SendTextAsync(ctx, 403, Strings.ErrorText(403));
            return;
        }
        string? destParent = Path.GetDirectoryName(dest.Physical);
        string name = dest.Segments[^1];
        bool nameOk = FileOperations.IsCreatableName(name) || (share!.WebDav && FileOperations.IsMacMetadataName(name));
        if (destParent is null || !Directory.Exists(destParent) || !nameOk)
        {
            await SendTextAsync(ctx, 409, "Bad destination");
            return;
        }

        bool overwriteHeader = !string.Equals(ctx.Request.Headers["Overwrite"]?.Trim(), "F", StringComparison.OrdinalIgnoreCase);
        bool existed = dest.Exists;
        if (existed)
        {
            if (!overwriteHeader)
            {
                await SendTextAsync(ctx, 412, "Destination exists");
                return;
            }
            if (!share!.Delete || string.Equals(source.Physical, dest.Physical, StringComparison.OrdinalIgnoreCase))
            {
                await SendTextAsync(ctx, 403, Strings.ErrorText(403));
                return;
            }
            FileOperations.Delete(dest.Physical);
        }

        FileOpResult result;
        if (move)
        {
            result = MoveIncludingMacMetadata(source.Physical!, dest.Physical);
        }
        else
        {
            result = CopyRecursive(source.Physical!, dest.Physical);
        }
        if (result != FileOpResult.Ok)
        {
            await SendTextAsync(ctx, 409, Strings.OpResult(result, name));
            return;
        }
        log.Info($"{ctx.Request.RemoteEndPoint?.Address} DAV {(move ? "MOVE" : "COPY")} /{string.Join('/', source.Segments)} -> /{string.Join('/', dest.Segments)}");
        ctx.Response.StatusCode = existed ? 204 : 201;
        await ctx.Response.StartAsync(0, ctx.Aborted);
    }

    /// <summary>Mueve sin la restriccion de nombres con punto (Finder renombra sus ._ temporales).</summary>
    private static FileOpResult MoveIncludingMacMetadata(string source, string target)
    {
        string name = Path.GetFileName(target);
        if (FileOperations.IsCreatableName(name))
            return FileOperations.Move(source, target);
        try
        {
            if (File.Exists(source))
                File.Move(source, target);
            else
                Directory.Move(source, target);
            return FileOpResult.Ok;
        }
        catch (Exception e) when (e is IOException or UnauthorizedAccessException)
        {
            return FileOpResult.Failed;
        }
    }

    private static FileOpResult CopyRecursive(string source, string target)
    {
        try
        {
            if (File.Exists(source))
            {
                File.Copy(source, target, overwrite: false);
                return FileOpResult.Ok;
            }
            if (target.StartsWith(Path.TrimEndingDirectorySeparator(source) + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase))
                return FileOpResult.Conflict; // dentro de si misma
            Directory.CreateDirectory(target);
            foreach (var info in new DirectoryInfo(source).EnumerateFileSystemInfos())
            {
                if (info.LinkTarget is not null)
                    continue; // los enlaces no se siguen
                var r = CopyRecursive(info.FullName, Path.Combine(target, info.Name));
                if (r != FileOpResult.Ok)
                    return r;
            }
            return FileOpResult.Ok;
        }
        catch (Exception e) when (e is IOException or UnauthorizedAccessException)
        {
            return FileOpResult.Failed;
        }
    }

    /// <summary>Destination: URL absoluta o ruta; se queda la ruta y se valida como cualquier otra.</summary>
    internal static bool TryDestination(string? header, out List<string> segments)
    {
        segments = [];
        if (string.IsNullOrWhiteSpace(header))
            return false;
        var (path, _) = HttpRequestReader.SplitTarget(header.Trim());
        if (!PathRules.TrySplitUrlPath(path, out segments, out _))
            return false;
        return segments.Count == 0 || !segments[0].Equals(LocationPath.Reserved, StringComparison.OrdinalIgnoreCase);
    }

    // ------------------------------------------------------------------ LOCK

    private async Task LockAsync(HttpContext ctx, DavNode node)
    {
        await ReadBodyAsync(ctx);
        int status = 200;
        if (!node.Exists)
        {
            // Bloquear un recurso que no existe crea un fichero vacio (lo hacen el Explorador y Finder).
            if (!node.InsideShare || !node.Share!.Upload || node.Physical is null ||
                Path.GetDirectoryName(node.Physical) is not { } parent || !Directory.Exists(parent))
            {
                await SendTextAsync(ctx, 409, "Cannot lock here");
                return;
            }
            var created = await FileOperations.SaveAsync(parent, node.Segments[^1], Stream.Null, false, ctx.Aborted,
                allowMacMetadata: FileOperations.IsMacMetadataName(node.Segments[^1]));
            if (created != FileOpResult.Ok)
            {
                await SendTextAsync(ctx, 409, Strings.OpResult(created, node.Segments[^1]));
                return;
            }
            status = 201;
        }
        string token = ctx.Request.Headers["If"] is { } ifHeader && ifHeader.Contains("opaquelocktoken:", StringComparison.Ordinal)
            ? ifHeader[(ifHeader.IndexOf("opaquelocktoken:", StringComparison.Ordinal))..].TrimEnd(')', '>', ' ')
            : "opaquelocktoken:" + Guid.NewGuid();
        string href = DavHref(node.Segments, node.IsCollection);
        ctx.Response.Headers["Lock-Token"] = $"<{token}>";
        await SendXmlAsync(ctx, status, w =>
        {
            w.WriteStartElement("D", "prop", DavNs);
            w.WriteStartElement("D", "lockdiscovery", DavNs);
            w.WriteStartElement("D", "activelock", DavNs);
            w.WriteStartElement("D", "locktype", DavNs);
            w.WriteElementString("D", "write", DavNs, "");
            w.WriteEndElement();
            w.WriteStartElement("D", "lockscope", DavNs);
            w.WriteElementString("D", "exclusive", DavNs, "");
            w.WriteEndElement();
            w.WriteElementString("D", "depth", DavNs, "0");
            w.WriteElementString("D", "timeout", DavNs, "Second-3600");
            w.WriteStartElement("D", "locktoken", DavNs);
            w.WriteElementString("D", "href", DavNs, token);
            w.WriteEndElement();
            w.WriteStartElement("D", "lockroot", DavNs);
            w.WriteElementString("D", "href", DavNs, href);
            w.WriteEndElement();
            w.WriteEndElement();
            w.WriteEndElement();
            w.WriteEndElement();
        });
    }

    // ------------------------------------------------------------------ utilidades

    private static readonly XmlReaderSettings SafeXml = new()
    {
        DtdProcessing = DtdProcessing.Prohibit,
        XmlResolver = null,
        IgnoreComments = true,
        IgnoreWhitespace = true,
        MaxCharactersInDocument = MaxXmlBytes,
    };

    /// <summary>"/fotos/a%20b/" (las colecciones acaban en '/').</summary>
    private static string DavHref(IEnumerable<string> segments, bool collection)
    {
        string path = "/" + string.Join('/', segments.Select(HtmlBuilder.UrlSegment));
        return collection && path != "/" ? path + "/" : path;
    }

    private static async Task<byte[]> ReadBodyAsync(HttpContext ctx)
    {
        var ms = new MemoryStream();
        var buffer = new byte[8192];
        int n;
        while ((n = await ctx.Request.Body.ReadAsync(buffer, ctx.Aborted)) > 0)
        {
            ms.Write(buffer, 0, n);
            if (ms.Length > MaxXmlBytes)
                throw new InvalidDataException("XML body too large");
        }
        return ms.ToArray();
    }

    private static async Task SendXmlAsync(HttpContext ctx, int status, Action<XmlWriter> write)
    {
        var ms = new MemoryStream();
        using (var w = XmlWriter.Create(ms, new XmlWriterSettings { Encoding = new UTF8Encoding(false) }))
        {
            w.WriteStartDocument();
            write(w);
            w.WriteEndDocument();
        }
        ctx.Response.StatusCode = status;
        ctx.Response.Headers["Cache-Control"] = "no-cache";
        await ctx.Response.SendAsync(ms.ToArray(), "application/xml; charset=utf-8", ctx.Aborted);
    }
}
