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

-- imports
import("core.base.option")
import("core.project.depend")
import("core.cache.memcache")
import("lib.detect.find_tool")
import("utils.progress")

function _get_linux_headers_builddir(linux_headers)
    return linux_headers.builddir or linux_headers.sdkdir
end

function _get_linux_arch(target)
    if target:is_arch("arm", "armv7") then
        return "arm"
    elseif target:is_arch("arm64", "arm64-v8a") then
        return "arm64"
    elseif target:is_arch("x86", "i386") then
        return "x86"
    elseif target:is_arch("x86_64", "x64") then
        return "x86_64"
    elseif target:is_arch("mips", "mipsel", "mips64", "mips64el") then
        return "mips"
    elseif target:is_arch("ppc", "ppc64", "powerpc", "powerpc64") then
        return "powerpc"
    elseif target:is_arch("riscv64", "riscv32") then
        return "riscv"
    end
end

function _get_linux_cross_compile(target)
    local cc = target:tool("cc")
    if cc then
        return cc:match("^(.*%-)gcc[%-%d%.]*$")
    end
end

function _get_linux_headers_modulecommon(linux_headers)
    local builddir = _get_linux_headers_builddir(linux_headers)
    local sdkdir = linux_headers.sdkdir and path.normalize(linux_headers.sdkdir) or nil
    local normalized_builddir = builddir and path.normalize(builddir) or nil
    local modulecommon = sdkdir and path.join(sdkdir, "scripts", "module-common.c") or nil
    if modulecommon and not os.isfile(modulecommon) and normalized_builddir and normalized_builddir ~= sdkdir then
        modulecommon = path.join(normalized_builddir, "scripts", "module-common.c")
    end
    if modulecommon and os.isfile(modulecommon) then
        return modulecommon
    end
end

function _get_linux_headers_config(linux_headers)
    local builddir = _get_linux_headers_builddir(linux_headers)
    local key = table.concat({linux_headers.sdkdir, builddir or "", "config"}, "|")
    local configdata = memcache.get2("linux.driver", key, "data")
    if configdata == nil then
        local configfile = path.join(builddir, "include", "config", "auto.conf")
        if os.isfile(configfile) then
            configdata = io.readfile(configfile)
        else
            configfile = path.join(linux_headers.includedir, "generated", "autoconf.h")
            if os.isfile(configfile) then
                configdata = io.readfile(configfile)
            end
        end
        memcache.set2("linux.driver", key, "data", configdata or false)
    end
    return configdata or nil
end

function _has_linux_headers_config(linux_headers, config)
    local configdata = _get_linux_headers_config(linux_headers)
    if configdata then
        for _, line in ipairs(configdata:split("\n")) do
            if line == config .. "=y" or line == "#define " .. config .. " 1" then
                return true
            end
        end
    end
    return false
end

-- get linux-headers sdk
function _get_linux_headers_sdk(target)
    local linux_headersdir = target:values("linux.driver.linux-headers")
    local linux_builddir = target:values("linux.driver.linux-builddir")
    if linux_headersdir then
        return {
            sdkdir = linux_headersdir,
            builddir = linux_builddir,
            includedir = path.join(linux_headersdir, "include")
        }
    end
    local linux_headers = assert(target:pkg("linux-headers"), "please add `add_requires(\"linux-headers\", {configs = {driver_modules = true}})` and `add_packages(\"linux-headers\")` to the given target!")
    local includedirs = linux_headers:get("includedirs") or linux_headers:get("sysincludedirs")
    local version = linux_headers:version()
    local includedir
    for _, dir in ipairs(includedirs) do
        if dir:find("linux-headers", 1, true) then
            includedir = dir
            linux_headersdir = path.directory(dir)
            break
        end
    end
    assert(linux_headersdir, "linux-headers not found!")
    if not os.isfile(path.join(includedir, "generated/autoconf.h")) and
        not os.isfile(path.join(includedir, "config/auto.conf")) then
        raise("kernel configuration is invalid. include/generated/autoconf.h or include/config/auto.conf are missing.")
    end
    return {version = version, sdkdir = linux_headersdir, includedir = includedir}
end

