# Public.

"""
    json(ast::Node; dicttype=Dict) -> String
    json(filename::String, ast::Node; dicttype=Dict)
    json(io::IO, ast::Node; dicttype=Dict)
    json(::Type{<:AbstractDict}, ast::Node) -> AbstractDict

Render a CommonMark AST to Pandoc AST JSON format.

The output can be piped to `pandoc -f json -t <format>` to convert to any
format Pandoc supports (docx, epub, rst, asciidoc, etc.).

The `dicttype` keyword argument controls the dictionary type used internally.
Use `OrderedCollections.OrderedDict` for deterministic key ordering.

Pass a dict type as the first argument to return the dict directly without
JSON string serialization: `json(Dict, ast)`.

# Examples

```julia
p = Parser()
ast = p("# Hello\\n\\nWorld")
output = json(ast)
# Use with: echo \$output | pandoc -f json -t docx -o out.docx

# Get dict directly (no JSON string):
d = json(Dict, ast)
```
"""
json(args...; dicttype = Dict) =
    writer(MIME"application/pandoc+json"(), args...; dicttype = dicttype)

function json(::Type{D}, ast::Node) where {D <: AbstractDict}
    return build_json_dict(D, ast)
end

function Base.show(
        io::IO,
        ::MIME"application/pandoc+json",
        ast::Node,
        env = Dict{String, Any}();
        dicttype = Dict,
        kws...,
    )
    DictType = get(env, "dicttype", dicttype)
    doc = build_json_dict(DictType, ast)
    _json(io, doc)
    return nothing
end

function build_json_dict(DictType::Type, ast::Node)
    ctx = JSONContext(DictType)
    if has_frontmatter(ast)
        fm = frontmatter(ast)
        for (k, v) in fm
            ctx.doc["meta"][k] = json_meta_value(ctx, v)
        end
    end
    for (node, enter) in ast
        write_json(node.t, ctx, node, enter)
    end
    return ctx.doc
end

# Internals.

mime_to_str(::MIME"application/pandoc+json") = "json"

# Context for building AST during traversal.
mutable struct JSONContext{D <: AbstractDict}
    doc::D
    stack::Vector{Vector{Any}}  # Stack of inline/block containers
end

function JSONContext(DictType::Type = Dict)
    doc = DictType{String, Any}(
        "pandoc-api-version" => [1, 23, 1],
        "meta" => DictType{String, Any}(),
        "blocks" => Any[],
    )
    return JSONContext{typeof(doc)}(doc, Vector{Any}[doc["blocks"]])
end

# Current container being built.
current(ctx::JSONContext) = ctx.stack[end]

# Push a new container onto the stack.
push_container!(ctx::JSONContext, container::Vector{Any}) = push!(ctx.stack, container)

# Pop container from stack.
pop_container!(ctx::JSONContext) = pop!(ctx.stack)

# Add element to current container.
push_element!(ctx::JSONContext, el) = push!(current(ctx), el)

# Element constructors - use same dict type as context.
dicttype(::JSONContext{D}) where {D} = D
json_el(ctx::JSONContext{D}, t::String) where {D} = D("t" => t)
json_el(ctx::JSONContext{D}, t::String, c) where {D} = D("t" => t, "c" => c)

# Empty attribute triple.
empty_attr() = Any["", String[], Any[]]

# Build attribute triple from node metadata.
function node_attr(node::Node)
    id = getmeta(node, "id", "")
    class_val = getmeta(node, "class", "")
    classes = if class_val isa AbstractVector
        String.(class_val)
    elseif isempty(class_val)
        String[]
    else
        String.(split(class_val))
    end
    return Any[id, classes, Any[]]
end

# Convert frontmatter values to meta format.
json_meta_value(ctx::JSONContext{D}, s::AbstractString) where {D} =
    D("t" => "MetaString", "c" => s)
json_meta_value(ctx::JSONContext{D}, v::AbstractVector) where {D} =
    D("t" => "MetaList", "c" => Any[json_meta_value(ctx, x) for x in v])
json_meta_value(ctx::JSONContext{D}, d::AbstractDict) where {D} =
    D("t" => "MetaMap", "c" => D(string(k) => json_meta_value(ctx, v) for (k, v) in d))
json_meta_value(ctx::JSONContext{D}, b::Bool) where {D} = D("t" => "MetaBool", "c" => b)
json_meta_value(ctx::JSONContext{D}, n::Number) where {D} =
    D("t" => "MetaString", "c" => string(n))
json_meta_value(ctx::JSONContext{D}, x) where {D} = D("t" => "MetaString", "c" => string(x))

# Split text into Str + Space tokens.
function text_to_inlines(ctx::JSONContext, s::AbstractString)
    result = Any[]
    isempty(s) && return result
    i = 1
    while i <= lastindex(s)
        if isspace(s[i])
            # Collect whitespace run.
            j = i
            has_newline = false
            while j <= lastindex(s) && isspace(s[j])
                s[j] == '\n' && (has_newline = true)
                j = nextind(s, j)
            end
            push!(result, json_el(ctx, has_newline ? "SoftBreak" : "Space"))
            i = j
        else
            # Collect non-whitespace run.
            j = i
            while j <= lastindex(s) && !isspace(s[j])
                j = nextind(s, j)
            end
            push!(result, json_el(ctx, "Str", s[i:prevind(s, j)]))
            i = j
        end
    end
    return result
