--!A cross-platform build utility based on Lua
--
-- Licensed under the Apache License, Version 2.0 (the "License");
-- you may not use this file except in compliance with the License.
-- You may obtain a copy of the License at
--
--     http://www.apache.org/licenses/LICENSE-2.0
--
-- Unless required by applicable law or agreed to in writing, software
-- distributed under the License is distributed on an "AS IS" BASIS,
-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-- See the License for the specific language governing permissions and
-- limitations under the License.
--
-- Copyright (C) 2015-present, Xmake Open Source Community.
--
-- @author      ruki
-- @file        download.lua
--

-- imports
import("core.base.option")
import("core.base.global")
import("core.base.tty")
import("core.base.hashset")
import("core.project.config")
import("core.package.package", {alias = "core_package"})
import("lib.detect.find_file")
import("lib.detect.find_directory")
import("private.action.require.impl.check_api")
import("private.action.require.impl.utils.filter")
import("private.action.require.impl.utils.url_filename")
import("net.http")
import("net.proxy")
import("devel.git")
import("utils.archive")

-- get url extension
function _url_extension(url)
    local extension = archive.extension(url)
    if extension == "" then
        -- maybe non-archive file, e.g. .exe, .sh, ..
        local urlpath = url:split('?', {plain = true})[1]
        extension = path.extension(urlpath)
    end
    return extension
end

