//! TOML printer: renders a fig AST as canonical TOML.
//!
//! This is a *canonical* serializer, not a format-preserving round-tripper — the
//! editor (`editor.zig`) handles in-place, comment-preserving edits via source
//! spans. The printer takes a bare `*const AST` (no spans) and emits clean TOML.
//!
//! Structure mapping (the inverse of the parser):
//!   * the root mapping's scalar/array entries print as bare `key = value` lines;
//!   * a mapping value prints as a `[header.path]` section (so an inline-table
//!     value `p = {x=1}` canonicalizes to a `[p]` section — same AST);
//!   * a non-empty all-mapping sequence prints as `[[header.path]]` array-of-
//!     tables elements;
//!   * every other value (scalars, scalar/mixed arrays, empty arrays) prints
//!     inline, with nested mappings becoming inline tables `{ ... }`.
//!
//! A table's own header is suppressed when it has no direct inline entries but
//! does have sub-tables/AoTs (their headers imply it); an otherwise-empty table
//! still emits its `[header]` so its existence round-trips.
//!
//! **Key order is preserved.** TOML forces section headers to trail a table's
//! `key = value` lines, so a table child that precedes later inline siblings
//! cannot become a `[section]` without semantically reordering the map. Such
//! mid-order children *demote* to order-preserving spellings — dotted keys for
//! a table, an inline array for an array-of-tables — when they are
//! comment-free; a commented one keeps the trailing-section form (comments
//! outrank order). See `Ctx.body`.
//!
//! TOML has no null, so a `null` value is `error.NullUnsupported` (e.g. a JSON
//! `null` cannot convert to TOML). `enum_literal`/`char_literal` extended scalars
//! (only minted by ZON) degrade to a string / integer; datetimes print verbatim.

const Printer = @This();
const std = @import("std");
const AST = @import("../../ast/ast.zig");
const width = @import("../../util/util.zig").width;
const Writer = std.Io.Writer;

/// TOML cannot represent a YAML alias (materialize expands them first), a null,
/// or a non-string table key.
pub const Error = Writer.Error || error{ NullUnsupported, NonStringKey, UnresolvedAlias };

/// A header path built on the call stack (one link per nesting level), rendered
/// parent-first so no allocation is needed to accumulate `a.b.c`.
const Path = struct {
    key: []const u8,
    parent: ?*const Path,
};

/// How a value renders inside its parent table body.
const Class = enum {
    /// `key = value` on one line (scalars, arrays, inline tables).
    inline_,
    /// `[header]` section (any mapping value).
    section,
    /// `[[header]]` array-of-tables (non-empty, all-mapping sequence).
    aot,
};

