"""
    upgrade(src, dest)
    upgrade(src, dest, project, manifest)
    upgrade(src::Vector, dest::Vector)

Handle upgrading existing JLSO files. This can be useful to:

1. Avoid repeatedly reading legacy jlso files (requiring structure updates at runtime)
2. Allow overriding project and manifest data rather than building it as part of the
upgrade step. Won't be needed once the v2 file format is dropped.
3. Allows for efficient batch updating of files created with the same environment.

!!! warning
    The manifest generated by this process are best guesses.
    JLSO v2 files do not include information on indirect dependencies, so the upgrade process relies on the package manager to work those out.
    The project generated is accurate, except for duplicated names (no UUID) and custom branchs.
"""
function upgrade(src, dest)
    info(LOGGER, "Upgrading $src -> $dest")
    jlso = open(io -> read(io, JLSOFile), src)
    write(dest, jlso)
end

function upgrade(src, dest, project, manifest)
    info(LOGGER, "Upgrading $src -> $dest")
    parsed = BSON.load(string(src))
    haskey(parsed["metadata"], "pkgs") && empty!(parsed["metadata"]["pkgs"])
    d = upgrade_jlso(parsed)

    if VersionNumber(d["metadata"]["version"]) < v"4"
        jlso = JLSOFile(
            VersionNumber(d["metadata"]["version"]),
            VersionNumber(d["metadata"]["julia"]),
            Symbol(d["metadata"]["format"]),
            Symbol(d["metadata"]["compression"]),
            d["metadata"]["image"],
            project,
            manifest,
            Dict{Symbol, Vector{UInt8}}(Symbol(k) => v for (k, v) in d["objects"]),
            ReentrantLock()
        )
    else
        # We don't need to upgrade v4 files
        jlso = read(src, JLSOFile)
    end

    write(dest, jlso)
end

function upgrade(sources::Vector, destinations::Vector)
    iter = zip(sources, destinations)

    if !isempty(iter)
        (src, dest), s = iterate(iter)
        mkpath(dirname(dest))
        jlso = open(io -> read(io, JLSOFile), src)
        write(dest, jlso)

        for (src, dest) in Iterators.rest(iter, s)
            mkpath(dirname(dest))
            upgrade(src, dest, jlso.project, jlso.manifest)
        end
    end
end

function upgrade_jlso(raw_dict::AbstractDict)
    result = copy(raw_dict)
    version = version_number(result["metadata"]["version"])

    while version < v"3"
        result = upgrade_jlso(result, Val(Int(version.major)))
        version = version_number(result["metadata"]["version"])
    end

    return result
end

# v1 and v2 stored version numbers but v3 stores strings to be bson parser agnostic
version_number(x::VersionNumber) = x
version_number(x::String) = VersionNumber(x)

# Metadata changes to upgrade file from v1 to v2
function upgrade_jlso(raw_dict::AbstractDict, ::Val{1})
    info(LOGGER, "Upgrading JLSO format from v1 to v2")
    metadata = copy(raw_dict["metadata"])
    version = version_number(metadata["version"])
    @assert version ∈ semver_spec("1")

    if metadata["format"] == :serialize
        metadata["format"] = :julia_serialize
    end

    metadata["compression"] = :none
    metadata["version"] = v"2"

    return Dict("metadata" => metadata, "objects" => get(raw_dict, "objects", Dict()))
end

# Metadata changes to upgrade from v2 to v3
function upgrade_jlso(raw_dict::AbstractDict, ::Val{2})
    info(LOGGER, "Upgrading JLSO format from v2 to v3")
    metadata = copy(raw_dict["metadata"])
    version = version_number(metadata["version"])
    @assert version ∈ semver_spec("2")

    # JLSO v3 expects to be loading a compressed manifest
    compression = get(metadata, "compression", :none)
    project, manifest = _upgrade_env(get(metadata, "pkgs", nothing))

    return Dict{String, Dict}(
        "metadata" => Dict{String, Union{String, Vector{UInt8}}}(
            "version" => "3",
            "julia" => string(get(metadata, "julia", VERSION)),
            "format" => string(get(metadata, "format", :julia_serialize)),
            "compression" => string(compression),
            "image" => get(metadata, "image", ""),
            "project" => project,
            "manifest" => read(compress(compression, IOBuffer(manifest))),
        ),
        "objects" => Dict{String, Vector{UInt8}}(
            k => v for (k, v) in get(raw_dict, "objects", Dict())
        ),
    )
end

#=
NOTE:

JLSO file v4 just moved the object data from the BSON doc to raw bytes at the
end of the file, avoiding BSON size limitations.
Extracting the object data is handled separately in `_extract_objects` in file_io.jl.
=#

# Used to convert the old "PkgName" => VersionNumber metadata to a
# Project.toml and Manifest.toml file.
function _upgrade_env(pkgs::Dict{String, VersionNumber})
    src_env = Base.active_project()

    try
        mktempdir() do tmp
            Pkg.activate(tmp)

            # In julia 1.5 a `require_not_empty` restriction was added for adding packages,
            # we use this early exit to avoid that.
            # JuliaLang/Pkg.jl@87aab4a#diff-a43f827629f8ae12bbcbb218a293d325afcdda3a7f3c7016e2292840bf29ed1cR58
            isempty(pkgs) && return _env()

            # We construct an array of PackageSpecs to avoid ordering problems with
            # adding each package individually
            try
                Pkg.add([
                    Pkg.PackageSpec(; name=key, version=value) for (key, value) in pkgs
                ])
            catch e
                # Warn about failure and fallback to simply trying to install the pacakges
                # without version constraints.
                warn(LOGGER) do
                    "Failed to construct an environment with the provide package version " *
                    "($pkgs): $e.\n Falling back to simply adding the packages."
                end
                Pkg.add([Pkg.PackageSpec(; name=key) for (key, value) in pkgs])
            end

            return _env()
        end
    finally
        Pkg.activate(src_env)
    end
end

_upgrade_env(::Nothing) = ("", "")
