#!/usr/bin/env lua
--[[
Copyright (C) 2023-2024 CachyOS

This program is free software; you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation; either version 2 of the License, or
(at your option) any later version.

This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
GNU General Public License for more details.

You should have received a copy of the GNU General Public License along
with this program; if not, write to the Free Software Foundation, Inc.,
51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
--]]
local pacman, pmconfig, pmroot, cachedir, sync

---@alias hookAlias {pre_install: string?, post_install: string?, post_remove: string?, pre_remove: string?, conditional_packages: string?}

---@param s string
---@param ... any
local function printf(s, ...)
    print(s:format(...))
end

---@param err string
---@param ... any
local function die(err, ...)
    printf(err, ...)
    os.exit(1)
end

---@param path string
---@return boolean
local function file_exists(path)
    local file = io.open(path, "r")
    if file then
        file:close()
        return true
    else
        return false
    end
end

---@return boolean
local function check_on_multilib()
    local multilib_pattern = "^%[multilib%]"
    for line in io.lines(pmconfig) do
        if line:match(multilib_pattern) then
            return true
        end
    end
    return false
end

---@param str string
---@return string[]
local function split(str)
    local t = {}
    for found in str:gmatch("([^%s]+)") do
        t[#t + 1] = found
    end
    return t
end

---@param args string[]
---@return table<string, integer>
local function get_opts(args)
    local options = {}
    local option_pattern = "-%-?(.+)"

    for i = 1, #args do
        local option = args[i]
        local match = option:match(option_pattern)

        if match then
            options[match] = i
        end
    end
    return options
end

---@param package_name string
---@return boolean
local function is_installed(package_name)
    local handle = io.popen("/bin/pacman -Qi " .. package_name)

    if handle then
        ---@type string?
        local line
        ---@type string?
        local provider
        repeat
            line = handle:read("*l")
            if line then
                provider = line:match("Name%s+:%s+([^%s]+)")
            end
        until provider or line == nil
        handle:close()

        if provider and provider == package_name then
            return true
        end
    end

    return false
end

---@param action string
---@param pkgs string
---@return number?
local function pacman_handle(action, pkgs)
    local cmd = table.concat({ pacman, action, pkgs }, " ")
    local _, _, code = os.execute(cmd)
    return code
end

---@param packages string
---@return number?
local function install(packages)
    if sync then
        return pacman_handle("--needed -Sy", packages)
    end
    return pacman_handle("--needed -S", packages)
end

---@param packages string
---@return number?
local function remove(packages)
    local pkgs = ""
    for _, pkg in ipairs(split(packages)) do
        if is_installed(pkg) and (pkg ~= "mesa" or pkg ~= "lib32-mesa") then
            pkgs = pkgs .. " " .. pkg
        end
    end

    if #pkgs == 0 then
        print("Nothing to remove...")
        return 0
    else
        return pacman_handle("-Rdd", pkgs)
    end
end

---@param hooks hookAlias
---@param name string
---@return string?
local function exec_hook(hooks, name)
    local hook = hooks[name]

    if not hook then
        print("WARNING: An unknown hook is being called")
        return
    end

    if hook == "" then
        return
    end

    local handle, errmsg = io.popen(hook, "r")

    if not handle then
        printf("ERROR: Unknown shell invocation error for %s hook: %s", name, errmsg)
        return
    end

    local output = handle:read("*a")
    local success, _, exitcode = handle:close()

    if not success then
        printf("ERROR: Error occurred while executing %s hook: %s", name, exitcode)
    end

    return output
end

---@param text string
---@return string, integer
local function escape_pattern(text)
    return text:gsub("([^%w])", "%%%1")
end

---@param path string
---@return table<string, {packages: string?, hooks: hookAlias}>
local function parse_profiles(path)
    local profile_name_pattern = "^%[([A-Za-z0-9-. ]+)%]"
    local packages_pattern     = "^packages%s*=%s*'?\"?([A-Za-z0-9- ]+)'?\"?"
    local profiles             = {}
    ---@type string?
    local profile
    ---@type string?
    local captured_hook

    local profile_file, errmsg = io.open(path, "r")

    if not profile_file then
        die("Failed to open %s: %s", path, errmsg)
    end

    local line = profile_file:read("l")

    while line do
        local profile_found = line:match(profile_name_pattern)

        if profile_found and not captured_hook then
            profile = profile_found
            profiles[profile] = {
                ["packages"] = nil,
                ["hooks"] = {
                    ["pre_install"] = "",
                    ["post_install"] = "",
                    ["post_remove"] = "",
                    ["pre_remove"] = "",
                    ["conditional_packages"] = ""
                }
            }
        else
            if profile then
                if not profiles[profile].packages then
                    profiles[profile].packages = line:match(packages_pattern)
                else
                    local hooks = profiles[profile]["hooks"]
                    if captured_hook == nil then
                        for hook in pairs(hooks) do
                            local hook_pattern = '^' .. escape_pattern(hook) .. '%s*=%s*"""'
                            if line:match(hook_pattern) then
                                captured_hook = hook
                            end
                        end
                    else
                        local hook_end = line:match('(.*)"""')
                        if hook_end then
                            hooks[captured_hook] = hooks[captured_hook] .. hook_end
                            captured_hook = nil
                        else
                            hooks[captured_hook] = hooks[captured_hook] .. line .. "\n"
                        end
                    end
                end
            end
        end

        line = profile_file:read("l")
    end

    return profiles
end


---@param profiles table<string, {packages: string?, hooks: hookAlias}>
---@param name string
---@return string?, hookAlias
local function get_profile(profiles, name)
    local packages
    local hooks = {}
    local keys = {}

    for tname in name:gmatch("([^.]*)") do
        keys[#keys + 1] = tname
        local key = table.concat(keys, ".")
        local profile = profiles[key]

        if not profile then
            die("Parent profile not found: %s", key)
        end

        if profile.packages then
            packages = profile.packages
        end

        for hook_name, hook in pairs(profile.hooks) do
            if hooks[hook_name] ~= "" then
                if hook ~= "" then
                    hooks[hook_name] = hook
                end
            else
                hooks[hook_name] = hook
            end
        end
    end

    return packages, hooks
end

---@param options table<string, integer>
---@param option string
---@param default string?
---@return string
local function get_opt_argument(options, option, default)
    local index = options[option]
    if index == nil then
        if default then
            return default
        else
            die("The mandatory option --%s is omitted", option)
        end
    else
        local option_argument = arg[index + 1]
        if option_argument == nil or options[option_argument:gsub("-%-", "")] then
            die("Missing argument for option %s", option)
        else
            return option_argument
        end
    end
end

local function main()
    local options = get_opts(arg)

    cachedir = get_opt_argument(options, "cachedir", "/var/cache/pacman/pkg")
    pmroot = get_opt_argument(options, "pmroot", "/")
    pmconfig = get_opt_argument(options, "pmconfig", "/etc/pacman.conf")
    pacman = table.concat({ "pacman --noconfirm", "--cachedir", cachedir, "-r", pmroot, "--config", pmconfig }, " ")
    local profile_name = get_opt_argument(options, "profile")
    local path = get_opt_argument(options, "path")

    if not path or not profile_name then
        return
    end

    if options.sync then
        sync = true
    end

    if not file_exists(path) then
        die("Profiles file is not found: %s", path)
    end

    local profiles = parse_profiles(path)

    if not next(profiles) then
        die("Couldn't find any profiles in %s", path)
    end

    if not profiles[profile_name] then
        die("Profile not found")
    end

    local packages, hooks = get_profile(profiles, profile_name)

    if not packages then
        die("Profile %s is not valid", profile_name)
    end

    if packages and not check_on_multilib() then
        packages = packages:gsub("%s?(lib32-[A-Za-z0-9-]+)", "")
    end

    if options.install then
        exec_hook(hooks, "pre_install")

        local conditional_packages = exec_hook(hooks, "conditional_packages")

        if conditional_packages then
            packages = packages .. " " .. conditional_packages
        end

        local code = install(packages)
        if code ~= 0 then
            exec_hook(hooks, "pre_remove")
            die("ERROR: Pacman command was failed! Exit code: %s", code)
        else
            exec_hook(hooks, "post_install")
        end
    elseif options.remove then
        exec_hook(hooks, "pre_remove")

        local conditional_packages = exec_hook(hooks, "conditional_packages")

        if conditional_packages then
            packages = packages .. " " .. conditional_packages
        end

        local code = remove(packages)
        if code ~= 0 then
            die("ERROR: Pacman command was failed! Exit code: %s", code)
        else
            exec_hook(hooks, "post_remove")
        end
    else
        die("Action is missing, exit...")
    end
end

---@diagnostic disable-next-line
if _TEST then -- luacheck: ignore
    return {
        get_profile = get_profile,
        parse_profiles = parse_profiles
    }
else
    main()
end