/// Document-emit state: `wrote` gates the blank line printed before each
/// section/AoT header (suppressed at the very start of the output); `opts`
/// carries the width budget / indent / pretty knobs that drive the
/// inline-vs-expanded layout decisions below.
const Ctx = struct {
    w: *Writer,
    opts: AST.SerializeOptions,
    wrote: bool = false,

    /// Emit a table's body: its `key = value` lines in document order, then —
    /// as TOML requires — the trailing sub-table / array-of-tables sections.
    /// `path` is this table's header path (null at root).
    ///
    /// TOML cannot re-open a table header, so section children may only TRAIL
    /// the body lines. A table child that sits BEFORE a later inline sibling
    /// would be silently reordered by a naive inline-then-sections split — a
    /// semantic key-order change. Instead, such a child is **demoted** to an
    /// order-preserving spelling: dotted keys (`fig.version = "1.0.0"`) for a
    /// table, an inline array for an array-of-tables. Demotion requires a
    /// comment-free entry (the demoted spellings would drop or re-anchor
    /// comments); a commented mid-position child keeps the trailing-section
    /// form — comments outrank order.
    fn body(ctx: *Ctx, ast: *const AST, node_id: AST.Node.Id, first_child: ?AST.Node.Id, path: ?*const Path) Error!void {
        // The last inline-classified child bounds the demotion zone: only
        // section/AoT children before it are out of order.
        var last_inline: ?AST.Node.Id = null;
        {
            var cur = first_child;
            while (cur) |id| : (cur = ast.nodes[id].next_sibling) {
                if (classify(ctx, ast, id) == .inline_) last_inline = id;
            }
        }
        // Pass 1: body lines, in document order.
        var cur = first_child;
        var in_body_zone = (last_inline != null);
        while (cur) |id| : (cur = ast.nodes[id].next_sibling) {
            if (!in_body_zone) break;
            const kv = ast.nodes[id].kind.keyvalue;
            switch (classify(ctx, ast, id)) {
                .inline_ => try ctx.kvLine(ast, kv.key, kv.value),
                .section => if (demotable(ast, id)) {
                    const seg = Path{ .key = try keyText(ast, kv.key), .parent = null };
                    try ctx.dottedBody(ast, &seg, ast.nodes[kv.value].kind.mapping);
                },
                .aot => if (demotable(ast, id)) try ctx.kvLine(ast, kv.key, kv.value),
            }
            if (id == last_inline.?) in_body_zone = false;
        }
        // Comments dangling at the end of this table's own lines (after its
        // inline entries, before any sub-tables).
        for (ast.comments(node_id).dangling) |c| {
            var it = std.mem.splitScalar(u8, c.text, '\n');
            while (it.next()) |line| {
                try writeHashLine(ctx.w, std.mem.trim(u8, line, " \t"));
                try ctx.w.writeByte('\n');
            }
            ctx.wrote = true;
        }
        // Pass 2: trailing sections — children after the last inline line,
        // plus any commented mid-position child that could not demote.
        cur = first_child;
        var in_demote_zone = (last_inline != null);
        while (cur) |id| : (cur = ast.nodes[id].next_sibling) {
            const kv = ast.nodes[id].kind.keyvalue;
            const demoted = in_demote_zone and demotable(ast, id);
            switch (classify(ctx, ast, id)) {
                .inline_ => {},
                .section => if (!demoted) try ctx.section(ast, kv.key, kv.value, path),
                .aot => if (!demoted) try ctx.aot(ast, kv.key, kv.value, path),
            }
            if (in_demote_zone and id == last_inline.?) in_demote_zone = false;
        }
    }

    /// Emit a demoted table as dotted-key lines (`fig.version = "1.0.0"`),
    /// preserving document order where a `[section]` would have to move past
    /// later inline siblings. Only reached for comment-free subtrees. A
    /// non-empty sub-table recurses into deeper dots; everything else —
    /// scalars, arrays (including arrays of tables, rendered inline), empty
    /// tables — is one `prefix.key = value` line.
    fn dottedBody(ctx: *Ctx, ast: *const AST, prefix: *const Path, first_child: ?AST.Node.Id) Error!void {
        var cur = first_child;
        while (cur) |id| : (cur = ast.nodes[id].next_sibling) {
            const kv = ast.nodes[id].kind.keyvalue;
            const seg = Path{ .key = try keyText(ast, kv.key), .parent = prefix };
            switch (ast.nodes[kv.value].kind) {
                .mapping => |first| if (first != null) {
                    try ctx.dottedBody(ast, &seg, first);
                    continue;
                },
                else => {},
            }
            try writePath(ctx.w, &seg);
            try ctx.w.writeAll(" = ");
            const col = (pathByteLen(&seg) orelse 0) + 3;
            try ctx.writeValue(ast, kv.value, col);
            try ctx.w.writeByte('\n');
            ctx.wrote = true;
        }
    }

    fn kvLine(ctx: *Ctx, ast: *const AST, key_id: AST.Node.Id, value_id: AST.Node.Id) Error!void {
        try ctx.leading(ast, key_id);
        try writeKey(ctx.w, ast, key_id);
        try ctx.w.writeAll(" = ");
        const col = (keyByteLen(ast, key_id) orelse 0) + 3; // `key` + ` = `
        try ctx.writeValue(ast, value_id, col);
        try ctx.trailing(ast, value_id);
        try ctx.w.writeByte('\n');
        ctx.wrote = true;
    }

    /// Render a `key = value` right-hand side. A scalar/mixed array that would
    /// overflow `opts.width` (and `pretty` is set) wraps one element per line;
    /// everything else renders inline. (Mappings and array-of-tables that reach
    /// here already fit by construction — see `classify`.)
    fn writeValue(ctx: *Ctx, ast: *const AST, value_id: AST.Node.Id, col: usize) Error!void {
        switch (ast.nodes[value_id].kind) {
            .sequence => |first| {
                if (ctx.opts.pretty and first != null) {
                    // A null/alias inside makes the array un-measurable; fall
                    // through so `writeInline` surfaces the proper error.
                    if (inlineByteLen(ast, value_id)) |len| {
                        if (col + len > ctx.opts.width) return ctx.writeArrayMultiline(ast, first);
                    }
                }
                try writeInline(ctx.w, ast, value_id);
            },
            else => try writeInline(ctx.w, ast, value_id),
        }
    }

    /// Wrap an array across lines: `[`, then each element indented one level with
    /// a trailing comma, then `]` at column 0. Elements themselves render inline.
    fn writeArrayMultiline(ctx: *Ctx, ast: *const AST, first: ?AST.Node.Id) Error!void {
        const w = ctx.w;
        try w.writeAll("[\n");
        var cur = first;
        while (cur) |id| : (cur = ast.nodes[id].next_sibling) {
            try w.splatByteAll(' ', ctx.opts.indent);
            try writeInline(w, ast, id);
            try w.writeAll(",\n");
        }
        try w.writeByte(']');
    }

    fn section(ctx: *Ctx, ast: *const AST, key_id: AST.Node.Id, value_id: AST.Node.Id, parent: ?*const Path) Error!void {
        const seg = Path{ .key = try keyText(ast, key_id), .parent = parent };
        const first = ast.nodes[value_id].kind.mapping;
        if (needsHeader(ctx, ast, first)) {
            if (ctx.wrote) try ctx.w.writeByte('\n');
            try ctx.leading(ast, key_id); // comments above the `[header]` line
            try ctx.w.writeByte('[');
            try writePath(ctx.w, &seg);
            try ctx.w.writeAll("]\n");
            ctx.wrote = true;
        }
        try ctx.body(ast, value_id, first, &seg);
    }

    // ── Comments ──────────────────────────────────────────────────────────
    // TOML has only `#` line comments. A block comment carried from another
    // format degrades to a run of `#` lines (one per content line).

    /// Emit a key's leading comments above its line, at column 0.
    fn leading(ctx: *Ctx, ast: *const AST, key_id: AST.Node.Id) Error!void {
        for (ast.comments(key_id).leading) |c| {
            var it = std.mem.splitScalar(u8, c.text, '\n');
            while (it.next()) |line| {
                try writeHashLine(ctx.w, std.mem.trim(u8, line, " \t"));
                try ctx.w.writeByte('\n');
            }
            ctx.wrote = true;
        }
    }

    /// Emit a value's trailing comment after its line (` # …`). A multi-line
    /// block flattens to one line (newlines → spaces).
    fn trailing(ctx: *Ctx, ast: *const AST, value_id: AST.Node.Id) Error!void {
        const c = ast.comments(value_id).trailing orelse return;
        try ctx.w.writeAll(" #");
        if (c.text.len != 0) {
            try ctx.w.writeByte(' ');
            for (c.text) |ch| try ctx.w.writeByte(if (ch == '\n') ' ' else ch);
        }
    }

    fn aot(ctx: *Ctx, ast: *const AST, key_id: AST.Node.Id, value_id: AST.Node.Id, parent: ?*const Path) Error!void {
        const seg = Path{ .key = try keyText(ast, key_id), .parent = parent };
        var elem = ast.nodes[value_id].kind.sequence;
        while (elem) |eid| : (elem = ast.nodes[eid].next_sibling) {
            if (ctx.wrote) try ctx.w.writeByte('\n');
            try ctx.w.writeAll("[[");
            try writePath(ctx.w, &seg);
            try ctx.w.writeAll("]]\n");
            ctx.wrote = true;
            try ctx.body(ast, eid, ast.nodes[eid].kind.mapping, &seg);
        }
    }
};

