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

-- imports
import("core.base.colors")
import("core.tool.compiler")
import("core.project.config")
import("core.project.project")
import("core.language.language")
import("core.platform.platform")
import("lib.detect.find_tool")
import("private.utils.batchcmds")
import("plugins.project.utils.target_cmds", {rootdir = os.programdir()})
import("private.utils.target", {alias = "target_utils"})

-- tranlate path
function _translate_path(filepath, outputdir)
    filepath = path.translate(filepath)
    if filepath == "" then
        return ""
    end
    if path.is_absolute(filepath) then
        if filepath:startswith(project.directory()) then
            return path.relative(filepath, outputdir)
        end
        return filepath
    else
        return path.relative(path.absolute(filepath), outputdir)
    end
end

-- get relative unix path
function _get_relative_unix_path(filepath, outputdir)
    filepath = _translate_path(filepath, outputdir)
    filepath = path.translate(filepath)
    return os.args(filepath)
end

-- translate flag
function _translate_flag(flag, outputdir)
    if flag then
        if path.instance_of(flag) then
            flag = flag:clone():set(_get_relative_unix_path(flag:rawstr(), outputdir)):str()
        elseif path.is_absolute(flag) then
            flag = _get_relative_unix_path(flag, outputdir)
        elseif flag:startswith("-fmodule-file=") then
            flag = "-fmodule-file=" .. _get_relative_unix_path(flag:sub(15), outputdir)
        elseif flag:startswith("-fmodule-mapper=") then
            flag = "-fmodule-mapper=" .. _get_relative_unix_path(flag:sub(17), outputdir)
        elseif flag:startswith("-isystem") and #flag > 8 then
            flag = "-isystem" .. _get_relative_unix_path(flag:sub(9), outputdir)
        elseif flag:startswith("-I") and #flag > 2 then
            flag = "-I" .. _get_relative_unix_path(flag:sub(3), outputdir)
        elseif flag:startswith("-L") and #flag > 2 then
            flag = "-L" .. _get_relative_unix_path(flag:sub(3), outputdir)
        elseif flag:startswith("-F") and #flag > 2 then
            flag = "-F" .. _get_relative_unix_path(flag:sub(3), outputdir)
        elseif flag:match("(.+)=(.+)") then
            local k, v = flag:match("(.+)=(.+)")
            if v and v:endswith(".ifc") then -- e.g. hello=xxx/hello.ifc
                flag = k .. "=" .. _get_relative_unix_path(v, outputdir)
            end
        end
    end
    return flag
end

-- translate flags
function _translate_flags(flags, outputdir)
    local result = {}
    for _, flag in ipairs(flags) do
        if type(flag) == "table" and not path.instance_of(flag) then
            for _, v in ipairs(flag) do
                table.insert(result, _translate_flag(v, outputdir))
            end
        else
            table.insert(result, _translate_flag(flag, outputdir))
        end
    end
    return result
end

-- get program from target toolchains
function _get_program_from_target(target, toolkind)
    local program = target:get("toolchain." .. toolkind)
    if not program then
        program, _ = target:tool(toolkind)
    end
    return program
end

-- get common flags
function _get_common_flags(target, sourcekind, sourcebatch)

    -- make source flags
    local sourceflags = {}
    local flags_stats = {}
    local files_count = 0
    local first_flags = nil
    for _, sourcefile in ipairs(sourcebatch.sourcefiles) do

        -- make compiler flags
        local flags = compiler.compflags(sourcefile, {target = target, sourcekind = sourcekind})
        for _, flag in ipairs(flags) do
            flags_stats[flag] = (flags_stats[flag] or 0) + 1
        end

        -- update files count
        files_count = files_count + 1

        -- save first flags
        if first_flags == nil then
            first_flags = flags
        end

        -- save source flags
        sourceflags[sourcefile] = flags
    end

    -- make common flags
    local commonflags = {}
    for _, flag in ipairs(first_flags) do
        if flags_stats[flag] >= files_count then
            table.insert(commonflags, flag)
        end
    end

    -- remove common flags from source flags
    local sourceflags_ = {}
    for sourcefile, flags in pairs(sourceflags) do
        local otherflags = {}
        for _, flag in ipairs(flags) do
            if flags_stats[flag] < files_count then
                table.insert(otherflags, flag)
            end
        end
        sourceflags_[sourcefile] = otherflags
    end
    return commonflags, sourceflags_
