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

/// JSON cannot represent a YAML alias. A materialized AST contains none (aliases
/// are expanded to copied subtrees by `yaml.materialize`), so reaching one here
/// means an unmaterialized YAML AST was handed to the JSON printer.
pub const Error = Writer.Error || error{UnresolvedAlias};

writer: *Writer,
ast: *const AST,
options: AST.SerializeOptions,
/// Which dialect to emit. JSON5 differs from JSON in exactly two places:
/// object keys print unquoted when they are bare identifiers, and the
/// non-finite `number_special` scalars (`Infinity`/`NaN`) print verbatim
/// rather than degrading to quoted strings. JSONC is plain JSON syntax (quoted
/// keys, normalized numbers) but, like JSON5, carries `//` and `/* */` comments.
dialect: Dialect = .json,

pub const Dialect = enum { json, jsonc, json5 };

/// Prints a given document in JSON format.
pub fn print(writer: *Writer, ast: *const AST, options: AST.SerializeOptions) Error!void {
    var p: Printer = .{ .writer = writer, .ast = ast, .options = options };
    try p.node(ast.root, 0);
    try writer.writeByte('\n');
    try writer.flush();
}

/// Prints the subtree rooted at `id`. Used for partial renders; unlike `print`
/// it adds no trailing newline and does not flush.
pub fn printNode(writer: *Writer, ast: *const AST, id: AST.Node.Id, depth: usize, options: AST.SerializeOptions) Error!void {
    var p: Printer = .{ .writer = writer, .ast = ast, .options = options };
    try p.node(id, depth);
}

/// `print`, emitting JSON5: unquoted bare-identifier keys and verbatim
/// `Infinity`/`NaN`.
pub fn print5(writer: *Writer, ast: *const AST, options: AST.SerializeOptions) Error!void {
    var p: Printer = .{ .writer = writer, .ast = ast, .options = options, .dialect = .json5 };
    try p.leadingComments(ast.leadingCommentAnchor(ast.root), 0);
    try p.node(ast.root, 0);
    try p.rootTrailing();
    try writer.writeByte('\n');
    try writer.flush();
}

/// `printNode`, emitting JSON5.
pub fn printNode5(writer: *Writer, ast: *const AST, id: AST.Node.Id, depth: usize, options: AST.SerializeOptions) Error!void {
    var p: Printer = .{ .writer = writer, .ast = ast, .options = options, .dialect = .json5 };
    try p.node(id, depth);
}

/// `print`, emitting JSONC: plain-JSON syntax with `//`/`/* */` comments.
pub fn printc(writer: *Writer, ast: *const AST, options: AST.SerializeOptions) Error!void {
    var p: Printer = .{ .writer = writer, .ast = ast, .options = options, .dialect = .jsonc };
    try p.leadingComments(ast.leadingCommentAnchor(ast.root), 0);
    try p.node(ast.root, 0);
    try p.rootTrailing();
    try writer.writeByte('\n');
    try writer.flush();
}

/// Emit the root's trailing comment — but only when the root is a scalar. A
/// container root already emitted its own trailing beside its opening delimiter.
fn rootTrailing(self: *Printer) Error!void {
    const anchor = self.ast.trailingCommentAnchor(self.ast.root);
    if (!self.isContainer(anchor)) try self.trailingComment(anchor);
}

/// `printNode`, emitting JSONC.
pub fn printNodec(writer: *Writer, ast: *const AST, id: AST.Node.Id, depth: usize, options: AST.SerializeOptions) Error!void {
    var p: Printer = .{ .writer = writer, .ast = ast, .options = options, .dialect = .jsonc };
    try p.node(id, depth);
}

