using System.Globalization;
using System.Net;
using System.Net.Http.Headers;
using System.Text;
using System.Xml;
using Sunfish.Shares;

namespace Sunfish.Dav;

/// <summary>Un elemento de una carpeta remota.</summary>
public sealed record DavItem(string Name, string Path, bool IsCollection, long Size, DateTime? Modified);

/// <summary>Error de un servidor WebDAV (401: hace falta usuario y contrasena).</summary>
public sealed class DavException(int status, string message) : Exception(message)
{
    public int Status { get; } = status;
}

/// <summary>
/// Cliente WebDAV minimo para explorar otro Sunfish (o cualquier servidor WebDAV) y copiar en los
/// dos sentidos. Las rutas son absolutas y sin codificar ("/fotos/2026"); se codifican aqui.
/// </summary>
public sealed class DavClient : IDisposable
{
    private static readonly HttpMethod PropFind = new("PROPFIND");
    private static readonly HttpMethod MkCol = new("MKCOL");
    private static readonly HttpMethod Move = new("MOVE");

    private readonly HttpClient _http;

    public DavClient(Uri server, string? user = null, string? password = null)
    {
        Server = new Uri(server.GetLeftPart(UriPartial.Authority) + "/");
        _http = new HttpClient(new SocketsHttpHandler { AllowAutoRedirect = true, PooledConnectionLifetime = TimeSpan.FromMinutes(2) })
        {
            BaseAddress = Server,
            Timeout = Timeout.InfiniteTimeSpan,
        };
        _http.DefaultRequestHeaders.UserAgent.ParseAdd("Sunfish/3.0");
        if (!string.IsNullOrEmpty(user))
            _http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Basic",
                Convert.ToBase64String(Encoding.UTF8.GetBytes(user + ":" + (password ?? ""))));
    }

    public Uri Server { get; }

    /// <summary>"192.168.1.10:8080", "http://equipo:8080/fotos/" -> Uri del servidor y ruta inicial.</summary>
    public static bool TryParseAddress(string text, out Uri server, out string path)
    {
        server = null!;
        path = "/";
        text = text.Trim();
        if (text.Length == 0)
            return false;
        if (!text.Contains("://", StringComparison.Ordinal))
            text = "http://" + text;
        if (!Uri.TryCreate(text, UriKind.Absolute, out var uri) || uri.Scheme is not ("http" or "https"))
            return false;
        server = new Uri(uri.GetLeftPart(UriPartial.Authority) + "/");
        path = Uri.UnescapeDataString(uri.AbsolutePath);
        return true;
    }

    public static string Combine(string folder, string name) => folder.TrimEnd('/') + "/" + name;

    public static string Parent(string path)
    {
        string trimmed = path.TrimEnd('/');
        int cut = trimmed.LastIndexOf('/');
        return cut <= 0 ? "/" : trimmed[..cut];
    }

    private static string Encode(string path, bool collection)
    {
        var parts = path.Split('/', StringSplitOptions.RemoveEmptyEntries).Select(Uri.EscapeDataString);
        string encoded = "/" + string.Join('/', parts);
        return collection && encoded != "/" ? encoded + "/" : encoded;
    }

    /// <summary>Contenido de una carpeta (sin la propia carpeta), carpetas primero.</summary>
    public async Task<List<DavItem>> ListAsync(string folder, CancellationToken ct = default)
    {
        const string body = "<?xml version=\"1.0\" encoding=\"utf-8\"?><D:propfind xmlns:D=\"DAV:\"><D:prop>" +
                            "<D:resourcetype/><D:getcontentlength/><D:getlastmodified/></D:prop></D:propfind>";
        using var request = new HttpRequestMessage(PropFind, Encode(folder, collection: true))
        {
            Content = new StringContent(body, Encoding.UTF8, "application/xml"),
        };
        request.Headers.Add("Depth", "1");
        using var response = await _http.SendAsync(request, ct);
        await EnsureAsync(response, 207);
        var xml = await response.Content.ReadAsByteArrayAsync(ct);
        string self = Encode(folder, collection: true);
        return ParseMultiStatus(xml)
            .Where(i => !SamePath(i.Path, folder))
            .OrderBy(i => i.IsCollection ? 0 : 1)
            .ThenBy(i => i.Name, StringComparer.OrdinalIgnoreCase)
            .ToList();
    }

    private static bool SamePath(string a, string b) =>
        string.Equals(a.TrimEnd('/'), b.TrimEnd('/'), StringComparison.Ordinal);

    internal static List<DavItem> ParseMultiStatus(byte[] xml)
    {
        var items = new List<DavItem>();
        var settings = new XmlReaderSettings { DtdProcessing = DtdProcessing.Prohibit, XmlResolver = null, IgnoreWhitespace = true };
        using var reader = XmlReader.Create(new MemoryStream(xml), settings);
        string? href = null;
        bool collection = false;
        long size = 0;
        DateTime? modified = null;
        string? current = null;
        while (reader.Read())
        {
            if (reader.NodeType == XmlNodeType.Element && reader.NamespaceURI == "DAV:")
            {
                switch (reader.LocalName)
                {
                    case "response":
                        href = null; collection = false; size = 0; modified = null;
                        break;
                    case "collection":
                        collection = true;
                        break;
                    case "href" or "getcontentlength" or "getlastmodified":
                        current = reader.LocalName;
                        break;
                }
            }
            else if (reader.NodeType == XmlNodeType.Text && current is not null)
            {
                switch (current)
                {
                    case "href": href ??= reader.Value.Trim(); break;
                    case "getcontentlength":
                        long.TryParse(reader.Value.Trim(), NumberStyles.None, CultureInfo.InvariantCulture, out size);
                        break;
                    case "getlastmodified":
                        if (DateTime.TryParse(reader.Value.Trim(), CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal, out var t))
                            modified = t.ToLocalTime();
                        break;
                }
                current = null;
            }
            else if (reader.NodeType == XmlNodeType.EndElement && reader.NamespaceURI == "DAV:" && reader.LocalName == "response" && href is not null)
            {
                string path = HrefToPath(href);
                string name = path.TrimEnd('/');
                name = name[(name.LastIndexOf('/') + 1)..];
                items.Add(new DavItem(name, path.Length > 1 ? path.TrimEnd('/') : "/", collection, size, modified));
            }
        }
        return items;
    }

    /// <summary>href absoluto o relativo -> ruta decodificada.</summary>
    private static string HrefToPath(string href)
    {
        if (Uri.TryCreate(href, UriKind.Absolute, out var absolute) && absolute.Scheme.StartsWith("http", StringComparison.Ordinal))
            href = absolute.AbsolutePath;
        return PathRules.PercentDecode(href);
    }

    public async Task DownloadAsync(string path, Stream destination, IProgress<long>? progress = null, CancellationToken ct = default)
    {
        using var response = await _http.GetAsync(Encode(path, false), HttpCompletionOption.ResponseHeadersRead, ct);
        await EnsureAsync(response, 200);
        await using var source = await response.Content.ReadAsStreamAsync(ct);
        await CopyAsync(source, destination, progress, ct);
    }

    public async Task UploadAsync(string path, Stream source, IProgress<long>? progress = null, CancellationToken ct = default)
    {
        using var content = new ProgressContent(source, progress);
        using var response = await _http.PutAsync(Encode(path, false), content, ct);
        await EnsureAsync(response, 201, 204, 200);
    }

    public async Task CreateFolderAsync(string path, CancellationToken ct = default)
    {
        using var response = await _http.SendAsync(new HttpRequestMessage(MkCol, Encode(path, true)), ct);
        await EnsureAsync(response, 201, 405); // 405: ya existe
    }

    public async Task DeleteAsync(string path, bool collection, CancellationToken ct = default)
    {
        using var response = await _http.DeleteAsync(Encode(path, collection), ct);
        await EnsureAsync(response, 204, 200);
    }

    public async Task MoveAsync(string from, string to, bool collection, CancellationToken ct = default)
    {
        using var request = new HttpRequestMessage(Move, Encode(from, collection));
        request.Headers.Add("Destination", new Uri(Server, Encode(to, collection)).ToString());
        request.Headers.Add("Overwrite", "F");
        using var response = await _http.SendAsync(request, ct);
        await EnsureAsync(response, 201, 204);
    }

    /// <summary>Descarga un fichero o una carpeta entera a una carpeta local.</summary>
    public async Task DownloadTreeAsync(DavItem item, string localFolder, IProgress<long>? progress = null, CancellationToken ct = default)
    {
        string target = Path.Combine(localFolder, SafeLocalName(item.Name));
        if (!item.IsCollection)
        {
            string temp = target + ".part";
            await using (var file = File.Create(temp))
                await DownloadAsync(item.Path, file, progress, ct);
            File.Move(temp, target, overwrite: true);
            return;
        }
        Directory.CreateDirectory(target);
        foreach (var child in await ListAsync(item.Path, ct))
            await DownloadTreeAsync(child, target, progress, ct);
    }

    /// <summary>Sube un fichero o una carpeta local entera a una carpeta remota.</summary>
    public async Task UploadTreeAsync(string localPath, string remoteFolder, IProgress<long>? progress = null, CancellationToken ct = default)
    {
        string name = Path.GetFileName(Path.TrimEndingDirectorySeparator(localPath));
        string remote = Combine(remoteFolder, name);
        if (File.Exists(localPath))
        {
            await using var file = File.OpenRead(localPath);
            await UploadAsync(remote, file, progress, ct);
            return;
        }
        await CreateFolderAsync(remote, ct);
        foreach (var entry in Directory.EnumerateFileSystemEntries(localPath))
        {
            if (Path.GetFileName(entry).StartsWith('.'))
                continue;
            await UploadTreeAsync(entry, remote, progress, ct);
        }
    }

    /// <summary>Un nombre remoto que se pueda guardar en este sistema.</summary>
    private static string SafeLocalName(string name)
    {
        var invalid = Path.GetInvalidFileNameChars();
        var clean = new string(name.Select(c => invalid.Contains(c) || c == ':' ? '_' : c).ToArray()).TrimEnd('.', ' ');
        return clean.Length == 0 || clean is "." or ".." ? "_" : clean;
    }

    private static async Task CopyAsync(Stream source, Stream destination, IProgress<long>? progress, CancellationToken ct)
    {
        var buffer = new byte[65536];
        int n;
        while ((n = await source.ReadAsync(buffer, ct)) > 0)
        {
            await destination.WriteAsync(buffer.AsMemory(0, n), ct);
            progress?.Report(n);
        }
    }

    private static async Task EnsureAsync(HttpResponseMessage response, params int[] ok)
    {
        int status = (int)response.StatusCode;
        if (ok.Contains(status))
            return;
        string text = "";
        try
        {
            text = await response.Content.ReadAsStringAsync();
            if (text.Length > 200 || text.TrimStart().StartsWith('<'))
                text = "";
        }
        catch (HttpRequestException) { }
        throw new DavException(status, $"{status} {response.ReasonPhrase} {text}".Trim());
    }

    public void Dispose() => _http.Dispose();

    /// <summary>Cuerpo de PUT que informa del progreso.</summary>
    private sealed class ProgressContent(Stream source, IProgress<long>? progress) : HttpContent
    {
        protected override async Task SerializeToStreamAsync(Stream stream, TransportContext? context) =>
            await CopyAsync(source, stream, progress, CancellationToken.None);

        protected override bool TryComputeLength(out long length)
        {
            if (source.CanSeek)
            {
                length = source.Length - source.Position;
                return true;
            }
            length = 0;
            return false;
        }
    }
}