end

-- get command: mkdir
function _get_cmd_mkdir(dir)
    if is_subhost("windows") then
        return string.format("-@mkdir %s > NUL 2>&1", dir)
    else
        return string.format("@mkdir -p %s", dir)
    end
end

-- get command: cp
function _get_cmd_cp(sourcefile, targetfile)
    if is_subhost("windows") then
        return string.format("@copy /Y %s %s > NUL 2>&1", sourcefile, targetfile)
    else
        return string.format("@cp %s %s", sourcefile, targetfile)
    end
end

-- get command: mv
function _get_cmd_mv(sourcefile, targetfile)
    if is_subhost("windows") then
        return string.format("@ren %s %s > NUL 2>&1", sourcefile, targetfile)
    else
        return string.format("@mv %s %s", sourcefile, targetfile)
    end
end

-- get command: ln
function _get_cmd_ln(sourcefile, targetfile)
    if is_subhost("windows") then
        if os.isdir(sourcefile) then
            return string.format("@mklink /D %s %s > NUL 2>&1", sourcefile, targetfile)
        else
            return string.format("@mklink %s %s > NUL 2>&1", sourcefile, targetfile)
        end
    else
        return string.format("@ln -s %s %s", sourcefile, targetfile)
    end
end

-- get command: cpdir
function _get_cmd_cpdir(sourcedir, targetdir)
    if is_subhost("windows") then
        return string.format("@copy /Y %s %s > NUL 2>&1", sourcedir, targetdir)
    else
        return string.format("@cp -r %s %s", sourcedir, targetdir)
    end
end

-- get command: rm
function _get_cmd_rm(filedir)
    if is_subhost("windows") then
        -- we attempt to delete it as file first, we remove it as directory if failed
        return string.format("@del /F /Q %s > NUL 2>&1 || rmdir /S /Q %s > NUL 2>&1", filedir, filedir)
    else
        return string.format("@rm -rf %s", filedir)
    end
end

-- get command: rmdir
function _get_cmd_rmdir(filedir)
    if is_subhost("windows") then
        return string.format("@rmdir /S /Q %s > NUL 2>&1", filedir)
    else
        return string.format("@rm -rf %s", filedir)
    end
end

-- get command: echo
function _get_cmd_echo(str)
    return string.format("@echo %s", colors.ignore(str))
end

-- get command: cd
function _get_cmd_cd(dir)
    return string.format("@cd %s", dir)
end