fn node(self: *Printer, id: AST.Node.Id, depth: usize) Error!void {
    const n = self.ast.nodes[id];
    switch (n.kind) {
        .null_ => try self.writer.writeAll("null"),
        .boolean => |value| try self.writer.writeAll(if (value) "true" else "false"),
        .number => |value| try self.number(value.raw),
        // JSON has none of these types. Datetimes and enum literals render as
        // strings (the timestamp / the bare name); a char literal renders as its
        // codepoint number.
        .extended => |value| switch (value.kind) {
            .char_literal => try self.writer.writeAll(value.text),
            // JSON5 has native non-finite floats; JSON must degrade to a string.
            .number_special => if (self.dialect == .json5)
                try self.writer.writeAll(value.text)
            else
                try json_string.writeQuoted(self.writer, value.text),
            else => try json_string.writeQuoted(self.writer, value.text),
        },
        .string => |value| try json_string.writeQuoted(self.writer, value),
        .sequence => |first_child| try self.sequence(id, first_child, depth),
        .mapping => |first_child| try self.mapping(id, first_child, depth),
        .keyvalue => |kv| {
            try self.key(kv.key, depth);
            // Compact output omits the space after the colon.
            try self.writer.writeAll(if (self.options.pretty) ": " else ":");
            try self.node(kv.value, depth);
        },
        .alias => return error.UnresolvedAlias,
    }
}

/// Render an object key. In JSON5 a string key that is a bare ECMAScript
/// identifier prints unquoted (`foo: 1`); otherwise it falls back to a normal
/// quoted string. JSON always quotes.
fn key(self: *Printer, id: AST.Node.Id, depth: usize) Error!void {
    const k = self.ast.nodes[id].kind;
    if (self.dialect == .json5 and k == .string and isBareIdentifier(k.string)) {
        try self.writer.writeAll(k.string);
        return;
    }
    try self.node(id, depth);
}

/// The ASCII subset of an ECMAScript IdentifierName, matching what the JSON5
/// tokenizer accepts unquoted. Reserved words are intentionally allowed (JSON5
/// permits `while: 1`); only the lexical shape matters.
fn isBareIdentifier(name: []const u8) bool {
    if (name.len == 0) return false;
    for (name, 0..) |c, i| {
        const start_ok = (c >= 'a' and c <= 'z') or (c >= 'A' and c <= 'Z') or c == '_' or c == '$';
        const part_ok = start_ok or (c >= '0' and c <= '9');
        if (!(if (i == 0) start_ok else part_ok)) return false;
    }
    return true;
}

/// Render a number. JSON5 keeps the source lexeme verbatim (hex, leading/
/// trailing `.`, leading `+` are all valid JSON5). Plain JSON has none of those,
/// so a non-JSON lexeme — whether from a JSON5 source (`0xFF`, `.5`, `+15`) or a
/// TOML one (`0xFF`, `0o17`, `1_000`) — is normalized to a valid JSON number.
fn number(self: *Printer, raw: []const u8) Error!void {
    if (self.dialect == .json5) {
        try self.writer.writeAll(raw);
        return;
    }
    try writeJsonNumber(self.writer, raw);
}

