"""
The Conda module provides access to the [conda](http://conda.pydata.org/) packages
manager to install binary dependencies of other Julia packages.

The main functions in Conda are:

- `Conda.add(package)`: install a package;
- `Conda.rm(package)`: remove (uninstall) a package;
- `Conda.update()`: update all installed packages to the latest version;
- `Conda.list()`: list all installed packages.
- `Conda.add_channel(channel)`: add a channel to the list of channels;
- `Conda.channels()`: get the current list of channels;
- `Conda.rm_channel(channel)`: remove a channel from the list of channels;
```
"""
module Conda
using JSON, VersionParsing
import Downloads

const deps_file = joinpath(dirname(@__FILE__), "..", "deps", "deps.jl")

if isfile(deps_file)
    # Includes definition for ROOTENV, and MINICONDA_VERSION
    include(deps_file)
else
    error("Conda is not properly configured.  Run Pkg.build(\"Conda\") before importing the Conda module.")
end

const Environment = Union{AbstractString,Symbol}

"""
    prefix(name::Symbol)

Prefix for installation of the environment `name`.
"""
function prefix(name::Symbol)
    sname = string(name)
    if isempty(sname)
        throw(ArgumentError("Environment name should be non empty."))
    end
    return joinpath(ROOTENV, "envs", sname)
end

"""
    prefix(path::String)

Checks that `path` is a valid directory for a Conda environment, and
returns `path`.
"""
function prefix(path::AbstractString)
    if !isdir(path)
        throw(ArgumentError("Path to conda environment is not valid: $path"))
    end
    return path
end

const PREFIX = prefix(ROOTENV)

"""
    bin_dir(env::Union{AbstractString,Symbol})

Directory for the executable files installed with the packages for
the environment named `env`.
"""
function bin_dir(env::Environment)
    return Sys.iswindows() ? joinpath(prefix(env), "Library", "bin") : joinpath(prefix(env), "bin")
end
const BINDIR = bin_dir(ROOTENV)

"""
    lib_dir(env::Union{AbstractString,Symbol})

Directory for the shared libraries installed with the packages for
the environment named `env`.   To get this directory for the
default root environment `ROOTENV`, use `LIBDIR`.
"""
function lib_dir(env::Environment)
    return Sys.iswindows() ? joinpath(prefix(env), "Library", "bin") : joinpath(prefix(env), "lib")
end
const LIBDIR = lib_dir(ROOTENV)

"""
    script_dir(env::Union{AbstractString,Symbol})

Directory for the Python scripts installed with the packages for
the environment named `env`.   To get this directory for the
default root environment `ROOTENV`, use `SCRIPTDIR`.
"""
function script_dir(env::Environment)
    return Sys.iswindows() ? joinpath(prefix(env), "Scripts") : bin_dir(env)
end
const SCRIPTDIR = script_dir(ROOTENV)

"""
    python_dir(env::Union{AbstractString,Symbol})

Directory where the `python` command lives for
the environment named `env`.   To get this directory for the
default root environment `ROOTENV`, use `PYTHONDIR`.
"""
function python_dir(env::Environment)
    return Sys.iswindows() ? prefix(env) : bin_dir(env)
end
const PYTHONDIR = python_dir(ROOTENV)

if ! @isdefined(CONDA_EXE)
    # We have an oudated deps.jl file that does not define CONDA_EXE
    error("CONDA_EXE not defined in $deps_file.\nPlease rebuild Conda.jl via `using Pkg; pkg\"build Conda\";`")
end
# note: the same conda program is used for all environments
const conda = CONDA_EXE

"""
    conda_rc(env::Union{AbstractString,Symbol}=ROOTENV)

Path of the condarc file for the environment named `env`,
defaulting to `ROOTENV`.
"""
function conda_rc(env::Environment)
    #=
    sys_condarc is looked at by conda for almost operations
    except when adding channels with --file argument.
    we copy it to env_condarc avoid this conda bug
    =#
    env_condarc = joinpath(prefix(env), "condarc-julia.yml")
    sys_condarc = joinpath(prefix(ROOTENV), ".condarc")
    if isdir(prefix(env)) && !isfile(env_condarc) && isfile(sys_condarc)
        cp(sys_condarc, env_condarc)
    end
    return env_condarc
end

const CONDARC = conda_rc(ROOTENV)

