//! TOML-specific editing helpers for `Editor(Toml)`.
//!
//! The generic span-splice engine lives in `../editor.zig`; this module holds the
//! TOML-only logic it delegates to — the multi-region gather that lets whole-table
//! ops (delete/insert/rename/move/reorder) work across TOML's scattered headers,
//! plus header-path rendering. Everything here is a pure function of
//! `(Document, source, allocator)` returning regions/bytes, so it has no
//! dependency on the `Editor` struct itself (only the shared source-coordinate
//! utilities, aliased below). See `editor.zig` for the public methods.

const std = @import("std");

const AST = @import("../../ast/ast.zig");
const Document = @import("../../document.zig");
const Span = @import("../../util/span.zig");
const editor = @import("../../editor.zig");
const Toml = @import("toml.zig").Language;
const log = std.log.scoped(.editor);

/// The concrete editor these structural ops drive. All functions below take a
/// `*TomlEditor`: they are the TOML arm of the generic engine, factored out so
/// `editor.zig` stays format-agnostic and every TOML edit lives in one file. The
/// public methods on `editor.Editor(Toml)` are thin wrappers that call these.
const TomlEditor = editor.Editor(Toml);

// Shared source-coordinate / rendering utilities (defined in editor.zig).
const lineStartBefore = editor.lineStartBefore;
const lineEndAfter = editor.lineEndAfter;
const firstNonSpace = editor.firstNonSpace;
const commentBlockStart = editor.commentBlockStart;
const CommentStyle = editor.CommentStyle;
const isFlow = editor.isFlow;
const appendBlockSep = editor.appendBlockSep;

/// Largest source `end` over the subtree rooted at `id` — the textual end of an
/// AoT element including any nested `[header]`/`[[header]]` sub-tables (whose own
/// node spans point at their header key, with their body following). Used to
/// find where a new `[[…]]` element can be spliced without splitting the prior
/// element's contents.
pub fn subtreeMaxEnd(parsed: Document, id: AST.Node.Id) usize {
    var max = parsed.span(parsed.ast.nodes[id]).end;
    switch (parsed.ast.nodes[id].kind) {
        .mapping => |first| {
            var c = first;
            while (c) |cid| : (c = parsed.ast.nodes[cid].next_sibling) max = @max(max, subtreeMaxEnd(parsed, cid));
        },
        .sequence => |first| {
            var c = first;
            while (c) |cid| : (c = parsed.ast.nodes[cid].next_sibling) max = @max(max, subtreeMaxEnd(parsed, cid));
        },
        .keyvalue => |kv| max = @max(max, @max(subtreeMaxEnd(parsed, kv.key), subtreeMaxEnd(parsed, kv.value))),
        else => {},
    }
    return max;
}

// --- TOML whole-table structural editing (multi-region) ---
//
// A logical TOML table is assembled from scattered source: `[a]` x=1 … `[other]`
// y=2 … `[a.b]` z=3. The AST has one mapping node per logical table; its span is
// only its key segment inside the header, and its keyvalue children carry their
// own line spans. So a whole-table op (delete/move/rename) cannot splice a single
// `[min,max)` range — foreign tables may be interleaved. Instead we *gather* the
// disjoint line-regions that belong to the table's subtree and rebuild the source
// once. `replaceAtSpan` reparses per call, so every op does exactly one splice.

/// A line-aligned source range `[start, end)` belonging to a logical table.
pub const Region = struct { start: usize, end: usize };

/// Expand `seg_span` (a header key segment, sitting inside `[...]`/`[[...]]`) to
/// its full physical line(s) plus any owned leading comment block. Returns null
/// when the segment's line does not start with `[` — i.e. the table is a dotted
/// or root table that has no header line of its own.
pub fn headerLineRegion(source: []const u8, seg_span: Span, style: CommentStyle) ?Region {
    const ls = lineStartBefore(source, seg_span.start);
    const fns = firstNonSpace(source, ls);
    if (fns >= source.len or source[fns] != '[') return null;
    return .{
        .start = commentBlockStart(source, ls, style),
        .end = lineEndAfter(source, seg_span.end -| 1),
    };
}

/// Full line-region of an in-table entry (`key = value`, possibly multi-line):
/// its owned comment block through the newline ending its last line.
pub fn entryLineRegion(source: []const u8, kv_span: Span, style: CommentStyle) Region {
    return .{
        .start = commentBlockStart(source, lineStartBefore(source, kv_span.start), style),
        .end = lineEndAfter(source, kv_span.end -| 1),
    };
}

/// Line start of the nearest line at or above `at` whose first non-space byte is
/// `[` (a `[table]` / `[[aot]]` header), or null if none. Used to recover an
/// array-of-tables element's header, whose node span is shared across elements
/// and so cannot be trusted.
fn headerLineAtOrAbove(source: []const u8, at: usize) ?usize {
    var ls = lineStartBefore(source, at);
    while (true) {
        const fns = firstNonSpace(source, ls);
        if (fns < source.len and source[fns] == '[') return ls;
        if (ls == 0) return null;
        ls = lineStartBefore(source, ls - 1);
    }
}

/// Line start of the nearest line at or after `at` whose first non-space byte is
/// `[`, or null. Forward counterpart of `headerLineAtOrAbove`, for locating an
/// *empty* AoT element's header (no child to anchor an upward scan).
fn headerLineAtOrAfter(source: []const u8, at: usize) ?usize {
    var ls = at;
    while (ls < source.len) {
        const fns = firstNonSpace(source, ls);
        if (fns < source.len and source[fns] == '[') return ls;
        ls = lineEndAfter(source, ls);
    }
    return null;
}

/// Append every line-region belonging to the logical table rooted at `node` (a
/// `.mapping`). `include_header` adds the table's own `[header]` line (omitted for
/// the AoT-element case, which recovers its `[[…]]` header by scanning). Children
/// are classified purely by whether their source line starts with `[`: such a
/// line is a sub-table (`.mapping`) or nested AoT (`.sequence`) and is recursed
/// into; any other line is an in-region entry whose whole span is taken verbatim
/// (covering scalars, multi-line arrays/strings, inline tables, and dotted keys).
pub fn gatherTableRegions(parsed: Document, source: []const u8, allocator: std.mem.Allocator, node: AST.Node, include_header: bool, out: *std.ArrayList(Region)) std.mem.Allocator.Error!void {
    if (include_header) {
        if (headerLineRegion(source, parsed.span(node), .hash)) |r| try out.append(allocator, r);
    }
    if (node.kind != .mapping) return;
    var cur = node.kind.mapping;
    while (cur) |id| : (cur = parsed.ast.nodes[id].next_sibling) {
        const kv = parsed.ast.nodes[id];
        const kv_span = parsed.span(kv);
        const fns = firstNonSpace(source, lineStartBefore(source, kv_span.start));
        const is_header = fns < source.len and source[fns] == '[';
        if (!is_header) {
            try out.append(allocator, entryLineRegion(source, kv_span, .hash));
            continue;
        }
        // Sub-table header line: recurse into the keyvalue's value node.
        const val = parsed.ast.nodes[kv.kind.keyvalue.value];
        switch (val.kind) {
            .mapping => try gatherTableRegions(parsed, source, allocator, val, true, out),
            .sequence => try gatherAotRegions(parsed, source, allocator, val, out),
            else => try out.append(allocator, entryLineRegion(source, kv_span, .hash)),
        }
    }
}