end

# Core block types.

write_json(::Document, ctx, node, enter) = nothing

function write_json(::Paragraph, ctx, node, enter)
    return if enter
        inlines = Any[]
        push_container!(ctx, inlines)
    else
        inlines = pop_container!(ctx)
        # Use Plain for tight list items, Para otherwise.
        grandparent = node.parent.parent
        block_type =
        if !isnull(grandparent) &&
                grandparent.t isa List &&
                grandparent.t.list_data.tight
            "Plain"
        else
            "Para"
        end
        push_element!(ctx, json_el(ctx, block_type, inlines))
    end
end

function write_json(h::Heading, ctx, node, enter)
    return if enter
        inlines = Any[]
        push_container!(ctx, inlines)
    else
        inlines = pop_container!(ctx)
        push_element!(ctx, json_el(ctx, "Header", Any[h.level, node_attr(node), inlines]))
    end
end

function write_json(::CodeBlock, ctx, node, enter)
    enter || return
    info = unescape_string(node.t.info)
    classes = isempty(info) ? String[] : String[info]
    attr = Any["", classes, Any[]]
    # Strip trailing newline.
    content = chomp(node.literal)
    return push_element!(ctx, json_el(ctx, "CodeBlock", Any[attr, content]))
end

function write_json(::BlockQuote, ctx, node, enter)
    return if enter
        blocks = Any[]
        push_container!(ctx, blocks)
    else
        blocks = pop_container!(ctx)
        push_element!(ctx, json_el(ctx, "BlockQuote", blocks))
    end
end

function write_json(::ThematicBreak, ctx, node, enter)
    enter || return
    return push_element!(ctx, json_el(ctx, "HorizontalRule"))
end

function write_json(::HtmlBlock, ctx, node, enter)
    enter || return
    return push_element!(ctx, json_el(ctx, "RawBlock", Any["html", node.literal]))
end

# Lists.

function write_json(list::List, ctx, node, enter)
    return if enter
        items = Any[]
        push_container!(ctx, items)
    else
        items = pop_container!(ctx)
        if list.list_data.type === :bullet
            push_element!(ctx, json_el(ctx, "BulletList", items))
        else
            start = list.list_data.start
            style = json_el(ctx, "Decimal")
            delim =
                list.list_data.delimiter == ")" ? json_el(ctx, "OneParen") :
                json_el(ctx, "Period")
            push_element!(
                ctx,
                json_el(ctx, "OrderedList", Any[Any[start, style, delim], items]),
            )
        end
    end
end

function write_json(::Item, ctx, node, enter)
    return if enter
        blocks = Any[]
        push_container!(ctx, blocks)
    else
        blocks = pop_container!(ctx)
        push_element!(ctx, blocks)
    end
end

# Inline types.

function write_json(::Text, ctx, node, enter)
    enter || return
    return append!(current(ctx), text_to_inlines(ctx, node.literal))
end

write_json(::SoftBreak, ctx, node, enter) =
    enter && push_element!(ctx, json_el(ctx, "SoftBreak"))

write_json(::LineBreak, ctx, node, enter) =
    enter && push_element!(ctx, json_el(ctx, "LineBreak"))

function write_json(::Code, ctx, node, enter)
    enter || return
    return push_element!(ctx, json_el(ctx, "Code", Any[empty_attr(), node.literal]))
end

function write_json(::Emph, ctx, node, enter)
    return if enter
        inlines = Any[]
        push_container!(ctx, inlines)
    else
        inlines = pop_container!(ctx)
        push_element!(ctx, json_el(ctx, "Emph", inlines))
    end
end

function write_json(::Strong, ctx, node, enter)
    return if enter
        inlines = Any[]
        push_container!(ctx, inlines)
    else
        inlines = pop_container!(ctx)
        push_element!(ctx, json_el(ctx, "Strong", inlines))
    end
end

function write_json(link::Link, ctx, node, enter)
    return if enter
        inlines = Any[]
        push_container!(ctx, inlines)
    else
        inlines = pop_container!(ctx)
        target = Any[link.destination, link.title]
        push_element!(ctx, json_el(ctx, "Link", Any[node_attr(node), inlines, target]))
    end
end

function write_json(img::Image, ctx, node, enter)
    return if enter
        inlines = Any[]
        push_container!(ctx, inlines)
    else
        inlines = pop_container!(ctx)
        target = Any[img.destination, img.title]
        push_element!(ctx, json_el(ctx, "Image", Any[node_attr(node), inlines, target]))
    end
end

function write_json(::HtmlInline, ctx, node, enter)
    enter || return
    return push_element!(ctx, json_el(ctx, "RawInline", Any["html", node.literal]))
end

write_json(::Backslash, ctx, node, enter) = nothing

# Fallback for unknown/extension types.
write_json(container, ctx, node, enter) = nothing
