-- Utility functions for dependencies

module ("dist.depends", package.seeall)

local cfg = require "dist.config"
local mf = require "dist.manifest"
local sys = require "dist.sys"
local const = require "dist.constraints"
local utils = require "dist.utils"
local package = require "dist.package"

-- Return all packages with specified names from manifest.
-- Names can also contain version constraint (e.g. 'copas>=1.2.3', 'saci-1.0' etc.).
function find_packages(package_names, manifest)
    if type(package_names) == "string" then package_names = {package_names} end
    manifest = manifest or mf.get_manifest()
    assert(type(package_names) == "table", "depends.find_packages: Argument 'package_names' is not a table or string.")
    assert(type(manifest) == "table", "depends.find_packages: Argument 'manifest' is not a table.")

    local packages_found = {}
    -- find matching packages in manifest
    for _, pkg_to_find in pairs(package_names) do
        local pkg_name, pkg_constraint = split_name_constraint(pkg_to_find)
        pkg_name = utils.escape_magic(pkg_name):gsub("%%%*",".*")
        for _, repo_pkg in pairs(manifest) do
            if string.match(repo_pkg.name, "^" .. pkg_name .. "$") and (not pkg_constraint or satisfies_constraint(repo_pkg.version, pkg_constraint)) then
                table.insert(packages_found, repo_pkg)
            end
        end
    end
    return packages_found
end

-- Return manifest consisting of packages installed in specified deploy_dir directory
function get_installed(deploy_dir)
    deploy_dir = deploy_dir or cfg.root_dir
    assert(type(deploy_dir) == "string", "depends.get_installed: Argument 'deploy_dir' is not a string.")
    deploy_dir = sys.abs_path(deploy_dir)

    local distinfos_path = sys.make_path(deploy_dir, cfg.distinfos_dir)
    local manifest = {}

    if not sys.is_dir(distinfos_path) then return {} end

    -- from all directories of packages installed in deploy_dir
    for dir in sys.get_directory(distinfos_path) do

        if dir ~= "." and dir ~= ".." and sys.is_dir(sys.make_path(distinfos_path, dir)) then
            local pkg_dist_dir = sys.make_path(distinfos_path, dir)

            -- load the dist.info file
            for file in sys.get_directory(pkg_dist_dir) do
                local pkg_dist_file = sys.make_path(pkg_dist_dir, file)

                if sys.is_file(pkg_dist_file) then
                    table.insert(manifest, mf.load_distinfo(pkg_dist_file))
                end
            end

        end

    end
    return manifest
end

-- If 'pkg.selected' == true then returns 'selected' else 'installed'.
-- Used in error messages.
local function selected_or_installed(pkg)
    assert(type(pkg) == "table", "depends.selected_or_installed: Argument 'pkg' is not a table.")
    if pkg.selected == true then
        return "selected"
    else
        return "installed"
    end
end

-- Return whether the 'package_name' is installed according to the the manifest 'installed_pkgs'
-- If optional 'version_wanted' constraint is specified, then installed packages must
-- also satisfy specified version constraint.
-- If package is installed but doesn't satisfy version constraint, error message
-- is returned as the second value.
function is_installed(package_name, installed_pkgs, version_wanted)
    assert(type(package_name) == "string", "depends.is_installed: Argument 'package_name' is not a string.")
    assert(type(installed_pkgs) == "table", "depends.is_installed: Argument 'installed_pkgs' is not a table.")
    assert(type(version_wanted) == "string" or type(version_wanted) == "nil", "depends.is_installed: Argument 'version_wanted' is not a string or nil.")

    local pkg_is_installed, err = false, nil

    for _, installed_pkg in pairs(installed_pkgs) do

        -- check if package_name is in installed
        if package_name == installed_pkg.name then

            -- check if package is installed in satisfying version
            if not version_wanted or satisfies_constraint(installed_pkg.version, version_wanted) then
                pkg_is_installed = true
                break
            else
                err = "Package '" .. package_name .. (version_wanted and " " .. version_wanted or "") .. "' needed, but " .. selected_or_installed(installed_pkg) .. " at version '" .. installed_pkg.version .. "'."
                break
            end
        end

    end
    return pkg_is_installed, err
