using System.Buffers.Binary;
using System.Text;
using Sunfish.Shares;

namespace Sunfish.Web;

/// <summary>
/// ZIP generado en flujo, sin fichero temporal, pensado para descompresores antiguos (PKZIP 2.x,
/// StuffIt): metodo Stored, CRC calculado antes de cada fichero (sin data descriptors) y tamano
/// total conocido de antemano (Content-Length, para que el navegador muestre el progreso).
/// Nombres no ASCII: para clientes antiguos en CP437 si se pueden representar (lo que esperan
/// PKZIP y compania); para los modernos, y si CP437 no basta, en UTF-8 con su indicador (los
/// descompresores actuales leen como UTF-8 los nombres sin indicador).
/// Cada fichero se lee dos veces (CRC y envio). Sin ZIP64: hasta 4 GiB y 65535 entradas.
/// </summary>
internal sealed class ZipStreamWriter
{
    public sealed record Entry(string Name, string? Physical, long Size, DateTime Modified)
    {
        public bool IsDirectory => Physical is null;
    }

    private sealed record Planned(Entry Entry, byte[] Name, bool Utf8, long Offset);

    private readonly List<Planned> _plan = new();

    public long TotalLength { get; }

    public int Count => _plan.Count;

    public const long MaxLength = uint.MaxValue - 1;
    public const int MaxEntries = ushort.MaxValue - 1;

    public ZipStreamWriter(IEnumerable<Entry> entries, bool preferUtf8)
    {
        long offset = 0;
        long central = 0;
        foreach (var entry in entries)
        {
            var (name, utf8) = EncodeName(entry.Name, preferUtf8);
            _plan.Add(new Planned(entry, name, utf8, offset));
            offset += 30 + name.Length + entry.Size;
            central += 46 + name.Length;
        }
        TotalLength = offset + central + 22;
    }

    public bool TooLarge => TotalLength > MaxLength || _plan.Count > MaxEntries;

    /// <summary>Recorre una carpeta para el ZIP: sin ocultos ni enlaces (no se siguen).</summary>
    public static List<Entry> Collect(string directory, string prefix = "")
    {
        var result = new List<Entry>();
        foreach (var e in ShareFileSystem.List(directory).OrderBy(e => e.Name, StringComparer.OrdinalIgnoreCase))
        {
            if (e.IsLink)
                continue;
            string physical = Path.Combine(directory, e.Name);
            string name = prefix + e.Name;
            if (e.IsDirectory)
            {
                result.Add(new Entry(name + "/", null, 0, e.Modified));
                result.AddRange(Collect(physical, name + "/"));
            }
            else
            {
                result.Add(new Entry(name, physical, e.Size, e.Modified));
            }
        }
        return result;
    }

    public async Task WriteAsync(Stream output, CancellationToken ct)
    {
        var crcs = new uint[_plan.Count];
        var buffer = new byte[65536];
        for (int i = 0; i < _plan.Count; i++)
        {
            var p = _plan[i];
            uint crc = 0;
            if (!p.Entry.IsDirectory)
                crc = await Crc32.OfFileAsync(p.Entry.Physical!, p.Entry.Size, buffer, ct);
            crcs[i] = crc;
            await output.WriteAsync(LocalHeader(p, crc), ct);
            if (!p.Entry.IsDirectory)
                await CopyExactAsync(p.Entry.Physical!, p.Entry.Size, output, buffer, ct);
        }
        long centralStart = _plan.Count == 0 ? 0 : _plan[^1].Offset + 30 + _plan[^1].Name.Length + _plan[^1].Entry.Size;
        long centralSize = 0;
        for (int i = 0; i < _plan.Count; i++)
        {
            var header = CentralHeader(_plan[i], crcs[i]);
            centralSize += header.Length;
            await output.WriteAsync(header, ct);
        }
        await output.WriteAsync(EndOfCentralDirectory(centralStart, centralSize), ct);
    }

