using System.Buffers.Binary;
using System.Text;

namespace NpncbDiagnostic;

internal sealed record ZipEntryMetadata(
    int Index,
    string Name,
    ushort Flags,
    ushort Method,
    uint Crc32,
    long CompressedSize,
    long UncompressedSize,
    long LocalHeaderOffset,
    long DataOffset,
    bool IsDirectory);

internal sealed record ZipContainerMetadata(
    long FileSize,
    IReadOnlyList<ZipEntryMetadata> Entries);

/// <summary>
/// Reads standard single-disk ZIP central-directory records and returns the raw
/// compressed bytes of each entry. Data descriptors are supported because size
/// and offset information comes from the central directory.
/// </summary>
internal static class ZipContainerReader
{
    private const uint LocalHeaderSignature = 0x0403_4B50;
    private const uint CentralHeaderSignature = 0x0201_4B50;
    private const uint EocdSignature = 0x0605_4B50;
    private const ushort Utf8Flag = 1 << 11;

    private static readonly UTF8Encoding StrictUtf8 = new(false, true);

    public static ZipContainerMetadata ReadMetadata(string zipPath, DiagnosticOptions options)
    {
        if (string.IsNullOrWhiteSpace(zipPath))
            throw new ArgumentException("ZIP path is required.", nameof(zipPath));
        if (options is null) throw new ArgumentNullException(nameof(options));
        options.Validate();

        using var stream = new FileStream(
            zipPath,
            FileMode.Open,
            FileAccess.Read,
            FileShare.Read,
            bufferSize: 64 * 1024,
            FileOptions.RandomAccess);

        long eocdOffset = FindEndOfCentralDirectory(stream, out byte[] eocd);
        ushort thisDisk = ReadUInt16(eocd, 4);
        ushort centralStartDisk = ReadUInt16(eocd, 6);
        ushort entriesOnDisk = ReadUInt16(eocd, 8);
        ushort totalEntries = ReadUInt16(eocd, 10);
        uint centralSizeValue = ReadUInt32(eocd, 12);
        uint centralOffsetValue = ReadUInt32(eocd, 16);

        if (thisDisk != 0 || centralStartDisk != 0 || entriesOnDisk != totalEntries)
            throw new NotSupportedException("Multi-disk ZIP archives are not supported.");

        if (totalEntries == ushort.MaxValue ||
            centralSizeValue == uint.MaxValue ||
            centralOffsetValue == uint.MaxValue)
        {
            throw new NotSupportedException("ZIP64 archives are not supported by this diagnostic sample.");
        }

        if (totalEntries > options.MaximumEntries)
        {
            throw new InvalidDataException(
                $"The archive has {totalEntries:N0} entries, exceeding the configured limit of {options.MaximumEntries:N0}.");
        }

        long centralOffset = centralOffsetValue;
        long centralSize = centralSizeValue;
        EnsureFileRange(stream.Length, centralOffset, centralSize, "central directory");
        if (centralOffset + centralSize > eocdOffset)
            throw new InvalidDataException("The central directory overlaps the EOCD record.");

        stream.Position = centralOffset;
        var entries = new List<ZipEntryMetadata>(totalEntries);

        for (int index = 0; index < totalEntries; index++)
        {
            byte[] fixedHeader = ReadExactly(stream, 46);
            if (ReadUInt32(fixedHeader, 0) != CentralHeaderSignature)
                throw new InvalidDataException($"Invalid central-directory header at entry {index}.");

            ushort flags = ReadUInt16(fixedHeader, 8);
            ushort method = ReadUInt16(fixedHeader, 10);
            uint crc32 = ReadUInt32(fixedHeader, 16);
            uint compressedSizeValue = ReadUInt32(fixedHeader, 20);
            uint uncompressedSizeValue = ReadUInt32(fixedHeader, 24);
            ushort fileNameLength = ReadUInt16(fixedHeader, 28);
            ushort extraLength = ReadUInt16(fixedHeader, 30);
            ushort commentLength = ReadUInt16(fixedHeader, 32);
            ushort diskNumberStart = ReadUInt16(fixedHeader, 34);
            uint localHeaderOffsetValue = ReadUInt32(fixedHeader, 42);

            if (compressedSizeValue == uint.MaxValue ||
                uncompressedSizeValue == uint.MaxValue ||
                localHeaderOffsetValue == uint.MaxValue)
            {
                throw new NotSupportedException(
                    $"Entry {index} uses ZIP64 size or offset fields, which this sample does not support.");
            }

            if (diskNumberStart != 0)
                throw new NotSupportedException($"Entry {index} starts on another ZIP disk.");

            byte[] nameBytes = ReadExactly(stream, fileNameLength);
            _ = ReadExactly(stream, extraLength);
            _ = ReadExactly(stream, commentLength);
            long nextCentralRecord = stream.Position;

            string entryName = DecodeEntryName(nameBytes, flags);
            long localHeaderOffset = localHeaderOffsetValue;
            long compressedSize = compressedSizeValue;
            long uncompressedSize = uncompressedSizeValue;
            long dataOffset = ReadLocalDataOffset(
                stream,
                localHeaderOffset,
                method,
                compressedSize,
                stream.Length);

            entries.Add(new ZipEntryMetadata(
                index,
                entryName,
                flags,
                method,
                crc32,
                compressedSize,
                uncompressedSize,
                localHeaderOffset,
                dataOffset,
                entryName.EndsWith("/", StringComparison.Ordinal)));

            stream.Position = nextCentralRecord;
        }

        long centralEnd = checked(centralOffset + centralSize);
        if (stream.Position > centralEnd)
            throw new InvalidDataException("Central-directory records exceed the EOCD central-directory size.");

        return new ZipContainerMetadata(stream.Length, entries);
    }

