-- sic.lua — remote execution through sic, shaped like an MCP session.
--
-- Why this exists alongside mcp.lua: token cost at session start.
--
-- The MCP path announces every remote tool up front. tools_schema() emits one
-- OpenAI function schema per tool per server, and those schemas ride in EVERY
-- request for the whole session. A single lmcp host exposes ~31 tools; two
-- servers put ~36 full JSON-Schema blobs in front of the model before it has
-- been asked anything. That is paid for on every turn.
--
-- sic inverts it: three tools, and discovery happens ON DEMAND. The model asks
-- what a host offers only when it needs to know, and the 31 schemas materialise
-- for that one turn instead of all of them. Same reach, a fraction of the
-- standing cost.
--
-- Two further consequences, both wanted:
--   * No bearer token in the client config. sic rides ssh; the remote
--     lmcp-tool reads the token from its own canonical location.
--   * No reachable port. The lmcp servers may bind loopback only.
--
-- This module deliberately mirrors mcp.lua's Session surface — connect /
-- initialize / list_tools / call_tool / close — so repl.lua's tools_schema()
-- and dispatch need no special case. A sic session is just another session.

local ffi = require("ffi")
local bit = require("bit")
local C   = ffi.C

local M = {}
local Session = {}
Session.__index = Session

-- ---------------------------------------------------------------- quoting
-- Single-quote for /bin/sh. io.popen goes through a shell, so every argument
-- has to survive it. This is the one place in the sic path where quoting is
-- our problem; beyond the sic client, argv is passed as netstrings and no
-- shell re-parses it.
local function shq(s)
    return "'" .. tostring(s):gsub("'", "'\\''") .. "'"
end

-- ---------------------------------------------------------------- run
-- popen/pclose over FFI, so the exit status comes back through the RETURN
-- channel instead of being smuggled through the output.
--
-- The obvious alternative — appending a marker like `; printf "__RC:%d" "$?"`
-- and parsing it off the tail — mixes control data into the data stream. It
-- breaks the moment a command's own output happens to end that way, or gets
-- truncated. In-band signalling for something the OS already hands us is a
-- trade we do not need to make.
--
-- The other alternative, Lua's own p:close(), is not dependable here: LuaJIT
-- returns the detailed status only when built with -DLUAJIT_ENABLE_LUA52COMPAT.
-- The manual splits its 5.2 extensions into those that cannot break existing
-- code (goto, io.read("*L"), os.exit — always on) and those that change
-- existing RETURN VALUES, including "os.execute() and pipe:close() return
-- detailed [status]" — build-switch only. Measured on
-- luajit 2.1.0+openresty20250117: p:close() gives `true, nil, nil` even after
-- `exit 3`. Relying on it would make this file depend on how someone else
-- compiled their interpreter.
--
-- pclose() returns the raw wait(2) status. WEXITSTATUS/WIFEXITED are macros,
-- so they are open-coded here — the layout is fixed by POSIX.
ffi.cdef[[
    typedef struct _IO_FILE FILE;
    FILE  *popen(const char *command, const char *type);
    int    pclose(FILE *stream);
    size_t fread(void *ptr, size_t size, size_t nmemb, FILE *stream);
]]

local BUFSZ = 65536
local buf = ffi.new("char[?]", BUFSZ)

-- Returns stdout, exit_code. stderr is folded into stdout: a model needs the
-- error text, and a silent failure is worse than a noisy one.
-- exit_code is -1 if the process could not be started, and 128+signal if it
-- was killed rather than exiting.
local function run(argv)
    local parts = {}
    for i = 1, #argv do parts[i] = shq(argv[i]) end
    local cmd = table.concat(parts, " ") .. " 2>&1"

    local fh = C.popen(cmd, "r")
    if fh == nil then return nil, -1 end

    local chunks, n = {}, 0
    while true do
        local got = tonumber(C.fread(buf, 1, BUFSZ, fh))
        if got <= 0 then break end
        n = n + 1
        chunks[n] = ffi.string(buf, got)
    end

    local status = tonumber(C.pclose(fh))
    if status < 0 then return table.concat(chunks), -1 end

    -- WIFEXITED(s) -> (s & 0x7f) == 0 ; WEXITSTATUS(s) -> (s >> 8) & 0xff
    -- WTERMSIG(s)  -> s & 0x7f
    local code
    if bit.band(status, 0x7f) == 0 then
        code = bit.band(bit.rshift(status, 8), 0xff)
    else
        code = 128 + bit.band(status, 0x7f)   -- killed by a signal
    end
    return table.concat(chunks), code
end

-- ---------------------------------------------------------------- M.connect
-- opts:
--   alias     short name for this pseudo-server (default "sic")
--   hosts     array of reachable host names, for the tool descriptions.
--             Purely documentation — sic resolves names itself; listing them
--             just saves the model a guess.
--   sic_bin   path to the sic client (default "sic", found via PATH)
function M.connect(opts)
    opts = opts or {}
    return setmetatable({
        alias    = opts.alias or "sic",
        hosts    = opts.hosts or {},
        sic_bin  = opts.sic_bin or "sic",
        -- repl.lua prints sess.url in `:mcp`; give it something truthful.
        url      = "sic:// (ssh transport, no port)",
        tools    = nil,
        version_warning = nil,
    }, Session)
end

-- ---------------------------------------------------------------- initialize
-- No round-trip: sic is a local binary and each call opens its own ssh.
-- We only check the client is actually there, so a missing package fails
-- loudly at startup rather than on the first tool call.
function Session:initialize()
    local out, code = run({ "sh", "-c", "command -v " .. self.sic_bin })
    if code ~= 0 or not out or out == "" then
        return false, "transport_error",
               ("sic client not found (%s not on PATH)"):format(self.sic_bin)
    end
    self.tools = self:_build_tools()
    return true, "ok"
