--- @module "zync.api.configurator"
---
--- Zync configurator
---
local Configurator = {}

--- Dependencies
local lfs = require('lfs')
local utils = require('zync.lib.utils')
local sandbox = require('zync.api.configurator.sandbox')

--- Parses Lua source to extract profile declaration order.
---
--- This is used to preserve zconf.lua ordering deterministically.
---
local function skip_string(src, idx, quote)
    local i = idx + 1
    while i <= #src do
        local ch = src:sub(i, i)
        if ch == '\\' then
            i = i + 2
        elseif ch == quote then
            return i + 1
        else
            i = i + 1
        end
    end
    return i
end

local function skip_line_comment(src, idx)
    local i = idx + 2
    while i <= #src do
        local ch = src:sub(i, i)
        if ch == '\n' then
            return i + 1
        end
        i = i + 1
    end
    return i
end

local function skip_block_comment(src, idx)
    local i = idx + 4
    while i <= #src do
        if src:sub(i, i + 1) == ']]' then
            return i + 2
        end
        i = i + 1
    end
    return i
end

local function skip_ws_and_comments(src, idx)
    local i = idx
    while i <= #src do
        local ch = src:sub(i, i)
        if ch:match('%s') then
            i = i + 1
        elseif ch == '-' and src:sub(i, i + 1) == '--' then
            if src:sub(i + 2, i + 3) == '[[' then
                i = skip_block_comment(src, i)
            else
                i = skip_line_comment(src, i)
            end
        else
            return i
        end
    end
    return i
end

local function scan_identifier(src, idx)
    local i = idx
    while i <= #src do
        local ch = src:sub(i, i)
        if ch:match('[%w_]') then
            i = i + 1
        else
            break
        end
    end
    return src:sub(idx, i - 1), i
end

local function scan_string(src, idx)
    local quote = src:sub(idx, idx)
    local i = idx + 1
    local out = {}
    while i <= #src do
        local ch = src:sub(i, i)
        if ch == '\\' then
            local nextc = src:sub(i + 1, i + 1)
            table.insert(out, nextc)
            i = i + 2
        elseif ch == quote then
            return table.concat(out), i + 1
        else
            table.insert(out, ch)
            i = i + 1
        end
    end
    return table.concat(out), i
end

local function find_profiles_block(src)
    local i = 1
    while i <= #src do
        local ch = src:sub(i, i)
        if ch == '"' or ch == "'" then
            i = skip_string(src, i, ch)
        elseif ch == '-' and src:sub(i, i + 1) == '--' then
            if src:sub(i + 2, i + 3) == '[[' then
                i = skip_block_comment(src, i)
            else
                i = skip_line_comment(src, i)
            end
        elseif ch:match('[%a_]') then
            local ident, next_i = scan_identifier(src, i)
            if ident == 'profiles' then
                local j = skip_ws_and_comments(src, next_i)
                if src:sub(j, j) == '=' then
                    j = skip_ws_and_comments(src, j + 1)
                    if src:sub(j, j) == '{' then
                        return j
                    end
                end
            end
            i = next_i
        else
            i = i + 1
        end
    end
    return nil
end

local function extract_profile_order(src)
    local start = find_profiles_block(src)
    if not start then
        return {}
    end

    local order = {}
    local i = start + 1
    local depth = 1
    while i <= #src and depth > 0 do
        local ch = src:sub(i, i)
        if ch == '"' or ch == "'" then
            local key, next_i = scan_string(src, i)
            if depth == 1 then
                local j = skip_ws_and_comments(src, next_i)
                if src:sub(j, j) == '=' then
                    table.insert(order, key)
                end
            end
            i = next_i
        elseif ch == '-' and src:sub(i, i + 1) == '--' then
            if src:sub(i + 2, i + 3) == '[[' then
                i = skip_block_comment(src, i)
            else
                i = skip_line_comment(src, i)
            end
        elseif ch == '{' then
            depth = depth + 1
            i = i + 1
        elseif ch == '}' then
            depth = depth - 1
            i = i + 1
        elseif depth == 1 and ch:match('[%a_]') then
            local ident, next_i = scan_identifier(src, i)
            local j = skip_ws_and_comments(src, next_i)
            if src:sub(j, j) == '=' then
                table.insert(order, ident)
            end
            i = next_i
        elseif depth == 1 and ch == '[' then
            local j = skip_ws_and_comments(src, i + 1)
            local q = src:sub(j, j)
            if q == '"' or q == "'" then
                local key, next_i = scan_string(src, j)
                j = skip_ws_and_comments(src, next_i)
                if src:sub(j, j) == ']' then
                    j = skip_ws_and_comments(src, j + 1)
                    if src:sub(j, j) == '=' then
                        table.insert(order, key)
                    end
                end
                i = next_i
            else
                i = i + 1
            end
        else
            i = i + 1
        end
    end

    return order