"""
    _get_conda_env(env::Union{AbstractString,Symbol}=ROOTENV)

Get a sanitized copy of the environment [`ENV`](@ref) for the
Conda environment named `env` (defaulting to `ROOTENV`),
in order to run the `conda` program.

In particular, this removes any environment
variable whose named begins with `CONDA` or `PYTHON`, which might
cause `conda` to have unexpected behaviors.
"""
function _get_conda_env(env::Environment=ROOTENV)
    env_var = copy(ENV)
    to_remove = String[]
    for var in keys(env_var)
        if startswith(var, "CONDA") || startswith(var, "PYTHON")
            push!(to_remove, var)
        end
    end
    for var in to_remove
        pop!(env_var, var)
    end
    env_var["PYTHONIOENCODING"]="UTF-8"
    env_var["CONDARC"] = conda_rc(env)
    env_var["CONDA_PREFIX"] = prefix(env)
    if Sys.iswindows()
        env_var["PATH"] = bin_dir(env) * ';' * get(env_var, "PATH", "")
    end
    env_var
end

_set_conda_env(cmd, env::Environment=ROOTENV) = setenv(cmd, _get_conda_env(env))

"""
    runconda(args::Cmd, env::Union{AbstractString,Symbol}=ROOTENV)

Run the `conda` program with the given arguments `args`, i.e.
run `conda \$args`, in the given environment `env` (defaulting to `ROOTENV`).

(Installs `conda` if necessary, and sanitizes the runtime environment using
`_get_conda_env`.)

Run conda command with environment variables set.
"""
function runconda(args::Cmd, env::Environment=ROOTENV)
    _install_conda(env)
    @info("Running $(`conda $args`) in $(env==ROOTENV ? "root" : env) environment")
    run(_set_conda_env(`$conda $args`, env))
    return nothing
end

"""
    parseconda(args::Cmd, env::Union{AbstractString,Symbol}=ROOTENV)

Run the `conda` program with the given arguments `args`, i.e.
run `conda --json \$args`, in the given environment `env` (defaulting to `ROOTENV`),
parsing resulting JSON output and returning a Julia dictionary of the contents.

(Installs `conda` if necessary, and sanitizes the runtime environment using
`_get_conda_env`.)
"""

"Run conda command with environment variables set and return the json output as a julia object"
function parseconda(args::Cmd, env::Environment=ROOTENV)
    _install_conda(env)
    JSON.parse(read(_set_conda_env(`$conda $args --json`, env), String); dicttype=Dict{String, Any})
end

"""
    _installer_url()

Get the miniconda/miniforge installer URL.
"""
function _installer_url()
    if Sys.isapple()
        conda_os = "MacOSX"
    elseif Sys.islinux()
        conda_os = "Linux"
    elseif Sys.iswindows()
        conda_os = "Windows"
    else
        error("Unsuported OS.")
    end

    # mapping of Julia architecture names to Conda architecture names, where they differ
    arch2conda = Dict(:i686 => :x86, :powerpc64le => :ppc64le)

    if Sys.isapple()
        arch2conda[:aarch64] = :arm64
    end

    conda_platform = string(conda_os, '-', get(arch2conda, Sys.ARCH, Sys.ARCH))

    MINIFORGE_PLATFORMS = ["Linux-aarch64", "Linux-x86_64", "Linux-ppc64le",
                           "MacOSX-arm64", "MacOSX-x86_64",
                           "Windows-x86_64"]
    MINICONDA_PLATFORMS = ["Linux-aarch64", "Linux-x86_64", "Linux-ppc64le",
                           "Linux-x86", "Linux-s390x",
                           "MacOSX-arm64", "MacOSX-x86_64", "MacOSX-x86",
                           "Windows-x86", "Windows-x86_64"]

    if USE_MINIFORGE
        if !(conda_platform in MINIFORGE_PLATFORMS)
            error("Unsupported miniforge platform: $(conda_platform)")
        else
            res = "https://github.com/conda-forge/miniforge/releases/latest/download/Miniforge3-"
        end
    else
        if !(conda_platform in MINICONDA_PLATFORMS)
            error("Unsupported miniconda platform: $(conda_platform)")
        else
            res = "https://repo.continuum.io/miniconda/Miniconda$(MINICONDA_VERSION)-latest-"
        end
    end

    res *= conda_platform
    res *= Sys.iswindows() ? ".exe" : ".sh"
    return res
end

"""
    _quiet()

Returns `conda` command-line argument to suppress progress bar
in continuous integration (CI) environments.
"""
_quiet() = get(ENV, "CI", "false") == "true" ? `-q` : ``