/// Append every region of an array-of-tables `node` (a `.sequence` of element
/// mappings): each element's `[[…]]` header plus its body. Element mappings share
/// one node span, so each header is recovered by scanning from the element's
/// content (or, for an empty element, forward from the previous element's end).
pub fn gatherAotRegions(parsed: Document, source: []const u8, allocator: std.mem.Allocator, node: AST.Node, out: *std.ArrayList(Region)) std.mem.Allocator.Error!void {
    var search_from: usize = 0;
    var elem = node.kind.sequence;
    while (elem) |eid| : (elem = parsed.ast.nodes[eid].next_sibling) {
        const em = parsed.ast.nodes[eid];
        try gatherElementRegions(parsed, source, allocator, em, search_from, out);
        search_from = lineEndAfter(source, subtreeMaxEnd(parsed, eid) -| 1);
    }
}

/// Append one AoT element's regions: its `[[…]]` header (recovered by scan) and
/// its body. `search_from` is the end of the previous element (start for the
/// first), used to find an empty element's header.
pub fn gatherElementRegions(parsed: Document, source: []const u8, allocator: std.mem.Allocator, elem: AST.Node, search_from: usize, out: *std.ArrayList(Region)) std.mem.Allocator.Error!void {
    const first = if (elem.kind == .mapping) elem.kind.mapping else null;
    const header_ls: ?usize = if (first) |fc|
        headerLineAtOrAbove(source, lineStartBefore(source, parsed.span(parsed.ast.nodes[fc]).start) -| 1)
    else
        headerLineAtOrAfter(source, search_from);
    if (header_ls) |ls| try out.append(allocator, .{
        .start = commentBlockStart(source, ls, .hash),
        .end = lineEndAfter(source, ls),
    });
    // Body: same child classification as a regular table.
    var cur = if (elem.kind == .mapping) elem.kind.mapping else null;
    while (cur) |id| : (cur = parsed.ast.nodes[id].next_sibling) {
        const kv = parsed.ast.nodes[id];
        const kv_span = parsed.span(kv);
        const fns = firstNonSpace(source, lineStartBefore(source, kv_span.start));
        const is_header = fns < source.len and source[fns] == '[';
        if (!is_header) {
            try out.append(allocator, entryLineRegion(source, kv_span, .hash));
            continue;
        }
        const val = parsed.ast.nodes[kv.kind.keyvalue.value];
        switch (val.kind) {
            .mapping => try gatherTableRegions(parsed, source, allocator, val, true, out),
            .sequence => try gatherAotRegions(parsed, source, allocator, val, out),
            else => try out.append(allocator, entryLineRegion(source, kv_span, .hash)),
        }
    }
}

/// Span of the dotted-key segment at `depth` (0-based) within the header line of
/// `region` (`[a.b.c]` or `[[a.b.c]]`), or null when the region has no header
/// line or fewer than `depth+1` segments. The span covers the raw key token
/// (including any surrounding quotes), so splicing a rendered replacement over it
/// rewrites just that one path segment.
pub fn headerSegmentSpan(source: []const u8, region: Region, depth: usize) ?Span {
    // Locate the `[`-line inside the region (comments may precede it).
    var ls = region.start;
    const line_start = while (ls < region.end) : (ls = lineEndAfter(source, ls)) {
        const fns = firstNonSpace(source, ls);
        if (fns < source.len and source[fns] == '[') break fns;
    } else return null;

    var p = line_start;
    while (p < source.len and source[p] == '[') p += 1; // skip `[` or `[[`
    var seg: usize = 0;
    while (p < source.len and source[p] != ']') {
        while (p < source.len and (source[p] == ' ' or source[p] == '\t')) p += 1;
        if (p >= source.len or source[p] == ']') break;
        const start = p;
        switch (source[p]) {
            '"' => {
                p += 1;
                while (p < source.len and source[p] != '"') : (p += 1) {
                    if (source[p] == '\\') p += 1;
                }
                if (p < source.len) p += 1; // closing quote
            },
            '\'' => {
                p += 1;
                while (p < source.len and source[p] != '\'') p += 1;
                if (p < source.len) p += 1; // closing quote
            },
            else => while (p < source.len and isTomlBareKey(source[p .. p + 1])) : (p += 1) {},
        }
        const end = p;
        if (seg == depth) return Span.init(start, end);
        seg += 1;
        while (p < source.len and (source[p] == ' ' or source[p] == '\t')) p += 1;
        if (p < source.len and source[p] == '.') p += 1; // segment separator
    }
    return null;
}

/// Sort regions by start and coalesce overlapping ones into a disjoint, ascending
/// set (in place); returns the coalesced count.
pub fn normalizeRegions(regions: []Region) usize {
    std.mem.sort(Region, regions, {}, struct {
        fn lt(_: void, a: Region, b: Region) bool {
            return a.start < b.start;
        }
    }.lt);
    if (regions.len == 0) return 0;
    var w: usize = 0;
    for (regions[1..]) |r| {
        // Merge only on real overlap, not mere touching — rename needs each header
        // line as its own region, while delete/move are unaffected (a zero-width
        // gap between touching regions copies nothing).
        if (r.start < regions[w].end) {
            regions[w].end = @max(regions[w].end, r.end);
        } else {
            w += 1;
            regions[w] = r;
        }
    }
    return w + 1;
}

/// Append `block` to `out` separated from any preceding content by exactly one
/// blank line (two newlines). `block` is appended verbatim (it already ends in a
/// newline). Used to splice a relocated TOML table so it reads as its own section.
pub fn appendWithBlankBefore(out: *std.ArrayList(u8), allocator: std.mem.Allocator, block: []const u8) !void {
    if (block.len == 0) return;
    const n = out.items.len;
    if (n > 0) {
        if (n >= 2 and out.items[n - 1] == '\n' and out.items[n - 2] == '\n') {
            // already a blank line
        } else if (out.items[n - 1] == '\n') {
            try out.append(allocator, '\n');
        } else {
            try out.appendSlice(allocator, "\n\n");
        }
    }
    try out.appendSlice(allocator, block);
}

/// Render a TOML header path (`a.b.c`) from a PathSegment list into `out`. Index
/// segments are skipped — `[[a.b]]` always targets `a`'s last element, so the
/// index is implied. Each key prints bare when it is all `[A-Za-z0-9_-]`, else as
/// a basic-quoted string.
pub fn appendTomlHeaderPath(out: *std.ArrayList(u8), allocator: std.mem.Allocator, path: []const AST.PathSegment) !void {
    var first = true;
    for (path) |seg| switch (seg) {
        .index => {},
        .key => |k| {
            if (!first) try out.append(allocator, '.');
            first = false;
            if (isTomlBareKey(k)) {
                try out.appendSlice(allocator, k);
            } else {
                try out.append(allocator, '"');
                for (k) |ch| switch (ch) {
                    '"' => try out.appendSlice(allocator, "\\\""),
                    '\\' => try out.appendSlice(allocator, "\\\\"),
                    else => try out.append(allocator, ch),
                };
                try out.append(allocator, '"');
            }
        },
    };
}

