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

-- define module
local sandbox_lib_detect_find_program = sandbox_lib_detect_find_program or {}

-- load modules
local os          = require("base/os")
local path        = require("base/path")
local winos       = require("base/winos")
local table       = require("base/table")
local utils       = require("base/utils")
local option      = require("base/option")
local profiler    = require("base/profiler")
local project     = require("project/project")
local detectcache = require("cache/detectcache")
local sandbox     = require("sandbox/sandbox")
local package     = require("package/package")
local raise       = require("sandbox/modules/raise")
local vformat     = require("sandbox/modules/vformat")
local scheduler   = require("sandbox/modules/import/core/base/scheduler")

-- do check
function sandbox_lib_detect_find_program._do_check(program, opt)

    -- do not attempt to run program? check it fastly
    if opt.norun then
        return os.isfile(program)
    elseif opt.norunfile and path.is_absolute(program) and os.isfile(program) then
        return true
    end

    -- no check script? attempt to run it directly
    if not opt.check then
        local ok, errors = os.runv(program, {"--version"}, {envs = opt.envs, shell = opt.shell})
        if not ok and option.get("verbose") and option.get("diagnosis") then
            utils.cprint("${color.warning}checkinfo: ${clear dim}" .. errors)
        end
        return ok
    end

    -- check it
    local ok = false
    local errors = nil
    if type(opt.check) == "string" then
        ok, errors = os.runv(program, {opt.check}, {envs = opt.envs, shell = opt.shell})
    elseif type(opt.check) == "table" then
        ok, errors = os.runv(program, opt.check, {envs = opt.envs, shell = opt.shell})
    else
        ok, errors = sandbox.load(opt.check, program)
    end

    -- check failed? print verbose error info
    if not ok and option.get("verbose") and option.get("diagnosis") then
        utils.cprint("${color.warning}checkinfo: ${clear dim}" .. errors)
    end
    return ok
end

-- check program
function sandbox_lib_detect_find_program._check(program, opt)
    opt = opt or {}
    local findname = program
    if os.subhost() == "windows" then
        local ext = path.extension(program):lower()
        if not opt.shell and ext ~= ".exe" and ext ~= ".cmd" and ext ~= ".bat" then
            findname = program .. ".exe"
        end
    elseif os.subhost() == "msys" and os.isfile(program) and os.filesize(program) < 256 then
        -- only a sh script on msys2? e.g. c:/msys64/usr/bin/7z
        -- we need to use sh to wrap it, otherwise os.exec cannot run it
        local ext = path.extension(program):lower()
        if ext ~= ".exe" and ext ~= ".cmd" and ext ~= ".bat" then
            program = "sh " .. program
        end
        findname = program
    end
    if sandbox_lib_detect_find_program._do_check(findname, opt) then
        return program
    -- check "zig c++" without ".exe"
    -- https://github.com/xmake-io/xmake/issues/2232
    elseif findname ~= program and path.filename(program):find(" ", 1, true) and
        sandbox_lib_detect_find_program._do_check(program, opt) then
        return program
    end
end

-- find program from the given paths
function sandbox_lib_detect_find_program._find_from_paths(name, paths, opt)

    -- attempt to check it from the given directories
    if not path.is_absolute(name) then
        for _, _path in ipairs(table.wrap(paths)) do

            -- format path for builtin variables
            if type(_path) == "function" then
                local ok, results = sandbox.load(_path)
                if ok then
                    _path = results or ""
                else
                    raise(results)
                end
            elseif type(_path) == "string" then
                if _path:match("^%$%(env .+%)$") then
                    _path = path.splitenv(vformat(_path))
                else
                    _path = vformat(_path)
                end
            end

            for _, _s_path in ipairs(table.wrap(_path)) do

                -- get program path
                local program_path = nil
                if os.isfile(_s_path) then
                    program_path = _s_path
                elseif os.isdir(_s_path) then
                    program_path = path.join(_s_path, name)
                end

                -- the program path
                if program_path and (os.isexec(program_path) or os.isexec(program_path:split("%s")[1])) then
                    local program_path_real = sandbox_lib_detect_find_program._check(program_path, opt)
                    if program_path_real then
                        return program_path_real
                    end
                end
            end
        end
    end
