-- rebind: min_sdk=3.0.0
-- rebind: name=Remote Access
-- rebind: version=1.1.0
-- rebind: author=Rebind
-- rebind: description=JSON over WebSocket — drive HID, read Screen/Input/Window/Clipboard, and subscribe to live event streams from any external program.
-- rebind: instance=single
-- rebind: tick_rate=1000
-- rebind: permission=net
--
-- Reference remote-control server. Run this script in Rebind and any conforming
-- client — the official TypeScript / Python / Rust libraries, the browser demo,
-- or your own code — can drive a real keyboard and mouse over a WebSocket.
--
-- THE PROTOCOL (everything is one JSON object per message)
--   request:   { "t": "<command>", ...args, "id": <number?> }
--   response:  { "id": <number>, ...result }          (only when the request had an id)
--   error:     { "id": <number>, "error": { "code", "message" } }
--   push:      { "t": "mouse" | "window" | "input", ... }   (after `subscribe`)
--
-- A command with no `id` is fire-and-forget (e.g. an HID write). Add an `id` to
-- get a correlated reply (e.g. reading a pixel). This is the whole contract; the
-- SDK clients are thin wrappers over it, and you can speak it from any language.
--
-- Edit the command table below to extend the surface — every change is local and
-- needs no SDK update.

-- ── Config ───────────────────────────────────────────────────────────────────
-- AUTH_TOKEN     ""  = open (fine on localhost, risky on a LAN). When set, a
--                    client must send { t = "auth", token = "..." } before any
--                    other command succeeds.
-- ALLOW_LUA_EXEC false = the `lua.exec` escape hatch is refused. Leave it off
--                    unless you fully trust every client — it runs arbitrary Lua.
local AUTH_TOKEN = ""
local ALLOW_LUA_EXEC = false

local cfg = UI.Schema({
  port = UI.Slider(
    19561,
    { min = 1024, max = 65535, label = "WebSocket port" }
  ),
})

local PROTOCOL_VERSION = "1.1.0"

-- ── Connection state ─────────────────────────────────────────────────────────
local server = nil
local authed = {} -- client_id -> true once the token is accepted
local subscribers = {} -- client_id -> { client, mouse=bool, window=bool, input=bool }
local last_mouse_x, last_mouse_y, last_window_title = nil, nil, nil

-- ── Wire helpers ─────────────────────────────────────────────────────────────
local function send(client, obj)
  local ok, encoded = pcall(JSON.Stringify, obj)
  if ok then
    client:Send(encoded)
  else
    Log.Warn("remote_access: encode failed: " .. tostring(encoded))
  end
end

-- reply only when the request carried an id (otherwise it was fire-and-forget)
local function reply(client, req, payload)
  if req.id == nil then
    return
  end
  payload.id = req.id
  send(client, payload)
end

local function err(client, req, code, message)
  if req.id == nil then
    Log.Warn(
      ("remote_access: client %d (%s): %s"):format(client.id, code, message)
    )
    return
  end
  send(client, { id = req.id, error = { code = code, message = message } })
end

local function authorized(client, req)
  if AUTH_TOKEN == "" or authed[client.id] then
    return true
  end
  err(
    client,
    req,
    "unauthenticated",
    'send { t = "auth", token = "..." } first'
  )
  return false
end

-- ── Commands ─────────────────────────────────────────────────────────────────
-- Each handler receives (client, req) and replies when req.id is present.
-- HID writes are one-shot; reads return a result.
local handlers = {}

-- handshake & auth
handlers["hello"] = function(client, req)
  reply(
    client,
    req,
    { protocol = PROTOCOL_VERSION, auth_required = AUTH_TOKEN ~= "" }
  )
end
handlers["auth"] = function(client, req)
  if AUTH_TOKEN == "" then
    authed[client.id] = true
    reply(client, req, { ok = true, note = "no token required" })
  elseif req.token == AUTH_TOKEN then
    authed[client.id] = true
    reply(client, req, { ok = true })
  else
    err(client, req, "bad_token", "token does not match")
  end
end
handlers["ping"] = function(client, req)
  reply(client, req, { pong = true, time_ms = System.Time() })
end

-- HID output (fire-and-forget)
handlers["hid.down"] = function(c, r)
  if authorized(c, r) then
    HID.Down(r.code)
  end
end
handlers["hid.up"] = function(c, r)
  if authorized(c, r) then
    HID.Up(r.code)
  end
