//! Snapshot hyperlink entry encoding.
//!
//! Each entry contains a URI and either an implicit numeric ID or an explicit
//! byte-string ID. Implicit IDs are generated by the terminal when the source
//! hyperlink has no explicit ID. A record can use these entries to build a
//! hyperlink table and assign indexes according to that record's format.
//! Indexing and ordering are properties of the containing record rather than
//! this codec.
//!
//! URIs and explicit IDs are non-empty arbitrary byte strings. Their lengths
//! are retained on the wire; they are not NUL-terminated and need not contain
//! UTF-8. Native page storage requires every allocated hyperlink string to
//! contain at least one byte. Encoding and standalone decoding reject empty
//! strings; PAGE decoding consumes but ignores entries it cannot represent.
//!
//! All integers are unsigned and little-endian.
//!
//! ## Implicit ID
//!
//! | Offset | Size      | Field              |
//! | -----: | --------: | :----------------- |
//! |      0 |         1 | Kind, `1`          |
//! |      1 |         4 | Implicit ID (`u32`) |
//! |      5 |         4 | URI length (`u32`) |
//! |      9 | `uri_len` | URI bytes          |
//!
//! ## Explicit ID
//!
//! | Offset       | Size      | Field                      |
//! | -----------: | --------: | :------------------------- |
//! |            0 |         1 | Kind, `2`                  |
//! |            1 |         4 | Explicit ID length (`u32`) |
//! |            5 |  `id_len` | Explicit ID bytes          |
//! | `5 + id_len` |         4 | URI length (`u32`)         |
//! | `9 + id_len` | `uri_len` | URI bytes                  |
//!
//! Both variants have nine bytes of fixed overhead. The remaining bytes are
//! the URI plus the explicit ID, when present.
//!
//! The standalone decoder returns an allocator-owned hyperlink. PAGE decoding
//! reads strings directly into the destination page's allocator instead.

const std = @import("std");
const Allocator = std.mem.Allocator;
const test_fixture = @import("fixture.zig");
const io = @import("io.zig");
const terminal_hyperlink = @import("../hyperlink.zig");
const terminal_page = @import("../page.zig");
const terminal_size = @import("../size.zig");

const Kind = enum(u8) {
    implicit = 1,
    explicit = 2,
};

/// Errors possible while encoding one hyperlink entry.
pub const EncodeError = std.Io.Writer.Error || error{
    /// A native hyperlink URI must contain at least one byte.
    InvalidUri,

    /// A native explicit hyperlink ID must contain at least one byte.
    InvalidExplicitId,

    /// A string does not fit the version 1 length field.
    StringTooLong,
};

/// Errors possible while decoding one allocator-owned hyperlink entry.
pub const DecodeError = std.Io.Reader.Error || Allocator.Error || error{
    /// The hyperlink kind is not defined by snapshot version 1.
    InvalidKind,

    /// A native hyperlink URI must contain at least one byte.
    InvalidUri,

    /// A native explicit hyperlink ID must contain at least one byte.
    InvalidExplicitId,
};

/// Errors possible while decoding directly into a native page.
pub const DecodePageError = std.Io.Reader.Error || error{
    /// The hyperlink kind is not defined by snapshot version 1.
    InvalidKind,
};

/// Encode one hyperlink entry.
pub fn encode(
    value: terminal_hyperlink.Hyperlink,
    writer: *std.Io.Writer,
) EncodeError!void {
    // Validate the complete value before writing any part of this variable-
    // length entry. This keeps callers able to roll back at entry boundaries.
    if (value.uri.len == 0) return error.InvalidUri;
    if (value.uri.len > std.math.maxInt(u32)) return error.StringTooLong;
    if (value.id == .explicit) {
        const id = value.id.explicit;
        if (id.len == 0) return error.InvalidExplicitId;
        if (id.len > std.math.maxInt(u32)) return error.StringTooLong;
    }

    switch (value.id) {
        .implicit => |id| {
            try writer.writeByte(@intFromEnum(Kind.implicit));
            try io.writeInt(writer, u32, id);
            try io.writeInt(writer, u32, @intCast(value.uri.len));
            try writer.writeAll(value.uri);
        },
        .explicit => |id| {
            try writer.writeByte(@intFromEnum(Kind.explicit));
            try io.writeInt(writer, u32, @intCast(id.len));
            try writer.writeAll(id);
            try io.writeInt(writer, u32, @intCast(value.uri.len));
            try writer.writeAll(value.uri);
        },
    }
}

