-- /opt/lmcp/tools.d-room/bullpen.lua — SCOPED room-only "bullpen" surface (:8081).
-- Same room_say/room_read as the main tool, but the privileged-post gate is relaxed:
-- this endpoint is already token-gated and exposes NO shell, so a privileged nick may
-- post without carrying the shared secret (that is how the sandboxed @foreman speaks).
-- Append-only JSONL room log + say/read tools. Single lmcp process = writers
-- are serialized, so id assignment (counter-first, never reuse) is atomic here.
-- See memory project_bullpen. MVP1.

local server, run = ...
local json = require("json")

local ROOM_DIR = os.getenv("BULLPEN_STATE_DIR") or "/var/lib/bullpen"
local ROOM_LOG = ROOM_DIR .. "/room.jsonl"
local COUNTER  = ROOM_DIR .. "/counter"
local SECRET_FILE = os.getenv("BULLPEN_SECRET_FILE") or "/etc/bullpen/post-secret"

-- Includes the bullpen_worker one-shot family (dispatcher/callboy/researcher/librarian/coder)
-- and the triage supervisor: they're already in cfg.TRUST and consulted by @foreman, but a
-- consumer's lurker requires verified:true (R4), and only PRIVILEGED nicks get that stamp. Left
-- out, @dispatcher's routing replies were structurally unconsumable — @foreman asked its router
-- and never heard back (the "dispatcher -> foreman, verified=None" dead-end, 2026-07-24).
-- `deus` seit 2026-08-08: er stand in cfg.TRUST (darf wecken), aber nicht hier
-- (bekommt den Stempel). Zwei getrennte Tore, und das zweite ist keine Erlaubnis
-- sondern eine BEFOERDERUNG: wer hier fehlt, darf ohne Geheimnis posten und wird
-- nie verifiziert -- der Raum antwortet ok:true, jeder Lurker verwirft es (R4).
-- Exakt der "verified=None dead-end" von 2026-07-24, nur mit anderem Nick.
local PRIVILEGED = { markus = true, deus = true, noether = true, foreman = true, herder = true, py = true, reviewer = true, testdesigner = true, godev = true, jsdev = true, dispatcher = true, callboy = true, researcher = true, librarian = true, coder = true, triage = true }

local function read_counter()
    local f = io.open(COUNTER, "r")
    if not f then return 0 end
    local n = tonumber((f:read("*a") or ""):match("%d+")) or 0
    f:close()
    return n
end

local function write_counter(n)
    local f = assert(io.open(COUNTER, "w"))
    f:write(tostring(n)); f:close()
end

local function constant_time_compare(a, b)
    if #a ~= #b then return false end
    local diff = 0
    for i = 1, #a do
        local ba = string.byte(a, i)
        local bb = string.byte(b, i)
        if ba ~= bb then diff = diff + 1 end
    end
    return diff == 0
end

-- ---------------------------------------------------------------------------
-- Pro-Nick-Geheimnisse (2026-08-08).
--
-- `verified:true` soll einen ABSENDER bezeichnen. Mit einem geteilten
-- Geheimnis tut es das nicht: wer es hat, spricht als jeder privilegierte
-- Nick. Nachgewiesen -- aus dem deus-Container als `testdesigner` gepostet,
-- Ergebnis `from: testdesigner, verified: true`.
--
-- Jetzt hat jeder Nick seine eigene Datei unter SECRET_DIR. Ein Nick ist
-- privilegiert GENAU DANN, wenn seine Datei existiert -- eine Quelle, keine
-- zweite Liste, die jemand nachpflegen muss.
--
-- Der Nickname wird gefiltert, bevor er in einen Pfad wandert: `..` oder ein
-- Schraegstrich im `from`-Feld waere sonst ein Weg, eine beliebige Datei als
-- Geheimnis lesen zu lassen.
local SECRET_DIR = os.getenv("BULLPEN_SECRET_DIR") or "/etc/bullpen/post-secret.d"

local function nick_ok(n)
    return type(n) == "string" and n ~= "" and n:match("^[%w_.-]+$") ~= nil
end

local function read_nick_secret(nick)
    if not nick_ok(nick) then return nil end
    local f = io.open(SECRET_DIR .. "/" .. nick, "r")
    if not f then return nil end
    local c = f:read("*a"); f:close()
    if not c then return nil end
    return (c:gsub("%s+$", ""))
end

-- Gibt es das Verzeichnis ueberhaupt? Nur dann gilt die neue Regel; sonst
-- bleibt alles beim Alten (dieselben Dateien laufen auch in aelteren Aufbauten).
local function pernick_aktiv()
    local f = io.open(SECRET_DIR, "r")
    if f then f:close() end
    -- Ein Verzeichnis laesst sich nicht sinnvoll lesen; `io.open` gelingt auf
    -- manchen Systemen trotzdem. Verlaesslich ist: existiert wenigstens EIN
    -- Geheimnis darin?
    for _, n in ipairs({ "markus", "foreman", "noether", "deus", "testdesigner" }) do
        if read_nick_secret(n) then return true end
    end
    return false