pub fn isTomlBareKey(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;
}

// ============================================================================
// STRUCTURAL EDITING — the TOML arm of `editor.Editor`
// ============================================================================
//
// These drive the editor's splice engine (`self.replaceAtSpan`, which reparses
// and rolls back on failure) using the region helpers above. `editor.zig` keeps
// only thin public wrappers (`Editor(Toml).deleteTable` etc.) that forward here,
// plus the comptime `if (Language == Toml)` branches that call into these for
// the shared ops (e.g. `insertKey`).

// --- TOML structural inserts ---
//
// TOML splits a logical table across `[header]`…dotted-key…lines, so an insert
// must land where the new entry attaches to the *intended* table. A scalar
// `key = value` is placed at the end of the table's own header region — after
// its last direct (non-`[header]`) entry, before any sub-table header opens —
// never after a sub-table, which would silently reparent it. `key_text`/
// `value_text` are verbatim TOML literals.
pub fn tomlInsertKey(self: *TomlEditor, parsed: Document, node: AST.Node, span: Span, is_root: bool, key_text: []const u8, value_text: []const u8) !void {
    if (node.kind != .mapping) return error.NotAMapping;
    const source = self.source.items;
    // Inline table `{ … }`: splice a `key = value` inside the braces.
    if (isFlow(source, span))
        return tomlInsertFlowEntry(self, parsed, node, span, key_text, value_text);

    // Block table: scan its direct children for the in-region ones (those whose
    // line does not start with `[` — i.e. scalars, arrays, inline tables, and
    // dotted sub-tables, all of which live under this table's header). The last
    // such child's line is where the new entry goes; its column sets the indent.
    var last_end: ?usize = null;
    var col: usize = 0;
    var col_set = false;
    var cur = node.kind.mapping;
    while (cur) |id| : (cur = parsed.ast.nodes[id].next_sibling) {
        const kv_span = parsed.span(parsed.ast.nodes[id]);
        const ls = lineStartBefore(source, kv_span.start);
        const fns = firstNonSpace(source, ls);
        if (fns < source.len and source[fns] == '[') continue; // sub-table header: out of region
        last_end = kv_span.end;
        if (!col_set) {
            col = fns - ls;
            col_set = true;
        }
    }

    const insert_at = if (last_end) |e|
        lineEndAfter(source, e -| 1)
    else if (is_root)
        0 // empty document: top of file
    else
        lineEndAfter(source, span.end -| 1); // header-only table: just past its `[header]` line

    var out: std.ArrayList(u8) = .empty;
    defer out.deinit(self.allocator);
    if (insert_at > 0 and source[insert_at - 1] != '\n') try out.append(self.allocator, '\n');
    try out.appendNTimes(self.allocator, ' ', col);
    try out.appendSlice(self.allocator, key_text);
    try out.appendSlice(self.allocator, " = ");
    try out.appendSlice(self.allocator, value_text);
    try out.append(self.allocator, '\n');
    try self.replaceAtSpan(Span.init(insert_at, insert_at), out.items);
}

/// Splice `key = value` into an inline table, keeping the conventional
/// `{ a = 1, b = 2 }` spacing: into a non-empty table the entry is inserted
/// right after the last entry's value (`…1` → `…1, key = value`); into an
/// empty `{}` it is padded with surrounding spaces (`{ key = value }`).
pub fn tomlInsertFlowEntry(self: *TomlEditor, parsed: Document, node: AST.Node, span: Span, key_text: []const u8, value_text: []const u8) !void {
    var out: std.ArrayList(u8) = .empty;
    defer out.deinit(self.allocator);
    const at = if (node.kind.mapping) |first| blk: {
        var last = first;
        while (parsed.ast.nodes[last].next_sibling) |n| last = n;
        try out.appendSlice(self.allocator, ", ");
        break :blk parsed.span(parsed.ast.nodes[last]).end;
    } else blk: {
        try out.append(self.allocator, ' ');
        break :blk span.start + 1; // just after '{'
    };
    try out.appendSlice(self.allocator, key_text);
    try out.appendSlice(self.allocator, " = ");
    try out.appendSlice(self.allocator, value_text);
    if (node.kind.mapping == null) try out.append(self.allocator, ' ');
    try self.replaceAtSpan(Span.init(at, at), out.items);
}

/// Append a new `[[header]]` element to the array-of-tables at `path`, with
/// `body_text` (verbatim TOML `key = value` lines, possibly empty) as its
/// contents. The element is spliced after the AoT's current last element — past
/// every line of that element's subtree, so a nested sub-table inside it is not
/// split.
pub fn appendTableToArray(self: *TomlEditor, path: []const AST.PathSegment, body_text: []const u8) !void {
    const parsed = try self.getParsed();
    const node = try parsed.ast.getValByPath(path);
    if (node.kind != .sequence) return error.NotAnArrayOfTables;
    var elem = node.kind.sequence orelse return error.NotAnArrayOfTables;
    var last_elem = elem;
    while (true) {
        if (parsed.ast.nodes[elem].kind != .mapping) return error.NotAnArrayOfTables;
        last_elem = elem;
        elem = parsed.ast.nodes[elem].next_sibling orelse break;
    }
    const source = self.source.items;
    const end = subtreeMaxEnd(parsed, last_elem);
    const insert_at = lineEndAfter(source, end -| 1);

    var out: std.ArrayList(u8) = .empty;
    defer out.deinit(self.allocator);
    if (insert_at > 0 and source[insert_at - 1] != '\n') try out.append(self.allocator, '\n');
    try out.append(self.allocator, '\n'); // blank line before the new header
    try out.appendSlice(self.allocator, "[[");
    try appendTomlHeaderPath(&out, self.allocator, path);
    try out.appendSlice(self.allocator, "]]\n");
    if (body_text.len > 0) {
        try out.appendSlice(self.allocator, body_text);
        if (body_text[body_text.len - 1] != '\n') try out.append(self.allocator, '\n');
    }
    try self.replaceAtSpan(Span.init(insert_at, insert_at), out.items);
}

// --- TOML whole-table structural editing ---
//
// A logical TOML table spans scattered source lines, so these ops gather the
// table's disjoint regions (see `gatherTableRegions`) and rebuild the source in
// a *single* splice. Foreign tables interleaved between the gathered regions are
// left in place. Library-level (not CLI/C-ABI wired), matching the rest of TOML
// editing.

/// Rebuild the source with `regions` (disjoint, ascending) removed, in one
/// `replaceAtSpan` so the reparse/rollback runs once. `regions` is the slice
/// actually used (caller passes the coalesced prefix).
pub fn spliceOutRegions(self: *TomlEditor, regions: []const Region) !void {
    const source = self.source.items;
    var out: std.ArrayList(u8) = .empty;
    defer out.deinit(self.allocator);
    var pos: usize = 0;
    for (regions) |r| {
        try out.appendSlice(self.allocator, source[pos..r.start]);
        pos = r.end;
    }
    try out.appendSlice(self.allocator, source[pos..]);
    try self.replaceAtSpan(Span.init(0, source.len), out.items);
}