-- get command string
function _get_command_string(cmd, outputdir)
    local kind = cmd.kind
    local opt = cmd.opt
    if cmd.program then
        -- @see https://github.com/xmake-io/xmake/discussions/2156
        local argv = {}
        for _, v in ipairs(cmd.argv) do
            table.insert(argv, _translate_flag(v, outputdir))
        end
        local command = _get_relative_unix_path(cmd.program) .. " " .. os.args(argv)
        if opt and opt.curdir then
            wprint("curdir has been not supported in batchcmds:execv() for makefile generator!")
        end
        return "%$(VV)" .. command
    elseif kind == "cp" then
        if os.isdir(cmd.srcpath) then
            return _get_cmd_cpdir(get_relative_unix_path(cmd.srcpath, outputdir), _get_relative_unix_path(cmd.dstpath, outputdir))
        else
            return _get_cmd_cp(_get_relative_unix_path(cmd.srcpath, outputdir), _get_relative_unix_path(cmd.dstpath, outputdir))
        end
    elseif kind == "rm" then
        return _get_cmd_rm(_get_relative_unix_path(cmd.filepath, outputdir))
    elseif kind == "rmdir" then
        return _get_cmd_rmdir(_get_relative_unix_path(cmd.dir, outputdir))
    elseif kind == "mv" then
        return _get_cmd_mv(_get_relative_unix_path(cmd.srcpath, outputdir), _get_relative_unix_path(cmd.dstpath, outputdir))
    elseif kind == "ln" then
        return _get_cmd_ln(_get_relative_unix_path(cmd.srcpath, outputdir), _get_relative_unix_path(cmd.dstpath, outputdir))
    elseif kind == "cd" then
        return _get_cmd_cd(_get_relative_unix_path(cmd.dir, outputdir))
    elseif kind == "mkdir" then
        return _get_cmd_mkdir(_get_relative_unix_path(cmd.dir, outputdir))
    elseif kind == "show" or kind == "show_progress" then
        local text = string.format(cmd.format, table.unpack(cmd.argv))
        return _get_cmd_echo(text)
    end
end

-- remove the given files or directories
function _add_remove_files(makefile, filedirs, outputdir)
    for _, filedir in ipairs(filedirs) do
        filedir = _get_relative_unix_path(filedir, outputdir)
        makefile:print("\t%s", _get_cmd_rm(filedir))
    end
end

-- add header
function _add_header(makefile)
   makefile:print([[# this is the build file for project %s
# it is autogenerated by the xmake build system.
# do not edit by hand.
]], project.name() or "")
end

-- add switches
function _add_switches(makefile)
    if is_subhost("windows") then
        makefile:print("!if \"%$(VERBOSE)\" != \"1\"")
        makefile:print("VV=@")
        makefile:print("!endif")
    else
        makefile:print("ifneq (%$(VERBOSE),1)")
        makefile:print("VV=@")
        makefile:print("endif")
    end
    makefile:print("")
end

-- add toolchains
function _add_toolchains(makefile, outputdir)

    -- add ccache
    local ccache = find_tool("ccache")
    if ccache then
        makefile:print("CCACHE=" .. ccache.program)
    end

    -- add compilers
    for sourcekind, _ in pairs(language.sourcekinds()) do
        local program = platform.tool(sourcekind)
        if program and program ~= "" then
            makefile:print("%s=%s", sourcekind:upper(), program)
        end
    end
    makefile:print("")

    -- add linkers
    local linkerkinds = {}
    for _, _linkerkinds in pairs(language.targetkinds()) do
        table.join2(linkerkinds, _linkerkinds)
    end
    for _, linkerkind in ipairs(table.unique(linkerkinds)) do
        local program = platform.tool(linkerkind)
        if program and program ~= "" then
            makefile:print("%s=%s", (linkerkind:upper():gsub('%-', '_')), program)
        end
    end
    makefile:print("")

    -- add toolchains from targets
    local project_targets = target_utils.get_project_targets()
    for targetname, target in pairs(project_targets) do
        if not _phony_or_headeronly(target) then
            local program = _get_program_from_target(target, target:linker():kind())
            if program then
                makefile:print("%s_%s=%s", targetname, target:linker():kind():upper(), program)
            end
            for _, sourcebatch in pairs(target:sourcebatches()) do
                local sourcekind = sourcebatch.sourcekind
                if sourcekind then
                    local program = _get_program_from_target(target, sourcekind)
                    if program then
                        makefile:print("%s_%s=%s", targetname, sourcekind:upper(), program)
                    end
                end
            end
        end
    end
    makefile:print("")
end

function _phony_or_headeronly(target) 
    return target:is_phony() or target:is_headeronly()
end