/// Decode one allocator-owned hyperlink entry.
///
/// The caller owns the returned value and must call `Hyperlink.deinit`.
pub fn decode(
    reader: *std.Io.Reader,
    alloc: Allocator,
) DecodeError!terminal_hyperlink.Hyperlink {
    const kind_raw = try reader.takeByte();
    const kind = std.enums.fromInt(Kind, kind_raw) orelse
        return error.InvalidKind;

    return switch (kind) {
        .implicit => implicit: {
            const id = try io.readInt(reader, u32);
            const uri_len: usize = @intCast(try io.readInt(reader, u32));
            if (uri_len == 0) return error.InvalidUri;

            const uri = try alloc.alloc(u8, uri_len);
            errdefer alloc.free(uri);
            try reader.readSliceAll(uri);

            break :implicit .{
                .id = .{ .implicit = id },
                .uri = uri,
            };
        },

        .explicit => explicit: {
            const id_len: usize = @intCast(try io.readInt(reader, u32));
            if (id_len == 0) return error.InvalidExplicitId;

            const id = try alloc.alloc(u8, id_len);
            errdefer alloc.free(id);
            try reader.readSliceAll(id);

            const uri_len: usize = @intCast(try io.readInt(reader, u32));
            if (uri_len == 0) return error.InvalidUri;

            const uri = try alloc.alloc(u8, uri_len);
            errdefer alloc.free(uri);
            try reader.readSliceAll(uri);

            break :explicit .{
                .id = .{ .explicit = id },
                .uri = uri,
            };
        },
    };
}

/// Decode one hyperlink directly into page-owned storage.
///
/// Explicit ID and URI bytes are read into the page string allocator and the
/// completed entry is inserted into the page hyperlink set. The returned ID is
/// the native ID assigned by the destination page. Zero is returned when the
/// encoded value cannot be represented within the destination page, including
/// empty strings and insufficient advertised capacity.
pub fn decodePage(
    page: *terminal_page.Page,
    reader: *std.Io.Reader,
) DecodePageError!terminal_hyperlink.Id {
    const kind_raw = try reader.takeByte();
    const kind = std.enums.fromInt(Kind, kind_raw) orelse
        return error.InvalidKind;

    const entry: terminal_hyperlink.PageEntry = switch (kind) {
        .implicit => implicit: {
            const id = try io.readInt(reader, u32);
            const uri_len: usize = @intCast(try io.readInt(reader, u32));
            if (uri_len == 0) return 0;

            const uri = decodePageString(
                page,
                reader,
                uri_len,
            ) catch |err| switch (err) {
                error.StringsOutOfMemory => {
                    try reader.discardAll(uri_len);
                    return 0;
                },
                else => |read_err| return read_err,
            };

            break :implicit .{
                .id = .{ .implicit = id },
                .uri = uri,
            };
        },

        .explicit => explicit: {
            const id_len: usize = @intCast(try io.readInt(reader, u32));
            if (id_len == 0) {
                const uri_len: usize = @intCast(
                    try io.readInt(reader, u32),
                );
                try reader.discardAll(uri_len);
                return 0;
            }

            const id = decodePageString(
                page,
                reader,
                id_len,
            ) catch |err| switch (err) {
                error.StringsOutOfMemory => {
                    try reader.discardAll(id_len);
                    const uri_len: usize = @intCast(
                        try io.readInt(reader, u32),
                    );
                    try reader.discardAll(uri_len);
                    return 0;
                },
                else => |read_err| return read_err,
            };
            errdefer page.string_alloc.free(
                page.memory,
                id.slice(page.memory),
            );

            const uri_len: usize = @intCast(try io.readInt(reader, u32));
            if (uri_len == 0) {
                page.string_alloc.free(
                    page.memory,
                    id.slice(page.memory),
                );
                return 0;
            }

            const uri = decodePageString(
                page,
                reader,
                uri_len,
            ) catch |err| switch (err) {
                error.StringsOutOfMemory => {
                    try reader.discardAll(uri_len);
                    page.string_alloc.free(
                        page.memory,
                        id.slice(page.memory),
                    );
                    return 0;
                },
                else => |read_err| return read_err,
            };

            break :explicit .{
                .id = .{ .explicit = id },
                .uri = uri,
            };
        },
    };
    return page.hyperlink_set.addContext(
        page.memory,
        entry,
        .{ .page = page },
    ) catch {
        // The entry was fully consumed, so capacity failure only loses this
        // optional hyperlink. Free its strings and let cells restore unlinked.
        entry.free(page);
        return 0;
    };
}