/// Delete the whole table, array-of-tables, or single AoT element named by
/// `path` — including every scattered region of its subtree — leaving any
/// interleaved foreign tables untouched. A path ending in an index targets one
/// AoT element; otherwise the path's value must be a `[table]` (`.mapping`) or a
/// `[[aot]]` array (`.sequence`). A scalar key is refused with `error.NotATable`
/// (use `deleteKey`).
pub fn deleteTable(self: *TomlEditor, path: []const AST.PathSegment) !void {
    if (path.len == 0) return error.NotATable;
    const parsed = try self.getParsed();
    const node = try parsed.ast.getValByPath(path);
    const source = self.source.items;

    var regions: std.ArrayList(Region) = .empty;
    defer regions.deinit(self.allocator);

    switch (node.kind) {
        .mapping => {
            if (path[path.len - 1] == .index) {
                // A single AoT element: span is shared across elements, so
                // recover its header by scanning. Search anchor = end of the
                // preceding element (or 0 for the first).
                const search_from = try aotElementSearchFrom(self, parsed, path);
                try gatherElementRegions(parsed, source, self.allocator, node, search_from, &regions);
            } else {
                try gatherTableRegions(parsed, source, self.allocator, node, true, &regions);
            }
        },
        .sequence => try gatherAotRegions(parsed, source, self.allocator, node, &regions),
        else => return error.NotATable,
    }
    const n = normalizeRegions(regions.items);
    try spliceOutRegions(self, regions.items[0..n]);
}

/// Search anchor for the AoT element at `path` (which ends in an index): the
/// source end of the previous element, or 0 when it is the first. Lets
/// `gatherElementRegions` locate an empty element's header.
pub fn aotElementSearchFrom(self: *TomlEditor, parsed: Document, path: []const AST.PathSegment) !usize {
    const idx = path[path.len - 1].index;
    if (idx == 0) return 0;
    const seq = try parsed.ast.getValByPath(path[0 .. path.len - 1]);
    if (seq.kind != .sequence) return 0;
    var prev = seq.kind.sequence;
    var i: usize = 0;
    while (prev) |pid| : (prev = parsed.ast.nodes[pid].next_sibling) {
        if (i + 1 == idx) return lineEndAfter(self.source.items, subtreeMaxEnd(parsed, pid) -| 1);
        i += 1;
    }
    return 0;
}

/// Create a new `[path]` table (or sub-table) whose body is `body_text`
/// (verbatim TOML `key = value` lines, possibly empty). The header is spliced
/// *after* the parent table's entire subtree — or at end-of-file for a
/// root-level table — so no existing key is reparented. Refuses
/// `error.TableExists` if the table already exists.
pub fn insertTable(self: *TomlEditor, path: []const AST.PathSegment, body_text: []const u8) !void {
    if (path.len == 0) return error.NotATable;
    const parsed = try self.getParsed();
    if (parsed.ast.getValByPath(path)) |_| {
        return error.TableExists;
    } else |_| {}
    const source = self.source.items;

    // Insertion point: just past the parent table's whole subtree, else EOF.
    const insert_at = blk: {
        if (path.len > 1) {
            if (parsed.ast.getValByPath(path[0 .. path.len - 1])) |parent| {
                if (parent.kind == .mapping)
                    break :blk lineEndAfter(source, subtreeMaxEnd(parsed, parent.id) -| 1);
            } else |_| {}
        }
        break :blk source.len;
    };

    var out: std.ArrayList(u8) = .empty;
    defer out.deinit(self.allocator);
    if (insert_at > 0 and source[insert_at - 1] != '\n') try out.append(self.allocator, '\n');
    if (insert_at > 0) try out.append(self.allocator, '\n'); // blank line before the header
    try out.appendSlice(self.allocator, "[");
    try appendTomlHeaderPath(&out, self.allocator, path);
    try out.appendSlice(self.allocator, "]\n");
    if (body_text.len > 0) {
        try out.appendSlice(self.allocator, body_text);
        if (body_text[body_text.len - 1] != '\n') try out.append(self.allocator, '\n');
    }
    try self.replaceAtSpan(Span.init(insert_at, insert_at), out.items);
}

/// Rename the leaf key of the table at `path` to `new_leaf`, rewriting the
/// table's own header and every descendant sub-header that shares the prefix
/// (`[a.b]`, `[a.b.c]`, `[[a.b]]` → `[q.b]`, `[q.b.c]`, `[[q.b]]` when renaming
/// `a`→`q`). Format-preserving: only the renamed segment of each affected header
/// changes. A collision with an existing sibling is rejected via the reparse
/// rollback (`error.DuplicateKey`).
pub fn renameTable(self: *TomlEditor, path: []const AST.PathSegment, new_leaf: []const u8) !void {
    if (path.len == 0) return error.NotATable;
    const parsed = try self.getParsed();
    const node = try parsed.ast.getValByPath(path);
    if (node.kind != .mapping and node.kind != .sequence) return error.NotATable;
    const source = self.source.items;

    // Depth of the renamed segment within each header (count of key segments
    // before the leaf; AoT indices don't appear in headers).
    var depth: usize = 0;
    for (path[0 .. path.len - 1]) |seg| switch (seg) {
        .key => depth += 1,
        .index => {},
    };

    // Gather every header line belonging to this table's subtree, then rewrite
    // the segment at `depth` in each. Rebuild once.
    var regions: std.ArrayList(Region) = .empty;
    defer regions.deinit(self.allocator);
    switch (node.kind) {
        .mapping => try gatherTableRegions(parsed, source, self.allocator, node, true, &regions),
        .sequence => try gatherAotRegions(parsed, source, self.allocator, node, &regions),
        else => unreachable,
    }
    const n = normalizeRegions(regions.items);

    var rendered: std.ArrayList(u8) = .empty;
    defer rendered.deinit(self.allocator);
    try appendTomlHeaderPath(&rendered, self.allocator, &.{.{ .key = new_leaf }});

    var out: std.ArrayList(u8) = .empty;
    defer out.deinit(self.allocator);
    var pos: usize = 0;
    for (regions.items[0..n]) |r| {
        // Only header regions carry the renamed segment; a region may begin with
        // an owned comment block, so locate the `[`-line within it.
        const seg = headerSegmentSpan(source, r, depth) orelse continue;
        try out.appendSlice(self.allocator, source[pos..seg.start]);
        try out.appendSlice(self.allocator, rendered.items);
        pos = seg.end;
    }
    try out.appendSlice(self.allocator, source[pos..]);
    try self.replaceAtSpan(Span.init(0, source.len), out.items);
}