fn writeJsonNumber(writer: *Writer, raw: []const u8) Writer.Error!void {
    // Leading sign: JSON keeps `-`, drops `+`.
    var s = raw;
    if (s.len > 0 and s[0] == '-') {
        try writer.writeByte('-');
        s = s[1..];
    } else if (s.len > 0 and s[0] == '+') {
        s = s[1..];
    }

    // Radix-prefixed integers (`0x`/`0o`/`0b`) convert to decimal. Plain decimal
    // integers are passed through (arbitrary precision; never reformatted).
    if (s.len >= 2 and s[0] == '0' and (s[1] | 0x20 == 'x' or s[1] | 0x20 == 'o' or s[1] | 0x20 == 'b')) {
        const base: u8 = switch (s[1] | 0x20) {
            'x' => 16,
            'o' => 8,
            else => 2,
        };
        var buf: [128]u8 = undefined;
        if (stripUnderscores(s[2..], &buf)) |digits| {
            if (std.fmt.parseInt(u128, digits, base)) |v| {
                try writer.print("{d}", .{v});
                return;
            } else |_| {}
        }
        // Fallback: emit the (sign-stripped) lexeme rather than nothing.
        try writer.writeAll(s);
        return;
    }

    // Decimal/float: drop digit-group underscores and pad a bare `.`
    // (`.5` -> `0.5`, `5.` -> `5.0`) so the result is a valid JSON number.
    const e_idx = std.mem.indexOfAny(u8, s, "eE");
    const mantissa = if (e_idx) |i| s[0..i] else s;
    const exponent = if (e_idx) |i| s[i..] else "";

    if (mantissa.len > 0 and mantissa[0] == '.') try writer.writeByte('0');
    for (mantissa) |c| {
        if (c != '_') try writer.writeByte(c);
    }
    if (mantissa.len > 0 and mantissa[mantissa.len - 1] == '.') try writer.writeByte('0');

    for (exponent) |c| {
        if (c != '_') try writer.writeByte(c);
    }
}

/// Copy `s` into `buf` without `_` digit separators; null if it would overflow.
fn stripUnderscores(s: []const u8, buf: []u8) ?[]const u8 {
    var n: usize = 0;
    for (s) |c| {
        if (c == '_') continue;
        if (n >= buf.len) return null;
        buf[n] = c;
        n += 1;
    }
    return buf[0..n];
}

fn sequence(self: *Printer, node_id: AST.Node.Id, first_child: ?AST.Node.Id, depth: usize) Error!void {
    try self.container(node_id, '[', ']', first_child, depth);
}

fn mapping(self: *Printer, node_id: AST.Node.Id, first_child: ?AST.Node.Id, depth: usize) Error!void {
    try self.container(node_id, '{', '}', first_child, depth);
}

/// Sequences and mappings differ only in their delimiters and in how each child
/// renders (a bare node vs. a `key: value`), the latter dispatched by `node`.
fn container(self: *Printer, node_id: AST.Node.Id, open: u8, close: u8, first_child: ?AST.Node.Id, depth: usize) Error!void {
    // Dangling comments (orphans at the end of the body) force the block form so
    // they have somewhere to print; only an empty-and-comment-free container is
    // inline. Comments only print in pretty JSON5/JSONC, so an empty container
    // with dangling comments in a comment-less mode still prints inline.
    const dangling = if (self.commentsOn()) self.ast.comments(node_id).dangling else &.{};
    if (first_child == null and dangling.len == 0) {
        try self.writer.writeByte(open);
        try self.writer.writeByte(close);
        try self.trailingComment(node_id); // empty inline container: `[] // c`
        return;
    }

    const pretty = self.options.pretty;
    try self.writer.writeByte(open);
    // A container's own trailing comment rides the line it opened on, so it sits
    // beside the `[`/`{` (next to its key), not after the distant close.
    try self.trailingComment(node_id);
    if (pretty) try self.writer.writeByte('\n');

    var current_id = first_child;
    while (current_id) |id| {
        try self.leadingComments(self.ast.leadingCommentAnchor(id), depth + 1);
        if (pretty) try self.writeIndent(depth + 1);
        try self.node(id, depth + 1);
        current_id = self.ast.nodes[id].next_sibling;
        if (current_id != null) try self.writer.writeByte(',');
        // A scalar child's trailing prints here; a container child emits its own
        // (beside its opener, above), so skip it to avoid a double / misplacement.
        const anchor = self.ast.trailingCommentAnchor(id);
        if (!self.isContainer(anchor)) try self.trailingComment(anchor);
        if (pretty) try self.writer.writeByte('\n');
    }
    try self.danglingComments(dangling, depth + 1);

    if (pretty) try self.writeIndent(depth);
    try self.writer.writeByte(close);
}

