"""Citation reference. Build with `Node(Citation, "id"; brackets=false)`."""
struct Citation <: AbstractBlock
    id::String
    brackets::Bool
end

function Node(::Type{Citation}, id::AbstractString; brackets::Bool = false)
    node = Node(Citation(id, brackets))
    node.literal = "@$id"
    return node
end

struct CitationBracket <: AbstractBlock end

"""
    CitationRule()

Parse citation references using `@key` or `[@key]` syntax.

Not enabled by default. Citations can be bare (`@smith2020`) or bracketed
(`[@smith2020]`). Requires a bibliography to be configured for rendering.

```markdown
According to @smith2020, this is true.

Multiple citations: [@smith2020; @jones2021]
```
"""
struct CitationRule
    cites::Vector{Node}
    CitationRule() = new([])
end

inline_rule(rule::CitationRule) = Rule(1, "@") do parser, block
    m = consume(
        parser,
        match(r"@[_\w\d](?:[_\w\d]|[:#$%&\-\+\?\<\>~/](?=[_\w\d]))*", parser),
    )
    m === nothing && return false
    bs = parser.brackets
    opener = bs !== nothing && is_bracket(bs.node, "[")
    citation = Node(Citation(chop(m.match; head = 1, tail = 0), opener))
    citation.literal = m.match
    append_child(block, citation)
    push!(rule.cites, citation)
    return true
end

is_bracket(n::Node, c) = n.literal == c && n.t isa Text

function mark_citation_brackets!(cite::Node, openers::Set{Node}, closers::Set{Node})
    opener = closer = cite
    while !isnull(opener.prv)
        opener = opener.prv
        opener in openers && return
        opener.t isa Citation && break
        if is_bracket(opener, "[")
            opener.t = CitationBracket()
            push!(openers, opener)
            break
        end
    end
    while !isnull(closer.nxt)
        closer = closer.nxt
        closer in closers && return
        closer.t isa Citation && break
        if is_bracket(closer, "]")
            closer.t = CitationBracket()
            push!(closers, closer)
            break
        end
    end
    return
end

inline_modifier(rule::CitationRule) = Rule(1) do parser, block
    openers = Set{Node}()
    closers = Set{Node}()
    while !isempty(rule.cites)
        cite = pop!(rule.cites)
        cite.t.brackets && mark_citation_brackets!(cite, openers, closers)
    end
end

struct References <: AbstractBlock end

block_modifier(::CitationRule) = Rule(10) do parser, b
    if !isnull(b.parent) && b.parent.t isa Document
        if hasmeta(b, "id") && getmeta(b, "id", "") == "refs"
            insert_after(b, Node(References()))
        end
    end
    return nothing
end

# Writers. Styling is fixed to author-year; .csl style files are not parsed.

# Citations inside a `Link` would emit nested `<a>` (HTML) or nested
# `\href`/`\hyperlink` (LaTeX). HTML5 §4.5.1 forbids the former and browsers
# rewrite the DOM; LaTeX nesting is undefined. Skip the inner hyperlink in
# that case.
function inside_link(n::Node)
    p = n.parent
    while !isnull(p)
        p.t isa Link && return true
        p = p.parent
    end
    return false
end

function write_html(c::Citation, w, n, ent)
    tag(w, "span", attributes(w, n, ["class" => "citation"]))
    linked = !inside_link(n)
    linked && tag(w, "a", ["href" => "#ref-$(c.id)"])
    literal(w, CSL.author_year(w.env, c.id))
    linked && tag(w, "/a")
    return tag(w, "/span")
end

function write_latex(c::Citation, w, n, ent)
    if ent
        # Allow the latex writer environment to control how citations are
        # printed. `basic` just uses the built in hyperlinking similar to the
        # HTML writer. `biblatex` will generate citations suitable for use with
        # `biblatex` and `biber`.
        if get(w.env, "citations", "basic") == "biblatex"
            literal(w, "\\cite{", c.id, "}")
        else
            name = CSL.author_year(w.env, c.id)
            name = name === nothing ? c.id : name
            if inside_link(n)
                literal(w, name)
            else
                literal(w, "\\protect\\hyperlink{ref-", c.id, "}{", name, "}")
            end
        end
    end
    return nothing
end

write_markdown(c::Citation, w, n, ent) = literal(w, "@", c.id)
write_typst(c::Citation, w, n, ent) = literal(w, "@", c.id)

function write_term(c::Citation, w, n, ent)
    style = crayon"red"
    print_literal(w, style)
    push_inline!(w, style)
    print_literal(w, CSL.author_year(w.env, c.id))
    pop_inline!(w)
    return print_literal(w, inv(style))
end

write_html(::CitationBracket, w, n, ent) = literal(w, n.literal == "[" ? "(" : ")")
write_latex(::CitationBracket, w, n, ent) = literal(w, n.literal == "[" ? "(" : ")")
write_markdown(::CitationBracket, w, n, ent) = literal(w, n.literal)
write_typst(::CitationBracket, w, n, ent) = literal(w, n.literal)
write_term(::CitationBracket, w, n, ent) = print_literal(w, n.literal == "[" ? "(" : ")")

