-- lmcp/json.lua — Minimal JSON encoder/decoder, zero dependencies
-- SPDX-License-Identifier: MIT

local json = {}

-- Encode --

local encode_value

local escape_chars = {
    ['"']  = '\\"',
    ['\\'] = '\\\\',
    ['\b'] = '\\b',
    ['\f'] = '\\f',
    ['\n'] = '\\n',
    ['\r'] = '\\r',
    ['\t'] = '\\t',
}

local function encode_string(s)
    return '"' .. s:gsub('[%z\1-\31"\\]', function(c)
        return escape_chars[c] or string.format('\\u%04x', c:byte())
    end) .. '"'
end

local function encode_array(t)
    local parts = {}
    for i = 1, #t do
        parts[i] = encode_value(t[i])
    end
    return '[' .. table.concat(parts, ',') .. ']'
end

local function encode_object(t)
    local parts = {}
    for k, v in pairs(t) do
        if type(k) == 'string' then
            parts[#parts + 1] = encode_string(k) .. ':' .. encode_value(v)
        end
    end
    return '{' .. table.concat(parts, ',') .. '}'
end

local function is_array(t)
    if type(t) ~= 'table' then return false end
    local n = #t
    if n == 0 then
        -- empty table: check if it has any keys
        return next(t) == nil
    end
    for k in pairs(t) do
        if type(k) ~= 'number' or k < 1 or k > n or k ~= math.floor(k) then
            return false
        end
    end
    return true
end

encode_value = function(v)
    local t = type(v)
    if v == nil or v == json.null then
        return 'null'
    elseif t == 'boolean' then
        return v and 'true' or 'false'
    elseif t == 'number' then
        if v ~= v then return 'null' end -- NaN
        if v == math.huge or v == -math.huge then return 'null' end
        if v == math.floor(v) and v >= -2^53 and v <= 2^53 then
            return string.format('%.0f', v)
        end
        return tostring(v)
    elseif t == 'string' then
        return encode_string(v)
    elseif t == 'table' then
        if is_array(v) then
            return encode_array(v)
        else
            return encode_object(v)
        end
    else
        return 'null'
    end
end

function json.encode(v)
    return encode_value(v)
end

-- Decode --

local decode_value
local ws_chars = { [' '] = true, ['\t'] = true, ['\n'] = true, ['\r'] = true }

local function skip_ws(s, pos)
    while pos <= #s and ws_chars[s:sub(pos, pos)] do
        pos = pos + 1
    end
    return pos
end

local function decode_string(s, pos)
    -- pos is at opening quote
    pos = pos + 1
    local parts = {}
    while pos <= #s do
        local c = s:sub(pos, pos)
        if c == '"' then
            return table.concat(parts), pos + 1
        elseif c == '\\' then
            pos = pos + 1
            c = s:sub(pos, pos)
            if c == 'u' then
                local hex = s:sub(pos + 1, pos + 4)
                parts[#parts + 1] = utf8.char(tonumber(hex, 16))
                pos = pos + 5
            else
                local esc = { n = '\n', r = '\r', t = '\t', b = '\b', f = '\f' }
                parts[#parts + 1] = esc[c] or c
                pos = pos + 1
            end
        else
            local next_special = s:find('["\\]', pos)
            if next_special then
                parts[#parts + 1] = s:sub(pos, next_special - 1)
                pos = next_special
            else
                parts[#parts + 1] = s:sub(pos)
                break
            end
        end
    end
    error('unterminated string')
end

local function decode_number(s, pos)
    local start = pos
    if s:sub(pos, pos) == '-' then pos = pos + 1 end
    while pos <= #s and s:sub(pos, pos):match('[%d%.eE%+%-]') do
        pos = pos + 1
    end
    local n = tonumber(s:sub(start, pos - 1))
    if not n then error('invalid number at ' .. start) end
    return n, pos
end

local function decode_array(s, pos)
    pos = pos + 1 -- skip [
    local arr = {}
    pos = skip_ws(s, pos)
    if s:sub(pos, pos) == ']' then return arr, pos + 1 end
    while true do
        local val
        val, pos = decode_value(s, pos)
        arr[#arr + 1] = val
        pos = skip_ws(s, pos)
        local c = s:sub(pos, pos)
        if c == ']' then return arr, pos + 1 end
        if c ~= ',' then error('expected , or ] at ' .. pos) end
        pos = skip_ws(s, pos + 1)
    end
end

local function decode_object(s, pos)
    pos = pos + 1 -- skip {
    local obj = {}
    pos = skip_ws(s, pos)
    if s:sub(pos, pos) == '}' then return obj, pos + 1 end
    while true do
        pos = skip_ws(s, pos)
        if s:sub(pos, pos) ~= '"' then error('expected string key at ' .. pos) end
        local key
        key, pos = decode_string(s, pos)
        pos = skip_ws(s, pos)
        if s:sub(pos, pos) ~= ':' then error('expected : at ' .. pos) end
        pos = skip_ws(s, pos + 1)
        local val
        val, pos = decode_value(s, pos)
        obj[key] = val
        pos = skip_ws(s, pos)
        local c = s:sub(pos, pos)
        if c == '}' then return obj, pos + 1 end
        if c ~= ',' then error('expected , or } at ' .. pos) end
        pos = pos + 1
    end
end

decode_value = function(s, pos)
    pos = skip_ws(s, pos)
    local c = s:sub(pos, pos)
    if c == '"' then return decode_string(s, pos)
    elseif c == '{' then return decode_object(s, pos)
    elseif c == '[' then return decode_array(s, pos)
    elseif c == 't' then
        if s:sub(pos, pos + 3) == 'true' then return true, pos + 4 end
    elseif c == 'f' then
        if s:sub(pos, pos + 4) == 'false' then return false, pos + 5 end
    elseif c == 'n' then
        if s:sub(pos, pos + 3) == 'null' then return json.null, pos + 4 end
    elseif c == '-' or c:match('%d') then
        return decode_number(s, pos)
    end
    error('unexpected character at ' .. pos .. ': ' .. c)
end

function json.decode(s)
    local val, pos = decode_value(s, 1)
    return val
end

-- Sentinel for JSON null
json.null = setmetatable({}, { __tostring = function() return 'null' end })

-- Helper: encode a table as a JSON array even if empty
function json.array(t)
    return setmetatable(t or {}, { __is_array = true })
end

return json
