-- executor.lua — command execution.
-- Phase 1: forkpty via ffi/pty + bidirectional multiplex. Replaces Phase 0's
-- io.popen + sentinel-echo workaround. The multiplex loop forwards stdin
-- keystrokes to the child master fd while streaming master output to stdout,
-- so vim / less / htop / nano are usable end-to-end. Parent's tty (fd 0) is
-- flipped to raw mode for the duration so single-key UIs work.
-- `cd` interception is unchanged (still libc.chdir per §3, §7).
-- See docs/PHASE0.md §7 and docs/PHASE1.md §5.

local ffi  = require("ffi")
local bit  = require("bit")
local libc = require("ffi.libc")
local pty  = require("ffi.pty")

local M = {}

local pollfd_arr2 = ffi.typeof("struct pollfd[2]")

-- Multiplex stdin (fd 0) <-> sess.master_fd until the child writes EOF.
-- Output is streamed live to stdout AND collected for the (output, code)
-- return so context.append_exec_output still has the body to inject into
-- the next user turn.
local function multiplex(sess)
    local saved_termios = libc.set_raw(0)  -- nil if stdin isn't a tty
    local stdin_is_tty  = (saved_termios ~= nil)

    local fds = pollfd_arr2()
    -- Only poll stdin when it's a tty. With piped stdin (scripted runs /
    -- tests), aish's stdin holds the *next* aish commands queued for the
    -- repl loop — draining it into the child would swallow those.
    fds[0].fd = stdin_is_tty and 0 or -1
    fds[0].events = libc.POLLIN
    fds[1].fd = sess.master_fd
    fds[1].events = libc.POLLIN

    local chunks = {}
    while true do
        fds[0].revents = 0
        fds[1].revents = 0
        local rc = libc.poll(fds, 2, -1)
        if rc < 0 then
            if libc.errno() == libc.EINTR then
                -- signal during poll; loop and retry
            else
                break
            end
        else
            -- Drain master first (output priority). Read on *any* revents —
            -- POLLHUP fires (and POLLIN doesn't) when the child closes its
            -- slave PTY end on exit; reading then returns 0 = EOF.
            if fds[1].revents ~= 0 then
                local data, n = sess:read()
                if not data or n == 0 then break end
                chunks[#chunks + 1] = data
                io.write(data); io.flush()
            end
            -- Forward stdin keystrokes (or piped-in bytes) to the child.
            if fds[0].revents ~= 0 then
                local input, n = libc.read(0, 4096)
                if input and n > 0 then
                    sess:write(input)
                elseif input == "" then
                    -- aish's own stdin closed; stop forwarding but keep
                    -- draining master until child exits
                    fds[0].fd = -1
                end
            end
        end
    end

    if saved_termios then libc.restore_termios(0, saved_termios) end
    return chunks
end

-- Execute a shell command.
-- Returns: (output_string, exit_code).
--   0       success
--   1..255  child exited with that status
--   128+N   child killed by signal N (bash convention)
--   -1      forkpty / spawn / wait failure
function M.exec(cmd)
    if not cmd or cmd:match("^%s*$") then
        return "(empty command)", -1
    end

    local sess, err = pty.spawn(cmd)
    if not sess then
        return "(pty.spawn failed: " .. tostring(err) .. ")", -1
    end

    local chunks = multiplex(sess)
    local kind, code = sess:wait()
    sess:close()

    -- PTY line discipline emits \r\n for every \n the child writes; collapse
    -- back to \n so the Phase 0 caller contract ("output uses \n separators")
    -- still holds for context-injection purposes.
    local output = table.concat(chunks):gsub("\r\n", "\n")

    if kind == "exit"   then return output, code         end
    if kind == "signal" then return output, 128 + code   end
    return output, -1
end

-- Intercept and apply `cd <path>` (or bare `cd` -> $HOME) without forking.
-- Returns:
--   nil          : the command is not a `cd` (caller falls through to exec)
--   true         : it was a cd, libc.chdir succeeded
--   false, err   : it was a cd, libc.chdir failed with errmsg
function M.maybe_chdir(cmd)
    local rest = cmd:match("^%s*cd%s*$") and ""
              or cmd:match("^%s*cd%s+(.+)$")
    if not rest then return nil end

    local target = rest:match("^%s*(.-)%s*$") or ""

    -- Phase 0: no $OLDPWD support, so `cd -` is not handled.
    if target == ""  then target = os.getenv("HOME") or "/" end
    if target == "~" then target = os.getenv("HOME") or "/" end
    if target:sub(1, 2) == "~/" then
        target = (os.getenv("HOME") or "") .. target:sub(2)
    end

    return libc.chdir(target)
end

-- Extract `CMD: ` lines from an assistant response per the §6 broker contract.
-- The "CMD: " prefix is a §3 substrate invariant: exact prefix, single space,
-- start-of-line only. Leading whitespace before CMD: does NOT match.
-- "CMD&: " lines are issue #8 background variants — extracted separately so
-- repl.lua can route them to the bg spawner instead of the synchronous gate.
function M.extract_cmd_lines(text)
    local cmds = {}
    for line in (text or ""):gmatch("[^\n]+") do
        local cmd = line:match("^CMD: (.*)$")
        if cmd and cmd:match("%S") then cmds[#cmds + 1] = cmd end
    end
    return cmds
end

function M.extract_cmd_bg_lines(text)
    local cmds = {}
    for line in (text or ""):gmatch("[^\n]+") do
        local cmd = line:match("^CMD&: (.*)$")
        if cmd and cmd:match("%S") then cmds[#cmds + 1] = cmd end
    end
    return cmds
end

-- Issue #6: `DELEGATE: <preset> "<prompt>"` lines. Parses each into
-- (preset, prompt) — quotes around the prompt are required so the
-- parser can find the boundary unambiguously (the prompt may contain
-- arbitrary punctuation otherwise). Lines that don't match the
-- quoted shape are silently dropped (rendered as text to the user).
function M.extract_delegate_lines(text)
    local out = {}
    for line in (text or ""):gmatch("[^\n]+") do
        local preset, prompt = line:match([[^DELEGATE: (%S+)%s+"(.+)"%s*$]])
        if not preset then
            preset, prompt = line:match([[^DELEGATE: (%S+)%s+'(.+)'%s*$]])
        end
        if preset and prompt and prompt:match("%S") then
            out[#out + 1] = { preset = preset, prompt = prompt }
        end
    end
    return out
end

-- Phase 10 / #89: extract `TASK: <imperative>` lines from a cloud
-- preplanner's response. Wire contract for the planning/executor
-- split: cloud emits a list of imperative TASKs once per :norris
-- launch, local model executes each.
--
-- More permissive than extract_cmd_lines: tolerates leading
-- whitespace (cloud models often indent) AND leading whitespace
-- after the colon, AND strips trailing whitespace. Strict only on
-- the literal "TASK:" prefix.
--
-- Returns an array of strings (already trimmed); empty TASKs and
-- non-TASK lines are dropped silently.
function M.extract_task_lines(text)
    local out = {}
    for line in (text or ""):gmatch("[^\n]+") do
        local task = line:match("^%s*TASK:%s*(.-)%s*$")
        if task and task:match("%S") then out[#out + 1] = task end
    end
    return out
end

return M