-- add flags
function _add_flags(makefile, targetflags, outputdir)
    local project_targets = target_utils.get_project_targets()
    for targetname, target in pairs(project_targets) do
        if not _phony_or_headeronly(target) then
            for _, sourcebatch in pairs(target:sourcebatches()) do
                local sourcekind = sourcebatch.sourcekind
                if sourcekind then
                    local commonflags, sourceflags = _get_common_flags(target, sourcekind, sourcebatch)
                    makefile:print("%s_%sFLAGS=%s", targetname, sourcekind:upper(), os.args(_translate_flags(commonflags, outputdir)))
                    targetflags[targetname .. '_' .. sourcekind:upper()] = sourceflags
                end
            end
            makefile:print("%s_%sFLAGS=%s", targetname, target:linker():kind():upper(), os.args(_translate_flags(target:linkflags(), outputdir)))
        end
    end
    makefile:print("")
end

-- add build object
function _add_build_object(makefile, target, sourcefile, objectfile, sourceflags, outputdir, precmds_label)

    -- get the source file kind
    local sourcekind = language.sourcekind_of(sourcefile)

    -- get program
    local program_global = false
    local program = _get_program_from_target(target, sourcekind)
    if not program then
        program = platform.tool(sourcekind)
        program_global = true
    end

    -- get complier flags
    local compflags = _translate_flags(sourceflags[sourcefile], outputdir)

    -- translate file paths
    sourcefile = _get_relative_unix_path(sourcefile, outputdir)
    objectfile = _get_relative_unix_path(objectfile, outputdir)

    -- make command
    local macro = "$\01" .. target:name() .. '_' .. sourcekind:upper() .. "FLAGS\02"
    local command = compiler.compcmd(sourcefile, objectfile, {target = target, compflags = table.join(macro, compflags)})
    command = command:gsub('\01', '(')
    command = command:gsub('\02', ')')

    -- replace program to $(XX)
    local p, e = command:find("\"" .. program .. "\"", 1, true)
    if not p then
        p, e = command:find(program, 1, true)
    end
    if p then
        if program_global then
            command = format("%s$(%s)%s", command:sub(1, p - 1), sourcekind:upper(), command:sub(e + 1))
        else
            command = format("%s$(%s_%s)%s", command:sub(1, p - 1), target:name(), sourcekind:upper(), command:sub(e + 1))
        end
    end

    -- replace ccache to $(CCACHE)
    local ccache = config.get("ccache") ~= false and find_tool("ccache")
    if ccache then
        p, e = command:find(ccache.program, 1, true)
        if p then
            command = format("%s$(%s)%s", command:sub(1, p - 1), "CCACHE", command:sub(e + 1))
        end
    end

    -- make head
    makefile:printf("%s:", objectfile)
    if precmds_label then
        makefile:printf(" %s", precmds_label)
    end

    -- make dependence
    makefile:print(" %s", sourcefile)

    -- make body
    makefile:print("\t%s", _get_cmd_echo(string.format("%scompiling.$(mode) %s", ccache and "ccache " or "", sourcefile)))
    makefile:print("\t%s", _get_cmd_mkdir(path.directory(objectfile)))
    makefile:writef("\t$(VV)%s\n", command)

    -- make tail
    makefile:print("")
end

-- add build objects
function _add_build_objects(makefile, target, sourcekind, sourcebatch, sourceflags, outputdir, precmds_label)
    local handled_objects = target:data("makefile.handled_objects")
    if not handled_objects then
        handled_objects = {}
        target:data_set("makefile.handled_objects", handled_objects)
    end
    for index, objectfile in ipairs(sourcebatch.objectfiles) do
        -- remove repeat
        -- this is because some rules will repeatedly bind the same sourcekind, e.g. `rule("c++.build.modules.builder")`
        if not handled_objects[objectfile] then
            _add_build_object(makefile, target,
                sourcebatch.sourcefiles[index], objectfile, sourceflags, outputdir, precmds_label)
            handled_objects[objectfile] = true
        end
    end
end