/// Whether a section emits its own `[header]`: yes when empty (records its
/// existence) or when it has at least one direct inline entry; no when it has
/// only sub-tables/AoTs (whose own headers imply this table). Note an inline
/// table counts as a direct inline entry, so a table whose sub-mappings all
/// collapse to inline tables does regain its header.
fn needsHeader(ctx: *Ctx, ast: *const AST, first_child: ?AST.Node.Id) bool {
    var cur = first_child orelse return true; // empty table
    while (true) {
        if (classify(ctx, ast, cur) == .inline_) return true;
        cur = ast.nodes[cur].next_sibling orelse return false;
    }
}

/// Decide how a key/value entry renders. A mapping collapses to an inline table
/// (`{ ... }`) when `fitsInline` allows it, otherwise it expands to a
/// `[section]`. A non-empty all-mapping sequence is always an `[[array.of.tables]]`
/// (the block form reads better and is the conventional shape — we don't collapse
/// it even when it would fit). Scalars and scalar/mixed arrays are always
/// `inline_` (the array may still *wrap*, but it stays in place).
fn classify(ctx: *Ctx, ast: *const AST, kv_id: AST.Node.Id) Class {
    const kv = ast.nodes[kv_id].kind.keyvalue;
    return switch (ast.nodes[kv.value].kind) {
        .mapping => if (fitsInline(ctx, ast, kv_id)) .inline_ else .section,
        .sequence => |first| if (allMappings(ast, first)) .aot else .inline_,
        else => .inline_,
    };
}

