--- @module "zync.api.manager"
---
--- Zync submodule to manage Zephyr app lifecycle
---
local Manager = {}

--- Dependencies
local lfs = require('lfs')
local utils = require('zync.lib.utils')
local log = utils.log
local normalize_exit_code = utils.normalize_exit_code
local exit_code_from_execute = utils.exit_code_from_execute

--- Resolves build directory based on config and profile.
---
--- @param config zync.api.config
---
--- @return string
---
local function resolve_build_dir(config)
    if config.build_dir and config.build_dir ~= '' then
        return config.build_dir
    end
    if config.profile and config.profile ~= '' then
        return 'build/' .. config.profile
    end
    return 'build'
end

--- Resolves project root directory for command execution.
---
--- @param config zync.api.config
---
--- @return string
---
local function resolve_project_dir(config)
    if config.project_dir and config.project_dir ~= '' then
        return config.project_dir
    end

    local cwd = lfs.currentdir()
    local project_dir = cwd:match('^(.*)/build/[^/]+$')
    if project_dir and project_dir ~= '' then
        return project_dir
    end
    project_dir = cwd:match('^(.*)/build$')
    if project_dir and project_dir ~= '' then
        return project_dir
    end

    return cwd
end

--- Creates build directory
---
--- @param config table<any>    App config table
---
function Manager.mkbuild(config)
    if not config.is_build then
        local build_dir = resolve_build_dir(config)
        os.execute('mkdir -p ' .. build_dir)
        lfs.chdir(build_dir)
        config.is_build = true
    end
end

--- Checks whether app is configured
---
--- @return boolean True if configured, false otherwise
---
local function is_configured()
    local ccache = utils.find_file('CMakeCache.txt')
    local nbuild = utils.find_file('build.ninja')

    if ccache ~= '' and nbuild ~= '' then
        return true
    end

    return false
end

--- Opens build dir, if not exists creates it as well
---
--- @param config zync.api.config   App config
---
local function open_build(config)
    local build_dir = resolve_build_dir(config)
    local project_dir = resolve_project_dir(config)
    local desired = project_dir .. '/' .. build_dir
    local cwd = lfs.currentdir()

    if config.is_build and cwd ~= desired then
        config.is_build = false
    end

    if config.is_build and config.is_build == false or not config.is_build then
        lfs.chdir(project_dir)
        Manager.mkbuild(config)
    end
end

--- @class zync.api.manager.cache
---
--- CMake cache config table
---
--- @field board string Cached board config
---

--- Reads CMake cache
---
--- @param config zync.api.config   App config table
---
--- @return zync.api.manager.cache  CMake cache config table
---
local function read_cache(config)
    open_build(config)

    local cpath = utils.find_file('CMakeCache.txt')
    local cont = utils.read_file(cpath)
    local cache = {}

    local start, stop, board = cont:find('BOARD:STRING=(.-)%s')
    if start and stop and board then
        cache.board = board
    end

    return cache
end

--- Cleans build dir
---
--- @param config zync.api.config   App config
---
local function clean(config)
    log('Cleaning build directory')
    local build_dir = resolve_build_dir(config)
    local project_dir = resolve_project_dir(config)
    lfs.chdir(project_dir)
    local ok, exit_type, code = os.execute('rm -rf ./' .. build_dir)
    config.is_build = false
    open_build(config)
    return exit_code_from_execute(ok, exit_type, code)
end

--- Builds command from a table of arguments
---
--- @param args table<string>   Table of args
---
--- @return string  Built command
---
function Manager.build_cmd(args)
    local cmd = ''
    for _, arg in ipairs(args) do
        cmd = cmd .. ' ' .. arg
    end
    return cmd
end

--- @type table<string, string>
---
--- Command arg cmake command prefixes
---
local arg_prefixes = {
    board = 'BOARD',
    runner = 'BOARD_FLASH_RUNNER',
}

--- @type table<string, string>
---
--- Command arg west flash command prefixes
---
local flash_arg_prefixes = {
    board = 'board',
    runner = 'runner',
    image = 'domain',
}

--- Add arg to arg table
---
--- @param args table<string>   Array of args to add to
--- @param arg string           Arg to add
---
local function add_arg(args, arg)
    table.insert(args, arg)