"""
    _install_conda(env::Union{AbstractString,Symbol}, force::Bool=false)

Install miniconda/miniforge into `env` if it hasn't been installed yet;
`_install_conda(env, true)` re-installs Conda even if it has already been installed.
"""
function _install_conda(env::Environment, force::Bool=false)
    if force || !isfile(Conda.conda)
        @assert startswith(abspath(Conda.conda), abspath(PREFIX)) "CONDA_EXE, $(conda), does not exist within $PREFIX"

        if (' ' ∈ PREFIX) || (Sys.iswindows() && !isascii(PREFIX))
            error("""Conda.jl cannot be installed to its default location $(PREFIX)
as Miniconda does not support the installation to a directory with a space or a
non-ASCII character on Windows. The work-around is to install Miniconda to a
user-writable directory by setting the CONDA_JL_HOME environment variable. For
example on Windows:

ENV["CONDA_JL_HOME"] = raw"C:\\Conda-Julia\\3"
using Pkg
Pkg.build("Conda")

The Julia session need to be restarted. More information is available at
https://github.com/JuliaPy/Conda.jl.
""")
        end

        @info("Downloading miniconda installer ...")
        INSTALLER_DIR = tempdir()
        if Sys.isunix()
            installer = joinpath(INSTALLER_DIR, "installer.sh")
        end
        if Sys.iswindows()
            installer = joinpath(INSTALLER_DIR, "installer.exe")
        end
        mkpath(INSTALLER_DIR)
        Downloads.download(_installer_url(), installer)

        @info("Installing miniconda ...")
        mkpath(PREFIX)
        if Sys.isunix()
            chmod(installer, 33261)  # 33261 corresponds to 755 mode of the 'chmod' program
            run(`$installer -b -f -p $PREFIX`)
        end
        if Sys.iswindows()
            run(Cmd(`$installer /NoShortcuts=1 /NoRegistry=1 /AddToPath=0 /RegisterPython=0 /S /D=$PREFIX`, windows_verbatim=true))
        end
        write("$PREFIX/condarc-julia.yml", "auto_update_conda: false")
    end
    if !isdir(prefix(env))
        create(env)
    end
end

const PkgOrPkgs = Union{AbstractString, AbstractVector{<: AbstractString}}

"""
    add(pkg, env::Union{AbstractString,Symbol}=ROOTENV)

Installs new package(s) `pkg` into the conda environment `env`
(defaulting to `ROOTENV`).   `pkg` can be a string giving the name
of a single package, or a vector of strings giving the names of
several packages.
"""
function add(pkg::PkgOrPkgs, env::Environment=ROOTENV;
             channel::AbstractString="",
             satisfied_skip_solve::Bool = false,
             args::Cmd = ``,
            )
    c = isempty(channel) ? `` : `-c $channel`
    @static if Sys.iswindows() && Sys.WORD_SIZE == 32
        if satisfied_skip_solve
            @warn """
            The keyword satisfied_skip_solve was set to true,
            but conda does not support --satisfied-skip-resolve on 32-bit Windows.
            """
        end
        S = ``
    else
        S = satisfied_skip_solve ? `--satisfied-skip-solve` : ``
    end
    runconda(`install $(_quiet()) -y $c $S $args $pkg`, env)
end

"""
    rm(pkg, env::Union{AbstractString,Symbol}=ROOTENV)

Uninstall package(s) `pkg` from the conda environment `env`
(defaulting to `ROOTENV`).   `pkg` can be a string giving the name
of a single package, or a vector of strings giving the names of
several packages.
"""
function rm(pkg::PkgOrPkgs, env::Environment=ROOTENV)
    runconda(`remove $(_quiet()) -y $pkg`, env)
end

"""
    create(env::Union{AbstractString,Symbol})

Create a new conda environment `env` (running `conda create`).
"""
function create(env::Environment)
    runconda(`create $(_quiet()) -y -p $(prefix(env))`)
end

"""
    update(env::Union{AbstractString,Symbol}=ROOTENV)

Update all installed packages for the conda environment `env`
(defaulting to `ROOTENV`).
"""
function update(env::Environment=ROOTENV)
    if env == ROOTENV
        runconda(`update $(_quiet()) -y --all conda`, env)
    else
        runconda(`update $(_quiet()) -y --all`, env)
    end
end

"""
    _installed_packages_dict(env::Union{AbstractString,Symbol}=ROOTENV)

Return a list of all installed packages for the conda environment `env`
(defaulting to `ROOTENV`) as a dictionary mapping package names to
 tuples of `(version_number, fullname)`.
"""
function  _installed_packages_dict(env::Environment=ROOTENV)
    _install_conda(env)
    package_dict = Dict{String, Tuple{VersionNumber, String}}()
    for line in eachline(_set_conda_env(`$conda list`, env))
        line = chomp(line)
        if !startswith(line, "#")
            name, version, build_string = split(line)
            try
                package_dict[name] = (vparse(version), line)
            catch
                package_dict[name] = (v"9999.9999.9999", line)
                warn("Failed parsing string: \"$(version)\" to a version number. Please open an issue!")
            end
        end
    end
    return package_dict