end

-- Check whether the package 'pkg' conflicts with 'installed_pkg' and return
-- false or error message.
local function packages_conflicts(pkg, installed_pkg, was_scm_version)
    assert(type(pkg) == "table", "depends.packages_conflicts: Argument 'pkg' is not a table.")
    assert(type(installed_pkg) == "table", "depends.packages_conflicts: Argument 'installed_pkg' is not a table.")
    assert(type(was_scm_version) == "boolean", "depends.packages_conflicts: Argument 'was_scm_version' is not a boolean.")

    -- check if pkg doesn't provide an already installed_pkg
    if pkg.provides then
        -- for all of pkg's provides
        for _, provided_pkg in pairs(get_provides(pkg)) do
            if provided_pkg.name == installed_pkg.name then
                return "Package '" .. pkg_full_name(pkg.name, pkg.version, was_scm_version) .. "' provides '" .. pkg_full_name(provided_pkg.name, provided_pkg.version) .. "' but package '" .. pkg_full_name(installed_pkg.name, installed_pkg.version) .. "' is already " .. selected_or_installed(installed_pkg) .. "."
            end
        end
    end

    -- check for conflicts of package to install with installed package
    if pkg.conflicts then
        for _, conflict in pairs (pkg.conflicts) do
            if conflict == installed_pkg.name then
                return "Package '" .. pkg_full_name(pkg.name, pkg.version, was_scm_version) .. "' conflicts with already " .. selected_or_installed(installed_pkg) .. " package '" .. pkg_full_name(installed_pkg.name, installed_pkg.version) .. "'."
            end
        end
    end

    -- check for conflicts of installed package with package to install
    if installed_pkg.conflicts then

        -- direct conflicts with 'pkg'
        for _, conflict in pairs (installed_pkg.conflicts) do
            if conflict == pkg.name then
                return "Already " .. selected_or_installed(installed_pkg) .. " package '" .. pkg_full_name(installed_pkg.name, installed_pkg.version) .. "' conflicts with package '" .. pkg_full_name(pkg.name, pkg.version, was_scm_version) .. "'."
            end
        end

        -- conflicts with 'provides' of 'pkg' (packages provided by package to install)
        if pkg.provides then
            for _, conflict in pairs (installed_pkg.conflicts) do
                -- for all of pkg's provides
                for _, provided_pkg in pairs(get_provides(pkg)) do
                    if conflict == provided_pkg.name then
                        return "Already '" .. selected_or_installed(installed_pkg) .. " package '" .. pkg_full_name(installed_pkg.name, installed_pkg.version) .. "' conflicts with package '" .. pkg_full_name(provided_pkg.name, provided_pkg.version) .. "' provided by '" .. pkg_full_name(pkg.name, pkg.version, was_scm_version) .. "'."
                    end
                end
            end
        end
    end

    -- no conflicts found
    return false
end

