//! Test-only support for reviewable snapshot golden fixtures.
//!
//! Snapshot compatibility references are stored as annotated hexadecimal text
//! under `testdata/`. Each byte is written as exactly two hexadecimal digits.
//! Whitespace is insignificant, and `#` begins a comment through the end of the
//! line. This permits field, record, and absolute-offset annotations without
//! changing the bytes embedded by a test.
//!
//! `parse` validates and decodes an `@embedFile` at comptime. Tests should
//! encode their native value, compare the generated bytes with that embedded
//! reference using `expectEqual`, and then decode the reference itself. This
//! keeps encoding regression coverage separate from backwards-decoding
//! coverage.
//!
//! `expectEqual` formats a fresh candidate on every run. Matching candidates
//! remain in the test's temporary directory. A mismatch copies the candidate
//! to the repository root, reports its path and first differing byte, and
//! fails without modifying the checked-in reference. After reviewing an
//! intentional wire change, a maintainer manually replaces the reference.
//! Fixture filenames include the snapshot version so a future format adds new
//! references instead of overwriting compatibility data for an older version.
//!
//! A typical fixed-value test looks like this:
//!
//! ```zig
//! const fixture = @import("fixture.zig");
//!
//! const reference = fixture.parse(
//!     @embedFile("testdata/example-v1.hex"),
//! );
//!
//! test "example golden encoding and decoding" {
//!     var encoded: [Example.len]u8 = undefined;
//!     var writer: std.Io.Writer = .fixed(&encoded);
//!     try Example.encode(value, &writer);
//!
//!     try fixture.expectEqual(
//!         .bytes,
//!         "src/terminal/snapshot/testdata/example-v1.hex",
//!         "snapshot_fixture-example-v1.hex",
//!         &reference,
//!         writer.buffered(),
//!     );
//!
//!     var reader: std.Io.Reader = .fixed(&reference);
//!     try std.testing.expectEqualDeep(
//!         value,
//!         try Example.decode(&reader),
//!     );
//! }
//! ```
//!
//! ## Kaitai
//!
//! Every fixture has three machine-readable comment fields:
//!
//! ```text
//! # Kaitai type: page_payload
//! # Kaitai params:
//! # Kaitai offset: 0
//! ```
//!
//! These select a type from `snapshot.ksy`, its whitespace-separated
//! parameters, and the byte offset where parsing begins. The Kaitai verifier
//! discovers fixtures from the filesystem and uses this metadata instead of
//! maintaining a parallel filename table. Generated candidates copy the
//! metadata from their checked-in reference.
//!

const std = @import("std");
const envelope = @import("envelope.zig");
const record = @import("record.zig");

const log = std.log.scoped(.snapshot_fixture);

pub const Kind = enum {
    bytes,
    page,
    snapshot,
};

/// Decode an annotated hexadecimal fixture at comptime.
///
/// Whitespace is ignored. `#` begins a comment that continues through the end
/// of the line. Every other token must be exactly two hexadecimal digits.
pub fn parse(comptime source: []const u8) [decodedLen(source)]u8 {
    comptime {
        // Large complete-snapshot fixtures exceed Zig's default comptime
        // branch budget while scanning their comments and byte tokens.
        @setEvalBranchQuota(100_000_000);

        // The first pass in decodedLen gives this result its exact array type.
        // Keeping it an array lets callers use the fixture in comptime slices.
        var result: [decodedLen(source)]u8 = undefined;
        var source_index: usize = 0;
        var result_index: usize = 0;

        while (source_index < source.len) {
            // Comments and layout are purely for reviewers and contribute no
            // bytes to the embedded reference.
            skipIgnored(source, &source_index);
            if (source_index == source.len) break;

            // Every data token is one complete byte. Parse it directly rather
            // than accepting variable-width integers or other hex syntax.
            if (source_index + 1 >= source.len) {
                @compileError("snapshot fixture ends with one hex digit");
            }
            result[result_index] =
                hexNibble(source[source_index]) << 4 |
                hexNibble(source[source_index + 1]);
            result_index += 1;
            source_index += 2;

            // Requiring a separator catches accidental third digits and makes
            // the accepted grammar match the formatter below.
            if (source_index < source.len and
                !std.ascii.isWhitespace(source[source_index]) and
                source[source_index] != '#')
            {
                @compileError(
                    "snapshot fixture hex bytes must be separated by whitespace",
                );
            }
        }

        return result;
    }
}