end

"""
    _installed_packages(env::Union{AbstractString,Symbol}=ROOTENV)

Return an array of names of all installed packages for the conda environment `env`
(defaulting to `ROOTENV`).
"""
_installed_packages(env::Environment=ROOTENV) = keys(_installed_packages_dict(env))

"""
    list(env::Union{AbstractString,Symbol}=ROOTENV)

List all installed packages for the conda environment `env`
(defaulting to `ROOTENV`) to standard output (`stdout`).
"""
function list(env::Environment=ROOTENV)
    runconda(`list`, env)
end

"""
    export_list(filepath, env=$ROOTENV)
    export_list(io, env=$ROOTENV)

List all installed packages for the conda environment `env`
(defaulting to `ROOTENV`) and write them to an export `filepath`
or I/O stream `io`, mainly for use the [`import_list`](@ref) function.
"""
function export_list(filepath::AbstractString, env::Environment=ROOTENV)
    _install_conda(env)
    open(filepath, "w") do fobj
        export_list(fobj, env)
    end
end

function export_list(io::IO, env::Environment=ROOTENV)
    write(io, read(_set_conda_env(`$conda list --export`, env)))
end

"""
    version(name::AbstractString, env::Union{AbstractString,Symbol}=ROOTENV)

Return the installed version of the package `name`
(as a [`VersionNumber`](@ref) for the conda environment `env`
(defaulting to `ROOTENV`).
"""
function version(name::AbstractString, env::Environment=ROOTENV)
    packages = parseconda(`list`, env)
    for package in packages
        pname = get(package, "name", "")
        startswith(pname, name) && return vparse(package["version"])
    end
    error("Could not find the $name package")
end

"""
    search(matchspec::AbstractString, env::Union{AbstractString,Symbol}=ROOTENV;
           version::Union{AbstractString,VersionNumber,Nothing}=nothing)

Search the list of available conda packages for the string `matchspec`,
for the conda environment `env` (defaulting to `ROOTENV`), and return
the result as an array of package names.

If the optional keyword `version` is passed, then only packages having a match
for this version number will be returned.
"""
function search(matchspec::AbstractString, env::Environment=ROOTENV;
                version::Union{AbstractString,VersionNumber,Nothing}=nothing)
    ret = parseconda(`search $matchspec`, env)
    if isnothing(version)
        return collect(keys(ret))
    else
        ver = string(version)
        verv = vparse(ver)
        return collect(filter(keys(ret)) do k
            any(ret[k]) do pkg
                kver = pkg["version"]
                kver==ver || vparse(kver)==verv
            end
        end)
    end
end

# old API, has a method ambiguity for `search(package, version)` when
# `version` is a string:
search(package::AbstractString, _ver::AbstractString, env::Environment) =
    search(package, env; version=_ver)
search(package::AbstractString, _ver::VersionNumber, env::Environment=ROOTENV) =
    search(package, env; version=_ver)

"""
    exists(package::AbstractString, env::Union{AbstractString,Symbol}=ROOTENV)

Return whether the given `package` exists for the conda environment `env`
(defaulting to `ROOTENV`).   A particular version may be specified by
passing `"package==version"` as the `package` string.
"""
function exists(package::AbstractString, env::Environment=ROOTENV)
    if occursin("==", package)
      pkg,ver=split(package,"==")  # Remove version if provided
      return pkg in search(pkg,env; version=ver)
    else
      return package in search(package,env)
    end
end

"""
    channels(env::Union{AbstractString,Symbol}=ROOTENV)

Return an array of channels used to search packages in
the conda environment `env` (defaulting to `ROOTENV`).
"""
function channels(env::Environment=ROOTENV)
    ret=parseconda(`config --get channels --file $(conda_rc(env))`, env)
    if haskey(ret["get"], "channels")
        return collect(String, ret["get"]["channels"])
    else
        return String[]
    end
end

"""
    add_channel(channel::AbstractString, env::Union{AbstractString,Symbol}=ROOTENV)

Add `channel` to the list of channels used to search packages in
the conda environment `env` (defaulting to `ROOTENV`).
"""
function add_channel(channel::AbstractString, env::Environment=ROOTENV)
    runconda(`config --add channels $channel --file $(conda_rc(env)) --force`, env)
end