-- Return all packages needed in order to install package 'pkg'
-- and with specified 'installed' packages in the system using 'manifest'.
-- 'pkg' can also contain version constraint (e.g. 'copas>=1.2.3', 'saci-1.0' etc.).
--
-- This function also downloads packages to get information about their dependencies.
-- Directory where the package was downloaded is stored in 'download_dir' attribute
-- of that package in the table of packages returned by this function.
--
-- When optional 'force_no_download' parameter is set to true, then information
-- about packages won't be downloaded during dependency resolving, assuming that
-- entries in the provided manifest are already complete.
--
-- When optional 'suppress_printing' parameter is set to true, then messages
-- for the user won't be printed during dependency resolving.
--
-- Optional argument 'deploy_dir' is used just as a temporary place to place
-- the downloaded packages into.
--
-- 'dependency_parents' is table of all packages encountered so far when resolving dependencies
-- and is used to detect and deal with circular dependencies. Leave it 'nil'
-- and it will do its job just fine :-).
--
-- 'tmp_installed' is internal table used in recursion and should be left 'nil' when
-- calling this function from other context. It is used for passing the changes
-- in installed packages between the recursive calls of this function.
--
-- TODO: refactor this spaghetti code!
local function get_packages_to_install(pkg, installed, manifest, force_no_download, suppress_printing, deploy_dir, dependency_parents, tmp_installed)
    manifest = manifest or mf.get_manifest()
    force_no_download = force_no_download or false
    suppress_printing = suppress_printing or false
    deploy_dir = deploy_dir or cfg.root_dir
    dependency_parents = dependency_parents or {}

    -- set helper table 'tmp_installed'
    tmp_installed = tmp_installed or utils.deepcopy(installed)

    assert(type(pkg) == "string", "depends.get_packages_to_install: Argument 'pkg' is not a string.")
    assert(type(installed) == "table", "depends.get_packages_to_install: Argument 'installed' is not a table.")
    assert(type(manifest) == "table", "depends.get_packages_to_install: Argument 'manifest' is not a table.")
    assert(type(force_no_download) == "boolean", "depends.get_packages_to_install: Argument 'force_no_download' is not a boolean.")
    assert(type(suppress_printing) == "boolean", "depends.get_packages_to_install: Argument 'suppress_printing' is not a boolean.")
    assert(type(deploy_dir) == "string", "depends.get_packages_to_install: Argument 'deploy_dir' is not a string.")
    assert(type(dependency_parents) == "table", "depends.get_packages_to_install: Argument 'dependency_parents' is not a table.")
    assert(type(tmp_installed) == "table", "depends.get_packages_to_install: Argument 'tmp_installed' is not a table.")
    deploy_dir = sys.abs_path(deploy_dir)

    --[[ for future debugging:
    print('resolving: '.. pkg)
    print('    installed: ', utils.table_tostring(installed))
    print('    tmp_installed: ', utils.table_tostring(tmp_installed))
    --]]

    -- check if package is already installed
    local pkg_name, pkg_constraint = split_name_constraint(pkg)
    local pkg_is_installed, err = is_installed(pkg_name, tmp_installed, pkg_constraint)
    if pkg_is_installed then return {} end
    if err then return nil, err end

    -- table of packages needed to be installed (will be returned)
    local to_install = {}

    -- find out available versions of 'pkg'
    if not force_no_download then
        local versions, err = package.retrieve_versions(pkg, manifest)
        if not versions then return nil, err end
        for _, version in pairs(versions) do
            table.insert(manifest, version)
        end
    end

    -- find candidates & sort them
    local candidates_to_install = find_packages(pkg, manifest)
    if #candidates_to_install == 0 then
        return nil, "No suitable candidate for package '" .. pkg .. "' found."
    end
    candidates_to_install = sort_by_versions(candidates_to_install)

    for _, pkg in pairs(candidates_to_install) do

        --[[ for future debugging:
        print('  candidate: '.. pkg.name..'-'..pkg.version)
        print('      installed: ', utils.table_tostring(installed))
        print('      tmp_installed: ', utils.table_tostring(tmp_installed))
        print('      to_install: ', utils.table_tostring(to_install))
        print('  -is installed: ', is_installed(pkg.name, tmp_installed, pkg_constraint))
        --]]

        -- if there's an error from previous candidate, print the reason for trying another one
        if not suppress_printing and err then print(" - trying another candidate due to: " .. err) end

        -- clear the state from previous candidate
        pkg_is_installed, err = false, nil

        -- check whether this package has already been added to 'tmp_installed' by another of its candidates
        pkg_is_installed, err = is_installed(pkg.name, tmp_installed, pkg_constraint)
        if pkg_is_installed then break end

        local was_scm_version = false
        if pkg.version == "scm" then was_scm_version = true end

        -- download info about the package
        if not force_no_download then
            local path_or_err
            pkg, path_or_err = package.retrieve_pkg_info(pkg, deploy_dir)
            if not pkg then
                err = "Error when resolving dependencies: " .. path_or_err
            else
                -- set path to downloaded package - used to delete unused but downloaded
                -- packages and also to install packages selected to install
                pkg.download_dir = path_or_err
            end
        end

        -- check arch & type
        if not err then
            if not (pkg.arch == "Universal" or pkg.arch == cfg.arch) or
               not (pkg.type == "all" or pkg.type == "source" or pkg.type == cfg.type) then
                err = "Package '" .. pkg_full_name(pkg.name, pkg.version) .. "' doesn't have required arch and type."
            end
        end

        -- checks for conflicts with other installed (or previously selected) packages
        if not err then
            for _, installed_pkg in pairs(tmp_installed) do
                err = packages_conflicts(pkg, installed_pkg, was_scm_version)
                if err then break end
            end
        end

        -- if pkg passed all of the above tests
        if not err then

            -- check if pkg's dependencies are satisfied
            if pkg.depends then

                -- insert pkg into the stack of circular dependencies detection
                table.insert(dependency_parents, pkg.name)

                -- collect all OS specific dependencies of pkg
                for k, depend in pairs(pkg.depends) do

                    -- if 'depend' is a table of OS specific dependencies for
                    -- this arch, add them to the normal dependencies of pkg
                    if type(depend) == "table" then
                        if k == cfg.arch then
                            for _, os_specific_depend in pairs(depend) do
                                table.insert(pkg.depends, os_specific_depend)
                            end
                        end
                    end
                end

                -- for all dependencies of pkg
                for _, depend in pairs(pkg.depends) do

                    -- skip tables of OS specific dependencies
                    if type(depend) ~= "table" then
                        local dep_name = split_name_constraint(depend)

                        -- detect circular dependencies using 'dependency_parents'
                        local is_circular_dependency = false
                        for _, parent in pairs(dependency_parents) do
                            if dep_name == parent then
                                is_circular_dependency = true
                                break
                            end
                        end

                        -- if circular dependencies not detected
                        if not is_circular_dependency then

                            -- recursively call this function on the candidates of this pkg's dependency
                            local depends_to_install, dep_err = get_packages_to_install(depend, installed, manifest, force_no_download, suppress_printing, deploy_dir, dependency_parents, tmp_installed)

                            -- if any suitable dependency packages were found, insert them to the 'to_install' table
                            if depends_to_install then
                                for _, depend_to_install in pairs(depends_to_install) do
                                    table.insert(to_install, depend_to_install)
                                    table.insert(tmp_installed, depend_to_install)
                                    table.insert(installed, depend_to_install)
                                end
                            else
                                err = "Error getting dependency of '" .. pkg_full_name(pkg.name, pkg.version) .. "': " .. dep_err
                                break
                            end

                        -- if circular dependencies detected
                        else
                            err = "Error getting dependency of '" .. pkg_full_name(pkg.name, pkg.version) .. "': '" .. dep_name .. "' is a circular dependency."
                            break
                        end

                    end
                end

                -- remove last package from the stack of circular dependencies detection
                table.remove(dependency_parents)
            end

            -- if no error occured
            if not err then
                -- add pkg and it's provides to the fake table of installed packages, with
                -- property 'selected' set, indicating that the package isn't
                -- really installed in the system, just selected to be installed (this is used e.g. in error messages)
                pkg.selected = true
                table.insert(tmp_installed, pkg)
                if pkg.provides then
                    for _, provided_pkg in pairs(get_provides(pkg)) do
                        provided_pkg.selected = true
                        table.insert(tmp_installed, provided_pkg)
                    end
                end
                -- add pkg to the table of packages to install
                table.insert(to_install, pkg)

            -- if some error occured
            else
                -- delete the downloaded package
                if pkg.download_dir and not cfg.debug then sys.delete(pkg.download_dir) end

                -- set tables of 'packages to install' and 'installed packages' to their original state
                to_install = {}
                tmp_installed = utils.deepcopy(installed)

                -- add provided packages to installed ones
                for _, installed_pkg in pairs(tmp_installed) do
                    for _, pkg in pairs(get_provides(installed_pkg)) do
                        table.insert(tmp_installed, pkg)
                    end
                end
            end

        -- if error occured
        else
            -- delete the downloaded package
            if pkg and pkg.download_dir and not cfg.debug then sys.delete(pkg.download_dir) end

            -- if pkg is already installed, skip checking its other candidates
            if pkg_is_installed then break end
        end
    end

    -- if package is not installed and no suitable candidates were found, return the last error
    if #to_install == 0 and not pkg_is_installed then
        return nil, err
    else
        return to_install
    end