fn decodePageString(
    page: *terminal_page.Page,
    reader: *std.Io.Reader,
    len: usize,
) (std.Io.Reader.Error || error{StringsOutOfMemory})!terminal_size.Offset(u8).Slice {
    std.debug.assert(len > 0);

    // Allocate space for the string and read directly into it.
    const value = page.string_alloc.alloc(
        u8,
        page.memory,
        len,
    ) catch return error.StringsOutOfMemory;
    errdefer page.string_alloc.free(page.memory, value);
    try reader.readSliceAll(value);

    return .{
        .len = value.len,
        .offset = terminal_size.getOffset(
            u8,
            page.memory,
            &value[0],
        ),
    };
}

const test_implicit_fixture = test_fixture.parse(@embedFile("testdata/hyperlink-implicit-v1.hex"));
const test_explicit_fixture = test_fixture.parse(@embedFile("testdata/hyperlink-explicit-v1.hex"));

test "golden implicit encoding" {
    const value: terminal_hyperlink.Hyperlink = .{
        .id = .{ .implicit = 0x01020304 },
        .uri = "uri",
    };

    var buf: [128]u8 = undefined;
    var writer: std.Io.Writer = .fixed(&buf);
    try encode(value, &writer);

    try test_fixture.expectEqual(
        .bytes,
        "src/terminal/snapshot/testdata/hyperlink-implicit-v1.hex",
        "snapshot_fixture-hyperlink-implicit-v1.hex",
        &test_implicit_fixture,
        writer.buffered(),
    );

    var reader: std.Io.Reader = .fixed(&test_implicit_fixture);
    var decoded = try decode(&reader, std.testing.allocator);
    defer decoded.deinit(std.testing.allocator);
    try std.testing.expectEqualDeep(value, decoded);
}

test "golden explicit encoding" {
    const value: terminal_hyperlink.Hyperlink = .{
        .id = .{ .explicit = "id" },
        .uri = "uri",
    };

    var buf: [128]u8 = undefined;
    var writer: std.Io.Writer = .fixed(&buf);
    try encode(value, &writer);

    try test_fixture.expectEqual(
        .bytes,
        "src/terminal/snapshot/testdata/hyperlink-explicit-v1.hex",
        "snapshot_fixture-hyperlink-explicit-v1.hex",
        &test_explicit_fixture,
        writer.buffered(),
    );

    var reader: std.Io.Reader = .fixed(&test_explicit_fixture);
    var decoded = try decode(&reader, std.testing.allocator);
    defer decoded.deinit(std.testing.allocator);
    try std.testing.expectEqualDeep(value, decoded);
}