/// Whether `id` is a container node (whose own trailing comment is emitted beside
/// its opening delimiter, not by its parent).
fn isContainer(self: *const Printer, id: AST.Node.Id) bool {
    return switch (self.ast.nodes[id].kind) {
        .sequence, .mapping => true,
        else => false,
    };
}

// ── comments (JSON5 only) ───────────────────────────────────────────────────
// Plain JSON has no comment syntax, so comments are emitted only in the JSON5
// dialect and only when pretty-printing (a `//` line comment can't survive on a
// minified single line). Both predicates are checked in the helpers, so callers
// can invoke them unconditionally.

/// True when comments may be emitted: a comment-bearing dialect (JSON5 or JSONC)
/// and multi-line output (a `//` can't survive on a minified single line).
fn commentsOn(self: *const Printer) bool {
    return (self.dialect == .json5 or self.dialect == .jsonc) and self.options.pretty;
}

/// Emit a node's leading comments, one per line at `depth`.
fn leadingComments(self: *Printer, id: AST.Node.Id, depth: usize) Error!void {
    if (!self.commentsOn()) return;
    for (self.ast.comments(id).leading) |c| {
        try self.writeIndent(depth);
        try self.writeComment(c);
        try self.writer.writeByte('\n');
    }
}

/// Emit a node's trailing comment (if any) after a leading space, no newline.
fn trailingComment(self: *Printer, id: AST.Node.Id) Error!void {
    if (!self.commentsOn()) return;
    if (self.ast.comments(id).trailing) |c| {
        try self.writer.writeByte(' ');
        try self.writeComment(c);
    }
}

/// Emit a container's dangling comments (end of body), one per line at `depth`.
/// The caller has already gated on `commentsOn` via the passed slice.
fn danglingComments(self: *Printer, dangling: []const AST.Comment, depth: usize) Error!void {
    for (dangling) |c| {
        try self.writeIndent(depth);
        try self.writeComment(c);
        try self.writer.writeByte('\n');
    }
}

/// Render one comment in JSON5 syntax. JSON5 has both forms, so the stored
/// `style` is honored directly with no degradation.
fn writeComment(self: *Printer, c: AST.Comment) Error!void {
    switch (c.style) {
        .line => {
            try self.writer.writeAll("//");
            if (c.text.len != 0) {
                try self.writer.writeByte(' ');
                try self.writer.writeAll(c.text);
            }
        },
        .block => {
            try self.writer.writeAll("/*");
            if (c.text.len != 0) {
                try self.writer.writeByte(' ');
                try self.writer.writeAll(c.text);
                try self.writer.writeByte(' ');
            }
            try self.writer.writeAll("*/");
        },
    }
}

fn writeIndent(self: *Printer, depth: usize) Writer.Error!void {
    for (0..depth * self.options.indent) |_| try self.writer.writeByte(' ');
}

test "prints JSON document" {
    const Parser = @import("parser.zig");
    const input = "{\"name\":\"Ada\",\"tags\":[\"zig\",true,null]}";
    var doc = try Parser.parseAbstract(std.testing.allocator, input, .JSON);
    defer doc.deinit();

    var output: Writer.Allocating = .init(std.testing.allocator);
    defer output.deinit();

    try print(&output.writer, &doc, .{});
    try std.testing.expectEqualSlices(u8,
        \\{
        \\  "name": "Ada",
        \\  "tags": [
        \\    "zig",
        \\    true,
        \\    null
        \\  ]
        \\}
        \\
    , output.written());
}

test "prints compact JSON document" {
    const Parser = @import("parser.zig");
    const input = "{\"name\":\"Ada\",\"tags\":[\"zig\",true,null],\"empty\":{}}";
    var doc = try Parser.parseAbstract(std.testing.allocator, input, .JSON);
    defer doc.deinit();

    var output: Writer.Allocating = .init(std.testing.allocator);
    defer output.deinit();

    try print(&output.writer, &doc, .{ .pretty = false });
    try std.testing.expectEqualSlices(u8,
        \\{"name":"Ada","tags":["zig",true,null],"empty":{}}
        \\
    , output.written());
}