end
handlers["hid.press"] = function(c, r)
  if authorized(c, r) then
    -- HID.Press sleeps between down/up, so it is coroutine-only — wrap in Run()
    -- or it errors and the click silently fails.
    Run(function()
      HID.Press(r.code, r.hold_ms or 20)
    end)
  end
end
handlers["hid.type"] = function(c, r)
  if authorized(c, r) then
    HID.Type(r.text or "")
  end
end
handlers["hid.move"] = function(c, r)
  if authorized(c, r) then
    HID.Move(r.dx or 0, r.dy or 0)
  end
end
handlers["hid.move_to"] = function(c, r)
  if authorized(c, r) then
    HID.MoveTo(r.x or 0, r.y or 0)
  end
end
handlers["hid.scroll"] = function(c, r)
  if authorized(c, r) then
    HID.Scroll(r.delta or 0)
  end
end

-- Screen reads (Windows + macOS)
handlers["screen.pixel"] = function(client, req)
  if not authorized(client, req) then
    return
  end
  local ok, hex = pcall(Screen.GetPixelColor, req.x, req.y)
  if not ok then
    err(client, req, "screen_error", tostring(hex))
    return
  end
  -- GetPixelColor returns "RRGGBB"; expand to numeric channels for the client
  reply(client, req, {
    r = tonumber(hex:sub(1, 2), 16) or 0,
    g = tonumber(hex:sub(3, 4), 16) or 0,
    b = tonumber(hex:sub(5, 6), 16) or 0,
  })
end
handlers["screen.resolution"] = function(client, req)
  if not authorized(client, req) then
    return
  end
  local w, h = System.Screen()
  reply(client, req, { width = w, height = h })
end
handlers["screen.displays"] = function(client, req)
  if not authorized(client, req) then
    return
  end
  reply(client, req, { displays = Screen.List() })
end
-- screen.capture passes Screen.Capture through verbatim: a native-res base64 PNG
-- plus display geometry and cursor for mapping image coords back to the screen.
-- req.display (1-based) and req.region ({x,y,w,h}) are optional.
handlers["screen.capture"] = function(client, req)
  if not authorized(client, req) then
    return
  end
  local ok, result =
    pcall(Screen.Capture, { display = req.display, region = req.region })
  if not ok then
    err(client, req, "capture_error", tostring(result))
    return
  end
  reply(client, req, result)
end

-- System reads
handlers["system.mouse"] = function(c, r)
  if authorized(c, r) then
    local x, y = System.Mouse()
    reply(c, r, { x = x, y = y })
  end
end
handlers["system.window"] = function(c, r)
  if authorized(c, r) then
    reply(c, r, { window = System.Window() })
  end
end
handlers["system.time"] = function(c, r)
  if authorized(c, r) then
    reply(c, r, { time_ms = System.Time() })
  end
end

-- Input state
handlers["input.keys"] = function(c, r)
  if authorized(c, r) then
    reply(c, r, { keys = Input.GetActiveKeys() })
  end
end
handlers["input.is_down"] = function(c, r)
  if authorized(c, r) then
    reply(c, r, { down = Input.IsDown(r.code) })
  end
end
handlers["input.modifiers"] = function(c, r)
  if authorized(c, r) then
    reply(c, r, { modifiers = Input.GetModifiers() })
  end
end

-- Clipboard (Windows + macOS)
handlers["clipboard.get"] = function(c, r)
  if authorized(c, r) then
    reply(c, r, { text = Clipboard.Get() or "" })
  end
end
handlers["clipboard.set"] = function(c, r)
  if authorized(c, r) then
    Clipboard.Set(r.text or "")
    reply(c, r, { ok = true })
  end
end

-- Window manipulation (full on Windows, partial on macOS)
handlers["window.list"] = function(c, r)
  if authorized(c, r) then
    reply(c, r, { windows = Window.List(r.filter) })
  end
end
handlers["window.find"] = function(c, r)
  if authorized(c, r) then
    reply(c, r, { handle = Window.Find(r.title or "") })
  end
end
handlers["window.activate"] = function(c, r)
  if authorized(c, r) then
    Window.Activate(r.handle)
    reply(c, r, { ok = true })
  end
end
handlers["window.move"] = function(c, r)
  if authorized(c, r) then
    Window.Move(r.handle, r.x, r.y, r.width, r.height)
    reply(c, r, { ok = true })
  end
end

