Skip to content
zit Artifact [5b24a56897]
Login

Artifact [5b24a56897]

Artifact 5b24a5689717507bb8a0b3384eb78bcdc267145b67a442d48c3736421fa7418f:


//! Artifact Format — draft-fossil-artifact-format-00.
//!
//! An artifact is an immutable byte sequence named by the lowercase-hex hash
//! of its content (Section 3). Structural artifacts are line-oriented "cards".
//! This module owns the canonical vocabulary for the whole suite: naming, the
//! card grammar, the structural types, and the two checksums (Z, R).
//!
//! Scope note: zit targets the version-control substrate — the check-in
//! (manifest), cluster, and tag/control artifact types. The collaboration
//! types (wiki, ticket, forum, attachment, technote) are carried opaquely.

const std = @import("std");

/// Card-type letters. Only the version-control cards are interpreted; others
/// are recognised syntactically and otherwise carried opaquely.
pub const CardType = enum(u8) {
    B = 'B', // baseline (delta manifest)
    C = 'C', // comment
    D = 'D', // date (mtime)
    F = 'F', // file
    M = 'M', // cluster member
    N = 'N', // mimetype
    P = 'P', // parent(s)
    Q = 'Q', // cherrypick
    R = 'R', // aggregate content checksum (MD5)
    T = 'T', // tag
    U = 'U', // user
    Z = 'Z', // artifact self-checksum (MD5), always last
};

/// Structural artifact types (§5.1 Table 3). Every structural artifact
/// classifies as exactly one of these eight; the backing integer is the
/// stable numeric code from Table 3. Code 0 ("not yet determined") is not
/// a variant here -- it is a transient parser state modelled as the
/// absence of an `ArtifactType` value (`?ArtifactType`), never as a value
/// of this enum (§5.1-m1).
pub const ArtifactType = enum(u8) {
    manifest = 1, // a check-in
    cluster = 2, // a set of artifact hashes (sync housekeeping)
    control = 3, // a tag/branch mutation
    wiki = 4,
    ticket = 5,
    attachment = 6,
    event = 7, // a technote
    forum = 8, // a forum post

    /// §5.1 Table 3 external name -- the name emitted on machine-readable
    /// (JSON/API) surfaces. Distinct from the informative diagnostic
    /// vocabulary the RFC explicitly declines to normatize (manifest and
    /// "forum post" with a space); that vocabulary is not implemented.
    pub fn externalName(self: ArtifactType) []const u8 {
        return switch (self) {
            .manifest => "checkin",
            .cluster => "cluster",
            .control => "tag",
            .wiki => "wiki",
            .ticket => "ticket",
            .attachment => "attachment",
            .event => "technote",
            .forum => "forumpost",
        };
    }
};

/// Hash algorithm that names an artifact (Section 3, Section 7 hash policy).
/// The canonical (writer) form is always lowercase hex.
pub const HashAlgo = enum { sha1, sha3_256 };

/// Compute the artifact name: the lowercase-hex hash of `content`.
/// Writes into `out` and returns the populated slice (40 hex for SHA-1,
/// 64 hex for SHA3-256).
pub fn artifactName(algo: HashAlgo, content: []const u8, out: *[64]u8) []u8 {
    switch (algo) {
        .sha1 => {
            var digest: [20]u8 = undefined;
            std.crypto.hash.Sha1.hash(content, &digest, .{});
            const hex = std.fmt.bytesToHex(digest, .lower);
            @memcpy(out[0..hex.len], &hex);
            return out[0..hex.len];
        },
        .sha3_256 => {
            var digest: [32]u8 = undefined;
            std.crypto.hash.sha3.Sha3_256.hash(content, &digest, .{});
            const hex = std.fmt.bytesToHex(digest, .lower);
            @memcpy(out[0..hex.len], &hex);
            return out[0..hex.len];
        },
    }
}

/// Length-to-algorithm dispatch (§3.2-m1). The two name lengths are disjoint,
/// so length alone fully determines the algorithm: 40 hex digits is SHA-1,
/// 64 is SHA3-256, any other length names nothing.
pub fn nameAlgo(len: usize) ?HashAlgo {
    return switch (len) {
        40 => .sha1,
        64 => .sha3_256,
        else => null,
    };
}

/// ABNF `lhexdig` (§3.5): a lowercase hex digit, 0-9 or a-f. Governs the
/// canonical (writer/parse) form only.
fn isHexLower(c: u8) bool {
    return (c >= '0' and c <= '9') or (c >= 'a' and c <= 'f');
}

/// ABNF `hexdig-ci` (§3.5): 0-9, A-F, or a-f. Used only on the prefix-lookup
/// path (§3.6), which canonicalizes case; the parse path never uses this.
fn isHexCi(c: u8) bool {
    return (c >= '0' and c <= '9') or (c >= 'a' and c <= 'f') or (c >= 'A' and c <= 'F');
}

/// Canonical-form validation (§3.2, §3.3, §3.5 `artifact-name`). A token is
/// valid iff it is exactly 40 or 64 lowercase hex digits. Length is checked
/// before character class (§3.2-r1: length gate precedes char-class check).
/// Uppercase digits are rejected outright, never case-normalized (§3.3-m2).
pub fn validateName(token: []const u8) bool {
    if (nameAlgo(token.len) == null) return false;
    std.debug.assert(token.len == 40 or token.len == 64);
    var i: usize = 0;
    while (i < token.len) : (i += 1) {
        if (!isHexLower(token[i])) return false;
    }
    return true;
}

/// Verify `name` against `content` (§3.4). Dispatch on length picks the
/// digest to recompute; the compare is exact byte-for-byte lowercase hex.
/// Wrong length, non-hex characters, and a digest mismatch collapse into the
/// same `false` (§3.4-m1) — nothing here distinguishes them for the caller.
/// An uppercase name never verifies either, since the compare never
/// case-folds (§3.4-r1).
pub fn verify(name: []const u8, content: []const u8) bool {
    const algo = nameAlgo(name.len) orelse return false;
    var buf: [64]u8 = undefined;
    const computed = artifactName(algo, content, &buf);
    std.debug.assert(computed.len == name.len);
    return std.mem.eql(u8, name, computed);
}

// ---------------------------------------------------------------------------
// Section 7 -- Hash Policy
// ---------------------------------------------------------------------------
//
// Pure primitives only. The repository inventory scan (which names exist),
// the persisted policy setting, and the store-lookup used for
// deduplication are all effectful and belong to the store/repostate layer;
// this module models §7's decisions as total functions over injected
// booleans and lengths.

/// §7.1 Table 7 hash-policy values. The backing integer is the ordinal
/// column: "0 through 4, in the order listed" (§7.1-w1).
pub const HashPolicy = enum(u3) { sha1 = 0, auto = 1, sha3 = 2, sha3_only = 3, shun_sha1 = 4 };

/// §7.1 Table 7 "Value" column -- the one comptime source of truth for the
/// wire strings, shared by `policyName` (encode) and `policyFromString`
/// (parse) so the five spellings are never transcribed twice.
const policy_value_string = std.EnumArray(HashPolicy, []const u8).init(.{
    .sha1 = "sha1",
    .auto = "auto",
    .sha3 = "sha3",
    .sha3_only = "sha3-only",
    .shun_sha1 = "shun-sha1",
});

/// Encode a policy as its Table 7 value string.
pub fn policyName(p: HashPolicy) []const u8 {
    return policy_value_string.get(p);
}

/// Parse a Table 7 value string. Matching is case-sensitive and exact
/// (§7.1-p1); an unrecognized value is `null`, which the caller treats as
/// fatal (§7.1-r1). Bounded by the fixed 5-entry policy table.
pub fn policyFromString(s: []const u8) ?HashPolicy {
    for (std.enums.values(HashPolicy)) |tag| {
        if (std.mem.eql(u8, s, policy_value_string.get(tag))) return tag;
    }
    return null;
}

/// §7.1 Table 7 "Primary hash" column: the algorithm that names newly
/// stored content under `p`.
pub fn primaryHash(p: HashPolicy) HashAlgo {
    return switch (p) {
        .sha1, .auto => .sha1, // §7.1-m1
        .sha3, .sha3_only, .shun_sha1 => .sha3_256, // §7.1-m2
    };
}

/// §7.1 Table 7 "Alternative hash" column. `null` means the policy
/// disallows the alternative hash outright: a request for it MUST fail
/// with an empty result, never a hash value (§7.1-r2).
pub fn alternativeHash(p: HashPolicy) ?HashAlgo {
    return switch (p) {
        .sha1, .auto => .sha3_256, // §7.1-m3
        .sha3 => .sha1, // §7.1-m3
        .sha3_only, .shun_sha1 => null, // §7.1-r2
    };
}

/// §7.2 default-policy derivation for a repository with no assigned hash-
/// policy setting. `has_sha1_named`/`has_sha3_named` are the caller's
/// inventory-scan result (a name over 40 hex digits is SHA3-named, exactly
/// 40 is SHA1-named -- see `nameAlgo`).
pub fn defaultPolicy(has_sha1_named: bool, has_sha3_named: bool) HashPolicy {
    // §7.2-m1: any SHA3 name, or no SHA-1 name at all (including empty).
    const r: HashPolicy = if (has_sha3_named or !has_sha1_named) .sha3 else .auto; // §7.2-m2 otherwise
    std.debug.assert(r == .sha3 or r == .auto); // §7.2-m3: never shun-sha1
    return r;
}

/// §7.3 set-time coercion of an explicit policy assignment. Only `auto` is
/// ever rewritten, and only when a SHA3 name already exists (§7.3-m2,
/// "set-time coercion"); every other requested value -- including an
/// explicit demotion back to `sha1` -- passes through unchanged (§7.3-m1,
/// "no demotion guard").
pub fn coerceAssignment(requested: HashPolicy, has_sha3_named: bool) HashPolicy {
    const r: HashPolicy = if (requested == .auto and has_sha3_named) .sha3 else requested;
    std.debug.assert(requested == .auto or r == requested);
    return r;
}

/// §7.4 ratchet decision for one content-storage write under `effective`.
/// `selected_name` is the name already chosen for this write -- computed
/// or caller-supplied (§7.4-m2, selection precedes the ratchet check).
/// Promotion triggers only out of `auto`, and only once the selected name
/// exceeds the 40-character SHA-1 length (§7.4-m1).
pub fn ratchet(effective: HashPolicy, selected_name: []const u8) HashPolicy {
    std.debug.assert(nameAlgo(selected_name.len) != null); // a legal 40/64 name
    const r: HashPolicy = if (effective == .auto and selected_name.len > 40) .sha3 else effective;
    std.debug.assert(r == effective or (effective == .auto and r == .sha3));
    return r;
}

/// §7.5 naming algorithm for content stored without an externally supplied
/// name. `alternative_present` is the caller's store-lookup result for the
/// alternative-algorithm hash. Tries the alternative first where the
/// policy permits one, falling back to the primary hash on a miss --
/// which is also where sha3-only/shun-sha1 land, their disallowed
/// alternative request absorbed silently as if it had missed (§7.5-p1,
/// §7.5-r1).
pub fn dedupNameAlgo(p: HashPolicy, alternative_present: bool) HashAlgo {
    const r: HashAlgo = blk: {
        if (alternativeHash(p)) |alt| {
            if (alternative_present) break :blk alt;
        }
        break :blk primaryHash(p);
    };
    if (alternativeHash(p)) |alt| {
        std.debug.assert(r == primaryHash(p) or r == alt);
    } else {
        std.debug.assert(r == primaryHash(p));
    }
    return r;
}

/// §4.7.1 hash-token position kinds. Every artifact-hash-bearing card
/// position (A target/source, B, E id, F hash, G, I, M, P, Q, T target)
/// maps to `.artifact_hash`; the two named exceptions get their own kind.
pub const HashTokenKind = enum { artifact_hash, ticket_id, checksum };

/// §4.7.1 Hash-Token Validation Map. Validates a RAW card token against the
/// length/char-class policy for `kind`. Never escape-decodes (§4.7.1-m2);
/// the sole carve-out is the A card target, which the future A-card parser
/// decodes first and then feeds here as `.artifact_hash` (§4.3.1).
pub fn validateHashToken(kind: HashTokenKind, token: []const u8) bool {
    switch (kind) {
        // 40 or 64 case-insensitive hex (§4.7.1-m1: "MUST be exactly 40 or
        // 64 case-insensitive hexadecimal characters"). This is a harder
        // requirement than the bare artifact-name arm (§3.3), where
        // rejecting uppercase is only a MAY — so this does not delegate to
        // validateName, which takes that MAY. Length gate precedes
        // char-class check (§3.2-r1).
        .artifact_hash => {
            if (nameAlgo(token.len) == null) return false;
            std.debug.assert(token.len == 40 or token.len == 64);
            for (token) |c| {
                if (!isHexCi(c)) return false;
            }
            return true;
        },
        // Pinned to SHA-1 length; 64 is always invalid regardless of hash
        // policy (§4.7.1-r1). Canonical form is lowercase (§4.7.1: "MUST
        // emit lowercase"); a parser MAY accept upper- or lowercase, but
        // acceptance is optional, so this arm takes the stricter,
        // lowercase-only reading (the same MAY the artifact-name arm takes).
        .ticket_id => {
            if (token.len != 40) return false;
            std.debug.assert(token.len == 40);
            for (token) |c| {
                if (!isHexLower(c)) return false;
            }
            return true;
        },
        // Exactly 32 hex, parse-accept case-insensitive (§4.7.1-r2, §6.1
        // md5-token = 32hexdig-ci).
        .checksum => {
            if (token.len != 32) return false;
            std.debug.assert(token.len == 32);
            for (token) |c| {
                if (!isHexCi(c)) return false;
            }
            return true;
        },
    }
}

/// Prefix-resolution outcome (§3.6, informative — not part of the wire
/// format). `one` carries the matching index into the caller's `names`.
pub const Resolution = union(enum) { none, one: usize, ambiguous };

/// Resolve a hex name or prefix against a candidate universe (§3.6).
/// `prefix` must be 4-64 hex characters of any case; it is lowercased before
/// matching (the lookup path canonicalizes case, unlike the parse path —
/// see `validateName`). One query walk covers both name lengths, so a
/// cross-algorithm collision (one 40-char match, one 64-char match) reports
/// `.ambiguous` exactly like a same-algorithm collision (§3.6-r1).
pub fn resolvePrefix(prefix: []const u8, names: []const []const u8) error{InvalidPrefix}!Resolution {
    if (prefix.len < 4 or prefix.len > 64) return error.InvalidPrefix;
    std.debug.assert(prefix.len >= 4 and prefix.len <= 64);

    var lowered: [64]u8 = undefined;
    var i: usize = 0;
    while (i < prefix.len) : (i += 1) {
        const c = prefix[i];
        if (!isHexCi(c)) return error.InvalidPrefix;
        lowered[i] = if (c >= 'A' and c <= 'F') c + ('a' - 'A') else c;
    }
    const query = lowered[0..prefix.len];

    var first: ?usize = null;
    var matches: usize = 0;
    var n: usize = 0;
    while (n < names.len) : (n += 1) {
        std.debug.assert(matches <= names.len);
        if (std.mem.startsWith(u8, names[n], query)) {
            matches += 1;
            if (first == null) first = n;
            if (matches > 1) return .ambiguous;
        }
    }
    if (first) |idx| {
        std.debug.assert(idx < names.len);
        return .{ .one = idx };
    }
    return .none;
}

/// The Z-card value: lowercase-hex MD5 of all stripped-content bytes that
/// precede the `Z ` line (Section 6, Z card).
pub fn selfChecksum(stripped_prefix: []const u8, out: *[32]u8) []u8 {
    var digest: [16]u8 = undefined;
    std.crypto.hash.Md5.hash(stripped_prefix, &digest, .{});
    const hex = std.fmt.bytesToHex(digest, .lower);
    @memcpy(out, &hex);
    return out;
}

// ---------------------------------------------------------------------------
// §6.3 R Card: Aggregate Content Checksum
// ---------------------------------------------------------------------------

/// §6.3.2 one tracked file's contribution to the R aggregate: pathname and
/// raw content. The file's size (§6.3.2 step 2) is `content.len`.
pub const TrackedFile = struct { name: []const u8, content: []const u8 };

/// Decimal digits needed for the largest representable `usize` size header
/// (§6.3.2 step 2 bound): a 64-bit count needs at most 20 digits.
const max_size_digits = 20;
comptime {
    std.debug.assert(std.math.maxInt(u64) < std.math.pow(u128, 10, max_size_digits));
}

/// Feed one file's §6.3.2 step-2 size header into the running MD5 context:
/// a single space, then `size` as minimal decimal ASCII (no leading zeros,
/// no sign, no separators -- §6.3.2-m1), then LF. Manual digit extraction
/// into a bounded stack buffer avoids `bufPrint`'s `catch unreachable`
/// (forbidden outside test code).
fn updateSizeHeader(ctx: *std.crypto.hash.Md5, size: usize) void {
    var digits: [max_size_digits]u8 = undefined;
    var n_digits: usize = 0;
    var v = size;
    while (true) {
        std.debug.assert(n_digits < digits.len);
        digits[n_digits] = @as(u8, @intCast(v % 10)) + '0';
        n_digits += 1;
        v /= 10;
        if (v == 0) break;
    }
    std.debug.assert(n_digits >= 1 and n_digits <= digits.len); // "0" for size 0

    var header: [1 + max_size_digits + 1]u8 = undefined;
    var w: usize = 0;
    header[w] = ' ';
    w += 1;
    var i: usize = n_digits;
    while (i > 0) {
        i -= 1;
        std.debug.assert(w < header.len);
        header[w] = digits[i]; // most-significant digit first
        w += 1;
    }
    std.debug.assert(w < header.len);
    header[w] = '\n';
    w += 1;
    std.debug.assert(w <= header.len);

    ctx.update(header[0..w]);
}

/// §6.3 R-card aggregate content checksum. Lowercase-hex MD5 over, for each
/// tracked file in canonical filename order, name || SP || minimal-decimal
/// (size) || LF || content (§6.3.2). `files` MUST already be in §4.5.3
/// canonical ascending order (the F-card ordering, §4.5.2) -- asserted, not
/// sorted here (ordering is owned by manifest assembly; re-sorting here
/// would duplicate that mechanism). Allocation-free; writes 32 lowercase hex
/// bytes to `out` and returns the slice. Delta manifests (§6.3.3, out of
/// batch scope) resolve their merged file set in the caller before calling
/// this.
pub fn aggregateChecksum(files: []const TrackedFile, out: *[32]u8) []u8 {
    if (files.len > 1) {
        for (1..files.len) |i| {
            std.debug.assert(compareBytes(files[i - 1].name, files[i].name) == .lt);
        }
    }

    var ctx = std.crypto.hash.Md5.init(.{});
    for (files) |f| {
        ctx.update(f.name);
        updateSizeHeader(&ctx, f.content.len);
        ctx.update(f.content);
    }
    var digest: [16]u8 = undefined;
    ctx.final(&digest);
    const hex = std.fmt.bytesToHex(digest, .lower);
    @memcpy(out, &hex);

    for (out) |c| std.debug.assert(isHexLower(c));
    return out;
}

// ---------------------------------------------------------------------------
// §4.1.1 Signature-Wrapper Removal
// ---------------------------------------------------------------------------

/// PGP/SSH clearsign open-armor literals (§4.1.1-r1): stripping fires only
/// when one of these matches the first 34 bytes of the input, at offset 0.
const pgp_open_armor = "-----BEGIN PGP SIGNED MESSAGE-----";
const ssh_open_armor = "-----BEGIN SSH SIGNED MESSAGE-----";
comptime {
    std.debug.assert(pgp_open_armor.len == 34);
    std.debug.assert(ssh_open_armor.len == 34);
}

/// Trailing-armor compare literals (§4.1.1-p3). The real armor line is 30
/// bytes (LF + a 29-byte dashed literal), but only 29 are ever compared:
/// LF plus the first 28 armor bytes, one dash short of the closing run —
/// the 30th byte, whatever it is, is never read.
const pgp_close_armor = "\n-----BEGIN PGP SIGNATURE----";
const ssh_close_armor = "\n-----BEGIN SSH SIGNATURE----";
comptime {
    std.debug.assert(pgp_close_armor.len == 29);
    std.debug.assert(ssh_close_armor.len == 29);
}

/// Forward scan for the first blank-line separator (§4.1.1-p2): LF LF or
/// LF CR LF — the sole CR tolerance anywhere in the parse pipeline. Returns
/// the offset just past the blank line (start of body), or null if none is
/// found before end of input.
fn firstBlankLineEnd(bytes: []const u8) ?usize {
    var i: usize = 0;
    while (i < bytes.len) : (i += 1) {
        if (bytes[i] != '\n') continue;
        if (i + 1 < bytes.len and bytes[i + 1] == '\n') return i + 2;
        if (i + 2 < bytes.len and bytes[i + 1] == '\r' and bytes[i + 2] == '\n') return i + 3;
    }
    return null;
}

/// Backward scan for the trailing signature armor (§4.1.1-p3). "First
/// occurrence" read right-to-left makes the rightmost match win. Returns
/// the truncation length (keeping the matched LF as the final byte), or
/// `body.len` when no armor is found.
fn trailingArmorEnd(body: []const u8) usize {
    if (body.len < pgp_close_armor.len) return body.len;
    var p: usize = body.len - pgp_close_armor.len;
    while (true) : (p -= 1) {
        std.debug.assert(p + pgp_close_armor.len <= body.len);
        const slice = body[p .. p + pgp_close_armor.len];
        if (std.mem.eql(u8, slice, pgp_close_armor) or std.mem.eql(u8, slice, ssh_close_armor)) {
            return p + 1; // truncate here, keeping the matched LF
        }
        if (p == 0) break;
    }
    return body.len;
}

/// §4.1.1 signature-wrapper removal. Total: never fails. Returns `bytes`
/// unchanged unless the first 34 bytes are a recognized open armor AND a
/// blank line follows somewhere in the input — either condition failing
/// leaves the input wholly unstripped (§4.1.1-r1, -m1).
pub fn stripSignatureWrapper(bytes: []const u8) []const u8 {
    const is_pgp = bytes.len >= pgp_open_armor.len and
        std.mem.eql(u8, bytes[0..pgp_open_armor.len], pgp_open_armor);
    const is_ssh = bytes.len >= ssh_open_armor.len and
        std.mem.eql(u8, bytes[0..ssh_open_armor.len], ssh_open_armor);
    if (!is_pgp and !is_ssh) return bytes;

    const body_start = firstBlankLineEnd(bytes) orelse return bytes;
    std.debug.assert(body_start <= bytes.len);

    const body = bytes[body_start..];
    const body_end = trailingArmorEnd(body);
    std.debug.assert(body_end <= body.len);

    const result = body[0..body_end];
    std.debug.assert(result.len <= bytes.len);
    return result;
}

// ---------------------------------------------------------------------------
// §4.1 Artifact Framing
// ---------------------------------------------------------------------------

/// §6.2 Z-card verification outcome (Table 6).
pub const ZVerdict = enum { absent, match, mismatch };

/// §6.2 Z self-checksum, evaluated positionally on the final 35 bytes of
/// `content` (already wrapper-stripped): the marker sits at n-35, the
/// digest at n-33..n-1. Never reads `content[n-1]`, the artifact's
/// terminating byte — that byte belongs to the §4.1 raw-LF framing rule.
pub fn verifyZCard(content: []const u8) ZVerdict {
    const n = content.len;
    if (n < 35) return .absent;
    if (content[n - 35] != 'Z' or content[n - 34] != ' ') return .absent;

    const stored = content[n - 33 .. n - 1];
    std.debug.assert(stored.len == 32);
    var digest_buf: [32]u8 = undefined;
    const computed = selfChecksum(content[0 .. n - 35], &digest_buf);
    std.debug.assert(computed.len == 32);

    return if (std.mem.eql(u8, stored, computed)) .match else .mismatch;
}

/// §4.1 framing gates, evaluated in RFC order before any card is
/// interpreted. Returns the wrapper-stripped, gate-passed content, or null
/// if the input fails any gate — every rejection reason collapses to null,
/// as the RFC only ever says "reject" (the §4.1.2 fast well-formedness
/// probe reads this way too).
///
/// The final §4.1 rule — unconsumed bytes after the card loop MUST cause
/// rejection — is not enforced here: it is defined relative to the card
/// loop, one layer up. `parse` calls `frame` as its entry seam and enforces
/// it structurally: the §4.4 order/§4.5.1 occurrence gates make a Z card
/// (order-terminal, single-occurrence) refuse everything that could follow
/// it, so trailing bytes past the last legitimate card always reject.
pub fn frame(bytes: []const u8) ?[]const u8 {
    // Gate 1: raw bytes, non-empty, final byte LF — checked before strip.
    if (bytes.len == 0 or bytes[bytes.len - 1] != '\n') return null;

    // Gate 2: strip signature wrapper, if present.
    const stripped = stripSignatureWrapper(bytes);
    if (stripped.len > 0) std.debug.assert(stripped[stripped.len - 1] == '\n');

    // Gate 3: strict line-1 (length, byte0 A-Z, byte1 SP).
    if (stripped.len < 10) return null;
    if (stripped[0] < 'A' or stripped[0] > 'Z') return null;
    if (stripped[1] != ' ') return null;

    // Gate 4: Z self-checksum — a mismatch rejects; absent or matching pass.
    if (verifyZCard(stripped) == .mismatch) return null;

    return stripped;
}

// ---------------------------------------------------------------------------
// §4.2 Line and Token Grammar / §4.2.2 Closed Card Alphabet
// ---------------------------------------------------------------------------

/// §4.2.2 closed alphabet: true for exactly the 21 defined card letters.
/// A-Z minus the five in-range gaps (O S V X Y) — closed, no
/// forward-compatibility path for an unknown letter (RFC lines 623-627).
pub fn isCardLetter(b: u8) bool {
    return switch (b) {
        'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'P', 'Q', 'R', 'T', 'U', 'W', 'Z' => true,
        else => false,
    };
}

comptime {
    var count: usize = 0;
    var b: usize = 0;
    while (b < 256) : (b += 1) {
        if (isCardLetter(@intCast(b))) count += 1;
    }
    std.debug.assert(count == 21);
    std.debug.assert(!isCardLetter('O'));
    std.debug.assert(!isCardLetter('S'));
    std.debug.assert(!isCardLetter('V'));
    std.debug.assert(!isCardLetter('X'));
    std.debug.assert(!isCardLetter('Y'));
    std.debug.assert(!isTokenByte(' '));
    std.debug.assert(!isTokenByte('\n'));
    std.debug.assert(isTokenByte(0x00));
    std.debug.assert(isTokenByte(0xFF));
}

/// §4.2 tchar: any octet except SP (0x20) and LF (0x0A).
pub fn isTokenByte(b: u8) bool {
    return b != ' ' and b != '\n';
}

/// Splits a card's raw argument bytes on SP only (§4.2-m2 — tab is never a
/// delimiter). `rest == null` yields zero tokens (argument-less card);
/// `rest.?.len == 0` yields exactly one empty token (§4.7.17's "empty
/// token" case) before going null.
pub const TokenIterator = struct {
    rest: ?[]const u8,

    pub fn next(self: *TokenIterator) ?[]const u8 {
        const s = self.rest orelse return null;
        var i: usize = 0;
        while (i < s.len) : (i += 1) {
            if (s[i] == ' ') {
                self.rest = s[i + 1 ..];
                return s[0..i];
            }
        }
        self.rest = null;
        return s;
    }
};

/// One tokenized card (§4.2 `card`). Borrows from the input; no allocation.
pub const CardLine = struct {
    letter: u8,
    /// null  => argument-less card (`X\n`), 0 tokens.
    /// slice => SP form (`X ...\n`); raw arg bytes with the LF and the one
    ///          leading SP removed. May be "" (one empty token).
    args: ?[]const u8,

    pub fn tokens(self: CardLine) TokenIterator {
        return .{ .rest = self.args };
    }
};

/// Tokenize one card, given its full byte extent including the terminating
/// LF (the card loop supplies the extent; W's byte-counted body is that
/// loop's concern, not this function's — §4.7.18). Returns null iff the
/// letter is outside the closed 21-letter alphabet (§4.2.2-r1,-r2) or the
/// byte after the letter is neither SP nor LF (§4.2-m1).
pub fn tokenizeCardLine(line: []const u8) ?CardLine {
    std.debug.assert(line.len >= 1);
    std.debug.assert(line[line.len - 1] == '\n');

    // Non-W invariant: no embedded LF before the terminator. The W card's
    // byte-counted body is the sole exception (§4.7.18), realized by the
    // card loop, not here.
    var i: usize = 0;
    while (i < line.len - 1) : (i += 1) {
        std.debug.assert(line[i] != '\n');
    }

    if (line.len < 2) return null;
    if (!isCardLetter(line[0])) return null;

    const result: CardLine = if (line[1] == '\n')
        .{ .letter = line[0], .args = null }
    else if (line[1] == ' ')
        .{ .letter = line[0], .args = line[2 .. line.len - 1] }
    else
        return null;

    std.debug.assert(isCardLetter(result.letter));
    return result;
}

// ---------------------------------------------------------------------------
// §4.4 Card Ordering
// ---------------------------------------------------------------------------

comptime {
    // Z is the last defined letter (§4.4 "Nothing follows Z", lines 739-741):
    // every card letter sorts at or below it.
    var b: usize = 0;
    while (b < 256) : (b += 1) {
        if (isCardLetter(@intCast(b))) std.debug.assert(b <= 'Z');
    }
    // Premises the N-after-P exception rests on (lines 714-717, §A.1 2499-2500).
    std.debug.assert('N' < 'P');
    std.debug.assert('E' < 'P');
}

/// §4.4 card-type ordering, fed one accepted card letter at a time in
/// document order. Holds the previous letter and whether a valid E card has
/// been presented (the N-after-P technote exception, lines 714-717).
pub const OrderState = struct {
    prev: ?u8 = null,
    e_seen: bool = false,

    /// Admit `letter` as the next card. Returns false iff it is out of
    /// order and not the N-after-P exception; the caller collapses false
    /// to reject, matching the module's "every rejection reason collapses
    /// to null/false" posture (§4.1). Pure predicate: no error set.
    pub fn admit(self: *OrderState, letter: u8) bool {
        std.debug.assert(isCardLetter(letter));
        if (self.prev) |p| std.debug.assert(isCardLetter(p));

        const ok = if (self.prev == null)
            true
        else if (letter >= self.prev.?)
            true
        else
            letter == 'N' and self.prev.? == 'P' and self.e_seen;
        if (!ok) return false;

        if (letter == 'E') self.e_seen = true;
        self.prev = letter;
        std.debug.assert(self.prev.? == letter);
        std.debug.assert(!(letter == 'E') or self.e_seen);
        return true;
    }
};

// ---------------------------------------------------------------------------
// §4.3 Character-Escape Encoding
// ---------------------------------------------------------------------------

/// §4.3 Table 1: the decoded octet for a recognized escape successor, or
/// null for an unrecognized one. Exactly eight pairs are defined; every
/// other successor byte falls through to `else`.
fn escapeOctet(c: u8) ?u8 {
    return switch (c) {
        'n' => 0x0A, // LF
        's' => 0x20, // SP
        't' => 0x09, // TAB
        'r' => 0x0D, // CR
        'v' => 0x0B, // VT
        'f' => 0x0C, // FF
        '0' => 0x00, // NUL
        '\\' => 0x5C, // backslash
        else => null,
    };
}

comptime {
    var count: usize = 0;
    var b: usize = 0;
    while (b < 256) : (b += 1) {
        if (escapeOctet(@intCast(b)) != null) count += 1;
    }
    std.debug.assert(count == 8);
    std.debug.assert(escapeOctet('n').? == 0x0A);
    std.debug.assert(escapeOctet('s').? == 0x20);
    std.debug.assert(escapeOctet('0').? == 0x00);
    std.debug.assert(escapeOctet('\\').? == 0x5C);
}

/// §4.3 in-place escape decode of one free-text token. Writes the decoded
/// bytes into `out` and returns the used prefix. Never errors (§4.3: "No
/// error is raised"). Precondition: `out.len >= token.len` — decoded length
/// never exceeds input length (a recognized or unrecognized escape turns 2
/// input bytes into 1; an ordinary byte or a lone trailing backslash is 1:1,
/// §4.3-amb1).
pub fn decodeEscapes(token: []const u8, out: []u8) []u8 {
    std.debug.assert(out.len >= token.len);
    var i: usize = 0;
    var w: usize = 0;
    while (i < token.len) {
        std.debug.assert(w <= i);
        if (token[i] == '\\' and i + 1 < token.len) {
            out[w] = escapeOctet(token[i + 1]) orelse token[i + 1];
            i += 2;
        } else {
            // Ordinary byte, or a backslash with no successor (§4.3-amb1):
            // pass through literally.
            out[w] = token[i];
            i += 1;
        }
        w += 1;
    }
    std.debug.assert(w <= token.len);
    return out[0..w];
}

/// §4.3 inverse of `escapeOctet`: the two-char escape successor for a raw
/// octet that Table 1 names, or null for any other byte. Same eight pairs
/// as `escapeOctet`, reversed.
fn escapeSuccessor(c: u8) ?u8 {
    return switch (c) {
        0x0A => 'n', // LF
        0x20 => 's', // SP
        0x09 => 't', // TAB
        0x0D => 'r', // CR
        0x0B => 'v', // VT
        0x0C => 'f', // FF
        0x00 => '0', // NUL
        0x5C => '\\', // backslash
        else => null,
    };
}

comptime {
    std.debug.assert(escapeSuccessor(0x0A).? == 'n');
    std.debug.assert(escapeSuccessor(0x20).? == 's');
    std.debug.assert(escapeSuccessor(0x00).? == '0');
    std.debug.assert(escapeSuccessor(0x5C).? == '\\');
    std.debug.assert(escapeSuccessor('x') == null);
}

/// §4.3 in-place escape ENCODE of one free-text token -- the writer-side
/// inverse of `decodeEscapes`. Writes the encoded bytes into `out` and
/// returns the used prefix: each of Table 1's eight named octets becomes
/// the two-byte pair `\<succ>`; every other byte passes through verbatim.
/// Precondition: `out.len >= 2 * token.len` -- an escaped byte doubles,
/// nothing shrinks, so this bound is always sufficient.
pub fn encodeEscapes(token: []const u8, out: []u8) []u8 {
    std.debug.assert(out.len >= 2 * token.len);
    var w: usize = 0;
    for (token) |c| { // bound: token.len iterations, each writes <= 2 bytes
        std.debug.assert(w <= 2 * token.len);
        if (escapeSuccessor(c)) |succ| {
            out[w] = '\\';
            out[w + 1] = succ;
            w += 2;
        } else {
            out[w] = c;
            w += 1;
        }
    }
    std.debug.assert(w <= 2 * token.len);
    std.debug.assert(w >= token.len);
    return out[0..w];
}

// ---------------------------------------------------------------------------
// §4.5 Occurrence Classes and Intra-Type Ordering
// ---------------------------------------------------------------------------

/// §4.5.1 occurrence class of a card letter (lines 753-766). `single`: a
/// duplicate MUST be rejected. `repeatable`: each occurrence appends to the
/// artifact's growing list for that type — a duplicate is never a syntax
/// error. `neither`: the Z card only — no duplicate guard, no list.
pub const OccurrenceClass = enum { single, repeatable, neither };

/// §4.5.1 classifier (lines 755-764). Precondition: `isCardLetter(letter)`.
pub fn occurrenceClass(letter: u8) OccurrenceClass {
    std.debug.assert(isCardLetter(letter));
    return switch (letter) {
        'A', 'B', 'C', 'D', 'E', 'G', 'H', 'I', 'K', 'L', 'N', 'R', 'U', 'W' => .single,
        'F', 'J', 'M', 'T', 'P', 'Q' => .repeatable,
        'Z' => .neither,
        else => unreachable, // isCardLetter admits only the 21 letters above
    };
}

comptime {
    var single: usize = 0;
    var repeatable: usize = 0;
    var neither: usize = 0;
    var b: usize = 0;
    while (b < 256) : (b += 1) {
        if (!isCardLetter(@intCast(b))) continue;
        switch (occurrenceClass(@intCast(b))) {
            .single => single += 1,
            .repeatable => repeatable += 1,
            .neither => neither += 1,
        }
    }
    std.debug.assert(single == 14);
    std.debug.assert(repeatable == 6);
    std.debug.assert(neither == 1);
    std.debug.assert(occurrenceClass('Z') == .neither);
}

/// §4.5.2 Table 2 intra-run check structure for a repeatable card's list
/// (lines 789-809). `ascending`: strict ascent by a single decoded key (F,
/// J, M — the key itself varies by letter, extracted by the caller).
/// `tag_two_level`: T's (tag name, then target hash) rule. `unordered`: P
/// (semantic order, lines 804-805) and Q (no check at all, lines 807-808).
pub const IntraRunRule = enum { ascending, tag_two_level, unordered };

/// §4.5.2 Table 2 dispatch. Non-null iff `letter` is repeatable; null for
/// every single-occurrence letter and for Z. Precondition: `isCardLetter`.
pub fn intraRunRule(letter: u8) ?IntraRunRule {
    std.debug.assert(isCardLetter(letter));
    return switch (letter) {
        'F', 'J', 'M' => .ascending,
        'T' => .tag_two_level,
        'P', 'Q' => .unordered,
        else => null,
    };
}

comptime {
    var b: usize = 0;
    while (b < 256) : (b += 1) {
        if (!isCardLetter(@intCast(b))) continue;
        const letter: u8 = @intCast(b);
        const has_rule = intraRunRule(letter) != null;
        const is_repeatable = occurrenceClass(letter) == .repeatable;
        std.debug.assert(has_rule == is_repeatable);
    }
    std.debug.assert(intraRunRule('F').? == .ascending);
    std.debug.assert(intraRunRule('J').? == .ascending);
    std.debug.assert(intraRunRule('M').? == .ascending);
    std.debug.assert(intraRunRule('T').? == .tag_two_level);
    std.debug.assert(intraRunRule('P').? == .unordered);
    std.debug.assert(intraRunRule('Q').? == .unordered);
    std.debug.assert(intraRunRule('Z') == null);
}

/// §4.5.1-r1 duplicate guard, fed one accepted card letter at a time.
/// Fixed 26-slot membership set indexed by `letter - 'A'` — a card letter is
/// always in A..Z (`isCardLetter`), so 26 slots cover every case with no
/// dynamic sizing.
pub const SingleOccurrenceSet = struct {
    seen: [26]bool = .{false} ** 26,

    /// Admit `letter` as an occurrence. Returns false iff `letter` is
    /// single-occurrence and this is its second-or-later appearance;
    /// repeatable letters and Z are always admitted (§4.5.1-m1, §4.5.2-m3,
    /// the Z paragraph at lines 764-766).
    pub fn admit(self: *SingleOccurrenceSet, letter: u8) bool {
        std.debug.assert(isCardLetter(letter));
        if (occurrenceClass(letter) != .single) return true;
        const idx = letter - 'A';
        std.debug.assert(idx < 26);
        if (self.seen[idx]) return false;
        self.seen[idx] = true;
        std.debug.assert(self.seen[idx]);
        return true;
    }
};

/// §4.5.3 the canonical comparator (lines 823-836): raw-byte comparison,
/// octets as unsigned values (u8 is already unsigned), locale-independent,
/// no Unicode normalization, no case folding. On a common-prefix tie the
/// shorter string sorts first (§4.5.3-m1). No byte value — including `/`
/// 0x2F — gets special treatment (§4.5.3-m2): there is no branch for it.
pub fn compareBytes(a: []const u8, b: []const u8) std.math.Order {
    const n = @min(a.len, b.len);
    var i: usize = 0;
    while (i < n) : (i += 1) {
        // n = min(a.len, b.len), so this is what actually justifies the
        // unchecked a[i] / b[i] below — not a restatement of `i < n`.
        std.debug.assert(i < a.len and i < b.len);
        if (a[i] != b[i]) return std.math.order(a[i], b[i]);
    }
    return std.math.order(a.len, b.len);
}

comptime {
    std.debug.assert(compareBytes("A", "a") == .lt); // no case folding
    std.debug.assert(compareBytes("abc", "abcd") == .lt); // shorter-first
    std.debug.assert(compareBytes("a.b", "a/b") == .lt); // 0x2E < 0x2F, '/' unprivileged
}

/// §4.5.3-m1 "an absent value sorts before any present value." Both absent
/// compares equal; one absent sorts before any present value including "".
/// Two present values compare by `compareBytes`.
pub fn compareOptional(a: ?[]const u8, b: ?[]const u8) std.math.Order {
    if (a == null and b == null) return .eq;
    if (a == null) return .lt;
    if (b == null) return .gt;
    return compareBytes(a.?, b.?);
}

/// §4.5.2-r4 T two-level rule: compare tag name first; on a name tie,
/// target hash must strictly increase, so the hash comparison decides.
pub fn compareTag(a_name: []const u8, a_hash: []const u8, b_name: []const u8, b_hash: []const u8) std.math.Order {
    const name_order = compareBytes(a_name, b_name);
    if (name_order != .eq) return name_order;
    return compareBytes(a_hash, b_hash);
}

/// §4.6.1 Simple-Pathname predicate. Operates on the DECODED form of a token
/// (§4.6 line 853), as a sequence of unsigned octets. Applies to the A-card
/// filename and to the F-card filename and old-name (§4.6.1-p1, line 858).
/// Returns true iff the name is ACCEPTED; false iff it must be rejected.
/// Rejection is decidable from the '/'-separator structure alone (§4.6.1-m1):
/// no byte-content restriction — backslashes, spaces, control bytes, and
/// arbitrary/invalid UTF-8 all pass. The stricter writer profile does NOT
/// constrain acceptance (§4.6.1-m2, line 888).
pub fn isSimplePathname(name: []const u8) bool {
    if (name.len == 0) return false; // §4.6.1-r1: empty
    var start: usize = 0;
    var i: usize = 0;
    while (i < name.len) : (i += 1) { // bound: i++ each step, ends at name.len
        std.debug.assert(start <= i); // invariant: start never runs past i
        if (name[i] != '/') continue;
        if (!pathElementOk(name[start..i])) return false;
        start = i + 1; // element after the '/'
    }
    std.debug.assert(start <= name.len);
    return pathElementOk(name[start..name.len]); // final element, no trailing '/'
}

/// A single '/'-delimited element is bad iff empty (r2/r5/r6) or exactly
/// `.`/`..` (r3/r4/r7). See `isSimplePathname` doc comment for the mapping
/// from these two checks back to the seven enumerated RFC bullets.
fn pathElementOk(elem: []const u8) bool {
    if (elem.len == 0) return false;
    if (std.mem.eql(u8, elem, ".") or std.mem.eql(u8, elem, "..")) return false;
    return true;
}

comptime {
    std.debug.assert(isSimplePathname("alpha.txt")); // §A.3 accept
    std.debug.assert(isSimplePathname("a/b/c")); // nested accept
    std.debug.assert(isSimplePathname("...")); // r3/r7 are exact: "..." passes
    std.debug.assert(!isSimplePathname("")); // r1
    std.debug.assert(!isSimplePathname("/a")); // r2
    std.debug.assert(!isSimplePathname(".")); // r3
    std.debug.assert(!isSimplePathname("./a")); // r4
    std.debug.assert(!isSimplePathname("a//b")); // r5
    std.debug.assert(!isSimplePathname("a/")); // r6
    std.debug.assert(!isSimplePathname("a/../b")); // r7
}

const WIKI_NAME_MAX: usize = 100; // §4.6.2-r5 upper bound, in bytes

/// §4.6.2 Wiki-Name predicate. Operates on the DECODED form of a token as a
/// sequence of unsigned octets. Applies to the L-card title and to the A-card
/// target in its wiki-name alternative (§4.6.2-p1, line 903). Returns true iff
/// ACCEPTED; false iff it must be rejected. Reject iff any of: first byte
/// <= 0x20 (r1: empty/leading-space/leading-control); a subsequent byte < 0x20
/// (r2: control incl. tab/LF); consecutive 0x20 0x20 anywhere (r3); final byte
/// 0x20 (r4); byte length outside 1..100 (r5). Bytes >= 0x21 are otherwise
/// unconstrained: 0x7F and all bytes >= 0x80 accepted, no UTF-8 check, length
/// counted in bytes not characters (§4.6.2-m1, lines 919-921).
pub fn isWikiName(name: []const u8) bool {
    if (name.len < 1 or name.len > WIKI_NAME_MAX) return false; // r5 (also empty)
    if (name[0] <= 0x20) return false; // r1: first byte
    if (name[name.len - 1] == 0x20) return false; // r4: final byte
    var i: usize = 1;
    while (i < name.len) : (i += 1) { // bound: i++ each step, ends at name.len
        // i starts at 1 and only grows, so this is what actually justifies
        // the unchecked i - 1 below — not a restatement of i < name.len.
        std.debug.assert(i >= 1);
        const b = name[i];
        if (b < 0x20) return false; // r2: subsequent control byte
        if (b == 0x20 and name[i - 1] == 0x20) return false; // r3: consecutive spaces
    }
    return true;
}

comptime {
    std.debug.assert(isWikiName("A")); // 1-byte accept (r5 lower)
    std.debug.assert(isWikiName("Wiki Page")); // single interior space ok
    std.debug.assert(isWikiName("x" ** 100)); // r5 upper accept
    std.debug.assert(isWikiName("a\x7f")); // 0x7F accepted (m1)
    std.debug.assert(isWikiName("a\x80")); // >=0x80 accepted (m1)
    std.debug.assert(!isWikiName("")); // r5/r1: empty
    std.debug.assert(!isWikiName(" x")); // r1: leading space
    std.debug.assert(!isWikiName("\x1fx")); // r1: leading control
    std.debug.assert(!isWikiName("a\tb")); // r2: tab
    std.debug.assert(!isWikiName("a\nb")); // r2: LF
    std.debug.assert(!isWikiName("a  b")); // r3: double space
    std.debug.assert(!isWikiName("a ")); // r4: trailing space
    std.debug.assert(!isWikiName("x" ** 101)); // r5 upper reject
}

/// Sidecar suffixes stripped in reserved-filename step 1 (§4.6.3-p1, line
/// 926-929). Order does not matter for correctness: the three are mutually
/// non-suffixing (none is a suffix of another), so at most one can ever
/// match a given trailing region; kept as a fixed comptime-unrolled list.
const RESERVED_SIDECAR_SUFFIXES = [_][]const u8{ "-journal", "-wal", "-shm" };

/// §4.6.3 Reserved-filename predicate. Operates on the decoded CURRENT
/// F-card filename only -- the old-name field is never tested (line 957).
/// Returns true iff the three-step procedure (lines 926-937) matches, which
/// means the caller MUST silently drop the F card, not reject it (r2, line
/// 961): reserved-ness is a bool drop signal, never a ParseError variant.
pub fn isReservedFilename(name: []const u8) bool {
    // Step 1 (p1, lines 926-929): strip exactly one sidecar suffix. The
    // `!sidecar_stripped` guard is what makes this "exactly one" -- once a
    // suffix matches we stop, we never re-scan the shortened remainder.
    var remaining = name;
    var sidecar_stripped = false;
    inline for (RESERVED_SIDECAR_SUFFIXES) |suffix| {
        if (!sidecar_stripped and std.ascii.endsWithIgnoreCase(remaining, suffix)) {
            remaining = remaining[0 .. remaining.len - suffix.len];
            sidecar_stripped = true;
        }
    }

    // Step 2 (p2, lines 933-934): reserved tail, case-insensitive.
    const tail_fossil = "_FOSSIL_";
    const tail_fslckout = ".fslckout";
    const tail_len: usize = if (std.ascii.endsWithIgnoreCase(remaining, tail_fossil))
        tail_fossil.len
    else if (std.ascii.endsWithIgnoreCase(remaining, tail_fslckout))
        tail_fslckout.len
    else
        return false;
    std.debug.assert(remaining.len >= tail_len); // endsWithIgnoreCase guarantees this

    // Step 3 (p3, lines 936-937): whole-string, preceding '/', or sidecar.
    if (remaining.len == tail_len) return true; // 3a: tail is the whole string
    if (sidecar_stripped) return true; // 3c: a sidecar suffix was stripped
    std.debug.assert(remaining.len > tail_len); // 3a ruled out above; index is in-bounds
    return remaining[remaining.len - tail_len - 1] == '/'; // 3b
}

comptime {
    std.debug.assert(isReservedFilename("_FOSSIL_")); // 3a whole string
    std.debug.assert(isReservedFilename(".fslckout")); // 3a whole string
    std.debug.assert(isReservedFilename("dir/_FOSSIL_")); // 3b preceding '/'
    std.debug.assert(isReservedFilename("x_FOSSIL_-wal")); // 3c sidecar stripped
    std.debug.assert(isReservedFilename("_FOSSIL_-WAL")); // p1 case-insensitive strip
    std.debug.assert(isReservedFilename("_fossil_")); // p2 case-insensitive tail
    std.debug.assert(!isReservedFilename("x_FOSSIL_")); // r1: no step-3 condition holds
    std.debug.assert(!isReservedFilename("CON")); // m1: no reserved tail at all
    std.debug.assert(!isReservedFilename("readme.txt")); // p2 fails: no tail
    std.debug.assert(!isReservedFilename("")); // underflow guard boundary
    std.debug.assert(!isReservedFilename("foo-wal")); // strip leaves no tail
}

/// §4.6.3-m2 sequencing (lines 967-973): "the F card sort check (Section
/// 4.5.2) runs before the reserved-name drop, so a reserved-named F card
/// must itself be in sort position relative to the previously retained
/// entry, and the next F card is compared against that same retained
/// entry. The dropped name never becomes the comparison anchor. A
/// validator that checks order before dropping reproduces this behavior
/// exactly." `OutOfOrder` is the §4.5.2-r1 sort rejection (equal or
/// descending); it is distinct from a drop, which is not an error.
pub const FCardSortError = error{OutOfOrder};

/// Feeds F cards one at a time, in wire order, and tracks the effective
/// file set (§4.6.3-r2): a reserved-named card is silently dropped, not
/// admitted, so it never appears in the retained set and never replaces
/// `anchor`. `admit` runs the §4.5.2 sort check first and the §4.6.3
/// reserved check second, per the sequencing clause above.
pub const FCardAdmission = struct {
    /// Decoded current filename of the last RETAINED F card, or null
    /// before the first admitted card. This is the sort-check anchor; a
    /// dropped card's name is never written here.
    anchor: ?[]const u8 = null,

    /// Admit one F card by its decoded CURRENT filename only (§4.6.3 lines
    /// 957-959: the old-name field is never tested by this rule -- there
    /// is no old-name parameter for it to reach). Order is checked against
    /// `anchor` first: equal or descending is `OutOfOrder`, matching
    /// §4.5.2-r1, and applies even to a reserved name -- reserved-ness
    /// does not exempt a card from the sort check. Only a card that passes
    /// the sort check is then tested for reserved-ness. Returns true and
    /// advances `anchor` to `name` iff retained; returns false (anchor
    /// unchanged) iff dropped.
    pub fn admit(self: *FCardAdmission, name: []const u8) FCardSortError!bool {
        if (self.anchor) |prev| {
            if (compareBytes(prev, name) != .lt) return FCardSortError.OutOfOrder;
        }
        if (isReservedFilename(name)) return false; // dropped: anchor untouched
        self.anchor = name;
        std.debug.assert(self.anchor != null and std.mem.eql(u8, self.anchor.?, name));
        return true;
    }
};

// ---------------------------------------------------------------------------
// §4.6.4 Timestamps
// ---------------------------------------------------------------------------

/// The five accepted §4.6.4 D/E timestamp token forms (p1: the token is
/// classified RAW, never escape-decoded). `date_time` is the broad reader
/// form -- leading '-', whitespace/'T' separator, and tz all permitted; a
/// writer that must satisfy m8 uses `isWriterTimestamp` for the narrower
/// canonical subset.
pub const TimestampForm = enum { date_time, date, time, relative, julian };

/// §4.6.4 classify the raw D/E card timestamp token. Returns the matched
/// form, or null iff the token is rejected outright. Pure: reads no clock,
/// filesystem, or RNG state (m9 -- `now`/`subsec`/`subsecond` are classified
/// as `.relative`, never resolved to an instant here; resolution happens at
/// [FossilRepoState] ingestion time, outside this module).
pub fn classifyTimestamp(token: []const u8) ?TimestampForm {
    if (token.len == 0) return null; // negative space: nothing to classify
    if (matchDate(token)) |dm| {
        if (dm.cur == token.len) {
            if (!dateGateOk(dm.y_neg, dm.y, dm.m, dm.d)) return null; // r3
            return .date;
        }
        // Not a bare date: the remainder must be a date-time separator +
        // time [+ tz], or the whole token is rejected -- a matched date
        // prefix is never reinterpreted as a bare time (p1/r1 tokens rely
        // on this: they fail here, not by falling through below).
        var cur = dm.cur;
        const sep_start = cur;
        while (cur < token.len and (token[cur] == 'T' or std.ascii.isWhitespace(token[cur]))) : (cur += 1) {}
        if (cur == sep_start) return null; // m1 "1 or more"; r1 lowercase t never advances this
        const tm = matchTime(token, cur) orelse return null;
        const tz_end = matchTz(token, tm.cur);
        if (tz_end != token.len) return null; // trailing garbage after tz
        if (!dateGateOk(dm.y_neg, dm.y, dm.m, dm.d)) return null; // r3
        return .date_time;
    }
    if (matchTime(token, 0)) |tm| {
        if (tm.cur == token.len) return .time; // m4: implied 2000-01-01, gate always passes
    }
    if (isRelativeLiteral(token)) return .relative; // m5
    if (matchBareDecimal(token)) return .julian; // m6/r3
    return null;
}

/// §4.6.4-m8 writer grammar: `date %x54 time [frac]` -- YYYY-MM-DD, exactly
/// the byte 'T' (never lowercase, never whitespace, never a bare space), then
/// HH:MM:SS with SS mandatory, then an optional `.frac`. No leading sign, no
/// timezone. True iff `token` is exactly that canonical interoperable form
/// (w1: `2017-12-06T15:36:24.287`). This is the oracle a future D/E emitter
/// and any round-trip check call; parallels the hash-token canonical-writer
/// split already in this module (see `validateName` vs. its callers).
pub fn isWriterTimestamp(token: []const u8) bool {
    if (token.len == 0) return false; // negative space
    const dm = matchDate(token) orelse return false;
    if (dm.y_neg) return false; // writer form: no leading '-'
    if (dm.cur >= token.len or token[dm.cur] != 'T') return false; // exactly one 'T', no ws
    const tm = matchTime(token, dm.cur + 1) orelse return false;
    if (!tm.had_seconds) return false; // writer time = HH:MM:SS, SS mandatory
    if (tm.cur != token.len) return false; // no tz permitted
    return dateGateOk(dm.y_neg, dm.y, dm.m, dm.d); // r3
}

/// Parse exactly `n` ASCII digit bytes from the front of `s` as an unsigned
/// int, or null if fewer than `n` bytes remain or any of the first `n` bytes
/// is not '0'-'9' (§4.6.4-m7 "exactly N digits" -- no sign, no short/long
/// reads). Wire-derived input: failure is a guard return, never a panic.
fn parseFixedDigits(s: []const u8, comptime n: usize) ?u32 {
    comptime std.debug.assert(n <= 4); // largest field here is the 4-digit year
    if (s.len < n) return null;
    var v: u32 = 0;
    for (s[0..n]) |b| { // bound: fixed at comptime n
        if (b < '0' or b > '9') return null;
        v = v * 10 + (b - '0');
    }
    std.debug.assert(v < comptime std.math.pow(u32, 10, n)); // n digits bound the value
    return v;
}

const DateMatch = struct { cur: usize, y_neg: bool, y: u32, m: u32, d: u32 };

/// §4.6.4-m3/m7 date: optional leading '-', then YYYY-MM-DD. Month is
/// range-checked to 01-12 and day to 01-31 here (m7); there is no
/// calendar-aware check, so 2024-02-31 parses fine (that acceptance is
/// exercised by the caller, not rejected here). Returns the match plus the
/// cursor just past the day field, or null on any mismatch.
fn matchDate(token: []const u8) ?DateMatch {
    var cur: usize = 0;
    var y_neg = false;
    if (token.len > 0 and token[0] == '-') {
        y_neg = true;
        cur = 1;
    }
    const y = parseFixedDigits(token[cur..], 4) orelse return null;
    cur += 4;
    if (cur >= token.len or token[cur] != '-') return null;
    cur += 1;
    const m = parseFixedDigits(token[cur..], 2) orelse return null;
    if (m < 1 or m > 12) return null; // m7: month 01-12
    std.debug.assert(m >= 1 and m <= 12);
    cur += 2;
    if (cur >= token.len or token[cur] != '-') return null;
    cur += 1;
    const d = parseFixedDigits(token[cur..], 2) orelse return null;
    if (d < 1 or d > 31) return null; // m7: day 01-31, no calendar check
    std.debug.assert(d >= 1 and d <= 31);
    cur += 2;
    std.debug.assert(cur <= token.len);
    return DateMatch{ .cur = cur, .y_neg = y_neg, .y = y, .m = m, .d = d };
}

/// §4.6.4-r3 JD>0 gate, date-granularity reading (RESOLVED operator decision,
/// 2026-07-09 -- see ambiguities.md): reject a date/date-time token iff its
/// signed (year, month, day) is lexicographically <= (-4713, 11, 24),
/// independent of time-of-day or tz. `year` is the signed astronomical year
/// (y_neg negates `y`); this is what makes -4713-11-24T18:00:00 reject
/// despite its positive fractional Julian Date.
fn dateGateOk(y_neg: bool, y: u32, m: u32, d: u32) bool {
    std.debug.assert(m >= 1 and m <= 12);
    std.debug.assert(d >= 1 and d <= 31);
    const year: i32 = if (y_neg) -@as(i32, @intCast(y)) else @as(i32, @intCast(y));
    if (year < -4713) return false;
    if (year > -4713) return true;
    if (m < 11) return false;
    if (m > 11) return true;
    return d > 24;
}

const TimeMatch = struct { cur: usize, had_seconds: bool };

/// §4.6.4-m1/m4/m7 time-of-day: HH:MM, HH:MM:SS, or HH:MM:SS.frac. HH is
/// 00-24, MM/SS are 00-59, frac (only legal after ':SS') is 1+ digits.
/// Returns the cursor just past the match plus whether ':SS' was present, or
/// null on any mismatch.
fn matchTime(token: []const u8, i: usize) ?TimeMatch {
    var cur = i;
    const hh = parseFixedDigits(token[cur..], 2) orelse return null;
    if (hh > 24) return null; // m7: hour 00-24
    std.debug.assert(hh <= 24);
    cur += 2;
    if (cur >= token.len or token[cur] != ':') return null;
    cur += 1;
    const mm = parseFixedDigits(token[cur..], 2) orelse return null;
    if (mm > 59) return null; // m7: minute 00-59
    std.debug.assert(mm <= 59);
    cur += 2;
    var had_seconds = false;
    if (cur < token.len and token[cur] == ':') {
        cur += 1;
        const ss = parseFixedDigits(token[cur..], 2) orelse return null;
        if (ss > 59) return null; // m7: second 00-59
        std.debug.assert(ss <= 59);
        cur += 2;
        had_seconds = true;
        if (cur < token.len and token[cur] == '.') {
            cur += 1;
            const frac_start = cur;
            while (cur < token.len and std.ascii.isDigit(token[cur])) : (cur += 1) {} // bound: token.len
            if (cur == frac_start) return null; // m7: frac needs 1+ digits
            std.debug.assert(cur > frac_start);
        }
    }
    std.debug.assert(cur <= token.len);
    return TimeMatch{ .cur = cur, .had_seconds = had_seconds };
}

/// §4.6.4-m2 optional timezone: optional whitespace, then '+HH:MM',
/// '-HH:MM', 'Z', or 'z'. HH/MM are exactly 2 digits each, structural only --
/// the RFC gives no tz range and no conforming writer ever emits one (m8).
/// Returns the cursor past a matched tz, or `i` unchanged if none is present:
/// that "no advance" is what makes the whole timezone optional -- the
/// caller's final `cur == token.len` check does the actual accept/reject.
fn matchTz(token: []const u8, i: usize) usize {
    var cur = i;
    while (cur < token.len and std.ascii.isWhitespace(token[cur])) : (cur += 1) {} // bound: token.len
    if (cur >= token.len) return i; // no tz; don't consume trailing ws
    const c = token[cur];
    if (c == 'Z' or c == 'z') return cur + 1;
    if (c == '+' or c == '-') {
        var p = cur + 1;
        _ = parseFixedDigits(token[p..], 2) orelse return i;
        p += 2;
        if (p >= token.len or token[p] != ':') return i;
        p += 1;
        _ = parseFixedDigits(token[p..], 2) orelse return i;
        p += 2;
        std.debug.assert(p <= token.len);
        return p;
    }
    return i; // no tz
}

/// §4.6.4-m5: `now`, `subsec`, or `subsecond`, case-insensitive, whole-token
/// exact match only.
fn isRelativeLiteral(token: []const u8) bool {
    return std.ascii.eqlIgnoreCase(token, "now") or
        std.ascii.eqlIgnoreCase(token, "subsec") or
        std.ascii.eqlIgnoreCase(token, "subsecond");
}

/// §4.6.4-m6/r3 bare decimal: lexically `1*DIGIT ["." 1*DIGIT]` (>=1 integer
/// digit; a dot requires >=1 fraction digit, mirroring the time frac rule),
/// consuming the whole token, then accepted iff its value r satisfies
/// `0 < r < 5373484.5` (m6 gives `0 <= r`; r3 tightens to `r > 0`, dropping
/// the numeric token "0").
fn matchBareDecimal(token: []const u8) bool {
    var i: usize = 0;
    while (i < token.len and std.ascii.isDigit(token[i])) : (i += 1) {} // bound: token.len
    if (i == 0) return false; // need >=1 integer digit
    std.debug.assert(i >= 1);
    if (i < token.len and token[i] == '.') {
        var j = i + 1;
        while (j < token.len and std.ascii.isDigit(token[j])) : (j += 1) {} // bound: token.len
        if (j == i + 1) return false; // dot requires >=1 fraction digit
        i = j;
    }
    if (i != token.len) return false; // trailing byte the lexical grammar doesn't cover
    const r = std.fmt.parseFloat(f64, token) catch return false; // unreachable on a validated slice
    return r > 0 and r < 5373484.5;
}

comptime {
    std.debug.assert(classifyTimestamp("2017-12-06T15:36:24.287").? == .date_time); // w1
    std.debug.assert(isWriterTimestamp("2017-12-06T15:36:24.287")); // w1
    std.debug.assert(classifyTimestamp("2017-12-06").? == .date); // m3
    std.debug.assert(classifyTimestamp("15:36:24").? == .time); // m4
    std.debug.assert(classifyTimestamp("now").? == .relative); // m5
    std.debug.assert(classifyTimestamp("2451545").? == .julian); // m6
    std.debug.assert(classifyTimestamp("2017-12-06t15:36:24") == null); // r1
    std.debug.assert(classifyTimestamp("-4713-11-24") == null); // r3 boundary
    std.debug.assert(classifyTimestamp("0") == null); // r3 numeric zero
    std.debug.assert(!isWriterTimestamp("2017-12-06")); // m8: date alone is not the writer form
}

// ---------------------------------------------------------------------------
// §4.7.2 A Card
// ---------------------------------------------------------------------------

/// One parsed A card (§4.7.2 attachment card). Grammar: `a-card = "A" SP
/// filename SP target [ SP source ] LF` (line 1074). `name` and `target`
/// borrow from the caller's `decode_buf`; `source` borrows the card's raw
/// args -- no allocation anywhere in this parse.
pub const ACard = struct {
    /// Decoded filename's final path component only; any directory prefix
    /// is discarded (m1, lines 1084-1086).
    name: []const u8,
    /// Decoded target: hash- or wiki-name-shaped (r2, lines 1088-1089).
    /// Which family it is, and whether it resolves, is crosslink-time work
    /// (lines 1097-1107) -- out of scope for parse-time acceptance.
    target: []const u8,
    /// Raw, never-decoded artifact hash when present; null when absent
    /// (r3/m2, lines 1093-1095).
    source: ?[]const u8,
};

/// Return the final '/'-delimited element of `name`. `isSimplePathname`
/// already rejects an empty element and a trailing '/' (§4.6.1), so the
/// element after the last '/' -- or the whole name, if there is none -- is
/// always non-empty.
fn finalPathComponent(name: []const u8) []const u8 {
    std.debug.assert(name.len >= 1);
    var last_slash: ?usize = null;
    for (name, 0..) |c, i| { // bound: name.len, via the slice iterator
        if (c == '/') last_slash = i;
    }
    const result = if (last_slash) |idx| name[idx + 1 ..] else name;
    std.debug.assert(result.len >= 1);
    return result;
}

comptime {
    std.debug.assert(std.mem.eql(u8, finalPathComponent("a/b/c"), "c"));
    std.debug.assert(std.mem.eql(u8, finalPathComponent("x"), "x"));
}

/// Parse one already-tokenized A card (§4.7.2). Returns null for any
/// rejection reason -- bad arity (§4.2.1 line 610: "a fourth on A"), a
/// missing filename/target (p1), a bad filename (r1), a bad target (r2), or
/// a bad source (r3) -- matching the module's "every rejection collapses to
/// null/false" posture. Parse-time acceptance is syntactic only; it does
/// not resolve the target to an object (lines 1080-1081).
///
/// `decode_buf` is caller-owned and holds both decoded fields.
/// Precondition: `decode_buf.len >= filename_token.len + target_token.len`
/// (decoded length never exceeds wire length, per `decodeEscapes`).
pub fn parseACard(card: CardLine, decode_buf: []u8) ?ACard {
    std.debug.assert(card.letter == 'A');

    var it = card.tokens();
    const filename_tok = it.next() orelse return null; // p1: filename required
    const target_tok = it.next() orelse return null; // p1: target required
    const source_tok = it.next(); // optional (grammar's "[ SP source ]")
    if (it.next() != null) return null; // §4.2.1 line 610: "a fourth on A"

    std.debug.assert(decode_buf.len >= filename_tok.len + target_tok.len);

    // Filename: decode, then the pathname predicate, then basename (m1).
    const filename_dec = decodeEscapes(filename_tok, decode_buf[0..filename_tok.len]);
    if (!isSimplePathname(filename_dec)) return null; // r1
    const name = finalPathComponent(filename_dec);

    // Target: the one hash-bearing position decoded before validation
    // (§4.3.1 lines 690-692), since it may alternatively hold a wiki name.
    const target_buf = decode_buf[filename_tok.len..][0..target_tok.len];
    const target = decodeEscapes(target_tok, target_buf);
    const target_ok = validateHashToken(.artifact_hash, target) or isWikiName(target);
    if (!target_ok) return null; // r2

    // Source: raw hash, never decoded (r3; §4.3.1 line 680 names it the
    // sole exception among hash-bearing positions). Absent -> null (m2).
    var source: ?[]const u8 = null;
    if (source_tok) |raw| {
        if (!validateHashToken(.artifact_hash, raw)) return null; // r3
        source = raw;
    }

    std.debug.assert(name.len >= 1);
    std.debug.assert(target.len >= 1);
    std.debug.assert(source == null or source.?.len == 40 or source.?.len == 64);
    return .{ .name = name, .target = target, .source = source };
}

// ---------------------------------------------------------------------------
// §4.7.3 B Card
// ---------------------------------------------------------------------------

/// One parsed B card (§4.7.3 baseline card). Grammar: `b-card = "B" SP hash
/// LF` (line 1127). `hash` is raw -- B is a hash/checksum position and §4.3.1
/// (line 686-688) never decodes it, unlike the A card's target. Presence
/// marks the artifact as a delta manifest and the delta-of-delta rejection
/// is §8 assembly work, out of scope here (line 1130-1134).
pub const BCard = struct { hash: []const u8 };

/// Parse one already-tokenized B card (§4.7.3). Returns null for bad arity
/// or a hash failing the hash map (`.artifact_hash`, §4.7.1).
pub fn parseBCard(card: CardLine) ?BCard {
    std.debug.assert(card.letter == 'B');

    var it = card.tokens();
    const hash_tok = it.next() orelse return null; // p1: hash required
    if (it.next() != null) return null; // grammar has no second token

    if (!validateHashToken(.artifact_hash, hash_tok)) return null; // r1

    std.debug.assert(hash_tok.len == 40 or hash_tok.len == 64);
    return .{ .hash = hash_tok };
}

// ---------------------------------------------------------------------------
// §4.7.4 C Card
// ---------------------------------------------------------------------------

/// One parsed C card (§4.7.4 comment card). Grammar: `c-card = "C" SP
/// comment LF` (line 1140). `comment` borrows the caller's `decode_buf` --
/// the C card is one of the 12 decoded positions (§4.3.1 line 681), and its
/// decoded content is otherwise unvalidated (m1).
pub const CCard = struct { comment: []const u8 };

/// Parse one already-tokenized C card (§4.7.4). Returns null for a missing
/// token, an empty token (`comment = 1*tchar`, Appendix B), or a second
/// token -- the grammar has room for exactly one (r1).
/// Precondition: `decode_buf.len >= comment token length` (decoded length
/// never exceeds wire length, per `decodeEscapes`).
pub fn parseCCard(card: CardLine, decode_buf: []u8) ?CCard {
    std.debug.assert(card.letter == 'C');

    var it = card.tokens();
    const comment_tok = it.next() orelse return null; // r1: no token
    if (comment_tok.len == 0) return null; // r1: empty token
    if (it.next() != null) return null; // grammar has no second token

    std.debug.assert(decode_buf.len >= comment_tok.len);
    const comment = decodeEscapes(comment_tok, decode_buf[0..comment_tok.len]);

    std.debug.assert(comment.len <= comment_tok.len);
    return .{ .comment = comment };
}

// ---------------------------------------------------------------------------
// §4.7.5 D Card and E Card
// ---------------------------------------------------------------------------

/// One parsed D card (§4.7.5 date card). Grammar: `d-card = "D" SP
/// timestamp LF` (line 1150). `timestamp` is raw -- D/E timestamps are
/// never decoded (§4.3.1 line 687-688).
pub const DCard = struct { timestamp: []const u8 };

/// One parsed E card (§4.7.5 technote card). Grammar: `e-card = "E" SP
/// timestamp SP technote-id LF` (line 1151). Both fields raw. EVENT-type
/// marking is §5.3 type resolution -- deferred to the manifest-assembly
/// batch, exactly as A/B's "marks the artifact as..." effects are deferred.
pub const ECard = struct { timestamp: []const u8, technote_id: []const u8 };

/// Parse one already-tokenized D card (§4.7.5). Returns null for bad arity
/// or a timestamp failing `classifyTimestamp` (§4.6.4).
pub fn parseDCard(card: CardLine) ?DCard {
    std.debug.assert(card.letter == 'D');

    var it = card.tokens();
    const ts_tok = it.next() orelse return null; // p1: timestamp required
    if (it.next() != null) return null; // grammar has no second token

    if (classifyTimestamp(ts_tok) == null) return null; // r2

    return .{ .timestamp = ts_tok };
}

/// Parse one already-tokenized E card (§4.7.5). Returns null for bad arity,
/// a timestamp failing §4.6.4, or a technote-id failing the hash map
/// (`.artifact_hash`, §4.7.1) -- the id is a hash/checksum position and is
/// never decoded.
pub fn parseECard(card: CardLine) ?ECard {
    std.debug.assert(card.letter == 'E');

    var it = card.tokens();
    const ts_tok = it.next() orelse return null; // p2: timestamp required
    const id_tok = it.next() orelse return null; // p2: technote-id required
    if (it.next() != null) return null; // grammar has no third token

    if (classifyTimestamp(ts_tok) == null) return null; // r2
    if (!validateHashToken(.artifact_hash, id_tok)) return null; // r3

    std.debug.assert(id_tok.len == 40 or id_tok.len == 64);
    return .{ .timestamp = ts_tok, .technote_id = id_tok };
}

// ---------------------------------------------------------------------------
// §4.7.6 F Card
// ---------------------------------------------------------------------------

/// §4.7.6-m4/m5 permission-derived entry kind. `x` in the raw perm token
/// wins over `l` (checked first); anything else, including an absent perm
/// or a hash-less delete marker, is `.regular` (lines 1196-1199).
pub const FileMode = enum { regular, executable, symlink };

/// One parsed F card (§4.7.6 file-list entry). `filename` and `old_name`
/// borrow the caller's `decode_buf` (decoded, §4.6.1-checked); `hash` and
/// `perm` are raw slices of the wire args -- never decoded (m3). `hash ==
/// null` is the delta-manifest deletion marker (§8.2); whether that's legal
/// here depends on a B card elsewhere in the same artifact, which this
/// single-card parser can't see -- deferred to the manifest-assembly batch,
/// same as B's delta-of-delta check. Sort/dedup and reserved-name dropping
/// are per-artifact machinery that already exists, in `FCardAdmission` and
/// `isReservedFilename` -- not this parser either.
pub const FCard = struct {
    filename: []const u8,
    hash: ?[]const u8,
    perm: ?[]const u8,
    old_name: ?[]const u8,
    mode: FileMode,
};

/// §4.7.6-m5 classifier: raw perm token -> `FileMode`. `x` anywhere wins;
/// else `l` anywhere is a symlink; a null or perm-less token is regular.
fn fileMode(perm: ?[]const u8) FileMode {
    const p = perm orelse return .regular;
    if (std.mem.indexOfScalar(u8, p, 'x') != null) return .executable;
    if (std.mem.indexOfScalar(u8, p, 'l') != null) return .symlink;
    return .regular;
}

/// Parse one already-tokenized F card (§4.7.6). Returns null for a missing
/// filename (r1), a filename or old-name failing the pathname predicate
/// (§4.6.1, m1/m2), a hash failing the hash map when present (r3), an empty
/// perm token when present (`token = 1*tchar`, Appendix B -- perm is
/// unvalidated against {w,x,l} but still must be nonempty), or a 5th token
/// (the grammar has room for exactly four positions).
/// Precondition: `decode_buf.len >= filename_tok.len + old_tok.len`.
pub fn parseFCard(card: CardLine, decode_buf: []u8) ?FCard {
    std.debug.assert(card.letter == 'F');

    var it = card.tokens();
    const filename_tok = it.next() orelse return null; // r1: filename required
    const hash_tok = it.next(); // optional
    const perm_tok = it.next(); // optional, only meaningful with hash present
    const old_tok = it.next(); // optional, only meaningful with perm present
    if (it.next() != null) return null; // grammar has no 5th token

    std.debug.assert(decode_buf.len >= filename_tok.len + (if (old_tok) |t| t.len else 0));

    const filename = decodeEscapes(filename_tok, decode_buf[0..filename_tok.len]);
    if (!isSimplePathname(filename)) return null; // m1/r2

    var hash: ?[]const u8 = null;
    if (hash_tok) |raw| {
        if (!validateHashToken(.artifact_hash, raw)) return null; // r3
        hash = raw;
    }

    // perm: raw, unvalidated against {w,x,l} at parse time (m4), but the
    // grammar still requires `token = 1*tchar` (Appendix B) -- an empty
    // 3rd token (e.g. a trailing SP after hash) has no production and
    // MUST reject, not silently parse as perm=="".
    if (perm_tok) |raw| {
        if (raw.len == 0) return null; // p1/r1: no production for empty token
    }
    const perm = perm_tok;

    var old_name: ?[]const u8 = null;
    if (old_tok) |raw| {
        const old_buf = decode_buf[filename.len..][0..raw.len];
        const decoded = decodeEscapes(raw, old_buf);
        if (!isSimplePathname(decoded)) return null; // m2/r4
        old_name = decoded;
    }

    std.debug.assert(filename.len >= 1);
    std.debug.assert(hash == null or hash.?.len == 40 or hash.?.len == 64);
    std.debug.assert(perm == null or perm.?.len >= 1);
    std.debug.assert(old_name == null or old_name.?.len >= 1);
    return .{
        .filename = filename,
        .hash = hash,
        .perm = perm,
        .old_name = old_name,
        .mode = fileMode(perm),
    };
}

// ---------------------------------------------------------------------------
// §4.7.7 G Card, H Card, and I Card
// ---------------------------------------------------------------------------

/// One parsed G card (§4.7.7 forum thread-root). Grammar: `g-card = "G" SP
/// hash LF` (line 1207). `hash` is raw -- a hash/checksum position, never
/// decoded (§4.3.1). Presence marks the artifact as a forum post (§5.3 type
/// resolution) -- deferred to the manifest-assembly batch, same posture as
/// A/B's "marks the artifact as..." effects.
pub const GCard = struct { hash: []const u8 };

/// One parsed I card (§4.7.7 forum reply-to). Grammar: `i-card = "I" SP hash
/// LF` (line 1209). Byte-identical wire shape to G, raw hash, but distinct
/// meaning -- the post being replied to, not the thread root. An artifact
/// MUST NOT carry both an H and an I card (line 1218), but that is a
/// whole-artifact check a single-card parser cannot see; deferred to the
/// §5.4 assembly batch, same as B's delta-of-delta check.
pub const ICard = struct { hash: []const u8 };

/// One parsed H card (§4.7.7 forum thread title). Grammar: `h-card = "H" SP
/// threadtitle LF` (line 1208). `title` borrows the caller's `decode_buf` --
/// one of the 12 decoded positions (§4.3.1); no content validation beyond
/// arity/nonempty applies (line 1216-1217): "any decoded string is
/// accepted", unlike the L card's wiki-name gate below.
pub const HCard = struct { title: []const u8 };

/// Shared G/I helper: one required raw hash token, validated per the hash
/// map (`.artifact_hash`), never decoded. G and I are byte-identical on the
/// wire (line 1207, 1209); this is the only place that shape is checked --
/// the two callers below wrap the result into their distinct struct types.
fn hashOnlyForumCard(card: CardLine) ?[]const u8 {
    std.debug.assert(card.letter == 'G' or card.letter == 'I');

    var it = card.tokens();
    const hash_tok = it.next() orelse return null; // p1: hash required
    if (it.next() != null) return null; // grammar has no second token

    if (!validateHashToken(.artifact_hash, hash_tok)) return null; // r1

    std.debug.assert(hash_tok.len == 40 or hash_tok.len == 64);
    return hash_tok;
}

/// Parse one already-tokenized G card (§4.7.7). Returns null for bad arity
/// or a hash failing the hash map.
pub fn parseGCard(card: CardLine) ?GCard {
    std.debug.assert(card.letter == 'G');
    const hash = hashOnlyForumCard(card) orelse return null;
    return .{ .hash = hash };
}

/// Parse one already-tokenized I card (§4.7.7). Returns null for bad arity
/// or a hash failing the hash map.
pub fn parseICard(card: CardLine) ?ICard {
    std.debug.assert(card.letter == 'I');
    const hash = hashOnlyForumCard(card) orelse return null;
    return .{ .hash = hash };
}

/// Parse one already-tokenized H card (§4.7.7). Returns null for a missing
/// token, an empty token (`threadtitle = token = 1*tchar`, Appendix B), or a
/// second token -- the grammar has room for exactly one. Any nonempty
/// decoded content is accepted (line 1216-1217); contrast `parseLCard`.
/// Precondition: `decode_buf.len >= title token length`.
pub fn parseHCard(card: CardLine, decode_buf: []u8) ?HCard {
    std.debug.assert(card.letter == 'H');

    var it = card.tokens();
    const title_tok = it.next() orelse return null; // p1: title required
    if (title_tok.len == 0) return null; // token = 1*tchar
    if (it.next() != null) return null; // grammar has no second token

    std.debug.assert(decode_buf.len >= title_tok.len);
    const title = decodeEscapes(title_tok, decode_buf[0..title_tok.len]);

    std.debug.assert(title.len <= title_tok.len);
    return .{ .title = title };
}

// ---------------------------------------------------------------------------
// §4.7.8 J Card
// ---------------------------------------------------------------------------

/// One parsed J card (§4.7.8 ticket field-change). Grammar: `j-card = "J" SP
/// fieldname [ SP value ] LF` (line 1222). `field` is the raw wire token,
/// stored verbatim including any leading `+` (m1/m2, line 1225-1226) -- not
/// a decoded position. `value` is escape-decoded (m1) and borrows the
/// caller's `decode_buf`; an absent value is "" per m3, never null -- the
/// type is plain `[]const u8`, not `?[]const u8`, which defines the null
/// case out of existence rather than modelling it. Ascending-by-field
/// ordering across a run of J cards (§4.5.2) is manifest-assembly work, out
/// of scope for this single-card parser. Ticket-processing semantics (the
/// +-prefix append rule, unknown-field tolerance) are explicitly out of
/// scope for the artifact layer (line 1227-1240).
pub const JCard = struct { field: []const u8, value: []const u8 };

/// Parse one already-tokenized J card (§4.7.8). Returns null for a missing
/// fieldname, an empty fieldname or value token (`token = 1*tchar`, Appendix
/// B -- neither `fieldname` nor `value` has its own production, so an empty
/// wire token has no production either way), or a third token -- the
/// grammar has room for exactly two.
/// Precondition: `decode_buf.len >= value token length`.
pub fn parseJCard(card: CardLine, decode_buf: []u8) ?JCard {
    std.debug.assert(card.letter == 'J');

    var it = card.tokens();
    const field_tok = it.next() orelse return null; // r1: fieldname required
    if (field_tok.len == 0) return null; // token = 1*tchar
    const value_tok = it.next(); // optional
    if (it.next() != null) return null; // grammar has no third token

    var value: []const u8 = ""; // m3: absent value is "", never null
    if (value_tok) |raw| {
        if (raw.len == 0) return null; // token = 1*tchar: no production
        std.debug.assert(decode_buf.len >= raw.len);
        value = decodeEscapes(raw, decode_buf[0..raw.len]);
    }

    std.debug.assert(field_tok.len >= 1);
    std.debug.assert(value.len <= if (value_tok) |t| t.len else 0);
    return .{ .field = field_tok, .value = value };
}

// ---------------------------------------------------------------------------
// §4.7.9 K Card
// ---------------------------------------------------------------------------

/// One parsed K card (§4.7.9 ticket card). Grammar: `k-card = "K" SP
/// ticket-id LF` (line 1244). `ticket_id` is raw, pinned to the 40-char
/// SHA-1 length (r1) -- a 64-char SHA3-length id is always invalid,
/// regardless of hash policy. Presence marks the artifact as a ticket
/// change (§5.3 type resolution) -- deferred to manifest assembly.
pub const KCard = struct { ticket_id: []const u8 };

/// Parse one already-tokenized K card (§4.7.9). Returns null for bad arity
/// or an id failing the hash map (`.ticket_id`, §4.7.1) -- pinned to 40 hex,
/// lowercase-only (s1: the module takes the parser's MAY as lowercase-only,
/// mirroring the §4.7.1 hash-map decision at line 1648).
pub fn parseKCard(card: CardLine) ?KCard {
    std.debug.assert(card.letter == 'K');

    var it = card.tokens();
    const id_tok = it.next() orelse return null; // p1: ticket-id required
    if (it.next() != null) return null; // grammar has no second token

    if (!validateHashToken(.ticket_id, id_tok)) return null; // r1

    std.debug.assert(id_tok.len == 40);
    return .{ .ticket_id = id_tok };
}

// ---------------------------------------------------------------------------
// §4.7.10 L Card
// ---------------------------------------------------------------------------

/// One parsed L card (§4.7.10 wiki-title card). Grammar: `l-card = "L" SP
/// wikititle LF` (line 1256). `title` is escape-decoded and borrows the
/// caller's `decode_buf`; unlike H, it MUST pass the wiki-name predicate
/// (§4.6.2) -- this is the H/L contrast the RFC calls out at line 1217.
/// Presence marks the artifact as a wiki page (§5.3) -- deferred.
pub const LCard = struct { title: []const u8 };

/// Parse one already-tokenized L card (§4.7.10). Returns null for a missing
/// token or a decoded title failing `isWikiName` (§4.6.2) -- which subsumes
/// the empty-token and length-bound cases (wiki-name rejects length outside
/// 1..100), so no separate `token = 1*tchar` check is needed here. A second
/// token also rejects -- the grammar has room for exactly one.
/// Precondition: `decode_buf.len >= title token length`.
pub fn parseLCard(card: CardLine, decode_buf: []u8) ?LCard {
    std.debug.assert(card.letter == 'L');

    var it = card.tokens();
    const title_tok = it.next() orelse return null; // p1: title required
    if (it.next() != null) return null; // grammar has no second token

    std.debug.assert(decode_buf.len >= title_tok.len);
    const title = decodeEscapes(title_tok, decode_buf[0..title_tok.len]);
    if (!isWikiName(title)) return null; // r1

    std.debug.assert(isWikiName(title));
    return .{ .title = title };
}

// ---------------------------------------------------------------------------
// §4.7.11 M Card
// ---------------------------------------------------------------------------

/// One parsed M card (§4.7.11 cluster-member card). Grammar: `m-card = "M"
/// SP hash LF` (line 1263). `hash` is raw -- a hash/checksum position, never
/// decoded (§4.3.1). Repeatable; the strict-ascending-by-hash run check
/// (line 1266) is §4.5.2 manifest-assembly work, already tested there.
/// Presence marks the artifact as a cluster (§5.3 type resolution) --
/// deferred, same posture as A/B/G.
pub const MCard = struct { hash: []const u8 };

/// Parse one already-tokenized M card (§4.7.11). Returns null for bad arity
/// or a hash failing the hash map (`.artifact_hash`, §4.7.1).
pub fn parseMCard(card: CardLine) ?MCard {
    std.debug.assert(card.letter == 'M');

    var it = card.tokens();
    const hash_tok = it.next() orelse return null; // p1: hash required
    if (it.next() != null) return null; // grammar has no second token

    if (!validateHashToken(.artifact_hash, hash_tok)) return null; // r1

    std.debug.assert(hash_tok.len == 40 or hash_tok.len == 64);
    return .{ .hash = hash_tok };
}

// ---------------------------------------------------------------------------
// §4.7.12 N Card
// ---------------------------------------------------------------------------

/// One parsed N card (§4.7.12 mimetype card). Grammar: `n-card = "N" SP
/// mimetype LF` (line 1270). `mimetype` is escape-decoded and borrows the
/// caller's `decode_buf` -- one of the 12 decoded positions (§4.3.1); no
/// mime-syntax validation applies (line 1272). Per-type default/target
/// (text/x-fossil-wiki on wiki, text/plain on attachment, inert on
/// check-in) is crosslink-time semantics -- deferred to manifest assembly.
pub const NCard = struct { mimetype: []const u8 };

/// Parse one already-tokenized N card (§4.7.12). Returns null for a missing
/// token, an empty token (`mimetype = token = 1*tchar`, Appendix B), or a
/// second token -- the grammar has room for exactly one.
/// Precondition: `decode_buf.len >= mimetype token length`.
pub fn parseNCard(card: CardLine, decode_buf: []u8) ?NCard {
    std.debug.assert(card.letter == 'N');

    var it = card.tokens();
    const tok = it.next() orelse return null; // r1: no token
    if (tok.len == 0) return null; // r1: empty token
    if (it.next() != null) return null; // grammar has no second token

    std.debug.assert(decode_buf.len >= tok.len);
    const mimetype = decodeEscapes(tok, decode_buf[0..tok.len]);

    std.debug.assert(mimetype.len >= 1 and mimetype.len <= tok.len);
    return .{ .mimetype = mimetype };
}

// ---------------------------------------------------------------------------
// §4.7.13 P Card
// ---------------------------------------------------------------------------

/// One parsed P card (§4.7.13 parent card). Grammar: `p-card = "P" *( SP
/// hash ) LF` (line 1290). `parents` borrows the raw wire arg bytes (never
/// decoded -- a hash/checksum position); zero tokens (bare `P\n`) MUST be
/// accepted (line 1293-1295) and yields `parents == null`, `count == 0`.
/// Tokens are neither sorted nor deduplicated -- order is semantic (line
/// 1296). Per-type meaning (primary-ancestor + merge-parents on a
/// check-in, edit-chain link on a technote, ...) is manifest-assembly work.
pub const PCard = struct {
    parents: ?[]const u8,
    count: usize,

    /// Re-tokenize the already-validated raw arg bytes into hashes, in
    /// wire order. Cheap: no allocation, `TokenIterator` borrows.
    pub fn hashes(self: PCard) TokenIterator {
        return .{ .rest = self.parents };
    }
};

/// Parse one already-tokenized P card (§4.7.13). Returns null iff any token
/// fails the hash map (`.artifact_hash`, §4.7.1) -- zero tokens is not a
/// failure (m1).
pub fn parsePCard(card: CardLine) ?PCard {
    std.debug.assert(card.letter == 'P');

    var count: usize = 0;
    var it = card.tokens();
    // Bounded by card.args.len: each next() call strictly shrinks
    // it.rest, so this loop terminates within card.args.len iterations.
    while (it.next()) |tok| {
        if (!validateHashToken(.artifact_hash, tok)) return null; // r1
        count += 1;
    }

    std.debug.assert(card.args != null or count == 0);
    return .{ .parents = card.args, .count = count };
}

// ---------------------------------------------------------------------------
// §4.7.14 Q Card
// ---------------------------------------------------------------------------

/// §4.7.14 q-sign: the byte glued onto the first hash token, distinguishing
/// a cherry-pick from a backout (`q-sign = "+" / "-"`, line 1327).
pub const QSign = enum { cherrypick, backout };

/// One parsed Q card (§4.7.14 cherry-pick card). Grammar: `q-card = "Q" SP
/// q-sign hash [ SP hash ] LF` (line 1325-1326). `target` is the sign-
/// stripped first-token remainder, raw, validated per the hash map;
/// `baseline` is the optional plain unprefixed second token, raw. An
/// absent baseline is left `null`, not resolved to the primary parent
/// (m3, line 1334-1336) -- that resolution is manifest-assembly work.
pub const QCard = struct { sign: QSign, target: []const u8, baseline: ?[]const u8 };

/// Parse one already-tokenized Q card (§4.7.14). Returns null for a
/// missing/third token, a first byte other than '+' or '-' (q-sign, line
/// 1327), or a remainder/baseline failing the hash map.
pub fn parseQCard(card: CardLine) ?QCard {
    std.debug.assert(card.letter == 'Q');

    var it = card.tokens();
    const first = it.next() orelse return null; // r2: token required
    const baseline_tok = it.next(); // optional
    if (it.next() != null) return null; // r2: grammar has no third token

    // token = 1*tchar (Appendix B) guarantees first.len >= 1 on the wire,
    // but an argument-less-looking empty token (`Q \n`) still reaches
    // here as one empty token -- guard before indexing the sign byte.
    if (first.len == 0) return null; // r1
    const sign: QSign = switch (first[0]) {
        '+' => .cherrypick,
        '-' => .backout,
        else => return null, // r1: q-sign is "+" / "-" only
    };
    const target = first[1..];
    if (!validateHashToken(.artifact_hash, target)) return null; // r3

    var baseline: ?[]const u8 = null;
    if (baseline_tok) |raw| {
        if (!validateHashToken(.artifact_hash, raw)) return null; // r3
        baseline = raw;
    }

    std.debug.assert(target.len == 40 or target.len == 64);
    std.debug.assert(baseline == null or baseline.?.len == 40 or baseline.?.len == 64);
    return .{ .sign = sign, .target = target, .baseline = baseline };
}

// ---------------------------------------------------------------------------
// §4.7.15 R Card
// ---------------------------------------------------------------------------

/// One parsed R card (§4.7.15 repository-checksum card). Grammar: `r-card =
/// "R" SP md5-token LF`. `checksum` is raw -- a hash/checksum position,
/// never decoded (§4.7.1). An R card may appear only in a manifest (§5.2)
/// and is never required there -- both deferred to manifest assembly, same
/// posture as A/B/G/M.
pub const RCard = struct { checksum: []const u8 };

/// Parse one already-tokenized R card (§4.7.15). Returns null for bad arity
/// or a checksum failing the hash map (`.checksum`, §4.7.1: exactly 32
/// case-insensitive hex).
pub fn parseRCard(card: CardLine) ?RCard {
    std.debug.assert(card.letter == 'R');

    var it = card.tokens();
    const tok = it.next() orelse return null; // p1: checksum required
    if (it.next() != null) return null; // r1: grammar has no second token

    if (!validateHashToken(.checksum, tok)) return null;

    std.debug.assert(tok.len == 32);
    return .{ .checksum = tok };
}

// ---------------------------------------------------------------------------
// §4.7.16 T Card
// ---------------------------------------------------------------------------

/// §4.7.16 tag-sign: the byte glued onto the tagname, selecting the tag
/// operation (`tag-sign = "+" / "-" / "*"`). `+` adds, `-` cancels, `*`
/// propagates to descendants -- crosslink semantics deferred (§5.4).
pub const TSign = enum { add, cancel, propagate };

/// One parsed T card (§4.7.16 tag card). Grammar: `t-card = "T" SP tag-sign
/// tagname SP ( hash / "*" ) [ SP value ] LF` (lines 1360-1362). `name` is
/// the escape-decoded, sign-stripped tagname remainder; `target` is the raw,
/// never-decoded hash, or null for the literal `*` self-referential tag
/// (p2); `value` is escape-decoded and null iff absent -- an absent value is
/// null (m1), unlike the J card's empty-string default. Repeatable; the
/// two-level-ascending-by-(name,target) run check (§4.5.2) is manifest-
/// assembly work, already tested there.
pub const TCard = struct {
    sign: TSign,
    name: []const u8,
    target: ?[]const u8,
    value: ?[]const u8,
};

/// `s` is the tagname after the sign byte is stripped (`tagname = tag-sign
/// token`, App. B) -- non-empty per grammar, and MUST NOT consist entirely
/// of hexadecimal digits at any length (§4.7.16-r2), so a tag name stays
/// distinguishable from a hash prefix. Case-insensitive per the hexdig-ci
/// production a hash prefix itself uses (§3.6).
fn isAllHexCi(s: []const u8) bool {
    std.debug.assert(s.len >= 1);
    for (s) |c| {
        if (!isHexCi(c)) return false;
    }
    return true;
}

/// Parse one already-tokenized T card (§4.7.16). Returns null for bad arity
/// (r4), a decoded name that is empty or not `+`/`-`/`*`-prefixed (r1), a
/// remainder that is entirely hex digits (r2), or a target failing `*` / the
/// hash map (r3).
/// Precondition: `decode_buf.len >= name_tok.len + (value_tok orelse "").len`.
pub fn parseTCard(card: CardLine, decode_buf: []u8) ?TCard {
    std.debug.assert(card.letter == 'T');

    var it = card.tokens();
    const name_tok = it.next() orelse return null; // p1: name required
    const target_tok = it.next() orelse return null; // p1: target required
    const value_tok = it.next(); // optional
    if (it.next() != null) return null; // r4: grammar has no fourth token

    std.debug.assert(decode_buf.len >= name_tok.len);
    const name_decoded = decodeEscapes(name_tok, decode_buf[0..name_tok.len]);
    if (name_decoded.len == 0) return null; // r1: needs at least the sign byte

    const sign: TSign = switch (name_decoded[0]) {
        '+' => .add,
        '-' => .cancel,
        '*' => .propagate,
        else => return null, // r1: tag-sign is "+" / "-" / "*" only
    };
    const remainder = name_decoded[1..];
    if (remainder.len == 0) return null; // tagname = tag-sign token, token = 1*tchar
    if (isAllHexCi(remainder)) return null; // r2: stays distinguishable from a hash prefix

    // m3: target is never decoded -- "*" here means the literal wire byte.
    var target: ?[]const u8 = null;
    if (!std.mem.eql(u8, target_tok, "*")) {
        if (!validateHashToken(.artifact_hash, target_tok)) return null; // r3
        target = target_tok;
    }

    var value: ?[]const u8 = null; // m1: absent value is null, not ""
    if (value_tok) |raw| {
        std.debug.assert(decode_buf.len >= name_tok.len + raw.len);
        value = decodeEscapes(raw, decode_buf[name_tok.len..][0..raw.len]); // m2
    }

    std.debug.assert(remainder.len >= 1);
    std.debug.assert(target == null or target.?.len == 40 or target.?.len == 64);
    return .{ .sign = sign, .name = remainder, .target = target, .value = value };
}

// ---------------------------------------------------------------------------
// §4.7.17 U Card
// ---------------------------------------------------------------------------

/// Anonymous-login substitute (§4.7.17, lines 1416-1419): a bare `U` line or
/// a `U` followed by an empty token both yield this literal, never null or
/// "".
const ANONYMOUS_LOGIN = "anonymous";

/// One parsed U card (§4.7.17 user card). Grammar: `u-card = "U" [ SP
/// [ login ] ] LF`. `login` is the escape-decoded login token, or
/// `ANONYMOUS_LOGIN` when the token is absent or empty (m1) -- this card's
/// grammar takes precedence over the general non-empty-token rule other
/// single-token cards enforce (m2, contrast `parseNCard`). Single-occurrence;
/// required on a wiki artifact (§5.2/App. C) -- deferred to manifest
/// assembly.
pub const UCard = struct { login: []const u8 };

/// Parse one already-tokenized U card (§4.7.17). Returns null only for a
/// second token -- the grammar has room for exactly one, possibly-empty,
/// possibly-absent login.
/// Precondition: `decode_buf.len >= login token length (0 if absent/empty)`.
pub fn parseUCard(card: CardLine, decode_buf: []u8) ?UCard {
    std.debug.assert(card.letter == 'U');

    var it = card.tokens();
    const login_tok = it.next(); // optional -- bare `U\n` has args == null
    if (it.next() != null) return null; // r1: grammar has no second token

    const login: []const u8 = if (login_tok) |tok| blk: {
        if (tok.len == 0) break :blk ANONYMOUS_LOGIN; // m1: empty token
        std.debug.assert(decode_buf.len >= tok.len);
        break :blk decodeEscapes(tok, decode_buf[0..tok.len]);
    } else ANONYMOUS_LOGIN; // m1: bare U line

    std.debug.assert(login.len >= 1);
    return .{ .login = login };
}

// ---------------------------------------------------------------------------
// §4.7.18 W Card
// ---------------------------------------------------------------------------

/// One parsed W card (§4.7.18 body card). `body` borrows the raw, opaque
/// payload -- located by byte count, MAY contain 0x0A, never line-split
/// (lines 1438-1440). `next` is the offset one past the body's
/// terminating LF, where the card loop resumes.
pub const WCard = struct { body: []const u8, next: usize };

/// Parse one W card starting at `artifact[start]` (the 'W' letter),
/// byte-counted rather than tokenized -- the body may embed LF and so
/// cannot flow through `tokenizeCardLine` (§4.7.18, l.422-433). Returns
/// null on any rejection: bad grammar, digit-accumulator overflow (r1), a
/// declared size exceeding the remaining artifact (r2), a non-LF byte
/// after the body (r3), or a terminator LF that is the artifact's final
/// byte (r4).
/// Precondition: `start < artifact.len` and `artifact[start] == 'W'` (the
/// card loop dispatches by letter).
/// Postcondition on success: `body.len` equals the parsed size and
/// `next < artifact.len`.
pub fn parseWCard(artifact: []const u8, start: usize) ?WCard {
    std.debug.assert(start < artifact.len);
    std.debug.assert(artifact[start] == 'W');

    // w-card = %x57 SP size LF body LF (l.1429).
    if (start + 1 >= artifact.len) return null;
    if (artifact[start + 1] != ' ') return null;

    // Locate the size-line terminator. Bounded by artifact.len.
    var size_lf: usize = start + 2;
    while (size_lf < artifact.len and artifact[size_lf] != '\n') : (size_lf += 1) {}
    if (size_lf >= artifact.len) return null; // no LF before end

    // p2/r1: accumulate leading decimal digits only, stop at the first
    // non-digit byte, never re-check the remainder (w1: "12x" accepted;
    // w2: no leading digit parses as zero). Overflow MUST reject (r1).
    // Loop bounded by the size-line slice, itself bounded by artifact.len.
    var size: usize = 0;
    for (artifact[start + 2 .. size_lf]) |c| {
        if (c < '0' or c > '9') break;
        const mul = @mulWithOverflow(size, 10);
        if (mul[1] != 0) return null;
        const add = @addWithOverflow(mul[0], c - '0');
        if (add[1] != 0) return null;
        size = add[0];
    }

    const body_start = size_lf + 1;
    std.debug.assert(body_start <= artifact.len);

    // r2: bound the declared size against what remains before consuming.
    const remaining = artifact.len - body_start;
    if (size > remaining) return null;
    const body_end = body_start + size;
    std.debug.assert(body_end <= artifact.len);

    // r4: the position one past the terminator LF must stay addressable.
    if (body_end + 1 >= artifact.len) return null;
    // r3: the byte right after the counted body must be LF. m1: the body
    // itself is never scanned -- it is sliced by offset, not searched.
    if (artifact[body_end] != '\n') return null;

    const body = artifact[body_start..body_end];
    const next = body_end + 1;
    std.debug.assert(body.len == size);
    std.debug.assert(next < artifact.len);
    return .{ .body = body, .next = next };
}

// ---------------------------------------------------------------------------
// §4.7.19 Z Card
// ---------------------------------------------------------------------------

/// One parsed Z card (§4.7.19 self-checksum card). `checksum` is the raw
/// md5-token, never decoded (§6.1). The "structurally last, final 35
/// bytes" property (m1) is enforced by the §6.2 positional framing
/// (`verifyZCard`), not here -- this validates only token syntax.
pub const ZCard = struct { checksum: []const u8 };

/// Parse one already-tokenized Z card (§4.7.19). Returns null for bad
/// arity (r2: zero or a second token) or a token failing the 32-hex-ci
/// checksum rule (r1, §6.1).
pub fn parseZCard(card: CardLine) ?ZCard {
    std.debug.assert(card.letter == 'Z');

    var it = card.tokens();
    const tok = it.next() orelse return null; // r2: token required
    if (it.next() != null) return null; // r2: grammar has no second token
    if (!validateHashToken(.checksum, tok)) return null; // r1: 32hexdig-ci

    std.debug.assert(tok.len == 32);
    return .{ .checksum = tok };
}

// ---------------------------------------------------------------------------
// §5 Structural Artifact Types
// ---------------------------------------------------------------------------

/// Card-presence census over one artifact's cards. Built by the (later)
/// single-pass parser; classification and validity below consume this
/// summary instead of re-scanning raw card bytes.
pub const Census = struct {
    /// Bit (letter - 'A') set iff at least one card of that letter was
    /// seen in the artifact.
    letters: u32 = 0,
    /// Count of hash tokens recorded across all P cards. Only "zero / one
    /// / more than one" is ever distinguished downstream (§5.4-r5), so a
    /// future producer may saturate rather than track the exact count.
    p_parents: u8 = 0,
    /// At least one T card targets the literal `*` -- self-referential
    /// (§4.7.16 p2; consumed by §5.4 checks 1 and 2).
    t_self_ref: bool = false,
    /// At least one T card targets a hash, not `*` (§5.4 check 2).
    t_not_self_ref: bool = false,
    /// At least one T card's sign is not `+` (§4.7.16 tag-sign; §5.4
    /// check 2).
    t_not_plus: bool = false,

    /// Whether a card of `letter` was recorded in this census.
    pub fn has(self: Census, letter: u8) bool {
        std.debug.assert(letter >= 'A' and letter <= 'Z');
        return (self.letters & (@as(u32, 1) << @intCast(letter - 'A'))) != 0;
    }
};

/// The thirteen type-setting letters (§5.3 Table 5), ascending. Card
/// order on the wire is non-decreasing (§4.4), so folding this array in
/// ascending order and unconditionally overwriting on each match
/// reproduces "the alphabetically latest present type-setting card wins"
/// (r1) without needing the actual card sequence.
const type_setting_letters = [_]u8{ 'A', 'B', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'Q', 'R' };

/// §5.3 Table 5: the type one type-setting letter assigns.
fn typeSettingType(letter: u8) ArtifactType {
    return switch (letter) {
        'A' => .attachment,
        'B', 'F', 'Q', 'R' => .manifest,
        'E' => .event,
        'G', 'H', 'I' => .forum,
        'J', 'K' => .ticket,
        'L' => .wiki,
        'M' => .cluster,
        else => unreachable, // caller only passes type_setting_letters
    };
}

/// §5.3: determine an artifact's type from its card census. The eight
/// remaining letters (C, D, N, P, T, U, W, Z) never set the type (m1); if
/// no type-setting letter is present, fall back per p2. Total: always
/// returns a concrete code-1..8 type, never the code-0 "undetermined"
/// sentinel (§5.1-m1) -- that sentinel is modelled only as the absence of
/// an input census entry, never as this function's output.
pub fn determineType(c: Census) ArtifactType {
    var result: ?ArtifactType = null;
    // Bounded: exactly type_setting_letters.len iterations.
    for (type_setting_letters) |letter| {
        if (c.has(letter)) result = typeSettingType(letter);
    }
    const t = result orelse fallback(c);
    std.debug.assert(@intFromEnum(t) >= 1 and @intFromEnum(t) <= 8);
    return t;
}

/// §5.3 p2 fallback, reached only when no type-setting letter was seen:
/// MANIFEST if a C card was present or P-card parents were recorded,
/// CONTROL otherwise. The RFC's third fallback disjunct ("F card file
/// entries recorded") is unreachable through this path -- F is itself
/// type-setting, so an artifact carrying F entries already resolved to
/// MANIFEST in the loop above and never reaches fallback.
fn fallback(c: Census) ArtifactType {
    // Precondition (negative space): no type-setting letter present.
    for (type_setting_letters) |letter| std.debug.assert(!c.has(letter));
    return if (c.has('C') or c.p_parents >= 1) .manifest else .control;
}

/// Comptime helper: OR together the per-letter bits of a Table 4 letter
/// list into one mask.
fn maskOfLetters(comptime letters: []const u8) u32 {
    var m: u32 = 0;
    for (letters) |l| m |= @as(u32, 1) << @intCast(l - 'A');
    return m;
}

/// §5.2 Table 4 "Allowed cards" column, one bit per letter per type.
const allowed_mask = std.EnumArray(ArtifactType, u32).init(.{
    .manifest = maskOfLetters("BCDFNPQRTUZ"),
    .cluster = maskOfLetters("MZ"),
    .control = maskOfLetters("DTUZ"),
    .wiki = maskOfLetters("CDLNPUWZ"),
    .ticket = maskOfLetters("DJKUZ"),
    .attachment = maskOfLetters("ACDNUZ"),
    .event = maskOfLetters("CDENPTUWZ"),
    .forum = maskOfLetters("DGHINPUWZ"),
});

/// §5.2 Table 4 "Required cards" column, one bit per letter per type.
const required_mask = std.EnumArray(ArtifactType, u32).init(.{
    .manifest = maskOfLetters("DZ"),
    .cluster = maskOfLetters("MZ"),
    .control = maskOfLetters("DTUZ"),
    .wiki = maskOfLetters("DLUWZ"),
    .ticket = maskOfLetters("DJKUZ"),
    .attachment = maskOfLetters("ADZ"),
    .event = maskOfLetters("DEWZ"),
    .forum = maskOfLetters("DUWZ"),
});

comptime {
    // Table 4 invariant: every required card is also allowed, for all
    // eight types -- a transcription guard against the two columns
    // silently disagreeing.
    for (std.enums.values(ArtifactType)) |t| {
        std.debug.assert(required_mask.get(t) & allowed_mask.get(t) == required_mask.get(t));
    }
}

pub const ClassifyError = error{
    /// §5.2-r1: a card letter outside the finalized type's allowed set.
    DisallowedCard,
    /// §5.2-r2: a required card letter is absent.
    MissingRequiredCard,
    /// §5.4 check 1: CONTROL with a self-referential T card.
    ControlSelfTag,
    /// §5.4 check 2: EVENT with a T card that does not target itself.
    EventTagNotSelfRef,
    /// §5.4 check 2: EVENT with a T card lacking the `+` prefix.
    EventTagNotPlus,
    /// §5.4 check 3: FORUM with both an H and an I card.
    ForumHeadAndReply,
    /// §5.4 check 4: FORUM whose P card lists more than one parent.
    ForumMultiParent,
};

/// §5.2: reject any card letter outside `t`'s allowed set, or any missing
/// required letter.
fn checkLegality(t: ArtifactType, c: Census) ClassifyError!void {
    const allowed = allowed_mask.get(t);
    const required = required_mask.get(t);
    if (c.letters & ~allowed != 0) return error.DisallowedCard; // r1
    if (c.letters & required != required) return error.MissingRequiredCard; // r2
}

/// §5.4: the four per-type semantic checks beyond card legality.
fn checkSemantics(t: ArtifactType, c: Census) ClassifyError!void {
    switch (t) {
        .control => {
            if (c.t_self_ref) return error.ControlSelfTag; // check 1
        },
        .event => {
            if (c.t_not_self_ref) return error.EventTagNotSelfRef; // check 2
            if (c.t_not_plus) return error.EventTagNotPlus; // check 2
        },
        .forum => {
            if (c.has('H') and c.has('I')) return error.ForumHeadAndReply; // check 3
            if (c.p_parents > 1) return error.ForumMultiParent; // check 4
        },
        else => {},
    }
}

/// §5 deep entry point: finalize the type (§5.3), then card legality
/// (§5.2), then per-type semantics (§5.4) -- strictly in that order, per
/// §5.2-m1 and §5.3-m2.
pub fn classify(c: Census) ClassifyError!ArtifactType {
    const t = determineType(c);
    try checkLegality(t, c);
    try checkSemantics(t, c);
    std.debug.assert(@intFromEnum(t) >= 1 and @intFromEnum(t) <= 8);
    return t;
}

// ---------------------------------------------------------------------------
// §4/§5 Manifest Assembly
// ---------------------------------------------------------------------------

/// A fully-decoded artifact (the Deck shape, per the operator-approved
/// fsl_deck/go-libfossil design): one field per card type, holding every
/// card's decoded data, not just the type verdict. `arena_state` OWNS every
/// slice below except the raw wire-borrows (hashes/timestamps/W body),
/// which point into the caller's `bytes` -- the caller must keep `bytes`
/// alive for the Manifest's lifetime and must call `deinit` exactly once.
pub const Manifest = struct {
    arena_state: std.heap.ArenaAllocator,

    // §5.1-m1: a successful parse always finalizes a code-1..8 type --
    // "not yet determined" never survives to here, it is modelled as
    // `parse` returning an error, not as a null field on this struct.
    kind: ArtifactType,

    // §4.5.1 single-occurrence cards: null iff the letter was absent.
    a: ?ACard,
    b: ?BCard,
    c: ?CCard,
    d: ?DCard,
    e: ?ECard,
    g: ?GCard,
    h: ?HCard,
    i: ?ICard,
    k: ?KCard,
    l: ?LCard,
    n: ?NCard,
    r: ?RCard,
    u: ?UCard,
    z: ?ZCard,
    /// W's body only -- `WCard.next` is a parse-loop cursor, not artifact
    /// data, so it never belongs on the retained Manifest.
    w: ?[]const u8,

    // §4.5.2 repeatable cards: arena slice in wire order; empty iff absent.
    f: []const FCard,
    j: []const JCard,
    m: []const MCard,
    p: []const PCard,
    q: []const QCard,
    t: []const TCard,

    /// Frees every decoded slice this Manifest owns. Raw fields borrow the
    /// caller's input bytes, unaffected -- the caller owns those.
    pub fn deinit(self: *Manifest) void {
        self.arena_state.deinit();
    }
};

/// Growing accumulator threaded alongside `RunState` through the card loop,
/// one lane per card type, mirroring `Manifest`'s shape. Kept separate from
/// `RunState` -- this refactor adds a retention lane, it does not rewire
/// the §4.4/§4.5 gate `RunState` already enforces. All storage lives on the
/// caller's arena.
const ManifestBuilder = struct {
    a: ?ACard = null,
    b: ?BCard = null,
    c: ?CCard = null,
    d: ?DCard = null,
    e: ?ECard = null,
    g: ?GCard = null,
    h: ?HCard = null,
    i: ?ICard = null,
    k: ?KCard = null,
    l: ?LCard = null,
    n: ?NCard = null,
    r: ?RCard = null,
    u: ?UCard = null,
    z: ?ZCard = null,
    w: ?[]const u8 = null,
    f: std.ArrayList(FCard) = .empty,
    j: std.ArrayList(JCard) = .empty,
    m: std.ArrayList(MCard) = .empty,
    p: std.ArrayList(PCard) = .empty,
    q: std.ArrayList(QCard) = .empty,
    t: std.ArrayList(TCard) = .empty,

    /// Finalize the six repeatable lists into arena slices and hand back a
    /// Manifest that owns `arena_state` (moved in by value -- safe: every
    /// decoded slice above points into a heap buffer node the arena holds,
    /// never into this struct itself). `kind` is `classify`'s output; the
    /// retention<->validation invariant against `RunState.census` is the
    /// caller's job (`parse`), since `census` lives there, not here.
    fn finish(self: *ManifestBuilder, arena_state: *std.heap.ArenaAllocator, kind: ArtifactType) std.mem.Allocator.Error!Manifest {
        const arena = arena_state.allocator();
        // Finalize every owned slice BEFORE copying the arena by value. A
        // toOwnedSlice can append a node to the arena's used_list, and Zig
        // evaluates struct-literal fields in source order; copying arena_state
        // ahead of these would capture a stale node list and leak whatever a
        // later slice allocates. Locals force all arena growth to happen first.
        const f = try self.f.toOwnedSlice(arena);
        const j = try self.j.toOwnedSlice(arena);
        const m = try self.m.toOwnedSlice(arena);
        const p = try self.p.toOwnedSlice(arena);
        const q = try self.q.toOwnedSlice(arena);
        const t = try self.t.toOwnedSlice(arena);
        return .{
            .arena_state = arena_state.*,
            .kind = kind,
            .a = self.a,
            .b = self.b,
            .c = self.c,
            .d = self.d,
            .e = self.e,
            .g = self.g,
            .h = self.h,
            .i = self.i,
            .k = self.k,
            .l = self.l,
            .n = self.n,
            .r = self.r,
            .u = self.u,
            .z = self.z,
            .w = self.w,
            .f = f,
            .j = j,
            .m = m,
            .p = p,
            .q = q,
            .t = t,
        };
    }
};

/// §4's per-card/per-run rejections all collapse to `SyntaxError`, matching
/// the module's standing "every rejection reason collapses to null/false"
/// posture; §5's `classify` rejections keep their distinct variants, giving
/// per-clause coverage for §5.2/§5.4 without inventing a parallel taxonomy.
/// `OutOfMemory` surfaces the arena's allocation failures (§4.3 decode
/// scratch) -- closed set, no `anyerror` across this boundary.
pub const ParseError = error{ SyntaxError, OutOfMemory } || ClassifyError;

/// Mutable state threaded through one card loop run: the §4.4 order
/// tracker, §4.5.1 occurrence guard, §4.5.2/§4.6.3 F-run admission, the
/// two remaining §4.5.2 intra-run anchors (J, M single-key; T two-level),
/// and the growing §5 census the loop folds every card into.
const RunState = struct {
    order: OrderState = .{},
    occ: SingleOccurrenceSet = .{},
    fadmit: FCardAdmission = .{},
    prev_j: ?[]const u8 = null,
    prev_m: ?[]const u8 = null,
    prev_t: ?struct { name: []const u8, target: []const u8 } = null,
    census: Census = .{},
};

/// Mark `letter` present in the running census (§5 card-presence bit).
fn censusMark(rs: *RunState, letter: u8) void {
    std.debug.assert(isCardLetter(letter));
    rs.census.letters |= @as(u32, 1) << @intCast(letter - 'A');
}

/// §4.5.2 single-key ascending check shared by the F/J/M runs' J and M
/// halves (F has its own admission type, `FCardAdmission`, since it also
/// carries the §4.6.3 reserved-name drop). Rejects iff `key` does not
/// strictly exceed the previous key in the same run.
fn admitAscending(prev: *?[]const u8, key: []const u8) ParseError!void {
    if (prev.*) |p| {
        if (compareBytes(p, key) != .lt) return error.SyntaxError;
    }
    prev.* = key;
}

/// True for the nine card letters whose §4.7.x parser decodes at least one
/// field via `decodeEscapes` -- these need a scratch buffer; the other
/// twelve tokenized letters carry only raw (hash/checksum/timestamp/sign)
/// positions and need none (§4.3.1).
fn letterNeedsDecodeBuf(letter: u8) bool {
    return switch (letter) {
        'A', 'C', 'F', 'H', 'J', 'L', 'N', 'T', 'U' => true,
        else => false,
    };
}

/// §4.5.2 T two-level ascending check plus the §5.4 census flags a T card
/// contributes (self-ref vs. targeted, sign != '+'). `target` folds the
/// self-ref sentinel to the literal wire byte `*` (ledger §4.5.2 reading
/// #1) so the sort key is always a concrete byte string.
fn admitTCard(arena: std.mem.Allocator, card: CardLine, decode_buf: []u8, rs: *RunState, mb: *ManifestBuilder) ParseError!void {
    const tc = parseTCard(card, decode_buf) orelse return error.SyntaxError;
    const target = tc.target orelse "*";
    if (rs.prev_t) |prev| {
        if (compareTag(prev.name, prev.target, tc.name, target) != .lt) return error.SyntaxError;
    }
    rs.prev_t = .{ .name = tc.name, .target = target };
    if (tc.target == null) rs.census.t_self_ref = true else rs.census.t_not_self_ref = true;
    if (tc.sign != .add) rs.census.t_not_plus = true;
    try mb.t.append(arena, tc);
}

/// Dispatch one already-tokenized non-W card to its §4.7.x parser, run the
/// §4.5.2 intra-run check where the letter has one, and mark it present in
/// `rs.census`. `decode_buf` is a fresh arena slice sized to the card's raw
/// arg bytes -- always sufficient, since no parser's decoded fields exceed
/// the raw bytes they are decoded from.
fn admitTokenized(arena: std.mem.Allocator, card: CardLine, decode_buf: []u8, rs: *RunState, mb: *ManifestBuilder) ParseError!void {
    switch (card.letter) {
        'A' => mb.a = parseACard(card, decode_buf) orelse return error.SyntaxError,
        'B' => mb.b = parseBCard(card) orelse return error.SyntaxError,
        'C' => mb.c = parseCCard(card, decode_buf) orelse return error.SyntaxError,
        'D' => mb.d = parseDCard(card) orelse return error.SyntaxError,
        'E' => mb.e = parseECard(card) orelse return error.SyntaxError,
        'F' => {
            const fc = parseFCard(card, decode_buf) orelse return error.SyntaxError;
            _ = rs.fadmit.admit(fc.filename) catch return error.SyntaxError; // §4.5.2/§4.6.3
            try mb.f.append(arena, fc); // retained regardless of admit's drop verdict
        },
        'G' => mb.g = parseGCard(card) orelse return error.SyntaxError,
        'H' => mb.h = parseHCard(card, decode_buf) orelse return error.SyntaxError,
        'I' => mb.i = parseICard(card) orelse return error.SyntaxError,
        'J' => {
            const jc = parseJCard(card, decode_buf) orelse return error.SyntaxError;
            try admitAscending(&rs.prev_j, jc.field);
            try mb.j.append(arena, jc);
        },
        'K' => mb.k = parseKCard(card) orelse return error.SyntaxError,
        'L' => mb.l = parseLCard(card, decode_buf) orelse return error.SyntaxError,
        'M' => {
            const mc = parseMCard(card) orelse return error.SyntaxError;
            try admitAscending(&rs.prev_m, mc.hash);
            try mb.m.append(arena, mc);
        },
        'N' => mb.n = parseNCard(card, decode_buf) orelse return error.SyntaxError,
        'P' => {
            const pc = parsePCard(card) orelse return error.SyntaxError;
            const sum = @as(usize, rs.census.p_parents) + pc.count;
            rs.census.p_parents = @intCast(@min(sum, 2)); // §5.4-r5: only 0/1/>1 matter
            try mb.p.append(arena, pc);
        },
        'Q' => {
            const qc = parseQCard(card) orelse return error.SyntaxError;
            try mb.q.append(arena, qc);
        },
        'R' => mb.r = parseRCard(card) orelse return error.SyntaxError,
        'T' => try admitTCard(arena, card, decode_buf, rs, mb),
        'U' => mb.u = parseUCard(card, decode_buf) orelse return error.SyntaxError,
        'Z' => mb.z = parseZCard(card) orelse return error.SyntaxError,
        else => unreachable, // caller excludes 'W'; tokenizeCardLine admits only the 21 letters
    }
    censusMark(rs, card.letter);
}

/// Parse and admit exactly one card at `content[pos]`: frame the card
/// region (byte-count for W, LF-scan + tokenize otherwise), apply the
/// §4.4 order gate and §4.5.1 occurrence gate, then (non-W) dispatch to
/// the §4.7.x grammar and §4.5.2 intra-run check. Returns the position
/// immediately after the card. Bound: the shortest legal card is `X\n` (2
/// bytes), so the return value always exceeds `pos`.
fn admitCard(arena: std.mem.Allocator, content: []const u8, pos: usize, rs: *RunState, mb: *ManifestBuilder) ParseError!usize {
    std.debug.assert(pos < content.len);
    const letter = content[pos];

    if (letter == 'W') {
        const w = parseWCard(content, pos) orelse return error.SyntaxError;
        if (!rs.order.admit(letter)) return error.SyntaxError; // §4.4
        if (!rs.occ.admit(letter)) return error.SyntaxError; // §4.5.1
        censusMark(rs, letter);
        mb.w = w.body;
        std.debug.assert(w.next > pos);
        return w.next;
    }

    const lf = std.mem.indexOfScalarPos(u8, content, pos, '\n') orelse return error.SyntaxError;
    const card = tokenizeCardLine(content[pos .. lf + 1]) orelse return error.SyntaxError;
    if (!rs.order.admit(letter)) return error.SyntaxError; // §4.4
    if (!rs.occ.admit(letter)) return error.SyntaxError; // §4.5.1

    var decode_buf: []u8 = &.{};
    if (letterNeedsDecodeBuf(letter)) {
        const arg_len = if (card.args) |a| a.len else 0;
        decode_buf = try arena.alloc(u8, arg_len); // may propagate OutOfMemory
    }
    try admitTokenized(arena, card, decode_buf, rs, mb);

    std.debug.assert(lf + 1 > pos);
    return lf + 1;
}

/// Parse a structural artifact from its bytes: §4 framing and card grammar,
/// then §5 classification. `bytes` is the raw (possibly signature-wrapped)
/// artifact; the full pipeline is `frame` -> §5.5 pre-filter -> card loop ->
/// `classify`. Returns a fully-populated, data-carrying `Manifest` (every
/// decoded card retained, not just the type verdict) or a `ParseError` --
/// never a partial result. The returned Manifest owns an arena; the caller
/// must call `manifest.deinit()` exactly once and must keep `bytes` alive
/// for the Manifest's lifetime (raw fields borrow it, zero-copy).
pub fn parse(allocator: std.mem.Allocator, bytes: []const u8) ParseError!Manifest {
    const content = frame(bytes) orelse return error.SyntaxError; // §4.1/§6.2
    std.debug.assert(content.len >= 10); // frame's gate-3 postcondition

    // §5.5 well-formedness pre-filter: every type's smallest required
    // letter is <= 'M' (CLUSTER's own required M), and card order is
    // non-decreasing, so the artifact's first letter can never exceed the
    // smallest required letter of a type it could possibly satisfy. A
    // first letter above 'M' fails the same way the full parse eventually
    // would (§4.1.2) -- this only fast-paths, it never diverges on
    // accept/reject.
    if (content[0] > 'M') return error.SyntaxError;
    std.debug.assert(content[0] >= 'A' and content[0] <= 'M');

    // §4.4 ownership flip: the arena is freed on the error path only. On
    // success it is moved by value into the returned Manifest (via
    // `mb.finish`) -- the caller owns it from there and calls `deinit`.
    var arena_state = std.heap.ArenaAllocator.init(allocator);
    errdefer arena_state.deinit();
    const arena = arena_state.allocator();

    var rs: RunState = .{};
    var mb: ManifestBuilder = .{};
    var pos: usize = 0;
    const LoopState = enum { parse_card, done };
    state: switch (LoopState.parse_card) {
        .parse_card => {
            if (pos >= content.len) continue :state .done;
            const next = try admitCard(arena, content, pos, &rs, &mb);
            std.debug.assert(next > pos); // strict progress bound
            pos = next;
            continue :state .parse_card;
        },
        .done => {},
    }
    std.debug.assert(pos == content.len); // §4.1: unconsumed bytes never happen here

    const kind = try classify(rs.census); // §5.3 -> §5.2 -> §5.4
    const manifest = try mb.finish(&arena_state, kind);
    assertRetentionMatchesCensus(manifest, rs.census, content.len);
    return manifest;
}

/// Retention<->validation invariant (positive + negative space): every
/// retained field's presence must exactly match the census bit the
/// (untouched) validation lane already recorded, and every repeatable
/// list's length stays within the card loop's own progress bound (`next >
/// pos`, so the card count -- and each list's length -- can never exceed
/// `content_len`). Catches any retain/verdict divergence this batch's
/// refactor could introduce; never mutates.
fn assertRetentionMatchesCensus(m: Manifest, census: Census, content_len: usize) void {
    std.debug.assert((m.a != null) == census.has('A'));
    std.debug.assert((m.b != null) == census.has('B'));
    std.debug.assert((m.c != null) == census.has('C'));
    std.debug.assert((m.d != null) == census.has('D'));
    std.debug.assert((m.e != null) == census.has('E'));
    std.debug.assert((m.g != null) == census.has('G'));
    std.debug.assert((m.h != null) == census.has('H'));
    std.debug.assert((m.i != null) == census.has('I'));
    std.debug.assert((m.k != null) == census.has('K'));
    std.debug.assert((m.l != null) == census.has('L'));
    std.debug.assert((m.n != null) == census.has('N'));
    std.debug.assert((m.r != null) == census.has('R'));
    std.debug.assert((m.u != null) == census.has('U'));
    std.debug.assert((m.w != null) == census.has('W'));
    std.debug.assert((m.z != null) == census.has('Z'));
    std.debug.assert((m.f.len > 0) == census.has('F'));
    std.debug.assert((m.j.len > 0) == census.has('J'));
    std.debug.assert((m.m.len > 0) == census.has('M'));
    std.debug.assert((m.p.len > 0) == census.has('P'));
    std.debug.assert((m.q.len > 0) == census.has('Q'));
    std.debug.assert((m.t.len > 0) == census.has('T'));

    std.debug.assert(m.f.len <= content_len);
    std.debug.assert(m.j.len <= content_len);
    std.debug.assert(m.m.len <= content_len);
    std.debug.assert(m.p.len <= content_len);
    std.debug.assert(m.q.len <= content_len);
    std.debug.assert(m.t.len <= content_len);
}

// ---------------------------------------------------------------------------
// §8 Delta Manifest Reconstruction
// ---------------------------------------------------------------------------

/// The minimal view §8 reconstruction reads from a manifest: its B-card hash
/// (null iff baseline manifest, §8-p1) and its own F cards, sorted by
/// decoded filename (§8.1 parse-time invariant -- every successfully parsed
/// manifest satisfies it). Manifest assembly (a later batch) will produce
/// this view; §8 depends on nothing else about the manifest.
pub const ManifestFiles = struct {
    baseline_hash: ?[]const u8,
    files: []const FCard,
};

/// §8-p1: "A manifest is a delta manifest if and only if it contains a B
/// card; otherwise it is a baseline manifest."
pub fn isDelta(m: ManifestFiles) bool {
    return m.baseline_hash != null;
}

/// Injected seam resolving a B-card hash to the baseline artifact, viewed
/// as `ManifestFiles` (§8.3). No store or IO exists at this layer -- the
/// caller supplies resolution. `null` means the baseline could not be
/// located, or the located artifact is not a manifest at all; both collapse
/// to `BaselineUnresolvable` (§8.3-m1), the caller cannot tell them apart
/// and doesn't need to. A resolved *delta* manifest MUST be returned as-is
/// (its `baseline_hash` left non-null, never collapsed to null) so
/// `enumerate`/`lookup` can reject the delta-of-delta case at resolution
/// time (§4.7.3). The returned slice must outlive the walk -- caller-owned.
pub const BaselineResolver = struct {
    ctx: *anyopaque,
    resolveFn: *const fn (ctx: *anyopaque, hash: []const u8) ?ManifestFiles,

    fn resolve(self: BaselineResolver, hash: []const u8) ?ManifestFiles {
        return self.resolveFn(self.ctx, hash);
    }
};

/// §8.3 hard-failure surface for both enumeration entry points (full walk,
/// §8.2/§8.3, and single-name lookup, §8.4). `BaselineUnresolvable`
/// collapses "not located" and "not a manifest" into one fatal outcome
/// (§8.3-m1). `BaselineIsDelta` stays distinct: it fires only after a
/// *successful* resolve, when the resolved baseline itself carries a B
/// card -- the delta-of-delta rejection (§4.7.3).
pub const BaselineError = error{ BaselineUnresolvable, BaselineIsDelta };

/// §8.1 precondition: `files` strictly ascending by filename under the
/// canonical comparator (§4.5.3). A parse-time invariant of every
/// successfully parsed manifest -- asserted here, not re-validated.
fn assertSorted(files: []const FCard) void {
    if (files.len > 1) {
        for (1..files.len) |i| {
            std.debug.assert(compareBytes(files[i - 1].filename, files[i].filename) == .lt);
        }
    }
}

/// §8.1-r1 / §4.7.6 negative-space companion: a baseline manifest's F cards
/// never carry a delete marker (every hash is present). Sound only where
/// `files` is known to come from a genuine baseline -- `enumerate`/`lookup`
/// reject a resolved delta baseline before this ever runs on it.
fn assertNoDeleteMarkers(files: []const FCard) void {
    for (files) |f| std.debug.assert(f.hash != null);
}

/// §8.2 engine: allocation-free filename-ordered merge-walk over a
/// baseline's and a delta's F-card lists (both pre-sorted per §8.1-p1).
/// `next()` yields each emitted card verbatim, including an unmatched
/// delete marker (§8.2-w1) and any tail delete markers (§8.2-m1); it
/// suppresses only the matched-delete case (step 2, §8.2-r2).
pub const MergeWalk = struct {
    baseline: []const FCard,
    delta: []const FCard,
    bi: usize = 0,
    di: usize = 0,

    /// §8.2 step-by-step merge. Every pass through the loop either returns
    /// (an entry was emitted) or advances both cursors (step 2's matched
    /// delete, which emits nothing), so the loop makes monotonic progress;
    /// the step counter plus assert catches a broken invariant rather than
    /// looping past the bound (§8.2-m1: terminates only once both cursors
    /// are exhausted).
    pub fn next(self: *MergeWalk) ?FCard {
        std.debug.assert(self.bi <= self.baseline.len);
        std.debug.assert(self.di <= self.delta.len);

        const bound = self.baseline.len + self.delta.len + 1;
        var steps: usize = 0;
        while (true) {
            std.debug.assert(steps <= bound);
            steps += 1;

            const baseline_done = self.bi >= self.baseline.len;
            const delta_done = self.di >= self.delta.len;
            if (baseline_done and delta_done) return null; // m1: both exhausted

            if (baseline_done) { // delta tail verbatim, incl. markers (m1)
                const f = self.delta[self.di];
                self.di += 1;
                return f;
            }
            if (delta_done) { // baseline tail verbatim (m1)
                const f = self.baseline[self.bi];
                self.bi += 1;
                return f;
            }

            const order = compareBytes(self.baseline[self.bi].filename, self.delta[self.di].filename);
            switch (order) {
                .lt => { // baseline sorts first (step 3)
                    const f = self.baseline[self.bi];
                    self.bi += 1;
                    return f;
                },
                .gt => { // delta sorts first, incl. unmatched marker (step 3, w1)
                    const f = self.delta[self.di];
                    self.di += 1;
                    return f;
                },
                .eq => {
                    const d = self.delta[self.di];
                    self.bi += 1;
                    self.di += 1;
                    if (d.hash != null) return d; // step 1: delta replaces baseline
                    // step 2: matched delete -- no entry emitted, loop again
                },
            }
        }
    }
};

/// §8.2 + §8.3 hard-failure entry point: resolve the baseline lazily -- only
/// for a delta manifest, only here (§8.3-p1) -- before any iteration begins
/// (§8.3-m2, no silent delta-only degrade), reject a delta baseline
/// (§4.7.3), then hand back the ready walk.
pub fn enumerate(m: ManifestFiles, resolver: BaselineResolver) BaselineError!MergeWalk {
    assertSorted(m.files);

    if (!isDelta(m)) {
        assertNoDeleteMarkers(m.files); // §4.7.6: baseline F cards all carry a hash
        return MergeWalk{ .baseline = m.files, .delta = &.{} };
    }

    const resolved = resolver.resolve(m.baseline_hash.?) orelse return error.BaselineUnresolvable; // §8.3-m1
    if (isDelta(resolved)) return error.BaselineIsDelta; // §4.7.3: delta-of-delta, rejected at resolution
    assertSorted(resolved.files);
    assertNoDeleteMarkers(resolved.files);
    return MergeWalk{ .baseline = resolved.files, .delta = m.files };
}

pub const LookupResult = union(enum) {
    found: FCard,
    not_found,
};

/// §8.4 exact-match scan of one already-sorted F-card list. Bounded by
/// `files.len`; terminates early once the sorted invariant proves no later
/// entry can match (best-match/closest-name lookup is not a zit feature --
/// this is the only search shape, for both delta and baseline, §8.4-m1).
fn scanExact(files: []const FCard, name: []const u8) ?FCard {
    for (files) |f| {
        const order = compareBytes(name, f.filename);
        if (order == .eq) return f;
        if (order == .lt) break; // sorted: nothing further can match
    }
    return null;
}

/// §8.4: resolve one filename against a manifest. The delta's own F cards
/// are searched first (m1). A delta delete marker for the name reports
/// not-found without consulting the baseline (m2). Only when the name is
/// entirely absent from the delta is the baseline resolved and searched
/// (p1), rejecting a resolved delta baseline (§4.7.3) the same as
/// `enumerate`.
pub fn lookup(m: ManifestFiles, name: []const u8, resolver: BaselineResolver) BaselineError!LookupResult {
    assertSorted(m.files);
    if (!isDelta(m)) assertNoDeleteMarkers(m.files); // §4.7.6: baseline carries no delete markers

    if (scanExact(m.files, name)) |f| {
        if (f.hash != null) return .{ .found = f };
        return .not_found; // m2: delete marker, baseline never consulted
    }
    if (!isDelta(m)) return .not_found;

    const resolved = resolver.resolve(m.baseline_hash.?) orelse return error.BaselineUnresolvable; // §8.3-m1
    if (isDelta(resolved)) return error.BaselineIsDelta; // §4.7.3
    assertSorted(resolved.files);
    if (scanExact(resolved.files, name)) |f| {
        std.debug.assert(f.hash != null); // §4.7.6: baseline carries no delete markers
        return .{ .found = f };
    }
    return .not_found;
}

// ---------------------------------------------------------------------------
// §6/§4 Writer: Manifest -> Canonical Bytes
// ---------------------------------------------------------------------------

/// `create`'s sole failure mode -- a `Manifest` is valid by construction
/// (every field was already grammar-checked by `parse`), so the only way
/// serialization can fail is the output allocation itself. Closed set, no
/// `anyerror` across the boundary.
pub const WriteError = error{OutOfMemory};

/// Append escape-encoded `token` (§4.3, the writer-side inverse of
/// `decodeEscapes`). Grows `buf` by exactly the encoded length: reserve the
/// worst case (`2 * token.len`, §4.3's own bound), encode into that tail,
/// then shrink back to what was actually written.
fn appendEscaped(buf: *std.ArrayList(u8), allocator: std.mem.Allocator, token: []const u8) WriteError!void {
    const dst = try buf.addManyAsSlice(allocator, 2 * token.len);
    const written = encodeEscapes(token, dst);
    std.debug.assert(written.len <= dst.len);
    buf.items.len -= (dst.len - written.len);
}

/// Append the minimal decimal ASCII rendering of `n` -- no leading zeros,
/// no sign (§4.7.18 W-card size prefix). Digit extraction mirrors
/// `updateSizeHeader`'s (§6.3.2); kept as its own small function rather than
/// shared, since that one's job is a fixed `SP digits LF` MD5-context
/// header, not a general buffer append.
fn appendMinimalDecimal(buf: *std.ArrayList(u8), allocator: std.mem.Allocator, n: usize) WriteError!void {
    var digits: [max_size_digits]u8 = undefined;
    var n_digits: usize = 0;
    var v = n;
    while (true) {
        std.debug.assert(n_digits < digits.len);
        digits[n_digits] = @as(u8, @intCast(v % 10)) + '0';
        n_digits += 1;
        v /= 10;
        if (v == 0) break;
    }
    std.debug.assert(n_digits >= 1 and n_digits <= digits.len);

    try buf.ensureUnusedCapacity(allocator, n_digits);
    var i: usize = n_digits;
    while (i > 0) {
        i -= 1;
        buf.appendAssumeCapacity(digits[i]); // most-significant digit first
    }
}

/// Append one single-token RAW card: `<letter> <token>\n`. `letter` must be
/// one of the twelve hash/checksum/timestamp/sign positions §4.3.1 never
/// escape-decodes, so the token is never escape-encoded on the way back out.
fn appendRawCard(buf: *std.ArrayList(u8), allocator: std.mem.Allocator, letter: u8, token: []const u8) WriteError!void {
    std.debug.assert(isCardLetter(letter));
    try buf.append(allocator, letter);
    try buf.append(allocator, ' ');
    try buf.appendSlice(allocator, token);
    try buf.append(allocator, '\n');
}

/// Append one single-token FREE-TEXT card: `<letter> <esc(token)>\n` (§4.3
/// writer inverse of `decodeEscapes`).
fn appendEscapedCard(buf: *std.ArrayList(u8), allocator: std.mem.Allocator, letter: u8, token: []const u8) WriteError!void {
    std.debug.assert(isCardLetter(letter));
    try buf.append(allocator, letter);
    try buf.append(allocator, ' ');
    try appendEscaped(buf, allocator, token);
    try buf.append(allocator, '\n');
}

/// §4.7.2 A card: `A <esc(name)> <esc(target)> [<source>]\n`. `source` is
/// raw (the parser's one exception among hash-bearing positions, §4.3.1
/// line 680); a prefixed filename never round-trips (parse keeps only the
/// basename, §4.7.2-m1) -- not reachable from any card this batch's
/// fixtures carry.
fn appendACard(buf: *std.ArrayList(u8), allocator: std.mem.Allocator, a: ACard) WriteError!void {
    try buf.append(allocator, 'A');
    try buf.append(allocator, ' ');
    try appendEscaped(buf, allocator, a.name);
    try buf.append(allocator, ' ');
    try appendEscaped(buf, allocator, a.target);
    if (a.source) |src| {
        try buf.append(allocator, ' ');
        try buf.appendSlice(allocator, src);
    }
    try buf.append(allocator, '\n');
}

/// §4.7.5 E card: `E <timestamp> <technote-id>\n`, both raw.
fn appendECard(buf: *std.ArrayList(u8), allocator: std.mem.Allocator, e: ECard) WriteError!void {
    try buf.append(allocator, 'E');
    try buf.append(allocator, ' ');
    try buf.appendSlice(allocator, e.timestamp);
    try buf.append(allocator, ' ');
    try buf.appendSlice(allocator, e.technote_id);
    try buf.append(allocator, '\n');
}

/// §4.7.6 F card: `F <esc(filename)> [<hash>] [<perm>] [<esc(old)>]\n`. The
/// three trailing positions nest strictly (perm only with hash, old_name
/// only with perm) -- a structural invariant of every successfully-parsed
/// FCard, since `parseFCard` reads them positionally, not by name.
fn appendFCard(buf: *std.ArrayList(u8), allocator: std.mem.Allocator, f: FCard) WriteError!void {
    try buf.append(allocator, 'F');
    try buf.append(allocator, ' ');
    try appendEscaped(buf, allocator, f.filename);
    if (f.hash) |h| {
        try buf.append(allocator, ' ');
        try buf.appendSlice(allocator, h);
    }
    if (f.perm) |p| {
        std.debug.assert(f.hash != null);
        try buf.append(allocator, ' ');
        try buf.appendSlice(allocator, p);
    }
    if (f.old_name) |old| {
        std.debug.assert(f.perm != null);
        try buf.append(allocator, ' ');
        try appendEscaped(buf, allocator, old);
    }
    try buf.append(allocator, '\n');
}

/// §4.7.8 J card: `J <field> [<esc(value)>]\n`. `field` is raw (never
/// decoded on read, §4.7.8); an empty `value` is the absent-value sentinel
/// (m3) and is omitted, never emitted as a trailing bare SP.
fn appendJCard(buf: *std.ArrayList(u8), allocator: std.mem.Allocator, j: JCard) WriteError!void {
    try buf.append(allocator, 'J');
    try buf.append(allocator, ' ');
    try buf.appendSlice(allocator, j.field);
    if (j.value.len > 0) {
        try buf.append(allocator, ' ');
        try appendEscaped(buf, allocator, j.value);
    }
    try buf.append(allocator, '\n');
}

/// §4.7.13 P card: `P [<parents>]\n`. `count == 0` (bare `P\n`, m1) is the
/// only case with no argument; `parents` otherwise carries the already
/// SP-joined raw hash tokens verbatim.
fn appendPCard(buf: *std.ArrayList(u8), allocator: std.mem.Allocator, p: PCard) WriteError!void {
    try buf.append(allocator, 'P');
    if (p.count > 0) {
        std.debug.assert(p.parents != null);
        try buf.append(allocator, ' ');
        try buf.appendSlice(allocator, p.parents.?);
    }
    try buf.append(allocator, '\n');
}

/// §4.7.14 Q card: `Q <sign><target> [<baseline>]\n`. Sign and target glue
/// into one wire token with no SP between them (q-sign is a byte prefix on
/// the hash, not a separate token).
fn appendQCard(buf: *std.ArrayList(u8), allocator: std.mem.Allocator, q: QCard) WriteError!void {
    try buf.append(allocator, 'Q');
    try buf.append(allocator, ' ');
    try buf.append(allocator, if (q.sign == .cherrypick) '+' else '-');
    try buf.appendSlice(allocator, q.target);
    if (q.baseline) |bl| {
        try buf.append(allocator, ' ');
        try buf.appendSlice(allocator, bl);
    }
    try buf.append(allocator, '\n');
}

/// §6.3.4 R card: `R <checksum>\n`, lowered -- the parser accepts either
/// case (§6.1), but the producer MUST always render exactly 32 lowercase
/// hex digits (§6.3.4).
fn appendRCard(buf: *std.ArrayList(u8), allocator: std.mem.Allocator, r: RCard) WriteError!void {
    std.debug.assert(r.checksum.len == 32);
    var lower: [32]u8 = undefined;
    for (r.checksum, 0..) |c, i| lower[i] = std.ascii.toLower(c);
    try appendRawCard(buf, allocator, 'R', &lower);
}

/// §4.7.16 T card: `T <sign><esc(name)> <target|*> [<esc(value)>]\n`. Sign
/// and name glue into one token (no SP), matching Q's sign convention; a
/// null `target` is the literal self-referential wire byte `*` (m3).
fn appendTCard(buf: *std.ArrayList(u8), allocator: std.mem.Allocator, t: TCard) WriteError!void {
    const sign_byte: u8 = switch (t.sign) {
        .add => '+',
        .cancel => '-',
        .propagate => '*',
    };
    try buf.append(allocator, 'T');
    try buf.append(allocator, ' ');
    try buf.append(allocator, sign_byte);
    try appendEscaped(buf, allocator, t.name);
    try buf.append(allocator, ' ');
    if (t.target) |tgt| {
        try buf.appendSlice(allocator, tgt);
    } else {
        try buf.append(allocator, '*');
    }
    if (t.value) |v| {
        try buf.append(allocator, ' ');
        try appendEscaped(buf, allocator, v);
    }
    try buf.append(allocator, '\n');
}

/// §4.7.18 W card: `W <size>\n<body>\n`, size-counted rather than tokenized
/// (the body may itself embed LF, §4.7.18).
fn appendWCard(buf: *std.ArrayList(u8), allocator: std.mem.Allocator, body: []const u8) WriteError!void {
    try buf.append(allocator, 'W');
    try buf.append(allocator, ' ');
    try appendMinimalDecimal(buf, allocator, body.len);
    try buf.append(allocator, '\n');
    try buf.appendSlice(allocator, body);
    try buf.append(allocator, '\n');
}

/// Serialize `manifest` to canonical artifact bytes (§4/§6). Cards are
/// emitted in the fixed §4.7/§4.4 lexical order A,B,C,D,E,F,G,H,I,J,K,L,M,
/// N,P,Q,R,T,U,W,Z -- the writer never reasons about the reader's N-after-P
/// compat exception (§4.4), since walking the Manifest's own fields in this
/// order always produces a strictly non-decreasing sequence. Z is computed
/// over the serialized prefix and appended last (§6.2); `manifest.z` (the
/// parsed Z, if any) is never read. Caller owns the returned slice and
/// frees it with `allocator`.
pub fn create(manifest: *const Manifest, allocator: std.mem.Allocator) WriteError![]u8 {
    std.debug.assert(@intFromEnum(manifest.kind) >= 1 and @intFromEnum(manifest.kind) <= 8);

    var buf: std.ArrayList(u8) = .empty;
    errdefer buf.deinit(allocator);

    if (manifest.a) |a| try appendACard(&buf, allocator, a);
    if (manifest.b) |b| try appendRawCard(&buf, allocator, 'B', b.hash);
    if (manifest.c) |c| try appendEscapedCard(&buf, allocator, 'C', c.comment);
    if (manifest.d) |d| try appendRawCard(&buf, allocator, 'D', d.timestamp);
    if (manifest.e) |e| try appendECard(&buf, allocator, e);
    for (manifest.f) |f| try appendFCard(&buf, allocator, f);
    if (manifest.g) |g| try appendRawCard(&buf, allocator, 'G', g.hash);
    if (manifest.h) |h| try appendEscapedCard(&buf, allocator, 'H', h.title);
    if (manifest.i) |i| try appendRawCard(&buf, allocator, 'I', i.hash);
    for (manifest.j) |j| try appendJCard(&buf, allocator, j);
    if (manifest.k) |k| try appendRawCard(&buf, allocator, 'K', k.ticket_id);
    if (manifest.l) |l| try appendEscapedCard(&buf, allocator, 'L', l.title);
    for (manifest.m) |m| try appendRawCard(&buf, allocator, 'M', m.hash);
    if (manifest.n) |n| try appendEscapedCard(&buf, allocator, 'N', n.mimetype);
    for (manifest.p) |p| try appendPCard(&buf, allocator, p);
    for (manifest.q) |q| try appendQCard(&buf, allocator, q);
    if (manifest.r) |r| try appendRCard(&buf, allocator, r);
    for (manifest.t) |t| try appendTCard(&buf, allocator, t);
    if (manifest.u) |u| try appendEscapedCard(&buf, allocator, 'U', u.login);
    if (manifest.w) |w| try appendWCard(&buf, allocator, w);

    var digest: [32]u8 = undefined;
    _ = selfChecksum(buf.items, &digest); // §6.2: over everything emitted so far
    try buf.append(allocator, 'Z');
    try buf.append(allocator, ' ');
    try buf.appendSlice(allocator, &digest);
    try buf.append(allocator, '\n');

    const out = try buf.toOwnedSlice(allocator);
    std.debug.assert(out.len >= 35); // §4.1: minimum framing (the Z card alone)
    std.debug.assert(out[out.len - 1] == '\n');
    std.debug.assert(out[out.len - 35] == 'Z' and out[out.len - 34] == ' ');
    std.debug.assert(verifyZCard(out) == .match); // the writer's own output MUST self-verify
    return out;
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

test "artifact §3.1: name is SHA3-256 of exact raw bytes" {
    var out: [64]u8 = undefined;
    // §A.3 / Table 8 (RFC line 2584): alpha.txt's content is "hello world\n",
    // named by the SHA3-256 given as its F-card hash.
    const name = artifactName(.sha3_256, "hello world\n", &out);
    try std.testing.expectEqualStrings(
        "a8009a7a528d87778c356da3a55d964719e818666a04e4f960c9e2439e35f138",
        name,
    );
}

// The §A.1.1 SHA-1 worked-example manifest: 127 raw bytes (27+22+2+35+6+35
// per the §A.2 Z-card offset table), literal backslash-s escapes retained
// exactly as printed in the RFC. Its stated name is
// a28c83647dfa805f05f3204a7e146eb1f0d90505 (RFC line 2426).
const a1_1_manifest = @embedFile("rfc_vectors/artifact_a1_1_sha1_manifest.txt");
const a1_1_name = "a28c83647dfa805f05f3204a7e146eb1f0d90505";

test "artifact §3.2: 40-hex name is SHA-1 over raw bytes" {
    try std.testing.expectEqual(@as(usize, 127), a1_1_manifest.len);
    var out: [64]u8 = undefined;
    const name = artifactName(.sha1, a1_1_manifest, &out);
    try std.testing.expectEqualStrings(a1_1_name, name);
}

test "artifact §3.2: name length selects algorithm" {
    try std.testing.expectEqual(HashAlgo.sha1, nameAlgo(40).?);
    try std.testing.expectEqual(HashAlgo.sha3_256, nameAlgo(64).?);
}

test "artifact §3.2: name of any other length is rejected" {
    const bad_lens = [_]usize{ 0, 4, 39, 41, 63, 65, 128 };
    for (bad_lens) |len| {
        try std.testing.expect(nameAlgo(len) == null);
        // Fabricate an all-'a' token of that length: rejection here must be
        // on length alone, before any character-class check (§3.2-r1).
        var buf: [128]u8 = undefined;
        std.debug.assert(len <= buf.len);
        @memset(buf[0..len], 'a');
        try std.testing.expect(!validateName(buf[0..len]));
    }
}

test "artifact §3.3: computed name is lowercase hex" {
    var out: [64]u8 = undefined;
    const sha1_name = artifactName(.sha1, a1_1_manifest, &out);
    for (sha1_name) |c| try std.testing.expect(isHexLower(c));

    var out2: [64]u8 = undefined;
    const sha3_name = artifactName(.sha3_256, "hello world\n", &out2);
    for (sha3_name) |c| try std.testing.expect(isHexLower(c));
}

test "artifact §3.3: validation rejects uppercase, does not normalize" {
    // Same fixture as the rejection test above, restated to make the point
    // explicit: a normalize-then-accept parser would flip this to true.
    var upper: [40]u8 = undefined;
    for (a1_1_name, 0..) |c, i| upper[i] = std.ascii.toUpper(c);
    try std.testing.expect(!validateName(&upper));
    // And the lowercase original still validates, proving the reject is
    // case-specific rather than a blanket failure.
    try std.testing.expect(validateName(a1_1_name));
}

test "artifact §3.4: verify dispatches on length" {
    try std.testing.expect(verify(a1_1_name, a1_1_manifest));
    try std.testing.expect(verify(
        "a8009a7a528d87778c356da3a55d964719e818666a04e4f960c9e2439e35f138",
        "hello world\n",
    ));
}

test "artifact §3.4: uppercase name never verifies" {
    var upper: [40]u8 = undefined;
    for (a1_1_name, 0..) |c, i| upper[i] = std.ascii.toUpper(c);
    try std.testing.expect(!verify(&upper, a1_1_manifest));
}

test "artifact §3.4: one undifferentiated failure signal" {
    // Wrong length.
    try std.testing.expect(!verify("deadbeef10", a1_1_manifest));
    // Non-hex character at a valid (40) length.
    var non_hex: [40]u8 = undefined;
    @memcpy(&non_hex, a1_1_name);
    non_hex[0] = 'g';
    try std.testing.expect(!verify(&non_hex, a1_1_manifest));
    // Valid-form 40-hex name, wrong digest (mismatched content).
    try std.testing.expect(!verify(a1_1_name, "hello world\n"));
}

test "artifact §3.5: ABNF canonical grammar accepts lhexdig only" {
    var boundary: [40]u8 = undefined;
    @memset(&boundary, '0');
    try std.testing.expect(validateName(&boundary));
    @memset(&boundary, '9');
    try std.testing.expect(validateName(&boundary));
    @memset(&boundary, 'a');
    try std.testing.expect(validateName(&boundary));
    @memset(&boundary, 'f');
    try std.testing.expect(validateName(&boundary));

    var has_g: [40]u8 = undefined;
    @memset(&has_g, 'a');
    has_g[39] = 'g';
    try std.testing.expect(!validateName(&has_g));

    var has_upper: [40]u8 = undefined;
    @memset(&has_upper, 'a');
    has_upper[0] = 'A';
    try std.testing.expect(!validateName(&has_upper));

    var sha3_all_f: [64]u8 = undefined;
    @memset(&sha3_all_f, 'f');
    try std.testing.expect(validateName(&sha3_all_f));
}

test "artifact §3.6: prefix resolves and lowercases uppercase input" {
    const names = [_][]const u8{a1_1_name};
    switch (try resolvePrefix("a28c", &names)) {
        .one => |idx| try std.testing.expectEqual(@as(usize, 0), idx),
        else => return error.TestUnexpectedResult,
    }
    switch (try resolvePrefix("A28C", &names)) {
        .one => |idx| try std.testing.expectEqual(@as(usize, 0), idx),
        else => return error.TestUnexpectedResult,
    }
}

test "artifact §3.6: prefix length outside 4..64 is invalid" {
    const names = [_][]const u8{a1_1_name};
    try std.testing.expectError(error.InvalidPrefix, resolvePrefix("a28", &names));
    var too_long: [65]u8 = undefined;
    @memset(&too_long, 'a');
    try std.testing.expectError(error.InvalidPrefix, resolvePrefix(&too_long, &names));
    try std.testing.expectError(error.InvalidPrefix, resolvePrefix("zzzz", &names));
}

test "artifact §3.6: ambiguous prefix, incl. cross-algorithm collision" {
    // Cross-algorithm: one SHA-1-named, one SHA3-named, sharing "a28c".
    const cross = [_][]const u8{
        a1_1_name,
        "a28c000000000000000000000000000000000000000000000000000000000000",
    };
    try std.testing.expectEqual(Resolution.ambiguous, try resolvePrefix("a28c", &cross));

    // Same-algorithm: two 40-char names sharing a prefix, reported the same.
    const same = [_][]const u8{
        a1_1_name,
        "a28c000000000000000000000000000000000000",
    };
    try std.testing.expectEqual(Resolution.ambiguous, try resolvePrefix("a28c", &same));
}

const sha3_name_64 = "2cee2f88646e224275d9445d523e4a8021f8e6cffa2b3978f8ea324cb0210261";

test "artifact §4.7.1: artifact-hash accepts 40 and 64 hex" {
    try std.testing.expect(validateHashToken(.artifact_hash, a1_1_name));
    try std.testing.expect(validateHashToken(.artifact_hash, sha3_name_64));
}

test "artifact §4.7.1: artifact-hash is case-insensitive per its explicit MUST" {
    // §4.7.1: "MUST be exactly 40 or 64 case-insensitive hexadecimal
    // characters" — a harder requirement than artifact-name's MAY (§3.3),
    // so upper- and mixed-case tokens are accepted, unlike validateName.
    var upper: [40]u8 = undefined;
    for (a1_1_name, 0..) |c, i| upper[i] = std.ascii.toUpper(c);
    try std.testing.expect(validateHashToken(.artifact_hash, &upper));

    var mixed: [64]u8 = undefined;
    for (sha3_name_64, 0..) |c, i| {
        mixed[i] = if (i % 2 == 0) std.ascii.toUpper(c) else c;
    }
    try std.testing.expect(validateHashToken(.artifact_hash, &mixed));
}

test "artifact §4.7.1: artifact-hash rejects wrong length and non-hex" {
    // §4.7.1-p1 length gate; §3.2-r1 length precedes char-class check.
    const bad_lens = [_]usize{ 39, 41, 32, 63, 65 };
    var buf: [65]u8 = undefined;
    @memset(&buf, 'a');
    for (bad_lens) |len| {
        std.debug.assert(len <= buf.len);
        try std.testing.expect(!validateHashToken(.artifact_hash, buf[0..len]));
    }
    var non_hex: [40]u8 = undefined;
    @memcpy(&non_hex, a1_1_name);
    non_hex[0] = 'g';
    try std.testing.expect(!validateHashToken(.artifact_hash, &non_hex));
}

test "artifact §4.7.1: K ticket id is 40, never 64" {
    // §4.7.1-r1: pinned to SHA-1 length regardless of hash policy.
    try std.testing.expect(validateHashToken(.ticket_id, a1_1_name));
    try std.testing.expect(!validateHashToken(.ticket_id, sha3_name_64));

    // Negative space beyond the two named lengths, and a non-hex char.
    try std.testing.expect(!validateHashToken(.ticket_id, a1_1_name[0..39]));
    var non_hex: [40]u8 = undefined;
    @memcpy(&non_hex, a1_1_name);
    non_hex[0] = 'g';
    try std.testing.expect(!validateHashToken(.ticket_id, &non_hex));
}

test "artifact §4.7.1: R/Z checksum tokens are exactly 32 hex" {
    // §4.7.1-r2, §6.1 md5-token = 32hexdig-ci.
    const md5 = "d41d8cd98f00b204e9800998ecf8427e";
    try std.testing.expect(validateHashToken(.checksum, md5));
    try std.testing.expect(!validateHashToken(.checksum, a1_1_name));
    try std.testing.expect(!validateHashToken(.checksum, md5[0..31]));
    var too_long: [33]u8 = undefined;
    @memcpy(too_long[0..32], md5);
    too_long[32] = 'a';
    try std.testing.expect(!validateHashToken(.checksum, &too_long));

    var non_hex: [32]u8 = undefined;
    @memcpy(&non_hex, md5);
    non_hex[0] = 'g';
    try std.testing.expect(!validateHashToken(.checksum, &non_hex));
}

test "artifact §4.7.1: checksum is case-insensitive; ticket id is lowercase-only" {
    // §6.1 "parse-accept grammar is case-insensitive" for R/Z; §4.7.1's K
    // exception takes the artifact-name-style MAY and stays lowercase-only,
    // unlike the artifact-hash arm's case-insensitive MUST (covered above).
    try std.testing.expect(validateHashToken(.checksum, "D41D8CD98F00B204E9800998ECF8427E"));

    var upper: [40]u8 = undefined;
    for (a1_1_name, 0..) |c, i| upper[i] = std.ascii.toUpper(c);
    try std.testing.expect(!validateHashToken(.ticket_id, &upper));
}

test "artifact §4.7.1: the map never escape-decodes a raw token" {
    // §4.7.1-m2; §4.3.1 defines the A-target decode-then-validate contract
    // that lives entirely in the future A-card parser, not here. A decoding
    // validator would strip the leading "\a" -> "a" and see 40 valid hex
    // bytes; the raw validator must see 41 bytes including a non-hex '\'.
    var wire: [41]u8 = undefined;
    wire[0] = '\\';
    @memcpy(wire[1..41], a1_1_name);
    try std.testing.expect(!validateHashToken(.artifact_hash, &wire));

    // Interior backslash inside an otherwise 40-byte token.
    var interior: [40]u8 = undefined;
    @memcpy(&interior, a1_1_name);
    interior[10] = '\\';
    try std.testing.expect(!validateHashToken(.artifact_hash, &interior));
}

test "self-checksum = lowercase MD5 (empty input vector)" {
    var out: [32]u8 = undefined;
    const z = selfChecksum("", &out);
    try std.testing.expectEqualStrings("d41d8cd98f00b204e9800998ecf8427e", z);
}

test "artifact §4.1.1: stripping only when first 34 bytes match armor" {
    // Under 34 bytes: too short to match, returned unchanged.
    const short = "-----BEGIN PGP SIGNED MESSAGE---"; // 32 bytes, two dashes short
    try std.testing.expectEqualStrings(short, stripSignatureWrapper(short));

    // Armor text present, but not at offset 0: unchanged.
    const offset5 = "12345-----BEGIN PGP SIGNED MESSAGE-----\n\nbody\n";
    try std.testing.expectEqualStrings(offset5, stripSignatureWrapper(offset5));

    // 34 bytes, none of them the armor literal: unchanged.
    const not_armor = "x" ** 34 ++ "\n\nbody\n";
    try std.testing.expectEqualStrings(not_armor, stripSignatureWrapper(not_armor));

    // Exact open armor at offset 0: stripped.
    const wrapped = "-----BEGIN PGP SIGNED MESSAGE-----\n\nbody\n";
    try std.testing.expectEqualStrings("body\n", stripSignatureWrapper(wrapped));
}

test "artifact §4.1.1: body begins after first blank line (LF LF or LF CR LF)" {
    const wrapped_lf = "-----BEGIN PGP SIGNED MESSAGE-----\nHash: SHA256\n\nbody line\n";
    const wrapped_crlf = "-----BEGIN PGP SIGNED MESSAGE-----\nHash: SHA256\n\r\nbody line\n";
    try std.testing.expectEqualStrings("body line\n", stripSignatureWrapper(wrapped_lf));
    try std.testing.expectEqualStrings("body line\n", stripSignatureWrapper(wrapped_crlf));
}

test "artifact §4.1.1: no blank line leaves content wholly unstripped" {
    const wrapped = "-----BEGIN PGP SIGNED MESSAGE-----\nhello\n";
    try std.testing.expectEqualStrings(wrapped, stripSignatureWrapper(wrapped));
}

test "artifact §4.1.1: trailing signature armor truncates, retaining the LF" {
    const pgp_wrapped = "-----BEGIN PGP SIGNED MESSAGE-----\n\nbody\n" ++
        "-----BEGIN PGP SIGNATURE-----\nabc\n-----END PGP SIGNATURE-----\n";
    try std.testing.expectEqualStrings("body\n", stripSignatureWrapper(pgp_wrapped));

    const ssh_wrapped = "-----BEGIN SSH SIGNED MESSAGE-----\n\nbody\n" ++
        "-----BEGIN SSH SIGNATURE-----\nabc\n-----END SSH SIGNATURE-----\n";
    try std.testing.expectEqualStrings("body\n", stripSignatureWrapper(ssh_wrapped));

    // No trailing armor at all: runs to end of input.
    const no_close = "-----BEGIN PGP SIGNED MESSAGE-----\n\nbody\n";
    try std.testing.expectEqualStrings("body\n", stripSignatureWrapper(no_close));
}

test "artifact §4.1.1: trailing armor one dash short still matches (29 of 30)" {
    // 4 trailing dashes (one short of the closing run) + arbitrary byte: matches.
    const one_short = "-----BEGIN PGP SIGNED MESSAGE-----\n\nbody\n" ++
        "-----BEGIN PGP SIGNATURE----X\ntrailer\n";
    try std.testing.expectEqualStrings("body\n", stripSignatureWrapper(one_short));

    // 3 trailing dashes (two short): the 29-byte compare fails, runs to EOF.
    const two_short = "-----BEGIN PGP SIGNED MESSAGE-----\n\nbody\n" ++
        "-----BEGIN PGP SIGNATURE---\ntrailer\n";
    const expected_two_short = "body\n-----BEGIN PGP SIGNATURE---\ntrailer\n";
    try std.testing.expectEqualStrings(expected_two_short, stripSignatureWrapper(two_short));
}

test "artifact §4.1.1: PGP and SSH wrappers are removed before parsing" {
    const pgp_wrapped = "-----BEGIN PGP SIGNED MESSAGE-----\nHash: SHA256\n\n" ++
        a1_1_manifest ++ "-----BEGIN PGP SIGNATURE-----\nabc123\n-----END PGP SIGNATURE-----\n";
    try std.testing.expectEqualStrings(a1_1_manifest, stripSignatureWrapper(pgp_wrapped));

    const ssh_wrapped = "-----BEGIN SSH SIGNED MESSAGE-----\n\n" ++
        a1_1_manifest ++ "-----BEGIN SSH SIGNATURE-----\nabc123\n-----END SSH SIGNATURE-----\n";
    try std.testing.expectEqualStrings(a1_1_manifest, stripSignatureWrapper(ssh_wrapped));
}

test "artifact §4.1: raw input must be non-empty and end in LF" {
    try std.testing.expect(frame("") == null);
    try std.testing.expect(frame("C comment") == null); // no trailing LF
    try std.testing.expect(frame("C comment\n") != null);
}

test "artifact §4.1: stripped content under 10 bytes is rejected" {
    try std.testing.expect(frame("C 123456\n") == null); // 9 bytes
    try std.testing.expect(frame("C 1234567\n") != null); // 10 bytes
}

test "artifact §4.1: first stripped byte must be uppercase A-Z" {
    const table = [_]struct { byte0: u8, ok: bool }{
        .{ .byte0 = '@', .ok = false }, // 0x40, just below 'A'
        .{ .byte0 = 'A', .ok = true },
        .{ .byte0 = 'Z', .ok = true },
        .{ .byte0 = '[', .ok = false }, // 0x5B, just above 'Z'
        .{ .byte0 = 'c', .ok = false }, // lowercase
    };
    for (table) |row| {
        var buf = [_]u8{ row.byte0, ' ', '1', '2', '3', '4', '5', '6', '7', '\n' };
        std.debug.assert(buf.len == 10);
        try std.testing.expectEqual(row.ok, frame(&buf) != null);
    }
}

test "artifact §4.1: second stripped byte must be a single space" {
    const table = [_]struct { byte1: u8, ok: bool }{
        .{ .byte1 = ' ', .ok = true },
        .{ .byte1 = '\t', .ok = false },
        .{ .byte1 = 'X', .ok = false },
        .{ .byte1 = '\n', .ok = false }, // argument-less first card
    };
    for (table) |row| {
        var buf = [_]u8{ 'C', row.byte1, '1', '2', '3', '4', '5', '6', '7', '\n' };
        std.debug.assert(buf.len == 10);
        try std.testing.expectEqual(row.ok, frame(&buf) != null);
    }
}

test "artifact §4.1/§6.2: present but mismatched Z card is rejected" {
    // Z card occupies the final 35 bytes of the 127-byte manifest: Z@92,
    // SP@93, digest 94..125, LF@126 (§A.2 offset table).
    var flipped: [127]u8 = undefined;
    @memcpy(&flipped, a1_1_manifest);
    flipped[94] = 'd'; // digest[0] was 'c'; still hex, still a mismatch
    try std.testing.expectEqual(ZVerdict.mismatch, verifyZCard(&flipped));
    try std.testing.expect(frame(&flipped) == null);

    var uppercased: [127]u8 = undefined;
    @memcpy(&uppercased, a1_1_manifest);
    uppercased[94] = 'C'; // numerically same digit, wrong case
    try std.testing.expectEqual(ZVerdict.mismatch, verifyZCard(&uppercased));
    try std.testing.expect(frame(&uppercased) == null);
}

test "artifact §4.1/§6.2: absent Z card passes the framing gate" {
    try std.testing.expectEqual(ZVerdict.absent, verifyZCard("C comment\n"));
    try std.testing.expect(frame("C comment\n") != null);

    try std.testing.expectEqual(ZVerdict.match, verifyZCard(a1_1_manifest));
    try std.testing.expect(frame(a1_1_manifest) != null);
}

test "artifact §4.1: gates apply in order (raw-LF before strip)" {
    // Outer wrapper omits its final LF: gate 1 must fire on the raw bytes,
    // before stripping ever runs — the stripped inner body (a1_1_manifest)
    // always ends in LF regardless, so this only fails if gates run in order.
    const wrapped_no_lf = "-----BEGIN PGP SIGNED MESSAGE-----\nHash: SHA256\n\n" ++
        a1_1_manifest ++ "-----BEGIN PGP SIGNATURE-----\nabc\n-----END PGP SIGNATURE-----";
    try std.testing.expect(frame(wrapped_no_lf) == null);

    const wrapped_with_lf = wrapped_no_lf ++ "\n";
    try std.testing.expect(frame(wrapped_with_lf) != null);
}

// ---------------------------------------------------------------------------
// §4.2 Line and Token Grammar / §4.2.2 Closed Card Alphabet — tests
// ---------------------------------------------------------------------------

test "artifact §4.2.2: exactly the 21 defined letters are accepted" {
    // Lines 622-624: "Exactly 21 card letters are defined: A, B, C, D, E, F,
    // G, H, I, J, K, L, M, N, P, Q, R, T, U, W, Z." Closed set, no
    // forward-compat (lines 626-627).
    const undefined_in_range = "OSVXY";
    var accepted: usize = 0;
    var c: u8 = 'A';
    while (true) : (c += 1) {
        const is_undefined = std.mem.indexOfScalar(u8, undefined_in_range, c) != null;
        try std.testing.expectEqual(!is_undefined, isCardLetter(c));
        if (isCardLetter(c)) accepted += 1;
        if (c == 'Z') break;
    }
    try std.testing.expectEqual(@as(usize, 21), accepted);
}

test "artifact §4.2.2: card letter outside A-Z is rejected" {
    // Line 624: "A card letter outside A-Z MUST be rejected."
    const table = [_]u8{ 0x40, '[', 'c', '0' }; // '@' just below 'A', '[' just above 'Z'
    for (table) |b| {
        try std.testing.expect(!isCardLetter(b));
        var buf = [_]u8{ b, '\n' };
        try std.testing.expect(tokenizeCardLine(&buf) == null);
    }
}

test "artifact §4.2.2: in-range undefined letters O S V X Y are rejected" {
    // Line 625: "an in-range but undefined letter (O, S, V, X, Y) MUST be
    // rejected."
    const table = [_]u8{ 'O', 'S', 'V', 'X', 'Y' };
    for (table) |b| {
        try std.testing.expect(!isCardLetter(b));
        var buf = [_]u8{ b, '\n' };
        try std.testing.expect(tokenizeCardLine(&buf) == null);
    }
}

test "artifact §4.2: tchar admits every octet except SP and LF" {
    // Lines 590-591: "tchar = %x00-09 / %x0B-1F / %x21-FF ; any octet except
    // SP (0x20) and LF (0x0A)".
    const admitted = [_]u8{ 0x00, 0x09, 0x0B, 0x1F, 0x21, 0xFF };
    for (admitted) |b| try std.testing.expect(isTokenByte(b));
    const excluded = [_]u8{ 0x0A, 0x20 };
    for (excluded) |b| try std.testing.expect(!isTokenByte(b));
}

test "artifact §4.2: card tokenizes to letter and SP-split tokens" {
    // Grammar lines 586-589: card-args = token *( SP token ).
    const line = "F path abc123 w\n";
    const card = tokenizeCardLine(line).?;
    try std.testing.expectEqual(@as(u8, 'F'), card.letter);
    try std.testing.expectEqualStrings("path abc123 w", card.args.?);

    var it = card.tokens();
    try std.testing.expectEqualStrings("path", it.next().?);
    try std.testing.expectEqualStrings("abc123", it.next().?);
    try std.testing.expectEqualStrings("w", it.next().?);
    try std.testing.expect(it.next() == null);
}

test "artifact §4.2-m1: byte after card letter must be SP or LF, tab rejected" {
    // Lines 593-595: "MUST be exactly one space (0x20 ...) or LF ... Any
    // other second byte — including tab (0x09) — makes the card invalid."
    {
        var buf = "C x\n".*;
        const card = tokenizeCardLine(&buf).?;
        try std.testing.expectEqualStrings("x", card.args.?);
    }
    {
        var buf = "C\n".*;
        const card = tokenizeCardLine(&buf).?;
        try std.testing.expectEqual(@as(?[]const u8, null), card.args);
        var it = card.tokens();
        try std.testing.expect(it.next() == null);
    }
    {
        var buf = "C\tx\n".*;
        try std.testing.expect(tokenizeCardLine(&buf) == null);
    }
    {
        var buf = "CX\n".*;
        try std.testing.expect(tokenizeCardLine(&buf) == null);
    }
}

test "artifact §4.2-m2: tokens split on space only; tab retained in token" {
    // Lines 596-598: "split on space and LF only; tab is not a delimiter and
    // is retained ... a token can never contain an embedded space." The
    // byte after the letter must still be SP (§4.2-m1); the tab lives
    // inside the token that follows, not in the letter/SP boundary.
    var buf = "T \tvalue\n".*;
    const card = tokenizeCardLine(&buf).?;
    var it = card.tokens();
    const only = it.next().?;
    try std.testing.expectEqualStrings("\tvalue", only);
    try std.testing.expect(std.mem.indexOfScalar(u8, only, ' ') == null);
    try std.testing.expect(it.next() == null);

    var buf2 = "T a b c\n".*;
    const card2 = tokenizeCardLine(&buf2).?;
    var it2 = card2.tokens();
    var n: usize = 0;
    while (it2.next()) |tok| : (n += 1) {
        try std.testing.expect(std.mem.indexOfScalar(u8, tok, ' ') == null);
    }
    try std.testing.expectEqual(@as(usize, 3), n);
}

test "artifact §4.2-m3: CR is an ordinary token byte, not a delimiter" {
    // Lines 598-600: "There is no CR (0x0D) handling in the card and token
    // path — a CR is an ordinary token byte."
    var buf = "C a\rb\n".*;
    const card = tokenizeCardLine(&buf).?;
    var it = card.tokens();
    try std.testing.expectEqualStrings("a\rb", it.next().?);
    try std.testing.expect(it.next() == null);
}

test "artifact §4.2: bare U vs empty-token U distinguished (RFC line 1418)" {
    // §4.7.17: a bare U line (0 tokens) differs from a U followed by an
    // empty token (1 empty token) — both collapse to "anonymous" at the
    // per-card layer, but the tokenizer must still tell them apart.
    var bare = "U\n".*;
    const bare_card = tokenizeCardLine(&bare).?;
    try std.testing.expectEqual(@as(?[]const u8, null), bare_card.args);
    var bare_it = bare_card.tokens();
    try std.testing.expect(bare_it.next() == null);

    var empty = "U \n".*;
    const empty_card = tokenizeCardLine(&empty).?;
    try std.testing.expectEqualStrings("", empty_card.args.?);
    var empty_it = empty_card.tokens();
    try std.testing.expectEqualStrings("", empty_it.next().?);
    try std.testing.expect(empty_it.next() == null);
}

// ---------------------------------------------------------------------------
// §4.3 Character-Escape Encoding — tests
// ---------------------------------------------------------------------------

test "artifact §4.3-m1: the eight escape pairs decode to their octets" {
    // Table 1 (lines 635-653): each row's "Decoded octet" column.
    const table = [_]struct { input: []const u8, expected: u8 }{
        .{ .input = "\\n", .expected = 0x0A },
        .{ .input = "\\s", .expected = 0x20 },
        .{ .input = "\\t", .expected = 0x09 },
        .{ .input = "\\r", .expected = 0x0D },
        .{ .input = "\\v", .expected = 0x0B },
        .{ .input = "\\f", .expected = 0x0C },
        .{ .input = "\\0", .expected = 0x00 },
        .{ .input = "\\\\", .expected = 0x5C },
    };
    for (table) |row| {
        var out: [2]u8 = undefined;
        const decoded = decodeEscapes(row.input, &out);
        try std.testing.expectEqual(@as(usize, 1), decoded.len);
        try std.testing.expectEqual(row.expected, decoded[0]);
    }
}

test "artifact §4.3-m2: unrecognized escape drops the backslash, keeps the successor" {
    // Lines 657-660: "the decoder discards the backslash and emits the
    // following character unchanged ... decodes to the single byte C."
    const table = [_]struct { input: []const u8, expected: u8 }{
        .{ .input = "\\x", .expected = 'x' },
        .{ .input = "\\1", .expected = '1' }, // only \0 is in Table 1
        .{ .input = "\\!", .expected = '!' },
    };
    for (table) |row| {
        var out: [2]u8 = undefined;
        const decoded = decodeEscapes(row.input, &out);
        try std.testing.expectEqual(@as(usize, 1), decoded.len);
        try std.testing.expectEqual(row.expected, decoded[0]);
    }
}

test "artifact §4.3-amb1: a lone trailing backslash is emitted literally" {
    // ambiguities.md RESOLVED reading #1 (pass-through, operator 2026-07-09):
    // a backslash with no successor forms no pair, emitted as its own byte.
    var out1: [1]u8 = undefined;
    const decoded1 = decodeEscapes("\\", &out1);
    try std.testing.expectEqualStrings("\\", decoded1);

    var out2: [2]u8 = undefined;
    const decoded2 = decodeEscapes("a\\", &out2);
    try std.testing.expectEqualStrings("a\\", decoded2);
}

test "artifact §4.3-w1: a pure-hexadecimal wire token decodes to itself" {
    // Line 698: "A pure-hexadecimal wire token decodes to itself" — no
    // backslash present, every byte is ordinary.
    const short = "deadbeef";
    var out_short: [short.len]u8 = undefined;
    try std.testing.expectEqualStrings(short, decodeEscapes(short, &out_short));

    const long = "a28c83647dfa805f05f3204a7e146eb1f0d9050500000000000000000000000";
    var out_long: [long.len]u8 = undefined;
    try std.testing.expectEqualStrings(long, decodeEscapes(long, &out_long));
}

test "artifact §4.3: escapes decode left-to-right without reprocessing output" {
    // §4.3 escapes are "decoded in place" over the wire token (lines
    // 632-633): the decoded backslash from \\ -> 0x5C is emitted, not
    // re-read, so a trailing 'n' after it stays literal (not LF).
    const input = "\\\\n"; // backslash, backslash, n
    var out: [3]u8 = undefined;
    const decoded = decodeEscapes(input, &out);
    try std.testing.expectEqual(@as(usize, 2), decoded.len);
    try std.testing.expectEqual(@as(u8, 0x5C), decoded[0]);
    try std.testing.expectEqual(@as(u8, 'n'), decoded[1]);

    var out_empty: [0]u8 = undefined;
    try std.testing.expectEqualStrings("", decodeEscapes("", &out_empty));
}

test "artifact §4.3: encodeEscapes emits the Table 1 escape for each named octet" {
    const Row = struct { octet: u8, succ: u8 };
    const rows = [_]Row{
        .{ .octet = 0x0A, .succ = 'n' },
        .{ .octet = 0x20, .succ = 's' },
        .{ .octet = 0x09, .succ = 't' },
        .{ .octet = 0x0D, .succ = 'r' },
        .{ .octet = 0x0B, .succ = 'v' },
        .{ .octet = 0x0C, .succ = 'f' },
        .{ .octet = 0x00, .succ = '0' },
        .{ .octet = 0x5C, .succ = '\\' },
    };
    for (rows) |row| {
        var out: [2]u8 = undefined;
        const encoded = encodeEscapes(&[_]u8{row.octet}, &out);
        try std.testing.expectEqual(@as(usize, 2), encoded.len);
        try std.testing.expectEqual(@as(u8, '\\'), encoded[0]);
        try std.testing.expectEqual(row.succ, encoded[1]);
    }
}

test "artifact §4.3/§A.1.1: the worked-example comment round-trips" {
    var buf: [64]u8 = undefined;
    const encoded = encodeEscapes("initial empty baseline", &buf);
    try std.testing.expectEqualStrings("initial\\sempty\\sbaseline", encoded);
}

test "artifact §4.3: encodeEscapes then decodeEscapes round-trips" {
    var prng = std.Random.DefaultPrng.init(0xA47F_0100);
    const rand = prng.random();
    var round: usize = 0;
    while (round < 400) : (round += 1) {
        var src: [64]u8 = undefined;
        const len = rand.uintLessThan(usize, src.len + 1);
        for (src[0..len]) |*b| b.* = rand.int(u8);
        const token = src[0..len];

        var enc_buf: [128]u8 = undefined;
        const encoded = encodeEscapes(token, &enc_buf);
        try std.testing.expect(encoded.len <= 2 * token.len);
        try std.testing.expect(encoded.len >= token.len);

        var dec_buf: [128]u8 = undefined;
        const decoded = decodeEscapes(encoded, dec_buf[0..encoded.len]);
        try std.testing.expectEqualSlices(u8, token, decoded);
    }
}

// ---------------------------------------------------------------------------
// §4.4 Card Ordering — tests
// ---------------------------------------------------------------------------

test "artifact §4.4-p1: cards admit in non-decreasing type order" {
    // Lines 705-707: "MUST appear in non-decreasing lexicographic card-type
    // order: each card's type letter MUST be greater than or equal to the
    // previous card's type letter."
    var s = OrderState{};
    const in_order = "CCDFTUZ";
    for (in_order) |letter| try std.testing.expect(s.admit(letter));

    var below = OrderState{};
    try std.testing.expect(below.admit('F'));
    try std.testing.expect(!below.admit('D')); // D (0x44) < F (0x46)
}

test "artifact §4.4-p2: consecutive equal-letter cards are permitted" {
    // Lines 708-711: consecutive same-letter cards permitted; the ordering
    // is non-decreasing, not strictly increasing.
    var s1 = OrderState{};
    try std.testing.expect(s1.admit('F'));
    try std.testing.expect(s1.admit('F'));

    var s2 = OrderState{};
    try std.testing.expect(s2.admit('P'));
    try std.testing.expect(s2.admit('P'));
}

test "artifact §4.4-r1: a letter sorting below the previous is rejected" {
    // Lines 708-709: "The rejection condition fires only when a card's
    // letter sorts below the previous card's letter" — exactly that
    // condition, nothing else.
    const table = [_]struct { prev: u8, cur: u8, ok: bool }{
        .{ .prev = 'D', .cur = 'C', .ok = false },
        .{ .prev = 'P', .cur = 'C', .ok = false },
        .{ .prev = 'Z', .cur = 'U', .ok = false },
        .{ .prev = 'C', .cur = 'C', .ok = true },
        .{ .prev = 'C', .cur = 'D', .ok = true },
    };
    for (table) |row| {
        var s = OrderState{};
        try std.testing.expect(s.admit(row.prev));
        try std.testing.expectEqual(row.ok, s.admit(row.cur));
    }
}

test "artifact §4.4-m1: N after P admitted once a valid E precedes" {
    // Lines 714-717: "an N card MAY appear immediately after a P card when
    // the artifact has already presented a valid E card. A parser MUST
    // accept that sequence, and any other out-of-order card MUST be
    // rejected."
    var s = OrderState{};
    for ("CDE") |letter| try std.testing.expect(s.admit(letter));
    try std.testing.expect(s.admit('P'));
    try std.testing.expect(s.admit('N')); // N (0x4E) < P (0x50): exception fires

    // Same driver still rejects a non-exception out-of-order step.
    try std.testing.expect(!s.admit('C')); // C (0x43) < N (0x4E), not the exception
}

test "artifact §4.4-r2: the N-after-P exception requires a prior E card" {
    // Lines 714-716 precondition: "has already presented a valid E card."
    var no_e = OrderState{};
    try std.testing.expect(no_e.admit('P'));
    try std.testing.expect(!no_e.admit('N')); // N < P, but e_seen is false: inert

    var with_e = OrderState{};
    try std.testing.expect(with_e.admit('E'));
    try std.testing.expect(with_e.admit('P'));
    try std.testing.expect(with_e.admit('N')); // exception fires
}

test "artifact §4.4-r3: out-of-order cards other than N-after-P are rejected" {
    // Lines 716-717: "any other out-of-order card MUST be rejected."
    // Appendix C (lines 2952-2955): "other out-of-order cards remain
    // rejected." Only N-immediately-after-P is tolerated.
    {
        var s = OrderState{};
        try std.testing.expect(s.admit('E'));
        try std.testing.expect(s.admit('P'));
        try std.testing.expect(!s.admit('M')); // M (0x4D) < P, not N
    }
    {
        var s = OrderState{};
        try std.testing.expect(s.admit('E'));
        try std.testing.expect(s.admit('Q'));
        try std.testing.expect(!s.admit('N')); // prev is Q, not P: inert
    }
    {
        var s = OrderState{};
        for ("EPN") |letter| try std.testing.expect(s.admit(letter));
        try std.testing.expect(!s.admit('M')); // M < N, below-N step still rejects
    }
}

test "artifact §4.4-r4: no letter follows Z; the exception does not involve Z" {
    // Lines 739-741: "Z is the lexicographically last defined letter, the
    // ordering rule admits no letter after it (the N-after-P exception
    // does not involve Z)."
    var s1 = OrderState{};
    try std.testing.expect(s1.admit('Z'));
    try std.testing.expect(!s1.admit('U'));

    var s2 = OrderState{};
    try std.testing.expect(s2.admit('Z'));
    try std.testing.expect(!s2.admit('C'));

    var s3 = OrderState{};
    try std.testing.expect(s3.admit('E'));
    try std.testing.expect(s3.admit('Z'));
    try std.testing.expect(!s3.admit('N')); // prev is Z, not P: exception inert
}

test "artifact §4.4-w1: the technote sequence C D E P N T U W Z is admitted" {
    // §A.1 worked example (lines 2471-2489) card letters in order; lines
    // 2498-2501: "the N card (MIME type) appears after the P card even
    // though N sorts before P in ASCII order. This is accepted because the
    // E card has already been parsed when N is encountered."
    var s = OrderState{};
    const sequence = "CDEPNTUWZ";
    for (sequence) |letter| try std.testing.expect(s.admit(letter));
}

// ---------------------------------------------------------------------------
// §4.5 Occurrence Classes and Intra-Type Ordering — tests
// ---------------------------------------------------------------------------

test "artifact §4.5.1: card letters partition into single / repeatable / neither" {
    // Lines 753-766: 14 single-occurrence, 6 repeatable, 1 (Z) neither —
    // 21 total, matching the closed alphabet.
    var single: usize = 0;
    var repeatable: usize = 0;
    var neither: usize = 0;
    var c: u8 = 'A';
    while (true) : (c += 1) {
        if (isCardLetter(c)) {
            switch (occurrenceClass(c)) {
                .single => single += 1,
                .repeatable => repeatable += 1,
                .neither => neither += 1,
            }
        }
        if (c == 'Z') break;
    }
    try std.testing.expectEqual(@as(usize, 14), single);
    try std.testing.expectEqual(@as(usize, 6), repeatable);
    try std.testing.expectEqual(@as(usize, 1), neither);
}

test "artifact §4.5.1-r1: a single-occurrence duplicate is rejected" {
    // Line 755-756: "Single-occurrence (a duplicate MUST be rejected): A, B,
    // C, D, E, G, H, I, K, L, N, R, U, W." Rejection: 2nd occurrence.
    const single_letters = "ABCDEGHIKLNRUW";
    try std.testing.expectEqual(@as(usize, 14), single_letters.len);
    for (single_letters) |letter| {
        var set = SingleOccurrenceSet{};
        try std.testing.expect(set.admit(letter));
        try std.testing.expect(!set.admit(letter));
    }
}

test "artifact §4.5.1-m1: repeatable card types are not single-occurrence" {
    // Lines 758-762: "Repeatable (each occurrence appends ...): F, J, M, T,
    // P, Q. It is not the case that any duplicate card type is a syntax
    // error." A duplicate must be admitted, not rejected.
    const repeatable_letters = "FJMTPQ";
    try std.testing.expectEqual(@as(usize, 6), repeatable_letters.len);
    for (repeatable_letters) |letter| {
        try std.testing.expectEqual(OccurrenceClass.repeatable, occurrenceClass(letter));
        var set = SingleOccurrenceSet{};
        try std.testing.expect(set.admit(letter));
        try std.testing.expect(set.admit(letter));
    }
}

test "artifact §4.5.1-m2: the Z card belongs to neither class and has no duplicate guard" {
    // Lines 764-766: "The Z card ... belongs to neither class. It has no
    // duplicate guard, and the ordering rule permits equal consecutive
    // letters."
    try std.testing.expectEqual(OccurrenceClass.neither, occurrenceClass('Z'));
    var set = SingleOccurrenceSet{};
    try std.testing.expect(set.admit('Z'));
    try std.testing.expect(set.admit('Z'));
    try std.testing.expectEqual(@as(?IntraRunRule, null), intraRunRule('Z'));
}

test "artifact §4.5.2-r1: F cards must strictly ascend by decoded filename" {
    // Table 2 line 792: "Strictly ascending by decoded filename (equal or
    // descending rejected)."
    try std.testing.expectEqual(IntraRunRule.ascending, intraRunRule('F').?);
    try std.testing.expectEqual(std.math.Order.lt, compareBytes("a", "b"));
    // Rejection: equal.
    try std.testing.expectEqual(std.math.Order.eq, compareBytes("a", "a"));
    // Rejection: descending.
    try std.testing.expectEqual(std.math.Order.gt, compareBytes("b", "a"));
}

test "artifact §4.5.2-m1: the F sort check runs on the post-decode filename" {
    // Lines 813-817: "runs on the post-decode filename, not on the raw
    // wire-encoded token (decoding happens before the sort check)." Wire
    // "a\sb" decodes (§4.3 Table 1, \s -> SP 0x20) to "a b"; wire "a!b" has
    // no escape and stays "a!b". Decoded 2nd byte 0x20 < 0x21 ascends.
    var buf1: [8]u8 = undefined;
    const decoded1 = decodeEscapes("a\\sb", &buf1);
    try std.testing.expectEqualStrings("a b", decoded1);
    const raw2 = "a!b";
    try std.testing.expectEqual(std.math.Order.lt, compareBytes(decoded1, raw2));

    // On the RAW wire token (undecoded), "a\sb" is 0x61 0x5C 0x73 0x62: 2nd
    // byte 0x5C > 0x21, so a raw-token compare would report .gt (reject) —
    // the opposite of the post-decode result, proving decode precedes sort.
    const raw1 = "a\\sb";
    try std.testing.expectEqual(std.math.Order.gt, compareBytes(raw1, raw2));
}

test "artifact §4.5.2-r2: J cards must strictly ascend by field name" {
    // Table 2 line 795: "Strictly ascending by field name (equal or
    // descending rejected)."
    try std.testing.expectEqual(IntraRunRule.ascending, intraRunRule('J').?);
    try std.testing.expectEqual(std.math.Order.lt, compareBytes("a", "b"));
    try std.testing.expectEqual(std.math.Order.eq, compareBytes("a", "a"));
    try std.testing.expectEqual(std.math.Order.gt, compareBytes("b", "a"));
}

test "artifact §4.5.2-r3: M cards must strictly ascend by hash" {
    // Table 2 line 798: "Strictly ascending by hash (equal or descending
    // rejected)."
    try std.testing.expectEqual(IntraRunRule.ascending, intraRunRule('M').?);
    try std.testing.expectEqual(std.math.Order.lt, compareBytes("aa", "ab"));
    try std.testing.expectEqual(std.math.Order.eq, compareBytes("aa", "aa"));
    try std.testing.expectEqual(std.math.Order.gt, compareBytes("ab", "aa"));
}

test "artifact §4.5.2-r4: T cards ascend two-level by tag name then target hash" {
    // Table 2 lines 801-802: "Two-level ascending by (tag name, then target
    // hash); equal names require strictly increasing hash."
    try std.testing.expectEqual(IntraRunRule.tag_two_level, intraRunRule('T').?);
    // Names ascend: hash irrelevant.
    try std.testing.expectEqual(std.math.Order.lt, compareTag("a", "zz", "b", "aa"));
    // Equal name, ascending hash.
    try std.testing.expectEqual(std.math.Order.lt, compareTag("a", "aa", "a", "ab"));
    // Rejection: equal name, equal hash.
    try std.testing.expectEqual(std.math.Order.eq, compareTag("a", "aa", "a", "aa"));
    // Rejection: equal name, descending hash.
    try std.testing.expectEqual(std.math.Order.gt, compareTag("a", "ab", "a", "aa"));
    // Rejection: descending name (hash ascending doesn't rescue it).
    try std.testing.expectEqual(std.math.Order.gt, compareTag("b", "aa", "a", "zz"));
}

test "artifact §4.5.2-m2: P cards are neither sorted nor deduplicated" {
    // Table 2 lines 804-805: "Not sorted or deduplicated; order is semantic
    // (primary parent first)."
    try std.testing.expectEqual(IntraRunRule.unordered, intraRunRule('P').?);
}

test "artifact §4.5.2-m3: Q cards are unordered-repeatable with no duplicate guard" {
    // Table 2 lines 807-808: "No duplicate guard or ordering check;
    // unordered-repeatable."
    try std.testing.expectEqual(IntraRunRule.unordered, intraRunRule('Q').?);
    var set = SingleOccurrenceSet{};
    try std.testing.expect(set.admit('Q'));
    try std.testing.expect(set.admit('Q'));
}

test "artifact §4.5.3-p1: the comparator is unsigned, case-sensitive, and normalization-free" {
    // Lines 827-829: "octets are compared as unsigned values,
    // locale-independent, with no Unicode normalization and no case
    // folding."
    try std.testing.expectEqual(std.math.Order.lt, compareBytes("A", "a")); // 0x41 < 0x61
    // 0x80 vs 0x7F: unsigned compare puts 0x80 above 0x7F (a signed i8
    // compare would invert this, since 0x80 as i8 is negative).
    try std.testing.expectEqual(
        std.math.Order.gt,
        compareBytes(&[_]u8{0x80}, &[_]u8{0x7F}),
    );
    // A precomposed vs. decomposed representation of the same visual
    // character (e.g. NFC "é" = 0xC3 0xA9 vs. NFD "e" + combining acute =
    // 0x65 0xCC 0x81) compares by raw octets, not by Unicode equivalence:
    // first byte 0xC3 > 0x65, so no normalization occurred.
    const nfc = [_]u8{ 0xC3, 0xA9 };
    const nfd = [_]u8{ 0x65, 0xCC, 0x81 };
    try std.testing.expectEqual(std.math.Order.gt, compareBytes(&nfc, &nfd));
}

test "artifact §4.5.3-m1: shorter sorts before an extension, absent before present" {
    // Lines 829-830: "A shorter string sorts before any extension of
    // itself, and an absent value sorts before any present value."
    try std.testing.expectEqual(std.math.Order.lt, compareBytes("abc", "abcd"));
    try std.testing.expectEqual(std.math.Order.lt, compareOptional(null, "x"));
    // Absent precedes even the empty string.
    try std.testing.expectEqual(std.math.Order.lt, compareOptional(null, ""));
    try std.testing.expectEqual(std.math.Order.eq, compareOptional(null, null));
    try std.testing.expectEqual(std.math.Order.eq, compareOptional("", ""));
}

test "artifact §4.5.3-m2: the directory separator receives no special treatment" {
    // Line 831: "The directory separator / (0x2F) receives no special
    // treatment."
    try std.testing.expectEqual(std.math.Order.lt, compareBytes("a.b", "a/b")); // 0x2E < 0x2F
    try std.testing.expectEqual(std.math.Order.lt, compareBytes("a/", "a0")); // 0x2F < 0x30
    // Shorter-first still wins even though '/' would otherwise look like a
    // path-component boundary worth privileging.
    try std.testing.expectEqual(std.math.Order.lt, compareBytes("a", "a/b"));
}

test "artifact §4.6.1-r1: an empty decoded name is rejected" {
    // Line 862: "it is empty".
    try std.testing.expect(!isSimplePathname(""));
}

test "artifact §4.6.1-r2: a leading slash is rejected" {
    // Line 864: "its first byte is /".
    try std.testing.expect(!isSimplePathname("/foo"));
    try std.testing.expect(!isSimplePathname("/"));
}

test "artifact §4.6.1-r3: the whole name being . or .. is rejected" {
    // Line 866: "the whole string is . or ..".
    try std.testing.expect(!isSimplePathname("."));
    try std.testing.expect(!isSimplePathname(".."));
}

test "artifact §4.6.1-r4: a name beginning ./ or ../ is rejected" {
    // Line 868: "it begins with ./ or ../".
    try std.testing.expect(!isSimplePathname("./a"));
    try std.testing.expect(!isSimplePathname("../a"));
}

test "artifact §4.6.1-r5: an embedded // is rejected" {
    // Line 870: "it contains //".
    try std.testing.expect(!isSimplePathname("a//b"));
}

test "artifact §4.6.1-r6: a trailing slash is rejected" {
    // Line 872: "it ends with /".
    try std.testing.expect(!isSimplePathname("a/"));
}

test "artifact §4.6.1-r7: a . or .. path element is rejected" {
    // Lines 874-875: "any path element equals . or .. -- that is, it
    // contains /./ or /../, or ends with /. or /..". Table-driven over all
    // four element positions the RFC text distinguishes.
    const cases = [_][]const u8{
        "a/./b", // interior /./
        "a/../b", // interior /../
        "a/.", // ends /.
        "a/..", // ends /..
    };
    for (cases) |name| try std.testing.expect(!isSimplePathname(name));
}

test "artifact §4.6.1-p1: the predicate accepts well-formed simple pathnames" {
    // Positive-space / "if and only if" side of the procedure clause
    // (line 858). Accept vectors from the §A.3 (lines 2561-2565) and §A.4
    // (lines 2618-2620) worked-example F-card filenames, plus a nested
    // path and two names that look dot-like but are not exact `.`/`..`
    // elements (r3/r7 are exact-match, so these must pass).
    const cases = [_][]const u8{
        "alpha.txt", "beta.txt", "gamma.txt", "file-a.txt",
        "a/b/c",     "...",      ".hidden",
    };
    for (cases) |name| try std.testing.expect(isSimplePathname(name));
}

test "artifact §4.6.1-m1: byte content does not affect acceptance" {
    // Lines 877-880: "backslashes, spaces ... control bytes, and
    // arbitrary or invalid UTF-8 sequences all pass. Accept or reject is
    // decidable from the separator structure alone."
    const accept_cases = [_][]const u8{
        "a\\b", // backslash 0x5C
        "a b", // space 0x20, reachable via \s decoding
        "a\x00b", // control byte
        "a\xffb", // arbitrary high byte
        "\xc3\x28", // invalid UTF-8 (lone continuation-less lead byte)
    };
    for (accept_cases) |name| try std.testing.expect(isSimplePathname(name));
    // A hostile name with bad separator structure still rejects, so
    // acceptance above is not vacuous permissiveness.
    try std.testing.expect(!isSimplePathname("a\xff/../b"));
}

test "artifact §4.6.1-m2: the writer profile does not constrain acceptance" {
    // Lines 888-890: "A validator of existing artifacts MUST apply only
    // the parser rules; the writer profile does not constrain acceptance."
    // This name violates both writer-only bans (lines 882-887): it has a
    // backslash and an invalid UTF-8 byte. The validator still accepts it
    // because its separator structure is clean.
    try std.testing.expect(isSimplePathname("dir/a\\\xffb.txt"));
}

test "artifact §4.6.2-r1: a leading space, control byte, or empty name is rejected" {
    // Lines 907-908: "its first byte is less than or equal to 0x20 (this
    // rejects the empty string, a leading space, and any leading control
    // byte)".
    try std.testing.expect(!isWikiName(""));
    try std.testing.expect(!isWikiName(" abc")); // leading 0x20
    try std.testing.expect(!isWikiName("\x00abc")); // leading NUL
    try std.testing.expect(!isWikiName("\x1fabc")); // leading control
}

test "artifact §4.6.2-r2: a control byte after the first is rejected" {
    // Lines 910-911: "any subsequent byte is less than 0x20 (all control
    // bytes, including tab and LF)".
    const cases = [_][]const u8{
        "a\tb", // 0x09 tab
        "a\nb", // 0x0A LF
        "a\x1fb", // interior control
        "ab\x01", // trailing control
    };
    for (cases) |name| try std.testing.expect(!isWikiName(name));
}

test "artifact §4.6.2-r3: two consecutive spaces are rejected" {
    // Line 913: "two consecutive space bytes (0x20 0x20) appear anywhere".
    try std.testing.expect(!isWikiName("a  b")); // interior double space
    try std.testing.expect(!isWikiName("ab   cd")); // triple contains a pair
}

test "artifact §4.6.2-r4: a trailing space is rejected" {
    // Line 915: "its final byte is a space (0x20)".
    try std.testing.expect(!isWikiName("abc "));
    try std.testing.expect(!isWikiName("a b "));
}

test "artifact §4.6.2-r5: a length outside 1 through 100 is rejected" {
    // Line 917: "its total length in bytes is outside 1 through 100
    // inclusive." Boundary accepts (1-byte and 100-byte) live in the p1
    // test to keep the iff both-sided.
    try std.testing.expect(!isWikiName("")); // 0 bytes
    try std.testing.expect(!isWikiName("x" ** 101)); // 101 bytes
}

test "artifact §4.6.2-p1: the predicate accepts well-formed wiki names" {
    // Positive/"if and only if" side of the procedure clause (line
    // 903-905).
    const cases = [_][]const u8{
        "A", // r5 lower bound, 1 byte
        "Wiki Page",
        "a b c", // single interior spaces are fine
        "x" ** 100, // r5 upper bound, 100 bytes
    };
    for (cases) |name| try std.testing.expect(isWikiName(name));
}

test "artifact §4.6.2-m1: bytes >= 0x21 are unconstrained; length is bytes not characters" {
    // Lines 919-921: "All bytes greater than or equal to 0x21 are
    // otherwise unconstrained: 0x7F and all bytes at or above 0x80 are
    // accepted, no UTF-8 validity check is performed, and length is
    // counted in bytes, not characters."
    const accept_cases = [_][]const u8{
        "a\x7fb", // 0x7F
        "a\x80b", // >=0x80
        "\xff\xfe", // arbitrary high bytes
        "\xc3\x28", // invalid UTF-8 (lone continuation-less lead byte)
    };
    for (accept_cases) |name| try std.testing.expect(isWikiName(name));
    // Length-in-bytes proof: 50 two-byte sequences = 100 bytes accepts;
    // 51 = 102 bytes rejects even though it is only 51 "characters".
    try std.testing.expect(isWikiName("\xc3\xa9" ** 50));
    try std.testing.expect(!isWikiName("\xc3\xa9" ** 51));
    // A control byte still rejects even amid high bytes, so acceptance
    // above is not vacuous permissiveness.
    try std.testing.expect(!isWikiName("a\x80\x01"));
}

test "artifact §4.6.3-p1: one sidecar suffix stripped, case-insensitively" {
    // Line 929: "ends, case-insensitively, with exactly one of -wal, -shm,
    // or -journal, strip that one suffix". "_FOSSIL_-WAL" strips to
    // "_FOSSIL_" (mixed case), which is then the whole-string tail (p2/p3a)
    // => reserved.
    try std.testing.expect(isReservedFilename("_FOSSIL_-WAL"));
    // "Exactly one": only the LAST-matching suffix strips. "-shm" strips
    // first (it's the trailing one), leaving "_FOSSIL_-wal", which ends
    // with neither reserved tail (line 934) => p2 fails => not reserved.
    try std.testing.expect(!isReservedFilename("_FOSSIL_-wal-shm"));
}

test "artifact §4.6.3-p2: reserved tail matched case-insensitively" {
    // Lines 933-934: "_FOSSIL_ (8 bytes) or .fslckout (9 bytes)",
    // case-insensitive.
    try std.testing.expect(isReservedFilename("_fossil_")); // lowercase tail
    try std.testing.expect(isReservedFilename(".FSLCKOUT")); // uppercase tail
    // No reserved tail present at all => p2 fails.
    try std.testing.expect(!isReservedFilename("readme.txt"));
}

test "artifact §4.6.3-p3: tail qualifies only via whole-string, slash, or sidecar" {
    // Line 936: "the reserved tail is the entire remaining string".
    try std.testing.expect(isReservedFilename("_FOSSIL_"));
    try std.testing.expect(isReservedFilename(".fslckout"));
    // Line 937: "the byte immediately before the tail is /".
    try std.testing.expect(isReservedFilename("dir/_FOSSIL_"));
    try std.testing.expect(isReservedFilename("dir/.fslckout"));
}

test "artifact §4.6.3-r1: a tail match satisfying no step-3 condition is not reserved" {
    // Line 940, verbatim worked counter-example: "A tail match that
    // satisfies none of step 3, such as x_FOSSIL_, is not reserved." Not
    // the whole string, the byte before the tail is 'x' not '/', and no
    // sidecar suffix was stripped.
    try std.testing.expect(!isReservedFilename("x_FOSSIL_"));
}

test "artifact §4.6.3-w1: a sidecar-suffix match is reserved via the step-1 strip" {
    // Lines 941-942, verbatim worked examples: "A sidecar-suffix match such
    // as x_FOSSIL_-wal or dir/.fslckout-journal is reserved because step 1
    // stripped a suffix." Neither would qualify under 3a/3b alone (tail
    // isn't the whole string and isn't preceded by '/' after the strip
    // leaves "x_FOSSIL_" / "dir/.fslckout"), so only 3c (sidecar stripped)
    // explains the reserved verdict.
    try std.testing.expect(isReservedFilename("x_FOSSIL_-wal"));
    try std.testing.expect(isReservedFilename("dir/.fslckout-journal"));
}

test "artifact §4.6.3-m1: excludes platform checkout filters and Windows device names" {
    // Lines 943-945: "The predicate is limited to Fossil repository
    // metadata filenames; it does not include platform checkout filters or
    // Windows device names such as CON." CON has no reserved tail, so p2
    // fails outright -- this locks the scope, not merely a coincidence of
    // step 3.
    try std.testing.expect(!isReservedFilename("CON"));
}

test "artifact §4.6.3-r2: reserved-ness is a bool drop signal, never a parse error" {
    // Line 961: "MUST be silently dropped, not rejected." Locked
    // structurally: the return type is bool, not an error union, so there
    // is no rejection variant for a reserved name to be mistaken for.
    try std.testing.expectEqual(bool, @TypeOf(isReservedFilename("_FOSSIL_")));
    try std.testing.expect(isReservedFilename("_FOSSIL_"));
}

test "artifact §4.6.3: sidecar strip with no remaining reserved tail is not reserved" {
    // "foo-wal" strips to "foo" (step 1), which ends with neither reserved
    // tail (step 2 fails) => not reserved. Covers the sidecar-strip path
    // that does NOT lead to a match.
    try std.testing.expect(!isReservedFilename("foo-wal"));
}

test "artifact §4.6.3: the empty string is not reserved" {
    // No reserved tail can match an empty remaining string; also exercises
    // the underflow-guard boundary (remaining.len < tail_len).
    try std.testing.expect(!isReservedFilename(""));
}

test "artifact §4.6.3-m2: sort check runs before the reserved-name drop" {
    // Lines 967-969: "the F card sort check (Section 4.5.2) runs before
    // the reserved-name drop, so a reserved-named F card must itself be in
    // sort position relative to the previously retained entry." Anchor
    // starts at "z"; "_FOSSIL_" is reserved but sorts BEFORE "z"
    // ('_' 0x5F < 'z' 0x7A), so the order check rejects it first -- it is
    // never reached far enough to be dropped.
    var adm = FCardAdmission{};
    try std.testing.expect(try adm.admit("z"));
    try std.testing.expectError(FCardSortError.OutOfOrder, adm.admit("_FOSSIL_"));
    // Reserved-ness alone never rescues an out-of-order name: "a" is not
    // reserved and is also out of order against anchor "z".
    try std.testing.expectError(FCardSortError.OutOfOrder, adm.admit("a"));
}

test "artifact §4.6.3-m2: the dropped name never becomes the comparison anchor" {
    // Lines 969-972: "the next F card is compared against that same
    // retained entry. The dropped name never becomes the comparison
    // anchor." "dir/_FOSSIL_" is in sort order after "a" (passes §4.5.2-r1)
    // and is reserved (3b, preceded by '/'), so it is dropped -- anchor
    // stays "a". "b" then proves the anchor is still "a": "b" sorts after
    // "a" but BEFORE "dir/_FOSSIL_" ('b' 0x62 < 'd' 0x64), so if the dropped
    // name had wrongly become the anchor, "b" would fail the order check.
    var adm = FCardAdmission{};
    try std.testing.expect(try adm.admit("a"));
    try std.testing.expect(!(try adm.admit("dir/_FOSSIL_"))); // dropped
    try std.testing.expect(try adm.admit("b")); // compared against "a", not "dir/_FOSSIL_"
}

test "artifact §4.6.3-r2: a dropped reserved F card is removed from the effective file set" {
    // Lines 961-963: "An F card whose decoded current filename is reserved
    // MUST be silently dropped, not rejected. Dropping removes the card
    // from the effective file set." Feed three cards in wire order and
    // collect the retained ones: the reserved middle card must be absent
    // from the resulting set.
    const wire_order = [_][]const u8{ "a", "dir/_FOSSIL_", "b" };
    var effective_set: [wire_order.len][]const u8 = undefined;
    var effective_len: usize = 0;
    var adm = FCardAdmission{};
    for (wire_order) |name| { // bound: wire_order.len, fixed at 3
        if (try adm.admit(name)) {
            std.debug.assert(effective_len < wire_order.len);
            effective_set[effective_len] = name;
            effective_len += 1;
        }
    }
    try std.testing.expectEqual(@as(usize, 2), effective_len);
    try std.testing.expectEqualStrings("a", effective_set[0]);
    try std.testing.expectEqualStrings("b", effective_set[1]);
}

test "artifact §4.6.3: the rule tests only the current filename, never old-name" {
    // Lines 957-959: "This rule applies only to the current filename field
    // of an F card, after escape decoding. The old-name field is not
    // tested by this reserved-name rule." Reinforced at lines 1202-1203.
    // Simulate a rename FROM a reserved name TO a plain one: old-name
    // "_FOSSIL_" is reserved (sanity check below), but `admit` has exactly
    // one filename parameter -- the decoded current name -- so old-name
    // never reaches the check. The card is retained, not dropped.
    const old_name = "_FOSSIL_";
    try std.testing.expect(isReservedFilename(old_name)); // would drop, if tested
    var adm = FCardAdmission{};
    try std.testing.expect(try adm.admit("plain.txt")); // current name only: not reserved
}

// ---------------------------------------------------------------------------
// §4.6.4 Timestamps
// ---------------------------------------------------------------------------

test "artifact §4.6.4-p1: the token is classified raw, never escape-decoded" {
    // p1: "The D and E card timestamps are each a single raw (never
    // decoded) token." Feed a wire token containing a literal backslash
    // (0x5C) followed by 't' (0x74) where a decoded tab would sit: if this
    // were escape-decoded first, \t -> 0x09 (whitespace) would satisfy the
    // m1 separator and the token would classify as date_time. Undecoded, the
    // separator scan sees 0x5C, which is neither 'T' nor whitespace, so the
    // date-time branch has zero separator bytes and rejects.
    const token = "2017-12-06\\t15:36:24";
    try std.testing.expect(classifyTimestamp(token) == null);
}

test "artifact §4.6.4-m1: date-time accepts T and whitespace separators, optional :SS/.frac" {
    // m1: "a date-time: an optional leading -, then YYYY-MM-DD, then one or
    // more separator bytes each of which is either a whitespace-class byte
    // or the uppercase letter T, then HH:MM, HH:MM:SS, or HH:MM:SS. followed
    // by one or more digits"
    const cases = [_][]const u8{
        "2017-12-06T15:36:24",
        "2017-12-06T15:36",
        "2017-12-06T15:36:24.287", // w1
        "2017-12-06\x0915:36:24", // tab separator (whitespace class)
        "2017-12-06TT15:36", // 1+ separator bytes
        "-2017-12-06T15:36:24", // optional leading '-'
    };
    for (cases) |c| { // bound: cases.len, fixed at 6
        try std.testing.expect(classifyTimestamp(c).? == .date_time);
    }
}

test "artifact §4.6.4-m2: optional timezone, +HH:MM / -HH:MM / Z / z, optional leading ws" {
    // m2: "then an optional timezone of optional whitespace plus +HH:MM,
    // -HH:MM, Z, or z"
    const cases = [_][]const u8{
        "2017-12-06T15:36:24+05:30",
        "2017-12-06T15:36:24-08:00",
        "2017-12-06T15:36:24Z",
        "2017-12-06T15:36:24z",
        "2017-12-06T15:36:24\x09Z", // optional whitespace before tz
    };
    for (cases) |c| { // bound: cases.len, fixed at 5
        try std.testing.expect(classifyTimestamp(c).? == .date_time);
    }
}

test "artifact §4.6.4-r1: lowercase t as the separator is rejected" {
    // r1 verbatim: "Lowercase t as the separator is rejected."
    try std.testing.expect(classifyTimestamp("2017-12-06t15:36:24") == null);
}

test "artifact §4.6.4-m3: date only, with and without leading -" {
    // m3: "A date only: optional leading -, then YYYY-MM-DD."
    try std.testing.expect(classifyTimestamp("2017-12-06").? == .date);
    try std.testing.expect(classifyTimestamp("-2017-12-06").? == .date);
}

test "artifact §4.6.4-m4: time only is interpreted on 2000-01-01" {
    // m4: "A time only: HH:MM, optionally :SS and a fraction, interpreted
    // as a time on 2000-01-01." Year 2000 is always well past the r3
    // boundary, so the gate never has anything to reject for this form.
    const cases = [_][]const u8{ "15:36", "15:36:24", "15:36:24.287" };
    for (cases) |c| { // bound: cases.len, fixed at 3
        try std.testing.expect(classifyTimestamp(c).? == .time);
    }
}

test "artifact §4.6.4-m5: now/subsec/subsecond, case-insensitive" {
    // m5 verbatim: "The literals now, subsec, or subsecond, case-insensitive."
    const cases = [_][]const u8{ "now", "NOW", "Now", "subsec", "SubSec", "subsecond", "SUBSECOND" };
    for (cases) |c| { // bound: cases.len, fixed at 7
        try std.testing.expect(classifyTimestamp(c).? == .relative);
    }
}

test "artifact §4.6.4-m6: bare decimal accepted as Julian day iff 0 <= r < 5373484.5" {
    // m6: "A bare decimal number, treated as a Julian day number when its
    // value r satisfies 0 <= r < 5373484.5." (r3 tightens the lower bound
    // to strictly greater than zero; covered separately below.)
    try std.testing.expect(classifyTimestamp("2451545").? == .julian);
    try std.testing.expect(classifyTimestamp("2451545.0").? == .julian);
    try std.testing.expect(classifyTimestamp("0.5").? == .julian);
    try std.testing.expect(classifyTimestamp("5373484.4").? == .julian);
    try std.testing.expect(classifyTimestamp("5373484.5") == null); // upper bound exclusive
    try std.testing.expect(classifyTimestamp("5373485") == null);
}

test "artifact §4.6.4-m7: exact digit counts and field ranges" {
    // m7 verbatim: "the year is exactly 4 digits, the month exactly 2
    // digits in 01-12, the day exactly 2 digits in 01-31 with no
    // calendar-aware check (2024-02-31 is accepted), the hour 2 digits in
    // 00-24, the minute and second 2 digits each in 00-59, and the fraction
    // one or more digits."
    const rejects = [_][]const u8{
        "2017-1-06", // month 1 digit
        "2017-13-06", // month 13
        "2017-12-00", // day 00
        "2017-12-32", // day 32
        "2017-12-06T25:36", // hour 25
        "2017-12-06T15:60", // minute 60
        "2017-12-06T15:36:60", // second 60
        "201-12-06", // year 3 digits
        "20171-12-06", // year 5 digits
        "2017-12-06T15:36:24.", // fraction 0 digits
    };
    for (rejects) |c| { // bound: rejects.len, fixed at 10
        try std.testing.expect(classifyTimestamp(c) == null);
    }
    try std.testing.expect(classifyTimestamp("2017-12-31").? == .date);
    try std.testing.expect(classifyTimestamp("2024-02-31").? == .date); // no calendar check
    try std.testing.expect(classifyTimestamp("2017-12-06T24:00").? == .date_time); // hour 24 in 00-24
}

test "artifact §4.6.4-r3: JD>0 gate at the -4713-11-24 boundary and numeric 0" {
    // r3: "the token's value expressed as a Julian day number MUST be
    // greater than zero; this additionally rejects dates at or before
    // -4713-11-24 and the numeric token 0." RESOLVED (ambiguities.md,
    // 2026-07-09): date-granularity reading -- reject iff
    // (year,month,day) <= (-4713,11,24), independent of time-of-day.
    const rejects = [_][]const u8{
        "-4713-11-24",
        "-4713-11-24T18:00:00", // any time on the boundary date still rejects
        "-4713-11-23",
        "-4713-10-31",
        "-4714-01-01",
        "0",
        "0.0",
    };
    for (rejects) |c| { // bound: rejects.len, fixed at 7
        try std.testing.expect(classifyTimestamp(c) == null);
    }
    try std.testing.expect(classifyTimestamp("-4713-11-25").? == .date);
    try std.testing.expect(classifyTimestamp("-4713-12-01").? == .date);
    try std.testing.expect(classifyTimestamp("-4712-01-01").? == .date);
}

test "artifact §4.6.4-m8: the writer form is the interoperable absolute date-time" {
    // m8: "For stored structural artifacts whose D or E card timestamp can
    // feed repository-state ordering, a producer MUST emit the absolute
    // date-time form above." isWriterTimestamp is the oracle for that exact
    // canonical subset: YYYY-MM-DDTHH:MM:SS[.frac], no sign, no tz.
    try std.testing.expect(isWriterTimestamp("2017-12-06T15:36:24.287")); // w1
    try std.testing.expect(isWriterTimestamp("2017-12-06T15:36:24"));
    const rejects = [_][]const u8{
        "2017-12-06T15:36", // no :SS
        "2017-12-06", // date only
        "2017-12-06T15:36:24Z", // tz present
        "-2017-12-06T15:36:24", // leading sign
        "2017-12-06\x0915:36:24", // non-'T' separator
    };
    for (rejects) |c| { // bound: rejects.len, fixed at 5
        try std.testing.expect(!isWriterTimestamp(c));
    }
}

test "artifact §4.6.4-s1: a reader accepts the broader forms 2 through 5" {
    // s1: "A reader MAY accept the broader forms in items 2 through 5 for
    // compatibility." Realized as classifyTimestamp acceptance of one
    // representative token per non-date-time form.
    try std.testing.expect(classifyTimestamp("2017-12-06").? == .date);
    try std.testing.expect(classifyTimestamp("15:36:24").? == .time);
    try std.testing.expect(classifyTimestamp("now").? == .relative);
    try std.testing.expect(classifyTimestamp("2451545").? == .julian);
}

test "artifact §4.6.4-m9: relative literals are classified, never resolved against a clock" {
    // m9: "now, subsec, and subsecond are resolved against parse-time clock
    // state rather than denoting a stable instant in the artifact bytes.
    // Two ingestions of identical bytes can therefore yield different
    // stored mtime values." classifyTimestamp does no clock resolution at
    // all (that's [FossilRepoState]'s job); it is pure, so two calls on the
    // identical bytes always agree on the FORM even though a later resolver
    // may pick different instants.
    try std.testing.expect(classifyTimestamp("now").? == .relative);
    try std.testing.expect(classifyTimestamp("subsec").? == .relative);
    try std.testing.expect(classifyTimestamp("subsecond").? == .relative);
    try std.testing.expect(classifyTimestamp("now").? == classifyTimestamp("now").?);
}

test "artifact §4.6.4-w1: the technote worked example's D card timestamp" {
    // w1 verbatim: "D 2017-12-06T15:36:24.287" -- the token after the card
    // letter and space is the date-time form with a fractional second, and
    // it is also the exact writer-canonical form (no sign, exactly 'T', SS
    // present, no tz).
    const token = "2017-12-06T15:36:24.287";
    try std.testing.expect(classifyTimestamp(token).? == .date_time);
    try std.testing.expect(isWriterTimestamp(token));
}

// ---------------------------------------------------------------------------
// §4.7.2 A Card
// ---------------------------------------------------------------------------

const A_CARD_HASH40: []const u8 = "0123456789" ** 4; // 40 hex chars
const A_CARD_HASH64: []const u8 = "0123456789abcdef" ** 4; // 64 hex chars

comptime {
    std.debug.assert(A_CARD_HASH40.len == 40);
    std.debug.assert(A_CARD_HASH64.len == 64);
}

test "artifact §4.7.2-p1: A card accepts filename+target and filename+target+source" {
    // Grammar: `a-card = "A" SP filename SP target [ SP source ] LF`
    // (line 1074) -- both the 2-token and 3-token forms are valid.
    var buf: [256]u8 = undefined;
    const two = tokenizeCardLine("A alpha.txt " ++ A_CARD_HASH40 ++ "\n").?;
    const parsed_two = parseACard(two, &buf).?;
    try std.testing.expectEqualStrings("alpha.txt", parsed_two.name);
    try std.testing.expect(parsed_two.source == null);

    const three = tokenizeCardLine("A alpha.txt " ++ A_CARD_HASH40 ++ " " ++ A_CARD_HASH64 ++ "\n").?;
    const parsed_three = parseACard(three, &buf).?;
    try std.testing.expectEqualStrings("alpha.txt", parsed_three.name);
    try std.testing.expectEqualStrings(A_CARD_HASH64, parsed_three.source.?);
}

test "artifact §4.7.2-p1/§4.2.1: A card arity is exact" {
    // p1: filename and target are both required. §4.2.1 line 610: "a
    // fourth on A" -- the grammar has no room for a fourth token.
    var buf: [256]u8 = undefined;
    const zero = tokenizeCardLine("A\n").?;
    try std.testing.expect(parseACard(zero, &buf) == null);
    const one = tokenizeCardLine("A alpha.txt\n").?;
    try std.testing.expect(parseACard(one, &buf) == null);
    const four = tokenizeCardLine("A alpha.txt " ++ A_CARD_HASH40 ++ " " ++ A_CARD_HASH64 ++ " extra\n").?;
    try std.testing.expect(parseACard(four, &buf) == null);
}

test "artifact §4.7.2-p1/§4.5.1: a second A card is a syntax error" {
    // "a second A card is a syntax error" (line 1077); A is single-
    // occurrence (occurrenceClass('A') == .single, line 575).
    var set = SingleOccurrenceSet{};
    try std.testing.expect(set.admit('A'));
    try std.testing.expect(!set.admit('A'));
}

test "artifact §4.7.2-r1: the filename must pass the pathname predicate" {
    // "MUST pass the pathname predicate (Section 4.6.1)" (line 1083).
    var buf: [256]u8 = undefined;
    const cases = [_][]const u8{
        "A /etc " ++ A_CARD_HASH40 ++ "\n", // leading '/' (§4.6.1-r2)
        "A  " ++ A_CARD_HASH40 ++ "\n", // empty filename token (§4.6.1-r1)
        "A a/../b " ++ A_CARD_HASH40 ++ "\n", // path element .. (§4.6.1-r7)
    };
    for (cases) |line| {
        const card = tokenizeCardLine(line).?;
        try std.testing.expect(parseACard(card, &buf) == null);
    }
}

test "artifact §4.7.2-m1: only the final path component is retained" {
    // "Only its final path component is retained ... any directory
    // prefix is discarded" (lines 1084-1086).
    var buf: [256]u8 = undefined;
    const cases = [_]struct { line: []const u8, want: []const u8 }{
        .{ .line = "A dir/sub/f.txt " ++ A_CARD_HASH40 ++ "\n", .want = "f.txt" },
        .{ .line = "A a/b " ++ A_CARD_HASH40 ++ "\n", .want = "b" },
        .{ .line = "A x " ++ A_CARD_HASH40 ++ "\n", .want = "x" },
    };
    for (cases) |c| {
        const card = tokenizeCardLine(c.line).?;
        const parsed = parseACard(card, &buf).?;
        try std.testing.expectEqualStrings(c.want, parsed.name);
    }
}

test "artifact §4.7.2-r2: target is a valid hash or well-formed wiki name" {
    // "syntactically either a valid artifact hash or a well-formed wiki
    // name" (lines 1088-1089).
    var buf: [256]u8 = undefined;
    const accept = [_][]const u8{
        "A x " ++ A_CARD_HASH40 ++ "\n",
        "A x " ++ A_CARD_HASH64 ++ "\n",
        "A x My\\sPage\n", // decodes to "My Page", a well-formed wiki name
    };
    for (accept) |line| {
        const card = tokenizeCardLine(line).?;
        try std.testing.expect(parseACard(card, &buf) != null);
    }
    // Fails both alternatives: not 40/64 hex, and >100 bytes (wiki r5).
    const reject_line = "A x " ++ ("g" ** 101) ++ "\n";
    const reject_card = tokenizeCardLine(reject_line).?;
    try std.testing.expect(parseACard(reject_card, &buf) == null);
}

test "artifact §4.7.2-r2/§4.3.1: the target is escape-decoded before validation" {
    // Wire `\a` + 39x'0' decodes to `a` + 39x'0' -- 40 hex chars, accepted
    // as a hash. 'a' is not one of the 8 recognized escape successors
    // (escapeOctet), so the unrecognized-escape rule applies: emit the
    // successor, drop the backslash (§4.3.1 lines 694-698).
    var buf: [256]u8 = undefined;
    const wire_target = "\\a" ++ ("0" ** 39);
    const line = "A x " ++ wire_target ++ "\n";
    const card = tokenizeCardLine(line).?;
    const parsed = parseACard(card, &buf).?;
    try std.testing.expectEqualStrings("a" ++ ("0" ** 39), parsed.target);
}

test "artifact §4.7.2-r3: a present source is a raw, never-decoded artifact hash" {
    // "a raw (never decoded) artifact hash validated per the hash map"
    // (line 1093); §4.3.1 line 680: "but not the source hash".
    var buf: [256]u8 = undefined;
    const accept40 = tokenizeCardLine("A x " ++ A_CARD_HASH40 ++ " " ++ A_CARD_HASH40 ++ "\n").?;
    try std.testing.expectEqualStrings(A_CARD_HASH40, parseACard(accept40, &buf).?.source.?);

    const accept64 = tokenizeCardLine("A x " ++ A_CARD_HASH40 ++ " " ++ A_CARD_HASH64 ++ "\n").?;
    try std.testing.expectEqualStrings(A_CARD_HASH64, parseACard(accept64, &buf).?.source.?);

    const reject = tokenizeCardLine("A x " ++ A_CARD_HASH40 ++ " xyz\n").?;
    try std.testing.expect(parseACard(reject, &buf) == null);

    // Decode asymmetry: the identical 41 wire bytes that decode to a
    // 40-hex TARGET (previous test) are read RAW in the source slot -- 41
    // raw bytes match neither 40 nor 64, so this rejects.
    const wire = "\\a" ++ ("0" ** 39);
    const asymmetry = tokenizeCardLine("A x " ++ A_CARD_HASH40 ++ " " ++ wire ++ "\n").?;
    try std.testing.expect(parseACard(asymmetry, &buf) == null);
}

test "artifact §4.7.2-m2: an absent source is empty (null)" {
    // "When absent, the source field is empty" (lines 1094-1095).
    var buf: [256]u8 = undefined;
    const card = tokenizeCardLine("A x " ++ A_CARD_HASH40 ++ "\n").?;
    try std.testing.expect(parseACard(card, &buf).?.source == null);
}

// ---------------------------------------------------------------------------
// §4.7.3 B Card
// ---------------------------------------------------------------------------

test "artifact §4.7.3-p1: B card accepts one hash token, 40 or 64 hex" {
    // "Exactly one hash token, validated per the hash map" (line 1129).
    const forty = tokenizeCardLine("B " ++ A_CARD_HASH40 ++ "\n").?;
    try std.testing.expectEqualStrings(A_CARD_HASH40, parseBCard(forty).?.hash);

    const sixtyfour = tokenizeCardLine("B " ++ A_CARD_HASH64 ++ "\n").?;
    try std.testing.expectEqualStrings(A_CARD_HASH64, parseBCard(sixtyfour).?.hash);

    // A second token has no grammar slot to land in.
    const extra = tokenizeCardLine("B " ++ A_CARD_HASH40 ++ " x\n").?;
    try std.testing.expect(parseBCard(extra) == null);
}

test "artifact §4.7.3-r1: b-card grammar rejects bad arity and non-hash" {
    // `b-card = %x42 SP hash LF` (line 1127).
    try std.testing.expect(parseBCard(tokenizeCardLine("B\n").?) == null);
    try std.testing.expect(parseBCard(tokenizeCardLine("B " ++ A_CARD_HASH40 ++ " x\n").?) == null);
    try std.testing.expect(parseBCard(tokenizeCardLine("B xyz\n").?) == null);
    try std.testing.expect(parseBCard(tokenizeCardLine("B " ++ A_CARD_HASH40[0..39] ++ "\n").?) == null);
}

// ---------------------------------------------------------------------------
// §4.7.4 C Card
// ---------------------------------------------------------------------------

test "artifact §4.7.4-p1: C requires exactly one escape-decoded comment" {
    // A.1.2 (line 2471): "C Test\stech-note" decodes to "Test tech-note".
    var buf: [256]u8 = undefined;
    const card = tokenizeCardLine("C Test\\stech-note\n").?;
    try std.testing.expectEqualStrings("Test tech-note", parseCCard(card, &buf).?.comment);

    // Two tokens: no second grammar slot.
    const two_tok = tokenizeCardLine("C a b\n").?;
    try std.testing.expect(parseCCard(two_tok, &buf) == null);
}

test "artifact §4.7.4-r1: a C card with no token MUST be rejected" {
    // "a C card with no token MUST be rejected" (line 1143).
    var buf: [256]u8 = undefined;
    try std.testing.expect(parseCCard(tokenizeCardLine("C\n").?, &buf) == null);
    // Empty token: `comment = 1*tchar` (Appendix B) needs >=1 byte.
    try std.testing.expect(parseCCard(tokenizeCardLine("C \n").?, &buf) == null);
}

test "artifact §4.7.4-m1: the decoded comment is otherwise unvalidated" {
    // "The decoded comment content is otherwise unvalidated" (lines
    // 1143-1144) -- a comment decoding to control bytes still parses.
    var buf: [256]u8 = undefined;
    const card = tokenizeCardLine("C \\t\\n\n").?;
    try std.testing.expectEqualStrings("\t\n", parseCCard(card, &buf).?.comment);
}

// ---------------------------------------------------------------------------
// §4.7.5 D Card and E Card
// ---------------------------------------------------------------------------

const D_CARD_TS = "2017-12-06T15:36:24.287"; // A.1.2 line 2472
const E_CARD_ID = "bfdf42305400f6caa441fa816cb89231e90f8c82"; // A.1.2 line 2473, 40 hex

test "artifact §4.7.5-p1: D and E timestamps validate per §4.6.4 (raw)" {
    // "Each timestamp is a single raw token validated per Section 4.6.4"
    // (line 1153) -- .timestamp equals the wire bytes verbatim, never
    // decoded.
    const d = tokenizeCardLine("D " ++ D_CARD_TS ++ "\n").?;
    try std.testing.expectEqualStrings(D_CARD_TS, parseDCard(d).?.timestamp);

    const e = tokenizeCardLine("E " ++ D_CARD_TS ++ " " ++ E_CARD_ID ++ "\n").?;
    try std.testing.expectEqualStrings(D_CARD_TS, parseECard(e).?.timestamp);
}

test "artifact §4.7.5-p2: E requires a technote-id per the hash map" {
    // `e-card = ... SP technote-id LF` (line 1151). EVENT-type marking is
    // §5.3 type resolution, deferred to the manifest-assembly batch.
    const with_id = tokenizeCardLine("E " ++ D_CARD_TS ++ " " ++ E_CARD_ID ++ "\n").?;
    try std.testing.expectEqualStrings(E_CARD_ID, parseECard(with_id).?.technote_id);

    const missing_id = tokenizeCardLine("E " ++ D_CARD_TS ++ "\n").?;
    try std.testing.expect(parseECard(missing_id) == null);
}

test "artifact §4.7.5-r1: d-card / e-card grammars reject bad arity" {
    // Lines 1150-1151.
    try std.testing.expect(parseDCard(tokenizeCardLine("D\n").?) == null);
    try std.testing.expect(parseDCard(tokenizeCardLine("D " ++ D_CARD_TS ++ " b\n").?) == null);
    try std.testing.expect(parseECard(tokenizeCardLine("E\n").?) == null);
    try std.testing.expect(parseECard(tokenizeCardLine("E " ++ D_CARD_TS ++ "\n").?) == null);
    try std.testing.expect(parseECard(tokenizeCardLine("E " ++ D_CARD_TS ++ " " ++ E_CARD_ID ++ " x\n").?) == null);
}

test "artifact §4.7.5-r2: a timestamp failing §4.6.4 is rejected" {
    // "validated per Section 4.6.4" (line 1152).
    try std.testing.expect(parseDCard(tokenizeCardLine("D \n").?) == null); // empty
    try std.testing.expect(parseDCard(tokenizeCardLine("D banana\n").?) == null);
    try std.testing.expect(parseDCard(tokenizeCardLine("D 2017-12-06t15:36:24\n").?) == null); // r1 lowercase t
    // RESOLVED date-granularity gate (§4.6.4, ambiguities.md): reject at or
    // before -4713-11-24 regardless of time-of-day.
    try std.testing.expect(parseDCard(tokenizeCardLine("D -4713-11-24T18:00:00\n").?) == null);
}

test "artifact §4.7.5-r3: E technote-id validated per the hash map (raw)" {
    // "a technote identifier validated per the hash map" (line 1152);
    // §4.7.1 -- hash/checksum positions are never escape-decoded.
    const accept40 = tokenizeCardLine("E " ++ D_CARD_TS ++ " " ++ A_CARD_HASH40 ++ "\n").?;
    try std.testing.expect(parseECard(accept40) != null);
    const accept64 = tokenizeCardLine("E " ++ D_CARD_TS ++ " " ++ A_CARD_HASH64 ++ "\n").?;
    try std.testing.expect(parseECard(accept64) != null);

    try std.testing.expect(parseECard(tokenizeCardLine("E " ++ D_CARD_TS ++ " " ++ A_CARD_HASH40[0..39] ++ "\n").?) == null);
    try std.testing.expect(parseECard(tokenizeCardLine("E " ++ D_CARD_TS ++ " " ++ A_CARD_HASH40 ++ "1\n").?) == null);
    try std.testing.expect(parseECard(tokenizeCardLine("E " ++ D_CARD_TS ++ " xyz\n").?) == null);

    // A wire token whose escape decode would be 40 hex is read RAW here --
    // 41 raw bytes match neither 40 nor 64, so it rejects (raw != decoded).
    const wire_id = "\\a" ++ ("0" ** 39);
    try std.testing.expect(parseECard(tokenizeCardLine("E " ++ D_CARD_TS ++ " " ++ wire_id ++ "\n").?) == null);
}

test "artifact §4.7.5-w1: A.1.2 D and E cards parse" {
    // A.1.2, lines 2472-2473, exact bytes.
    const d = tokenizeCardLine("D 2017-12-06T15:36:24.287\n").?;
    const d_parsed = parseDCard(d).?;
    try std.testing.expectEqualStrings("2017-12-06T15:36:24.287", d_parsed.timestamp);

    const e = tokenizeCardLine("E 2013-08-26T07:35:00 bfdf42305400f6caa441fa816cb89231e90f8c82\n").?;
    const e_parsed = parseECard(e).?;
    try std.testing.expectEqualStrings("2013-08-26T07:35:00", e_parsed.timestamp);
    try std.testing.expectEqualStrings("bfdf42305400f6caa441fa816cb89231e90f8c82", e_parsed.technote_id);
    try std.testing.expectEqual(@as(usize, 40), e_parsed.technote_id.len); // SHA-1
}

// ---------------------------------------------------------------------------
// §4.7.6 F Card
// ---------------------------------------------------------------------------

test "artifact §4.7.6-p1: F parses across its positional forms" {
    // `f-card = "F" SP filename [ SP hash [ SP perm [ SP old-name ] ] ] LF`
    // (line 1160-1161) -- 1/2/3/4-token forms all valid; a 5th rejects.
    var buf: [256]u8 = undefined;
    const one = tokenizeCardLine("F f\n").?;
    try std.testing.expect(parseFCard(one, &buf) != null);

    const two = tokenizeCardLine("F f " ++ A_CARD_HASH40 ++ "\n").?;
    try std.testing.expect(parseFCard(two, &buf) != null);

    const three = tokenizeCardLine("F f " ++ A_CARD_HASH40 ++ " w\n").?;
    try std.testing.expect(parseFCard(three, &buf) != null);

    const four = tokenizeCardLine("F f " ++ A_CARD_HASH40 ++ " w old\n").?;
    try std.testing.expect(parseFCard(four, &buf) != null);

    const five = tokenizeCardLine("F f " ++ A_CARD_HASH40 ++ " w old extra\n").?;
    try std.testing.expect(parseFCard(five, &buf) == null);
}

test "artifact §4.7.6-p1: an empty perm token is rejected (token = 1*tchar)" {
    // `perm = token`, `token = 1*tchar` (Appendix B, lines 2846, 2852) -- no
    // production admits a zero-length token, so a trailing SP after the hash
    // (tokenizes to an empty 3rd token) must reject rather than silently
    // become perm=="". Same slip via a double SP ahead of old-name: the 3rd
    // token is still empty even though a 4th token follows.
    var buf: [256]u8 = undefined;
    try std.testing.expect(parseFCard(tokenizeCardLine("F f " ++ A_CARD_HASH40 ++ " \n").?, &buf) == null);
    try std.testing.expect(parseFCard(tokenizeCardLine("F f " ++ A_CARD_HASH40 ++ "  old\n").?, &buf) == null);
}

test "artifact §4.7.6-r1: the F filename is not optional" {
    var buf: [256]u8 = undefined;
    try std.testing.expect(parseFCard(tokenizeCardLine("F\n").?, &buf) == null);
    try std.testing.expect(parseFCard(tokenizeCardLine("F \n").?, &buf) == null); // empty
}

test "artifact §4.7.6-m1/r2: filename escape-decoded and held to §4.6.1" {
    // Line 1163-1164.
    var buf: [256]u8 = undefined;
    const good = tokenizeCardLine("F a\\sb " ++ A_CARD_HASH40 ++ "\n").?;
    try std.testing.expectEqualStrings("a b", parseFCard(good, &buf).?.filename);

    const bad_cases = [_][]const u8{
        "F /etc " ++ A_CARD_HASH40 ++ "\n", // §4.6.1-r2 leading '/'
        "F a/../b " ++ A_CARD_HASH40 ++ "\n", // §4.6.1-r7 '..'
        "F  " ++ A_CARD_HASH40 ++ "\n", // empty filename token
    };
    for (bad_cases) |line| {
        try std.testing.expect(parseFCard(tokenizeCardLine(line).?, &buf) == null);
    }
}

test "artifact §4.7.6-r3: F hash, when present, is exactly 40 or 64 hex" {
    var buf: [256]u8 = undefined;
    try std.testing.expect(parseFCard(tokenizeCardLine("F f " ++ A_CARD_HASH40 ++ "\n").?, &buf) != null);
    try std.testing.expect(parseFCard(tokenizeCardLine("F f " ++ A_CARD_HASH64 ++ "\n").?, &buf) != null);
    try std.testing.expect(parseFCard(tokenizeCardLine("F f xyz\n").?, &buf) == null);
    try std.testing.expect(parseFCard(tokenizeCardLine("F f " ++ A_CARD_HASH40[0..39] ++ "\n").?, &buf) == null);
    try std.testing.expect(parseFCard(tokenizeCardLine("F f " ++ A_CARD_HASH40 ++ "1\n").?, &buf) == null);
}

test "artifact §4.7.6-m2/r4: old-name decoded and held to §4.6.1" {
    var buf: [256]u8 = undefined;
    const good = tokenizeCardLine("F f " ++ A_CARD_HASH40 ++ " w a\\sb\n").?;
    try std.testing.expectEqualStrings("a b", parseFCard(good, &buf).?.old_name.?);

    const bad_cases = [_][]const u8{
        "F f " ++ A_CARD_HASH40 ++ " w /etc\n",
        "F f " ++ A_CARD_HASH40 ++ " w a/../b\n",
    };
    for (bad_cases) |line| {
        try std.testing.expect(parseFCard(tokenizeCardLine(line).?, &buf) == null);
    }
}

test "artifact §4.7.6-m3: hash and permission tokens are never decoded" {
    // Line 1166: "the hash and permission tokens are never decoded."
    var buf: [256]u8 = undefined;
    const card = tokenizeCardLine("F f " ++ A_CARD_HASH40 ++ " w\\sx\n").?;
    const parsed = parseFCard(card, &buf).?;
    try std.testing.expectEqualStrings(A_CARD_HASH40, parsed.hash.?);
    try std.testing.expectEqualStrings("w\\sx", parsed.perm.?); // backslash preserved raw
}

test "artifact §4.7.6-m4: third token is raw perm (no {w,x,l} check); fourth is old-name" {
    // Lines 1192-1196.
    var buf: [256]u8 = undefined;
    const weird_perm = tokenizeCardLine("F f " ++ A_CARD_HASH40 ++ " zzz\n").?;
    const parsed_weird = parseFCard(weird_perm, &buf).?;
    try std.testing.expectEqualStrings("zzz", parsed_weird.perm.?);
    try std.testing.expect(parsed_weird.old_name == null); // 3-token: no old-name

    const four_tok = tokenizeCardLine("F f " ++ A_CARD_HASH40 ++ " w old\n").?;
    try std.testing.expectEqualStrings("old", parseFCard(four_tok, &buf).?.old_name.?);
}

test "artifact §4.7.6-m5: perm x wins over l; else l is symlink; else regular" {
    // Lines 1196-1199.
    const cases = [_]struct { perm: ?[]const u8, want: FileMode }{
        .{ .perm = "x", .want = .executable },
        .{ .perm = "l", .want = .symlink },
        .{ .perm = "w", .want = .regular },
        .{ .perm = "xl", .want = .executable }, // x wins
        .{ .perm = "wl", .want = .symlink },
        .{ .perm = "", .want = .regular },
        .{ .perm = null, .want = .regular },
        .{ .perm = "abc", .want = .regular },
    };
    for (cases) |c| {
        try std.testing.expectEqual(c.want, fileMode(c.perm));
    }
}

test "artifact §4.7.6-w1: a hash-less F card parses (A.4.2)" {
    // A.4.2, line 2652, exact bytes: "F file-c.txt" with no continuation.
    var buf: [256]u8 = undefined;
    const card = tokenizeCardLine("F file-c.txt\n").?;
    const parsed = parseFCard(card, &buf).?;
    try std.testing.expectEqualStrings("file-c.txt", parsed.filename);
    try std.testing.expect(parsed.hash == null);
    try std.testing.expect(parsed.perm == null);
    try std.testing.expect(parsed.old_name == null);
    try std.testing.expectEqual(FileMode.regular, parsed.mode);
}

test "artifact §4.7.6-w2: F alpha.txt with SHA3-256 hash parses (A.3)" {
    // A.3, lines 2561-2562, exact bytes (unwrapped).
    var buf: [256]u8 = undefined;
    const card = tokenizeCardLine(
        "F alpha.txt a8009a7a528d87778c356da3a55d964719e818666a04e4f960c9e2439e35f138\n",
    ).?;
    const parsed = parseFCard(card, &buf).?;
    try std.testing.expectEqualStrings("alpha.txt", parsed.filename);
    try std.testing.expectEqualStrings(
        "a8009a7a528d87778c356da3a55d964719e818666a04e4f960c9e2439e35f138",
        parsed.hash.?,
    );
    try std.testing.expectEqual(@as(usize, 64), parsed.hash.?.len);
}

// ---------------------------------------------------------------------------
// §4.7.7 G Card, H Card, and I Card
// ---------------------------------------------------------------------------

test "artifact §4.7.7-g-p1: G card accepts a raw 40- and 64-hex hash, never decoded" {
    // Line 1207, 1212-1214: raw hash, validated per the hash map.
    try std.testing.expectEqualStrings(
        A_CARD_HASH40,
        parseGCard(tokenizeCardLine("G " ++ A_CARD_HASH40 ++ "\n").?).?.hash,
    );
    try std.testing.expectEqualStrings(
        A_CARD_HASH64,
        parseGCard(tokenizeCardLine("G " ++ A_CARD_HASH64 ++ "\n").?).?.hash,
    );
    try std.testing.expect(parseGCard(tokenizeCardLine("G xyz\n").?) == null); // non-hex
    try std.testing.expect(parseGCard(tokenizeCardLine("G " ++ A_CARD_HASH40[0..39] ++ "\n").?) == null); // wrong length
}

test "artifact §4.7.7-g-r1: G card rejects zero tokens and a second token" {
    // Line 1211: "single-occurrence with exactly one required token" --
    // occurrence/type-marking are deferred; arity is checked here.
    try std.testing.expect(parseGCard(tokenizeCardLine("G\n").?) == null);
    try std.testing.expect(parseGCard(tokenizeCardLine("G " ++ A_CARD_HASH40 ++ " x\n").?) == null);
}

test "artifact §4.7.7-i-p1: I card accepts a raw hash, never decoded" {
    // Line 1209, 1214-1215.
    try std.testing.expectEqualStrings(
        A_CARD_HASH40,
        parseICard(tokenizeCardLine("I " ++ A_CARD_HASH40 ++ "\n").?).?.hash,
    );
    try std.testing.expectEqualStrings(
        A_CARD_HASH64,
        parseICard(tokenizeCardLine("I " ++ A_CARD_HASH64 ++ "\n").?).?.hash,
    );
    try std.testing.expect(parseICard(tokenizeCardLine("I xyz\n").?) == null); // non-hex
}

test "artifact §4.7.7-i-r1: I card rejects zero tokens and a second token" {
    try std.testing.expect(parseICard(tokenizeCardLine("I\n").?) == null);
    try std.testing.expect(parseICard(tokenizeCardLine("I " ++ A_CARD_HASH40 ++ " x\n").?) == null);
}

test "artifact §4.7.7-h-p1: H card decodes the title and accepts any decoded content" {
    // Line 1216-1217: "no content validation -- any decoded string is
    // accepted." A title that fails the wiki-name predicate (leading space,
    // double space, >100 bytes) is still accepted by H; contrast the
    // parseLCard rejection test below.
    var buf: [256]u8 = undefined;
    try std.testing.expectEqualStrings(
        "thread title",
        parseHCard(tokenizeCardLine("H thread\\stitle\n").?, &buf).?.title,
    );
    try std.testing.expectEqualStrings(
        " leading",
        parseHCard(tokenizeCardLine("H \\sleading\n").?, &buf).?.title,
    );
    try std.testing.expectEqualStrings(
        "a  b",
        parseHCard(tokenizeCardLine("H a\\s\\sb\n").?, &buf).?.title,
    );
    const over_100 = "x" ** 101;
    try std.testing.expectEqualStrings(
        over_100,
        parseHCard(tokenizeCardLine("H " ++ over_100 ++ "\n").?, &buf).?.title,
    );
}

test "artifact §4.7.7-h-r1: H card rejects zero tokens, an empty token, and a second token" {
    var buf: [256]u8 = undefined;
    try std.testing.expect(parseHCard(tokenizeCardLine("H\n").?, &buf) == null);
    try std.testing.expect(parseHCard(tokenizeCardLine("H \n").?, &buf) == null); // empty token
    try std.testing.expect(parseHCard(tokenizeCardLine("H a b\n").?, &buf) == null);
}

// artifact §4.7.7-hi-r1: "An artifact MUST NOT contain both an H card and an
// I card" (line 1218) is a §5.4 whole-artifact check -- a single-card parser
// sees one card at a time and has no visibility into its siblings. Deferred
// to the manifest-assembly batch, same boundary as B's delta-of-delta check
// (see BCard's doc comment above). No test here: there is no enforcement
// point at this layer to exercise.

// ---------------------------------------------------------------------------
// §4.7.8 J Card
// ---------------------------------------------------------------------------

test "artifact §4.7.8-p1: J card parses fieldname and optional value" {
    var buf: [256]u8 = undefined;
    const with_value = parseJCard(tokenizeCardLine("J status open\n").?, &buf).?;
    try std.testing.expectEqualStrings("status", with_value.field);
    try std.testing.expectEqualStrings("open", with_value.value);

    const no_value = parseJCard(tokenizeCardLine("J foo\n").?, &buf).?;
    try std.testing.expectEqualStrings("foo", no_value.field);
    try std.testing.expectEqualStrings("", no_value.value);
}

test "artifact §4.7.8-m1: J value is escape-decoded, field name stored verbatim" {
    var buf: [256]u8 = undefined;
    const parsed = parseJCard(tokenizeCardLine("J comment a\\sb\n").?, &buf).?;
    try std.testing.expectEqualStrings("comment", parsed.field);
    try std.testing.expectEqualStrings("a b", parsed.value);
}

test "artifact §4.7.8-m2: a leading + in the J field name is preserved" {
    var buf: [256]u8 = undefined;
    const parsed = parseJCard(tokenizeCardLine("J +comment hi\n").?, &buf).?;
    try std.testing.expectEqualStrings("+comment", parsed.field);
}

test "artifact §4.7.8-m3: an absent J value is the empty string, not null" {
    var buf: [256]u8 = undefined;
    const parsed = parseJCard(tokenizeCardLine("J foo\n").?, &buf).?;
    try std.testing.expectEqual(@as(usize, 0), parsed.value.len);
}

test "artifact §4.7.8-r1: J card rejects a missing field, an empty value token, and a third token" {
    var buf: [256]u8 = undefined;
    try std.testing.expect(parseJCard(tokenizeCardLine("J\n").?, &buf) == null);
    try std.testing.expect(parseJCard(tokenizeCardLine("J \n").?, &buf) == null); // empty fieldname
    try std.testing.expect(parseJCard(tokenizeCardLine("J foo \n").?, &buf) == null); // empty value token
    try std.testing.expect(parseJCard(tokenizeCardLine("J foo bar baz\n").?, &buf) == null); // third token
}

// ---------------------------------------------------------------------------
// §4.7.9 K Card
// ---------------------------------------------------------------------------

test "artifact §4.7.9-p1: K card accepts a 40-hex ticket id, raw" {
    try std.testing.expectEqualStrings(
        A_CARD_HASH40,
        parseKCard(tokenizeCardLine("K " ++ A_CARD_HASH40 ++ "\n").?).?.ticket_id,
    );
}

test "artifact §4.7.9-r1: K card rejects a 64-char SHA3-length id" {
    try std.testing.expect(parseKCard(tokenizeCardLine("K " ++ A_CARD_HASH64 ++ "\n").?) == null);
}

test "artifact §4.7.9-r2: K card rejects zero tokens and a second token" {
    try std.testing.expect(parseKCard(tokenizeCardLine("K\n").?) == null);
    try std.testing.expect(parseKCard(tokenizeCardLine("K " ++ A_CARD_HASH40 ++ " x\n").?) == null);
}

test "artifact §4.7.9-s1: K card takes the MAY as lowercase-only, rejecting uppercase" {
    // Mirrors validateHashToken(.ticket_id)'s lowercase-only decision
    // (line 1648 above) -- a1_1_name has hex letters, unlike A_CARD_HASH40's
    // all-digit fixture, so the uppercase transform is observable.
    var upper: [40]u8 = undefined;
    for (a1_1_name, 0..) |c, i| upper[i] = std.ascii.toUpper(c);

    var line: [43]u8 = undefined; // "K " + 40 + "\n"
    line[0] = 'K';
    line[1] = ' ';
    @memcpy(line[2..42], &upper);
    line[42] = '\n';
    try std.testing.expect(parseKCard(tokenizeCardLine(&line).?) == null);

    // Lowercase original still parses -- proving the reject is case-specific.
    try std.testing.expect(parseKCard(tokenizeCardLine("K " ++ a1_1_name ++ "\n").?) != null);
}

// ---------------------------------------------------------------------------
// §4.7.10 L Card
// ---------------------------------------------------------------------------

test "artifact §4.7.10-p1: L card decodes the title and requires the wiki-name predicate" {
    var buf: [256]u8 = undefined;
    try std.testing.expectEqualStrings(
        "Wiki Page",
        parseLCard(tokenizeCardLine("L Wiki\\sPage\n").?, &buf).?.title,
    );
}

test "artifact §4.7.10-r1: L card rejects a decoded title failing the wiki-name predicate" {
    var buf: [256]u8 = undefined;
    try std.testing.expect(parseLCard(tokenizeCardLine("L \\sWiki\n").?, &buf) == null); // leading space (§4.6.2 r1)
    const over_100 = "x" ** 101;
    try std.testing.expect(parseLCard(tokenizeCardLine("L " ++ over_100 ++ "\n").?, &buf) == null); // §4.6.2 r5
}

test "artifact §4.7.10-r2: L card rejects zero tokens and a second token" {
    var buf: [256]u8 = undefined;
    try std.testing.expect(parseLCard(tokenizeCardLine("L\n").?, &buf) == null);
    try std.testing.expect(parseLCard(tokenizeCardLine("L Wiki Page\n").?, &buf) == null); // second token
}

// ---------------------------------------------------------------------------
// §4.7.11 M Card
// ---------------------------------------------------------------------------

test "artifact §4.7.11-p1: M card accepts a raw 40- and 64-hex hash, never decoded" {
    // `m-card = %x4D SP hash LF` (line 1263); "validated per the hash map"
    // (line 1265-1266). Ordering (strict-ascending run) is §4.5.2-r3,
    // already tested there.
    const forty = tokenizeCardLine("M " ++ A_CARD_HASH40 ++ "\n").?;
    try std.testing.expectEqualStrings(A_CARD_HASH40, parseMCard(forty).?.hash);

    const sixtyfour = tokenizeCardLine("M " ++ A_CARD_HASH64 ++ "\n").?;
    try std.testing.expectEqualStrings(A_CARD_HASH64, parseMCard(sixtyfour).?.hash);
}

test "artifact §4.7.11-r1: M card rejects zero tokens, a second token, and a non-hash" {
    const bad = [_][]const u8{
        "M\n",
        "M " ++ A_CARD_HASH40 ++ " x\n",
        "M xyz\n",
        "M " ++ A_CARD_HASH40[0..39] ++ "\n", // wrong length
    };
    for (bad) |line| {
        try std.testing.expect(parseMCard(tokenizeCardLine(line).?) == null);
    }
}

// ---------------------------------------------------------------------------
// §4.7.12 N Card
// ---------------------------------------------------------------------------

test "artifact §4.7.12-p1: N card decodes the mimetype token" {
    // `n-card = %x4E SP mimetype LF` (line 1270).
    var buf: [256]u8 = undefined;
    const card = tokenizeCardLine("N application/json\n").?;
    try std.testing.expectEqualStrings("application/json", parseNCard(card, &buf).?.mimetype);
}

test "artifact §4.7.12-m1: N mimetype is escape-decoded with no mime-syntax validation" {
    // Escape decode applies (§4.3 Table 1 \s -> SP); a non-mimetype token
    // is accepted verbatim -- "no mime-syntax validation" (line 1272).
    var buf: [256]u8 = undefined;
    try std.testing.expectEqualStrings(
        "a b",
        parseNCard(tokenizeCardLine("N a\\sb\n").?, &buf).?.mimetype,
    );
    try std.testing.expectEqualStrings(
        "zzz",
        parseNCard(tokenizeCardLine("N zzz\n").?, &buf).?.mimetype,
    );
}

test "artifact §4.7.12-r1: N card rejects zero tokens, an empty token, and a second token" {
    var buf: [256]u8 = undefined;
    try std.testing.expect(parseNCard(tokenizeCardLine("N\n").?, &buf) == null);
    try std.testing.expect(parseNCard(tokenizeCardLine("N \n").?, &buf) == null); // token = 1*tchar
    try std.testing.expect(parseNCard(tokenizeCardLine("N a b\n").?, &buf) == null);
}

test "artifact §4.7.12-w1: N card of the §A.1.2 technote decodes to text/x-fossil-wiki" {
    // §A.1.2 line 2475: "N text/x-fossil-wiki" -- no escapes, identity decode.
    var buf: [256]u8 = undefined;
    const card = tokenizeCardLine("N text/x-fossil-wiki\n").?;
    try std.testing.expectEqualStrings("text/x-fossil-wiki", parseNCard(card, &buf).?.mimetype);
}

// ---------------------------------------------------------------------------
// §4.7.13 P Card
// ---------------------------------------------------------------------------

test "artifact §4.7.13-p1: P card parses parents in order, neither sorted nor deduped" {
    // "Tokens are neither sorted nor deduplicated; their order is
    // semantic" (line 1296).
    const H_FF = "f" ** 40; // sorts after A_CARD_HASH40 lexically
    const unsorted = tokenizeCardLine("P " ++ H_FF ++ " " ++ A_CARD_HASH40 ++ "\n").?;
    const p1 = parsePCard(unsorted).?;
    try std.testing.expectEqual(@as(usize, 2), p1.count);
    var it1 = p1.hashes();
    try std.testing.expectEqualStrings(H_FF, it1.next().?);
    try std.testing.expectEqualStrings(A_CARD_HASH40, it1.next().?);
    try std.testing.expect(it1.next() == null);

    const dup = tokenizeCardLine("P " ++ A_CARD_HASH40 ++ " " ++ A_CARD_HASH40 ++ "\n").?;
    try std.testing.expectEqual(@as(usize, 2), parsePCard(dup).?.count);
}

test "artifact §4.7.13-m1: an empty P card (zero tokens) MUST be accepted" {
    // "An empty P card (zero tokens) MUST be accepted" (line 1293-1295).
    const card = parsePCard(tokenizeCardLine("P\n").?).?;
    try std.testing.expectEqual(@as(usize, 0), card.count);
    try std.testing.expect(card.parents == null);
}

test "artifact §4.7.13-r1: P card rejects any token failing the hash map" {
    const bad = [_][]const u8{
        "P xyz\n", // non-hash
        "P \n", // empty token is not a hash
        "P " ++ A_CARD_HASH40 ++ " zzz\n", // second token bad
    };
    for (bad) |line| {
        try std.testing.expect(parsePCard(tokenizeCardLine(line).?) == null);
    }
}

test "artifact §4.7.13-w1: the §A.1.1 root check-in's empty P card parses to zero parents" {
    // §A.1.1 line 2421: the bare "P" line, described at 2430-2432 as the
    // root check-in's parent-less card.
    const card = parsePCard(tokenizeCardLine("P\n").?).?;
    try std.testing.expectEqual(@as(usize, 0), card.count);
}

// ---------------------------------------------------------------------------
// §4.7.14 Q Card
// ---------------------------------------------------------------------------

test "artifact §4.7.14-p1: Q card parses q-sign, target hash, and optional baseline" {
    // `q-card = %x51 SP q-sign hash [ SP hash ] LF` (line 1325-1326).
    const cherry = parseQCard(tokenizeCardLine("Q +" ++ A_CARD_HASH40 ++ "\n").?).?;
    try std.testing.expectEqual(QSign.cherrypick, cherry.sign);
    try std.testing.expectEqualStrings(A_CARD_HASH40, cherry.target);
    try std.testing.expect(cherry.baseline == null);

    const backout = parseQCard(tokenizeCardLine("Q -" ++ A_CARD_HASH40 ++ " " ++ A_CARD_HASH64 ++ "\n").?).?;
    try std.testing.expectEqual(QSign.backout, backout.sign);
    try std.testing.expectEqualStrings(A_CARD_HASH40, backout.target);
    try std.testing.expectEqualStrings(A_CARD_HASH64, backout.baseline.?);
}

test "artifact §4.7.14-m1: the sign byte is stripped and the remainder validated as a hash" {
    // Lines 1329-1332: sign byte checked, remainder validated per the
    // hash map; the '+'/'-' byte itself never appears in `target`.
    const plus = parseQCard(tokenizeCardLine("Q +" ++ A_CARD_HASH40 ++ "\n").?).?;
    try std.testing.expectEqual(@as(usize, 40), plus.target.len);
    try std.testing.expectEqualStrings(A_CARD_HASH40, plus.target);
    try std.testing.expectEqual(QSign.cherrypick, plus.sign);

    const minus = parseQCard(tokenizeCardLine("Q -" ++ A_CARD_HASH64 ++ "\n").?).?;
    try std.testing.expectEqualStrings(A_CARD_HASH64, minus.target);
    try std.testing.expectEqual(QSign.backout, minus.sign);
}

test "artifact §4.7.14-m2: the optional second token is a plain unprefixed baseline hash" {
    // Line 1332-1334: baseline is a plain unprefixed hash.
    const card = parseQCard(tokenizeCardLine("Q +" ++ A_CARD_HASH40 ++ " " ++ A_CARD_HASH64 ++ "\n").?).?;
    try std.testing.expectEqualStrings(A_CARD_HASH64, card.baseline.?);

    // A '+'-prefixed second token is a 41-byte string, not a valid hash.
    const prefixed = tokenizeCardLine("Q +" ++ A_CARD_HASH40 ++ " +" ++ A_CARD_HASH40 ++ "\n").?;
    try std.testing.expect(parseQCard(prefixed) == null);
}

test "artifact §4.7.14-m3: an omitted baseline is left null, not resolved" {
    // Lines 1334-1336: "the field is left null" -- not resolved to the
    // primary parent at parse time.
    const card = parseQCard(tokenizeCardLine("Q +" ++ A_CARD_HASH40 ++ "\n").?).?;
    try std.testing.expect(card.baseline == null);
}

test "artifact §4.7.14-r1: Q card rejects a q-sign other than + or -" {
    // `q-sign = "+" / "-"` (line 1327) -- Q forbids the '*' the T card
    // allows; a bare hash with no sign byte also rejects.
    try std.testing.expect(parseQCard(tokenizeCardLine("Q *" ++ A_CARD_HASH40 ++ "\n").?) == null);
    try std.testing.expect(parseQCard(tokenizeCardLine("Q " ++ A_CARD_HASH40 ++ "\n").?) == null);
    // `Q \n` tokenizes to one empty token (no sign byte at all, not even a
    // bad one) -- the length guard ahead of the sign switch, not the
    // switch's `else` arm, is what rejects this.
    try std.testing.expect(parseQCard(tokenizeCardLine("Q \n").?) == null);
}

test "artifact §4.7.14-r2: Q card rejects zero tokens and a third token" {
    try std.testing.expect(parseQCard(tokenizeCardLine("Q\n").?) == null);
    const three = tokenizeCardLine("Q +" ++ A_CARD_HASH40 ++ " " ++ A_CARD_HASH64 ++ " " ++ A_CARD_HASH40 ++ "\n").?;
    try std.testing.expect(parseQCard(three) == null);
}

test "artifact §4.7.14-r3: Q card rejects a remainder or baseline failing the hash map" {
    const bad = [_][]const u8{
        "Q +xyz\n", // bad remainder
        "Q +\n", // sign only, empty remainder
        "Q +" ++ A_CARD_HASH40 ++ " zzz\n", // bad baseline
    };
    for (bad) |line| {
        try std.testing.expect(parseQCard(tokenizeCardLine(line).?) == null);
    }
}

const MD5_32: []const u8 = "0123456789abcdef" ** 2; // 32 hex chars
comptime {
    std.debug.assert(MD5_32.len == 32);
}

test "artifact §4.7.15-p1: R card accepts a 32-hex checksum, raw and never decoded" {
    const card = tokenizeCardLine("R " ++ MD5_32 ++ "\n").?;
    const parsed = parseRCard(card).?;
    try std.testing.expectEqualStrings(MD5_32, parsed.checksum);
    try std.testing.expectEqual(@as(usize, 32), parsed.checksum.len);
    // §6.3.1-p1/-m1: no file content is present here at all, yet the card
    // still parses -- the parse path never recomputes against file content.
}

test "artifact §4.7.15-r1: R card grammar rejects bad arity and a non-checksum" {
    try std.testing.expect(parseRCard(tokenizeCardLine("R\n").?) == null); // zero tokens
    const second = tokenizeCardLine("R " ++ MD5_32 ++ " " ++ MD5_32 ++ "\n").?;
    try std.testing.expect(parseRCard(second) == null); // second token
    try std.testing.expect(parseRCard(tokenizeCardLine("R " ++ A_CARD_HASH40 ++ "\n").?) == null); // 40 hex, not 32
    try std.testing.expect(parseRCard(tokenizeCardLine("R " ++ MD5_32[0..31] ++ "g\n").?) == null); // non-hex
}

test "artifact §4.7.16-p1: T card parses name, target, and value in order" {
    var buf: [256]u8 = undefined;
    const card = tokenizeCardLine("T +tag " ++ A_CARD_HASH40 ++ " hi\n").?;
    const parsed = parseTCard(card, &buf).?;
    try std.testing.expectEqual(TSign.add, parsed.sign);
    try std.testing.expectEqualStrings("tag", parsed.name);
    try std.testing.expectEqualStrings(A_CARD_HASH40, parsed.target.?);
    try std.testing.expectEqualStrings("hi", parsed.value.?);
}

test "artifact §4.7.16-p2: T target '*' is a self-referential tag with a null target" {
    var buf: [256]u8 = undefined;
    const star = tokenizeCardLine("T +tag *\n").?;
    try std.testing.expect(parseTCard(star, &buf).?.target == null);

    const hash = tokenizeCardLine("T +tag " ++ A_CARD_HASH64 ++ "\n").?;
    try std.testing.expectEqualStrings(A_CARD_HASH64, parseTCard(hash, &buf).?.target.?);
}

test "artifact §4.7.16-r1: T card rejects a name not prefixed +/-/*" {
    var buf: [256]u8 = undefined;
    try std.testing.expect(parseTCard(tokenizeCardLine("T xtag " ++ A_CARD_HASH40 ++ "\n").?, &buf) == null);
    // Bare hash as the name: first decoded char is a hex digit, not a sign.
    const bare_hash_name = tokenizeCardLine("T " ++ A_CARD_HASH40 ++ " " ++ A_CARD_HASH40 ++ "\n").?;
    try std.testing.expect(parseTCard(bare_hash_name, &buf) == null);
}

test "artifact §4.7.16-r2: T card rejects a remainder that is entirely hex digits" {
    var buf: [256]u8 = undefined;
    const bad = [_][]const u8{
        "T +deadbeef " ++ A_CARD_HASH40 ++ "\n",
        "T +dead " ++ A_CARD_HASH40 ++ "\n",
        "T +DEAD " ++ A_CARD_HASH40 ++ "\n", // hexdig-ci: uppercase still all-hex
    };
    for (bad) |line| {
        try std.testing.expect(parseTCard(tokenizeCardLine(line).?, &buf) == null);
    }
    // A non-hex byte in the remainder breaks the all-hex reject.
    const ok = tokenizeCardLine("T +dea-d " ++ A_CARD_HASH40 ++ "\n").?;
    try std.testing.expect(parseTCard(ok, &buf) != null);
}

test "artifact §4.7.16-r3: T card rejects a target failing '*' and the hash map" {
    var buf: [256]u8 = undefined;
    try std.testing.expect(parseTCard(tokenizeCardLine("T +tag xyz\n").?, &buf) == null);
    // A decoded '*' does not count -- target is never decoded (m3).
    try std.testing.expect(parseTCard(tokenizeCardLine("T +tag \\*\n").?, &buf) == null);
}

test "artifact §4.7.16-r4: T card rejects a fourth token" {
    var buf: [256]u8 = undefined;
    const four = tokenizeCardLine("T +tag " ++ A_CARD_HASH40 ++ " val extra\n").?;
    try std.testing.expect(parseTCard(four, &buf) == null);
}

test "artifact §4.7.16-m1: an absent T value is null; a present empty value is not" {
    var buf: [256]u8 = undefined;
    const absent = tokenizeCardLine("T +tag " ++ A_CARD_HASH40 ++ "\n").?;
    try std.testing.expect(parseTCard(absent, &buf).?.value == null);

    const present_empty = tokenizeCardLine("T +tag " ++ A_CARD_HASH40 ++ " \n").?;
    const parsed = parseTCard(present_empty, &buf).?;
    try std.testing.expect(parsed.value != null);
    try std.testing.expectEqualStrings("", parsed.value.?);
}

test "artifact §4.7.16-m2: the T value is escape-decoded" {
    var buf: [256]u8 = undefined;
    const card = tokenizeCardLine("T +tag " ++ A_CARD_HASH40 ++ " a\\sb\n").?;
    try std.testing.expectEqualStrings("a b", parseTCard(card, &buf).?.value.?);
}

test "artifact §4.7.16-m3: the T target token is never decoded" {
    var buf: [256]u8 = undefined;
    // "\*" decodes to "*" but the raw wire token is 2 bytes, so it never
    // matches the literal self-referential target and fails the hash map.
    try std.testing.expect(parseTCard(tokenizeCardLine("T +tag \\*\n").?, &buf) == null);
}

test "artifact §4.7.16-m4: the T name is escape-decoded before the sign check" {
    var buf: [256]u8 = undefined;
    const cancel = tokenizeCardLine("T \\-tag " ++ A_CARD_HASH40 ++ "\n").?;
    const parsed_cancel = parseTCard(cancel, &buf).?;
    try std.testing.expectEqual(TSign.cancel, parsed_cancel.sign);
    try std.testing.expectEqualStrings("tag", parsed_cancel.name);

    const spaced = tokenizeCardLine("T +a\\sb " ++ A_CARD_HASH40 ++ "\n").?;
    const parsed_spaced = parseTCard(spaced, &buf).?;
    try std.testing.expectEqual(TSign.add, parsed_spaced.sign);
    try std.testing.expectEqualStrings("a b", parsed_spaced.name);
}

test "artifact §4.7.17-p1: U card decodes a present login" {
    var buf: [64]u8 = undefined;
    const plain = tokenizeCardLine("U alice\n").?;
    try std.testing.expectEqualStrings("alice", parseUCard(plain, &buf).?.login);

    const escaped = tokenizeCardLine("U a\\sb\n").?;
    try std.testing.expectEqualStrings("a b", parseUCard(escaped, &buf).?.login);
}

test "artifact §4.7.17-m1: a bare or empty U login substitutes 'anonymous'" {
    var buf: [64]u8 = undefined;
    const bare = tokenizeCardLine("U\n").?;
    try std.testing.expectEqualStrings("anonymous", parseUCard(bare, &buf).?.login);

    const empty = tokenizeCardLine("U \n").?;
    try std.testing.expectEqualStrings("anonymous", parseUCard(empty, &buf).?.login);
}

test "artifact §4.7.17-m2: an empty U token returns a card, unlike parseNCard's empty-token reject" {
    var buf: [64]u8 = undefined;
    const empty_u = tokenizeCardLine("U \n").?;
    try std.testing.expect(parseUCard(empty_u, &buf) != null);
    const empty_n = tokenizeCardLine("N \n").?;
    try std.testing.expect(parseNCard(empty_n, &buf) == null);
}

test "artifact §4.7.17-r1: U card rejects a second token" {
    var buf: [64]u8 = undefined;
    try std.testing.expect(parseUCard(tokenizeCardLine("U a b\n").?, &buf) == null);
}

test "artifact §4.7.18-p1/m1: body located by byte count after the size LF" {
    const artifact = "W 5\nhello\nX";
    const w = parseWCard(artifact, 0).?;
    try std.testing.expectEqualStrings("hello", w.body);
    try std.testing.expectEqual(@as(usize, 10), w.next);
}

test "artifact §4.7.18-p2/w1: leading digits accumulated, suffix not re-checked (12x)" {
    const artifact = "W 12x\nabcdefghijkl\nT";
    const w = parseWCard(artifact, 0).?;
    try std.testing.expectEqualStrings("abcdefghijkl", w.body);
    try std.testing.expectEqual(@as(usize, 12), w.body.len);
}

test "artifact §4.7.18-r1: size accumulator overflow is rejected" {
    const artifact = "W " ++ ("9" ** 25) ++ "\nbody\nX";
    try std.testing.expect(parseWCard(artifact, 0) == null);
}

test "artifact §4.7.18-r2: declared size past remaining length is rejected" {
    const artifact = "W 100\nshort\n";
    try std.testing.expect(parseWCard(artifact, 0) == null);
}

test "artifact §4.7.18-m1: body consumed by count, not scanned for a card boundary" {
    const artifact = "W 4\na\nbc\nX";
    const w = parseWCard(artifact, 0).?;
    try std.testing.expectEqualStrings("a\nbc", w.body);
    try std.testing.expectEqual(@as(usize, 9), w.next);
}

test "artifact §4.7.18-r3: byte after declared-length body must be LF" {
    const artifact = "W 3\nabcXY";
    try std.testing.expect(parseWCard(artifact, 0) == null);
}

test "artifact §4.7.18-r4: position one past the body LF must remain within the artifact" {
    const artifact = "W 0\n\n";
    try std.testing.expect(parseWCard(artifact, 0) == null);
}

test "artifact §4.7.18-w2: no-leading-digit size parses as zero, accepted only if next byte is LF" {
    const accepted = "W abc\n\nX";
    const w = parseWCard(accepted, 0).?;
    try std.testing.expectEqual(@as(usize, 0), w.body.len);
    try std.testing.expectEqual(@as(usize, 7), w.next);

    const rejected = "W abc\nZY";
    try std.testing.expect(parseWCard(rejected, 0) == null);
}

test "artifact §4.7.18: W grammar guards -- missing SP or unterminated size line" {
    try std.testing.expect(parseWCard("W\n", 0) == null); // no SP
    try std.testing.expect(parseWCard("W 5", 0) == null); // no size-line LF before end
}

test "artifact §4.7.19-p1/m1: Z card accepts a 32-hex md5 token, raw and never decoded" {
    const card = tokenizeCardLine("Z " ++ MD5_32 ++ "\n").?;
    const z = parseZCard(card).?;
    try std.testing.expectEqualStrings(MD5_32, z.checksum);
    try std.testing.expectEqual(@as(usize, 32), z.checksum.len);
}

test "artifact §4.7.19-r1: Z/R checksum token must be exactly 32 hex" {
    try std.testing.expect(parseZCard(tokenizeCardLine("Z " ++ MD5_32[0..31] ++ "\n").?) == null); // 31
    try std.testing.expect(parseZCard(tokenizeCardLine("Z " ++ A_CARD_HASH40 ++ "\n").?) == null); // 40, not 32
    try std.testing.expect(parseZCard(tokenizeCardLine("Z " ++ MD5_32[0..31] ++ "g\n").?) == null); // non-hex
    try std.testing.expect(parseZCard(tokenizeCardLine("Z " ++ MD5_32 ++ "\n").?) != null); // exactly 32
}

test "artifact §4.7.19-r2: Z grammar takes a single token -- zero or two rejected" {
    try std.testing.expect(parseZCard(tokenizeCardLine("Z\n").?) == null); // zero tokens
    const second = tokenizeCardLine("Z " ++ MD5_32 ++ " " ++ MD5_32 ++ "\n").?;
    try std.testing.expect(parseZCard(second) == null); // second token
}

test "artifact §4.7.19-m1: Z card occupies the final 35 bytes of stripped content" {
    // z-card = "Z" SP md5-token LF: 1 + 1 + 32 + 1 == 35 (§4.4 l.742-743).
    try std.testing.expectEqual(@as(usize, 35), 1 + 1 + 32 + 1);

    // Ties to the §6.2 positional framing verifyZCard already implements:
    // marker at n-35, digest at n-33..n-1, terminating LF at n-1 untouched.
    // Empty-input MD5 digest (matches the self-checksum vector above) makes
    // this a genuine positional match, not just a length check.
    const stripped = "Z d41d8cd98f00b204e9800998ecf8427e\n";
    try std.testing.expectEqual(@as(usize, 35), stripped.len);
    try std.testing.expectEqual(ZVerdict.match, verifyZCard(stripped));
}

// ---------------------------------------------------------------------------
// §6 Checksums -- tests
// ---------------------------------------------------------------------------

test "artifact §6.3.2-p1: R aggregate matches A.3 and empty-checkin vectors" {
    // A.2: zero tracked files -> MD5("") = the "initial empty baseline" R.
    var out: [32]u8 = undefined;
    try std.testing.expectEqualStrings(
        "d41d8cd98f00b204e9800998ecf8427e",
        aggregateChecksum(&.{}, &out),
    );

    // A.3: three files, filename order, 85-byte input -> matches the R card.
    const files = [_]TrackedFile{
        .{ .name = "alpha.txt", .content = "hello world\n" },
        .{ .name = "beta.txt", .content = "fossil artifact format\n" },
        .{ .name = "gamma.txt", .content = "R card test\n" },
    };
    try std.testing.expectEqualStrings(
        "95feb799536dc053e7968efb7cdd3ec3",
        aggregateChecksum(&files, &out),
    );
}

test "artifact §6.3.2-m1: size header is minimal decimal (no leading zeros)" {
    // A.3 sizes 12/23 are already two-digit, so the p1 match above pins
    // minimal-decimal for those. A zero-byte file pins the size-0 case: the
    // record is "f" SP "0" LF with nothing after -- MD5("f 0\n"), a public
    // MD5 constant, not a leading-zero "f 00\n" or an omitted header.
    const files = [_]TrackedFile{.{ .name = "f", .content = "" }};
    var out: [32]u8 = undefined;
    try std.testing.expectEqualStrings(
        "bad208e121af55e1d62088c7ed0699f9",
        aggregateChecksum(&files, &out),
    );
}

test "artifact §6.3.2-m2: checksum covers name+size-header+content" {
    const files = [_]TrackedFile{
        .{ .name = "alpha.txt", .content = "hello world\n" },
        .{ .name = "beta.txt", .content = "fossil artifact format\n" },
        .{ .name = "gamma.txt", .content = "R card test\n" },
    };
    var out: [32]u8 = undefined;
    const got = aggregateChecksum(&files, &out);
    try std.testing.expectEqualStrings("95feb799536dc053e7968efb7cdd3ec3", got);

    // Raw-content-only concatenation (no filename, no size header) is a
    // different, well-known MD5 value -- proving the header/name matter.
    var raw_digest: [16]u8 = undefined;
    std.crypto.hash.Md5.hash("hello world\nfossil artifact format\nR card test\n", &raw_digest, .{});
    const raw_hex = std.fmt.bytesToHex(raw_digest, .lower);
    try std.testing.expect(!std.mem.eql(u8, got, &raw_hex));
}

test "artifact §6.3.2-m3: filename order is the §4.5.3 comparator" {
    // The A.3 vector's order is itself the canonical order under the §4.5.3
    // comparator -- assert that, then reuse it to reconfirm the p1 match.
    try std.testing.expectEqual(std.math.Order.lt, compareBytes("alpha.txt", "beta.txt"));
    try std.testing.expectEqual(std.math.Order.lt, compareBytes("beta.txt", "gamma.txt"));

    const files = [_]TrackedFile{
        .{ .name = "alpha.txt", .content = "hello world\n" },
        .{ .name = "beta.txt", .content = "fossil artifact format\n" },
        .{ .name = "gamma.txt", .content = "R card test\n" },
    };
    var out: [32]u8 = undefined;
    try std.testing.expectEqualStrings("95feb799536dc053e7968efb7cdd3ec3", aggregateChecksum(&files, &out));
}

test "artifact §6.3.4-r1: uppercase R fails the byte-exact commit check" {
    const files = [_]TrackedFile{
        .{ .name = "alpha.txt", .content = "hello world\n" },
        .{ .name = "beta.txt", .content = "fossil artifact format\n" },
        .{ .name = "gamma.txt", .content = "R card test\n" },
    };
    var out: [32]u8 = undefined;
    const recomputed = aggregateChecksum(&files, &out);
    for (recomputed) |c| try std.testing.expect(isHexLower(c));

    var uppercased: [32]u8 = undefined;
    for (recomputed, 0..) |c, i| uppercased[i] = std.ascii.toUpper(c);
    // Byte-exact compare against an always-lowercase recomputed digest --
    // an uppercase R card fails this even though it is numerically correct.
    try std.testing.expect(!std.mem.eql(u8, &uppercased, recomputed));
}

test "artifact §6.2-p1: Z compute over first n-35 matches the A.2 vector" {
    // A.2 offset table: 92 bytes checksummed (0..91), marker@92, digest
    // 94..125, LF@126; n = 127.
    try std.testing.expectEqual(@as(usize, 127), a1_1_manifest.len);
    var out: [32]u8 = undefined;
    try std.testing.expectEqualStrings(
        "c62e3619f7c41ee77b33f00df8ec26f4",
        selfChecksum(a1_1_manifest[0..92], &out),
    );
    try std.testing.expectEqual(ZVerdict.match, verifyZCard(a1_1_manifest));
}

test "artifact §6.2-m2: verification never reads byte n-1" {
    // Mutate the artifact's terminating byte (n-1); the verdict is
    // unaffected because Z verification's compare range is n-33..n-2.
    var mutated: [127]u8 = undefined;
    @memcpy(&mutated, a1_1_manifest);
    try std.testing.expectEqual(@as(u8, '\n'), mutated[126]);
    mutated[126] = 'X';
    try std.testing.expectEqual(ZVerdict.match, verifyZCard(&mutated));
}

test "artifact §6.2-s1/-m1: absent/too-short Z is not a verification reject" {
    const table = [_][]const u8{
        "C comment\n", // 10 bytes: no Z card at all
        "D 2007-07-21T14:09:59\n", // 22 bytes: still short of the 35-byte Z minimum
    };
    for (table) |bytes| {
        try std.testing.expect(bytes.len < 35);
        try std.testing.expectEqual(ZVerdict.absent, verifyZCard(bytes));
        // The absence itself never fails the framing gate here -- §5.2's
        // required-card rule is the one that rejects a missing Z.
        try std.testing.expect(frame(bytes) != null);
    }
}

test "artifact §6.1-p2/-r1: R/Z parse-accept is 32 hex, case-insensitive" {
    const md5_upper = "0123456789ABCDEF" ** 2;
    comptime std.debug.assert(md5_upper.len == 32);

    // Accept: all-lower and all-upper.
    try std.testing.expect(parseRCard(tokenizeCardLine("R " ++ MD5_32 ++ "\n").?) != null);
    try std.testing.expect(parseRCard(tokenizeCardLine("R " ++ md5_upper ++ "\n").?) != null);
    try std.testing.expect(parseZCard(tokenizeCardLine("Z " ++ MD5_32 ++ "\n").?) != null);
    try std.testing.expect(parseZCard(tokenizeCardLine("Z " ++ md5_upper ++ "\n").?) != null);

    // Reject: 31 chars, 33 chars, one non-hex byte, embedded space (splits
    // into two tokens, so arity rejects it).
    try std.testing.expect(parseRCard(tokenizeCardLine("R " ++ MD5_32[0..31] ++ "\n").?) == null);
    try std.testing.expect(parseRCard(tokenizeCardLine("R " ++ MD5_32 ++ "a\n").?) == null);
    try std.testing.expect(parseRCard(tokenizeCardLine("R " ++ MD5_32[0..16] ++ " " ++ MD5_32[16..32] ++ "\n").?) == null);

    try std.testing.expect(parseZCard(tokenizeCardLine("Z " ++ MD5_32[0..31] ++ "\n").?) == null);
    try std.testing.expect(parseZCard(tokenizeCardLine("Z " ++ MD5_32 ++ "a\n").?) == null);
    try std.testing.expect(parseZCard(tokenizeCardLine("Z " ++ MD5_32[0..16] ++ " " ++ MD5_32[16..32] ++ "\n").?) == null);

    // One non-hex byte, built at runtime (can't ++ a mutated buffer).
    var bad_hex: [32]u8 = undefined;
    @memcpy(&bad_hex, MD5_32);
    bad_hex[0] = 'g';
    var r_line: [35]u8 = undefined;
    r_line[0] = 'R';
    r_line[1] = ' ';
    @memcpy(r_line[2..34], &bad_hex);
    r_line[34] = '\n';
    try std.testing.expect(parseRCard(tokenizeCardLine(&r_line).?) == null);

    var z_line: [35]u8 = undefined;
    z_line[0] = 'Z';
    z_line[1] = ' ';
    @memcpy(z_line[2..34], &bad_hex);
    z_line[34] = '\n';
    try std.testing.expect(parseZCard(tokenizeCardLine(&z_line).?) == null);
}

test "artifact §6.1-m1: the parse path never case-folds the token" {
    const md5_upper = "0123456789ABCDEF" ** 2;
    comptime std.debug.assert(md5_upper.len == 32);

    const r = parseRCard(tokenizeCardLine("R " ++ md5_upper ++ "\n").?).?;
    try std.testing.expectEqualStrings(md5_upper, r.checksum); // verbatim, not lowered

    const z = parseZCard(tokenizeCardLine("Z " ++ md5_upper ++ "\n").?).?;
    try std.testing.expectEqualStrings(md5_upper, z.checksum); // verbatim, not lowered
}

fn parseNoLeak(allocator: std.mem.Allocator, bytes: []const u8) !void {
    if (parse(allocator, bytes)) |m| {
        var mm = m;
        mm.deinit(); // success owns the arena now -- release it, or the leak check misfires
    } else |err| switch (err) {
        ParseError.OutOfMemory => return err,
        else => {}, // SyntaxError + the 7 ClassifyError variants: not a leak signal
    }
}

test "artifact §4.2: parse propagates allocation failure (arena-per-parse)" {
    try std.testing.checkAllAllocationFailures(std.testing.allocator, parseNoLeak, .{a1_1_manifest});
}

// ---------------------------------------------------------------------------
// Seeded deterministic fuzz / property tests. Same posture as delta.zig's:
// a fixed seed drives every draw, so a failure reproduces exactly and each
// block runs under plain `zig build test`. The parameterized targets in
// artifact_fuzz.zig cover the same ground at a caller-chosen seed/round
// count for the out-of-band runner.
// ---------------------------------------------------------------------------

test "artifact fuzz: parse on arbitrary bytes never crashes (§4 robustness)" {
    const a = std.testing.allocator;
    var prng = std.Random.DefaultPrng.init(0xA47F_0001);
    const rand = prng.random();
    var round: usize = 0;
    while (round < 400) : (round += 1) {
        var buf: [512]u8 = undefined;
        const len = rand.uintLessThan(usize, buf.len + 1);
        for (buf[0..len]) |*b| b.* = rand.int(u8);
        // Contract: a declared ParseError or a Manifest value -- never a crash or UB.
        if (parse(a, buf[0..len])) |m| {
            var mm = m;
            mm.deinit();
        } else |_| {}
    }
}

test "artifact fuzz: tokenizeCardLine + decodeEscapes never crash (§4.2/§4.3 robustness)" {
    var prng = std.Random.DefaultPrng.init(0xA47F_0002);
    const rand = prng.random();
    var round: usize = 0;
    while (round < 400) : (round += 1) {
        var buf: [128]u8 = undefined;
        // tokenizeCardLine's precondition: non-empty, no embedded LF, exactly
        // one trailing LF.
        const body_len = 1 + rand.uintLessThan(usize, buf.len - 1);
        for (buf[0 .. body_len - 1]) |*b| {
            b.* = rand.int(u8);
            if (b.* == '\n') b.* = ' ';
        }
        buf[body_len - 1] = '\n';
        const line = buf[0..body_len];

        if (tokenizeCardLine(line)) |card| {
            try std.testing.expect(isCardLetter(card.letter));
            if (card.args) |args| {
                try std.testing.expect(args.len <= line.len);
                var out_buf: [128]u8 = undefined;
                const out = decodeEscapes(args, out_buf[0..args.len]);
                try std.testing.expect(out.len <= args.len);
            }

            // §4.7.15-4.7.19: the per-card parsers built on this batch's
            // tokenizer/decoder must also never crash on adversarial input,
            // regardless of card.letter (a rejecting null is fine). W is
            // excluded here -- it bypasses tokenizeCardLine entirely and
            // gets its own fuzz target below.
            var decode_buf: [128]u8 = undefined;
            const decode_bound = if (card.args) |a| a.len else 0;
            switch (card.letter) {
                'R' => _ = parseRCard(card),
                'T' => _ = parseTCard(card, decode_buf[0..decode_bound]),
                'U' => _ = parseUCard(card, decode_buf[0..decode_bound]),
                'Z' => _ = parseZCard(card),
                else => {},
            }
        }
    }
}

test "artifact fuzz: parseWCard never crashes and honors its postconditions (§4.7.18 robustness)" {
    // W bypasses tokenizeCardLine (its body may embed LF), so it needs its
    // own target: random bytes with the 'W' letter forced at start.
    var prng = std.Random.DefaultPrng.init(0xA47F_0006);
    const rand = prng.random();
    var round: usize = 0;
    while (round < 400) : (round += 1) {
        var buf: [128]u8 = undefined;
        const len = 1 + rand.uintLessThan(usize, buf.len); // start < artifact.len
        for (buf[0..len]) |*b| b.* = rand.int(u8);
        buf[0] = 'W'; // precondition: artifact[start] == 'W'
        const artifact = buf[0..len];

        // Contract: a null (rejection) or a WCard honoring next < len and
        // the terminator LF sitting one byte before next -- never a crash.
        if (parseWCard(artifact, 0)) |w| {
            try std.testing.expect(w.next < artifact.len);
            try std.testing.expect(w.next >= 1);
            try std.testing.expect(artifact[w.next - 1] == '\n');
            try std.testing.expect(w.body.len <= artifact.len);
        }
    }
}

test "artifact fuzz: classifyTimestamp on arbitrary bytes never crashes (§4.6.4 robustness)" {
    var prng = std.Random.DefaultPrng.init(0xA47F_0003);
    const rand = prng.random();
    var round: usize = 0;
    while (round < 400) : (round += 1) {
        var buf: [64]u8 = undefined;
        const len = rand.uintLessThan(usize, buf.len + 1);
        for (buf[0..len]) |*b| b.* = if (rand.boolean())
            rand.int(u8)
        else
            "0123456789-:.Tt"[rand.uintLessThan(usize, 15)];
        _ = classifyTimestamp(buf[0..len]);
    }
}

test "artifact fuzz: framing trio never crash (§4.1/§4.1.1/§6.2 robustness)" {
    var prng = std.Random.DefaultPrng.init(0xA47F_0004);
    const rand = prng.random();
    var round: usize = 0;
    while (round < 400) : (round += 1) {
        var buf: [256]u8 = undefined;
        const len = rand.uintLessThan(usize, buf.len + 1);
        for (buf[0..len]) |*b| b.* = rand.int(u8);
        const bytes = buf[0..len];

        // Contract: never grows the input (§4.1.1, §4.1), never crashes.
        const stripped = stripSignatureWrapper(bytes);
        try std.testing.expect(stripped.len <= bytes.len);
        if (frame(bytes)) |framed| try std.testing.expect(framed.len <= bytes.len);
        _ = verifyZCard(bytes);
    }
}

fn invertOrder(o: std.math.Order) std.math.Order {
    return switch (o) {
        .lt => .gt,
        .gt => .lt,
        .eq => .eq,
    };
}

test "artifact fuzz: pathname/wiki-name/reserved/compare predicates never crash (§4.5.3/§4.6 robustness)" {
    var prng = std.Random.DefaultPrng.init(0xA47F_0005);
    const rand = prng.random();
    var round: usize = 0;
    while (round < 400) : (round += 1) {
        var abuf: [64]u8 = undefined;
        var bbuf: [64]u8 = undefined;
        const alen = rand.uintLessThan(usize, abuf.len + 1);
        const blen = rand.uintLessThan(usize, bbuf.len + 1);
        for (abuf[0..alen]) |*b| b.* = rand.int(u8);
        for (bbuf[0..blen]) |*b| b.* = rand.int(u8);
        const a = abuf[0..alen];
        const b = bbuf[0..blen];

        _ = isSimplePathname(a);
        _ = isWikiName(a);
        _ = isReservedFilename(a);
        // compareBytes: reflexive, and swapping arguments exactly reverses
        // the order (§4.5.3).
        try std.testing.expectEqual(std.math.Order.eq, compareBytes(a, a));
        try std.testing.expectEqual(compareBytes(a, b), invertOrder(compareBytes(b, a)));
    }
}

// ---------------------------------------------------------------------------
// §5 tests
// ---------------------------------------------------------------------------

test "artifact §5.1: type codes and external names match Table 3" {
    const Row = struct { t: ArtifactType, code: u8, name: []const u8 };
    const rows = [_]Row{
        .{ .t = .manifest, .code = 1, .name = "checkin" },
        .{ .t = .cluster, .code = 2, .name = "cluster" },
        .{ .t = .control, .code = 3, .name = "tag" },
        .{ .t = .wiki, .code = 4, .name = "wiki" },
        .{ .t = .ticket, .code = 5, .name = "ticket" },
        .{ .t = .attachment, .code = 6, .name = "attachment" },
        .{ .t = .event, .code = 7, .name = "technote" },
        .{ .t = .forum, .code = 8, .name = "forumpost" },
    };
    for (rows) |row| {
        try std.testing.expectEqual(row.code, @intFromEnum(row.t));
        try std.testing.expectEqualStrings(row.name, row.t.externalName());
    }
}

test "artifact §5.1: undetermined type is always resolved (sentinel never final)" {
    // Code 0 is never implemented as a value (m1); a census with no
    // type-setting card still resolves to a concrete type via fallback.
    try std.testing.expectEqual(ArtifactType.control, determineType(.{ .letters = maskOfLetters("DZ") }));
    try std.testing.expectEqual(ArtifactType.manifest, determineType(.{ .letters = maskOfLetters("CDZ") }));
}

test "artifact §5.2: allowed and required sets match Table 4" {
    const Row = struct { t: ArtifactType, allowed: []const u8, required: []const u8 };
    const rows = [_]Row{
        .{ .t = .manifest, .allowed = "BCDFNPQRTUZ", .required = "DZ" },
        .{ .t = .cluster, .allowed = "MZ", .required = "MZ" },
        .{ .t = .control, .allowed = "DTUZ", .required = "DTUZ" },
        .{ .t = .wiki, .allowed = "CDLNPUWZ", .required = "DLUWZ" },
        .{ .t = .ticket, .allowed = "DJKUZ", .required = "DJKUZ" },
        .{ .t = .attachment, .allowed = "ACDNUZ", .required = "ADZ" },
        .{ .t = .event, .allowed = "CDENPTUWZ", .required = "DEWZ" },
        .{ .t = .forum, .allowed = "DGHINPUWZ", .required = "DUWZ" },
    };
    // inline: maskOfLetters needs its argument comptime-known, and rows is
    // itself a comptime-known literal array.
    inline for (rows) |row| {
        try std.testing.expectEqual(maskOfLetters(row.allowed), allowed_mask.get(row.t));
        try std.testing.expectEqual(maskOfLetters(row.required), required_mask.get(row.t));
    }
}

test "artifact §5.2: a card outside the allowed set is rejected" {
    const Row = struct { c: Census, want_type: ArtifactType };
    // Each baseline satisfies its type's required set plus one letter
    // outside the allowed set; the extra letter is never type-setting, so
    // the finalized type is unaffected (event's extra, 'A', is
    // type-setting but alphabetically precedes 'E' and so loses per r1).
    const rows = [_]Row{
        .{ .c = .{ .letters = maskOfLetters("BDWZ") }, .want_type = .manifest },
        .{ .c = .{ .letters = maskOfLetters("CMZ") }, .want_type = .cluster },
        // Not 'C': control has no type-setting letter, so (unlike every
        // other row here) it reaches §5.3's fallback, and 'C' present
        // would flip the fallback result to MANIFEST (p2).
        .{ .c = .{ .letters = maskOfLetters("DTUWZ") }, .want_type = .control },
        .{ .c = .{ .letters = maskOfLetters("DLTUWZ") }, .want_type = .wiki },
        .{ .c = .{ .letters = maskOfLetters("CDJKUZ") }, .want_type = .ticket },
        .{ .c = .{ .letters = maskOfLetters("ADPZ") }, .want_type = .attachment },
        .{ .c = .{ .letters = maskOfLetters("ADEWZ") }, .want_type = .event },
        .{ .c = .{ .letters = maskOfLetters("CDGUWZ") }, .want_type = .forum },
    };
    for (rows) |row| {
        try std.testing.expectEqual(row.want_type, determineType(row.c));
        try std.testing.expectError(error.DisallowedCard, classify(row.c));
    }
}

test "artifact §5.2: a missing required card is rejected" {
    const rows = [_]Census{
        .{ .letters = maskOfLetters("BZ") }, // manifest, D missing
        .{ .letters = maskOfLetters("M") }, // cluster, Z missing
        .{ .letters = maskOfLetters("DUZ") }, // control, T missing
        .{ .letters = maskOfLetters("LUWZ") }, // wiki, D missing
        .{ .letters = maskOfLetters("JKUZ") }, // ticket, D missing
        .{ .letters = maskOfLetters("AZ") }, // attachment, D missing
        .{ .letters = maskOfLetters("DEZ") }, // event, W missing
        .{ .letters = maskOfLetters("GUWZ") }, // forum, D missing
    };
    for (rows) |c| {
        try std.testing.expectError(error.MissingRequiredCard, classify(c));
    }
}

test "artifact §5.2: legality is checked against the finalized type" {
    // M sets the type to CLUSTER; CLUSTER allows only M and Z, so the
    // extra C card is rejected only if the check ran against CLUSTER, not
    // some other type (C is legal on several other types).
    const c = Census{ .letters = maskOfLetters("CM") };
    try std.testing.expectEqual(ArtifactType.cluster, determineType(c));
    try std.testing.expectError(error.DisallowedCard, classify(c));
}

test "artifact §5.2: a manifest with neither C nor U is accepted" {
    const c = Census{ .letters = maskOfLetters("BDZ") };
    try std.testing.expectEqual(ArtifactType.manifest, try classify(c));
}

test "artifact §5.2: a wiki without a U card is rejected" {
    const c = Census{ .letters = maskOfLetters("DLWZ") };
    try std.testing.expectError(error.MissingRequiredCard, classify(c));
}

test "artifact §5.2: an artifact with no Z card is rejected here" {
    // Required-complete-minus-Z per type: even though §4.1's checksum
    // gate tolerates an absent Z card, §5.2 rejects it for every type.
    const rows = [_]Census{
        .{ .letters = maskOfLetters("BD") },
        .{ .letters = maskOfLetters("M") },
        .{ .letters = maskOfLetters("DTU") },
        .{ .letters = maskOfLetters("DLUW") },
        .{ .letters = maskOfLetters("DJKU") },
        .{ .letters = maskOfLetters("AD") },
        .{ .letters = maskOfLetters("DEW") },
        .{ .letters = maskOfLetters("DGUW") },
    };
    for (rows) |c| {
        try std.testing.expectError(error.MissingRequiredCard, classify(c));
    }
}

test "artifact §5.3: each type-setting letter assigns its type" {
    const Row = struct { letter: u8, t: ArtifactType };
    const rows = [_]Row{
        .{ .letter = 'A', .t = .attachment },
        .{ .letter = 'B', .t = .manifest },
        .{ .letter = 'E', .t = .event },
        .{ .letter = 'F', .t = .manifest },
        .{ .letter = 'G', .t = .forum },
        .{ .letter = 'H', .t = .forum },
        .{ .letter = 'I', .t = .forum },
        .{ .letter = 'J', .t = .ticket },
        .{ .letter = 'K', .t = .ticket },
        .{ .letter = 'L', .t = .wiki },
        .{ .letter = 'M', .t = .cluster },
        .{ .letter = 'Q', .t = .manifest },
        .{ .letter = 'R', .t = .manifest },
    };
    for (rows) |row| {
        const c = Census{ .letters = @as(u32, 1) << @intCast(row.letter - 'A') };
        try std.testing.expectEqual(row.t, determineType(c));
    }
}

test "artifact §5.3: the eight non-type-setting letters leave the type to fallback" {
    const letters8 = "CDNPTUWZ";
    // Bounded: 2^8 letter-subsets x 2 p_parents values = 512 rounds.
    var subset: u32 = 0;
    while (subset < 256) : (subset += 1) {
        var letters: u32 = 0;
        for (letters8, 0..) |l, i| {
            if ((subset & (@as(u32, 1) << @intCast(i))) != 0) {
                letters |= @as(u32, 1) << @intCast(l - 'A');
            }
        }
        for ([_]u8{ 0, 1 }) |p| {
            const t = determineType(.{ .letters = letters, .p_parents = p });
            try std.testing.expect(t == .manifest or t == .control);
        }
    }
}

test "artifact §5.3: the alphabetically latest type-setting card wins" {
    try std.testing.expectEqual(ArtifactType.cluster, determineType(.{ .letters = maskOfLetters("AM") }));
    try std.testing.expectEqual(ArtifactType.wiki, determineType(.{ .letters = maskOfLetters("BL") }));
    try std.testing.expectEqual(ArtifactType.forum, determineType(.{ .letters = maskOfLetters("EG") }));
    try std.testing.expectEqual(ArtifactType.manifest, determineType(.{ .letters = maskOfLetters("AB") }));
}

test "artifact §5.3: fallback resolves by C/P presence" {
    try std.testing.expectEqual(ArtifactType.manifest, determineType(.{ .letters = maskOfLetters("C") }));
    try std.testing.expectEqual(ArtifactType.manifest, determineType(.{ .letters = maskOfLetters("P"), .p_parents = 1 }));
    try std.testing.expectEqual(ArtifactType.control, determineType(.{ .letters = maskOfLetters("DZ") }));
    // P card present but bare (zero parent tokens, §4.7.13 p1) does not
    // count as "P card parents recorded".
    try std.testing.expectEqual(ArtifactType.control, determineType(.{ .letters = maskOfLetters("P"), .p_parents = 0 }));
}

test "artifact §5.3: §5.2 legality runs after the fallback finalizes the type" {
    // No C, no P parents -> fallback to CONTROL; N is not in CONTROL's
    // allowed set, so this can only fail as DisallowedCard if the check
    // ran against the fallback-finalized type.
    const control_case = Census{ .letters = maskOfLetters("DNTUZ") };
    try std.testing.expectEqual(ArtifactType.control, determineType(control_case));
    try std.testing.expectError(error.DisallowedCard, classify(control_case));

    // C present -> fallback to MANIFEST; W is not in MANIFEST's allowed
    // set.
    const manifest_case = Census{ .letters = maskOfLetters("CDWZ") };
    try std.testing.expectEqual(ArtifactType.manifest, determineType(manifest_case));
    try std.testing.expectError(error.DisallowedCard, classify(manifest_case));
}

test "artifact §5.4: a CONTROL artifact with a self-referential T card is rejected" {
    const c = Census{ .letters = maskOfLetters("DTUZ"), .t_self_ref = true };
    try std.testing.expectError(error.ControlSelfTag, classify(c));

    const ok = Census{ .letters = maskOfLetters("DTUZ"), .t_self_ref = false };
    try std.testing.expectEqual(ArtifactType.control, try classify(ok));
}

test "artifact §5.4: an EVENT artifact with a non-self-referential T card is rejected" {
    const c = Census{ .letters = maskOfLetters("DETWZ"), .t_not_self_ref = true };
    try std.testing.expectError(error.EventTagNotSelfRef, classify(c));
}

test "artifact §5.4: an EVENT artifact with a T card lacking the + prefix is rejected" {
    const c = Census{
        .letters = maskOfLetters("DETWZ"),
        .t_self_ref = true,
        .t_not_plus = true,
    };
    try std.testing.expectError(error.EventTagNotPlus, classify(c));

    const ok = Census{
        .letters = maskOfLetters("DETWZ"),
        .t_self_ref = true,
        .t_not_plus = false,
    };
    try std.testing.expectEqual(ArtifactType.event, try classify(ok));
}

test "artifact §5.4: a FORUM artifact with both H and I cards is rejected" {
    const c = Census{ .letters = maskOfLetters("DHIUWZ") };
    try std.testing.expectError(error.ForumHeadAndReply, classify(c));
}

test "artifact §5.4: a FORUM artifact whose P card lists more than one parent is rejected" {
    const rejected = Census{ .letters = maskOfLetters("DGPUWZ"), .p_parents = 2 };
    try std.testing.expectError(error.ForumMultiParent, classify(rejected));

    const ok = Census{ .letters = maskOfLetters("DGPUWZ"), .p_parents = 1 };
    try std.testing.expectEqual(ArtifactType.forum, try classify(ok));
}

test "artifact §5.2/§5.4: a well-formed artifact of each type is accepted" {
    const Row = struct { c: Census, t: ArtifactType };
    const rows = [_]Row{
        .{ .c = .{ .letters = maskOfLetters("BDZ") }, .t = .manifest },
        .{ .c = .{ .letters = maskOfLetters("MZ") }, .t = .cluster },
        .{ .c = .{ .letters = maskOfLetters("DTUZ") }, .t = .control },
        .{ .c = .{ .letters = maskOfLetters("DLUWZ") }, .t = .wiki },
        .{ .c = .{ .letters = maskOfLetters("DJKUZ") }, .t = .ticket },
        .{ .c = .{ .letters = maskOfLetters("ADZ") }, .t = .attachment },
        .{ .c = .{ .letters = maskOfLetters("DEWZ") }, .t = .event },
        .{ .c = .{ .letters = maskOfLetters("DGUWZ") }, .t = .forum },
    };
    for (rows) |row| {
        try std.testing.expectEqual(row.t, try classify(row.c));
    }
}

test "artifact fuzz: classify and determineType are total over arbitrary censuses (§5 robustness)" {
    var prng = std.Random.DefaultPrng.init(0xA47F_0007);
    const rand = prng.random();
    var round: usize = 0;
    while (round < 400) : (round += 1) {
        const c = Census{
            .letters = rand.int(u32),
            .p_parents = rand.int(u8),
            .t_self_ref = rand.boolean(),
            .t_not_self_ref = rand.boolean(),
            .t_not_plus = rand.boolean(),
        };
        const t2 = determineType(c);
        try std.testing.expect(@intFromEnum(t2) >= 1 and @intFromEnum(t2) <= 8);
        // Contract: classify never panics -- always a declared ClassifyError
        // or a concrete code-1..8 type (§5.1-m1: never the sentinel).
        if (classify(c)) |t| {
            try std.testing.expect(@intFromEnum(t) >= 1 and @intFromEnum(t) <= 8);
        } else |_| {}
    }
}

// ---------------------------------------------------------------------------
// §4/§5 parse() assembler -- real card bytes end to end, as opposed to the
// classify()-level tests above, which feed a hand-built Census directly.
// ---------------------------------------------------------------------------

const TEST_TS = "2007-07-21T14:09:59"; // §4.6.4 writer form, reused across bodies
const HASH40_HIGH: []const u8 = "f" ** 40; // sorts above A_CARD_HASH40 (§4.5.3)

/// Test-only artifact constructor: appends a matching §6.2 Z card computed
/// over `body` (already newline-terminated card lines) into `buf`, so the
/// result passes frame's gate 4. The parse VERDICT under test is always
/// derived from RFC clauses, never from this digest.
fn buildArtifact(buf: []u8, body: []const u8) []const u8 {
    std.debug.assert(buf.len >= body.len + 35);
    @memcpy(buf[0..body.len], body);
    var digest_buf: [32]u8 = undefined;
    const digest = selfChecksum(body, &digest_buf);
    buf[body.len] = 'Z';
    buf[body.len + 1] = ' ';
    @memcpy(buf[body.len + 2 ..][0..32], digest);
    buf[body.len + 34] = '\n';
    const total = body.len + 35;
    std.debug.assert(buf[total - 1] == '\n');
    return buf[0..total];
}

test "artifact §5: parse assembles the worked-example manifest" {
    var m = try parse(std.testing.allocator, a1_1_manifest);
    defer m.deinit();
    try std.testing.expectEqual(ArtifactType.manifest, m.kind);
}

test "artifact §4.4/§A.1.1: parse retains every decoded card of the worked example" {
    var m = try parse(std.testing.allocator, a1_1_manifest);
    defer m.deinit();

    try std.testing.expectEqual(ArtifactType.manifest, m.kind);

    // §4.3 Table 1: the wire's \s decodes to SP.
    try std.testing.expect(m.c != null);
    try std.testing.expectEqualStrings("initial empty baseline", m.c.?.comment);

    // §4.3.1: D is a raw, never-decoded position.
    try std.testing.expect(m.d != null);
    try std.testing.expectEqualStrings("2007-07-21T14:09:59", m.d.?.timestamp);

    // §4.7.13 m1: bare P (zero tokens) is retained, not dropped.
    try std.testing.expectEqual(@as(usize, 1), m.p.len);
    try std.testing.expectEqual(@as(usize, 0), m.p[0].count);
    try std.testing.expectEqual(@as(?[]const u8, null), m.p[0].parents);

    // §4.7.15: R is raw md5.
    try std.testing.expect(m.r != null);
    try std.testing.expectEqualStrings("d41d8cd98f00b204e9800998ecf8427e", m.r.?.checksum);

    // §4.7.17: U is decoded; no escapes present in this fixture.
    try std.testing.expect(m.u != null);
    try std.testing.expectEqualStrings("drh", m.u.?.login);

    // §4.7.19: Z is raw md5.
    try std.testing.expect(m.z != null);
    try std.testing.expectEqualStrings("c62e3619f7c41ee77b33f00df8ec26f4", m.z.?.checksum);

    // Every letter absent from this fixture retains as null/empty.
    try std.testing.expect(m.a == null);
    try std.testing.expect(m.b == null);
    try std.testing.expect(m.e == null);
    try std.testing.expect(m.g == null);
    try std.testing.expect(m.h == null);
    try std.testing.expect(m.i == null);
    try std.testing.expect(m.k == null);
    try std.testing.expect(m.l == null);
    try std.testing.expect(m.n == null);
    try std.testing.expect(m.w == null);
    try std.testing.expectEqual(@as(usize, 0), m.f.len);
    try std.testing.expectEqual(@as(usize, 0), m.j.len);
    try std.testing.expectEqual(@as(usize, 0), m.m.len);
    try std.testing.expectEqual(@as(usize, 0), m.q.len);
    try std.testing.expectEqual(@as(usize, 0), m.t.len);
}

test "artifact §4.5.2/§A.1.1: repeatable-card fields expose wire-order runs" {
    // §4.7.6: a baseline manifest's F cards all carry a hash (deferred
    // enforcement -- see assertNoDeleteMarkers) -- give both a hash so this
    // stays a legal artifact under that eventual rule, not just today's parse.
    var buf: [200]u8 = undefined;
    const bytes = buildArtifact(buf[0..], "D " ++ TEST_TS ++ "\nF alpha.txt " ++ A_CARD_HASH40 ++ "\nF beta.txt " ++ HASH40_HIGH ++ "\n");
    var m = try parse(std.testing.allocator, bytes);
    defer m.deinit();

    try std.testing.expectEqual(ArtifactType.manifest, m.kind);
    try std.testing.expectEqual(@as(usize, 2), m.f.len);
    try std.testing.expectEqualStrings("alpha.txt", m.f[0].filename);
    try std.testing.expectEqualStrings("beta.txt", m.f[1].filename);
}

test "artifact §4.7.13: a P card with parents is retained with count and raw slice" {
    var buf: [220]u8 = undefined;
    const body = "D " ++ TEST_TS ++ "\nP " ++ A_CARD_HASH40 ++ " " ++ HASH40_HIGH ++ "\n";
    const bytes = buildArtifact(buf[0..], body);
    var m = try parse(std.testing.allocator, bytes);
    defer m.deinit();

    try std.testing.expectEqual(@as(usize, 1), m.p.len);
    try std.testing.expectEqual(@as(usize, 2), m.p[0].count);
    var it = m.p[0].hashes(); // §4.7.13: tokens are neither sorted nor deduped -- order is semantic
    try std.testing.expectEqualStrings(A_CARD_HASH40, it.next().?);
    try std.testing.expectEqualStrings(HASH40_HIGH, it.next().?);
    try std.testing.expect(it.next() == null);
}

test "artifact §4.7.3: a B card is retained as the delta-manifest marker" {
    var buf: [128]u8 = undefined;
    const bytes = buildArtifact(buf[0..], "B " ++ A_CARD_HASH40 ++ "\nD " ++ TEST_TS ++ "\n");
    var m = try parse(std.testing.allocator, bytes);
    defer m.deinit();

    try std.testing.expect(m.b != null);
    try std.testing.expectEqualStrings(A_CARD_HASH40, m.b.?.hash);
}

test "artifact §5.2/§5.4: parse accepts a minimal well-formed artifact of each type" {
    const Row = struct { body: []const u8, t: ArtifactType };
    const rows = [_]Row{
        .{ .body = "B " ++ A_CARD_HASH40 ++ "\nD " ++ TEST_TS ++ "\n", .t = .manifest },
        .{ .body = "M " ++ A_CARD_HASH40 ++ "\n", .t = .cluster },
        .{ .body = "D " ++ TEST_TS ++ "\nT +tagname " ++ A_CARD_HASH40 ++ "\nU drh\n", .t = .control },
        .{ .body = "D " ++ TEST_TS ++ "\nL Wiki\\sPage\nU drh\nW 5\nhello\n", .t = .wiki },
        .{ .body = "D " ++ TEST_TS ++ "\nJ field val\nK " ++ A_CARD_HASH40 ++ "\nU drh\n", .t = .ticket },
        .{ .body = "A file.txt " ++ A_CARD_HASH40 ++ "\nD " ++ TEST_TS ++ "\n", .t = .attachment },
        .{ .body = "D " ++ TEST_TS ++ "\nE " ++ TEST_TS ++ " " ++ A_CARD_HASH40 ++ "\nW 5\nhello\n", .t = .event },
        .{ .body = "D " ++ TEST_TS ++ "\nG " ++ A_CARD_HASH40 ++ "\nU drh\nW 5\nhello\n", .t = .forum },
    };
    var buf: [220]u8 = undefined;
    for (rows) |row| {
        const bytes = buildArtifact(buf[0..], row.body);
        var m = try parse(std.testing.allocator, bytes);
        defer m.deinit();
        try std.testing.expectEqual(row.t, m.kind);
    }
}

test "artifact §5.5: an input whose first card letter is N..Z is rejected" {
    // Every type's smallest required letter is <= 'M' (CLUSTER's own M) --
    // an 'N' first card can satisfy no type's required set (§5.5 minima).
    try std.testing.expectError(error.SyntaxError, parse(std.testing.allocator, "N text/plain\n"));
}

test "artifact §5.5: a first card letter of M (cluster minimum) passes the pre-filter" {
    var buf: [80]u8 = undefined;
    const bytes = buildArtifact(buf[0..], "M " ++ A_CARD_HASH40 ++ "\n");
    var m = try parse(std.testing.allocator, bytes);
    defer m.deinit();
    try std.testing.expectEqual(ArtifactType.cluster, m.kind);
}

test "artifact §4.1: raw input not ending in LF is rejected" {
    try std.testing.expectError(error.SyntaxError, parse(std.testing.allocator, "D " ++ TEST_TS));
}

test "artifact §4.1/§6.2: a present-but-mismatched Z card is rejected" {
    const body = "B " ++ A_CARD_HASH40 ++ "\nD " ++ TEST_TS ++ "\n";
    var buf: [128]u8 = undefined;
    const good = buildArtifact(buf[0..], body);
    var mutated: [128]u8 = undefined;
    @memcpy(mutated[0..good.len], good);
    const digest_pos = body.len + 2; // first hex digit of the digest
    mutated[digest_pos] = if (mutated[digest_pos] == 'a') 'b' else 'a';
    try std.testing.expectError(error.SyntaxError, parse(std.testing.allocator, mutated[0..good.len]));
}

test "artifact §4.2.2: a card line with an undefined/out-of-alphabet letter is rejected" {
    // 'O' as the SECOND card (not the first) proves the tokenizer's own
    // §4.2.2 rejection fires -- a first-card 'O' would instead be caught
    // earlier by the §5.5 pre-filter ('O' > 'M').
    try std.testing.expectError(error.SyntaxError, parse(std.testing.allocator, "D " ++ TEST_TS ++ "\nO foo\n"));
}

test "artifact §4.2.1: a card carrying excess tokens is rejected" {
    try std.testing.expectError(error.SyntaxError, parse(std.testing.allocator, "B " ++ A_CARD_HASH40 ++ " extra\n"));
}

test "artifact §4.4: out-of-order cards are rejected" {
    try std.testing.expectError(error.SyntaxError, parse(std.testing.allocator, "D " ++ TEST_TS ++ "\nC hello\n"));
}

test "artifact §4.5.1: a duplicate single-occurrence card is rejected" {
    try std.testing.expectError(error.SyntaxError, parse(std.testing.allocator, "D " ++ TEST_TS ++ "\nD 2008-01-01T00:00:00\n"));
}

test "artifact §4.5.2: a descending F-card run is rejected" {
    try std.testing.expectError(error.SyntaxError, parse(std.testing.allocator, "F beta.txt\nF alpha.txt\n"));
}

test "artifact §4.5.2: a descending J-card run is rejected" {
    try std.testing.expectError(error.SyntaxError, parse(std.testing.allocator, "J field2 val\nJ field1 val\n"));
}

test "artifact §4.5.2: a descending M-card run is rejected" {
    const bytes = "M " ++ HASH40_HIGH ++ "\nM " ++ A_CARD_HASH40 ++ "\n";
    try std.testing.expectError(error.SyntaxError, parse(std.testing.allocator, bytes));
}

test "artifact §4.5.2: a T-card run not two-level ascending is rejected" {
    // A leading D card keeps the run past the §5.5 pre-filter ('T' > 'M')
    // so the rejection under test is the intra-run check, not the filter.
    const bytes = "D " ++ TEST_TS ++ "\nT +tagname " ++ HASH40_HIGH ++ "\nT +tagname " ++ A_CARD_HASH40 ++ "\n";
    try std.testing.expectError(error.SyntaxError, parse(std.testing.allocator, bytes));
}

test "artifact §4.7: a card whose own grammar fails is rejected" {
    try std.testing.expectError(error.SyntaxError, parse(std.testing.allocator, "D not-a-timestamp\n"));
}

test "artifact §4.1: trailing bytes after the final card are rejected" {
    const body = "B " ++ A_CARD_HASH40 ++ "\nD " ++ TEST_TS ++ "\n";
    var buf: [128]u8 = undefined;
    const good = buildArtifact(buf[0..], body);
    var full: [160]u8 = undefined;
    @memcpy(full[0..good.len], good);
    const junk = "X garbage\n";
    @memcpy(full[good.len..][0..junk.len], junk);
    try std.testing.expectError(error.SyntaxError, parse(std.testing.allocator, full[0 .. good.len + junk.len]));
}

test "artifact §5.2: a card outside the finalized type's allowed set is rejected" {
    var buf: [128]u8 = undefined;
    const bytes = buildArtifact(buf[0..], "D " ++ TEST_TS ++ "\nM " ++ A_CARD_HASH40 ++ "\n");
    try std.testing.expectError(error.DisallowedCard, parse(std.testing.allocator, bytes));
}

test "artifact §5.2: parse rejects a missing required card" {
    // No type-setting letter and no C/P card -- §5.3 fallback resolves
    // this to CONTROL, whose required set (D T U Z) is still unmet.
    try std.testing.expectError(error.MissingRequiredCard, parse(std.testing.allocator, "D " ++ TEST_TS ++ "\n"));
}

test "artifact §5.4: CONTROL with a self-referential T card is rejected" {
    var buf: [220]u8 = undefined;
    const bytes = buildArtifact(buf[0..], "D " ++ TEST_TS ++ "\nT +tagname *\nU drh\n");
    try std.testing.expectError(error.ControlSelfTag, parse(std.testing.allocator, bytes));
}

test "artifact §5.4: EVENT T card not self-referential is rejected" {
    var buf: [220]u8 = undefined;
    const bytes = buildArtifact(buf[0..], "D " ++ TEST_TS ++ "\nE " ++ TEST_TS ++ " " ++ A_CARD_HASH40 ++ "\nT +tagname " ++ A_CARD_HASH40 ++ "\nW 5\nhello\n");
    try std.testing.expectError(error.EventTagNotSelfRef, parse(std.testing.allocator, bytes));
}

test "artifact §5.4: EVENT T card lacking the + prefix is rejected" {
    var buf: [220]u8 = undefined;
    const bytes = buildArtifact(buf[0..], "D " ++ TEST_TS ++ "\nE " ++ TEST_TS ++ " " ++ A_CARD_HASH40 ++ "\nT -tagname *\nW 5\nhello\n");
    try std.testing.expectError(error.EventTagNotPlus, parse(std.testing.allocator, bytes));
}

test "artifact §5.4: FORUM with both H and I cards is rejected" {
    var buf: [220]u8 = undefined;
    const bytes = buildArtifact(buf[0..], "D " ++ TEST_TS ++ "\nH My\\sThread\nI " ++ A_CARD_HASH40 ++ "\nU drh\nW 5\nhello\n");
    try std.testing.expectError(error.ForumHeadAndReply, parse(std.testing.allocator, bytes));
}

test "artifact §5.4: FORUM P card with more than one parent is rejected" {
    var buf: [220]u8 = undefined;
    const bytes = buildArtifact(buf[0..], "D " ++ TEST_TS ++ "\nG " ++ A_CARD_HASH40 ++ "\nP " ++ A_CARD_HASH40 ++ " " ++ HASH40_HIGH ++ "\nU drh\nW 5\nhello\n");
    try std.testing.expectError(error.ForumMultiParent, parse(std.testing.allocator, bytes));
}

test "artifact §7.1: policy value strings match case-sensitively and exactly" {
    // §7.1-p1: "Value strings MUST be matched case-sensitively and exactly."
    try std.testing.expectEqual(HashPolicy.sha1, policyFromString("sha1").?);
    try std.testing.expectEqual(HashPolicy.auto, policyFromString("auto").?);
    try std.testing.expectEqual(HashPolicy.sha3, policyFromString("sha3").?);
    try std.testing.expectEqual(HashPolicy.sha3_only, policyFromString("sha3-only").?);
    try std.testing.expectEqual(HashPolicy.shun_sha1, policyFromString("shun-sha1").?);

    try std.testing.expectEqual(@as(?HashPolicy, null), policyFromString("SHA1"));
    try std.testing.expectEqual(@as(?HashPolicy, null), policyFromString("Auto"));
    try std.testing.expectEqual(@as(?HashPolicy, null), policyFromString("SHA3-ONLY"));
    try std.testing.expectEqual(@as(?HashPolicy, null), policyFromString("Sha3-Only"));
    try std.testing.expectEqual(@as(?HashPolicy, null), policyFromString(" sha1"));
    try std.testing.expectEqual(@as(?HashPolicy, null), policyFromString("sha1 "));
}

test "artifact §7.1: unrecognized policy value is rejected" {
    // §7.1-r1: "an unrecognized value is a fatal error" -- modeled as null.
    try std.testing.expectEqual(@as(?HashPolicy, null), policyFromString("md5"));
    try std.testing.expectEqual(@as(?HashPolicy, null), policyFromString(""));
    try std.testing.expectEqual(@as(?HashPolicy, null), policyFromString("sha"));
    try std.testing.expectEqual(@as(?HashPolicy, null), policyFromString("sha3only"));
    try std.testing.expectEqual(@as(?HashPolicy, null), policyFromString("auto\n"));
}

test "artifact §7.1: policy ordinals are 0..4 in table order" {
    // §7.1-w1: "The ordinal column gives each policy's stable numeric
    // encoding (0 through 4, in the order listed)."
    try std.testing.expectEqual(@as(u3, 0), @intFromEnum(HashPolicy.sha1));
    try std.testing.expectEqual(@as(u3, 1), @intFromEnum(HashPolicy.auto));
    try std.testing.expectEqual(@as(u3, 2), @intFromEnum(HashPolicy.sha3));
    try std.testing.expectEqual(@as(u3, 3), @intFromEnum(HashPolicy.sha3_only));
    try std.testing.expectEqual(@as(u3, 4), @intFromEnum(HashPolicy.shun_sha1));
}

test "artifact §7.1: policy string round-trips" {
    // Guards the single value-string table: encode then parse is identity
    // for every policy (supports p1's encode/parse pairing).
    for (std.enums.values(HashPolicy)) |p| {
        try std.testing.expectEqual(p, policyFromString(policyName(p)).?);
    }
}

test "artifact §7.1: sha1 and auto name with the SHA-1 primary hash" {
    // "Under sha1 and auto the primary hash MUST be SHA-1."
    try std.testing.expectEqual(HashAlgo.sha1, primaryHash(.sha1));
    try std.testing.expectEqual(HashAlgo.sha1, primaryHash(.auto));
}

test "artifact §7.1: sha3, sha3-only, shun-sha1 name with the SHA3-256 primary" {
    // "under sha3, sha3-only, and shun-sha1 it MUST be SHA3-256."
    try std.testing.expectEqual(HashAlgo.sha3_256, primaryHash(.sha3));
    try std.testing.expectEqual(HashAlgo.sha3_256, primaryHash(.sha3_only));
    try std.testing.expectEqual(HashAlgo.sha3_256, primaryHash(.shun_sha1));
}

test "artifact §7.1: alternative hash permitted only under sha1, auto, sha3" {
    // "permitted only under sha1 and auto (where it is SHA3-256) and under
    // sha3 (where it is SHA-1)."
    try std.testing.expectEqual(HashAlgo.sha3_256, alternativeHash(.sha1).?);
    try std.testing.expectEqual(HashAlgo.sha3_256, alternativeHash(.auto).?);
    try std.testing.expectEqual(HashAlgo.sha1, alternativeHash(.sha3).?);
}

test "artifact §7.1: alternative hash request fails under sha3-only and shun-sha1" {
    // "a request for the alternative hash MUST fail, yielding a failure
    // indication and an empty result rather than a hash value."
    try std.testing.expectEqual(@as(?HashAlgo, null), alternativeHash(.sha3_only));
    try std.testing.expectEqual(@as(?HashAlgo, null), alternativeHash(.shun_sha1));
}

test "artifact §7.2: default is sha3 when a SHA3 name exists or no SHA-1 name exists" {
    // "If the repository already contains any SHA3-named artifact ... or
    // contains no SHA-1-named artifact ... at all -- including the case of
    // an empty repository -- the default is sha3."
    try std.testing.expectEqual(HashPolicy.sha3, defaultPolicy(false, false)); // empty repo
    try std.testing.expectEqual(HashPolicy.sha3, defaultPolicy(false, true));
    try std.testing.expectEqual(HashPolicy.sha3, defaultPolicy(true, true));
}

test "artifact §7.2: default is auto only with a SHA-1 name and no SHA3 name" {
    // "Otherwise -- only when at least one SHA-1 artifact exists and zero
    // SHA3 artifacts exist -- the default is auto."
    try std.testing.expectEqual(HashPolicy.auto, defaultPolicy(true, false));
}

test "artifact §7.2: default derivation never yields shun-sha1" {
    // "This derivation never yields shun-sha1."
    const bools = [_]bool{ false, true };
    for (bools) |has_sha1| {
        for (bools) |has_sha3| {
            const p = defaultPolicy(has_sha1, has_sha3);
            try std.testing.expect(p == .sha3 or p == .auto);
        }
    }
}

test "artifact §7.3: setting the policy back to sha1 is never coerced" {
    // "Nothing prevents an operator from explicitly setting the policy
    // back to sha1" -- and more generally, no non-auto request is coerced.
    try std.testing.expectEqual(HashPolicy.sha1, coerceAssignment(.sha1, true));
    try std.testing.expectEqual(HashPolicy.sha1, coerceAssignment(.sha1, false));
    try std.testing.expectEqual(HashPolicy.sha3, coerceAssignment(.sha3, true));
    try std.testing.expectEqual(HashPolicy.sha3_only, coerceAssignment(.sha3_only, true));
    try std.testing.expectEqual(HashPolicy.shun_sha1, coerceAssignment(.shun_sha1, true));
}

test "artifact §7.3: requesting auto with a SHA3 artifact present stores sha3" {
    // "Requesting auto when the repository already holds a SHA3-named
    // artifact silently stores sha3 instead."
    try std.testing.expectEqual(HashPolicy.sha3, coerceAssignment(.auto, true));
    try std.testing.expectEqual(HashPolicy.auto, coerceAssignment(.auto, false));
}

test "artifact §7.4: auto ratchets to sha3 when the selected name exceeds 40 characters" {
    // "MUST promote the repository to sha3 ... whenever that selected name
    // exceeds 40 characters."
    try std.testing.expectEqual(HashPolicy.sha3, ratchet(.auto, sha3_name_64));
}

test "artifact §7.4: the ratchet applies only under auto and only above 40 chars" {
    // A fresh SHA-1 name under auto is exactly 40 characters -- no
    // ratchet; and the automatic forward ratchet applies only while the
    // effective policy is auto (§7.3's "no demotion guard" sentence).
    try std.testing.expectEqual(HashPolicy.auto, ratchet(.auto, a1_1_name));
    try std.testing.expectEqual(HashPolicy.sha1, ratchet(.sha1, sha3_name_64));
    try std.testing.expectEqual(HashPolicy.sha3, ratchet(.sha3, sha3_name_64));
    try std.testing.expectEqual(HashPolicy.sha3_only, ratchet(.sha3_only, sha3_name_64));
    try std.testing.expectEqual(HashPolicy.shun_sha1, ratchet(.shun_sha1, sha3_name_64));
}

test "artifact §7.4: a caller-supplied 64-hex SHA3-256 name triggers the ratchet" {
    // §7.4 trigger 1: "a caller-supplied 64-hexadecimal SHA3-256 name
    // accepted for storage during transfer or ingest."
    try std.testing.expectEqual(HashPolicy.sha3, ratchet(.auto, sha3_name_64));
}

test "artifact §7.5: naming computes the alternative hash first, falls back to primary on a miss" {
    // "MUST first compute the alternative-algorithm hash (where the
    // policy permits one) and look it up; only if that lookup misses does
    // it fall back to naming the content with the primary hash."
    try std.testing.expectEqual(HashAlgo.sha3_256, dedupNameAlgo(.sha1, true));
    try std.testing.expectEqual(HashAlgo.sha1, dedupNameAlgo(.sha1, false));
    try std.testing.expectEqual(HashAlgo.sha1, dedupNameAlgo(.sha3, true));
    try std.testing.expectEqual(HashAlgo.sha3_256, dedupNameAlgo(.sha3, false));
    try std.testing.expectEqual(HashAlgo.sha3_256, dedupNameAlgo(.auto, true));
    try std.testing.expectEqual(HashAlgo.sha1, dedupNameAlgo(.auto, false));
}

test "artifact §7.5: under sha3-only and shun-sha1 new content is named SHA3-256 even when a SHA-1 name exists" {
    // "Because these policies disallow the alternative hash, the pre-check
    // cannot discover an existing SHA-1 name for the same bytes. New
    // artifacts MUST be named with SHA3-256 even when identical content
    // already carries a SHA-1 name."
    try std.testing.expectEqual(HashAlgo.sha3_256, dedupNameAlgo(.sha3_only, true));
    try std.testing.expectEqual(HashAlgo.sha3_256, dedupNameAlgo(.sha3_only, false));
    try std.testing.expectEqual(HashAlgo.sha3_256, dedupNameAlgo(.shun_sha1, true));
    try std.testing.expectEqual(HashAlgo.sha3_256, dedupNameAlgo(.shun_sha1, false));
}

// ---------------------------------------------------------------------------
// §8 Delta Manifest Reconstruction — tests
// ---------------------------------------------------------------------------

// Two distinct valid 40-hex hashes, reused across §8 vectors. `d8_h1` is
// `A_CARD_HASH40` verbatim; `d8_h2` is a different 40-hex prefix of
// `A_CARD_HASH64` so the two never collide.
const d8_h1: []const u8 = A_CARD_HASH40;
const d8_h2: []const u8 = A_CARD_HASH64[0..40];

comptime {
    std.debug.assert(d8_h1.len == 40 and d8_h2.len == 40);
    std.debug.assert(!std.mem.eql(u8, d8_h1, d8_h2));
}

/// Build one test F card: filename plus an optional hash (null = delete
/// marker). §8 never consults perm/old_name/mode, so every §8 vector holds
/// them at their default values.
fn d8F(filename: []const u8, hash: ?[]const u8) FCard {
    return .{ .filename = filename, .hash = hash, .perm = null, .old_name = null, .mode = .regular };
}

/// Field-wise FCard comparison for §8 tests: filename and hash presence/
/// value. perm/old_name/mode are never varied by `d8F`, so comparing them
/// would add nothing.
fn d8ExpectFCard(expected: FCard, actual: FCard) !void {
    try std.testing.expectEqualStrings(expected.filename, actual.filename);
    try std.testing.expectEqual(expected.hash == null, actual.hash == null);
    if (expected.hash) |h| try std.testing.expectEqualStrings(h, actual.hash.?);
}

/// Drain `walk` and compare against `expected`, then assert exhaustion --
/// the shared assertion shape for every §8.2 stream vector.
fn d8ExpectStream(walk: *MergeWalk, expected: []const FCard) !void {
    for (expected) |exp| {
        const got = walk.next() orelse return error.TestUnexpectedResult;
        try d8ExpectFCard(exp, got);
    }
    try std.testing.expect(walk.next() == null);
}

/// Test double for `BaselineResolver` (§8.3): returns a fixed result and
/// counts calls, so laziness (§8.3-p1) and "not consulted" claims (§8.4-m2)
/// are observable.
const D8SpyResolver = struct {
    result: ?ManifestFiles,
    calls: usize = 0,

    fn resolveImpl(ctx: *anyopaque, hash: []const u8) ?ManifestFiles {
        _ = hash;
        const self: *D8SpyResolver = @ptrCast(@alignCast(ctx));
        self.calls += 1;
        return self.result;
    }

    fn resolver(self: *D8SpyResolver) BaselineResolver {
        return .{ .ctx = self, .resolveFn = resolveImpl };
    }
};

test "artifact §8: manifest is a delta iff it carries a B card" {
    // "A manifest is a delta manifest if and only if it contains a B card;
    // otherwise it is a baseline manifest." (§8-p1)
    const delta = ManifestFiles{ .baseline_hash = d8_h1, .files = &.{} };
    const baseline = ManifestFiles{ .baseline_hash = null, .files = &.{} };
    try std.testing.expect(isDelta(delta));
    try std.testing.expect(!isDelta(baseline));
}

test "artifact §8: full file set is derived by merging delta with baseline" {
    // "Consumers that need the full file set MUST derive it by merging the
    // delta's F cards with the baseline's, as specified in this section."
    // (§8-m1)
    const baseline_files = [_]FCard{d8F("a", d8_h1)};
    const delta_files = [_]FCard{d8F("b", d8_h2)};
    var spy = D8SpyResolver{ .result = .{ .baseline_hash = null, .files = &baseline_files } };
    const delta = ManifestFiles{ .baseline_hash = d8_h1, .files = &delta_files };

    var walk = try enumerate(delta, spy.resolver());
    try d8ExpectStream(&walk, &.{ d8F("a", d8_h1), d8F("b", d8_h2) });
}

test "artifact §8.1: merge walk relies on sorted file lists" {
    // "The merge walk requires both file lists sorted by decoded filename
    // under the canonical comparator (Section 4.5.3). That sortedness is a
    // parse-time invariant (Section 4.5.2): every successfully parsed
    // manifest satisfies it." (§8.1-p1)
    const baseline_files = [_]FCard{ d8F("a", d8_h1), d8F("c", d8_h1) };
    const delta_files = [_]FCard{d8F("b", d8_h2)};
    var spy = D8SpyResolver{ .result = .{ .baseline_hash = null, .files = &baseline_files } };
    const delta = ManifestFiles{ .baseline_hash = d8_h1, .files = &delta_files };

    var walk = try enumerate(delta, spy.resolver());
    try d8ExpectStream(&walk, &.{ d8F("a", d8_h1), d8F("b", d8_h2), d8F("c", d8_h1) });
}

test "artifact §8.1: hashless F card is a delete marker removing the baseline file" {
    // "A delete marker is an F card that carries no hash. It is permitted
    // only in a delta manifest -- in a baseline manifest every F card MUST
    // carry a hash (Section 4.7.6) -- and it carries a null hash,
    // interpreted by the merge walk as removal of the named file from the
    // baseline's set." (§8.1-r1)
    const baseline_files = [_]FCard{d8F("a", d8_h1)};
    const delta_files = [_]FCard{d8F("a", null)};
    var spy = D8SpyResolver{ .result = .{ .baseline_hash = null, .files = &baseline_files } };
    const delta = ManifestFiles{ .baseline_hash = d8_h1, .files = &delta_files };

    var walk = try enumerate(delta, spy.resolver());
    var set: [1]FCard = undefined;
    var n: usize = 0;
    while (walk.next()) |f| {
        if (f.hash == null) continue;
        std.debug.assert(n < set.len);
        set[n] = f;
        n += 1;
    }
    try std.testing.expectEqual(@as(usize, 0), n); // "a" removed from the set
}

test "artifact §8.2: merge-walk over two cursors (comprehensive)" {
    // "To enumerate the complete file set of a delta manifest, an
    // implementation MUST perform a filename-ordered merge-walk over two
    // cursors: one over the baseline's file list and one over the delta's.
    // Filenames are compared byte-wise on their decoded form (Section
    // 4.5.3)." (§8.2-p1)
    const baseline_files = [_]FCard{ d8F("a", d8_h1), d8F("b", d8_h1), d8F("d", d8_h1) };
    const delta_files = [_]FCard{ d8F("b", d8_h2), d8F("c", null), d8F("d", null) };
    var spy = D8SpyResolver{ .result = .{ .baseline_hash = null, .files = &baseline_files } };
    const delta = ManifestFiles{ .baseline_hash = d8_h1, .files = &delta_files };

    var walk = try enumerate(delta, spy.resolver());
    try d8ExpectStream(&walk, &.{ d8F("a", d8_h1), d8F("b", d8_h2), d8F("c", null) });

    var walk2 = try enumerate(delta, spy.resolver());
    var set: [3]FCard = undefined;
    var n: usize = 0;
    while (walk2.next()) |f| {
        if (f.hash == null) continue;
        std.debug.assert(n < set.len);
        set[n] = f;
        n += 1;
    }
    try std.testing.expectEqual(@as(usize, 2), n);
    try d8ExpectFCard(d8F("a", d8_h1), set[0]);
    try d8ExpectFCard(d8F("b", d8_h2), set[1]);
}

test "artifact §8.2: same name, delta carries hash, delta replaces" {
    // RFC step 1: "If both cursors name the same file and the delta entry
    // carries a hash, the delta entry replaces the baseline entry: both
    // cursors advance and the delta entry is emitted." (§8.2-r1)
    const baseline_files = [_]FCard{d8F("a", d8_h1)};
    const delta_files = [_]FCard{d8F("a", d8_h2)};
    var spy = D8SpyResolver{ .result = .{ .baseline_hash = null, .files = &baseline_files } };
    const delta = ManifestFiles{ .baseline_hash = d8_h1, .files = &delta_files };

    var walk = try enumerate(delta, spy.resolver());
    try d8ExpectStream(&walk, &.{d8F("a", d8_h2)});
}

test "artifact §8.2: same name, delta delete marker removes the file" {
    // RFC step 2: "If both cursors name the same file and the delta entry
    // carries no hash (a delete marker), the file is removed: both cursors
    // advance and no entry is emitted." (§8.2-r2)
    const baseline_files = [_]FCard{d8F("a", d8_h1)};
    const delta_files = [_]FCard{d8F("a", null)};
    var spy = D8SpyResolver{ .result = .{ .baseline_hash = null, .files = &baseline_files } };
    const delta = ManifestFiles{ .baseline_hash = d8_h1, .files = &delta_files };

    var walk = try enumerate(delta, spy.resolver());
    try d8ExpectStream(&walk, &.{});
}

test "artifact §8.2: differing names, sorts-first entry emitted" {
    // RFC step 3: "If the names differ, the entry that sorts first is
    // emitted and only its cursor advances." (§8.2-r3)
    { // (a) delta's "a" sorts before baseline's "b"
        const baseline_files = [_]FCard{d8F("b", d8_h1)};
        const delta_files = [_]FCard{d8F("a", d8_h2)};
        var spy = D8SpyResolver{ .result = .{ .baseline_hash = null, .files = &baseline_files } };
        const delta = ManifestFiles{ .baseline_hash = d8_h1, .files = &delta_files };
        var walk = try enumerate(delta, spy.resolver());
        try d8ExpectStream(&walk, &.{ d8F("a", d8_h2), d8F("b", d8_h1) });
    }
    { // (b) baseline's "a" sorts before delta's "b"
        const baseline_files = [_]FCard{d8F("a", d8_h1)};
        const delta_files = [_]FCard{d8F("b", d8_h2)};
        var spy = D8SpyResolver{ .result = .{ .baseline_hash = null, .files = &baseline_files } };
        const delta = ManifestFiles{ .baseline_hash = d8_h1, .files = &delta_files };
        var walk = try enumerate(delta, spy.resolver());
        try d8ExpectStream(&walk, &.{ d8F("a", d8_h1), d8F("b", d8_h2) });
    }
}

test "artifact §8.2: unmatched delete marker emitted but adds no file" {
    // "A delta delete marker whose name is not present in the baseline (an
    // unmatched deletion) is emitted here like any other sorts-first entry,
    // but because it carries no hash it adds no file to the reconstructed
    // set. An unmatched deletion is therefore a no-op for materialization,
    // not an error." (§8.2-w1)
    const baseline_files = [_]FCard{d8F("b", d8_h1)};
    const delta_files = [_]FCard{d8F("a", null)};
    var spy = D8SpyResolver{ .result = .{ .baseline_hash = null, .files = &baseline_files } };
    const delta = ManifestFiles{ .baseline_hash = d8_h1, .files = &delta_files };

    var walk = try enumerate(delta, spy.resolver());
    try d8ExpectStream(&walk, &.{ d8F("a", null), d8F("b", d8_h1) });

    var walk2 = try enumerate(delta, spy.resolver());
    var set: [2]FCard = undefined;
    var n: usize = 0;
    while (walk2.next()) |f| {
        if (f.hash == null) continue;
        std.debug.assert(n < set.len);
        set[n] = f;
        n += 1;
    }
    try std.testing.expectEqual(@as(usize, 1), n);
    try d8ExpectFCard(d8F("b", d8_h1), set[0]);
}

test "artifact §8.2: enumeration terminates only when both cursors exhausted (tail delete markers)" {
    // "Enumeration terminates only when both cursors are exhausted. Once
    // either list runs out, the remaining entries of the other list are
    // emitted verbatim -- including any delete markers remaining in the
    // delta's tail." (§8.2-m1)
    { // delta tail, including a trailing delete marker
        const baseline_files = [_]FCard{d8F("a", d8_h1)};
        const delta_files = [_]FCard{d8F("z", null)};
        var spy = D8SpyResolver{ .result = .{ .baseline_hash = null, .files = &baseline_files } };
        const delta = ManifestFiles{ .baseline_hash = d8_h1, .files = &delta_files };
        var walk = try enumerate(delta, spy.resolver());
        try d8ExpectStream(&walk, &.{ d8F("a", d8_h1), d8F("z", null) });
    }
    { // baseline tail, delta exhausted first
        const baseline_files = [_]FCard{ d8F("a", d8_h1), d8F("b", d8_h1) };
        var spy = D8SpyResolver{ .result = .{ .baseline_hash = null, .files = &baseline_files } };
        const delta = ManifestFiles{ .baseline_hash = d8_h1, .files = &.{} };
        var walk = try enumerate(delta, spy.resolver());
        try d8ExpectStream(&walk, &.{ d8F("a", d8_h1), d8F("b", d8_h1) });
    }
}

test "artifact §8.2: consumers filter hash-absent entries" {
    // "Consumers of the merged stream MUST therefore filter out entries
    // whose hash is absent when computing derived results, as the
    // aggregate-checksum computation over the merged file set does
    // (Section 6.3.3)." (§8.2-m2)
    const baseline_files = [_]FCard{d8F("a", d8_h1)};
    const delta_files = [_]FCard{d8F("z", null)};
    var spy = D8SpyResolver{ .result = .{ .baseline_hash = null, .files = &baseline_files } };
    const delta = ManifestFiles{ .baseline_hash = d8_h1, .files = &delta_files };

    var walk = try enumerate(delta, spy.resolver());
    var tracked: [1]TrackedFile = undefined;
    var n: usize = 0;
    while (walk.next()) |f| {
        if (f.hash == null) continue; // m2: filter the hash-absent marker
        std.debug.assert(n < tracked.len);
        tracked[n] = .{ .name = f.filename, .content = "" };
        n += 1;
    }
    try std.testing.expectEqual(@as(usize, 1), n); // marker excluded, not fed forward

    var out: [32]u8 = undefined;
    _ = aggregateChecksum(tracked[0..n], &out); // §6.3.3: no crash on the filtered set
}

test "artifact §8.3: baseline resolved lazily, only when needed" {
    // "The baseline identified by the B card hash is resolved lazily: it
    // is located and parsed as a manifest artifact the first time the
    // baseline is needed." (§8.3-p1)
    const baseline_files = [_]FCard{d8F("a", d8_h1)};

    var delta_spy = D8SpyResolver{ .result = .{ .baseline_hash = null, .files = &baseline_files } };
    const delta = ManifestFiles{ .baseline_hash = d8_h1, .files = &.{} };
    _ = try enumerate(delta, delta_spy.resolver());
    try std.testing.expectEqual(@as(usize, 1), delta_spy.calls);

    var baseline_spy = D8SpyResolver{ .result = null };
    const baseline_manifest = ManifestFiles{ .baseline_hash = null, .files = &baseline_files };
    _ = try enumerate(baseline_manifest, baseline_spy.resolver());
    try std.testing.expectEqual(@as(usize, 0), baseline_spy.calls);
}

test "artifact §8.3: unresolvable baseline is a fatal error of the enumeration" {
    // "Hard failure, for the file-enumeration entry points: beginning full
    // file-set enumeration, and single-name search (Section 8.4), MUST
    // treat an unresolvable baseline as a fatal error of the enumeration."
    // (§8.3-m1, both entry points)
    var spy = D8SpyResolver{ .result = null };
    const delta_files = [_]FCard{d8F("b", d8_h2)};
    const delta = ManifestFiles{ .baseline_hash = d8_h1, .files = &delta_files };

    try std.testing.expectError(error.BaselineUnresolvable, enumerate(delta, spy.resolver()));
    try std.testing.expectError(error.BaselineUnresolvable, lookup(delta, "a", spy.resolver()));
}

test "artifact §8.3: baseline resolved before iteration, no silent delta-only degrade" {
    // "Per-step enumeration reports no error for an unresolved baseline...
    // Implementations MUST therefore ensure the baseline is resolved,
    // under the hard-failure discipline, before iteration begins."
    // (§8.3-m2) -- `enumerate` returns the error itself (not an ok
    // `MergeWalk` that would silently yield only the delta's own entries),
    // and the resolver was actually consulted before that error surfaced.
    var spy = D8SpyResolver{ .result = null };
    const delta_files = [_]FCard{d8F("b", d8_h2)};
    const delta = ManifestFiles{ .baseline_hash = d8_h1, .files = &delta_files };

    try std.testing.expectError(error.BaselineUnresolvable, enumerate(delta, spy.resolver()));
    try std.testing.expectEqual(@as(usize, 1), spy.calls); // resolution attempted, not skipped
}

test "artifact §4.7.3/§8.3: resolved baseline that is itself a delta manifest is rejected" {
    // "If the resolved baseline is itself a delta manifest (it carries a B
    // card), the delta manifest naming it is invalid and MUST be
    // rejected." (§4.7.3, enforced at §8's resolution point, §8.3-m1)
    const cases = [_][]const FCard{
        &.{d8F("a", null)}, // (a) delta baseline carries a delete marker
        &.{d8F("a", d8_h1)}, // (b) delta baseline carries only hashed cards
    };
    for (cases) |case_files| {
        var spy = D8SpyResolver{ .result = .{ .baseline_hash = d8_h2, .files = case_files } };
        const delta_files = [_]FCard{d8F("b", d8_h2)};
        const delta = ManifestFiles{ .baseline_hash = d8_h1, .files = &delta_files };

        try std.testing.expectError(error.BaselineIsDelta, enumerate(delta, spy.resolver()));
        try std.testing.expectError(error.BaselineIsDelta, lookup(delta, "a", spy.resolver()));
    }
}

test "artifact §8.4: delta's own F cards searched first; best-match suppressed" {
    // "When resolving a single filename against a delta manifest, an
    // implementation MUST search the delta's own F cards first, and
    // best-match (closest-name) lookup MUST be suppressed for delta
    // manifests even when the caller requests it." (§8.4-m1)
    const baseline_files = [_]FCard{d8F("a", d8_h1)};
    var spy = D8SpyResolver{ .result = .{ .baseline_hash = null, .files = &baseline_files } };
    const delta_files = [_]FCard{d8F("a", d8_h2)};
    const delta = ManifestFiles{ .baseline_hash = d8_h1, .files = &delta_files };

    const result = try lookup(delta, "a", spy.resolver());
    switch (result) {
        .found => |f| try d8ExpectFCard(d8F("a", d8_h2), f),
        .not_found => return error.TestUnexpectedResult,
    }
    try std.testing.expectEqual(@as(usize, 0), spy.calls); // delta's own card wins, baseline untouched

    // Near-miss: "ab" is not an exact match for delta's "abc" -- exact-only
    // means it falls through to the baseline rather than fuzzy-matching,
    // and the baseline (queried) has no "ab" either.
    const delta_files2 = [_]FCard{d8F("abc", d8_h2)};
    const baseline_files2 = [_]FCard{d8F("abd", d8_h1)};
    var spy2 = D8SpyResolver{ .result = .{ .baseline_hash = null, .files = &baseline_files2 } };
    const delta2 = ManifestFiles{ .baseline_hash = d8_h1, .files = &delta_files2 };
    const result2 = try lookup(delta2, "ab", spy2.resolver());
    try std.testing.expectEqual(LookupResult.not_found, result2);
    try std.testing.expectEqual(@as(usize, 1), spy2.calls); // fell through to baseline, found nothing there either
}

test "artifact §8.4: delta delete marker means not-found without consulting baseline" {
    // "If the delta contains an entry for the name whose hash is absent (a
    // delete marker), the lookup MUST report not-found without consulting
    // the baseline." (§8.4-m2)
    const baseline_files = [_]FCard{d8F("a", d8_h1)};
    var spy = D8SpyResolver{ .result = .{ .baseline_hash = null, .files = &baseline_files } };
    const delta_files = [_]FCard{d8F("a", null)};
    const delta = ManifestFiles{ .baseline_hash = d8_h1, .files = &delta_files };

    const result = try lookup(delta, "a", spy.resolver());
    try std.testing.expectEqual(LookupResult.not_found, result);
    try std.testing.expectEqual(@as(usize, 0), spy.calls);
}

test "artifact §8.4: name absent from delta means the baseline is resolved and searched" {
    // "Only when the name is entirely absent from the delta is the
    // baseline resolved and searched." (§8.4-p1)
    const baseline_files = [_]FCard{d8F("a", d8_h1)};
    var spy = D8SpyResolver{ .result = .{ .baseline_hash = null, .files = &baseline_files } };
    const delta_files = [_]FCard{d8F("b", d8_h2)};
    const delta = ManifestFiles{ .baseline_hash = d8_h1, .files = &delta_files };

    const found = try lookup(delta, "a", spy.resolver());
    switch (found) {
        .found => |f| try d8ExpectFCard(d8F("a", d8_h1), f),
        .not_found => return error.TestUnexpectedResult,
    }
    try std.testing.expectEqual(@as(usize, 1), spy.calls);

    const missing = try lookup(delta, "c", spy.resolver());
    try std.testing.expectEqual(LookupResult.not_found, missing);
}

// ---------------------------------------------------------------------------
// §9 Security Considerations -- tests
// ---------------------------------------------------------------------------
//
// Every clause here restates a byte-extent discipline already carried by
// `artifactName`/`verify`, `stripSignatureWrapper`/`verifyZCard`, and
// `parseWCard` -- no new production code, only the composed-primitive
// assertions the section demands.

test "artifact §9: name and Z-verify operate over different byte extents" {
    // Lines 2209-2213: name computation covers the complete stored bytes,
    // wrapper included; Z verification covers the stripped stream. The
    // third extent the section names -- signature verification over the
    // pre-strip bytes -- is out of scope for this module (lines 2224-2230
    // leave signer trust and verification mechanism outside the document),
    // so its absence here is not a gap.
    const s = "-----BEGIN PGP SIGNED MESSAGE-----\nHash: SHA256\n\n" ++
        a1_1_manifest ++ "-----BEGIN PGP SIGNATURE-----\nabc123\n-----END PGP SIGNATURE-----\n";
    const stripped = stripSignatureWrapper(s);
    try std.testing.expect(stripped.len < s.len); // wrapper strictly shrinks the input
    try std.testing.expectEqual(ZVerdict.match, verifyZCard(stripped));
}

test "artifact §9: Z-verify input excludes wrapper, name input includes it" {
    // Lines 2214-2217: feeding the wrong extent to each operation.
    const s = "-----BEGIN PGP SIGNED MESSAGE-----\nHash: SHA256\n\n" ++
        a1_1_manifest ++ "-----BEGIN PGP SIGNATURE-----\nabc123\n-----END PGP SIGNATURE-----\n";

    // The wrapped bytes' final 35 bytes are "abc123\n" (7) plus the 28-byte
    // "-----END PGP SIGNATURE-----\n" armor line, so s[n-35] == 'a', not
    // 'Z' -- the positional gate rejects before any digest compare runs.
    try std.testing.expectEqual(ZVerdict.absent, verifyZCard(s));
    try std.testing.expectEqual(ZVerdict.match, verifyZCard(stripSignatureWrapper(s)));

    // Name computation is fed the wrapper on purpose: the inner-content
    // name never verifies the wrapped bytes, and vice versa.
    try std.testing.expect(verify(a1_1_name, a1_1_manifest));
    try std.testing.expect(!verify(a1_1_name, s));
}

test "artifact §9: signed and inner artifacts have distinct independent names" {
    // Lines 2217-2219: a signed artifact and its unsigned inner content are
    // distinct artifacts with distinct names, handled independently -- not
    // merely unequal names but names that bind only their own bytes.
    const s = "-----BEGIN PGP SIGNED MESSAGE-----\nHash: SHA256\n\n" ++
        a1_1_manifest ++ "-----BEGIN PGP SIGNATURE-----\nabc123\n-----END PGP SIGNATURE-----\n";

    var buf: [64]u8 = undefined;
    const n_signed = artifactName(.sha1, s, &buf);
    try std.testing.expect(!std.mem.eql(u8, n_signed, a1_1_name));

    try std.testing.expect(verify(n_signed, s));
    try std.testing.expect(!verify(n_signed, a1_1_manifest));
    try std.testing.expect(!verify(a1_1_name, s));
}

test "artifact §9: token-validation acceptance is not verified integrity" {
    // Lines 2254-2258: a token that passes format validation (correct
    // length, all lowercase hex) MUST NOT be treated as verified content --
    // that conjunction, ACCEPT-at-token-stage paired with REJECT-at-verify,
    // is the property under test, not re-tested by either check alone.
    const zero40: []const u8 = "0" ** 40;
    const name_rows = [_]struct { tok: []const u8, content: []const u8 }{
        .{ .tok = zero40, .content = a1_1_manifest }, // valid 40-hex, wrong value
        .{ .tok = a1_1_name, .content = "hello world\n" }, // valid name, wrong content
    };
    for (name_rows) |row| {
        try std.testing.expect(validateName(row.tok)); // token stage: ACCEPT
        try std.testing.expect(!verify(row.tok, row.content)); // verify stage: REJECT
    }

    // Z row: flip one digest digit of the a1_1 manifest (still 32-hex, so
    // still token-valid), leaving the recomputed checksum mismatched.
    var flipped: [127]u8 = undefined;
    @memcpy(&flipped, a1_1_manifest);
    flipped[94] = 'd'; // digest[0] was 'c' (§A.2 offset table); still hex
    const digest = flipped[94..126];
    var z_line: [35]u8 = undefined;
    z_line[0] = 'Z';
    z_line[1] = ' ';
    @memcpy(z_line[2..34], digest);
    z_line[34] = '\n';
    try std.testing.expect(parseZCard(tokenizeCardLine(&z_line).?) != null); // token stage: ACCEPT
    try std.testing.expectEqual(ZVerdict.mismatch, verifyZCard(&flipped)); // verify stage: REJECT
}

test "artifact §9: positional Z framing wins over a textual decoy" {
    // Lines 2276-2280: a repeated "Z " earlier in the artifact is ordinary
    // content covered by the final checksum; only the final 35 bytes
    // participate in verification, so a textual scan for "Z " is misled by
    // the earlier occurrence while the positional check is not.
    const decoy_digest: []const u8 = "1" ** 32;
    const prefix = "Z " ++ decoy_digest ++ "\nC filler\n";

    var digest_buf: [32]u8 = undefined;
    const digest = selfChecksum(prefix, &digest_buf);

    var content_buf: [prefix.len + 35]u8 = undefined;
    @memcpy(content_buf[0..prefix.len], prefix);
    content_buf[prefix.len] = 'Z';
    content_buf[prefix.len + 1] = ' ';
    @memcpy(content_buf[prefix.len + 2 .. prefix.len + 34], digest);
    content_buf[prefix.len + 34] = '\n';
    const content: []const u8 = &content_buf;

    try std.testing.expectEqual(ZVerdict.match, verifyZCard(content));

    // The first textual "Z " hit is the decoy at offset 0, strictly before
    // the real positional Z at content.len - 35.
    const first_hit = std.mem.indexOf(u8, content, "Z ").?;
    try std.testing.expect(first_hit < content.len - 35);
}

test "artifact §9: parse never reads past the buffer on truncation/W-edge" {
    // Lines 2269-2274: the W card's size-bound and overflow rules, plus
    // the general "MUST NOT read beyond the end of the artifact buffer
    // under any malformed input". The uniform-random 0..N sweep is already
    // covered by the line-5413 full-parse fuzz and the line-5470 parseWCard
    // fuzz; this sweep targets the exact boundaries those rarely land on.
    const a = std.testing.allocator;

    // Every exact prefix of a well-formed artifact: a declared ParseError
    // or a Manifest, never a trap -- an out-of-bounds slice panics under
    // Debug/ReleaseSafe, the property under test.
    var k: usize = 0;
    while (k <= a1_1_manifest.len) : (k += 1) {
        if (parse(a, a1_1_manifest[0..k])) |m| {
            var mm = m;
            mm.deinit();
        } else |_| {}
    }

    // W-edge: an 8-byte body followed by its terminator LF and one
    // trailing filler byte -- "remaining" (bytes after the size line) is
    // fixed at 10. Only size == remaining-2 can land the r3 check on the
    // real LF; remaining-1 and remaining put body_end one step from (or
    // at) the buffer's end, where r4's bound check MUST fire before
    // artifact[body_end] is ever read. remaining+1 rejects at r2, before
    // body_end is even computed. The final row overflows the accumulator
    // (r1) partway through 25 digits, well short of the digit span's own
    // bound.
    const body = "orangeee";
    comptime std.debug.assert(body.len == 8);
    const size_rows = [_][]const u8{ "8", "9", "10", "11", "9" ** 25 };
    for (size_rows) |digits| {
        var buf: [64]u8 = undefined;
        var w: usize = 0;
        buf[w] = 'W';
        w += 1;
        buf[w] = ' ';
        w += 1;
        std.debug.assert(w + digits.len < buf.len - body.len - 2);
        @memcpy(buf[w .. w + digits.len], digits);
        w += digits.len;
        buf[w] = '\n';
        w += 1;
        @memcpy(buf[w .. w + body.len], body);
        w += body.len;
        buf[w] = '\n';
        w += 1;
        buf[w] = 'X';
        w += 1;
        const art = buf[0..w];

        if (parseWCard(art, 0)) |card| {
            try std.testing.expect(card.next < art.len);
            try std.testing.expect(art[card.next - 1] == '\n');
            try std.testing.expect(card.body.len <= art.len);
        }
    }
}

// ---------------------------------------------------------------------------
// §6/§4 Writer Tests
// ---------------------------------------------------------------------------

/// Test-only deep-equality: content-compares every field of two values of
/// the same shape. Unlike `std.meta.eql` (pointer-identity on slices), a
/// byte slice compares by content -- the whole point here is cross-buffer
/// equality (`m0` borrows a vector's bytes, `m1` borrows a freshly created
/// buffer, so their `[]const u8` fields never share an address even when
/// equal).
fn deepEql(a: anytype, b: @TypeOf(a)) bool {
    const T = @TypeOf(a);
    return switch (@typeInfo(T)) {
        .@"struct" => |info| blk: {
            inline for (info.fields) |f| {
                if (!deepEql(@field(a, f.name), @field(b, f.name))) break :blk false;
            }
            break :blk true;
        },
        .optional => blk: {
            const av = a orelse break :blk (b == null);
            const bv = b orelse break :blk false;
            break :blk deepEql(av, bv);
        },
        .pointer => |info| switch (info.size) {
            .slice => if (info.child == u8) std.mem.eql(u8, a, b) else blk2: {
                if (a.len != b.len) break :blk2 false;
                for (a, b) |x, y| {
                    if (!deepEql(x, y)) break :blk2 false;
                }
                break :blk2 true;
            },
            else => a == b,
        },
        else => a == b, // enums, bools, ints: value equality
    };
}

/// §6.3 round-trip helper: `m0`'s data must equal `m1`'s -- every optional
/// card (deep) and every repeatable list (length then elements). Skips
/// `arena_state` (not a data field) and `z` (the Z card is a checksum OVER
/// the serialized prefix, so it legitimately differs when the prefix's card
/// order differs, e.g. the N/P reorder this test exercises -- `verifyZCard`
/// is the correctness check for Z, not manifest-data equality) via an
/// explicit field list rather than recursing the whole `Manifest` struct.
fn expectManifestEqual(a_m: Manifest, b_m: Manifest) !void {
    try std.testing.expectEqual(a_m.kind, b_m.kind);
    const fields = [_][]const u8{
        "a", "b", "c", "d", "e", "g", "h", "i", "k", "l",
        "n", "r", "u", "w", "f", "j", "m", "p", "q", "t",
    };
    inline for (fields) |name| {
        try std.testing.expect(deepEql(@field(a_m, name), @field(b_m, name)));
    }
}

test "artifact §6.2/§A.1.1/§A.2: create round-trips the worked-example manifest byte-for-byte" {
    const a = std.testing.allocator;
    var m = try parse(a, a1_1_manifest);
    defer m.deinit();

    const out = try create(&m, a);
    defer a.free(out);

    try std.testing.expectEqualSlices(u8, a1_1_manifest, out);
    try std.testing.expectEqualStrings("Z c62e3619f7c41ee77b33f00df8ec26f4\n", out[out.len - 35 ..]);
    try std.testing.expectEqual(ZVerdict.match, verifyZCard(out));
}

// §A.3 R-card worked example (RFC line 2544), RFC 8792 fold removed. Its
// own Z line is MD5 of its own prefix (self-validating: a bad transcription
// fails `parse`'s Z gate loudly, not silently).
const a3_manifest = @embedFile("rfc_vectors/artifact_a3_rcard_manifest.txt");

test "artifact §6.3.4/§A.3: create round-trips the R-card worked example byte-for-byte" {
    const a = std.testing.allocator;
    var m = try parse(a, a3_manifest);
    defer m.deinit();

    const out = try create(&m, a);
    defer a.free(out);

    try std.testing.expectEqualSlices(u8, a3_manifest, out);
    try std.testing.expect(std.mem.indexOf(u8, out, "R 95feb799536dc053e7968efb7cdd3ec3\n") != null);
    try std.testing.expectEqual(ZVerdict.match, verifyZCard(out));
}

test "artifact §6.3.4: create emits the R token as 32 lowercase hex even when the parse-accepted token was uppercase" {
    const a = std.testing.allocator;
    const upper = "D41D8CD98F00B204E9800998ECF8427E"; // §6.1: case-insensitive on read
    var prefix_buf: [128]u8 = undefined;
    const prefix = try std.fmt.bufPrint(&prefix_buf, "C x\nD 2007-07-21T14:09:59\nP\nR {s}\nU drh\n", .{upper});
    var digest: [32]u8 = undefined;
    _ = selfChecksum(prefix, &digest);
    var art_buf: [256]u8 = undefined;
    const art = try std.fmt.bufPrint(&art_buf, "{s}Z {s}\n", .{ prefix, digest });

    var m = try parse(a, art);
    defer m.deinit();

    const out = try create(&m, a);
    defer a.free(out);

    try std.testing.expect(std.mem.indexOf(u8, out, "R d41d8cd98f00b204e9800998ecf8427e\n") != null);
    try std.testing.expect(std.mem.indexOf(u8, out, "R " ++ upper) == null);
}

// §A.1.2 SHA3-256 technote example (RFC line 2436): W/E/N/T/P exercise.
// The RFC's folded prose (RFC 8792 backslash wrapping) could not be
// reconstructed to reproduce the RFC's stated 658/418-byte totals or SHA3-256
// name under any tested whitespace hypothesis -- logged as a new, non-halting
// ambiguity (scratchpad ambiguities ledger). This fixture is the natural
// single-blank-line/single-space transcription of the visible prose, with
// its OWN self-consistent W-size and Z-checksum (not the RFC's stated
// values) -- `verifyZCard` on it is the self-consistency check, not a
// literal-name match.
const a1_2_technote = @embedFile("rfc_vectors/artifact_a1_2_sha3_technote.txt");

test "artifact §4.7.18/§A.1.2: create round-trips the technote Manifest across parse->create->parse" {
    const a = std.testing.allocator;
    var m0 = try parse(a, a1_2_technote);
    defer m0.deinit();

    const out1 = try create(&m0, a);
    defer a.free(out1);

    var m1 = try parse(a, out1);
    defer m1.deinit();

    // §A.1.2 stores N-after-P (§4.4 compat exception); the writer always
    // emits strict-lexical N-before-P -- same Manifest, different bytes, so
    // this is a data comparison, not a byte comparison.
    try expectManifestEqual(m0, m1);
    try std.testing.expect(std.mem.indexOf(u8, out1, "W 414\n") != null);
    try std.testing.expectEqual(ZVerdict.match, verifyZCard(out1));

    // Re-create identity: a second pass over the re-parsed data yields the
    // exact same bytes -- `create` is a pure function of the Manifest.
    const out2 = try create(&m1, a);
    defer a.free(out2);
    try std.testing.expectEqualSlices(u8, out1, out2);
}

test "artifact §4.4/§4.7: create emits cards in non-decreasing card-type order" {
    const a = std.testing.allocator;
    var m = try parse(a, a3_manifest);
    defer m.deinit();

    const out = try create(&m, a);
    defer a.free(out);

    var prev: u8 = 0;
    var pos: usize = 0;
    // Bounded by out.len: `frame`'s own §4.1 grammar guarantees every line
    // (including a W body, opaque here since a3 carries none) ends in LF.
    while (pos < out.len) {
        const letter = out[pos];
        try std.testing.expect(isCardLetter(letter));
        try std.testing.expect(letter >= prev);
        prev = letter;
        const lf = std.mem.indexOfScalarPos(u8, out, pos, '\n').?;
        pos = lf + 1;
    }
}

test "artifact §4.5.2: create->parse->deinit of a large manifest crossing an arena node boundary leaks nothing" {
    const a = std.testing.allocator;
    var buf: std.ArrayList(u8) = .empty;
    defer buf.deinit(a);

    try buf.appendSlice(a, "C large\\stest\\smanifest\n");
    try buf.appendSlice(a, "D 2026-07-02T11:18:42.940\n");

    const hash64 = "a" ** 64; // any well-formed 64-hex placeholder (§4.7.6 baseline hash)
    var i: usize = 0;
    while (i < 300) : (i += 1) {
        try buf.print(a, "F f{d:0>4}.txt {s}\n", .{ i, hash64 });
    }
    try buf.appendSlice(a, "P " ++ ("b" ** 64) ++ "\n");
    i = 0;
    while (i < 300) : (i += 1) {
        try buf.print(a, "T +t{d:0>4} *\n", .{i});
    }
    try buf.appendSlice(a, "U drh\n");

    var digest: [32]u8 = undefined;
    _ = selfChecksum(buf.items, &digest);
    try buf.print(a, "Z {s}\n", .{digest});

    const canonical = buf.items;
    var m = try parse(a, canonical);
    const out = try create(&m, a);
    defer a.free(out);
    try std.testing.expectEqualSlices(u8, canonical, out);

    var m2 = try parse(a, out);
    m2.deinit();
    m.deinit();
}

/// `checkAllAllocationFailures` callback: `create` is the only allocation
/// under test here, so `m` is parsed with the stable `std.testing.allocator`
/// up front (outside the failing-allocator harness) and only the returned
/// bytes are freed with the allocator under test.
fn createNoLeak(allocator: std.mem.Allocator, m: *const Manifest) !void {
    const out = try create(m, allocator);
    allocator.free(out);
}

test "artifact §6/§4: create propagates allocation failure" {
    const a = std.testing.allocator;
    var m = try parse(a, a1_1_manifest);
    defer m.deinit();
    try std.testing.checkAllAllocationFailures(a, createNoLeak, .{&m});
}