-- get cflags from make
function _get_cflags_from_make(target, sdkdir, builddir)
    local key = table.concat({sdkdir, builddir or "", target:arch(), "v2"}, "|")
    local cflags = memcache.get2("linux.driver", key, "cflags")
    local ldflags_o = memcache.get2("linux.driver", key, "ldflags_o")
    local ldflags_ko = memcache.get2("linux.driver", key, "ldflags_ko")
    if cflags == nil then
        local make = assert(find_tool("make"), "make not found!")
        local tmpdir = os.tmpfile() .. ".dir"
        local makefile = path.join(tmpdir, "Makefile")
        local stubfile = path.join(tmpdir, "src/stub.c")
        local foofile  = path.join(tmpdir, "src/foo.c")
        io.writefile(makefile, [[obj-m := stub.o
stub-objs := src/stub.o src/foo.o]])
        io.writefile(foofile, "")
        io.writefile(stubfile, [[
#include <linux/init.h>
#include <linux/module.h>

MODULE_LICENSE("Dual BSD/GPL");
MODULE_AUTHOR("Ruki");
MODULE_DESCRIPTION("A simple Hello World Module");
MODULE_ALIAS("a simplest module");

static int hello_init(void) {
    printk(KERN_INFO "Hello World\n");
    return 0;
}

static void hello_exit(void) {
    printk(KERN_INFO "Goodbye World\n");
}

module_init(hello_init);
module_exit(hello_exit);
        ]])
        local argv = {"-C", sdkdir, "V=1", "M=" .. tmpdir, "modules"}
        if builddir then
            table.insert(argv, "O=" .. builddir)
        end
        if target:is_cross() then
            -- e.g.	$(MAKE) -C $(KERN_DIR) V=1 ARCH=arm64 CROSS_COMPILE=/mnt/gcc-linaro-7.5.0-2019.12-x86_64_aarch64-linux-gnu/bin/aarch64-linux-gnu- M=$(PWD) modules
            local arch = _get_linux_arch(target)
            assert(arch, "unknown arch(%s)!", target:arch())
            local cross = _get_linux_cross_compile(target)
            assert(cross, "unknown cross-compile prefix for cc(%s)!", target:tool("cc"))
            table.insert(argv, "ARCH=" .. arch)
            table.insert(argv, "CROSS_COMPILE=" .. cross)
        end
        local result, errors = try {function () return os.iorunv(make.program, argv, {curdir = tmpdir}) end}
        if result then
            -- we can also split ';' for the muliple commands
            for _, line in ipairs(result:split("[\n;]")) do
                local line = line:trim()
                if line:endswith("stub.c") then
                    local include_cflag = false
                    for _, cflag in ipairs(line:split("%s+")) do
                        local cflag = cflag
                        local has_cflag = false
                        if cflag:startswith("-fplugin=") then
                            -- @see https://github.com/xmake-io/xmake/issues/3279
                            local plugindir = cflag:sub(10)
                            if not path.is_absolute(plugindir) then
                                plugindir = path.absolute(plugindir, sdkdir)
                            end
                            cflag = "-fplugin=" .. plugindir
                            has_cflag = true
                        elseif cflag == "-nostdinc" or cflag == "-undef" or cflag == "-pg"
                            or cflag:startswith("-std=") or cflag:startswith("-O")
                            or cflag:startswith("-g") or cflag:startswith("-U")
                            or cflag:startswith("--param=") then
                            has_cflag = true
                        elseif cflag:startswith("-f") or cflag:startswith("-m")
                            or (cflag:startswith("-W") and not cflag:startswith("-Wp,-MMD,") and not cflag:startswith("-Wp,-MD,"))
                            or (cflag:startswith("-D") and not cflag:find("KBUILD_MODNAME=") and not cflag:find("KBUILD_BASENAME=")) then
                            has_cflag = true
                            -- https://github.com/xmake-io/xmake/discussions/5972#discussioncomment-11576255
                            local macro, value = cflag:match("%-D(.+)='(.+)'") -- -DXXX='"xxx"'
                            if macro and value and value:startswith("\"") then
                                cflag = "-D" .. macro .. "=" .. value
                            else
                                macro = cflag:match("%-D\"(.+)\"") -- -D"KBUILD_XXX=xxx"
                                if macro then
                                    cflag = "-D" .. macro
                                end
                            end
                        elseif cflag == "-I" or cflag == "-isystem" or cflag == "-include" then
                            include_cflag = cflag
                        elseif cflag:startswith("-I") or include_cflag then
                            local includedir = cflag
                            if cflag:startswith("-I") then
                                includedir = cflag:sub(3)
                            end
                            if not path.is_absolute(includedir) then
                                includedir = path.absolute(includedir, builddir or sdkdir)
                            end
                            if cflag:startswith("-I") then
                                cflag = "-I" .. includedir
                            else
                                cflag = include_cflag .. " " .. includedir
                            end
                            has_cflag = true
                            include_cflag = nil
                        end
                        if has_cflag then
                            cflags = cflags or {}
                            table.insert(cflags, cflag)
                        end
                    end
                end
                local ldflags = line:match("%-ld (.+) %-o ") or line:match("ld (.+) %-o ")
                if ldflags then
                    local ko = ldflags:find("-T ", 1, true)
                    for _, ldflag in ipairs(os.argv(ldflags)) do
                        local ldflag = ldflag
                        if ldflag:endswith(".lds") then
                            if not path.is_absolute(ldflag) then
                                ldflag = path.absolute(ldflag, builddir or sdkdir)
                            end
                        end
                        if ko then
                            -- e.g. aarch64-linux-gnu-ld -r -EL  -maarch64elf --build-id=sha1  -T scripts/module.lds -o hello.ko hello.o hello.mod.o
                            ldflags_ko = ldflags_ko or {}
                            table.insert(ldflags_ko, ldflag)
                        else
                            -- e.g. aarch64-linux-gnu-ld -EL  -maarch64elf   -r -o hello.o xxx.o
                            ldflags_o = ldflags_o or {}
                            table.insert(ldflags_o, ldflag)
                        end
                    end
                end
                if cflags and ldflags_o and ldflags_ko then
                    break
                end
            end
        else
            if option.get("diagnosis") then
                print("rule(platform.linux.driver): cannot get cflags from make!")
                print(errors)
            end
        end
        os.tryrm(tmpdir)
        memcache.set2("linux.driver", key, "cflags", cflags or false)
        memcache.set2("linux.driver", key, "ldflags_o", ldflags_o or false)
        memcache.set2("linux.driver", key, "ldflags_ko", ldflags_ko or false)
    end
    return cflags or nil, ldflags_o or nil, ldflags_ko or nil