end

--- Normalizes extra args to an array of strings
---
--- @param value any
---
--- @return table<string>
---
local function normalize_extra_args(value)
    local out = {}
    if type(value) == 'string' then
        table.insert(out, value)
    elseif type(value) == 'table' then
        for _, v in ipairs(value) do
            if type(v) == 'string' then
                table.insert(out, v)
            end
        end
    end
    return out
end

--- Resolves extra args for a build phase, global first then profile.
---
--- @param config zync.api.config
--- @param phase string
---
--- @return table<string>
---
local function phase_extra_args(config, phase)
    local out = {}
    local function append(values)
        for _, v in ipairs(values) do
            add_arg(out, v)
        end
    end

    if config.args then
        append(normalize_extra_args(config.args[phase]))
    end

    if config.profile and config.profiles and config.profiles[config.profile] then
        local profile = config.profiles[config.profile]
        if profile.args then
            append(normalize_extra_args(profile.args[phase]))
        end
    end

    if config[''] then
        append(normalize_extra_args(config['']))
    end

    return out
end

--- Builds args based on their prefixes and config values
---
--- @param args table<string>       Array of args
--- @param config zync.api.config   App config
--- @param flash? boolean           Flash cmd args?
---
local function build_args(args, config, flash)
    local arg_prefix = '-D'
    local delimiter = '='
    local known_args = arg_prefixes

    --- use flash arg prefixes if constructing flash command
    if flash and flash == true then
        arg_prefix = '--'
        delimiter = ' '
        known_args = flash_arg_prefixes
    end

    local sorted = {}
    for arg, prefix in pairs(known_args) do
        table.insert(sorted, { arg = arg, prefix = prefix })
    end
    table.sort(sorted, function(a, b)
        return a.arg < b.arg
    end)

    -- build cmd from args
    for _, arg in ipairs(sorted) do
        if config[arg.arg] then
            add_arg(args, arg_prefix .. arg.prefix .. delimiter .. config[arg.arg])
        end
    end
end

--- Executes a command from a table of args
---
--- @param cmd table<string>    Array of cmd args
---
function Manager.execute(cmd)
    local built = Manager.build_cmd(cmd)
    log('Executing: ' .. built)
    local ok, exit_type, code = os.execute(built)
    return exit_code_from_execute(ok, exit_type, code)
end

--- Initializes project with a zync config
---
--- @param config zync.api.config   App config
---
function Manager.init(config)
    if config.is_build and config.is_build == true then
        lfs.chdir(resolve_project_dir(config))
    end

    log('Initializing zconf.lua')
    local conf = [[
return {
    board = '%s',
    runner = '%s',

    -- Uncomment to add additional args
    -- args = {
    --     -- run = '',
    -- },

    -- Uncomment to configure build profiles
    -- profiles = {
    --     sim = {
    --         board = 'native_sim',
    --     },
    --     prod = {
    --         -- runner = '',
    --     }
    -- }
}]]

    conf = string.format(conf, config.board or 'nucleo_f767zi', config.runner or 'openocd')
    utils.gen_file('./zconf.lua', conf)
    return 0
end

--- Clean command runtime handler
---
--- Cleans build directory
---
--- @param config zync.api.config   App config
---
function Manager.clean(config)
    if config and config.clean_all then
        log('Cleaning build directory')
        local project_dir = resolve_project_dir(config)
        lfs.chdir(project_dir)
        local ok, exit_type, code = os.execute('rm -rf ./build')
        config.is_build = false
        return exit_code_from_execute(ok, exit_type, code)
    end

    open_build(config)
    return normalize_exit_code(clean(config))
end

--- Checks whether zync board config matches CMake cache
---
--- If not matches, cleans build dir.
---
--- @param config zync.api.config   App config
---
local function check_boards(config)
    local cache = read_cache(config)
    if cache.board and config.board and cache.board ~= config.board then
        log('Board changed, cleaning build directory')
        Manager.clean(config)
    end
end