    private static async Task CopyExactAsync(string path, long size, Stream output, byte[] buffer, CancellationToken ct)
    {
        await using var file = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete, 1, useAsync: true);
        long remaining = size;
        while (remaining > 0)
        {
            int n = await file.ReadAsync(buffer.AsMemory(0, (int)Math.Min(buffer.Length, remaining)), ct);
            if (n == 0)
                throw new IOException("File changed while zipping: " + path);
            await output.WriteAsync(buffer.AsMemory(0, n), ct);
            remaining -= n;
        }
    }

    private static byte[] LocalHeader(Planned p, uint crc)
    {
        var h = new byte[30 + p.Name.Length];
        var s = h.AsSpan();
        BinaryPrimitives.WriteUInt32LittleEndian(s, 0x04034b50);
        BinaryPrimitives.WriteUInt16LittleEndian(s[4..], 20);
        BinaryPrimitives.WriteUInt16LittleEndian(s[6..], Flags(p));
        BinaryPrimitives.WriteUInt16LittleEndian(s[8..], 0); // Stored
        WriteDosTime(s[10..], p.Entry.Modified);
        BinaryPrimitives.WriteUInt32LittleEndian(s[14..], crc);
        BinaryPrimitives.WriteUInt32LittleEndian(s[18..], (uint)p.Entry.Size);
        BinaryPrimitives.WriteUInt32LittleEndian(s[22..], (uint)p.Entry.Size);
        BinaryPrimitives.WriteUInt16LittleEndian(s[26..], (ushort)p.Name.Length);
        BinaryPrimitives.WriteUInt16LittleEndian(s[28..], 0);
        p.Name.CopyTo(s[30..]);
        return h;
    }

    private static byte[] CentralHeader(Planned p, uint crc)
    {
        var h = new byte[46 + p.Name.Length];
        var s = h.AsSpan();
        BinaryPrimitives.WriteUInt32LittleEndian(s, 0x02014b50);
        BinaryPrimitives.WriteUInt16LittleEndian(s[4..], 20); // creado por: MS-DOS, version 2.0
        BinaryPrimitives.WriteUInt16LittleEndian(s[6..], 20);
        BinaryPrimitives.WriteUInt16LittleEndian(s[8..], Flags(p));
        BinaryPrimitives.WriteUInt16LittleEndian(s[10..], 0);
        WriteDosTime(s[12..], p.Entry.Modified);
        BinaryPrimitives.WriteUInt32LittleEndian(s[16..], crc);
        BinaryPrimitives.WriteUInt32LittleEndian(s[20..], (uint)p.Entry.Size);
        BinaryPrimitives.WriteUInt32LittleEndian(s[24..], (uint)p.Entry.Size);
        BinaryPrimitives.WriteUInt16LittleEndian(s[28..], (ushort)p.Name.Length);
        // extra, comentario, disco y atributos internos a cero
        BinaryPrimitives.WriteUInt32LittleEndian(s[38..], p.Entry.IsDirectory ? 0x10u : 0x20u);
        BinaryPrimitives.WriteUInt32LittleEndian(s[42..], (uint)p.Offset);
        p.Name.CopyTo(s[46..]);
        return h;
    }

    private byte[] EndOfCentralDirectory(long centralStart, long centralSize)
    {
        var h = new byte[22];
        var s = h.AsSpan();
        BinaryPrimitives.WriteUInt32LittleEndian(s, 0x06054b50);
        BinaryPrimitives.WriteUInt16LittleEndian(s[8..], (ushort)_plan.Count);
        BinaryPrimitives.WriteUInt16LittleEndian(s[10..], (ushort)_plan.Count);
        BinaryPrimitives.WriteUInt32LittleEndian(s[12..], (uint)centralSize);
        BinaryPrimitives.WriteUInt32LittleEndian(s[16..], (uint)centralStart);
        return h;
    }

    private static ushort Flags(Planned p) => p.Utf8 ? (ushort)0x0800 : (ushort)0;

    internal static (byte[] Name, bool Utf8) EncodeName(string name, bool preferUtf8)
    {
        if (name.All(char.IsAscii))
            return (Encoding.ASCII.GetBytes(name), false);
        if (preferUtf8)
            return (Encoding.UTF8.GetBytes(name), true);
        try
        {
            return (Encodings.Cp437Strict.GetBytes(name), false);
        }
        catch (EncoderFallbackException)
        {
            return (Encoding.UTF8.GetBytes(name), true);
        }
    }

    private static void WriteDosTime(Span<byte> s, DateTime time)
    {
        if (time.Year < 1980)
            time = new DateTime(1980, 1, 1);
        if (time.Year > 2107)
            time = new DateTime(2107, 12, 31, 23, 59, 58);
        ushort dosTime = (ushort)((time.Hour << 11) | (time.Minute << 5) | (time.Second / 2));
        ushort dosDate = (ushort)(((time.Year - 1980) << 9) | (time.Month << 5) | time.Day);
        BinaryPrimitives.WriteUInt16LittleEndian(s, dosTime);
        BinaryPrimitives.WriteUInt16LittleEndian(s[2..], dosDate);
    }
}

/// <summary>CRC-32 (el de ZIP). System.IO.Hashing no esta en la BCL.</summary>
internal static class Crc32
{
    private static readonly uint[] Table = BuildTable();

    private static uint[] BuildTable()
    {
        var table = new uint[256];
        for (uint i = 0; i < 256; i++)
        {
            uint c = i;
            for (int k = 0; k < 8; k++)
                c = (c & 1) != 0 ? 0xEDB88320u ^ (c >> 1) : c >> 1;
            table[i] = c;
        }
        return table;
    }

    public static uint Update(uint crc, ReadOnlySpan<byte> data)
    {
        crc = ~crc;
        foreach (byte b in data)
            crc = Table[(crc ^ b) & 0xFF] ^ (crc >> 8);
        return ~crc;
    }

    public static async Task<uint> OfFileAsync(string path, long size, byte[] buffer, CancellationToken ct)
    {
        await using var file = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete, 1, useAsync: true);
        uint crc = 0;
        long remaining = size;
        while (remaining > 0)
        {
            int n = await file.ReadAsync(buffer.AsMemory(0, (int)Math.Min(buffer.Length, remaining)), ct);
            if (n == 0)
                throw new IOException("File changed while zipping: " + path);
            crc = Update(crc, buffer.AsSpan(0, n));
            remaining -= n;
        }
        return crc;
    }
}