test "encode rejects invalid strings before writing" {
    const cases = [_]struct {
        value: terminal_hyperlink.Hyperlink,
        expected: anyerror,
    }{
        .{
            .value = .{ .id = .{ .implicit = 1 }, .uri = "" },
            .expected = error.InvalidUri,
        },
        .{
            .value = .{ .id = .{ .explicit = "" }, .uri = "uri" },
            .expected = error.InvalidExplicitId,
        },
        .{
            .value = .{ .id = .{ .explicit = "id" }, .uri = "" },
            .expected = error.InvalidUri,
        },
    };

    for (cases) |case| {
        var encoded: [32]u8 = undefined;
        var writer: std.Io.Writer = .fixed(&encoded);
        try std.testing.expectError(
            case.expected,
            encode(case.value, &writer),
        );
        try std.testing.expectEqual(@as(usize, 0), writer.end);
    }
}

test "decode rejects empty strings" {
    const cases = [_]struct {
        fixture: []const u8,
        expected: anyerror,
    }{
        .{
            .fixture = "\x01\x04\x03\x02\x01\x00\x00\x00\x00",
            .expected = error.InvalidUri,
        },
        .{
            .fixture = "\x02\x00\x00\x00\x00",
            .expected = error.InvalidExplicitId,
        },
        .{
            .fixture = "\x02\x02\x00\x00\x00id\x00\x00\x00\x00",
            .expected = error.InvalidUri,
        },
    };

    for (cases) |case| {
        var reader: std.Io.Reader = .fixed(case.fixture);
        try std.testing.expectError(
            case.expected,
            decode(&reader, std.testing.allocator),
        );
    }
}

test "decodePage ignores empty strings" {
    const fixtures = [_][]const u8{
        "\x01\x04\x03\x02\x01\x00\x00\x00\x00",
        "\x02\x00\x00\x00\x00\x03\x00\x00\x00uri",
        "\x02\x02\x00\x00\x00id\x00\x00\x00\x00",
    };

    for (fixtures) |fixture| {
        var page = try terminal_page.Page.init(.{
            .cols = 1,
            .rows = 1,
            .hyperlink_bytes = 512,
            .string_bytes = 16,
        });
        defer page.deinit();

        var reader: std.Io.Reader = .fixed(fixture);
        try std.testing.expectEqual(
            @as(terminal_hyperlink.Id, 0),
            try decodePage(&page, &reader),
        );
        try std.testing.expectEqual(@as(usize, 0), page.hyperlink_set.count());
        try std.testing.expectError(error.EndOfStream, reader.takeByte());
    }
}

test "decode rejects invalid kinds" {
    for ([_]u8{ 0, 3, std.math.maxInt(u8) }) |kind| {
        var fixture: [1]u8 = .{kind};
        var reader: std.Io.Reader = .fixed(&fixture);
        try std.testing.expectError(
            error.InvalidKind,
            decode(&reader, std.testing.allocator),
        );
    }
}

test "decode allocation failure" {
    {
        var reader: std.Io.Reader = .fixed(&test_implicit_fixture);
        var failing = std.testing.FailingAllocator.init(
            std.testing.allocator,
            .{ .fail_index = 0 },
        );
        try std.testing.expectError(
            error.OutOfMemory,
            decode(&reader, failing.allocator()),
        );
    }

    for (0..2) |fail_index| {
        var failing = std.testing.FailingAllocator.init(
            std.testing.allocator,
            .{ .fail_index = fail_index },
        );
        var reader: std.Io.Reader = .fixed(&test_explicit_fixture);
        try std.testing.expectError(
            error.OutOfMemory,
            decode(&reader, failing.allocator()),
        );
    }
}

test "reject every truncation" {
    const fixtures: [2][]const u8 = .{
        &test_implicit_fixture,
        &test_explicit_fixture,
    };

    for (fixtures) |fixture| {
        for (0..fixture.len) |fixture_len| {
            var reader: std.Io.Reader = .fixed(fixture[0..fixture_len]);
            try std.testing.expectError(
                error.EndOfStream,
                decode(&reader, std.testing.allocator),
            );
        }
    }
}