--- Config command runtime handler
---
--- Configures project using CMake
---
--- @param config zync.api.config   App config
---
function Manager.configure(config)
    local project_dir = resolve_project_dir(config)
    if config.sysbuild and config.sysbuild == true then
        log('Configuring sysbuild project')
    else
        log('Configuring project')
    end
    local cmd = { 'cmake -GNinja' }

    open_build(config)
    check_boards(config)

    build_args(cmd, config)
    local conf_extra = phase_extra_args(config, 'conf')
    for _, arg in ipairs(conf_extra) do
        add_arg(cmd, arg)
    end

    if config.sysbuild and config.sysbuild == true then
        add_arg(cmd, '-DAPP_DIR=' .. project_dir)
        add_arg(cmd, config.zephyr_base .. '/share/sysbuild')
    else
        add_arg(cmd, project_dir)
    end

    return normalize_exit_code(Manager.execute(cmd))
end

--- Reconfigures project
---
--- Re-runs cmake config if needed
---
--- @param config zync.api.config   App config
---
local function reconfigure(config)
    open_build(config)
    local cache = read_cache(config)

    if is_configured() == false then
        return normalize_exit_code(Manager.configure(config))
    elseif cache.board and config.board and cache.board ~= config.board then
        return normalize_exit_code(Manager.configure(config))
    end

    return 0
end

--- Build command runtime handler
---
--- Builds project using ninja
---
--- @param config zync.api.config   App config
---
function Manager.build(config)
    local cmd = { 'ninja' }
    log('Building project')
    local exit_code = normalize_exit_code(reconfigure(config))
    if exit_code ~= 0 then
        return exit_code
    end
    local build_extra = phase_extra_args(config, 'build')
    for _, arg in ipairs(build_extra) do
        add_arg(cmd, arg)
    end
    return normalize_exit_code(Manager.execute(cmd))
end

--- @type table<string, boolean>
---
--- Cmake build directories
---
local build_dirs = {
    ['.cmake'] = true,
    ['CMakeFiles'] = true,
    ['Kconfig'] = true,
    ['modules'] = true,
    ['zephyr'] = true,
    ['_sysbuild'] = true,
}

--- Finds sysbuild project images in build dir
---
--- @return table<string>   Array of sysbuild project images
---
local function find_images()
    local images = {}

    local dir = lfs.currentdir()
    for file in lfs.dir(dir) do
        if file ~= '..' and file ~= '.' and lfs.attributes(file).mode == 'directory' and not build_dirs[file] then
            table.insert(images, file)
        end
    end

    table.sort(images, function(a, b)
        return a < b
    end)

    return images
end

--- Lists available images in a sysbuild project
---
function Manager.list_images()
    local images = find_images()

    log('Listing sysbuild images')
    print('\nAvailable sysbuild images:\n')
    for i, image in ipairs(images) do
        print(i .. ': ' .. image)
    end
    print()

    return 0
end

--- Resolves selected sysbuild project image
---
--- Updates config.image with a resolved image name.
---
local function resolve_image(config)
    -- if sysbuild project find its images
    local images = find_images()

    if not config.image then
        -- if no image provided fallback to 1st non bootloader image
        for _, image in ipairs(images) do
            if image ~= 'mcuboot' then
                config.image = image
                break
            end
        end
    elseif config.image and type(config.image) == 'number' then
        -- convert image number to image name
        config.image = images[config.image]
    end
end

--- Flash command runtime handler
---
--- Flashes built binary using configured runner
---
--- @param config zync.api.config   App config
---
function Manager.flash(config)
    local cmd = { 'west flash' }
    if not config.sysbuild then
        if config.list and config.list == true then
            log('Option --list is only supported for sysbuild projects')
            return 1
        end
        if config.image and config.image ~= '' then
            log('Option --image is only supported for sysbuild projects')
            return 1
        end
    end
    log('Flashing project')
    local exit_code = normalize_exit_code(Manager.build(config))
    if exit_code ~= 0 then
        return exit_code
    end

    if config.sysbuild then
        -- list available images if called with list arg
        if config.list and config.list == true then
            Manager.list_images()
            return 0
        end

        resolve_image(config)

        if config.image and type(config.image) == 'string' and config.image ~= '' then
            log('Flashing sysbuild image: ' .. config.image)
            -- construct flash cmd for selected image
            build_args(cmd, config, true)
        else
            log('Failed to resolve image')
            return 1
        end
    end

    local flash_extra = phase_extra_args(config, 'flash')
    for _, arg in ipairs(flash_extra) do
        add_arg(cmd, arg)
    end

    return normalize_exit_code(Manager.execute(cmd))
