// Reference: golang/nats-server/server/filestore.go
// Go wire format: filestore.go:6720-6724 (writeMsgRecordLocked)
// Go decode: filestore.go:8180-8250 (msgFromBufEx)
// Go size calc: filestore.go:8770-8777 (fileStoreMsgSizeRaw)
// Go constants: filestore.go:1034-1038 (msgHdrSize, checksumSize, emptyRecordLen)
// Go bit flags: filestore.go:7972-7982 (ebit = 1 << 63)
//
// Binary message record format:
//   [1:flags][varint:subj_len][N:subject][varint:hdr_len][M:headers][varint:payload_len][P:payload][8:sequence_LE][8:checksum]
//
// Flags byte: 0x80 = deleted (ebit in Go).
// Varint encoding: high-bit continuation (same as protobuf).
// Checksum: XxHash64 over all bytes before the checksum field.

using System.Buffers.Binary;
using System.IO.Hashing;
using System.Text;

namespace NATS.Server.JetStream.Storage;

/// <summary>
/// Binary message record encoder/decoder matching Go's filestore.go wire format.
/// Each record represents a single stored message in a JetStream file store block.
/// </summary>
public sealed class MessageRecord
{
    /// <summary>Stream sequence number. Go: StoreMsg.seq</summary>
    public ulong Sequence { get; init; }

    /// <summary>NATS subject. Go: StoreMsg.subj</summary>
    public string Subject { get; init; } = string.Empty;

    /// <summary>Optional NATS message headers. Go: StoreMsg.hdr</summary>
    public ReadOnlyMemory<byte> Headers { get; init; }

    /// <summary>Message body payload. Go: StoreMsg.msg</summary>
    public ReadOnlyMemory<byte> Payload { get; init; }

    /// <summary>Wall-clock timestamp in Unix nanoseconds. Go: StoreMsg.ts</summary>
    public long Timestamp { get; init; }

    /// <summary>Whether this record is a deletion marker. Go: ebit (1 &lt;&lt; 63) on sequence.</summary>
    public bool Deleted { get; init; }

    // Wire format constants
    private const byte DeletedFlag = 0x80;
    private const int ChecksumSize = 8;
    private const int SequenceSize = 8;
    private const int TimestampSize = 8;
    // Trailer: sequence(8) + timestamp(8) + checksum(8)
    private const int TrailerSize = SequenceSize + TimestampSize + ChecksumSize;

    /// <summary>
    /// Encodes a <see cref="MessageRecord"/> to its binary wire format.
    /// </summary>
    /// <returns>The encoded byte array.</returns>
    public static byte[] Encode(MessageRecord record)
    {
        var size = MeasureEncodedSize(record.Subject, record.Headers.Span, record.Payload.Span);
        var buffer = new byte[size];
        EncodeTo(buffer, 0, record.Sequence, record.Subject, record.Headers.Span,
            record.Payload.Span, record.Timestamp, record.Deleted);
        return buffer;
    }

    /// <summary>
    /// Computes the encoded byte size of a record without allocating.
    /// </summary>
    public static int MeasureEncodedSize(string subject, ReadOnlySpan<byte> headers, ReadOnlySpan<byte> payload)
    {
        var subjectByteCount = Encoding.UTF8.GetByteCount(subject);
        return 1
            + VarintSize((ulong)subjectByteCount) + subjectByteCount
            + VarintSize((ulong)headers.Length) + headers.Length
            + VarintSize((ulong)payload.Length) + payload.Length
            + TrailerSize;
    }

    /// <summary>
    /// Encodes a record directly into an existing buffer at the given offset.
    /// Go equivalent: writeMsgRecordLocked writes directly into cache.buf.
    /// Returns the number of bytes written.
    /// </summary>
    public static int EncodeTo(
        byte[] buffer, int bufOffset,
        ulong sequence, string subject,
        ReadOnlySpan<byte> headers, ReadOnlySpan<byte> payload,
        long timestamp, bool deleted = false)
    {
        var startOffset = bufOffset;
        var span = buffer.AsSpan();

        // 1. Flags byte
        span[bufOffset++] = deleted ? DeletedFlag : (byte)0;

        // 2. Subject length (varint) + subject bytes — encode UTF8 directly into buffer
        var subjectByteCount = Encoding.UTF8.GetByteCount(subject);
        bufOffset += WriteVarint(span[bufOffset..], (ulong)subjectByteCount);
        Encoding.UTF8.GetBytes(subject.AsSpan(), span.Slice(bufOffset, subjectByteCount));
        bufOffset += subjectByteCount;

        // 3. Headers length (varint) + headers bytes
        bufOffset += WriteVarint(span[bufOffset..], (ulong)headers.Length);
        headers.CopyTo(span[bufOffset..]);
        bufOffset += headers.Length;

        // 4. Payload length (varint) + payload bytes
        bufOffset += WriteVarint(span[bufOffset..], (ulong)payload.Length);
        payload.CopyTo(span[bufOffset..]);
        bufOffset += payload.Length;

        // 5. Sequence (8 bytes, little-endian)
        BinaryPrimitives.WriteUInt64LittleEndian(span[bufOffset..], sequence);
        bufOffset += SequenceSize;

        // 6. Timestamp (8 bytes, little-endian)
        BinaryPrimitives.WriteInt64LittleEndian(span[bufOffset..], timestamp);
        bufOffset += TimestampSize;

        // 7. Checksum: XxHash64 over everything before the checksum field
        var checksumInput = span[startOffset..bufOffset];
        var checksum = XxHash64.HashToUInt64(checksumInput);
        BinaryPrimitives.WriteUInt64LittleEndian(span[bufOffset..], checksum);
        bufOffset += ChecksumSize;

        return bufOffset - startOffset;
    }