/// Move the whole table at `src_path` to sit immediately before the table at
/// `dest_path` (a top-level/header table), or to end-of-file when `dest_path` is
/// null. The table's scattered fragments are removed from their original
/// positions and re-emitted **contiguously** at the destination (comments ride
/// along); foreign tables stay put. A no-op when the destination falls inside
/// the source's own region.
pub fn moveTable(self: *TomlEditor, src_path: []const AST.PathSegment, dest_path: ?[]const AST.PathSegment) !void {
    if (src_path.len == 0) return error.NotATable;
    const parsed = try self.getParsed();
    const node = try parsed.ast.getValByPath(src_path);
    if (node.kind != .mapping and node.kind != .sequence) return error.NotATable;
    const source = self.source.items;

    var regions: std.ArrayList(Region) = .empty;
    defer regions.deinit(self.allocator);
    switch (node.kind) {
        .mapping => try gatherTableRegions(parsed, source, self.allocator, node, true, &regions),
        .sequence => try gatherAotRegions(parsed, source, self.allocator, node, &regions),
        else => unreachable,
    }
    const n = normalizeRegions(regions.items);
    const used = regions.items[0..n];
    if (n == 0) return;

    // Destination: start of the dest table's header line, or EOF.
    const dest_at = blk: {
        if (dest_path) |dp| {
            const dn = try parsed.ast.getValByPath(dp);
            const hr = headerLineRegion(source, parsed.span(dn), .hash) orelse return error.NotATable;
            break :blk hr.start;
        }
        break :blk source.len;
    };
    // No-op only if the destination lands strictly inside a moved region (a
    // boundary at a fragment's edge — e.g. EOF coinciding with the last fragment
    // — is still a real relocation that collapses the fragments).
    for (used) |r| if (dest_at > r.start and dest_at < r.end) return;

    // Capture the table's bytes (document order, gaps dropped) — this is the
    // contiguous re-emission spliced at the destination.
    var moved: std.ArrayList(u8) = .empty;
    defer moved.deinit(self.allocator);
    for (used) |r| try moved.appendSlice(self.allocator, source[r.start..r.end]);

    // Build the source with the table removed, tracking where `dest_at` lands in
    // the result; then splice `moved` there with one blank line of separation
    // from preceding content.
    var kept: std.ArrayList(u8) = .empty;
    defer kept.deinit(self.allocator);
    var insert_pos: ?usize = null;
    var pos: usize = 0;
    for (used) |r| {
        if (insert_pos == null and dest_at >= pos and dest_at <= r.start) {
            try kept.appendSlice(self.allocator, source[pos..dest_at]);
            insert_pos = kept.items.len;
            try kept.appendSlice(self.allocator, source[dest_at..r.start]);
        } else {
            try kept.appendSlice(self.allocator, source[pos..r.start]);
        }
        pos = r.end;
    }
    if (insert_pos == null) {
        try kept.appendSlice(self.allocator, source[pos..dest_at]);
        insert_pos = kept.items.len;
        try kept.appendSlice(self.allocator, source[dest_at..]);
    } else {
        try kept.appendSlice(self.allocator, source[pos..]);
    }

    const ip = insert_pos.?;
    var out: std.ArrayList(u8) = .empty;
    defer out.deinit(self.allocator);
    try out.appendSlice(self.allocator, kept.items[0..ip]);
    try appendWithBlankBefore(&out, self.allocator, moved.items);
    try out.appendSlice(self.allocator, kept.items[ip..]);
    try self.replaceAtSpan(Span.init(0, source.len), out.items);
}

/// Reorder a set of top-level tables (named by `order`, the keys in their
/// desired final order) among themselves. Each named table's scattered fragments
/// are removed and re-emitted contiguously, in `order`, at the position the
/// earliest of them currently occupies. Tables not named are untouched. Each
/// name must resolve to a `[table]` or `[[aot]]`.
pub fn reorderTables(self: *TomlEditor, order: []const []const u8) !void {
    if (order.len == 0) return;
    const parsed = try self.getParsed();
    const source = self.source.items;

    // Per-table region bundles, plus the global removal set.
    var all: std.ArrayList(Region) = .empty;
    defer all.deinit(self.allocator);
    // Captured bytes for each named table, in `order`.
    var bundles: std.ArrayList([]u8) = .empty;
    defer {
        for (bundles.items) |b| self.allocator.free(b);
        bundles.deinit(self.allocator);
    }

    for (order) |name| {
        const path: [1]AST.PathSegment = .{.{ .key = name }};
        const node = try parsed.ast.getValByPath(&path);
        if (node.kind != .mapping and node.kind != .sequence) return error.NotATable;
        var regions: std.ArrayList(Region) = .empty;
        defer regions.deinit(self.allocator);
        switch (node.kind) {
            .mapping => try gatherTableRegions(parsed, source, self.allocator, node, true, &regions),
            .sequence => try gatherAotRegions(parsed, source, self.allocator, node, &regions),
            else => unreachable,
        }
        const n = normalizeRegions(regions.items);
        var bytes: std.ArrayList(u8) = .empty;
        for (regions.items[0..n]) |r| {
            try bytes.appendSlice(self.allocator, source[r.start..r.end]);
            try all.append(self.allocator, r);
        }
        try bundles.append(self.allocator, try bytes.toOwnedSlice(self.allocator));
    }
    const total = normalizeRegions(all.items);
    const used = all.items[0..total];
    if (total == 0) return;
    const anchor = used[0].start;

    var out: std.ArrayList(u8) = .empty;
    defer out.deinit(self.allocator);
    var pos: usize = 0;
    for (used) |r| {
        if (anchor >= pos and anchor <= r.start) {
            try out.appendSlice(self.allocator, source[pos..anchor]);
            for (bundles.items) |b| {
                try appendBlockSep(&out, self.allocator, b);
                if (b.len > 0 and b[b.len - 1] != '\n') try out.append(self.allocator, '\n');
            }
            try out.appendSlice(self.allocator, source[anchor..r.start]);
        } else {
            try out.appendSlice(self.allocator, source[pos..r.start]);
        }
        pos = r.end;
    }
    try out.appendSlice(self.allocator, source[pos..]);
    try self.replaceAtSpan(Span.init(0, source.len), out.items);
}

// =======
// TESTS
// =======
//
// TOML editor tests live here (rather than in editor.zig) so each language's
// editing tests sit next to that language's helpers. They exercise the public
// `Editor(Toml)` surface end-to-end: point edits (value/key replacement on the
// contiguous spans every node keeps even in a scattered table), scalar/inline
// insert+delete, and the whole-table structural ops (delete/insert/rename/move/
// reorder) built on the multi-region gather above.

fn newTomlEditor(input: []const u8) !editor.Editor(Toml) {
    var ed: editor.Editor(Toml) = .{ .allocator = std.testing.allocator };
    try ed.init(input);
    return ed;
}

fn expectTomlSource(ed: *const editor.Editor(Toml), expected: []const u8) !void {
    errdefer log.err("actual:   \"{s}\"", .{ed.source.items});
    errdefer log.err("expected: \"{s}\"", .{expected});
    try std.testing.expectEqualStrings(expected, ed.source.items);
}

test "toml replace root scalar value" {
    var ed = try newTomlEditor("title = \"old\"\nport = 8080\n");
    defer ed.deinit();
    try ed.replaceValAtPath(&.{.{ .key = "port" }}, "9090");
    try expectTomlSource(&ed, "title = \"old\"\nport = 9090\n");
}