test "json5: unquoted keys, Infinity/NaN, pretty" {
    const Parser = @import("parser.zig");
    const input = "{ a: 1, 'b c': 2, while: true, n: NaN, inf: -Infinity }";
    var doc = try Parser.parseAbstract(std.testing.allocator, input, .JSON5);
    defer doc.deinit();

    var output: Writer.Allocating = .init(std.testing.allocator);
    defer output.deinit();

    try print5(&output.writer, &doc, .{});
    try std.testing.expectEqualSlices(u8,
        \\{
        \\  a: 1,
        \\  "b c": 2,
        \\  while: true,
        \\  n: NaN,
        \\  inf: -Infinity
        \\}
        \\
    , output.written());
}

test "json5: compact output" {
    const Parser = @import("parser.zig");
    const input = "{a:1,b:[2,Infinity,'x'],$_:3}";
    var doc = try Parser.parseAbstract(std.testing.allocator, input, .JSON5);
    defer doc.deinit();

    var output: Writer.Allocating = .init(std.testing.allocator);
    defer output.deinit();

    try print5(&output.writer, &doc, .{ .pretty = false });
    try std.testing.expectEqualSlices(u8,
        \\{a:1,b:[2,Infinity,"x"],$_:3}
        \\
    , output.written());
}

test "json5: round-trips through serialize and reparse" {
    const Parser = @import("parser.zig");
    const input = "{ a: .5, b: 0xC8, c: [+1, -Infinity, NaN], 'has space': null, while: 'kw' }";
    var doc = try Parser.parseAbstract(std.testing.allocator, input, .JSON5);
    defer doc.deinit();

    var output: Writer.Allocating = .init(std.testing.allocator);
    defer output.deinit();
    try print5(&output.writer, &doc, .{ .pretty = false });

    var reparsed = try Parser.parseAbstract(std.testing.allocator, output.written(), .JSON5);
    defer reparsed.deinit();
    try std.testing.expect(doc.eql(reparsed));
}

test "json dialect normalizes JSON5 number lexemes to valid JSON" {
    const Parser = @import("parser.zig");
    var doc = try Parser.parseAbstract(std.testing.allocator, "[0xFF, -0xa, .5, 5., +15, 1.5e3, -.25]", .JSON5);
    defer doc.deinit();

    var output: Writer.Allocating = .init(std.testing.allocator);
    defer output.deinit();
    try print(&output.writer, &doc, .{ .pretty = false });
    try std.testing.expectEqualSlices(u8,
        \\[255,-10,0.5,5.0,15,1.5e3,-0.25]
        \\
    , output.written());
}

test "json dialect degrades Infinity to a quoted string" {
    const Parser = @import("parser.zig");
    var doc = try Parser.parseAbstract(std.testing.allocator, "Infinity", .JSON5);
    defer doc.deinit();

    var output: Writer.Allocating = .init(std.testing.allocator);
    defer output.deinit();
    // The same extended node, rendered by the plain-JSON dialect.
    try print(&output.writer, &doc, .{ .pretty = false });
    try std.testing.expectEqualSlices(u8, "\"Infinity\"\n", output.written());
}

