using System.Text;

namespace DbaMessageInserter;

public sealed record ZipBuildResult(
    string OutputPath,
    uint Crc32,
    int UncompressedSize,
    int CompressedSize,
    IReadOnlyList<DeflateBlockLog> Blocks,
    long MeaningfulDeflateBits,
    long BytePaddedDeflateBits,
    int DbaCapacityBits,
    int DbaPayloadBitsWritten);

/// <summary>
/// Writes a classic, single-entry ZIP archive whose method-8 payload is the
/// raw DEFLATE stream generated by RawDeflateEncoder.
/// </summary>
public static class ControlledZipWriter
{
    private const uint LocalFileHeaderSignature = 0x0403_4B50;
    private const uint CentralDirectoryHeaderSignature = 0x0201_4B50;
    private const uint EndOfCentralDirectorySignature = 0x0605_4B50;
    private const ushort Version20 = 20;
    private const ushort Utf8Flag = 1 << 11;
    private const ushort DeflateMethod = 8;

    public static ZipBuildResult CreateSingleFileZip(
        string inputPath,
        string outputPath,
        byte[] input,
        IReadOnlyList<DeflateBlockSpec> plan)
    {
        return CreateSingleFileZip(inputPath, outputPath, input, plan, Array.Empty<byte>());
    }

    public static ZipBuildResult CreateSingleFileZip(
        string inputPath,
        string outputPath,
        byte[] input,
        IReadOnlyList<DeflateBlockSpec> plan,
        byte[] dbaPayloadBytes)
    {
        if (string.IsNullOrWhiteSpace(inputPath))
        {
            throw new ArgumentException("Input path is required.", nameof(inputPath));
        }

        if (string.IsNullOrWhiteSpace(outputPath))
        {
            throw new ArgumentException("Output path is required.", nameof(outputPath));
        }

        if (input is null)
        {
            throw new ArgumentNullException(nameof(input));
        }

        if (plan is null)
        {
            throw new ArgumentNullException(nameof(plan));
        }

        if (dbaPayloadBytes is null)
        {
            throw new ArgumentNullException(nameof(dbaPayloadBytes));
        }

        string entryName = Path.GetFileName(inputPath);

        if (string.IsNullOrEmpty(entryName))
        {
            throw new ArgumentException(
                "The input path does not contain a file name.",
                nameof(inputPath));
        }

        byte[] entryNameBytes =
            new UTF8Encoding(encoderShouldEmitUTF8Identifier: false).GetBytes(entryName);

        if (entryNameBytes.Length > ushort.MaxValue)
        {
            throw new NotSupportedException("The UTF-8 ZIP entry name is too long.");
        }

        RawDeflateResult deflate = RawDeflateEncoder.Encode(input, plan, dbaPayloadBytes);
        uint crc32 = Crc32.Compute(input);

        uint compressedSize = checked((uint)deflate.CompressedBytes.Length);
        uint uncompressedSize = checked((uint)input.Length);

        (ushort dosTime, ushort dosDate) =
            ToDosTimestamp(File.GetLastWriteTime(inputPath));

        string? directory = Path.GetDirectoryName(Path.GetFullPath(outputPath));

        if (!string.IsNullOrEmpty(directory))
        {
            Directory.CreateDirectory(directory);
        }

        using var file =
            new FileStream(outputPath, FileMode.Create, FileAccess.Write, FileShare.None);

        using var writer =
            new BinaryWriter(file, Encoding.UTF8, leaveOpen: true);

        uint localHeaderOffset = checked((uint)file.Position);

        // Local file header
        writer.Write(LocalFileHeaderSignature);
        writer.Write(Version20);
        writer.Write(Utf8Flag);
        writer.Write(DeflateMethod);
        writer.Write(dosTime);
        writer.Write(dosDate);
        writer.Write(crc32);
        writer.Write(compressedSize);
        writer.Write(uncompressedSize);
        writer.Write(checked((ushort)entryNameBytes.Length));
        writer.Write((ushort)0); // Extra field length
        writer.Write(entryNameBytes);
        writer.Write(deflate.CompressedBytes);

        uint centralDirectoryOffset = checked((uint)file.Position);

        // Central directory file header
        writer.Write(CentralDirectoryHeaderSignature);
        writer.Write(Version20); // Version made by: MS-DOS/FAT host, version 2.0
        writer.Write(Version20); // Version needed to extract
        writer.Write(Utf8Flag);
        writer.Write(DeflateMethod);
        writer.Write(dosTime);
        writer.Write(dosDate);
        writer.Write(crc32);
        writer.Write(compressedSize);
        writer.Write(uncompressedSize);
        writer.Write(checked((ushort)entryNameBytes.Length));
        writer.Write((ushort)0); // Extra field length
        writer.Write((ushort)0); // File comment length
        writer.Write((ushort)0); // Disk number start
        writer.Write((ushort)0); // Internal file attributes
        writer.Write(0u);        // External file attributes
        writer.Write(localHeaderOffset);
        writer.Write(entryNameBytes);

        uint centralDirectorySize =
            checked((uint)(file.Position - centralDirectoryOffset));

        // End of central directory
        writer.Write(EndOfCentralDirectorySignature);
        writer.Write((ushort)0); // This disk
        writer.Write((ushort)0); // Central-directory start disk
        writer.Write((ushort)1); // Entries on this disk
        writer.Write((ushort)1); // Total entries
        writer.Write(centralDirectorySize);
        writer.Write(centralDirectoryOffset);
        writer.Write((ushort)0); // ZIP comment length
        writer.Flush();

        return new ZipBuildResult(
            outputPath,
            crc32,
            input.Length,
            deflate.CompressedBytes.Length,
            deflate.Blocks,
            deflate.MeaningfulEndBit,
            deflate.BytePaddedEndBit,
            deflate.TotalDbaCapacityBits,
            deflate.TotalDbaPayloadBitsWritten);
    }

    private static (ushort Time, ushort Date) ToDosTimestamp(DateTime localTime)
    {
        DateTime value = localTime;

        if (value.Year < 1980)
        {
            value = new DateTime(1980, 1, 1, 0, 0, 0, DateTimeKind.Local);
        }
        else if (value.Year > 2107)
        {
            value = new DateTime(2107, 12, 31, 23, 59, 58, DateTimeKind.Local);
        }

        ushort time = (ushort)(
            (value.Hour << 11) |
            (value.Minute << 5) |
            (value.Second / 2));

        ushort date = (ushort)(
            ((value.Year - 1980) << 9) |
            (value.Month << 5) |
            value.Day);

        return (time, date);
    }
}