-- add build phony
function _add_build_phony(makefile, target)

    -- make dependence for the dependent targets
    makefile:printf("%s:", target:name())
    for _, dep in ipairs(target:get("deps")) do
        makefile:write(" " .. dep)
    end
    makefile:print("")
end

-- add custom commands before building target
function _add_build_custom_commands_before(makefile, target, outputdir)

    -- add before commands
    -- we use irpairs(groups), because the last group that should be given the highest priority.
    local cmds_before = target_cmds.get_target_buildcmds(target, {stages = {"before", "on"}})

    local targetname = target:name()
    local label = "precmds_" .. targetname
    if #cmds_before > 0 then
        makefile:print("%s:", label)
        for _, cmd in ipairs(cmds_before) do
            local command = _get_command_string(cmd, outputdir)
            if command then
                makefile:print("\t%s", command)
            end
        end
        makefile:print("")
        return label
    end
end

-- add custom commands after building target
function _add_build_custom_commands_after(makefile, target, outputdir)
    local cmds_after = target_cmds.get_target_buildcmds(target, {stages = {"after"}})
    if #cmds_after > 0 then
        for _, cmd in ipairs(cmds_after) do
            local command = _get_command_string(cmd, outputdir)
            if command then
                makefile:print("\t%s", command)
            end
        end
    end
end

-- add build target
function _add_build_target(makefile, target, targetflags, outputdir)

    -- https://github.com/xmake-io/xmake/issues/2337
    target:data_set("plugin.project.kind", "makefile")

    -- add custom commands before building target
    local precmds_label = _add_build_custom_commands_before(makefile, target, outputdir)

    -- is phony target?
    if _phony_or_headeronly(target) then
        return _add_build_phony(makefile, target)
    end

    -- make head
    local targetfile = _get_relative_unix_path(target:targetfile(), outputdir)
    local targetname = target:name()

    -- rules like `./target` and `target` are equivalent and can causes issues
    -- for cases where targetdir is .
    -- in these cases, the targetfile rule is not created
    if target:targetfile() == "./" .. targetname then
        makefile:printf("%s:", targetname)
        if precmds_label then
            makefile:printf(" %s", precmds_label)
        end
    else
        makefile:print("%s: %s", targetname, targetfile)
        makefile:printf("%s:", targetfile)
    end
    if precmds_label then
        makefile:printf(" %s", precmds_label)
    end

    -- make dependence for the dependent targets
    for _, depname in ipairs(target:get("deps")) do
        local dep = project.target(depname, {namespace = target:namespace()})
        makefile:write(" " .. (_phony_or_headeronly(dep) and depname or _get_relative_unix_path(dep:targetfile(), outputdir)))
    end

    -- make dependence for objects
    local objectfiles = target:objectfiles()
    local objectfiles_translated = {}
    for _, objectfile in ipairs(objectfiles) do
        objectfile = _get_relative_unix_path(objectfile, outputdir)
        table.insert(objectfiles_translated, objectfile)
        makefile:write(" " .. objectfile)
    end

    -- make dependence end
    makefile:print("")

    -- get linker kind
    local linkerkind = target:linker():kind()

    -- get program
    local program_global = false
    local program = _get_program_from_target(target, linkerkind)
    if not program then
        program = platform.tool(linkerkind)
        program_global = true
    end

    -- get command
    local command = target:linker():linkcmd(objectfiles_translated, targetfile, {target = target})

    -- replace linkflags to $(XX)
    local p, e = command:find(os.args(target:linkflags()), 1, true)
    if p then
        command = format("%s$(%s_%sFLAGS)%s", command:sub(1, p - 1), target:name(), (linkerkind:upper():gsub('%-', '_')), command:sub(e + 1))
    end

    -- replace program to $(XX)
    p, e = command:find("\"" .. program .. "\"", 1, true)
    if not p then
        p, e = command:find(program, 1, true)
    end
    if p then
        if program_global then
            command = format("%s$(%s)%s", command:sub(1, p - 1), (linkerkind:upper():gsub('%-', '_')), command:sub(e + 1))
        else
            command = format("%s$(%s_%s)%s", command:sub(1, p - 1), target:name(), (linkerkind:upper():gsub('%-', '_')), command:sub(e + 1))
        end
    end

    -- make body
    makefile:print("\t%s", _get_cmd_echo(string.format("linking.$(mode) %s", path.filename(targetfile))))
    makefile:print("\t%s", _get_cmd_mkdir(path.directory(targetfile)))
    makefile:writef("\t$(VV)%s\n", command)

    -- add custom commands after building target
    _add_build_custom_commands_after(makefile, target, outputdir)

    -- end
    makefile:print("")

    -- build source batches
    for _, sourcebatch in pairs(target:sourcebatches()) do
        local sourcekind = sourcebatch.sourcekind
        if sourcekind then
            -- compile source files to single object at once
            local sourceflags = targetflags[target:name() .. '_' .. sourcekind:upper()]
            _add_build_objects(makefile, target, sourcekind, sourcebatch, sourceflags, outputdir, precmds_label)
        end
    end