end

--- Builds profile order from zconf.lua or falls back to table iteration.
---
--- @param conf zync.api.config
--- @param zconf_path string
---
--- @return table<string>
local function profile_order_from_config(conf, zconf_path)
    local order = {}
    if zconf_path and zconf_path ~= '' then
        local src = utils.read_file(zconf_path)
        if src ~= '' then
            order = extract_profile_order(src)
        end
    end

    if #order == 0 then
        for name in pairs(conf.profiles) do
            table.insert(order, name)
        end
    end

    return order
end

--- Applies profile selection to the config, merging board/runner values.
---
--- Honors default_profile and preserves deterministic profile order.
---
--- @param conf zync.api.config
--- @param zconf_path string
local function apply_profile(conf, zconf_path)
    if not conf.profiles then
        if conf.profile and conf.profile ~= '' then
            error("zconf: profile '" .. tostring(conf.profile) .. "' not defined (profiles not configured)", 2)
        end
        return
    end

    if type(conf.profiles) ~= 'table' then
        error("zconf: field 'profiles' has to be table (got " .. type(conf.profiles) .. ')', 2)
    end

    local order = profile_order_from_config(conf, zconf_path)
    conf.profile_order = order
    if #order == 0 then
        error('zconf: no profiles defined under zconf.lua profiles', 2)
    end

    local name = conf.profile
    if not name or name == '' then
        if conf.default_profile and conf.default_profile ~= '' then
            name = conf.default_profile
        else
            name = order[1]
        end
    end

    local profile = conf.profiles[name]
    if not profile then
        error("zconf: unknown profile '" .. tostring(name) .. "'. Available profiles: " .. table.concat(order, ', '), 2)
    end
    if type(profile) ~= 'table' then
        error("zconf: profile '" .. tostring(name) .. "' must be a table", 2)
    end

    conf.profile = name
    if profile.board then
        conf.board = profile.board
    end
    if profile.runner then
        conf.runner = profile.runner
    end
end

--- @class zync.api.configurator.workspace
---
--- Zync configurator workspace table
---
--- @field cwd string           Current working dir path
--- @field is_build boolean     Is current dir a build dir?
--- @field sysbuild boolean     Is current project managed by sysbuild?
--- @field zync_conf string     Path of of zync config file
--- @field project_dir? string  Project root path
--- @field conf zync.api.config Zync app config
---