end

-- Resolve dependencies and return all packages needed in order to install
-- 'packages' into the system with already 'installed' packages, using 'manifest'.
--
-- When optional 'force_no_download' parameter is set to true, then information
-- about packages won't be downloaded during dependency resolving, assuming that
-- entries in manifest are complete.
--
-- When optional 'suppress_printing' parameter is set to true, then messages
-- for the user won't be printed during dependency resolving.
--
-- Optional argument 'deploy_dir' is used just as a temporary place to place
-- the downloaded packages into.
function get_depends(packages, installed, manifest, force_no_download, suppress_printing, deploy_dir)
    if not packages then return {} end
    manifest = manifest or mf.get_manifest()
    force_no_download = force_no_download or false
    suppress_printing = suppress_printing or false
    deploy_dir = deploy_dir or cfg.root_dir
    if type(packages) == "string" then packages = {packages} end

    assert(type(packages) == "table", "depends.get_depends: Argument 'packages' is not a table or string.")
    assert(type(installed) == "table", "depends.get_depends: Argument 'installed' is not a table.")
    assert(type(manifest) == "table", "depends.get_depends: Argument 'manifest' is not a table.")
    assert(type(force_no_download) == "boolean", "depends.get_depends: Argument 'force_no_download' is not a boolean.")
    assert(type(suppress_printing) == "boolean", "depends.get_depends: Argument 'suppress_printing' is not a boolean.")
    assert(type(deploy_dir) == "string", "depends.get_depends: Argument 'deploy_dir' is not a string.")
    deploy_dir = sys.abs_path(deploy_dir)

    local tmp_installed = utils.deepcopy(installed)

    -- add provided packages to installed ones
    for _, installed_pkg in pairs(tmp_installed) do
        for _, pkg in pairs(get_provides(installed_pkg)) do
            table.insert(tmp_installed, pkg)
        end
    end

    local to_install = {}

    -- get packages needed to to satisfy dependencies
    for _, pkg in pairs(packages) do

        local needed_to_install, err = get_packages_to_install(pkg, tmp_installed, manifest, force_no_download, suppress_printing, deploy_dir)

        if needed_to_install then
            for _, needed_pkg in pairs(needed_to_install) do
                table.insert(to_install, needed_pkg)
                table.insert(tmp_installed, needed_pkg)
                -- add provides of needed_pkg to installed ones
                for _, provided_pkg in pairs(get_provides(needed_pkg)) do
                    -- copy 'selected' property
                    provided_pkg.selected = needed_pkg.selected
                    table.insert(tmp_installed, provided_pkg)
                end
            end
        else
            -- delete already downloaded packages
            for _, pkg in pairs(to_install) do
                if pkg.download_dir and not cfg.debug then sys.delete(pkg.download_dir) end
            end
            return nil, "Cannot install package '" .. pkg .. "': ".. err
        end
    end

    return to_install