-- Live event streams: subscribe to "mouse", "window", and/or "input"; the server
-- pushes a frame from OnTick whenever that state changes.
handlers["subscribe"] = function(client, req)
  if not authorized(client, req) then
    return
  end
  local sub = subscribers[client.id] or { client = client }
  sub.client = client
  for _, name in ipairs(req.events or {}) do
    sub[name] = true
  end
  subscribers[client.id] = sub
  reply(client, req, { ok = true, subscribed = req.events or {} })
end
handlers["unsubscribe"] = function(client, req)
  if not authorized(client, req) then
    return
  end
  local sub = subscribers[client.id]
  if sub then
    for _, name in ipairs(req.events or {}) do
      sub[name] = nil
    end
  end
  reply(client, req, { ok = true })
end

-- Escape hatch: run arbitrary Lua in this script's sandbox. Off by default.
handlers["lua.exec"] = function(client, req)
  if not authorized(client, req) then
    return
  end
  if not ALLOW_LUA_EXEC then
    err(client, req, "disabled", "lua.exec is disabled in config")
    return
  end
  local fn, parse_err = loadstring(req.source or "")
  if not fn then
    err(client, req, "parse_error", tostring(parse_err))
    return
  end
  local ok, result = pcall(fn)
  if ok then
    reply(client, req, { result = result })
  else
    err(client, req, "runtime_error", tostring(result))
  end
end

-- ── Server lifecycle ─────────────────────────────────────────────────────────
function OnStart()
  server = Net.WSListen(cfg.port, {
    OnConnect = function(client)
      Log.Info(("Remote Access: client %d connected"):format(client.id))
      -- announce the protocol up front so clients need no round-trip to verify
      send(client, {
        t = "hello",
        protocol = PROTOCOL_VERSION,
        auth_required = AUTH_TOKEN ~= "",
      })
    end,

    OnMessage = function(client, payload, is_binary)
      if is_binary then
        err(
          client,
          { id = nil },
          "no_binary",
          "binary frames are not supported"
        )
        return
      end
      local ok, req = pcall(JSON.Parse, payload)
      if not ok or type(req) ~= "table" then
        err(client, { id = nil }, "bad_json", "could not parse a JSON object")
        return
      end
      local handler = handlers[req.t or ""]
      if not handler then
        err(
          client,
          req,
          "unknown_command",
          "unknown command: " .. tostring(req.t)
        )
        return
      end
      local ran, e = pcall(handler, client, req)
      if not ran then
        err(client, req, "handler_error", tostring(e))
      end
    end,

    OnClose = function(client)
      Log.Info(("Remote Access: client %d disconnected"):format(client.id))
      subscribers[client.id] = nil
      authed[client.id] = nil
    end,
  })

  Log.Info(
    ("Remote Access listening on ws://0.0.0.0:%d (auth=%s)"):format(
      cfg.port,
      AUTH_TOKEN ~= "" and "required" or "open"
    )
  )
  UI.Notify(("Remote Access: ws://0.0.0.0:%d"):format(cfg.port), "success")
end

function OnStop()
  if server then
    server:Stop()
    server = nil
  end
  subscribers, authed = {}, {}
end

-- Push subscribed streams once per tick, only when the underlying state changed.
-- A closed client's :Send() is a silent no-op, so OnClose handles all cleanup.
function OnTick()
  if not server or next(subscribers) == nil then
    return
  end

  local mouse_msg, window_msg, input_msg = nil, nil, nil

  local x, y = System.Mouse()
  if x ~= last_mouse_x or y ~= last_mouse_y then
    last_mouse_x, last_mouse_y = x, y
    local ok, m = pcall(JSON.Stringify, { t = "mouse", x = x, y = y })
    if ok then
      mouse_msg = m
    end
  end

  local win = System.Window()
  if win.title ~= last_window_title then
    last_window_title = win.title
    local ok, m = pcall(JSON.Stringify, { t = "window", window = win })
    if ok then
      window_msg = m
    end
  end

  local want_input = false
  for _, sub in pairs(subscribers) do
    if sub.input then
      want_input = true
      break
    end
  end
  if want_input then
    local ok, m = pcall(JSON.Stringify, {
      t = "input",
      keys = Input.GetActiveKeys(),
      modifiers = Input.GetModifiers(),
    })
    if ok then
      input_msg = m
    end
  end

  for _, sub in pairs(subscribers) do
    if mouse_msg and sub.mouse then
      sub.client:Send(mouse_msg)
    end
    if window_msg and sub.window then
      sub.client:Send(window_msg)
    end
    if input_msg and sub.input then
      sub.client:Send(input_msg)
    end
  end
end