"""
    rm_channel(channel::AbstractString, env::Union{AbstractString,Symbol}=ROOTENV)

Remove `channel` from the list of channels used to search packages in
the conda environment `env` (defaulting to `ROOTENV`).
"""
function rm_channel(channel::AbstractString, env::Environment=ROOTENV)
    runconda(`config --remove channels $channel --file $(conda_rc(env)) --force`, env)
end

"""
    clean(;
        debug=false, index=true, locks=false, tarballs=true, packages=true, sources=false
    )

Runs `conda clean -y` with the specified flags.
"""
function clean(;
    debug=false, index=true, locks=false, tarballs=true, packages=true, sources=false
)
    kwargs = [debug, index, locks, tarballs, packages, sources]
    if !any(kwargs[2:end])
        @warn(
            "Please specify 1 or more of the conda artifacts to clean up (e.g., `packages=true`)."
        )
    end
    if locks
        @warn "clean --lock is no longer supported in Anaconda 4.8.0"
    end
    if sources
        @warn "clean --source-cache is no longer supported"
    end

    flags = [
        "--debug",
        "--index-cache",
        "--lock",
        "--tarballs",
        "--packages",
        "--source-cache",
    ]
    cmd = Cmd([conda, "clean", "--yes", flags[kwargs]...])
    run(_set_conda_env(cmd))
end

""""
    import_list(filename, env=ROOTENV; channels=String[])
    import_list(io, env=ROOTENV; channels=String[])

Create a new environment `env` (defaulting to `ROOTENV`)
with various channels and a packages list file `filename`
(or I/O stream `io`)
"""
function import_list(
    filepath::AbstractString,
    env::Environment=ROOTENV;
    channels=String[]
)
    channel_str = ["-c=$channel" for channel in channels]
    run(_set_conda_env(
                       `$conda create $(_quiet()) -y -p $(prefix(env)) $(Cmd(channel_str)) --file $filepath`,
        env
    ))
    # persist the channels given for this environment
    for channel in reverse(channels)
        add_channel(channel, env)
    end
end

function import_list(io::IO, args...; kwargs...)
    mktemp() do path, fobj
        write(fobj, read(io))
        close(fobj)
        import_list(path, args...; kwargs...)
    end
end

"""
    pip_interop(enabled::Bool, env::Union{AbstractString,Symbol}=ROOTENV)

Sets the `pip_interop_enabled` value to `enabled` for
the conda environment `env` (defaulting to `ROOTENV`)

If `enabled==true`, then the conda solver is allowed to
interact with non-conda-installed python packages.
"""
function pip_interop(enabled::Bool, env::Environment=ROOTENV)
    runconda(`config --set pip_interop_enabled $enabled --file $(conda_rc(env))`, env)
end

"""
    pip_interop(env::Union{AbstractString,Symbol}=ROOTENV)

Gets the `pip_interop_enabled` value from the conda config for
the conda environment `env` (defaulting to `ROOTENV`)
"""
function pip_interop(env::Environment=ROOTENV)
    dict = parseconda(`config --get pip_interop_enabled --file $(conda_rc(env))`, env)["get"]
    get(dict, "pip_interop_enabled", false)
end

function check_pip_interop(env::Environment=ROOTENV)
    pip_interop(env) || error("""
                              pip_interop is not enabled
                              Use `Conda.pip_interop(true; [env::Environment=ROOTENV])` to enable
                              """)
end

_pip() = Sys.iswindows() ? "pip.exe" : "pip"

"""
    _pip(env::Union{AbstractString,Symbol}=ROOTENV)

Return the path of the `pip` command for
the conda environment `env` (defaulting to `ROOTENV`),
installing `pip` if necessary.
"""
function _pip(env::Environment)
    "pip" ∉ _installed_packages(env) && add("pip", env)
    joinpath(script_dir(env), _pip())
end

"""
    pip(cmd::AbstractString, pkg, env::Union{AbstractString,Symbol}=ROOTENV)

Run the `pip` command `cmd` for the package(s) `pkg` in
the conda environment `env` (defaulting to `ROOTENV`).
`pkg` can be a string giving the name of a single package,
or a vector of strings giving the names of several packages.
"""
function pip(cmd::AbstractString, pkgs::PkgOrPkgs, env::Environment=ROOTENV)
    check_pip_interop(env)
    # parse the pip command
    _cmd = String[split(cmd, " ")...]
    @info("Running $(`pip $_cmd $pkgs`) in $(env==ROOTENV ? "root" : env) environment")
    run(_set_conda_env(`$(_pip(env)) $_cmd $pkgs`, env))
    nothing
end

end