end

function load(target)
    -- we only need binary kind, because we will rewrite on_link
    target:set("kind", "binary")
    target:set("extension", ".ko")
end

function config(target)

    -- get and save linux-headers sdk
    local linux_headers = _get_linux_headers_sdk(target)
    target:data_set("linux.driver.linux_headers", linux_headers)

    -- check compiler, we must use gcc
    assert(target:has_tool("cc", "gcc"), "we must use gcc compiler!")

    -- check rules
    for _, rulename in ipairs({"mode.release", "mode.debug", "mode.releasedbg", "mode.minsizerel", "mode.asan", "mode.tsan"}) do
        assert(not target:rule(rulename), "target(%s) is linux driver module, it need not rule(%s)!", target:name(), rulename)
    end

    -- we need to disable includedirs from add_packages("linux-headers")
    if target:pkg("linux-headers") then
        target:pkg("linux-headers"):set("includedirs", nil)
        target:pkg("linux-headers"):set("sysincludedirs", nil)
    end

    -- add compilation flags
    target:add("defines", "KBUILD_MODNAME=\"" .. target:name() .. "\"")
    for _, sourcefile in ipairs(target:sourcefiles()) do
        target:fileconfig_set(sourcefile, {defines = "KBUILD_BASENAME=\"" .. path.basename(sourcefile) .. "\""})
    end
    local cflags, ldflags_o, ldflags_ko = _get_cflags_from_make(target, linux_headers.sdkdir, linux_headers.builddir)
    if cflags then
        target:add("cflags", cflags, {force = true})
        target:data_set("linux.driver.ldflags_o", ldflags_o)
        target:data_set("linux.driver.ldflags_ko", ldflags_ko)
    end
end