-- checkout codes from git
function _checkout(package, url, sourcedir, opt)
    opt = opt or {}
    local filename = opt.url_filename or url_filename(url)
    local sparse_includes = opt.url_includes
    local clone_submodules = opt.url_submodules ~= false

    -- we need to enable longpaths on windows
    local longpaths = package:policy("platform.longpaths")

    -- use previous source directory if exists
    local packagedir = path.join(sourcedir, package:name())
    if os.isdir(path.join(packagedir, ".git")) and
        not (option.get("force") and package:branch()) then -- we need disable cache if we force to clone from the given branch

        -- clean the previous build files
        git.clean({repodir = packagedir, force = true, all = true})
        -- reset the previous modified files
        git.reset({repodir = packagedir, hard = true})
        -- clean and reset submodules
        if os.isfile(path.join(packagedir, ".gitmodules")) then
            git.submodule.clean({repodir = packagedir, force = true, all = true})
            git.submodule.reset({repodir = packagedir, hard = true, longpaths = longpaths})
        end
        tty.erase_line_to_start().cr()
        return
    end

    -- we can use local package from the search directories directly if network is too slow
    local localdir
    local searchnames = {package:name() .. archive.extension(url),
                         path.basename(filename)}
    for _, searchname in ipairs(searchnames) do
        localdir = find_directory(searchname, core_package.searchdirs())
        if localdir then
            break
        end
    end
    if localdir and os.isdir(path.join(localdir, ".git")) then
        git.clean({repodir = localdir, force = true, all = true})
        git.reset({repodir = localdir, hard = true})
        if os.isfile(path.join(localdir, ".gitmodules")) then
            git.submodule.clean({repodir = localdir, force = true, all = true})
            git.submodule.reset({repodir = localdir, hard = true, longpaths = longpaths})
        end
        os.cp(localdir, packagedir)
        tty.erase_line_to_start().cr()
        return
    end

    -- remove temporary directory
    os.rm(sourcedir .. ".tmp")

    -- download package from branches?
    packagedir = path.join(sourcedir .. ".tmp", package:name())
    local branch = package:branch()
    if branch then

        -- we need to select the correct default branch
        -- @see https://github.com/xmake-io/xmake/issues/3248
        if branch == "@default" then
            branch = nil
        end

        -- only shallow clone this branch
        git.clone(url, {depth = 1, recursive = clone_submodules, shallow_submodules = clone_submodules, longpaths = longpaths, branch = branch, outputdir = packagedir})

    -- download package from revision or tag?
    else

        -- get tag and revision?
        local tag
        local revision = package:revision(opt.url_alias)
        if revision and (#revision ~= 40 or not revision:match("%w+")) then
            tag = revision
        end
        if not tag then
            tag = package:tag()
        end
        if not revision then
            revision = tag or package:commit() or package:version_str()
        end

        -- only shallow clone this tag
        -- @see https://github.com/xmake-io/xmake/issues/4151
        if tag and git.support.can_clone_tag() and not sparse_includes then
            git.clone(url, {depth = 1, recursive = clone_submodules, shallow_submodules = clone_submodules, longpaths = longpaths, branch = tag, outputdir = packagedir})
        else

            -- clone whole history and tags
            -- @see https://github.com/xmake-io/xmake/issues/5507
            git.clone(url, {treeless = true, checkout = false, longpaths = longpaths, outputdir = packagedir})

            -- attempt to checkout the given version
            git.checkout(revision, {repodir = packagedir, includes = sparse_includes})

            -- update all submodules
            if os.isfile(path.join(packagedir, ".gitmodules")) and clone_submodules then
                git.submodule.update({init = true, recursive = true, longpaths = longpaths, repodir = packagedir})
            end
        end
    end

    -- move to source directory
    os.rm(sourcedir)
    os.mv(sourcedir .. ".tmp", sourcedir)

    -- trace
    tty.erase_line_to_start().cr()
    cprint("${yellow}  => ${clear}clone %s %s .. ${color.success}${text.success}", url, package:version_str())
end

-- download codes from ftp/http/https
function _download(package, url, sourcedir, opt)
    opt = opt or {}

    -- get package file
    local packagefile = opt.url_filename
    if not packagefile then
        packagefile = url_filename(url)
        if not os.isfile(packagefile) then -- we need to be compatible with the old file names
            packagefile = package:name() .. "-" .. package:version_str() .. _url_extension(packagefile)
        end
    end

    -- get sourcehash from the given url
    --
    -- we don't need sourcehash and skip checksum to try download it directly if no version list in package()
    -- @see https://github.com/xmake-io/xmake/issues/930
    -- https://github.com/xmake-io/xmake/issues/1009
    --
    local sourcehash = package:sourcehash(opt.url_alias)
    assert(not package:is_verify() or not package:get("versions") or sourcehash, "cannot get source hash of %s in package(%s)", url, package:name())

    -- the package file have been downloaded?
    local cached = true
    if not os.isfile(packagefile) or sourcehash ~= hash.sha256(packagefile) then

        -- no cached
        cached = false

        -- attempt to remove package file first
        os.tryrm(packagefile)

        -- download or copy package file
        if os.isfile(url) then
            os.cp(url, packagefile)
        else
            local localfile
            local searchnames = {package:name() .. "-" .. package:version_str() .. _url_extension(url),
                                 packagefile}

            -- match github name mangling https://github.com/xmake-io/xmake/issues/1343
            local github_name = url_filename.github_filename(url)
            if github_name then
                table.insert(searchnames, github_name)
            end

            for _, searchname in ipairs(searchnames) do
                localfile = find_file(searchname, core_package.searchdirs())
                if localfile then
                    break
                end
            end
            if localfile and os.isfile(localfile) then
                -- we can use local package from the search directories directly if network is too slow
                os.cp(localfile, packagefile)
            else
                http.download(url, packagefile, {
                    insecure = global.get("insecure-ssl"),
                    insecure_fallback = true, -- retry without ssl verification on cert error, the file is verified by sha256 below
                    headers = opt.url_http_headers or package:policy("package.download.http_headers")})
            end
        end

        -- check hash
        if sourcehash and sourcehash ~= hash.sha256(packagefile) then
            if package:is_precompiled() then
                wprint("perhaps the local binary repository is not up to date, please run `xrepo update-repo` to update it and try again!")
            end
            raise("unmatched checksum, current hash(%s) != original hash(%s)", hash.sha256(packagefile):sub(1, 8), sourcehash:sub(1, 8))
        end
    end

    -- we do not extract it if we download only it.
    if opt.download_only then
        tty.erase_line_to_start().cr()
        if cached then
            cprint("${yellow}  => ${clear}download %s .. ${color.success}cached", url)
        else
            cprint("${yellow}  => ${clear}download %s .. ${color.success}${text.success}", url)
        end
        return
    end

    -- extract package file
    local sourcedir_tmp = sourcedir .. ".tmp"
    os.rm(sourcedir_tmp)
    local extension = archive.extension(packagefile)
    local errors
    local ok = try {
        function()
            archive.extract(packagefile, sourcedir_tmp, {excludes = opt.url_excludes})
            return true
        end,
        catch {
            function (errs)
                if errs then
                    errors = tostring(errs)
                end
            end
        }
    }
    if ok then
        -- move to source directory and we skip it to avoid long path issues on windows if only one root directory
        os.rm(sourcedir)
        local filedirs = os.filedirs(path.join(sourcedir_tmp, "*"))
        if #filedirs == 1 and os.isdir(filedirs[1]) then
            os.mv(filedirs[1], sourcedir)
            -- we need to anchor it to avoid expand it when installing package
            io.writefile(path.join(sourcedir, "__sourceroot_anchor__.txt"), "")
            os.rm(sourcedir_tmp)
        else
            os.mv(sourcedir_tmp, sourcedir)
        end
        -- mark this sourcedir as cleanable
        if not package:has_source() then
            package:data_set("cleanable_sourcedir", path.absolute(sourcedir))
        end
    elseif extension and extension ~= "" then
        -- create an empty source directory if do not extract package file
        os.tryrm(sourcedir)
        os.mkdir(sourcedir)
        raise(errors or string.format("cannot extract %s, maybe missing extractor or invalid package file!", packagefile))
    else
        -- if it is not archive file, we only need to create empty source directory and use package:originfile()
        -- e.g. .exe, .sh
        os.tryrm(sourcedir)
        os.mkdir(sourcedir)
    end

    -- save original file path
    package:originfile_set(path.absolute(packagefile))

    -- trace
    tty.erase_line_to_start().cr()
    if not cached then
        cprint("${yellow}  => ${clear}download %s .. ${color.success}${text.success}", url)
    end
end

-- download codes from script
function _download_from_script(package, script, opt)
    script(package, opt)
    tty.erase_line_to_start().cr()
    cprint("${yellow}  => ${clear}download %s .. ${color.success}${text.success}", opt.url)
end

-- get sorted urls
function _urls(package)

    -- sort urls from the version source
    local urls = {{}, {}}
    for _, url in ipairs(package:urls()) do
        if git.checkurl(url) then
            table.insert(urls[1], url)
        elseif not package:is_verify() or not package:get("versions") or package:sourcehash(package:url_alias(url)) then
            table.insert(urls[2], url)
        end
    end
    if package:gitref() then
        return table.join(urls[1], urls[2])
    else
        return table.join(urls[2], urls[1])
    end
end

-- download the given package
-- download the package source
--
-- @param package  the package instance
-- @param opt      the options
--
-- download the package source
--
-- @param package  the package instance
-- @param opt      the options
--
function main(package, opt)
    opt = opt or {}

    -- get working directory of this package
    local workdir = opt.outputdir or package:cachedir()

    -- ensure the working directory first
    os.mkdir(workdir)

    -- enter the working directory
    local oldir = os.cd(workdir)

    -- lock this package
    package:lock()

    -- get urls
    local urls = _urls(package)
    assert(#urls > 0, "cannot get url of package(%s)", package:name())

    -- download package from urls
    local ok = false
    local urls_failed = {}
    for idx, url in ipairs(urls) do
        local url = url
        local url_alias = package:url_alias(url)
        local url_excludes = package:url_excludes(url)
        local url_includes = package:url_includes(url)
        local url_http_headers = package:url_http_headers(url)
        local url_submodules = package:extraconf("urls", url, "submodules")
        local url_filename_custom = package:extraconf("urls", url, "filename")

        -- filter url
        url = filter.handle(url, package)

        -- use proxy url?
        if not os.isfile(url) then
            url = proxy.mirror(url) or url
        end

        -- download url
        local allerrors = {}
        ok = try
        {
            function ()

                -- use debug source directory directly?
                local debugdir = package:is_toplevel() and option.get("debugdir") or nil
                if debugdir then
                    package:set("sourcedir", debugdir)
                    tty.erase_line_to_start().cr()
                    return true
                end

                -- download package
                local sourcedir = "source"
                local script = package:script("download")
                if script then
                    _download_from_script(package, script, {
                        download_only = opt.download_only,
                        sourcedir = sourcedir,
                        url = url,
                        url_alias = url_alias,
                        url_excludes = url_excludes,
                        url_includes = url_includes,
                        url_submodules = url_submodules,
                        url_filename = url_filename_custom})
                elseif git.checkurl(url) then
                    _checkout(package, url, sourcedir, {
                        url_alias = url_alias,
                        url_includes = url_includes,
                        url_submodules = url_submodules,
                        url_filename = url_filename_custom})
                else
                    _download(package, url, sourcedir, {
                        download_only = opt.download_only,
                        url_alias = url_alias,
                        url_excludes = url_excludes,
                        url_includes = url_includes,
                        url_http_headers = url_http_headers,
                        url_filename = url_filename_custom})
                end
                return true
            end,
            catch
            {
                function (errors)

                    check_api(package, {download_failure = true})

                    -- show or save the last errors
                    if errors then
                        if (option.get("verbose") or option.get("diagnosis")) then
                            cprint("${dim color.error}error: ${clear}%s", errors)
                        else
                            table.insert(allerrors, errors)
                        end
                    end

                    -- trace
                    tty.erase_line_to_start().cr()
                    if git.checkurl(url) then
                        cprint("${yellow}  => ${clear}clone %s %s .. ${color.failure}${text.failure}", url, package:version_str())
                    else
                        cprint("${yellow}  => ${clear}download %s .. ${color.failure}${text.failure}", url)
                    end
                    table.insert(urls_failed, url)

                    -- failed? break it
                    if idx == #urls and not package:is_optional() then
                        if #urls_failed > 0 then
                            print("")
                            print("we can also download these packages manually:")
                            local searchnames = hashset.new()
                            for _, url_failed in ipairs(urls_failed) do
                                cprint("  ${yellow}- %s", url_failed)
                                if git.checkurl(url_failed) then
                                    searchnames:insert(package:name() .. archive.extension(url_failed))
                                    searchnames:insert(path.basename(url_filename(url_failed)))
                                else
                                    local extension = _url_extension(url_failed)
                                    if extension then
                                        searchnames:insert(package:name() .. "-" .. package:version_str() .. extension)
                                    end
                                    searchnames:insert(url_filename(url_failed))
                                end
                            end
                            cprint("to the local search directories: ${bright}%s", table.concat(table.wrap(core_package.searchdirs()), path.envsep()))
                            cprint("  ${bright}- %s", table.concat(searchnames:to_array(), ", "))
                            cprint("and we can run `xmake g --pkg_searchdirs=/xxx` to set the search directories.")
                        end
                        if #allerrors then
                            raise(table.concat(allerrors, "\n"))
                        else
                            raise("download failed!")
                        end
                    end
                end
            }
        }

        if ok then
            break
        end
    end

    -- unlock this package
    package:unlock()

    -- leave working directory
    os.cd(oldir)
    return ok
end