/// Whether a mapping value should render inline (`k = { ... }`) rather than as a
/// `[section]`. Inlining is allowed only when the whole `key = value` line fits in
/// `opts.width`, the subtree carries no comments (the inline form can't emit them
/// — they'd be silently dropped), and the value isn't an empty mapping (kept as
/// `[header]` so the table's existence round-trips).
fn fitsInline(ctx: *Ctx, ast: *const AST, kv_id: AST.Node.Id) bool {
    const kv = ast.nodes[kv_id].kind.keyvalue;
    const value_id = kv.value;
    switch (ast.nodes[value_id].kind) {
        .mapping => |first| if (first == null) return false,
        else => {},
    }
    if (nodeHasComments(ast, kv_id)) return false;
    if (subtreeHasComments(ast, kv.key)) return false;
    if (subtreeHasComments(ast, value_id)) return false;
    const klen = keyByteLen(ast, kv.key) orelse return false;
    const vlen = inlineByteLen(ast, value_id) orelse return false;
    return klen + 3 + vlen <= ctx.opts.width; // `key` + ` = ` + value
}

// ── Width / comment measurement ─────────────────────────────────────────────
// The inline-layout decision measures a value's rendered width by printing it to
// a discarding writer with the very functions that emit the real output (see
// `util.width`), so the estimate can never drift from what's actually written.

/// Rendered byte width of a value's inline form, or null if it can't be inlined
/// (a `null`/alias inside makes `writeInline` error).
fn inlineByteLen(ast: *const AST, id: AST.Node.Id) ?usize {
    return width.rendered(writeInline, .{ ast, id });
}

/// Rendered byte width of a key (bare or quoted), or null for a non-string key.
fn keyByteLen(ast: *const AST, key_id: AST.Node.Id) ?usize {
    return width.rendered(writeKey, .{ ast, key_id });
}

/// A mid-order table/AoT child may demote to dotted keys / an inline array
/// only when the whole entry (kv node, key, and value subtree) is
/// comment-free: the demoted spellings would drop interior comments or
/// re-anchor a key's leading run. Comments outrank order, so a commented
/// child keeps the trailing-section form instead.
fn demotable(ast: *const AST, kv_id: AST.Node.Id) bool {
    const kv = ast.nodes[kv_id].kind.keyvalue;
    if (nodeHasComments(ast, kv_id)) return false;
    if (subtreeHasComments(ast, kv.key)) return false;
    if (subtreeHasComments(ast, kv.value)) return false;
    return true;
}

/// Rendered byte width of a dotted path, or null for a non-string segment.
fn pathByteLen(path: *const Path) ?usize {
    return width.rendered(writePath, .{path});
}

fn nodeHasComments(ast: *const AST, id: AST.Node.Id) bool {
    const c = ast.comments(id);
    return c.leading.len != 0 or c.trailing != null or c.dangling.len != 0;
}

/// Whether any node in the subtree rooted at `id` carries a comment. Used to keep
/// a commented mapping/array as an expanded section, where the printer can still
/// emit the comments (the inline `{ ... }` / `[ ... ]` forms cannot).
fn subtreeHasComments(ast: *const AST, id: AST.Node.Id) bool {
    if (nodeHasComments(ast, id)) return true;
    switch (ast.nodes[id].kind) {
        .sequence => |first| {
            var cur = first;
            while (cur) |e| : (cur = ast.nodes[e].next_sibling)
                if (subtreeHasComments(ast, e)) return true;
        },
        .mapping => |first| {
            var cur = first;
            while (cur) |kvid| : (cur = ast.nodes[kvid].next_sibling) {
                if (nodeHasComments(ast, kvid)) return true;
                const kv = ast.nodes[kvid].kind.keyvalue;
                if (subtreeHasComments(ast, kv.key)) return true;
                if (subtreeHasComments(ast, kv.value)) return true;
            }
        },
        else => {},
    }
    return false;
}

/// True for a non-empty sequence whose every element is a mapping — the shape
/// TOML writes as `[[array.of.tables]]`.
fn allMappings(ast: *const AST, first: ?AST.Node.Id) bool {
    var cur = first orelse return false;
    while (true) {
        if (ast.nodes[cur].kind != .mapping) return false;
        cur = ast.nodes[cur].next_sibling orelse return true;
    }
}

// ── Inline value rendering ──────────────────────────────────────────────────

