-- ffi/libc.lua — shared libc bindings.
-- Phase 0: chdir, errno, strerror — enough for `cd` interception in executor.
-- Phase 1: waitpid + WEXITSTATUS, raw fd I/O (read/write/close), kill — the
-- syscalls ffi/pty needs to drive a forkpty'd child.
-- See docs/PHASE0.md §7 and docs/PHASE1.md §3.

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

ffi.cdef[[
int   chdir(const char *path);
int  *__errno_location(void);
char *strerror(int errnum);

typedef int     pid_t;
typedef long    ssize_t;
typedef unsigned long size_t;

pid_t   waitpid(pid_t pid, int *wstatus, int options);
ssize_t read   (int fd, void *buf, size_t count);
ssize_t write  (int fd, const void *buf, size_t count);
int     close  (int fd);
int     kill   (pid_t pid, int sig);

/* termios for raw-mode toggle around interactive PTY children. The struct
   is treated as opaque — cfmakeraw fills it; size 64 is comfortably larger
   than glibc's struct termios (60 bytes) on aarch64/x86_64 Linux. */
struct termios { char _opaque[64]; };
int  tcgetattr(int fd, struct termios *tio);
int  tcsetattr(int fd, int actions, const struct termios *tio);
void cfmakeraw(struct termios *tio);

/* poll for stdin↔master multiplex in executor. */
struct pollfd { int fd; short events; short revents; };
int poll(struct pollfd *fds, unsigned long nfds, int timeout);

/* Phase 4: advisory file locking on memory.jsonl. Single-writer
   enforcement via LOCK_EX | LOCK_NB — fail-fast if another aish
   process holds the lock. */
int flock(int fd, int operation);

/* TTY detection for non-interactive mode (`aish -p`). Returns 1 if the
   fd refers to a terminal, 0 otherwise (sets errno on error). */
int isatty(int fd);

/* getcwd — chdir() doesn't update PWD env, so prompt {cwd} needs the
   real cwd. NULL buffer + size 0 is the GNU extension that malloc()s
   the buffer; we use a fixed-size stack buffer instead. */
char *getcwd(char *buf, size_t size);
]]

local C = ffi.C

local M = {}

-- ---------------------------------------------------------------- chdir / errno
-- Phase 0 invariants. Apply chdir per PHASE0.md §7.
-- Returns: true on success; false, errmsg on failure.
function M.chdir(path)
    local rc = C.chdir(path)
    if rc == 0 then return true end
    return false, ffi.string(C.strerror(C.__errno_location()[0]))
end

function M.errno()    return C.__errno_location()[0]                end
function M.strerror(en) return ffi.string(C.strerror(en))           end

-- ---------------------------------------------------------------- waitpid
-- Mirrors glibc's WIFEXITED / WEXITSTATUS / WIFSIGNALED / WTERMSIG macros.
local function WIFEXITED  (status) return bit.band(status, 0x7f) == 0 end
local function WEXITSTATUS(status) return bit.band(bit.rshift(status, 8), 0xff) end
local function WIFSIGNALED(status)
    -- signal-killed iff low 7 bits in 1..126
    local s = bit.band(status, 0x7f)
    return s ~= 0 and s ~= 0x7f
end
local function WTERMSIG   (status) return bit.band(status, 0x7f) end

M.WIFEXITED   = WIFEXITED
M.WEXITSTATUS = WEXITSTATUS
M.WIFSIGNALED = WIFSIGNALED
M.WTERMSIG    = WTERMSIG

-- waitpid wrapper. Returns (kind, value):
--   "exit",   exit_code   on normal exit (WIFEXITED -> WEXITSTATUS)
--   "signal", signum      on signal kill (WIFSIGNALED -> WTERMSIG)
--   nil, errmsg           on waitpid syscall failure
local status_buf = ffi.new("int[1]")
function M.waitpid(pid, options)
    status_buf[0] = 0
    local rc = C.waitpid(pid, status_buf, options or 0)
    if rc < 0 then
        return nil, ffi.string(C.strerror(C.__errno_location()[0]))
    end
    local status = status_buf[0]
    if WIFEXITED(status)   then return "exit",   WEXITSTATUS(status) end
    if WIFSIGNALED(status) then return "signal", WTERMSIG(status)    end
    return "other", status
