--!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        install.lua
--

-- imports
import("core.base.option")
import("core.base.tty")
import("core.package.package", {alias = "core_package"})
import("core.project.target")
import("core.project.project")
import("core.platform.platform")
import("lib.detect.find_file")
import("utils.archive.merge_staticlib")
import("private.tools.ccache")
import("private.action.require.impl.actions.test")
import("private.action.require.impl.actions.patch_sources")
import("private.action.require.impl.actions.download_resources")
import("private.action.require.impl.utils.filter")

-- patch pkgconfig if not exists
function _patch_pkgconfig(package)

    -- only binary? need not pkgconfig
    if not package:is_library() then
        return
    end

    local installdir = path.unix(path.normalize(package:installdir()))

    -- get lib/pkgconfig/*.pc or share/pkgconfig/*.pc file
    local libpkgconfigdir = path.join(package:installdir(), "lib", "pkgconfig")
    local sharepkgconfigdir = path.join(package:installdir(), "share", "pkgconfig")
    local pcfiles = {}
    table.join2(pcfiles, os.isdir(libpkgconfigdir) and os.files(path.join(libpkgconfigdir, "*.pc")) or {})
    table.join2(pcfiles, os.isdir(sharepkgconfigdir) and os.files(path.join(sharepkgconfigdir, "*.pc")) or {})
    if #pcfiles > 0 then
        for _, pcfile in ipairs(pcfiles) do
            local pcfile_content = io.readfile(pcfile)
            if pcfile_content then
                local pcfile_content, count = pcfile_content:replace(installdir, "${installdir}", {plain = true})
                if count > 0 then
                    local line_ending = pcfile_content:find("\r\n") and "\r\n" or "\n"
                    pcfile_content = "# Modified by Xmake: Using relative paths to make package relocatable" .. line_ending
                                      .. "installdir=${pcfiledir}/" .. path.unix(path.relative(installdir, path.directory(pcfile))) .. line_ending
                                      .. pcfile_content
                    io.writefile(pcfile, pcfile_content)
                end
            end
        end
        return
    end

    -- trace
    local pcfile = path.join(libpkgconfigdir, package:name() .. ".pc")
    vprint("patching %s ..", pcfile)

    -- fetch package
    local fetchinfo = package:fetch_librarydeps()
    if not fetchinfo then
        return
    end

    -- get libs
    local libs = ""
    for _, linkdir in ipairs(fetchinfo.linkdirs) do
        linkdir = path.unix(path.normalize(linkdir)):replace(installdir, "${exec_prefix}", {plain = true})
        if linkdir ~= "${exec_prefix}/lib" then
            libs = libs .. " -L" .. linkdir
        end
    end
    libs = libs .. " -L${libdir}"
    for _, link in ipairs(fetchinfo.links) do
        libs = libs .. " -l" .. link
    end
    for _, link in ipairs(fetchinfo.syslinks) do
        libs = libs .. " -l" .. link
    end

    -- cflags
    local cflags = ""
    for _, includedir in ipairs(fetchinfo.includedirs or fetchinfo.sysincludedirs) do
        includedir = path.unix(path.normalize(includedir)):replace(installdir, "${prefix}", {plain = true})
        if includedir ~= "${prefix}/include" then
            cflags = cflags .. " -I" .. includedir
        end
    end
    cflags = cflags .. " -I${includedir}"
    for _, define in ipairs(fetchinfo.defines) do
        cflags = cflags .. " -D" .. define
    end

    -- patch a *.pc file
    local file = io.open(pcfile, 'w')
    if file then
        -- @see https://github.com/xmake-io/xmake-repo/pull/8165#discussion_r2366249416
        local version = package:version_str():ltrim("v")
        file:print("# Generated by Xmake")
        file:print("prefix=%s", "${pcfiledir}/" .. path.unix(path.relative(installdir, path.directory(pcfile))))
        file:print("exec_prefix=${prefix}")
        file:print("libdir=${exec_prefix}/lib")
        file:print("includedir=${prefix}/include")
        file:print("")
        file:print("Name: %s", package:name())
        file:print("Description: %s", package:description())
        file:print("Version: %s", version)
        file:print("Libs: %s", libs)
        file:print("Libs.private: ")
        file:print("Cflags: %s", cflags)
        file:close()
    end
end

-- Match to path like (string insides brackets is matched):
--     /home/user/.xmake/packages[/f/foo/v0.1.0/9adc96bd69124211aad7dd58a36f02ce]/lib
local _PACKAGE_VERSION_BUILDHASH_PATTERN = "[\\/]%w[\\/][^\\/]+[\\/][^\\/]+[\\/]" .. string.rep('%x', 32)

function _fix_path_for_file(file, search_pattern)
    -- Replace path string before package pattern with local package install
    -- directory.
    -- Note: It's possible that package A references files in package B, thus we
    -- need to match against all possible package install paths.
    --
    -- search_pattern should contain a whole and a sub capture.
    -- The sub capture will be replaced with local install path.
    -- The whole capture is to make the search more precise and less likely to
    -- match non package path.
    local prefix = core_package.installdir()

    io.gsub(file, search_pattern, function(whole_value, value)
        local mat = value:match(_PACKAGE_VERSION_BUILDHASH_PATTERN)
        if not mat then
            return nil
        end

        local result
        local splitinfo = value:split(mat, {plain = true})
        if #splitinfo == 2 then
            -- /home/user/packages[/f/foo/buildhash]/v1.0
            result = path.join(prefix, mat, splitinfo[2])
        elseif #splitinfo == 1 then
            if value:startswith(mat) then
                -- path begins with matched pattern: [/f/foo/buildhash]/v1.0
                result = path.join(prefix, value)
            else
                -- path ends with matched pattern: /home/user/packages[/f/foo/buildhash]
                result = path.join(prefix, mat)
            end
        else
            vprint("fix path split got more than 2 parts, something wrong?", whole_value)
        end
        if result then
            result = result:gsub("\\", "/")
            vprint("fix path: %s in %s", whole_value, file)
            return whole_value:replace(value, result, {plain = true})
        end
    end)
end

-- fix paths for the precompiled package
-- @see https://github.com/xmake-io/xmake/issues/1671
function _fix_paths_for_precompiled_package(package)
    local patterns = {
        {
            -- Fix path for cmake files.
            -- "|include/**" means exclude all files under include directory.
            -- Their are quite a few search paths used by cmake, so just look
            -- for all ".cmake" files for most reliable result.
            -- https://cmake.org/cmake/help/latest/command/find_package.html#config-mode-search-procedure
            file_pattern = {"**.cmake|include/**"},
            search_pattern = {'("(.-)")'},
        },
        {
            -- Fix path for pkg-config .pc files.
            -- 1. `varname=value` defines a variable, which may contain path.
            -- 2. A package may reference another package with absolute path.
            --    For example: glog.pc with gflags and unwind enabled contains something like following:
            --        Libs: -L/absolute/path/to/gflags/lib -L /absolute/path/to/libunwind/lib ...
            --    So searching for only prefix is not enough.
            -- 3. If path contains spaces, it should be double quoted.
            --    If not quoted, spaces should be backslash escaped, which we do
            --    not fix for now.
            --    For pkg-config behavior for spaces in path, refer to
            --    https://github.com/golang/go/issues/16455#issuecomment-255900404
            file_pattern = {"lib/pkgconfig/**.pc", "share/pkgconfig/**.pc"},
            search_pattern = {"([%w_]+%s*=%s*(.-)\n)", "(%-[I|L]%s*(%S+))", '("(.-)")'},
        },
    }

    -- If artifact contains installdir where it's built (remotedir), extract
    -- path prefix and do plain replace with local install dir.
    local remotedir
    local manifest = package:manifest_load()
    if manifest and manifest.artifacts then
        remotedir = manifest.artifacts.remotedir
    end

    local remote_prefix
    local local_prefix
    if remotedir then
        local idx = remotedir:find(_PACKAGE_VERSION_BUILDHASH_PATTERN)
        if idx then
            remote_prefix = remotedir:sub(1, idx)
            local_prefix = core_package.installdir()
            if not local_prefix:endswith(path.sep()) then
                local_prefix = local_prefix .. path.sep()
            end
        else
            wprint("no package buildhash pattern found in artifacts remotedir: %s", remotedir)
        end
    end

    for _, pat in ipairs(patterns) do
        for _, filepat in ipairs(pat.file_pattern) do
            local filepattern = path.join(package:installdir(), filepat)
            for _, file in ipairs(os.files(filepattern)) do
                if remote_prefix then
                    local _, count = io.replace(file, remote_prefix, local_prefix, {plain = true})
                    -- maybe we need to translate path seperator
                    -- @see https://github.com/xmake-io/xmake/discussions/3008
                    if count == 0 and is_host("windows") then
                        io.replace(file, (remote_prefix:gsub("\\", "/")), local_prefix:gsub("\\", "/"), {plain = true})
                    end
                else
                    for _, search_pattern in ipairs(pat.search_pattern) do
                        _fix_path_for_file(file, search_pattern)
                    end
                end
            end
        end
    end
end

-- merge static libraries
-- @see https://github.com/xmake-io/xmake/issues/5894
function _merge_staticlibs(package)
    local merge_staticlibs = project.policy("package.merge_staticlibs")
    if merge_staticlibs == nil then
        merge_staticlibs = package:policy("package.merge_staticlibs")
    end
    if merge_staticlibs and package:is_library()
        and not package:config("shared") and not package:is_headeronly() and not package:is_moduleonly() then
        local installdir = package:installdir()
        local linkdirs = table.wrap(package:get("linkdirs") or "lib")
        local libfiles = {}
        for _, linkdir in ipairs(linkdirs) do
            for _, libfile in ipairs(os.files(path.join(installdir, linkdir, "*"))) do
                if libfile:endswith(".lib") or libfile:endswith(".a") then
                    table.insert(libfiles, libfile)
                end
            end
        end
        if #libfiles > 0 then
            local linkdir = linkdirs[1]
            local linkname = package:name()
            local opt = {plat = package:plat(), arch = package:arch()}
            local libfile_new = path.join(installdir, linkdir, target.filename(linkname, "static", opt))

            merge_staticlib(package, libfile_new, libfiles)
            package:set("links", linkname)
            for _, libfile in ipairs(libfiles) do
                if libfile ~= libfile_new then
                    os.rm(libfile)
                end
            end
        end
    end
end

-- get failed install directory
function _get_installdir_failed(package)
    return path.join(package:cachedir(), "installdir.failed")
end

-- clear install directory
function _clear_installdir(package)
    os.tryrm(package:installdir())
    os.tryrm(_get_installdir_failed(package))
end

-- clear source directory
function _clear_sourcedir(package)
    local sourcedir = package:data("cleanable_sourcedir")
    if sourcedir then
        os.tryrm(sourcedir)
    end
end

-- enter working directory
function _enter_workdir(package)

    -- get working directory of this package
    local workdir = package:cachedir()

    -- lock this package
    package:lock()

    -- enter directory
    local oldir = nil
    local sourcedir = package:sourcedir()
    if sourcedir then
        oldir = os.cd(sourcedir)
    elseif #package:urls() > 0 then
        -- only one root directory? skip it
        local anchorfile = path.join(workdir, "source", "__sourceroot_anchor__.txt")
        local filedirs = os.filedirs(path.join(workdir, "source", "*"))
        if not os.isfile(anchorfile) and #filedirs == 1 and os.isdir(filedirs[1]) then
            oldir = os.cd(filedirs[1])
        else
            oldir = os.cd(path.join(workdir, "source"))
        end
    end
    if not oldir then
        os.mkdir(workdir)
        oldir = os.cd(workdir)
    end

    -- we need to copy source codes to the working directory with short path on windows
    --
    -- Because the target name and source file path of this project are too long,
    -- it's absolute path exceeds the windows path length limit.
    --
    if is_host("windows") and package:policy("platform.longpaths") then
        local sourcedir_tmp = os.tmpdir() .. ".dir"
        os.tryrm(sourcedir_tmp)
        os.cp(os.curdir(), sourcedir_tmp)
        os.cd(sourcedir_tmp)
    end

    return oldir
end

-- leave working directory
function _leave_workdir(package, oldir)

    -- clean the empty package directory
    local installdir = package:installdir()
    if os.emptydir(installdir) then
        os.tryrm(installdir)
    end

    -- unlock this package
    package:unlock()

    -- leave source codes directory
    if oldir then
        os.cd(oldir)
    end

    -- clean source directory if it is no longer needed
    _clear_sourcedir(package)
end

-- enter package install environments
function _enter_package_installenvs(package)
    for _, dep in ipairs(package:orderdeps()) do
        dep:envs_enter()
    end
end

-- enter package test environments
function _enter_package_testenvs(package)

    -- add compiler runtime library directory to $PATH
    -- @see https://github.com/xmake-io/xmake-repo/pull/3606
    if is_host("windows") and package:is_plat("windows", "mingw") then -- bin/*.dll for windows
        local toolchains = package:toolchains()
        if not toolchains then
            local platform_inst = platform.load(package:plat(), package:arch())
            toolchains = platform_inst:toolchains()
            for _, toolchain_inst in ipairs(toolchains) do
                if toolchain_inst:check() then
                    local runenvs = toolchain_inst:runenvs()
                    if runenvs and runenvs.PATH then
                        local envs = {PATH = runenvs.PATH}
                        os.addenvs(envs)
                    end
                end
            end
        end
    end

    -- enter package environments
    for _, dep in ipairs(package:orderdeps()) do
        dep:envs_enter()
    end
    package:envs_enter()
end

function _enable_ccache(package)
    if package:is_local() then
        return
    end

    if not project.policy("package.build.ccache") then
        return
    end

    local ccache = ccache.get()
    if ccache then
        local name = path.basename(ccache.program)
        package:data_set("ccache", name)
        local ccache_dir = path.join(path.directory(package:cachedir()), name)
        os.setenv(name:upper() .. "_DIR", ccache_dir)
    end
end


function main(package)

    -- enter working directory
    local oldir = _enter_workdir(package)

    -- init tipname
    local tipname = package:name()
    if package:version_str() then
        tipname = tipname .. "-" .. package:version_str()
    end

    -- install it
    local ok = true
    local oldenvs = os.getenvs()
    try
    {
        function ()

            -- install the third-party package directly, e.g. brew::pcre2/libpcre2-8, conan::OpenSSL/1.0.2n@conan/stable
            local installed_now = false
            local script = package:script("install")
            if package:is_thirdparty() then
                if script ~= nil then
                    filter.call(script, package)
                end
            else

                -- build and install package to the install directory
                local force_reinstall = package:policy("package.install_always") or package:data("force_reinstall") or option.get("force")
                if force_reinstall or not package:manifest_load() then

                    -- clear install directory
                    _clear_installdir(package)

                    -- download package resources
                    download_resources(package)

                    -- patch source codes of package
                    patch_sources(package)

                    -- enter the environments of all package dependencies
                    _enter_package_installenvs(package)

                    -- set package ccache dir
                    _enable_ccache(package)

                    -- do install
                    if script ~= nil then
                        filter.call(script, package, {oldenvs = oldenvs})
                    end

                    -- install rules
                    local rulesdir = package:rulesdir()
                    if rulesdir and os.isdir(rulesdir) then
                        os.cp(rulesdir, package:installdir())
                    end

                    -- merge static libraries
                    _merge_staticlibs(package)

                    -- leave the environments of all package dependencies
                    os.setenvs(oldenvs)

                    -- save the package info to the manifest file
                    package:manifest_save()
                    installed_now = true
                end
            end

            -- enter the package environments
            _enter_package_testenvs(package)

            -- fetch package and force to flush the cache
            local fetchinfo = package:fetch({force = true})
            if option.get("verbose") or option.get("diagnosis") then
                print(fetchinfo)
            end
            assert(fetchinfo, "fetch %s failed!", tipname)

            -- this package is installed now
            if installed_now then

                -- fix paths for the precompiled package
                if package:is_precompiled() and not package:is_system() then
                    _fix_paths_for_precompiled_package(package)
                end

                -- patch pkg-config files for package
                _patch_pkgconfig(package)

                -- test it
                test(package)
            end

            -- leave the package environments
            os.setenvs(oldenvs)

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

        catch
        {
            function (errors)

                -- show or save the last errors
                local errorfile = path.join(package:installdir("logs"), "install.txt")
                if errors then
                    if (option.get("verbose") or option.get("diagnosis")) then
                        cprint("${dim color.error}error: ${clear}%s", errors)
                    else
                        io.writefile(errorfile, errors .. "\n")
                    end
                end

                -- trace
                tty.erase_line_to_start().cr()
                cprint("${yellow}  => ${clear}install %s %s .. ${color.failure}${text.failure}", package:displayname(), package:version_str() or "")

                -- leave the package environments
                os.setenvs(oldenvs)

                -- copy the invalid package directory to cache
                local installdir = package:installdir()
                if os.isdir(installdir) then
                    local installdir_failed = _get_installdir_failed(package)
                    if not os.isdir(installdir_failed) then
                        os.cp(installdir, installdir_failed)
                    end
                    errorfile = path.join(installdir_failed, "logs", "install.txt")
                end
                os.tryrm(installdir)

                -- is precompiled package? we can fallback to source package and try reinstall it again
                if package:is_precompiled() then
                    ok = false
                else
                    -- failed
                    if not package:requireinfo().optional then
                        if os.isfile(errorfile) then
                            if errors and option.get("diagnosis") then
                                print(tostring(errors))
                            else
                                if errors then
                                    print("")
                                    for idx, line in ipairs(errors:split("\n")) do
                                        print(line)
                                        if idx > 16 then
                                            break
                                        end
                                    end
                                end
                                cprint("if you want to get more verbose errors, please see:")
                                cprint("  -> ${bright}%s", errorfile)
                            end
                        end
                        raise("install failed!")
                    end
                end
            end
        }
    }

    _leave_workdir(package, oldir)
    return ok
end