fn writeInline(w: *Writer, ast: *const AST, id: AST.Node.Id) Error!void {
    switch (ast.nodes[id].kind) {
        .null_ => return error.NullUnsupported,
        .boolean => |b| try w.writeAll(if (b) "true" else "false"),
        .number => |n| try w.writeAll(n.raw),
        .string => |s| try writeBasicString(w, s),
        .extended => |ext| try writeExtended(w, ext),
        .sequence => |first| try writeInlineArray(w, ast, first),
        .mapping => |first| try writeInlineTable(w, ast, first),
        .keyvalue => unreachable, // a keyvalue is never a value position
        .alias => return error.UnresolvedAlias,
    }
}

fn writeInlineArray(w: *Writer, ast: *const AST, first: ?AST.Node.Id) Error!void {
    if (first == null) {
        try w.writeAll("[]");
        return;
    }
    try w.writeByte('[');
    var cur = first;
    while (cur) |id| {
        try writeInline(w, ast, id);
        cur = ast.nodes[id].next_sibling;
        if (cur != null) try w.writeAll(", ");
    }
    try w.writeByte(']');
}

fn writeInlineTable(w: *Writer, ast: *const AST, first: ?AST.Node.Id) Error!void {
    if (first == null) {
        try w.writeAll("{}");
        return;
    }
    try w.writeAll("{ ");
    var cur = first;
    while (cur) |id| {
        const kv = ast.nodes[id].kind.keyvalue;
        try writeKey(w, ast, kv.key);
        try w.writeAll(" = ");
        try writeInline(w, ast, kv.value);
        cur = ast.nodes[id].next_sibling;
        if (cur != null) try w.writeAll(", ");
    }
    try w.writeAll(" }");
}

/// Render an `extended` scalar. Datetimes print verbatim (their text is already
/// valid TOML). An `enum_literal` (ZON-only) has no TOML form, so it degrades to
/// a string; a `char_literal`'s text is a decimal codepoint, valid as an integer.
fn writeExtended(w: *Writer, ext: AST.Node.Kind.Extended) Error!void {
    switch (ext.kind) {
        .offset_datetime, .local_datetime, .local_date, .local_time => try w.writeAll(ext.text),
        .enum_literal => try writeBasicString(w, ext.text),
        .char_literal => try w.writeAll(ext.text),
        // JSON5 `Infinity`/`NaN` map onto TOML's native lowercase float forms.
        .number_special => try w.writeAll(tomlSpecial(ext.text)),
    }
}

/// Map a JSON5 non-finite lexeme (`Infinity`, `-Infinity`, `+Infinity`, `NaN`,
/// `-NaN`, `+NaN`) to its TOML float spelling (`inf`/`-inf`/`+inf`/`nan`).
fn tomlSpecial(text: []const u8) []const u8 {
    if (std.mem.endsWith(u8, text, "NaN")) {
        return if (text.len > 0 and text[0] == '-') "-nan" else if (text.len > 0 and text[0] == '+') "+nan" else "nan";
    }
    if (std.mem.startsWith(u8, text, "-")) return "-inf";
    if (std.mem.startsWith(u8, text, "+")) return "+inf";
    return "inf";
}

// ── Keys and strings ────────────────────────────────────────────────────────

fn keyText(ast: *const AST, key_id: AST.Node.Id) Error![]const u8 {
    return switch (ast.nodes[key_id].kind) {
        .string => |s| s,
        else => error.NonStringKey,
    };
}

/// A key prints bare when non-empty and all of `[A-Za-z0-9_-]`, else quoted.
fn writeKey(w: *Writer, ast: *const AST, key_id: AST.Node.Id) Error!void {
    const name = try keyText(ast, key_id);
    if (isBareKey(name)) {
        try w.writeAll(name);
    } else {
        try writeBasicString(w, name);
    }
}

/// Write `# text` (or a bare `#` for an empty comment).
fn writeHashLine(w: *Writer, text: []const u8) Writer.Error!void {
    try w.writeByte('#');
    if (text.len != 0) {
        try w.writeByte(' ');
        try w.writeAll(text);
    }
}

fn isBareKey(name: []const u8) bool {
    if (name.len == 0) return false;
    for (name) |c| {
        const ok = (c >= 'A' and c <= 'Z') or (c >= 'a' and c <= 'z') or
            (c >= '0' and c <= '9') or c == '_' or c == '-';
        if (!ok) return false;
    }
    return true;
}

/// Render a header path (`a.b.c`), parent segment first.
fn writePath(w: *Writer, path: *const Path) Error!void {
    if (path.parent) |par| {
        try writePath(w, par);
        try w.writeByte('.');
    }
    if (isBareKey(path.key)) {
        try w.writeAll(path.key);
    } else {
        try writeBasicString(w, path.key);
    }
}

