-- factoryos.core.sched — minimal coroutine task scheduler.
--
-- Model: tasks are coroutines with an event filter. The run loop pulls raw
-- CC events and resumes every suspended task whose filter matches the event
-- name (or that waits on anything). A task may yield with a private filter
-- token ("opaque string") that no real event will match; the dispatcher
-- resumes it directly via sched.wake(). This is how request/response and
-- mailboxes are built without pulling events inside nested coroutines.
--
-- Cooperative only: a task that never yields stalls the node — same as any
-- CC program. pcall boundaries mean a dead task can't take the loop down.

local M = {}

local unpack = table.unpack or unpack
local tasks = {}
local running = nil

local function resume(task, ...)
  if coroutine.status(task.co) == "dead" then return end
  running = task
  local ok, filter = coroutine.resume(task.co, ...)
  running = nil
  if not ok then
    task.dead = true
    task.err = filter
    if M.onError then pcall(M.onError, task, filter) end
    return
  end
  task.filter = filter -- nil filter = wake on every event
  task.lastWake = os.clock()
end

--- Spawn a task. fn receives `args`; the task runs immediately up to its
--- first yield.
function M.spawn(fn, name, ...)
  local task = { co = coroutine.create(fn), name = name or "task", mail = {} }
  tasks[#tasks + 1] = task
  resume(task, ...)
  return task
end

--- Suspend until an event named `filter` arrives; nil waits for any event or
--- a direct wake. Returns the event arguments.
function M.yield(filter)
  return coroutine.yield(filter)
end

--- Resume a task (or bare coroutine) directly with return values for its
--- yield. Bare coroutines bypass filter tracking — callers are responsible.
function M.wake(t, ...)
  if type(t) == "thread" then
    if coroutine.status(t) ~= "dead" then coroutine.resume(t, ...) end
    return
  end
  if t and coroutine.status(t.co) ~= "dead" then resume(t, ...) end
end

function M.current() return running end

--- Sleep `sec` seconds (timer event race).
function M.sleep(sec)
  local id = os.startTimer(sec)
  while true do
    local _, tid = M.yield("timer")
    if tid == id then return end
  end
end

function M.tasks() return tasks end

--- Drop all tasks (host-side tests; never used in production).
function M.reset() tasks = {} end

--- Dispatch one event to all matching tasks. Public so host-side tests can
--- pump events without os.pullEventRaw.
function M.dispatch(name, ...)
  local ev = table.pack(...)
  for i = #tasks, 1, -1 do
    local t = tasks[i]
    if t.dead or coroutine.status(t.co) == "dead" then
      table.remove(tasks, i)
    elseif t.filter == nil or t.filter == name then
      resume(t, name, table.unpack(ev, 1, ev.n))
    end
  end
end

--- Run `main` as the first task, then dispatch events until it dies or a
--- "terminate" event arrives. Returns main's error if it crashed.
function M.run(main, ...)
  local root = M.spawn(main, "main", ...)
  while true do
    local ev = table.pack(os.pullEventRaw())
    if ev[1] == "terminate" then return "terminated" end
    M.dispatch(unpack(ev, 1, ev.n))
    if coroutine.status(root.co) == "dead" then
      return root.err
    end
  end
end

return M
