Utilities

"""
    sandbox(f, path)

Evaluate the function `f` in the given folder given by `path`. When `path`
does not exist it is created first.
"""
function sandbox end

sandbox(f, ::Nothing) = mktempdir(dir -> cd(f, dir))
sandbox(f, temp::AbstractString) = isdir(temp) ? cd(f, temp) : (mkpath(temp); cd(f, temp))

"""
    init_markdown_parser()

Returns a new `CommonMark.Parser` object with all extensions enabled.
"""
function init_markdown_parser(env=Dict())
    cm = CommonMark
    imports = get(() -> Module[], env, "cell-imports")
    return cm.enable!(cm.Parser(), [
        # CommonMark-provided.
        cm.AdmonitionRule(),
        cm.AttributeRule(),
        cm.AutoIdentifierRule(),
        cm.CitationRule(),
        cm.DollarMathRule(),
        cm.FootnoteRule(),
        cm.FrontMatterRule(toml=TOML.parse),
        cm.MathRule(),
        cm.RawContentRule(),
        cm.TableRule(),
        cm.TypographyRule(),
        # Publish-provided.
        CellRule(imports = imports),
    ])
end

load_markdown(io::IO, parser=init_markdown_parser()) = parser(seekstart(io))
load_markdown(str::AbstractString, parser=init_markdown_parser()) = load_markdown(IOBuffer(str), parser)

function visible_modules(env::AbstractDict)
    roots = env["publish"]["modules"]
    if isempty(roots)
        mod = findmodule(env)
        return mod === nothing ? Set{Module}() : modules(mod)
    else
        set = Set{Module}()
        for root in roots
            bind = binding(root)
            if Docs.defined(bind)
                modules(Docs.resolve(bind), set)
            else
                @warn "module '$root' listed in 'publish.modules' does not exist."
            end
        end
        return set
    end
end

"""
Modules to ignore when searching for available modules.
"""
const IGNORE_LIST = (Main, Base.__toplevel__, Base.MainInclude)

"""
    modules(root)

Returns the set of modules visible from the given `root` module.
"""
modules(root::Module) = union!(modules(root, Set{Module}()), DEFAULT_MODULES)
function modules(root::Module, mods::Set{Module})
    for name in names(root; all=true, imported=true)
        if !Base.isdeprecated(root, name) && isdefined(root, name) && ismodule(root, name)::Bool
            mod = convert(Module, getfield(root, name))
            if !(mod in mods) && !(mod in IGNORE_LIST)
                push!(mods, mod)
                modules(mod, mods)
            end
        end
    end
    return mods
end
@noinline ismodule(m::Module, s::Symbol) = getfield(m, s)::Any isa Module

"""
The set of modules available to all packages.
"""
const DEFAULT_MODULES = modules(Core, modules(Base, Set{Module}()))

"""
    categorise(binding)

Returns the category of a given `Docs.Binding` object `binding`. These
categories are used for displaying details about docstrings.
"""
function categorise(binding)
    ismacro(binding) = startswith(string(binding.var), '@')
    category(other)         = isconst(binding.mod, binding.var) ? "constant" : "global"
    category(obj::Module)   = "module"
    category(obj::DataType) = isconcretetype(obj) ? "struct" : "type"
    category(obj::UnionAll) = isconcretetype(obj) ? "parametric struct" : "parametric type"
    category(obj::Function) = ismacro(binding) ? "macro" : "function"
    return Docs.defined(binding) ? category(Docs.resolve(binding)) : "undefined"
end

"""
Returns the `Docs.Binding` object given an expression or string.
"""
function binding end

function binding(mod::Module, expr::Expr)
    if Meta.isexpr(expr, :.)
        parent = binding(mod, expr.args[1])
        if Docs.defined(parent)
            return binding(Docs.resolve(parent), expr.args[2:end]...)
        end
    end
    return Docs.Binding(mod, nameof(mod))
end
binding(mod::Module, str::AbstractString) = binding(mod, Meta.parse(str; raise=false))
binding(mod::Module, symbol::Symbol) = Docs.Binding(mod, symbol)
binding(mod::Module, quot::QuoteNode) = Docs.Binding(mod, quot.value)
binding(mod, other...) = Docs.Binding(mod, nameof(mod))
binding(str::AbstractString) = binding(Main, str)
binding(mod::Module) = binding(mod, nameof(mod))

function printdoc(io::IO, docstr)
    for part in docstr.text
        Docs.formatdoc(io, docstr, part)
    end
    return io
end

"""
    rmerge(ds...)

Recursively merge the `Dict`s provided in `ds`. Last argument wins when
conflicts occur.
"""
rmerge(ds::AbstractDict...) = merge(rmerge, ds...)
rmerge(args...) = last(args)

function try_touch(default::Function, f::FileTree, idx)
    try
        f[idx][]
    catch err
        f = touch(f, idx; value=default())
    end
    return f
end

const IGNORE_PATHS = r"^[^\.\_]"
const CODE_FENCE = '~'^10

function page_neighbours(pages::AbstractVector)
    flat = Iterators.flatten((Ref(first(pages)), pages, Ref(last(pages))))
    part = IterTools.partition(flat, 3, 1)
    return Dict(x => (prev=p, next=n) for (p, x, n) in part)
end

"""
    with_extension(path, ext)

Return a path with the extension set to `ext`.
"""
function with_extension end

with_extension(p::AbstractString, ext) = "$(first(splitext(p))).$ext"
with_extension(p::AbstractPath, ext) = with_extension(string(p), ext)

"""
    relative_paths(func, project, file)

Rewrite any file paths within a [`Project`](#)'s `.env` to be relative to the
given `file` and pass then to the `func` argument for evaluation.
"""
function relative_paths(func, p::Project, file::AbstractString)
    # Configuration replacement walker functions.
    with_replacement(f, v) = v
    with_replacement(f, v::Union{AbstractString,AbstractPath}) = f(v)
    with_replacement(f, xs::Vector{T}) where T = T[with_replacement(f, x) for x in xs]
    with_replacement(f, dict::T) where T <: AbstractDict = T(k => with_replacement(f, v) for (k, v) in dict)
    # Rewrite configuration paths.
    pub′ = with_replacement(p.env["publish"]) do value
        hasfile(p.tree, value) ? relpath(string(value), dirname(joinpath(".", file))) : value
    end
    return func(pub′)
end
relative_paths(f, p::Project, path::AbstractPath) = relative_paths(f, p, string(path))

"""
    revise(project)

When Revise.jl is loaded in the current session trigger `Revise.revise`.
"""
revise(project) = nothing

function hasfile(tree::FileTree, path)
    # TODO: needs real API here.
    try
        tree[path]
    catch err
        return false
    end
    return true
end

"""
    frontmatter(ast)

Returns a `Dict` containing the front matter content of a markdown `ast`. When
no front matter is found then an empty `Dict` is returned.
"""
function frontmatter(ast::CommonMark.Node)
    CommonMark.isnull(ast.first_child)           && return Dict{String,Any}()
    ast.first_child.t isa CommonMark.FrontMatter && return ast.first_child.t.data
    return Dict{String,Any}()
end

# Needed by latex engine since it doesn't like windows paths.
if Sys.iswindows()
    unix_style_path(path) = unix_joinpath(splitpath(path)...)
else
    unix_style_path(path) = path
end
function unix_joinpath(path::AbstractString, paths::AbstractString...)::String
    for p in paths
        if isabspath(p)
            path = p
        elseif isempty(path) || path[end] == '/'
            path *= p
        else
            path *= "/" * p
        end
    end
    return path
end
unix_joinpath(path::AbstractString) = path