    public static byte[] ReadRawCompressedData(
        FileStream stream,
        ZipEntryMetadata entry,
        long maximumCompressedBytes)
    {
        if (stream is null) throw new ArgumentNullException(nameof(stream));
        if (entry is null) throw new ArgumentNullException(nameof(entry));
        if (maximumCompressedBytes <= 0)
            throw new ArgumentOutOfRangeException(nameof(maximumCompressedBytes));

        if (entry.CompressedSize > maximumCompressedBytes)
        {
            throw new InvalidDataException(
                $"Compressed entry size {entry.CompressedSize:N0} exceeds the configured limit " +
                $"of {maximumCompressedBytes:N0} bytes.");
        }

        if (entry.CompressedSize > int.MaxValue)
            throw new NotSupportedException("A single compressed entry larger than 2 GiB is not supported.");

        EnsureFileRange(stream.Length, entry.DataOffset, entry.CompressedSize, "entry data");
        stream.Position = entry.DataOffset;
        return ReadExactly(stream, checked((int)entry.CompressedSize));
    }

    private static long FindEndOfCentralDirectory(FileStream stream, out byte[] eocd)
    {
        const int minimumEocdLength = 22;
        const int maximumEocdSearch = minimumEocdLength + ushort.MaxValue;

        if (stream.Length < minimumEocdLength)
            throw new InvalidDataException("The file is too short to be a ZIP archive.");

        int tailLength = checked((int)Math.Min(stream.Length, maximumEocdSearch));
        stream.Position = stream.Length - tailLength;
        byte[] tail = ReadExactly(stream, tailLength);

        for (int offset = tail.Length - minimumEocdLength; offset >= 0; offset--)
        {
            if (ReadUInt32(tail, offset) != EocdSignature) continue;
            ushort commentLength = ReadUInt16(tail, offset + 20);
            if (offset + minimumEocdLength + commentLength != tail.Length) continue;

            eocd = tail.AsSpan(offset, minimumEocdLength).ToArray();
            return stream.Length - tailLength + offset;
        }

        throw new InvalidDataException("The ZIP End of Central Directory record was not found.");
    }

    private static long ReadLocalDataOffset(
        FileStream stream,
        long localHeaderOffset,
        ushort centralMethod,
        long compressedSize,
        long fileLength)
    {
        EnsureFileRange(fileLength, localHeaderOffset, 30, "local header");
        stream.Position = localHeaderOffset;
        byte[] localHeader = ReadExactly(stream, 30);

        if (ReadUInt32(localHeader, 0) != LocalHeaderSignature)
            throw new InvalidDataException("Invalid local ZIP header signature.");

        ushort localMethod = ReadUInt16(localHeader, 8);
        if (localMethod != centralMethod)
            throw new InvalidDataException("Compression method differs between local and central headers.");

        ushort localNameLength = ReadUInt16(localHeader, 26);
        ushort localExtraLength = ReadUInt16(localHeader, 28);
        long dataOffset = checked(localHeaderOffset + 30L + localNameLength + localExtraLength);
        EnsureFileRange(fileLength, dataOffset, compressedSize, "compressed entry data");
        return dataOffset;
    }

    private static string DecodeEntryName(byte[] nameBytes, ushort flags)
    {
        if ((flags & Utf8Flag) != 0)
        {
            try
            {
                return StrictUtf8.GetString(nameBytes);
            }
            catch (DecoderFallbackException)
            {
                return Encoding.UTF8.GetString(nameBytes);
            }
        }

        // ZIP's legacy default is CP437. To keep this sample dependency-free,
        // ASCII is exact and bytes above 0x7F are represented through Latin-1.
        return Encoding.Latin1.GetString(nameBytes);
    }

    private static byte[] ReadExactly(Stream stream, int length)
    {
        if (length < 0) throw new ArgumentOutOfRangeException(nameof(length));
        var bytes = new byte[length];
        int offset = 0;

        while (offset < bytes.Length)
        {
            int read = stream.Read(bytes, offset, bytes.Length - offset);
            if (read == 0) throw new EndOfStreamException("Unexpected end of ZIP file.");
            offset += read;
        }

        return bytes;
    }

    private static ushort ReadUInt16(byte[] bytes, int offset)
    {
        EnsureBufferRange(bytes, offset, 2);
        return BinaryPrimitives.ReadUInt16LittleEndian(bytes.AsSpan(offset, 2));
    }

    private static uint ReadUInt32(byte[] bytes, int offset)
    {
        EnsureBufferRange(bytes, offset, 4);
        return BinaryPrimitives.ReadUInt32LittleEndian(bytes.AsSpan(offset, 4));
    }

    private static void EnsureBufferRange(byte[] bytes, int offset, int length)
    {
        if (offset < 0 || length < 0 || offset > bytes.Length - length)
            throw new InvalidDataException("ZIP structure points outside the available buffer.");
    }

    private static void EnsureFileRange(long fileLength, long offset, long length, string description)
    {
        if (offset < 0 || length < 0 || offset > fileLength - length)
            throw new InvalidDataException($"The {description} points outside the ZIP file.");
    }
}