end

-- find program from the xmake packages
function sandbox_lib_detect_find_program._find_from_packages(name, opt)

    -- get the manifest file of package, e.g. ~/.xmake/packages/g/git/1.1.12/ed41d5327fad3fc06fe376b4a94f62ef/manifest.txt
    opt = opt or {}
    local installdir = opt.installdir or path.join(package.installdir(), name:sub(1, 1), name, opt.require_version, opt.buildhash)
    local manifest_file = path.join(installdir, "manifest.txt")
    if not os.isfile(manifest_file) then
        return
    end

    -- get install directory of this package
    local installdir = path.directory(manifest_file)

    -- init paths
    local paths = {}
    local manifest = io.load(manifest_file)
    if manifest and manifest.envs then
        local pathenvs = manifest.envs.PATH
        if pathenvs then
            for _, pathenv in ipairs(pathenvs) do
                table.insert(paths, path.join(installdir, pathenv))
            end
        end
    end

    -- find it
    return sandbox_lib_detect_find_program._find_from_paths(name, paths, opt)
end

-- find program
function sandbox_lib_detect_find_program._find(name, paths, opt)

    -- attempt to find it from the given directories
    local program_path = sandbox_lib_detect_find_program._find_from_paths(name, paths, opt)
    if program_path and opt.system ~= false then
        return program_path
    end

    -- attempt to find it from the xmake packages
    if opt.require_version and opt.buildhash and opt.system ~= true then
        return sandbox_lib_detect_find_program._find_from_packages(name, opt)
    end

    -- we will not continue to find system program
    if opt.system == false then
        return
    end

    -- attempt to find it from regists
    if os.host() == "windows" then
        local program_name = name:lower()
        if not program_name:endswith(".exe") then
            program_name = program_name .. ".exe"
        end
        if path.is_absolute(program_name) and os.isfile(program_name) then
            program_path = program_name
        else
            program_path = winos.registry_query("HKEY_LOCAL_MACHINE\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\App Paths\\" .. program_name)
        end
        if program_path then
            program_path = program_path:trim()
            if os.isexec(program_path) then
                local program_path_real = sandbox_lib_detect_find_program._check(program_path, opt)
                if program_path_real then
                    return program_path_real
                end
            end
        end
    else
        if path.is_absolute(name) and os.isfile(name) then
            program_path = name
        else
            -- attempt to find it use `which program` command
            local ok, result = os.iorunv("which", {name})
            if ok and result then
                program_path = result:trim()
            else
                program_path = nil
            end
        end

        if program_path then
            local program_path_real = sandbox_lib_detect_find_program._check(program_path, opt)
            if program_path_real then
                return program_path_real
            end
        end
    end

    -- attempt to find it from the some default $PATH and system directories
    local syspaths = {}
    --[[
    local envpaths = os.getenv("PATH")
    if envpaths then
        table.join2(syspaths, path.splitenv(envpaths))
    end]]
    if os.host() ~= "windows" or os.is_subhost("msys", "cygwin") then
        table.insert(syspaths, "/usr/local/bin")
        table.insert(syspaths, "/usr/bin")
    end
    if #syspaths > 0 then
        program_path = sandbox_lib_detect_find_program._find_from_paths(name, syspaths, opt)
        if program_path then
            return program_path
        end
    end

    -- attempt to find it use `where.exe program.exe` command
    -- and we need to add `.exe` suffix to avoid find some incorrect programs. e.g. pkg-config.bat
    --
    -- it will return the absolute path, so we call it first
    -- https://github.com/xmake-io/xmake/discussions/6223#discussioncomment-12537122
    --
    if os.host() == "windows" then
        local program_name = name:lower()
        if not program_name:endswith(".exe") then
            program_name = program_name .. ".exe"
        end
        local ok, wherepaths = os.iorunv("where.exe", {program_name})
        if ok and wherepaths then
            for _, program_path in ipairs(wherepaths:split("\n")) do
                program_path = program_path:trim()
                if #program_path > 0 then
                    local program_path_real = sandbox_lib_detect_find_program._check(program_path, opt)
                    if program_path_real then
                        return program_path_real
                    end
                end
            end
        end
    end

    -- attempt to find it directly in current environment
    --
    -- @note must be detected at the end, because full path is more accurate
    --
    local program_path_real = sandbox_lib_detect_find_program._check(name, opt)
    if program_path_real then
        return program_path_real
    end