test "toml replace string value keeps quoting verbatim" {
    var ed = try newTomlEditor("title = \"old\"\n");
    defer ed.deinit();
    try ed.replaceValAtPath(&.{.{ .key = "title" }}, "\"new title\"");
    try expectTomlSource(&ed, "title = \"new title\"\n");
}

test "toml replace value in a table" {
    var ed = try newTomlEditor("[server]\nhost = \"a\"\nport = 1\n");
    defer ed.deinit();
    try ed.replaceValAtPath(&.{ .{ .key = "server" }, .{ .key = "port" } }, "2");
    try expectTomlSource(&ed, "[server]\nhost = \"a\"\nport = 2\n");
}

test "toml replace value through scattered table headers" {
    var ed = try newTomlEditor("[a]\nx = 1\n[a.b]\ny = 2\n[a.c]\nz = 3\n");
    defer ed.deinit();
    // The owning table `a` spans the whole file (it nests b and c), but the
    // value node's span is contiguous, so the point edit is exact.
    try ed.replaceValAtPath(&.{ .{ .key = "a" }, .{ .key = "b" }, .{ .key = "y" } }, "99");
    try expectTomlSource(&ed, "[a]\nx = 1\n[a.b]\ny = 99\n[a.c]\nz = 3\n");
}

test "toml replace dotted-key value" {
    var ed = try newTomlEditor("a.b.c = 1\n");
    defer ed.deinit();
    try ed.replaceValAtPath(&.{ .{ .key = "a" }, .{ .key = "b" }, .{ .key = "c" } }, "2");
    try expectTomlSource(&ed, "a.b.c = 2\n");
}

test "toml replace value with an inline array" {
    var ed = try newTomlEditor("ports = [1, 2]\n");
    defer ed.deinit();
    try ed.replaceValAtPath(&.{.{ .key = "ports" }}, "[3, 4, 5]");
    try expectTomlSource(&ed, "ports = [3, 4, 5]\n");
}

test "toml rename a leaf key" {
    var ed = try newTomlEditor("[server]\nport = 8080\n");
    defer ed.deinit();
    try ed.replaceKeyAtPath(&.{ .{ .key = "server" }, .{ .key = "port" } }, "listen_port");
    try expectTomlSource(&ed, "[server]\nlisten_port = 8080\n");
}

test "toml failed edit rolls back and keeps editor usable" {
    var ed = try newTomlEditor("a = 1\nb = 2\n");
    defer ed.deinit();
    // An unterminated array fails to reparse; the source must be restored.
    if (ed.replaceValAtPath(&.{.{ .key = "a" }}, "[oops")) |_| {
        return error.TestExpectedFailedEdit;
    } else |_| {}
    try expectTomlSource(&ed, "a = 1\nb = 2\n");
    try ed.replaceValAtPath(&.{.{ .key = "a" }}, "9");
    try expectTomlSource(&ed, "a = 9\nb = 2\n");
}

// --- TOML structural editing (insert/delete scalar keys, inline arrays, AoT append) ---
//
// Format-preserving via spans; the genuinely scattered cases (whole-table
// delete/move, non-contiguous tables) refuse with a clear error.

test "toml insert key into root" {
    var ed = try newTomlEditor("a = 1\nb = 2\n");
    defer ed.deinit();
    try ed.insertKey(&.{}, "c", "3");
    try expectTomlSource(&ed, "a = 1\nb = 2\nc = 3\n");
}

test "toml insert key into empty document" {
    var ed = try newTomlEditor("");
    defer ed.deinit();
    try ed.insertKey(&.{}, "a", "1");
    try expectTomlSource(&ed, "a = 1\n");
}

test "toml insert root key goes above the first header" {
    // The new root key must land in root's own region — before `[t]` opens —
    // not after the table (which would reparent it into `[t]`).
    var ed = try newTomlEditor("x = 1\n[t]\ny = 2\n");
    defer ed.deinit();
    try ed.insertKey(&.{}, "z", "3");
    try expectTomlSource(&ed, "x = 1\nz = 3\n[t]\ny = 2\n");
}

test "toml insert key into a table" {
    var ed = try newTomlEditor("[server]\nhost = \"a\"\nport = 1\n");
    defer ed.deinit();
    try ed.insertKey(&.{.{ .key = "server" }}, "tls", "true");
    try expectTomlSource(&ed, "[server]\nhost = \"a\"\nport = 1\ntls = true\n");
}

test "toml insert into a table that has a sub-table inserts before the sub-header" {
    var ed = try newTomlEditor("[a]\nx = 1\n[a.b]\ny = 2\n");
    defer ed.deinit();
    try ed.insertKey(&.{.{ .key = "a" }}, "w", "9");
    try expectTomlSource(&ed, "[a]\nx = 1\nw = 9\n[a.b]\ny = 2\n");
}

test "toml insert into a header-only table" {
    var ed = try newTomlEditor("[a]\n[a.b]\ny = 2\n");
    defer ed.deinit();
    try ed.insertKey(&.{.{ .key = "a" }}, "x", "1");
    try expectTomlSource(&ed, "[a]\nx = 1\n[a.b]\ny = 2\n");
}

test "toml insert preserves the column of existing entries" {
    var ed = try newTomlEditor("[a]\n  x = 1\n");
    defer ed.deinit();
    try ed.insertKey(&.{.{ .key = "a" }}, "y", "2");
    try expectTomlSource(&ed, "[a]\n  x = 1\n  y = 2\n");
}

test "toml insert into an inline table" {
    var ed = try newTomlEditor("p = { x = 1 }\n");
    defer ed.deinit();
    try ed.insertKey(&.{.{ .key = "p" }}, "y", "2");
    try expectTomlSource(&ed, "p = { x = 1, y = 2 }\n");
}

test "toml insert into an empty inline table" {
    var ed = try newTomlEditor("p = {}\n");
    defer ed.deinit();
    try ed.insertKey(&.{.{ .key = "p" }}, "x", "1");
    try expectTomlSource(&ed, "p = { x = 1 }\n");
}

test "toml insert duplicate key rolls back" {
    var ed = try newTomlEditor("a = 1\n");
    defer ed.deinit();
    try std.testing.expectError(error.DuplicateKey, ed.insertKey(&.{}, "a", "2"));
    try expectTomlSource(&ed, "a = 1\n");
}

test "toml delete scalar key" {
    var ed = try newTomlEditor("a = 1\nb = 2\nc = 3\n");
    defer ed.deinit();
    try ed.deleteKey(&.{.{ .key = "b" }});
    try expectTomlSource(&ed, "a = 1\nc = 3\n");
}

test "toml delete key with owned comment" {
    var ed = try newTomlEditor("a = 1\n# note\nb = 2\n");
    defer ed.deinit();
    try ed.deleteKey(&.{.{ .key = "b" }});
    try expectTomlSource(&ed, "a = 1\n");
}