    /// <summary>
    /// Decodes a binary record and validates its checksum.
    /// </summary>
    /// <param name="data">The raw record bytes.</param>
    /// <returns>The decoded <see cref="MessageRecord"/>.</returns>
    /// <exception cref="InvalidDataException">Thrown when the record is too short or the checksum does not match.</exception>
    public static MessageRecord Decode(ReadOnlySpan<byte> data)
    {
        // Minimum: flags(1) + varint(0)(1) + varint(0)(1) + varint(0)(1) + seq(8) + ts(8) + checksum(8)
        if (data.Length < 1 + 3 + TrailerSize)
            throw new InvalidDataException("Record too short.");

        // Validate checksum first: XxHash64 over everything except the last 8 bytes.
        var payloadRegion = data[..^ChecksumSize];
        var expectedChecksum = BinaryPrimitives.ReadUInt64LittleEndian(data[^ChecksumSize..]);
        var actualChecksum = XxHash64.HashToUInt64(payloadRegion);

        if (expectedChecksum != actualChecksum)
            throw new InvalidDataException("Checksum mismatch: record is corrupt.");

        var offset = 0;

        // 1. Flags
        var flags = data[offset++];
        var deleted = (flags & DeletedFlag) != 0;

        // 2. Subject
        var (subjectLen, subjectLenBytes) = ReadVarint(data[offset..]);
        offset += subjectLenBytes;
        var subject = Encoding.UTF8.GetString(data.Slice(offset, (int)subjectLen));
        offset += (int)subjectLen;

        // 3. Headers
        var (headersLen, headersLenBytes) = ReadVarint(data[offset..]);
        offset += headersLenBytes;
        var headers = data.Slice(offset, (int)headersLen).ToArray();
        offset += (int)headersLen;

        // 4. Payload
        var (payloadLen, payloadLenBytes) = ReadVarint(data[offset..]);
        offset += payloadLenBytes;
        var payload = data.Slice(offset, (int)payloadLen).ToArray();
        offset += (int)payloadLen;

        // 5. Sequence
        var sequence = BinaryPrimitives.ReadUInt64LittleEndian(data[offset..]);
        offset += SequenceSize;

        // 6. Timestamp
        var timestamp = BinaryPrimitives.ReadInt64LittleEndian(data[offset..]);

        return new MessageRecord
        {
            Sequence = sequence,
            Subject = subject,
            Headers = headers,
            Payload = payload,
            Timestamp = timestamp,
            Deleted = deleted,
        };
    }

    /// <summary>
    /// Writes a varint (protobuf-style high-bit continuation encoding) to the target span.
    /// </summary>
    /// <param name="buffer">The target buffer.</param>
    /// <param name="value">The value to encode.</param>
    /// <returns>The number of bytes written.</returns>
    public static int WriteVarint(Span<byte> buffer, ulong value)
    {
        var i = 0;
        while (value >= 0x80)
        {
            buffer[i++] = (byte)(value | 0x80);
            value >>= 7;
        }

        buffer[i++] = (byte)value;
        return i;
    }

    /// <summary>
    /// Reads a varint (protobuf-style high-bit continuation encoding) from the source span.
    /// </summary>
    /// <param name="data">The source buffer.</param>
    /// <returns>A tuple of (decoded value, number of bytes consumed).</returns>
    public static (ulong Value, int BytesRead) ReadVarint(ReadOnlySpan<byte> data)
    {
        ulong result = 0;
        var shift = 0;
        var i = 0;

        while (i < data.Length)
        {
            var b = data[i++];
            result |= (ulong)(b & 0x7F) << shift;

            if ((b & 0x80) == 0)
                return (result, i);

            shift += 7;

            if (shift >= 64)
                throw new InvalidDataException("Varint is too long.");
        }

        throw new InvalidDataException("Varint is truncated.");
    }

    /// <summary>
    /// Measures the total byte length of the first record in a buffer without fully decoding it.
    /// This parses the varint-encoded field lengths to compute the record size.
    /// </summary>
    /// <param name="data">Buffer that starts with a record (may contain trailing data).</param>
    /// <returns>The total byte length of the first record.</returns>
    /// <exception cref="InvalidDataException">If the buffer is too short to contain a valid record header.</exception>
    public static int MeasureRecord(ReadOnlySpan<byte> data)
    {
        if (data.Length < 1 + 3 + TrailerSize)
            throw new InvalidDataException("Buffer too short to contain a record.");

        var offset = 1; // flags byte

        // Subject length
        var (subjectLen, subjectLenBytes) = ReadVarint(data[offset..]);
        offset += subjectLenBytes + (int)subjectLen;

        // Headers length
        var (headersLen, headersLenBytes) = ReadVarint(data[offset..]);
        offset += headersLenBytes + (int)headersLen;

        // Payload length
        var (payloadLen, payloadLenBytes) = ReadVarint(data[offset..]);
        offset += payloadLenBytes + (int)payloadLen;

        // Trailer: sequence(8) + timestamp(8) + checksum(8)
        offset += TrailerSize;

        return offset;
    }

    /// <summary>
    /// Returns the number of bytes needed to encode a varint.
    /// </summary>
    private static int VarintSize(ulong value)
    {
        var size = 1;
        while (value >= 0x80)
        {
            size++;
            value >>= 7;
        }

        return size;
    }
}