end

-- add build targets
function _add_build_targets(makefile, targetflags, outputdir)
    local default = ""
    local project_targets = target_utils.get_project_targets()
    for targetname, target in pairs(project_targets) do
        if target:is_default() then
            default = default .. " " .. targetname
        end
    end
    makefile:print("default: %s\n", default)
    local all = ""
    for targetname, _ in pairs(project_targets) do
        all = all .. " " .. targetname
    end
    makefile:print("all: %s\n", all)
    makefile:print(".PHONY: default all %s\n", all)
    for _, target in pairs(project_targets) do
        _add_build_target(makefile, target, targetflags, outputdir)
    end
end

-- add build
function _add_build(makefile, targetflags, outputdir)

    -- TODO
    -- disable precompiled header first
    local project_targets = target_utils.get_project_targets()
    for _, target in pairs(project_targets) do
        target:set("pcheader", nil)
        target:set("pcxxheader", nil)
    end

    -- add build targets
    _add_build_targets(makefile, targetflags, outputdir)
end

-- add clean target
function _add_clean_target(makefile, target, outputdir)
    makefile:printf("clean_%s: ", target:name())
    for _, dep in ipairs(target:get("deps")) do
        makefile:write(" clean_" .. dep)
    end
    makefile:print("")
    if not _phony_or_headeronly(target) then
        _add_remove_files(makefile, target:targetfile(), outputdir)
        _add_remove_files(makefile, target:symbolfile(), outputdir)
        _add_remove_files(makefile, target:objectfiles(), outputdir)
    end
    makefile:print("")
end

-- add clean targets
function _add_clean_targets(makefile, outputdir)
    local all = ""
    local project_targets = target_utils.get_project_targets()
    for targetname, _ in pairs(project_targets) do
        all = all .. " clean_" .. targetname
    end
    makefile:print("clean: %s\n", all)

    -- add clean targets
    for _, target in pairs(project_targets) do
        _add_clean_target(makefile, target, outputdir)
    end
end

-- add clean
function _add_clean(makefile, outputdir)
    _add_clean_targets(makefile, outputdir)
end

function make(outputdir)
    local oldir = os.cd(os.projectdir())

    -- prepare targets
    target_cmds.prepare_targets()

    -- open the makefile
    local makefile = io.open(path.join(outputdir, "makefile"), "w")

    -- add header
    _add_header(makefile)

    -- add switches
    _add_switches(makefile)

    -- add toolchains
    _add_toolchains(makefile, outputdir)

    -- add flags
    local targetflags = {}
    _add_flags(makefile, targetflags, outputdir)

    -- add build
    _add_build(makefile, targetflags, outputdir)

    -- add clean
    _add_clean(makefile, outputdir)

    -- close the makefile
    makefile:close()
    os.cd(oldir)
end