test "toml delete key inside a table" {
    var ed = try newTomlEditor("[t]\nx = 1\ny = 2\n");
    defer ed.deinit();
    try ed.deleteKey(&.{ .{ .key = "t" }, .{ .key = "x" } });
    try expectTomlSource(&ed, "[t]\ny = 2\n");
}

test "toml delete an inline-table-valued key" {
    var ed = try newTomlEditor("a = 1\np = { x = 1, y = 2 }\nb = 2\n");
    defer ed.deinit();
    try ed.deleteKey(&.{.{ .key = "p" }});
    try expectTomlSource(&ed, "a = 1\nb = 2\n");
}

test "toml delete dotted key removes the line" {
    var ed = try newTomlEditor("a.b.c = 1\na.b.d = 2\n");
    defer ed.deinit();
    try ed.deleteKey(&.{ .{ .key = "a" }, .{ .key = "b" }, .{ .key = "c" } });
    try expectTomlSource(&ed, "a.b.d = 2\n");
}

test "toml deleting a header table is refused" {
    var ed = try newTomlEditor("[a]\nx = 1\n[a.b]\ny = 2\n");
    defer ed.deinit();
    try std.testing.expectError(error.CannotDeleteTable, ed.deleteKey(&.{ .{ .key = "a" }, .{ .key = "b" } }));
    try expectTomlSource(&ed, "[a]\nx = 1\n[a.b]\ny = 2\n");
}

test "toml deleting an array-of-tables is refused" {
    var ed = try newTomlEditor("[[fruit]]\nname = \"apple\"\n");
    defer ed.deinit();
    try std.testing.expectError(error.CannotDeleteTable, ed.deleteKey(&.{.{ .key = "fruit" }}));
}

test "toml inline array append/prepend/remove" {
    var ed = try newTomlEditor("ports = [1, 2]\n");
    defer ed.deinit();
    try ed.appendToSeq(&.{.{ .key = "ports" }}, "3");
    try expectTomlSource(&ed, "ports = [1, 2, 3]\n");
    try ed.prependToSeq(&.{.{ .key = "ports" }}, "0");
    try expectTomlSource(&ed, "ports = [0, 1, 2, 3]\n");
    try ed.removeSeqItem(&.{.{ .key = "ports" }}, 2);
    try expectTomlSource(&ed, "ports = [0, 1, 3]\n");
}

test "toml inline array append with pre-existing trailing comma" {
    // A trailing comma before ']' is legal TOML inline-array syntax;
    // appending must not double it into an empty element that fails to
    // reparse.
    var ed = try newTomlEditor("ports = [1, 2,]\n");
    defer ed.deinit();
    try ed.appendToSeq(&.{.{ .key = "ports" }}, "3");
    try expectTomlSource(&ed, "ports = [1, 2, 3,]\n");
}

test "toml inline array append onto a multi-line one-item-per-line array" {
    var ed = try newTomlEditor("ports = [\n  1,\n  2,\n]\n");
    defer ed.deinit();
    try ed.appendToSeq(&.{.{ .key = "ports" }}, "3");
    try expectTomlSource(&ed, "ports = [\n  1,\n  2,\n  3,\n]\n");
}

test "toml remove last item of a multi-line trailing-comma inline array (regression)" {
    // Same class of bug as the append regression above, on the delete side:
    // the backward scan for the preceding comma didn't cross newlines, so
    // removing the last item left its own trailing comma dangling as an
    // empty element that failed to reparse.
    var ed = try newTomlEditor("ports = [\n  1,\n  2,\n]\n");
    defer ed.deinit();
    try ed.removeSeqItem(&.{.{ .key = "ports" }}, std.math.maxInt(usize));
    try expectTomlSource(&ed, "ports = [\n  1,\n]\n");
}

test "toml inline array ops on array-of-tables are refused" {
    var ed = try newTomlEditor("[[fruit]]\nname = \"apple\"\n");
    defer ed.deinit();
    try std.testing.expectError(error.NotAnInlineArray, ed.appendToSeq(&.{.{ .key = "fruit" }}, "1"));
}

test "toml append array-of-tables element" {
    var ed = try newTomlEditor("[[fruit]]\nname = \"apple\"\n");
    defer ed.deinit();
    try ed.appendTableToArray(&.{.{ .key = "fruit" }}, "name = \"pear\"\n");
    try expectTomlSource(&ed, "[[fruit]]\nname = \"apple\"\n\n[[fruit]]\nname = \"pear\"\n");
}

test "toml append AoT element after one with a sub-table" {
    // The new element must splice past the last element's nested sub-table, not
    // into the middle of it.
    var ed = try newTomlEditor("[[fruit]]\nname = \"apple\"\n\n[fruit.variety]\nkind = \"red\"\n");
    defer ed.deinit();
    try ed.appendTableToArray(&.{.{ .key = "fruit" }}, "name = \"pear\"\n");
    try expectTomlSource(&ed, "[[fruit]]\nname = \"apple\"\n\n[fruit.variety]\nkind = \"red\"\n\n[[fruit]]\nname = \"pear\"\n");
}

test "toml append empty AoT element" {
    var ed = try newTomlEditor("[[fruit]]\nname = \"apple\"\n");
    defer ed.deinit();
    try ed.appendTableToArray(&.{.{ .key = "fruit" }}, "");
    try expectTomlSource(&ed, "[[fruit]]\nname = \"apple\"\n\n[[fruit]]\n");
}

test "toml append AoT with a dotted header path" {
    var ed = try newTomlEditor("[[a.b]]\nx = 1\n");
    defer ed.deinit();
    try ed.appendTableToArray(&.{ .{ .key = "a" }, .{ .key = "b" } }, "x = 2\n");
    try expectTomlSource(&ed, "[[a.b]]\nx = 1\n\n[[a.b]]\nx = 2\n");
}

test "toml appendTableToArray on a non-AoT is refused" {
    var ed = try newTomlEditor("nums = [1, 2]\n");
    defer ed.deinit();
    try std.testing.expectError(error.NotAnArrayOfTables, ed.appendTableToArray(&.{.{ .key = "nums" }}, "x = 1\n"));
}

// --- deleteTable ---

test "toml delete simple header table" {
    var ed = try newTomlEditor("[a]\nx = 1\n[b]\ny = 2\n");
    defer ed.deinit();
    try ed.deleteTable(&.{.{ .key = "a" }});
    try expectTomlSource(&ed, "[b]\ny = 2\n");
}

test "toml delete table leaves interleaved foreign table intact" {
    var ed = try newTomlEditor("[a]\nx = 1\n[other]\ny = 2\n[a.b]\nz = 3\n");
    defer ed.deinit();
    try ed.deleteTable(&.{.{ .key = "a" }});
    try expectTomlSource(&ed, "[other]\ny = 2\n");
}

test "toml delete header-only table with sub-tables" {
    var ed = try newTomlEditor("[a]\n[a.b]\ny = 2\n");
    defer ed.deinit();
    try ed.deleteTable(&.{.{ .key = "a" }});
    try expectTomlSource(&ed, "");
}

