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

-- imports
import("core.base.option")
import("core.base.graph")
import("core.base.hashset")
import("core.tool.toolchain")
import("lib.detect.find_tool")
import("utils.binary.rpath", {alias = "rpath_utils"})
import("core.base.binutils")

function _get_depends_by_binutils(binaryfile, opt)
    return binutils.deplibs(binaryfile)
end

function _get_depends_by_dumpbin(binaryfile, opt)
    local depends
    local plat = opt.plat or os.host()
    local arch = opt.arch or os.arch()
    local cachekey = "utils.binary.deplibs"
    local msvc = toolchain.load("msvc", {plat = plat, arch = arch})
    if msvc:check() then
        local dumpbin = find_tool("dumpbin", {cachekey = cachekey, envs = msvc:runenvs()})
        if dumpbin then
            local binarydir = path.directory(binaryfile)
            local result = try { function () return os.iorunv(dumpbin.program, {"/dependents", "/nologo", binaryfile}) end }
            if result then
                for _, line in ipairs(result:split("\n")) do
                    line = line:trim()
                    if not line:startswith("Dump of file") and line:endswith(".dll") then
                        depends = depends or {}
                        table.insert(depends, line)
                    end
                end
            end
        end
    end
    return depends
end

function _get_depends_by_objdump(binaryfile, opt)
    local depends
    local plat = opt.plat or os.host()
    local arch = opt.arch or os.arch()
    local cachekey = "utils.binary.deplibs"
    local objdump = find_tool("llvm-objdump", {cachekey = cachekey}) or find_tool("objdump", {cachekey = cachekey})
    if objdump then
        local binarydir = path.directory(binaryfile)
        local argv = {"-p", binaryfile}
        if plat == "macosx" or plat == "iphoneos" or plat == "appletvos" or plat == "watchos" then
            argv = {"--macho", "--dylibs-used", binaryfile}
        end
        local result = try { function () return os.iorunv(objdump.program, argv) end }
        if result then
            for _, line in ipairs(result:split("\n")) do
                line = line:trim()
                if not line:endswith(":") then
                    if plat == "windows" or plat == "mingw" then
                        if line:startswith("DLL Name:") then
                            local filename = line:split(":")[2]:trim()
                            if filename:endswith(".dll") then
                                depends = depends or {}
                                table.insert(depends, filename)
                            end
                        end
                    elseif plat == "macosx" or plat == "iphoneos" or plat == "appletvos" or plat == "watchos" then
                        local filename = line:match(".-%.dylib") or line:match(".-%.framework")
                        if filename then
                            depends = depends or {}
                            table.insert(depends, filename)
                        end
                    else
                        if line:startswith("NEEDED") then
                            local filename = line:split("%s+")[2]
                            if filename and filename:endswith(".so") or filename:find("%.so[%.%d+]+$") then
                                depends = depends or {}
                                table.insert(depends, filename)
                            end
                        end
                    end
                end
            end
        end
    end
    return depends
end

-- $ldd ./build/linux/x86_64/release/test
--	linux-vdso.so.1 (0x00007ffc51fdd000)
--	libfoo.so => /mnt/xmake/tests/projects/c/shared_library/./build/linux/x86_64/release/libfoo.so (0x00007fe241233000)
--	libstdc++.so.6 => /lib64/libstdc++.so.6 (0x00007fe240fca000)
--	libm.so.6 => /lib64/libm.so.6 (0x00007fe240ee7000)
--	libgcc_s.so.1 => /lib64/libgcc_s.so.1 (0x00007fe240eba000)
--	libc.so.6 => /lib64/libc.so.6 (0x00007fe240ccd000)
--	/lib64/ld-linux-x86-64.so.2 (0x00007fe24123a000)
--
function _get_depends_by_ldd(binaryfile, opt)
    local plat = opt.plat or os.host()
    local arch = opt.arch or os.arch()
    if plat ~= "linux" and plat ~= "bsd" then
        return
    end
    local depends
    local cachekey = "utils.binary.deplibs"
    local ldd = find_tool("ldd", {cachekey = cachekey})
    if ldd then
        local binarydir = path.directory(binaryfile)
        local result = try { function () return os.iorunv(ldd.program, {binaryfile}) end }
        if result then
            for _, line in ipairs(result:split("\n")) do
                local splitinfo = line:split("=>")
                line = splitinfo[2]
                if not line or line:find("not found", 1, true) then
                    line = splitinfo[1]
                end
                line = line:gsub("%(.+%)", ""):trim()
                local filename = line:match(".-%.so$") or line:match(".-%.so[%.%d+]+$")
                if filename then
                    depends = depends or {}
                    table.insert(depends, filename:trim())
                end
            end
        end
    end
    return depends
end