--- Initializes app config
---
--- @param unsafe boolean?  Force unsafe execution when loading zync config
--- @param profile? string  Optional profile to apply
---
--- @return zync.api.config
---
function Configurator.init_conf(unsafe, profile)
    --- @type zync.api.configurator.workspace
    ---
    local ws = {
        cwd = lfs.currentdir(),
        is_build = false,
        sysbuild = false,
        zync_conf = '',
        conf = {},
    }

    --- Resolves build/project context from the current working directory.
    ---
    --- Supports build/ and build/<profile> layouts.
    local function resolve_build_path(path)
        local project_dir, profile_name = path:match('^(.*)/build/([^/]+)$')
        if project_dir and profile_name then
            return true, project_dir, 'build/' .. profile_name, profile_name
        end

        project_dir = path:match('^(.*)/build$')
        if project_dir then
            return true, project_dir, 'build', nil
        end

        return false, path, 'build', nil
    end

    --- Finds zync config file
    ---
    --- @return boolean True if config file found, false otherwise
    ---
    local function find_conf()
        local cpath = utils.find_file('zconf.lua')
        if cpath ~= '' then
            ws.zync_conf = cpath
            return true
        end

        return false
    end

    --- Check if cwd is a build directory and capture project/build layout.
    local build_dir_from_path
    ws.is_build, ws.project_dir, build_dir_from_path = resolve_build_path(ws.cwd)

    --- Find config file
    if not find_conf() then
        if ws.is_build then
            local og_dir = lfs.currentdir()
            lfs.chdir(ws.project_dir)
            find_conf()
            lfs.chdir(og_dir)
        end
    end

    --- Check whether project is managed by sysbuild
    local sysbuild = utils.find_file('sysbuild.conf')
    if sysbuild ~= '' and not ws.is_build then
        ws.sysbuild = true
    elseif ws.is_build then
        local og_dir = lfs.currentdir()
        lfs.chdir(ws.project_dir)
        sysbuild = utils.find_file('sysbuild.conf')
        if sysbuild ~= '' then
            ws.sysbuild = true
        end
        lfs.chdir(og_dir)
    end

    --- Load config file (if present) using the sandbox.
    if ws.zync_conf ~= '' then
        local cfg, err = sandbox.load_zconf(ws.zync_conf, unsafe)
        if err then
            error(err, 2)
        end
        if cfg then
            ws.conf = cfg
        elseif not ws.is_build then
            ws.conf = { invalid = true }
        end
    end

    if profile and profile ~= '' then
        ws.conf.profile = profile
    end

    -- Apply profile selection after config load (including CLI override).
    local ok, perr = pcall(apply_profile, ws.conf, ws.zync_conf)
    if not ok then
        ws.conf = { invalid = true, error = perr }
        if not ws.is_build then
            error(perr, 2)
        end
    end

    --- Resolve build dir based on profile or current build path.
    local build_dir = 'build'
    if ws.conf.profile and ws.conf.profile ~= '' then
        build_dir = 'build/' .. ws.conf.profile
    elseif build_dir_from_path and build_dir_from_path ~= '' then
        build_dir = build_dir_from_path
    end
    ws.conf.build_dir = build_dir
    ws.conf.project_dir = ws.project_dir

    --- Get Zephyr workspace and base from ENV
    if not ws.conf.zephyr_ws or (ws.conf.zephyr_ws and ws.conf.zephyr_ws == '') then
        ws.conf.zephyr_ws = os.getenv('ZEPHYR_WORKSPACE') or ''
    end
    if not ws.conf.zephyr_base or (ws.conf.zephyr_base and ws.conf.zephyr_base == '') then
        ws.conf.zephyr_base = os.getenv('ZEPHYR_BASE') or ''
    end

    --- Get Zephyr workspace based on Zephyr base
    if ws.conf.zephyr_base ~= '' and ws.conf.zephyr_ws == '' then
        local start, stop = ws.conf.zephyr_base:find('%/zephyr$')
        if start and stop then
            ws.conf.zephyr_ws = ws.conf.zephyr_base:sub(0, start)
        end
    end

    --- Get Zephyr base based Zephyr on Zephyr workspace
    if ws.conf.zephyr_ws ~= '' and ws.conf.zephyr_base == '' then
        ws.conf.zephyr_base = ws.conf.zephyr_ws .. '/zephyr'
    end

    --- Get zephyr workspace and base based on app location
    if ws.conf.zephyr_ws == '' and ws.conf.zephyr_base == '' then
        local start, stop = ws.cwd:find('zephyrproject')
        if start and stop then
            ws.conf.zephyr_ws = ws.cwd:sub(0, stop)
            ws.conf.zephyr_base = ws.conf.zephyr_ws .. '/zephyr'
        end
    end

    --- Construct zync config base values based on configurator workspace
    ws.conf.cwd = ws.cwd
    ws.conf.is_build = ws.is_build
    ws.conf.sysbuild = ws.sysbuild

    --- Interpolate config placeholders using resolved config values
    --- First pass expands top-level config values (excluding profiles),
    --- then each profile is interpolated with its own scoped overrides.
    local profiles = ws.conf.profiles
    ws.conf.profiles = nil
    utils.interpolate_recursive(ws.conf, ws.conf)
    ws.conf.profiles = profiles

    if type(profiles) == 'table' then
        for name, profile_cfg in pairs(profiles) do
            if type(profile_cfg) == 'table' then
                local scope = utils.copy_table(ws.conf)
                scope.profile = name
                -- Profile scope overrides build_dir and selected keys to
                -- ensure ${key} resolves using the active profile values.
                scope.build_dir = profile_cfg.build_dir and profile_cfg.build_dir ~= '' and profile_cfg.build_dir
                    or ('build/' .. name)
                if profile_cfg.board then
                    scope.board = profile_cfg.board
                end
                if profile_cfg.runner then
                    scope.runner = profile_cfg.runner
                end
                if profile_cfg.zephyr_ws then
                    scope.zephyr_ws = profile_cfg.zephyr_ws
                end
                if profile_cfg.zephyr_base then
                    scope.zephyr_base = profile_cfg.zephyr_base
                end

                utils.interpolate_recursive(profile_cfg, scope)
            end
        end
    end

    return ws.conf
end

return Configurator