test "json5 emits leading and trailing comments; plain json drops them" {
    const a = std.testing.allocator;
    var b = AST.Builder.init(a);
    defer b.deinit();

    // { name: "fig" } with a leading line comment on the entry, a trailing line
    // comment on the value, and a leading block comment on the document.
    const v = try b.addString("fig");
    try b.setComments(v, .{ .trailing = .{ .text = "inline", .style = .line } });
    const k = try b.addString("name");
    try b.setComments(k, .{ .leading = &.{.{ .text = "greeting", .style = .line }} });
    const root = try b.addMapping(&.{.{ .key = k, .value = v }});
    try b.setComments(root, .{ .leading = &.{.{ .text = "doc", .style = .block }} });

    var ast = try b.finish(root);
    defer ast.deinit();

    var j5: Writer.Allocating = .init(a);
    defer j5.deinit();
    try print5(&j5.writer, &ast, .{});
    try std.testing.expectEqualStrings(
        \\/* doc */
        \\{
        \\  // greeting
        \\  name: "fig" // inline
        \\}
        \\
    , j5.written());

    // Plain JSON has no comment syntax: same AST emits clean JSON.
    var j: Writer.Allocating = .init(a);
    defer j.deinit();
    try print(&j.writer, &ast, .{});
    try std.testing.expectEqualStrings(
        \\{
        \\  "name": "fig"
        \\}
        \\
    , j.written());

    // Compact JSON5 also drops comments (a `//` can't survive one line).
    var c: Writer.Allocating = .init(a);
    defer c.deinit();
    try print5(&c.writer, &ast, .{ .pretty = false });
    try std.testing.expectEqualStrings("{name:\"fig\"}\n", c.written());
}

test "json5: a container value's trailing comment rides its opening bracket" {
    const Parser = @import("parser.zig");
    // `key: [ // note` round-trips: the comment stays beside the `[` (next to its
    // key), not after the distant `]`.
    const input =
        \\{
        \\  contents: [ // the note
        \\    "a",
        \\    "b"
        \\  ]
        \\}
    ;
    var doc = try Parser.parseAbstract(std.testing.allocator, input, .JSON5);
    defer doc.deinit();
    var out: Writer.Allocating = .init(std.testing.allocator);
    defer out.deinit();
    try print5(&out.writer, &doc, .{});
    try std.testing.expectEqualStrings(input ++ "\n", out.written());
}

test "json5: opening-line comment stays at top, closing-line at bottom" {
    const Parser = @import("parser.zig");
    // A comment on the `]` line is a bottom comment: it normalizes to the last
    // line of the body (before the close), distinct from the opening-line one.
    var doc = try Parser.parseAbstract(std.testing.allocator,
        \\{
        \\  a: [ // top
        \\    1
        \\  ],
        \\  b: [
        \\    2
        \\  ] // bottom
        \\}
    , .JSON5);
    defer doc.deinit();
    var out: Writer.Allocating = .init(std.testing.allocator);
    defer out.deinit();
    try print5(&out.writer, &doc, .{});
    try std.testing.expectEqualStrings(
        \\{
        \\  a: [ // top
        \\    1
        \\  ],
        \\  b: [
        \\    2
        \\    // bottom
        \\  ]
        \\}
        \\
    , out.written());
}

test "jsonc emits comments with quoted keys (unlike json5)" {
    const a = std.testing.allocator;
    var b = AST.Builder.init(a);
    defer b.deinit();

    const v = try b.addString("fig");
    try b.setComments(v, .{ .trailing = .{ .text = "inline", .style = .line } });
    const k = try b.addString("name");
    try b.setComments(k, .{ .leading = &.{.{ .text = "greeting", .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 printc(&out.writer, &ast, .{});
    // JSON syntax (quoted key) + JSON5-style comments.
    try std.testing.expectEqualStrings(
        \\{
        \\  /* greeting */
        \\  "name": "fig" // inline
        \\}
        \\
    , out.written());
}

test "honors custom indent width" {
    const Parser = @import("parser.zig");
    const input = "{\"a\":[1]}";
    var doc = try Parser.parseAbstract(std.testing.allocator, input, .JSON);
    defer doc.deinit();

    var output: Writer.Allocating = .init(std.testing.allocator);
    defer output.deinit();

    try print(&output.writer, &doc, .{ .indent = 4 });
    try std.testing.expectEqualSlices(u8,
        \\{
        \\    "a": [
        \\        1
        \\    ]
        \\}
        \\
    , output.written());
}