function link(target, opt)
    local targetfile  = target:targetfile()
    local dependfile  = target:dependfile(targetfile)
    local objectfiles = target:objectfiles()
    local linux_headers = target:data("linux.driver.linux_headers")
    local builddir = linux_headers and _get_linux_headers_builddir(linux_headers) or nil
    local dependfiles = table.join({}, objectfiles)
    if builddir then
        local kernelsymvers = path.join(builddir, "Module.symvers")
        if os.isfile(kernelsymvers) then
            table.insert(dependfiles, kernelsymvers)
        end
    end
    if linux_headers then
        local modulecommon = _get_linux_headers_modulecommon(linux_headers)
        if modulecommon then
            table.insert(dependfiles, modulecommon)
        end
    end
    depend.on_changed(function ()

        -- trace
        progress.show(opt.progress, "${color.build.object}linking.$(mode) %s", targetfile)

        -- get module scripts
        local modpost
        if linux_headers then
            modpost = path.join(builddir, "scripts", "mod", "modpost")
        end
        assert(modpost and os.isfile(modpost), "scripts/mod/modpost not found!")

        -- get ld
        local ld = target:tool("ld")
        assert(ld, "ld not found!")
        ld = ld:gsub("gcc$", "ld")
        ld = ld:gsub("gcc%-%d+$", "ld")
        ld = ld:gsub("g%+%+$", "ld")
        ld = ld:gsub("g%+%+%-%d+$", "ld")

        -- link target.o
        local argv = {}
        local ldflags_o = target:data("linux.driver.ldflags_o")
        if ldflags_o then
            table.join2(argv, ldflags_o)
        end
        local targetfile_base = path.join(path.directory(target:objectfile(targetfile)), target:basename())
        local targetfile_o = targetfile_base .. ".o"
        table.join2(argv, "-o", targetfile_o)
        table.join2(argv, objectfiles)
        os.mkdir(path.directory(targetfile_o))
        os.vrunv(ld, argv)

        -- generate target.mod
        local targetfile_mod = targetfile_base .. ".mod"
        io.writefile(targetfile_mod, table.concat(objectfiles, "\n") .. "\n\n")

        -- generate .sourcename.o.cmd
        -- we only need to touch an empty file, otherwise modpost command will raise error.
        for _, objectfile in ipairs(objectfiles) do
            local objectdir = path.directory(objectfile)
            local objectname = path.filename(objectfile)
            local cmdfile = path.join(objectdir, "." .. objectname .. ".cmd")
            io.writefile(cmdfile, "")
        end

        -- generate target.mod.c
        local orderfile = path.join(path.directory(targetfile_o), "modules.order")
        local symversfile = path.join(path.directory(targetfile_o), "Module.symvers")
        argv = {"-m", "-a", "-o", symversfile, "-e", "-N", "-w"}
        if _has_linux_headers_config(linux_headers, "CONFIG_BASIC_MODVERSIONS") then
            table.insert(argv, "-b")
        end
        if _has_linux_headers_config(linux_headers, "CONFIG_EXTENDED_MODVERSIONS") then
            table.insert(argv, "-x")
        end
        table.join2(argv, "-T", orderfile)
        local kernelsymvers = path.join(builddir, "Module.symvers")
        if os.isfile(kernelsymvers) then
            table.join2(argv, "-i", kernelsymvers)
        end
        io.writefile(orderfile, targetfile_o .. "\n")
        os.vrunv(modpost, argv)

        -- compile target.mod.c
        local targetfile_mod_c = targetfile_base .. ".mod.c"
        local targetfile_mod_o = targetfile_base .. ".mod.o"
        local compinst = target:compiler("cc")
        target:fileconfig_set(targetfile_mod_c, {defines = "KBUILD_BASENAME=\"" .. path.basename(targetfile_mod_c) .. "\""})
        if option.get("verbose") then
            print(compinst:compcmd(targetfile_mod_c, targetfile_mod_o, {target = target, rawargs = true}))
        end
        assert(compinst:compile(targetfile_mod_c, targetfile_mod_o, {target = target}))

        -- compile .module-common.o for vermagic/retpoline metadata on modern kernels
        local modulecommon_sourcefile = _get_linux_headers_modulecommon(linux_headers)
        local modulecommon_objectfile
        if modulecommon_sourcefile then
            modulecommon_objectfile = path.join(path.directory(targetfile_o), ".module-common.o")
            if option.get("verbose") then
                print(compinst:compcmd(modulecommon_sourcefile, modulecommon_objectfile, {target = target, rawargs = true}))
            end
            assert(compinst:compile(modulecommon_sourcefile, modulecommon_objectfile, {target = target}))
        end

        -- link target.ko
        argv = {}
        local ldflags_ko = target:data("linux.driver.ldflags_ko")
        if ldflags_ko then
            table.join2(argv, ldflags_ko)
        end
        table.join2(argv, "-o", targetfile, targetfile_o, targetfile_mod_o)
        if modulecommon_objectfile then
            table.insert(argv, modulecommon_objectfile)
        end
        os.mkdir(path.directory(targetfile))
        os.vrunv(ld, argv)

    end, {dependfile = dependfile, lastmtime = os.mtime(target:targetfile()), files = dependfiles, changed = target:is_rebuilt()})
end

function install(target)
    os.vrunv("insmod", {target:targetfile()})
end

function uninstall(target)
    os.vrunv("rmmod", {target:targetfile()})
end