/// Compare generated bytes with a checked-in reference.
///
/// A formatted candidate is always created in a temporary directory. On a
/// mismatch it is copied into the repository root before the test fails.
pub fn expectEqual(
    kind: Kind,
    reference_path: []const u8,
    candidate_name: []const u8,
    expected: []const u8,
    actual: []const u8,
) !void {
    const testing = std.testing;
    const alloc = testing.allocator;

    var tmp = testing.tmpDir(.{});
    defer tmp.cleanup();

    // Kaitai parser selection lives in the reference itself. Read the textual
    // source so generated candidates preserve that metadata even though the
    // byte comparison below uses the comptime-decoded reference.
    const reference_source = try std.Io.Dir.cwd().readFileAlloc(
        testing.io,
        reference_path,
        alloc,
        .unlimited,
    );
    defer alloc.free(reference_source);

    // Always materialize a candidate, even when it will match. This keeps the
    // generation path exercised on every test run while avoiding repository
    // artifacts for the normal successful case.
    {
        var candidate_file = try tmp.dir.createFile(
            testing.io,
            candidate_name,
            .{},
        );
        defer candidate_file.close(testing.io);
        var write_buffer: [4096]u8 = undefined;
        var candidate_writer = candidate_file.writer(
            testing.io,
            &write_buffer,
        );
        try format(
            kind,
            reference_source,
            actual,
            &candidate_writer.interface,
        );
        try candidate_writer.interface.flush();
    }

    // Matching candidates remain in the temporary directory and disappear
    // with its cleanup.
    if (std.mem.eql(u8, expected, actual)) return;

    // The test temp directory is not necessarily below the working directory.
    // Resolve both paths before copying the candidate to the stable,
    // user-visible repository-root name.
    const tmp_path = try tmp.dir.realPathFileAlloc(
        testing.io,
        candidate_name,
        alloc,
    );
    defer alloc.free(tmp_path);
    const cwd = try std.Io.Dir.cwd().realPathFileAlloc(
        testing.io,
        ".",
        alloc,
    );
    defer alloc.free(cwd);
    const candidate_path = try std.fs.path.join(
        alloc,
        &.{ cwd, candidate_name },
    );
    defer alloc.free(candidate_path);
    try std.Io.Dir.copyFileAbsolute(
        tmp_path,
        candidate_path,
        testing.io,
        .{},
    );

    // Report the first useful comparison point. When one byte sequence is a
    // prefix of the other, this is the shared length where they diverge.
    const difference = difference: {
        const shared_len = @min(expected.len, actual.len);
        for (
            expected[0..shared_len],
            actual[0..shared_len],
            0..,
        ) |expected_byte, actual_byte, index| {
            if (expected_byte != actual_byte) break :difference index;
        }
        break :difference shared_len;
    };
    log.err(
        "snapshot fixture differs reference={s} candidate={s} " ++
            "offset=0x{x} expected_len={} actual_len={}",
        .{
            reference_path,
            candidate_path,
            difference,
            expected.len,
            actual.len,
        },
    );
    return error.TestExpectedEqual;
}

fn decodedLen(comptime source: []const u8) usize {
    comptime {
        // This is a complete validation pass, not just a count. Invalid syntax
        // therefore fails before parse allocates and fills its result array.
        @setEvalBranchQuota(100_000_000);

        var index: usize = 0;
        var result: usize = 0;
        while (index < source.len) {
            skipIgnored(source, &index);
            if (index == source.len) break;

            if (index + 1 >= source.len) {
                @compileError("snapshot fixture ends with one hex digit");
            }
            _ = hexNibble(source[index]);
            _ = hexNibble(source[index + 1]);
            result += 1;
            index += 2;

            if (index < source.len and
                !std.ascii.isWhitespace(source[index]) and
                source[index] != '#')
            {
                @compileError(
                    "snapshot fixture hex bytes must be separated by whitespace",
                );
            }
        }
        return result;
    }
}

fn skipIgnored(comptime source: []const u8, index: *usize) void {
    while (index.* < source.len) {
        // Layout whitespace is unrestricted so fixtures can group fields and
        // keep deterministic sixteen-byte output lines.
        if (std.ascii.isWhitespace(source[index.*])) {
            index.* += 1;
            continue;
        }

        // A comment consumes the rest of its line. The newline itself is
        // consumed by the whitespace case on the next iteration.
        if (source[index.*] != '#') return;
        while (index.* < source.len and source[index.*] != '\n') {
            index.* += 1;
        }
    }
}

fn hexNibble(comptime value: u8) u8 {
    return switch (value) {
        '0'...'9' => value - '0',
        'a'...'f' => value - 'a' + 10,
        'A'...'F' => value - 'A' + 10,
        else => @compileError("snapshot fixture contains a non-hex digit"),
    };
}

fn format(
    kind: Kind,
    reference_source: []const u8,
    bytes: []const u8,
    writer: *std.Io.Writer,
) (std.Io.Writer.Error || error{InvalidKaitaiMetadata})!void {
    // Every generated candidate starts with enough maintenance context to be
    // understandable when copied out of a failing test's temp directory.
    try writer.writeAll("# Ghostty snapshot fixture\n");
    try writeKaitaiMetadata(reference_source, writer);
    try writer.print(
        "# Wire version: {}\n" ++
            "# Generated by its snapshot test; review before replacing.\n" ++
            "# On mismatch, the candidate is copied to the repository root.\n",
        .{envelope.version},
    );

    // Small values only need a hex dump. Larger compound formats get
    // structure-aware section labels without changing their decoded bytes.
    switch (kind) {
        .bytes => try writeSection(
            "encoded bytes",
            bytes,
            0,
            bytes.len,
            writer,
        ),
        .page => try formatPage(bytes, writer),
        .snapshot => try formatSnapshot(bytes, writer),
    }
}