function write_json(c::Citation, ctx, node, enter)
    enter || return
    D = dicttype(ctx)
    citation = D(
        "citationId" => c.id,
        "citationPrefix" => Any[],
        "citationSuffix" => Any[],
        "citationMode" => json_el(ctx, "NormalCitation"),
        "citationNoteNum" => 0,
        "citationHash" => 0,
    )
    return push_element!(
        ctx,
        json_el(ctx, "Cite", Any[Any[citation], text_to_inlines(ctx, "@" * c.id)]),
    )
end

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

write_markdown(::References, w, n, ent) = nothing
write_html(::References, w, n, ent) = write_references(write_html, w)
write_latex(::References, w, n, ent) = write_references(write_latex, w)
write_term(::References, w, n, ent) = write_references(write_term, w)
write_typst(::References, w, n, ent) = nothing # unsupported

function write_references(f, writer)
    ast = build_references(get(writer.env, "references", nothing))
    return f(writer, ast)
end

struct ReferenceList <: AbstractBlock end

is_container(::ReferenceList) = true

write_markdown(::ReferenceList, w, n, ent) = nothing
write_html(::ReferenceList, w, n, ent) = nothing
write_latex(::ReferenceList, w, n, ent) = nothing
write_term(::ReferenceList, w, n, ent) = nothing

write_json(::References, ctx, node, enter) = nothing
write_json(::ReferenceList, ctx, node, enter) = nothing

function build_references(items::AbstractVector)
    block = Node(ReferenceList())
    for item in sort!(items; by = CSL.authors_long)
        append_child(block, build_reference(item))
    end
    return block
end
build_references(::Nothing) = Node(ReferenceList())

function build_reference(item::AbstractDict)
    paragraph = Node(Paragraph())
    setmeta!(paragraph, "id", "ref-$(get(item, "id", ""))")
    # Authors.
    authors = CSL.authors_long(item)
    authors === nothing || append_child(paragraph, text("$authors. "))
    # Year of publication.
    year = CSL.year(item)
    year === nothing || append_child(paragraph, text("$year. "))
    # Title of document.
    title = Node(Emph())
    append_child(title, text(CSL.title(item) * ". "))
    append_child(paragraph, title)
    # Publisher and location of publication.
    publisher = CSL.publisher(item)
    publisher === nothing || append_child(paragraph, text("$publisher. "))
    # Digital object identifier.
    doi = CSL.doi(item)
    doi === nothing || append_child(paragraph, text("doi:$doi. "))
    # Document URL.
    url = CSL.url(item)
    if url !== nothing
        link = Node(Link())
        link.t.title = url
        link.t.destination = url
        append_child(link, text(url))
        append_child(paragraph, link)
        append_child(paragraph, text(". "))
    end
    return paragraph
end

module CSL

    rget(f, col, key, keys...) = contains(col, key) ? rget(f, col[key], keys...) : f()
    rget(f, col, key) = tryget(f, col, key)
    tryget(f, d, key) = contains(d, key) ? d[key] : f()
    contains(d::AbstractDict, key) = haskey(d, key)
    contains(v::AbstractVector, index) = index in keys(v)
    contains(others...) = false

    year(item) = rget(() -> nothing, item, "issued", "date-parts", 1, 1)
    authors(item) =
        filter(d -> d isa AbstractDict && haskey(d, "family"), rget(() -> [], item, "author"))
    title(item) = rget(() -> nothing, item, "title")

    function year(env::AbstractDict, id::AbstractString)
        y = year(get_item(env, id))
        return y === nothing ? "@$id" : y
    end

    author_short(item) = get(() -> get(item, "given", ""), item, "family")

    function authors_short(item)
        names = sort(authors(item); by = author_short)
        n = length(names)
        n === 0 && return "Unknown"
        1 ≤ n ≤ 2 && return join(author_short.(names), " and ")
        n === 3 && return join(author_short.(names), ", ", ", and ")
        return author_short(names[1]) * " et al."
    end

    function author_long(item, first)
        family = get(item, "family", "")
        given = get(item, "given", "")
        given == "" && return family
        family == "" && return given
        return first ? "$family, $given" : "$given $family"
    end

    function authors_long(item)
        names = sort(authors(item); by = author_short)
        return join((author_long(a, n == 1) for (n, a) in enumerate(names)), ", ", ", and ")
    end

    mapbib(items::AbstractVector) =
        Dict{String, Dict}(item["id"] => item for item in items if haskey(item, "id"))

    function get_item(env, id)
        if haskey(env, "references")
            refs = get!(() -> mapbib(env["references"]), env, "ref-map")
            haskey(refs, id) && return refs[id]
        end
        return nothing
    end

    function author_year(env::AbstractDict, id::AbstractString)
        item = get_item(env, id)
        return item === nothing ? "@$id" : author_year(item)
    end
    author_year(item) = "$(authors_short(item)) $(year(item))"

    publisher(d) = _publisher(get(d, "publisher-place", nothing), get(d, "publisher", nothing))
    _publisher(place, name) = "$place: $name"
    _publisher(::Nothing, name::AbstractString) = name
    _publisher(place::AbstractString, ::Nothing) = place
    _publisher(::Nothing, ::Nothing) = nothing

    url(d) = get(d, "URL", nothing)
    doi(d) = get(d, "DOI", nothing)

end