end

--- Run command runtime handler
---
--- Runs simulation
---
--- @param config zync.api.config   App config
---
function Manager.run(config)
    local cmd = { 'ninja run' }
    if not config.sysbuild then
        if config.list and config.list == true then
            log('Option --list is only supported for sysbuild projects')
            return 1
        end
        if config.image and config.image ~= '' then
            log('Option --image is only supported for sysbuild projects')
            return 1
        end
    end
    log('Running project')
    local exit_code = normalize_exit_code(reconfigure(config))
    if exit_code ~= 0 then
        return exit_code
    end

    local run_dir
    local zephyr_bin = 'zephyr/zephyr.exe'

    if config.sysbuild then
        -- list available images if called with list arg
        if config.list and config.list == true then
            Manager.list_images()
            return 0
        end

        resolve_image(config)

        if config.image and type(config.image) == 'string' and config.image ~= '' then
            log('Running sysbuild image: ' .. config.image)
            run_dir = config.image
            zephyr_bin = run_dir .. '/' .. zephyr_bin
        else
            log('Failed to resolve image')
            return 1
        end
    end

    local has_zephyr_bin = lfs.attributes(zephyr_bin, 'mode') == 'file'
    if has_zephyr_bin then
        if not config.norebuild then
            exit_code = normalize_exit_code(Manager.build(config))
            if exit_code ~= 0 then
                return exit_code
            end
        end
    end

    if run_dir then
        lfs.chdir(run_dir)
    end

    if has_zephyr_bin then
        local exec_bin = zephyr_bin
        if run_dir then
            exec_bin = 'zephyr/zephyr.exe'
        end
        cmd = { exec_bin }
        local run_extra = phase_extra_args(config, 'run')
        for _, arg in ipairs(run_extra) do
            add_arg(cmd, arg)
        end
    end

    return normalize_exit_code(Manager.execute(cmd))
end

--- Lists available zephyr boards
---
--- @param _ table<any>   Unused config
---
function Manager.boards(_)
    local cmd = { 'west boards' }
    log('Listing available boards')
    print('\nAvailable boards:\n')
    local exit_code = normalize_exit_code(Manager.execute(cmd))
    print()
    return exit_code
end

--- Lists available Zephyr runners
---
--- @param config zync.api.config   App config
---
function Manager.runners(config)
    local runners = {}
    local runner_dir

    if config.zephyr_base and config.zephyr_base ~= '' then
        log('Listing available runners')
        runner_dir = config.zephyr_base .. '/scripts/west_commands/runners'
        print('\nAvailable runners:\n')
    else
        log('Failed to determine zephyr base.')
        log('Try calling zync from inside a zephyr workspace or set up a ZEPHYR_BASE env variable.')
        return 1
    end

    for file in lfs.dir(runner_dir) do
        if file ~= '.' and file ~= '..' and file ~= '__init__.py' then
            table.insert(runners, file:match('(.+)%..+'))
        end
    end

    table.sort(runners, function(a, b)
        return a < b
    end)

    for _, runner in ipairs(runners) do
        print(runner)
    end

    print()

    return 0
end

--- Lists configured profiles
---
--- @param config zync.api.config   App config
---
function Manager.profiles(config)
    if not config.profiles then
        print('\nNo profiles configured - project configured in profile-less mode\n')
        return 0
    end

    local profiles = {}
    if config.profile_order and #config.profile_order > 0 then
        for _, name in ipairs(config.profile_order) do
            if config.profiles[name] then
                table.insert(profiles, name)
            end
        end
    else
        for name in pairs(config.profiles) do
            table.insert(profiles, name)
        end
        table.sort(profiles, function(a, b)
            return a < b
        end)
    end

    log('Listing configured profiles')
    print('\nConfigured profiles:\n')
    for _, name in ipairs(profiles) do
        print(name)
    end
    print()

    return 0
end

return Manager