fn writeKaitaiMetadata(
    reference_source: []const u8,
    writer: *std.Io.Writer,
) (std.Io.Writer.Error || error{InvalidKaitaiMetadata})!void {
    const prefixes = [_][]const u8{
        "# Kaitai type:",
        "# Kaitai params:",
        "# Kaitai offset:",
    };
    var found = [_]bool{false} ** prefixes.len;

    var lines = std.mem.splitScalar(u8, reference_source, '\n');
    while (lines.next()) |untrimmed| {
        const line = std.mem.trim(u8, untrimmed, " \r");
        for (prefixes, &found) |prefix, *seen| {
            if (!std.mem.startsWith(u8, line, prefix)) continue;
            if (seen.*) return error.InvalidKaitaiMetadata;
            seen.* = true;
            try writer.print("{s}\n", .{line});
            break;
        }
    }

    for (found) |seen| {
        if (!seen) return error.InvalidKaitaiMetadata;
    }
}

fn formatPage(
    bytes: []const u8,
    writer: *std.Io.Writer,
) std.Io.Writer.Error!void {
    // PAGE version 1 has a twenty-byte fixed header. The remainder is
    // self-describing through the counts and dimensions in that header.
    const header_end = @min(bytes.len, 20);
    try writeSection("PAGE header", bytes, 0, header_end, writer);
    if (header_end < bytes.len) {
        try writeSection(
            "style and hyperlink tables, rows, and cells",
            bytes,
            header_end,
            bytes.len,
            writer,
        );
    }
}

fn formatSnapshot(
    bytes: []const u8,
    writer: *std.Io.Writer,
) std.Io.Writer.Error!void {
    // A complete snapshot begins with its unframed envelope. Clamp this first
    // section so a partially generated candidate can still be inspected.
    const envelope_end = @min(bytes.len, envelope.encoded_len);
    try writeSection("snapshot envelope", bytes, 0, envelope_end, writer);

    // Walk record framing only far enough to annotate boundaries. The emitted
    // hex remains authoritative; this formatter never repairs candidate data.
    var offset: usize = envelope_end;
    while (offset < bytes.len) {
        // A partial header cannot provide a safe payload boundary, so preserve
        // the remaining bytes as one explicitly incomplete section.
        if (bytes.len - offset < record.Header.len) {
            try writeSection(
                "trailing partial record",
                bytes,
                offset,
                bytes.len,
                writer,
            );
            return;
        }

        // Tag and payload length are the first six bytes of every record
        // header. CRC validation belongs to the codec test, not this formatter.
        const tag_raw = std.mem.readInt(
            u16,
            bytes[offset..][0..2],
            .little,
        );
        const payload_len: u32 = std.mem.readInt(
            u32,
            bytes[offset + 2 ..][0..4],
            .little,
        );

        // Clamp the section to the available candidate bytes. This makes a
        // truncated payload reviewable instead of indexing beyond the slice.
        const record_end: usize = @min(
            bytes.len,
            offset + record.Header.len + @as(usize, payload_len),
        );

        // Known tags make the annotated diff readable. Preserve unknown tags
        // numerically so formatter output is still useful for encoder bugs.
        if (std.enums.fromInt(record.Tag, tag_raw)) |tag| {
            try writer.print(
                "\n# offset 0x{x:0>8}: {s} record, payload {} bytes\n",
                .{ offset, @tagName(tag), payload_len },
            );
        } else {
            try writer.print(
                "\n# offset 0x{x:0>8}: unknown record {}, payload {} bytes\n",
                .{ offset, tag_raw, payload_len },
            );
        }
        try writeHex(bytes, offset, record_end, writer);
        offset = record_end;
    }
}

fn writeSection(
    name: []const u8,
    bytes: []const u8,
    start: usize,
    end: usize,
    writer: *std.Io.Writer,
) std.Io.Writer.Error!void {
    // Sections begin on a fresh line and carry their absolute wire offset.
    try writer.print(
        "\n# offset 0x{x:0>8}: {s}\n",
        .{ start, name },
    );
    try writeHex(bytes, start, end, writer);
}

fn writeHex(
    bytes: []const u8,
    start: usize,
    end: usize,
    writer: *std.Io.Writer,
) std.Io.Writer.Error!void {
    const digits = "0123456789abcdef";
    var offset = start;
    while (offset < end) {
        // Lowercase, sixteen-byte lines provide stable textual diffs. The
        // trailing offset is a comment and is ignored by parse.
        const line_end = @min(end, offset + 16);
        for (bytes[offset..line_end], 0..) |byte, index| {
            if (index != 0) try writer.writeByte(' ');
            try writer.writeByte(digits[byte >> 4]);
            try writer.writeByte(digits[byte & 0x0f]);
        }
        try writer.print(" # 0x{x:0>8}\n", .{offset});
        offset = line_end;
    }
}
