-- ─────────────────────────────────────────────────────────────────────────────
-- Shared preferences
--
-- Reads the same file Panama Settings writes:
--   $XDG_CONFIG_HOME/panama/settings.json   (default ~/.config/panama/settings.json)
--
-- This is what makes the desktop one product rather than two that happen to
-- agree. The relationship is:
--
--   * the Lua config is the DEFAULT -- every prefs.get() call passes the shipped
--     value as its fallback, so this config still works standalone with no JSON
--     file at all;
--   * the JSON file is the TRUTH -- both Hyprland and Quickshell read it;
--   * Panama Settings is the EDITOR -- it writes the file and applies the change
--     live through `hyprctl eval`, so nothing needs a reload and the two sides
--     cannot drift apart.
--
-- Nothing here may raise. A missing, empty, truncated, or actively malformed
-- file must cost the user nothing worse than their customizations; it must
-- never cost them a working compositor. Every failure path returns the caller's
-- fallback.
-- ─────────────────────────────────────────────────────────────────────────────

local prefs = {}

-- ── A small JSON reader ─────────────────────────────────────────────────────
-- Hyprland's Lua has no JSON support and pulling in a rock for a flat object of
-- scalars is not worth the dependency. Handles the whole format apart from
-- non-ASCII \u escapes, which are replaced rather than decoded -- no setting is
-- a non-ASCII string, and mangling one is preferable to failing the parse.

local function decode(text)
    local pos = 1

    local function skipSpace()
        pos = text:find("[^ \t\r\n]", pos) or #text + 1
    end

    local parseValue

    local function parseString()
        pos = pos + 1 -- opening quote
        local parts = {}
        while true do
            local char = text:sub(pos, pos)
            if char == "" then
                error("unterminated string")
            elseif char == '"' then
                pos = pos + 1
                break
            elseif char == "\\" then
                local escape = text:sub(pos + 1, pos + 1)
                local simple = {
                    n = "\n", t = "\t", r = "\r", b = "\b", f = "\f",
                    ['"'] = '"', ["\\"] = "\\", ["/"] = "/",
                }
                if simple[escape] then
                    parts[#parts + 1] = simple[escape]
                    pos = pos + 2
                elseif escape == "u" then
                    local code = tonumber(text:sub(pos + 2, pos + 5), 16)
                    parts[#parts + 1] = (code and code < 128) and string.char(code) or "?"
                    pos = pos + 6
                else
                    error("invalid escape")
                end
            else
                parts[#parts + 1] = char
                pos = pos + 1
            end
        end
        return table.concat(parts)
    end

    local function parseNumber()
        local literal = text:match("^-?%d+%.?%d*[eE]?[-+]?%d*", pos)
        if not literal or literal == "" then
            error("invalid number")
        end
        pos = pos + #literal
        local value = tonumber(literal)
        if not value then
            error("invalid number")
        end
        return value
    end

    local function parseObject()
        pos = pos + 1 -- opening brace
        local out = {}
        skipSpace()
        if text:sub(pos, pos) == "}" then
            pos = pos + 1
            return out
        end
        while true do
            skipSpace()
            if text:sub(pos, pos) ~= '"' then
                error("expected key")
            end
            local key = parseString()
            skipSpace()
            if text:sub(pos, pos) ~= ":" then
                error("expected colon")
            end
            pos = pos + 1
            out[key] = parseValue()
            skipSpace()
            local char = text:sub(pos, pos)
            pos = pos + 1
            if char == "}" then
                return out
            elseif char ~= "," then
                error("expected comma or closing brace")
            end
        end
    end

    local function parseArray()
        pos = pos + 1 -- opening bracket
        local out = {}
        skipSpace()
        if text:sub(pos, pos) == "]" then
            pos = pos + 1
            return out
        end
        while true do
            out[#out + 1] = parseValue()
            skipSpace()
            local char = text:sub(pos, pos)
            pos = pos + 1
            if char == "]" then
                return out
            elseif char ~= "," then
                error("expected comma or closing bracket")
            end
        end
    end

    parseValue = function()
        skipSpace()
        local char = text:sub(pos, pos)
        if char == "{" then
            return parseObject()
        elseif char == "[" then
            return parseArray()
        elseif char == '"' then
            return parseString()
        elseif text:sub(pos, pos + 3) == "true" then
            pos = pos + 4
            return true
        elseif text:sub(pos, pos + 4) == "false" then
            pos = pos + 5
            return false
        elseif text:sub(pos, pos + 3) == "null" then
            pos = pos + 4
            return nil
        elseif char == "" then
            error("unexpected end of input")
        else
            return parseNumber()
        end
    end

    local value = parseValue()
    if type(value) ~= "table" then
        error("top level value is not an object")
    end
    return value
end

-- ── Loading ─────────────────────────────────────────────────────────────────

local function settingsPath()
    local configHome = os.getenv("XDG_CONFIG_HOME")
    if configHome == nil or configHome == "" then
        local home = os.getenv("HOME")
        if home == nil or home == "" then
            return nil
        end
        configHome = home .. "/.config"
    end
    return configHome .. "/panama/settings.json"
end

local function read()
    local path = settingsPath()
    if not path then
        return {}
    end
    local file = io.open(path, "r")
    if not file then
        return {} -- no file yet is the normal first-run case, not an error
    end
    local text = file:read("*a")
    file:close()
    if not text or text:match("^%s*$") then
        return {}
    end
    local ok, parsed = pcall(decode, text)
    if ok and type(parsed) == "table" then
        return parsed
    end
    return {}
end

-- Loaded once at config time. A pcall around the whole thing so that even an
-- unanticipated failure in the reader degrades to shipped defaults.
local values = {}
do
    local ok, parsed = pcall(read)
    if ok and type(parsed) == "table" then
        values = parsed
    end
end

-- ── Public interface ────────────────────────────────────────────────────────

-- Returns the stored value for `key`, or `fallback` when it is absent or is not
-- the same type as the fallback. The type guard matters: a stale or hand-edited
-- file that puts a string where Hyprland needs a number would otherwise abort
-- the config, taking down far more than the one setting that was wrong.
function prefs.get(key, fallback)
    local value = values[key]
    if value == nil then
        return fallback
    end
    if type(value) ~= type(fallback) then
        return fallback
    end
    return value
end

-- Hyprland has no boolean-to-integer coercion for options that take 0/1, and
-- several of them read more naturally as a toggle in the settings UI.
function prefs.getInt(key, fallback)
    local value = values[key]
    if type(value) == "boolean" then
        return value and 1 or 0
    end
    if type(value) ~= "number" then
        return fallback
    end
    return math.floor(value + 0.5)
end

-- Read and decode any JSON file, using the same never-raise parser the
-- settings store uses. Returns an empty table for a file that is missing,
-- empty, or malformed, so a caller can index the result without checking.
--
-- Exists so config/palette.json can be read by looks.lua rather than the eight
-- accents being written out a second time in Lua. A bad palette costs the
-- accent colours, never the compositor config.
function prefs.readJson(path)
    if type(path) ~= "string" or path == "" then
        return {}
    end
    local file = io.open(path, "r")
    if not file then
        return {}
    end
    local text = file:read("*a")
    file:close()
    if not text or text:match("^%s*$") then
        return {}
    end
    local ok, parsed = pcall(decode, text)
    if not ok or type(parsed) ~= "table" then
        return {}
    end
    return parsed
end

-- True when a settings file was actually read. Useful from overrides.lua.
function prefs.loaded()
    return next(values) ~= nil
end

return prefs