/// Emit a TOML basic string: double-quoted, with `"`, `\`, and control bytes
/// escaped (TOML keeps other Unicode literal).
fn writeBasicString(w: *Writer, value: []const u8) Writer.Error!void {
    try w.writeByte('"');
    for (value) |char| {
        switch (char) {
            '"' => try w.writeAll("\\\""),
            '\\' => try w.writeAll("\\\\"),
            0x08 => try w.writeAll("\\b"),
            '\t' => try w.writeAll("\\t"),
            '\n' => try w.writeAll("\\n"),
            0x0c => try w.writeAll("\\f"),
            '\r' => try w.writeAll("\\r"),
            0x00...0x07, 0x0b, 0x0e...0x1f, 0x7f => try writeUnicodeEscape(w, char),
            else => try w.writeByte(char),
        }
    }
    try w.writeByte('"');
}

fn writeUnicodeEscape(w: *Writer, char: u8) Writer.Error!void {
    const hex = "0123456789ABCDEF";
    try w.writeAll("\\u00");
    try w.writeByte(hex[char >> 4]);
    try w.writeByte(hex[char & 0x0f]);
}

// ── Public entry points ─────────────────────────────────────────────────────

pub fn print(writer: *Writer, ast: *const AST, options: AST.SerializeOptions) Error!void {
    var ctx = Ctx{ .w = writer, .opts = options };
    switch (ast.nodes[ast.root].kind) {
        .mapping => |first| try ctx.body(ast, ast.root, first, null),
        // A non-table root (e.g. converting a JSON array) has no valid TOML
        // document form; emit the inline value as a best-effort fragment.
        else => {
            try writeInline(writer, ast, ast.root);
            try writer.writeByte('\n');
        },
    }
    try writer.flush();
}

/// Print the subtree at `id` as a standalone fragment (used by `get <path>`): a
/// mapping prints as a document body rooted there; any other node prints inline.
pub fn printNode(writer: *Writer, ast: *const AST, id: AST.Node.Id, depth: usize, options: AST.SerializeOptions) Error!void {
    _ = depth;
    var ctx = Ctx{ .w = writer, .opts = options };
    switch (ast.nodes[id].kind) {
        .mapping => |first| try ctx.body(ast, id, first, null),
        else => {
            try writeInline(writer, ast, id);
            try writer.writeByte('\n');
        },
    }
}

// ── Tests ───────────────────────────────────────────────────────────────────

const Parser = @import("parser.zig");

fn expectPrint(input: []const u8, expected: []const u8) !void {
    try expectPrintWith(input, expected, .{});
}

fn expectPrintWith(input: []const u8, expected: []const u8, options: AST.SerializeOptions) !void {
    var ast = try Parser.parseAbstract(std.testing.allocator, input, .TOML_1_1);
    defer ast.deinit();
    var output: Writer.Allocating = .init(std.testing.allocator);
    defer output.deinit();
    try print(&output.writer, &ast, options);
    try std.testing.expectEqualStrings(expected, output.written());
}

/// Round-trip: the printed TOML must reparse without error, and the canonical
/// form must be a fixed point — re-printing the reparse yields identical bytes.
/// (We can't compare ASTs directly: `AST.eql` is structural-identity on node ids,
/// which canonicalization to inline tables intentionally changes even though the
/// data is preserved. Idempotency is the right invariant for a canonical printer.)
fn expectRoundTrip(input: []const u8) !void {
    var ast = try Parser.parseAbstract(std.testing.allocator, input, .TOML_1_1);
    defer ast.deinit();
    var out1: Writer.Allocating = .init(std.testing.allocator);
    defer out1.deinit();
    try print(&out1.writer, &ast, .{});

    var reparsed = try Parser.parseAbstract(std.testing.allocator, out1.written(), .TOML_1_1);
    defer reparsed.deinit();
    var out2: Writer.Allocating = .init(std.testing.allocator);
    defer out2.deinit();
    errdefer std.log.err("printed:\n{s}", .{out1.written()});
    try print(&out2.writer, &reparsed, .{});
    try std.testing.expectEqualStrings(out1.written(), out2.written());
}

test "captures and re-emits comments (leading, trailing, table header)" {
    try expectPrint(
        \\# document header
        \\name = "Tom" # the name
        \\count = 42
        \\
        \\# the server section
        \\[server]
        \\host = "a" # primary host
        \\
    ,
        \\# document header
        \\name = "Tom" # the name
        \\count = 42
        \\
        \\# the server section
        \\[server]
        \\host = "a" # primary host
        \\
    );
}

