-- factoryos.services.agent — per-node management agent ("admin.node").
-- Hosted by EVERY node (attached in Node:start). Lets the mainframe survey,
-- adopt, configure, push whitelisted files, and reboot remote computers.
--
-- Claim model: a node with no node.cfg (or adopted=false) answers admin ops
-- openly so a mainframe can claim it. admin.adopt writes adopted=true +
-- tokens, after which all admin ops require token_admin. Configured nodes
-- are never remotely writable. Audit log: every admin op is appended to
-- /factoryos/data/audit.tbl.

local config = require("factoryos.core.config")
local log    = require("factoryos.core.log")
local util   = require("factoryos.util")
local sched  = require("factoryos.core.sched")

local M = {}
local CFG_PATH = "/factoryos/node.cfg"
local AUDIT = "audit"

local function audit(node, env, op)
  local s = require("factoryos.core.store")
  local a = s.load(AUDIT, {})
  a[#a + 1] = { ts = util.now(), from = env.from, op = op,
    p = env.p and env.p.path or (env.p and env.p.role) or "" }
  while #a > 200 do table.remove(a, 1) end
  s.save(AUDIT, a)
  log.info("admin.%s from %s", op, tostring(env.from))
end

local function writeCfg(tbl)
  local h = fs.open(CFG_PATH, "w")
  if not h then return false, "open failed" end
  h.write("return " .. textutils.serialize(tbl))
  h.close()
  return true
end

-- only paths we're willing to write remotely — never arbitrary locations
local function safePath(p)
  return type(p) == "string" and #p > 1 and #p < 256
    and not p:find("%.%.")
    and (p:sub(1, 11) == "/factoryos/" or p == "/startup.lua")
end

function M.attach(node)
  node:service("admin.node", {
    ops = {

      info = { level = "read", fn = function()
        return { ok = true, id = node.id, name = node.name,
          role = node.role, facility = node.facility,
          adopted = node.cfg.adopted == true, cid = node.cid,
          uptime = util.clock() }
      end },

      -- hardware + method survey so the operator can see what this box has
      survey = { level = "read", fn = function()
        local out = {}
        for _, name in ipairs(peripheral.getNames()) do
          local types = { peripheral.getType(name) }
          local okM, methods = pcall(peripheral.getMethods, name)
          out[#out + 1] = { name = name, types = types,
            methods = okM and methods or {} }
        end
        return { ok = true, peripherals = out }
      end },

      -- GPS fix if a GPS constellation is in range (4+ hosts needed)
      locate = { level = "read", fn = function()
        if not (gps and gps.locate) then return { ok = false, err = "no_gps" } end
        local x, y, z = gps.locate(2)
        if x then return { ok = true, pos = { x, y, z } } end
        return { ok = false, err = "no_fix" }
      end },

      -- claim + assign identity/role. Open while unclaimed (see header).
      adopt = { level = "admin", fn = function(env, p)
        if type(p) ~= "table" then return { ok = false, err = "bad_params" } end
        audit(node, env, "adopt")
        local cfg = config.load(CFG_PATH) or {}
        cfg.name = p.name or cfg.name or node.name
        cfg.role = p.role or cfg.role or node.role
        cfg.facility = p.facility or cfg.facility or node.facility
        if p.token then cfg.token = p.token end
        if p.token_admin then cfg.token_admin = p.token_admin end
        if p.modules then cfg.modules = p.modules end
        cfg.adopted = true
        local ok, err = writeCfg(cfg)
        if not ok then return { ok = false, err = err } end
        -- apply in memory immediately: locks the claim window now
        node.cfg = cfg
        node.name, node.role, node.facility = cfg.name, cfg.role, cfg.facility
        node.token, node.tokenAdmin = cfg.token, cfg.token_admin
        return { ok = true, reboot_required = true }
      end },

      -- read back the node's own config (admin only — contains tokens)
      get_cfg = { level = "admin", fn = function(env)
        audit(node, env, "get_cfg")
        local cfg = config.load(CFG_PATH)
        return { ok = true, cfg = cfg }
      end },

      -- merge arbitrary keys into node.cfg (post-adoption admin)
      configure = { level = "admin", fn = function(env, p)
        if type(p) ~= "table" or type(p.set) ~= "table" then
          return { ok = false, err = "need p.set" }
        end
        audit(node, env, "configure")
        local cfg = config.load(CFG_PATH) or {}
        for k, v in pairs(p.set) do cfg[k] = v end
        local ok, err = writeCfg(cfg)
        return { ok = ok, err = err }
      end },

      -- write a file under /factoryos/ or /startup.lua only
      push_file = { level = "admin", fn = function(env, p)
        if not (p and safePath(p.path) and type(p.body) == "string") then
          return { ok = false, err = "bad_path" }
        end
        if #p.body > 65536 then return { ok = false, err = "too_large" } end
        audit(node, env, "push_file")
        local h = fs.open(p.path, "w")
        if not h then return { ok = false, err = "open failed" } end
        h.write(p.body); h.close()
        return { ok = true, bytes = #p.body }
      end },

      reboot = { level = "admin", danger = true, fn = function(env)
        audit(node, env, "reboot")
        sched.spawn(function()
          sched.sleep(0.3)
          if os.reboot then os.reboot() end
        end, "reboot")
        return { ok = true }
      end },
    },
  })
end

return M