-- $ readelf -d build/linux/x86_64/release/test
--
-- Dynamic section at offset 0x2db8 contains 29 entries:
--  Tag        Type                         Name/Value
-- 0x0000000000000001 (NEEDED)             Shared library: [libfoo.so]
-- 0x0000000000000001 (NEEDED)             Shared library: [libstdc++.so.6]
-- 0x0000000000000001 (NEEDED)             Shared library: [libm.so.6]
-- 0x0000000000000001 (NEEDED)             Shared library: [libgcc_s.so.1]
-- 0x0000000000000001 (NEEDED)             Shared library: [libc.so.6]
-- 0x000000000000001d (RUNPATH)            Library runpath: [$ORIGIN]
function _get_depends_by_readelf(binaryfile, opt)
    local plat = opt.plat or os.host()
    local arch = opt.arch or os.arch()
    if plat ~= "linux" and plat ~= "bsd" and plat ~= "android" and plat ~= "cross" then
        return
    end
    local depends
    local cachekey = "utils.binary.deplibs"
    local readelf = find_tool("readelf", {cachekey = cachekey})
    if readelf then
        local binarydir = path.directory(binaryfile)
        local result = try { function () return os.iorunv(readelf.program, {"-d", binaryfile}) end }
        if result then
            for _, line in ipairs(result:split("\n")) do
                if line:find("NEEDED", 1, true) then
                    local filename = line:match("Shared library: %[(.-)%]")
                    if filename then
                        depends = depends or {}
                        table.insert(depends, filename:trim())
                    end
                end
            end
        end
    end
    return depends
end

-- $ otool -L build/iphoneos/arm64/release/test
-- build/iphoneos/arm64/release/test:
--        @rpath/libfoo.dylib (compatibility version 0.0.0, current version 0.0.0)
--        /System/Library/Frameworks/Foundation.framework/Foundation (compatibility version 300.0.0, current version 2048.1.101)
--        /usr/lib/libobjc.A.dylib (compatibility version 1.0.0, current version 228.0.0)
--        /usr/lib/libc++.1.dylib (compatibility version 1.0.0, current version 1600.151.0)
--        /usr/lib/libSystem.B.dylib (compatibility version 1.0.0, current version 1336.0.0)
--
function _get_depends_by_otool(binaryfile, opt)
    local plat = opt.plat or os.host()
    local arch = opt.arch or os.arch()
    if plat ~= "macosx" and plat ~= "iphoneos" and plat ~= "appletvos" and plat ~= "watchos" then
        return
    end
    local depends
    local cachekey = "utils.binary.deplibs"
    local otool = find_tool("otool", {cachekey = cachekey})
    if otool then
        local binarydir = path.directory(binaryfile)
        local result = try { function () return os.iorunv(otool.program, {"-L", binaryfile}) end }
        if result then
            for _, line in ipairs(result:split("\n")) do
                line = line:trim()
                if not line:endswith(":") then
                    local filename = line:match(".-%.dylib") or line:match(".-%.framework")
                    if filename then
                        depends = depends or {}
                        table.insert(depends, filename:trim())
                    end
                end
            end
        end
    end
    return depends
end

function _get_depends(binaryfile, opt)
    opt = opt or {}
    local ops = {
        _get_depends_by_objdump,
        _get_depends_by_readelf,
        _get_depends_by_binutils
    }
    if is_host("windows") then
        table.insert(ops, 1, _get_depends_by_dumpbin)
    elseif is_host("linux", "bsd") then
        table.insert(ops, 1, _get_depends_by_ldd)
    elseif is_host("macosx") then
        table.insert(ops, 1, _get_depends_by_otool)
    end
    for _, op in ipairs(ops) do
        local depends = op(binaryfile, opt)
        if depends then
            return depends
        end
    end
end