test "block comment carried in degrades to a # run" {
    const a = std.testing.allocator;
    var b = AST.Builder.init(a);
    defer b.deinit();
    const v = try b.addInt(1);
    const k = try b.addString("x");
    try b.setComments(k, .{ .leading = &.{.{ .text = "line one\nline two", .style = .block }} });
    const root = try b.addMapping(&.{.{ .key = k, .value = v }});
    var ast = try b.finish(root);
    defer ast.deinit();
    var out: Writer.Allocating = .init(a);
    defer out.deinit();
    try print(&out.writer, &ast, .{});
    try std.testing.expectEqualStrings("# line one\n# line two\nx = 1\n", out.written());
}

test "prints root scalars" {
    try expectPrint(
        \\name = "Tom"
        \\count = 42
        \\pi = 3.14
        \\flag = true
        \\
    ,
        \\name = "Tom"
        \\count = 42
        \\pi = 3.14
        \\flag = true
        \\
    );
}

test "canonicalizes numbers" {
    try expectPrint("hex = 0xDEAD_beef\noct = 0o17\nneg = -0\n", "hex = 3735928559\noct = 15\nneg = 0\n");
}

test "datetimes print verbatim" {
    try expectPrint("when = 1979-05-27T07:32:00Z\nd = 1979-05-27\nt = 07:32:00\n", "when = 1979-05-27T07:32:00Z\nd = 1979-05-27\nt = 07:32:00\n");
}

test "a small table collapses to an inline table" {
    try expectPrint(
        \\[server]
        \\host = "a"
        \\port = 80
        \\
    ,
        \\server = { host = "a", port = 80 }
        \\
    );
}

test "a table too wide for the budget stays a [section]" {
    // The same table forced to expand by a tight width budget.
    try expectPrintWith(
        \\[server]
        \\host = "a"
        \\port = 80
        \\
    ,
        \\[server]
        \\host = "a"
        \\port = 80
        \\
    , .{ .width = 20 });
}

test "a table carrying a comment stays a [section]" {
    // Inlining would drop the comment, so the table keeps its header form.
    try expectPrint(
        \\[server]
        \\host = "a" # primary
        \\port = 80
        \\
    ,
        \\[server]
        \\host = "a" # primary
        \\port = 80
        \\
    );
}

test "scalars precede sub-tables and blank-line separates (when expanded)" {
    // A tight budget keeps `[a]` and `[a.b]` from collapsing inline, exercising
    // the scalars-before-subtables ordering and the blank-line separator.
    try expectPrintWith(
        \\[a]
        \\x = 1
        \\[a.b]
        \\y = 2
        \\
    ,
        \\[a]
        \\x = 1
        \\
        \\[a.b]
        \\y = 2
        \\
    , .{ .width = 8 });
}

test "a mid-order sub-table demotes to dotted keys (order preserved)" {
    // `b` (a table) precedes the scalar `c`; a `[a.b]` section would have to
    // move past it — a semantic key-order change — so it demotes to dotted
    // keys in place. The output is its own fixed point.
    try expectPrintWith(
        \\[a]
        \\b.x = 1
        \\b.y = 2
        \\c = 3
        \\
    ,
        \\[a]
        \\b.x = 1
        \\b.y = 2
        \\c = 3
        \\
    , .{ .width = 8 });
}

test "a commented mid-order sub-table keeps its section (comments outrank order)" {
    try expectPrintWith(
        \\[a]
        \\b.x = 1 # note
        \\b.y = 2
        \\c = 3
        \\
    ,
        \\[a]
        \\c = 3
        \\
        \\[a.b]
        \\x = 1 # note
        \\y = 2
        \\
    , .{ .width = 8 });
}

test "a mid-order array-of-tables demotes to an inline array (order preserved)" {
    try expectPrintWith(
        \\[t]
        \\a = [{ x = 1 }, { x = 2 }]
        \\b = "a long enough string value to keep the whole table t from collapsing inline"
        \\
    ,
        \\[t]
        \\a = [{ x = 1 }, { x = 2 }]
        \\b = "a long enough string value to keep the whole table t from collapsing inline"
        \\
    , .{});
}

test "order-preserving demotions round-trip" {
    try expectRoundTrip("[a]\nb.x = 1\nb.y = 2\nc = 3\n");
    try expectRoundTrip("[t]\na = [{ x = 1 }, { x = 2 }]\nb = 3\n");
    try expectRoundTrip("fig.version = \"1.0.0\"\nfig.default-features = false\nserde = \"1.0\"\n");
}