end

-- Return table of packages provided by specified package (from it's 'provides' field)
function get_provides(package)
    assert(type(package) == "table", "depends.get_provides: Argument 'package' is not a table.")
    if not package.provides then return {} end

    local provided = {}
    for _, provided_name in pairs(package.provides) do
        local pkg = {}
        pkg.name, pkg.version = split_name_constraint(provided_name)
        pkg.type = package.type
        pkg.arch = package.arch
        pkg.provided = package.name .. "-" .. package.version
        table.insert(provided, pkg)
    end
    return provided
end

-- Return package name and version constraint from full package version constraint specification
-- E. g.:
--          for 'luaexpat-1.2.3'  return:  'luaexpat' , '1.2.3'
--          for 'luajit >= 1.2'   return:  'luajit'   , '>=1.2'
function split_name_constraint(version_constraint)
    assert(type(version_constraint) == "string", "depends.split_name_constraint: Argument 'version_constraint' is not a string.")

    local split = version_constraint:find("[%s=~<>-]+%d") or version_constraint:find("[%s=~<>-]+scm")

    if split then
        return version_constraint:sub(1, split - 1), version_constraint:sub(split):gsub("[%s-]", "")
    else
        return version_constraint, nil
    end
end

-- Return only packages that can be installed on the specified architecture and type
function filter_packages_by_arch_and_type(packages, req_arch, req_type)
    assert(type(packages) == "table", "depends.filter_packages_by_arch_and_type: Argument 'packages' is not a table.")
    assert(type(req_arch) == "string", "depends.filter_packages_by_arch_and_type: Argument 'req_arch' is not a string.")
    assert(type(req_type) == "string", "depends.filter_packages_by_arch_and_type: Argument 'pkg_type' is not a string.")

    return utils.filter(packages,
                        function (pkg)
                                return (pkg.arch == "Universal" or pkg.arch == req_arch) and
                                        (pkg.type == "all" or pkg.type == "source" or pkg.type == req_type)
                                end)
end

-- Return only packages that contain one of the specified strings in their 'name-version'.
-- Case is ignored. If no strings are specified, return all the packages.
-- Argument 'search_in_desc' specifies if search also in description of packages.
function filter_packages_by_strings(packages, strings, search_in_desc)
    if type(strings) == "string" then strings = {strings} end
    assert(type(packages) == "table", "depends.filter_packages_by_strings: Argument 'packages' is not a table.")
    assert(type(strings) == "table", "depends.filter_packages_by_strings: Argument 'strings' is not a string or table.")

    if #strings ~= 0 then
        return utils.filter(packages,
                            function (pkg)
                                    for _,str in pairs(strings) do
                                        local name = pkg.name .. "-" .. pkg.version
                                        if search_in_desc then
                                            name = name .. " " .. (pkg.desc or "")
                                        end
                                        if string.find(string.lower(name), string.lower(str), 1 ,true) ~= nil then return true end
                                    end
                                end)
    else
        return packages
    end
end


-- Return full package name and version string (e.g. 'luajit-2.0'). When version
-- is nil or '' then return only name (e.g. 'luajit') and when name is nil or ''
-- then return '<unknown>'. Optional 'was_scm_version' argument is a boolean,
-- stating whether the package was originally selected for installation as a 'scm' version.
function pkg_full_name(name, version, was_scm_version)
    name = name or ""
    version = version or ""
    if type(version) == "number" then version = tostring(version) end

    assert(type(name) == "string", "depends.pkg_full_name: Argument 'name' is not a string.")
    assert(type(version) == "string", "depends.pkg_full_name: Argument 'version' is not a string.")

    if was_scm_version then version = version .. " [scm version]" end

    if name == "" then
        return "<unknown>"
    else
        return name .. ((version ~= "") and "-" .. version or "")
    end
end

-- Return table of packages, sorted descendingly by versions (newer ones are moved to the top).
function sort_by_versions(packages)
    assert(type(packages) == "table", "depends.sort_by_versions: Argument 'packages' is not a table.")
    return utils.sort(packages, function (a, b) return compare_versions(a.version, b.version) end)
end

-- Return table of packages, sorted alphabetically by name and then descendingly by version.
function sort_by_names(packages)
    assert(type(packages) == "table", "depends.sort_by_names: Argument 'packages' is not a table.")
    return utils.sort(packages, function (a, b)
                        if a.name == b.name then
                            return compare_versions(a.version, b.version)
                        else
                            return a.name < b.name
                        end
                     end)
end

-- Return if version satisfies the specified constraint
function satisfies_constraint(version, constraint)
    assert(type(version) == "string", "depends.satisfies_constraint: Argument 'version' is not a string.")
    assert(type(constraint) == "string", "depends.satisfies_constraint: Argument 'constraint' is not a string.")
    return const.constraint_satisfied(version, constraint)
end

-- For package versions, return whether: 'version_a' > 'version_b'
function compare_versions(version_a, version_b)
    assert(type(version_a) == "string", "depends.compare_versions: Argument 'version_a' is not a string.")
    assert(type(version_b) == "string", "depends.compare_versions: Argument 'version_b' is not a string.")
    return const.compareVersions(version_a, version_b)
end