end

-- ---------------------------------------------------------------- raw fd I/O
-- Used by ffi/pty for master-fd transfer. Errors return nil + errmsg so
-- callers can decide between EAGAIN/EINTR retry and abort. EOF on read is
-- represented as ("", 0) — empty string, zero bytes.
-- Note: READ_BUF is module-shared. Phase 1 has no reentrant M.read callers
-- (no coroutines, no concurrent FFI callbacks performing reads); revisit if
-- that ever changes.
local READ_BUF = ffi.new("char[?]", 4096)

function M.read(fd, count)
    count = count or 4096
    local buf = (count <= 4096) and READ_BUF or ffi.new("char[?]", count)
    local n = C.read(fd, buf, count)
    if n < 0 then
        return nil, ffi.string(C.strerror(C.__errno_location()[0])), M.errno()
    end
    return ffi.string(buf, n), tonumber(n)
end

function M.write(fd, data)
    local n = C.write(fd, data, #data)
    if n < 0 then
        return nil, ffi.string(C.strerror(C.__errno_location()[0])), M.errno()
    end
    return tonumber(n)
end

function M.close(fd)
    return C.close(fd) == 0
end

function M.kill(pid, sig)
    local rc = C.kill(pid, sig)
    if rc == 0 then return true end
    return false, ffi.string(C.strerror(C.__errno_location()[0]))
end

-- ---------------------------------------------------------------- termios
-- Save current tty mode and switch to raw via cfmakeraw. Returns the saved
-- termios pointer (to be passed back to M.restore_termios) or (nil, err) if
-- fd isn't a tty (e.g. stdin redirected from a file in CI / scripted runs).
local TCSANOW = 0

function M.set_raw(fd)
    local saved = ffi.new("struct termios")
    if C.tcgetattr(fd, saved) < 0 then
        return nil, M.strerror(M.errno())
    end
    local raw = ffi.new("struct termios")
    ffi.copy(raw, saved, ffi.sizeof("struct termios"))
    C.cfmakeraw(raw)
    if C.tcsetattr(fd, TCSANOW, raw) < 0 then
        return nil, M.strerror(M.errno())
    end
    return saved
end

function M.restore_termios(fd, saved)
    return C.tcsetattr(fd, TCSANOW, saved) == 0
end

-- ---------------------------------------------------------------- poll
M.POLLIN  = 0x0001
M.EINTR   = 4

-- Returns: rc (>= 0 fds ready, 0 timeout, -1 error)
function M.poll(fds_arr, nfds, timeout_ms)
    return C.poll(fds_arr, nfds, timeout_ms or -1)
end

-- ---------------------------------------------------------------- flock
-- Advisory file locking. Phase 4 uses LOCK_EX | LOCK_NB so a second
-- aish process opening the same memory.jsonl fails fast rather than
-- blocking. Lock is released on fd close or process exit.
M.LOCK_EX = 2
M.LOCK_NB = 4
M.LOCK_UN = 8

-- Returns: true on success; false, errmsg on failure (e.g. EWOULDBLOCK
-- when LOCK_NB is set and another holder exists).
function M.flock(fd, op)
    if C.flock(fd, op) == 0 then return true end
    return false, ffi.string(C.strerror(C.__errno_location()[0]))
end

-- ---------------------------------------------------------------- isatty
function M.isatty(fd)
    return C.isatty(fd) == 1
end

-- ---------------------------------------------------------------- getcwd
local CWD_BUF = ffi.new("char[?]", 4096)
function M.getcwd()
    local p = C.getcwd(CWD_BUF, 4096)
    if p == nil then
        return nil, ffi.string(C.strerror(C.__errno_location()[0]))
    end
    return ffi.string(CWD_BUF)
end

return M