test "supertable header is suppressed when it has only sub-tables" {
    // A tight budget keeps `b` expanded, so `a` has only the sub-table `b` and
    // its own header is implied (suppressed) — printing `[a.b]` directly.
    try expectPrintWith(
        \\[a.b]
        \\y = 2
        \\
    ,
        \\[a.b]
        \\y = 2
        \\
    , .{ .width = 8 });
}

test "empty table still emits its header" {
    try expectPrint("[a]\n", "[a]\n");
}

test "a small inline table stays inline (within the budget)" {
    try expectPrint("point = { x = 1, y = 2 }\n", "point = { x = 1, y = 2 }\n");
}

test "a wide inline table expands to a section" {
    try expectPrintWith(
        "point = { x = 1, y = 2 }\n",
        "[point]\nx = 1\ny = 2\n",
        .{ .width = 10 },
    );
}

test "arrays of scalars print inline" {
    try expectPrint("nums = [1, 2, 3]\nnested = [[1, 2], [\"a\", \"b\"]]\n", "nums = [1, 2, 3]\nnested = [[1, 2], [\"a\", \"b\"]]\n");
}

test "a wide array wraps one element per line with a trailing comma" {
    try expectPrintWith(
        "members = [\"alpha\", \"beta\", \"gamma\"]\n",
        \\members = [
        \\  "alpha",
        \\  "beta",
        \\  "gamma",
        \\]
        \\
    ,
        .{ .width = 20 },
    );
}

test "pretty=false keeps a wide array on one line" {
    try expectPrintWith(
        "members = [\"alpha\", \"beta\", \"gamma\"]\n",
        "members = [\"alpha\", \"beta\", \"gamma\"]\n",
        .{ .width = 20, .pretty = false },
    );
}

test "wrapped-array indent honors the indent option" {
    try expectPrintWith(
        "xs = [1, 2]\n",
        "xs = [\n  1,\n  2,\n]\n",
        .{ .width = 4, .indent = 2 },
    );
}

test "array of tables prints as double-bracket sections" {
    try expectPrint(
        \\[[fruit]]
        \\name = "apple"
        \\
        \\[[fruit]]
        \\name = "pear"
        \\
    ,
        \\[[fruit]]
        \\name = "apple"
        \\
        \\[[fruit]]
        \\name = "pear"
        \\
    );
}

test "quotes non-bare keys" {
    try expectPrint("\"a.b\" = 1\n\"\" = 2\n", "\"a.b\" = 1\n\"\" = 2\n");
}

test "escapes control characters in strings" {
    try expectPrint("s = \"a\\tb\\nc\"\n", "s = \"a\\tb\\nc\"\n");
}

test "null value is unsupported" {
    // Build an AST with a null by hand (TOML never parses one).
    var nodes = [_]AST.Node{
        .{ .id = 0, .kind = .{ .mapping = 1 } },
        .{ .id = 1, .kind = .{ .keyvalue = .{ .key = 2, .value = 3 } } },
        .{ .id = 2, .kind = .{ .string = "k" } },
        .{ .id = 3, .kind = .null_ },
    };
    const ast = AST{ .allocator = std.testing.allocator, .root = 0, .nodes = &nodes };
    var output: Writer.Allocating = .init(std.testing.allocator);
    defer output.deinit();
    try std.testing.expectError(error.NullUnsupported, print(&output.writer, &ast, .{}));
}

// Round-trips across the structural features.
test "round-trips a mixed document" {
    try expectRoundTrip(
        \\title = "demo"
        \\ports = [8000, 8001]
        \\
        \\[owner]
        \\name = "Tom"
        \\dob = 1979-05-27T07:32:00Z
        \\
        \\[servers.alpha]
        \\ip = "10.0.0.1"
        \\
        \\[servers.beta]
        \\ip = "10.0.0.2"
        \\
        \\[[products]]
        \\name = "Hammer"
        \\sku = 738594937
        \\
        \\[[products]]
        \\name = "Nail"
        \\color = "gray"
        \\
    );
}

test "round-trips nested arrays of tables" {
    try expectRoundTrip(
        \\[[a]]
        \\x = 1
        \\
        \\[[a.b]]
        \\y = 2
        \\
        \\[[a.b]]
        \\y = 3
        \\
    );
}

test "round-trips deeply nested empty and dotted tables" {
    try expectRoundTrip(
        \\a.b.c = 1
        \\
        \\[x]
        \\
        \\[x.y.z]
        \\w = "deep"
        \\
    );
}