test "toml delete table carries owned comment" {
    var ed = try newTomlEditor("# about a\n[a]\nx = 1\n[b]\ny = 2\n");
    defer ed.deinit();
    try ed.deleteTable(&.{.{ .key = "a" }});
    try expectTomlSource(&ed, "[b]\ny = 2\n");
}

test "toml delete table with multi-line array value" {
    var ed = try newTomlEditor("[a]\nl = [\n  1,\n  2,\n]\n[b]\ny = 2\n");
    defer ed.deinit();
    try ed.deleteTable(&.{.{ .key = "a" }});
    try expectTomlSource(&ed, "[b]\ny = 2\n");
}

test "toml delete dotted-only table" {
    var ed = try newTomlEditor("a.b = 1\na.c = 2\nz = 9\n");
    defer ed.deinit();
    try ed.deleteTable(&.{.{ .key = "a" }});
    try expectTomlSource(&ed, "z = 9\n");
}

test "toml delete whole array-of-tables" {
    var ed = try newTomlEditor("[[f]]\nn = \"a\"\n[[f]]\nn = \"b\"\n");
    defer ed.deinit();
    try ed.deleteTable(&.{.{ .key = "f" }});
    try expectTomlSource(&ed, "");
}

test "toml delete single AoT element" {
    var ed = try newTomlEditor("[[f]]\nn = \"a\"\n[[f]]\nn = \"b\"\n");
    defer ed.deinit();
    try ed.deleteTable(&.{ .{ .key = "f" }, .{ .index = 0 } });
    try expectTomlSource(&ed, "[[f]]\nn = \"b\"\n");
}

test "toml delete AoT element with nested sub-table" {
    var ed = try newTomlEditor("[[f]]\nn = \"a\"\n[f.sub]\nk = 1\n[[f]]\nn = \"b\"\n");
    defer ed.deinit();
    try ed.deleteTable(&.{ .{ .key = "f" }, .{ .index = 0 } });
    try expectTomlSource(&ed, "[[f]]\nn = \"b\"\n");
}

test "toml deleteTable on a scalar key is refused" {
    var ed = try newTomlEditor("x = 1\n");
    defer ed.deinit();
    try std.testing.expectError(error.NotATable, ed.deleteTable(&.{.{ .key = "x" }}));
}

// --- insertTable ---

test "toml insert new table at root end" {
    var ed = try newTomlEditor("a = 1\n[t]\nx = 1\n");
    defer ed.deinit();
    try ed.insertTable(&.{.{ .key = "s" }}, "p = 1\n");
    try expectTomlSource(&ed, "a = 1\n[t]\nx = 1\n\n[s]\np = 1\n");
}

test "toml insert sub-table after parent subtree" {
    var ed = try newTomlEditor("[a]\nx = 1\n");
    defer ed.deinit();
    try ed.insertTable(&.{ .{ .key = "a" }, .{ .key = "b" } }, "z = 3\n");
    try expectTomlSource(&ed, "[a]\nx = 1\n\n[a.b]\nz = 3\n");
}

test "toml insert empty table" {
    var ed = try newTomlEditor("a = 1\n");
    defer ed.deinit();
    try ed.insertTable(&.{.{ .key = "t" }}, "");
    try expectTomlSource(&ed, "a = 1\n\n[t]\n");
}

test "toml insert table with quoted-key segment" {
    var ed = try newTomlEditor("a = 1\n");
    defer ed.deinit();
    try ed.insertTable(&.{.{ .key = "needs space" }}, "x = 1\n");
    try expectTomlSource(&ed, "a = 1\n\n[\"needs space\"]\nx = 1\n");
}

test "toml insert duplicate table is refused" {
    var ed = try newTomlEditor("[a]\nx = 1\n");
    defer ed.deinit();
    try std.testing.expectError(error.TableExists, ed.insertTable(&.{.{ .key = "a" }}, "y = 2\n"));
}

// --- renameTable ---

test "toml rename leaf table header" {
    var ed = try newTomlEditor("[server]\nport = 8080\n");
    defer ed.deinit();
    try ed.renameTable(&.{.{ .key = "server" }}, "http");
    try expectTomlSource(&ed, "[http]\nport = 8080\n");
}

test "toml rename rewrites descendant sub-headers" {
    var ed = try newTomlEditor("[a]\nx = 1\n[a.b]\nz = 3\n[a.b.c]\nw = 4\n");
    defer ed.deinit();
    try ed.renameTable(&.{.{ .key = "a" }}, "q");
    try expectTomlSource(&ed, "[q]\nx = 1\n[q.b]\nz = 3\n[q.b.c]\nw = 4\n");
}

test "toml rename does not touch a similar-prefix foreign table" {
    var ed = try newTomlEditor("[a]\nx = 1\n[ab]\ny = 2\n");
    defer ed.deinit();
    try ed.renameTable(&.{.{ .key = "a" }}, "q");
    try expectTomlSource(&ed, "[q]\nx = 1\n[ab]\ny = 2\n");
}

test "toml rename leaf needing quotes" {
    var ed = try newTomlEditor("[a]\nx = 1\n");
    defer ed.deinit();
    try ed.renameTable(&.{.{ .key = "a" }}, "new key");
    try expectTomlSource(&ed, "[\"new key\"]\nx = 1\n");
}

test "toml rename AoT header" {
    var ed = try newTomlEditor("[[a.b]]\nn = 1\n[[a.b]]\nn = 2\n");
    defer ed.deinit();
    try ed.renameTable(&.{ .{ .key = "a" }, .{ .key = "b" } }, "c");
    try expectTomlSource(&ed, "[[a.c]]\nn = 1\n[[a.c]]\nn = 2\n");
}

// --- moveTable / reorderTables ---

test "toml move table to end" {
    var ed = try newTomlEditor("[a]\nx = 1\n[b]\ny = 2\n");
    defer ed.deinit();
    try ed.moveTable(&.{.{ .key = "a" }}, null);
    try expectTomlSource(&ed, "[b]\ny = 2\n\n[a]\nx = 1\n");
}

test "toml move scattered table collapses fragments contiguously" {
    var ed = try newTomlEditor("[a]\nx = 1\n[b]\ny = 2\n[a.c]\nz = 3\n");
    defer ed.deinit();
    try ed.moveTable(&.{.{ .key = "a" }}, null);
    try expectTomlSource(&ed, "[b]\ny = 2\n\n[a]\nx = 1\n[a.c]\nz = 3\n");
}

test "toml move table before another" {
    var ed = try newTomlEditor("[a]\nx = 1\n[b]\ny = 2\n[c]\nw = 3\n");
    defer ed.deinit();
    try ed.moveTable(&.{.{ .key = "c" }}, &.{.{ .key = "b" }});
    try expectTomlSource(&ed, "[a]\nx = 1\n\n[c]\nw = 3\n[b]\ny = 2\n");
}

test "toml reorder top-level tables" {
    var ed = try newTomlEditor("[a]\nx = 1\n[b]\ny = 2\n[c]\nw = 3\n");
    defer ed.deinit();
    try ed.reorderTables(&.{ "c", "a", "b" });
    try expectTomlSource(&ed, "[c]\nw = 3\n[a]\nx = 1\n[b]\ny = 2\n");
}