end

-- find program
--
-- @param name      the program name
-- @param opt       the options, e.g. {paths = {"/usr/bin"}, check = function (program) os.run("%s -h", program) end, verbose = true, force = true, cachekey = "xxx"}
--                    - opt.paths     the program paths (e.g. dirs, paths, winreg paths, script paths)
--                    - opt.check     the check script or command
--                    - opt.norun     do not attempt to run program to check program fastly
--                    - opt.norunfile do not attempt to run program to check program if it's valid file path.
--                    - opt.system    true: only find it from system, false: only find it from xmake/packages
--
-- @return          the program name or path
--
-- @code
--
-- local program = find_program("ccache")
-- local program = find_program("ccache", {paths = {"/usr/bin", "/usr/local/bin"}})
-- local program = find_program("ccache", {paths = {"/usr/bin", "/usr/local/bin"}, check = "--help"}) -- simple check command: ccache --help
-- local program = find_program("ccache", {paths = {"/usr/bin", "/usr/local/bin"}, check = function (program) os.run("%s -h", program) end})
-- local program = find_program("ccache", {paths = {"$(env PATH)", "$(reg HKEY_LOCAL_MACHINE\\SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion\\AeDebug;Debugger)"}})
-- local program = find_program("ccache", {paths = {"$(env PATH)", function () return "/usr/local/bin" end}})
-- local program = find_program("ccache", {envs = {PATH = "xxx"}})
--
-- @endcode
--
function sandbox_lib_detect_find_program.main(name, opt)
    opt = opt or {}

    -- init cachekey
    local cachekey = "find_program"
    if opt.cachekey then
        cachekey = cachekey .. "_" .. opt.cachekey
    end

    -- @see https://github.com/xmake-io/xmake/issues/4645
    -- @note avoid detect the same program in the same time leading to deadlock if running in the coroutine (e.g. ccache)
    local lockname = cachekey .. name
    scheduler.co_lock(lockname)

    -- attempt to get result from cache first
    local result = detectcache:get2(cachekey, name)
    if result ~= nil and not opt.force then
        scheduler.co_unlock(lockname)
        return result and result or nil
    end

    -- get paths from the opt.envs.PATH
    -- @note the wrong `pathes` word will be discarded, but the interface parameters will still be compatible
    local envs = opt.envs
    local paths = opt.paths or opt.pathes
    if envs and (envs.PATH or envs.path) then
        local pathenv = envs.PATH or envs.path
        if type(pathenv) == "string" then
            pathenv = path.splitenv(pathenv)
        end
        paths = table.join(table.wrap(opt.paths or opt.pathes), pathenv)
    end

    -- find executable program
    profiler:enter("find_program", name)
    result = sandbox_lib_detect_find_program._find(name, paths, opt)
    profiler:leave("find_program", name)

    -- cache result
    detectcache:set2(cachekey, name, result and result or false)

    -- trace
    if option.get("verbose") or opt.verbose then
        if result then
            utils.cprint("checking for %s ... ${color.success}%s", name, (name == result and "${text.success}" or result))
        else
            utils.cprint("checking for %s ... ${color.nothing}${text.nothing}", name)
        end
    end
    scheduler.co_unlock(lockname)
    return result
end

-- return module
return sandbox_lib_detect_find_program