end

-- ---------------------------------------------------------------- tools
function Session:_build_tools()
    local hostlist = (#self.hosts > 0)
        and (" Known hosts: " .. table.concat(self.hosts, ", ") .. ".")
        or ""

    return {
        {
            name = "run",
            description =
                "Run a command on a fleet host over sic (argv is passed without a "
                .. "shell re-parsing it, so arguments containing spaces or quotes "
                .. "survive intact). Use this for ordinary shell work on another "
                .. "machine." .. hostlist,
            inputSchema = {
                type = "object",
                properties = {
                    host = { type = "string",
                             description = "Target host name." },
                    argv = { type = "array", items = { type = "string" },
                             description = "Command and arguments, e.g. "
                                 .. "[\"systemctl\",\"is-active\",\"nginx\"]. "
                                 .. "NOT a shell string." },
                },
                required = { "host", "argv" },
            },
        },
        {
            name = "lmcp_list",
            description =
                "List the MCP tools a host offers, with their full input schemas. "
                .. "Call this BEFORE lmcp_call when you do not already know a "
                .. "tool's parameters — the schemas are deliberately not loaded "
                .. "up front, to keep the session prompt small." .. hostlist,
            inputSchema = {
                type = "object",
                properties = {
                    host = { type = "string", description = "Target host name." },
                },
                required = { "host" },
            },
        },
        {
            name = "lmcp_call",
            description =
                "Call one MCP tool on a host. Arguments are given as an object; "
                .. "consult lmcp_list first if the parameter names are unknown. "
                .. "Returns the tool's content, including when the tool itself "
                .. "reports an error." .. hostlist,
            inputSchema = {
                type = "object",
                properties = {
                    host = { type = "string", description = "Target host name." },
                    tool = { type = "string", description = "Tool name from lmcp_list." },
                    args = { type = "object",
                             description = "Tool arguments as key/value pairs." },
                },
                required = { "host", "tool" },
            },
        },
    }
end

function Session:list_tools()
    return self.tools or {}
end

-- ---------------------------------------------------------------- call_tool
-- Same contract as mcp.lua:
--   result, "ok"                    — result.content[] carries the text
--   result, "handler_error"         — tool ran, reported isError
--   nil,    "rpc_error",     obj    — malformed / refused
--   nil,    "transport_error", msg  — sic itself failed
local function text_result(s, is_err)
    return { content = { { type = "text", text = s or "" } },
             isError = is_err or false }
end

function Session:call_tool(name, args)
    args = args or {}
    local json = require("dkjson")

    local host = args.host
    if type(host) ~= "string" or host == "" then
        return nil, "rpc_error", { message = "missing 'host'" }
    end

    -- ---- run ----------------------------------------------------------
    if name == "run" then
        local argv = args.argv
        if type(argv) ~= "table" or #argv == 0 then
            return nil, "rpc_error",
                   { message = "'argv' must be a non-empty array of strings" }
        end
        local cmd = { self.sic_bin, host }
        for i = 1, #argv do cmd[#cmd + 1] = tostring(argv[i]) end
        local out, code = run(cmd)
        if out == nil then
            return nil, "transport_error", "could not spawn sic"
        end
        -- A non-zero exit is a RESULT, not a transport fault: the model wants
        -- to see the failure and its output, the same way a human would.
        if code ~= 0 then
            return text_result(("[exit %d]\n%s"):format(code, out), true),
                   "handler_error"
        end
        return text_result(out), "ok"
    end

    -- ---- lmcp_list ----------------------------------------------------
    if name == "lmcp_list" then
        local out, code = run({ self.sic_bin, host, "lmcp-tool", "list", "--json" })
        if out == nil then
            return nil, "transport_error", "could not spawn sic"
        end
        if code ~= 0 then
            return nil, "transport_error",
                   ("lmcp-tool list failed on %s: %s"):format(host, out:sub(1, 200))
        end
        local doc = json.decode(out)
        if not doc or not doc.tools then
            -- Most likely an older lmcp-tool without --json. Say so plainly
            -- instead of handing the model a parse error.
            return nil, "transport_error",
                   ("%s returned no tool list — is lmcp-tool new enough for "
                    .. "--json? (got: %s)"):format(host, out:sub(1, 120))
        end
        return text_result(out), "ok"
    end

    -- ---- lmcp_call ----------------------------------------------------
    if name == "lmcp_call" then
        local tool = args.tool
        if type(tool) ~= "string" or tool == "" then
            return nil, "rpc_error", { message = "missing 'tool'" }
        end
        local payload = json.encode(args.args or {})
        local out, code = run({ self.sic_bin, host, "lmcp-tool", "--json",
                                tool, payload })
        if out == nil then
            return nil, "transport_error", "could not spawn sic"
        end
        if code ~= 0 then
            -- lmcp-tool reserves exit 1 for transport/protocol errors; a tool
            -- that merely failed still exits 0 with isError in the payload.
            return nil, "rpc_error", { message = out:sub(1, 300) }
        end
        local doc = json.decode(out)
        if not doc then
            return nil, "transport_error",
                   "malformed JSON from lmcp-tool: " .. out:sub(1, 160)
        end
        if doc.isError then return doc, "handler_error" end
        return doc, "ok"
    end

    return nil, "rpc_error", { message = "unknown sic tool: " .. tostring(name) }
end

-- ---------------------------------------------------------------- close
function Session:close()
    self.tools = nil
end

return M