end

-- Ergebnis: nil = nicht privilegiert (kein Stempel, kein Geheimnis noetig),
--           true = geprueft und in Ordnung, false = privilegiert aber falsch.
local function pruefe_absender(from, vorgelegt)
    if pernick_aktiv() then
        local erwartet = read_nick_secret(from)
        if not erwartet then return nil end                    -- kein Geheimnis -> nicht privilegiert
        if not vorgelegt or not constant_time_compare(vorgelegt, erwartet) then
            return false
        end
        return true
    end
    -- Rueckfall: alte Aufstellung, geteiltes Geheimnis + Namensliste.
    if not PRIVILEGED[from] then return nil end
    local erwartet = read_secret()
    if not erwartet or not vorgelegt or not constant_time_compare(vorgelegt, erwartet) then
        return false
    end
    return true
end

local function read_secret()
    local f = io.open(SECRET_FILE, "r")
    if not f then return nil end
    local content = f:read("*a")
    f:close()
    if not content then return nil end
    -- trim trailing whitespace/newline from file content
    content = content:gsub("%s+$", "")
    return content
end

-- Campaign lease enforcement — design item (a), 2026-07-27. Acquisition/renewal/seize live
-- in bullpen.lua (:8080, where the lurker's normal posts already land); this file only
-- CHECKS the same lease.json — read-only here, so a lease acquired via :8080 is honored on
-- this (:8081, foreman's actual dispatch) surface. Gated on `type == "ask"` only: a
-- seized/expired orchestrator can still post chat/reply/ack (visibility is fine, even
-- wanted — "I've been stopped" should still be sayable), it just can't fan out MORE work.
local LEASE_FILE = ROOM_DIR .. "/lease.json"
local ORCHESTRATOR = { foreman = true }

local function read_leases()
    local f = io.open(LEASE_FILE, "r")
    if not f then return {} end
    local content = f:read("*a")
    f:close()
    if not content or content == "" then return {} end
    local ok, d = pcall(json.decode, content)
    if not ok or type(d) ~= "table" then return {} end
    return d
end

local function lease_valid(lease)
    if not lease or lease.revoked then return false end
    if not lease.acquired or not lease.ttl then return false end
    return (os.time() - lease.acquired) <= lease.ttl
end

server:tool("room_say",
    "Post a message to the bullpen chatroom. Args: from (your nick), body (text). "
    .. "Optional: to (recipient @nick or 'human'; empty=broadcast), type "
    .. "(chat|ask|ack|reply|system, default chat), in_reply_to (id you answer). "
    .. "Returns the new message id.",
    {
        type = "object",
        properties = {
            from        = { type = "string",  description = "Your nick (pipi/callboy/markus/...)" },
            body        = { type = "string",  description = "Message text" },
            to          = { type = "string",  description = "Recipient @nick or 'human'; empty = broadcast" },
            type        = { type = "string",  description = "chat|ask|ack|reply|system (default chat)" },
            in_reply_to = { type = "integer", description = "id of the message this answers" },
            secret      = { type = "string",  description = "Shared secret for privileged nicks" },
        },
        required = { "from", "body" },
    },
    function(a)
        if not a.from or a.from == "" then return "Error: from (nick) required" end
        if not a.body or a.body == "" then return "Error: body required" end
        -- lease gate: an orchestrator dispatching new work (`ask`) needs a valid, non-seized
        -- lease. Checked BEFORE the counter increments, so a rejected dispatch doesn't burn
        -- a message id.
        if ORCHESTRATOR[a.from] and (a.type or "chat") == "ask" then
            local leases = read_leases()
            if not lease_valid(leases[a.from]) then
                -- LAUT ablehnen (Entscheidung 2026-08-08). Eine stille Ablehnung sieht
                -- aus wie Schweigen, und Schweigen ist die Fehlerklasse, an der dieser
                -- Aufbau den ganzen Tag gelitten hat: ok beim Absender, nichts beim
                -- Empfaenger, niemand merkt es. Eigene id, type=system, from=room --
                -- dieser Beitrag ist selbst kein `ask` und laeuft nicht erneut ins Tor.
                local wid = read_counter() + 1
                write_counter(wid)
                local warnung = {
                    id = wid, ts = os.time(), from = "room", to = "",
                    type = "system", verified = true,
                    body = "LEASE ABGELEHNT: @" .. a.from .. " wollte verteilen, hat aber kein "
                        .. "gueltiges Lease. Der Dispatch ist NICHT passiert. Abhilfe: "
                        .. "lease_acquire nick=" .. a.from .. " secret=<eigenes>. "
                        .. "Wer das hier liest und nichts tut, wartet auf etwas, das nicht kommt.",
                }
                local wf = io.open(ROOM_LOG, "a")
                if wf then wf:write(json.encode(warnung) .. "\n"); wf:close() end
                return json.encode({ ok = false, error = "lease_required",
                    detail = a.from .. " hat kein gueltiges Lease — lease_acquire vor dem "
                        .. "Verteilen aufrufen (Warnung als Nachricht " .. wid .. " im Raum)" })
            end
        end
        local id = read_counter() + 1
        write_counter(id)                 -- counter-first: a crash may skip an id, never reuse one
        local msg = {
            id = id, ts = os.time(),
            from = a.from, to = a.to or "",
            type = a.type or "chat",
            body = a.body,
        }
        if a.in_reply_to then msg.in_reply_to = tonumber(a.in_reply_to) or a.in_reply_to end
        -- R4: gate privileged nicks with shared secret
        -- Frueher wurde hier serverseitig gestempelt: wer den Endpunkt erreicht,
        -- hatte den Raum-Token, und das galt als genug. Unter Pro-Nick-
        -- Geheimnissen ist das ein Identitaetsloch -- man koennte jeden Nick
        -- behaupten. Der Grund fuer die Nachsicht war, dass @foreman das
        -- GETEILTE Geheimnis nicht halten sollte; haelt jeder nur sein eigenes,
        -- ist das erfuellt und beide Endpunkte duerfen dieselbe Regel haben.
        local absender = pruefe_absender(a.from, a.secret)
        if absender == false then
            return json.encode({ ok = false, error = "unauthorized" })
        elseif absender == true then
            msg.verified = true
        end
        local f = assert(io.open(ROOM_LOG, "a"))
        f:write(json.encode(msg) .. "\n"); f:close()
        return json.encode({ ok = true, id = id })
    end)

-- Offset-Index ueber die Room-Datei: Position i -> {id, Byteoffset, Laenge}.
-- Die Datei ist append-only und die ids sind streng steigend (room_say vergibt sie
-- counter-first, Luecken moeglich, Rueckschritte nicht) - also traegt eine Sprungsuche.
-- Aufgebaut wird inkrementell: pro Aufruf werden nur die NEU angehaengten Bytes
-- gelesen, nie wieder die ganze Datei.
--
-- Warum das noetig war (2026-08-03): sieben Worker-Daemons pollen diesen Room. Die
-- vorherige Fassung lief bei JEDEM Aufruf ueber alle Zeilen und rief json.decode auf -
-- bei 2224 Zeilen / 1,9 MB waren das 156,51 ms je Aufruf und damit, bei ~6 Anfragen/s,
-- dauerhaft ein kompletter Kern von hertz (gemessen: 5 Tage 20 h CPU-Zeit in 7 Tagen
-- Laufzeit). Der Normalfall - "gibt es etwas neuer als meine letzte id?" - liefert
-- nichts und kostet jetzt einen seek und null Dekodierungen.
local ROOM_IDX, ROOM_SCANNED = {}, 0

local function room_reindex(f)
    local size = f:seek("end")
    if size < ROOM_SCANNED then ROOM_IDX, ROOM_SCANNED = {}, 0 end   -- rotiert/gekuerzt
    if size <= ROOM_SCANNED then return end
    f:seek("set", ROOM_SCANNED)
    local chunk = f:read(size - ROOM_SCANNED) or ""
    local off, start = ROOM_SCANNED, 1
    while true do
        local nl = chunk:find("\n", start, true)
        if not nl then break end        -- angebrochene letzte Zeile: liegen lassen,
        local line = chunk:sub(start, nl - 1)   -- beim naechsten Mal ist sie komplett
        local ok, d = pcall(json.decode, line)
        if ok and d and d.id then
            ROOM_IDX[#ROOM_IDX + 1] = { id = d.id, off = off, len = #line }
        end
        off = off + #line + 1
        start = nl + 1
    end
    ROOM_SCANNED = off
end

-- erste Indexposition mit id > since; #ROOM_IDX+1, wenn es keine gibt
local function room_after(since)
    local lo, hi, ans = 1, #ROOM_IDX, #ROOM_IDX + 1
    while lo <= hi do
        local mid = (lo + hi) // 2
        if ROOM_IDX[mid].id > since then ans, hi = mid, mid - 1 else lo = mid + 1 end
    end
    return ans
end

server:tool("room_read",
    "Read bullpen messages newer than an id. Args: since (id; 0/omitted = from start). "
    .. "Optional: limit (max, default 50, keeps newest). Returns JSONL (one object per line).",
    {
        type = "object",
        properties = {
            since = { type = "integer", description = "Return messages with id > since (default 0)" },
            limit = { type = "integer", description = "Max messages (default 50)" },
        },
    },
    function(a)
        local since = tonumber(a.since) or 0
        local limit = tonumber(a.limit) or 50
        local f = io.open(ROOM_LOG, "r")
        if not f then return "" end
        room_reindex(f)
        local first, last = room_after(since), #ROOM_IDX
        if first > last then f:close(); return "" end
        if last - first + 1 > limit then first = last - limit + 1 end  -- neueste behalten
        local out = {}
        for i = first, last do
            local e = ROOM_IDX[i]
            f:seek("set", e.off)
            out[#out + 1] = f:read(e.len)
        end
        f:close()
        return table.concat(out, "\n")
    end)