-- resolve file path with @rpath, @loader_path, and $ORIGIN
function _resolve_filepath(binaryfile, dependfile, opt)
    local loaderfile = opt._loaderfile
    local resolve_hint_paths = opt.resolve_hint_paths
    local resolve_search_paths = opt.resolve_search_paths
    local resolved = false

    -- resolve path from rpath
    if not resolved and dependfile:startswith("@rpath/") then
        local rpathlist = opt._rpathlist
        if rpathlist == nil then
            rpathlist = rpath_utils.list(loaderfile)
            opt._rpathlist = rpathlist or false
        end
        if rpathlist then
            for _, rpath in ipairs(rpathlist) do
                local filepath = dependfile:replace("@rpath/", rpath .. "/", {plain = true})
                if os.isfile(filepath) then
                    dependfile = path.absolute(filepath)
                    resolved = true
                    break
                elseif filepath:startswith("@loader_path/") then
                    filepath = filepath:replace("@loader_path/", path.directory(loaderfile) .. "/", {plain = true})
                    if os.isfile(filepath) then
                        dependfile = path.absolute(filepath)
                        resolved = true
                        break
                    end
                elseif filepath:startswith("$ORIGIN/") then
                    filepath = filepath:replace("$ORIGIN/", path.directory(loaderfile) .. "/", {plain = true})
                    if os.isfile(filepath) then
                        dependfile = path.absolute(filepath)
                        resolved = true
                        break
                    end
                end
            end
        end
    end

    if not path.is_absolute(dependfile) then

        -- resolve absolute path
        if not resolved and os.isfile(dependfile) then
            dependfile = path.absolute(dependfile)
            resolved = true
        end

        -- resolve path from the hint paths
        if not resolved and resolve_hint_paths then
            local filename = path.filename(dependfile)
            for _, filepath in ipairs(resolve_hint_paths) do
                if filename == path.filename(filepath) then
                    dependfile = path.absolute(filepath)
                    resolved = true
                    break
                end
            end
        end

        -- resolve path from the current loader directory on windows
        if not resolved and is_host("windows") then
            local loaderdir = path.directory(loaderfile)
            local filepath = path.join(loaderdir, dependfile)
            if os.isfile(filepath) then
                dependfile = path.absolute(filepath)
                resolved = true
            end
        end

        -- resolve path from the searth paths
        if resolve_search_paths then
            for _, searchdir in ipairs(resolve_search_paths) do
                local filepath = path.join(searchdir, dependfile)
                if os.isfile(filepath) then
                    dependfile = path.absolute(filepath)
                    resolved = true
                end
            end
        end

        -- resolve path from LD_LIBRARY_PATH, ...
        local library_paths = is_host("macosx") and os.getenv("DYLD_LIBRARY_PATH") or os.getenv("LD_LIBRARY_PATH")
        if library_paths then
            for _, searchdir in ipairs(path.splitenv(library_paths)) do
                local filepath = path.join(searchdir, dependfile)
                if os.isfile(filepath) then
                    dependfile = path.absolute(filepath)
                    resolved = true
                end
            end
        end
    end

    dependfile = path.normalize(dependfile)
    if binaryfile ~= dependfile then
        return dependfile
    end
end

function _get_plain_depends(binaryfile, opt)
    opt = opt or {}
    local depends = _get_depends(binaryfile, opt)
    if depends and opt.resolve_path then
        local result = {}
        for _, dependfile in ipairs(depends) do
            dependfile = _resolve_filepath(binaryfile, dependfile, opt)
            if dependfile then
                table.insert(result, dependfile)
            end
        end
        depends = result
    end
    return depends
end

function _get_recursive_depends(binaryfile, dag, depends, opt)
    local dependfiles = _get_plain_depends(binaryfile, opt)
    if dependfiles then
        for _, dependfile in ipairs(dependfiles) do
            dag:add_edge(binaryfile, dependfile)
            if not depends:has(dependfile) then
                depends:insert(dependfile)
                if os.isfile(dependfile) then
                    _get_recursive_depends(dependfile, dag, depends, opt)
                end
            end
        end
    end
end

-- get the library dependencies of the give binary files
--
-- @param binaryfile the binary file
-- @param opt        the option, e.g. {recursive = false, resolve_path = true, resolve_hint_paths = {}}
--                      - plat, arch: the platform and architecture
--                      - recursive: recursively get all sub-dependencies, sorted by topology
--                      - resolve_path: try to resolve the file full path, e.g. @rpath, @loader_path, $ORIGIN, relative path ..
--                      - resolve_hint_paths: we can resolve and match path from them
--                      - resolve_search_paths: the search library paths, like: LD_LIBRARY_PATH, DYLD_LIBRARY_PATH, ...
--                      - check_cycle: check cycle deps
--
function main(binaryfile, opt)
    opt = opt or {}
    if opt.resolve_path then
        opt._loaderfile = binaryfile
    end
    binaryfile = path.normalize(path.absolute(binaryfile))
    if opt.recursive then
        local dag = graph.new(true)
        _get_recursive_depends(binaryfile, dag, hashset.new(), opt)
        local depends, has_cycle = dag:topo_sort()
        if has_cycle and opt.check_cycle then
            local files = {}
            local cycle = dag:find_cycle()
            if cycle then
                for _, file in ipairs(cycle) do
                    table.insert(files, file)
                end
                table.insert(files, binaryfile)
            end
            raise("deplibs(%s): circular library dependencies detected!\n%s", binaryfile, table.concat(files, "\n   -> "))
        end
        if depends and #depends > 1 then
            return table.slice(depends, 2)
        end
    else
        return _get_plain_depends(binaryfile, opt)
    end
end
