-- renderer.lua — output formatting and ANSI sequences.
-- Phase 0: assistant text plain-printed with `CMD: ` lines highlighted;
-- exec output framed with the exit code on the closing rule.
-- Phase 1: assistant_delta + assistant_flush for streaming render. CMD:
-- highlighting in streaming mode is deferred (Q12); deltas print raw, the
-- §6 substrate `CMD: ` line is still extractable by executor afterwards.
-- Syntax highlighting hooks land in Phase 6 (was Phase 5 pre-MCP renumber).

local M = {}

local A = {
    reset = "\27[0m",
    bold  = "\27[1m",
    dim   = "\27[2m",
    cyan  = "\27[36m",
    red   = "\27[31m",
}

local function emit(...) io.write(...); io.flush() end

-- Print assistant response text. Lines beginning with `CMD: ` (per the §3
-- substrate-locked extraction marker) are emitted bold+cyan so the user
-- can spot the suggestion without scanning prose.
function M.assistant(text)
    for line in ((text or "") .. "\n"):gmatch("([^\n]*)\n") do
        if line:sub(1, 5) == "CMD: " then
            emit(A.bold, A.cyan, line, A.reset, "\n")
        else
            emit(line, "\n")
        end
    end
end

-- Phase 1: executor.exec streams output live to stdout (PTY multiplex), so
-- the frame is split — exec_begin before the spawn, exec_end after wait().
-- The body is not re-rendered here; live output lands directly between the
-- two rules.
function M.exec_begin()
    emit(A.dim, "─── exec output ───", A.reset, "\n")
end

function M.exec_end(exit_code)
    if exit_code and exit_code ~= 0 then
        emit(A.dim, "─── exit ", A.reset,
             A.red, tostring(exit_code), A.reset,
             A.dim, " ───", A.reset, "\n")
    else
        emit(A.dim, "─── exit 0 ───", A.reset, "\n")
    end
end

-- Single-line dim status (e.g. §8 eviction notice, model switch confirms).
function M.status(line)
    emit(A.dim, "[aish] ", tostring(line), A.reset, "\n")
end

-- Streaming assistant output. Phase 1: deltas are written raw — the §6 CMD:
-- highlighting from M.assistant() is not applied incrementally because
-- mid-line cursor manipulation isn't worth the complexity for Phase 1.
-- Q12 (PHASE1.md §10) tracks the upgrade. The full assistant text is still
-- captured by repl.lua and CMD: extraction works against the reassembled
-- string after the stream ends.

local stream_buf = nil   -- non-nil while a stream is in progress

-- Phase 6: fence-aware highlight filter. Off by default; toggled via
-- M.set_highlight(enabled, detected, highlight_fn). State machine:
--   outside: pass chunks through; hold a small tail when the suffix
--            could be the start of an opening fence (R1 — split fences
--            from local llama.cpp need accumulation).
--   inside:  buffer until closing "\n```" is seen; emit
--            highlight_fn(body, lang) then the closing fence verbatim.
-- N1: fences only open at start-of-stream OR after a newline ("^```"
--     or "\n```"); inline backticks in prose don't trigger.
local hl_enabled  = false
local hl_detected = false
local hl_fn       = nil          -- function(body, lang) -> rendered
local hl_state    = "outside"    -- "outside" | "inside"
local hl_tail     = ""           -- outside-state lookahead
local hl_inside_buf = ""         -- inside-state buffer
local hl_lang     = nil          -- captured at fence open

function M.set_highlight(enabled, detected, highlight_fn)
    hl_enabled  = not not enabled
    hl_detected = not not detected
    hl_fn       = highlight_fn
end

function M.highlight_state()
    return { enabled = hl_enabled, detected = hl_detected }
end

-- Longest suffix of `s` that is a prefix of any well-formed fence-open
-- marker ("\n```<lang>\n" or "```<lang>\n" at SOL). Returns the suffix
-- string. Bounded by max-lang-tag-length + 5.
local function _hl_partial_suffix(s)
    -- Look back up to 32 chars.
    local hi = math.min(#s, 32)
    for k = hi, 1, -1 do
        local cand = s:sub(#s - k + 1)
        -- Possible prefixes of a fence-open:
        --   "\n", "\n`", "\n``", "\n```", "\n```<langchars>"
        --   if k == #s (full string == cand), also bare "`", "``", "```"
        if cand:match("^\n`*[%w_-]*$") then return cand end
        if (k == #s) and cand:match("^`*[%w_-]*$") and cand:find("`") then
            return cand
        end
    end
    return ""
end

-- Find fence open in combined string. Returns (fence_start, content_start,
-- lang) or nil. fence_start = index of first backtick; content_start =
-- index after the closing newline of the fence-info line.
local function _hl_find_open(combined)
    -- Match at start-of-string OR after a newline.
    local s, e, lang = combined:find("^```([%w_-]*)\n")
    if s then return 1, e + 1, lang end
    s, e, lang = combined:find("\n```([%w_-]*)\n")
    if s then return s + 1, e + 1, lang end
    return nil
end

local function _hl_push(chunk)
    if not hl_enabled or not hl_fn then
        emit(chunk)
        return
    end
    if hl_state == "outside" then
        local combined = hl_tail .. chunk
        local fs, cs, lang = _hl_find_open(combined)
        if fs then
            if fs > 1 then emit(combined:sub(1, fs - 1)) end
            -- Emit the fence-open line verbatim too (model + user both
            -- see "```python\n" — the highlighter only colorizes BODY).
            emit(combined:sub(fs, cs - 1))
            hl_state      = "inside"
            hl_lang       = (lang ~= "" and lang) or nil
            hl_inside_buf = combined:sub(cs)
            hl_tail       = ""
            -- If the closing fence is already in the inside buffer
            -- (cloud may deliver whole blocks in one chunk), drain.
            if hl_inside_buf:find("\n```", 1, true) then
                _hl_push("")  -- triggers the inside branch's close detect
            end
            return
        end
        -- No opening fence — hold the trailing partial-fence so a
        -- split-fence ("``" then "`python\n") gets recognized.
        local hold = _hl_partial_suffix(combined)
        if #hold < #combined then
            emit(combined:sub(1, #combined - #hold))
        end
        hl_tail = hold
        return
    end

    -- state == "inside"
    hl_inside_buf = hl_inside_buf .. chunk
    local cpos = hl_inside_buf:find("\n```", 1, true)
    if not cpos then return end   -- still buffering
    local body    = hl_inside_buf:sub(1, cpos - 1)
    local closing = hl_inside_buf:sub(cpos, cpos + 3)  -- "\n```"
    local rest    = hl_inside_buf:sub(cpos + 4)
    local ok, rendered = pcall(hl_fn, body, hl_lang or "")
    emit((ok and rendered) or body)
    emit(closing)
    hl_state      = "outside"
    hl_inside_buf = ""
    hl_lang       = nil
    if rest ~= "" then _hl_push(rest) end
end

function M.assistant_delta(chunk)
    if not chunk or chunk == "" then return end
    if stream_buf == nil then stream_buf = "" end
    stream_buf = stream_buf .. chunk
    _hl_push(chunk)
end

function M.assistant_flush()
    if stream_buf == nil then return end
    -- Flush any held tail or in-progress fence body so the user sees it.
    if hl_state == "inside" and hl_inside_buf ~= "" then
        -- Stream ended mid-fence — emit raw (no highlight; no closing
        -- fence was seen). User sees the partial code as-is.
        emit(hl_inside_buf)
        hl_inside_buf = ""
        hl_state      = "outside"
        hl_lang       = nil
    elseif hl_tail ~= "" then
        emit(hl_tail)
        hl_tail = ""
    end
    if not stream_buf:match("\n$") then emit("\n") end
    stream_buf = nil
end

-- Phase 2: MCP tool-call frame. Visual parity with the exec_begin/exec_end
-- frame so the user reads tool dispatch and shell dispatch the same way.
-- tool_call_begin renders the top rule + (optionally) the args as a dim
-- preview; tool_call_end renders the result content followed by a status
-- rule. Status is "ok" (dim) by default; "error" (red) if is_error is true.
-- See docs/PHASE2.md §3 renderer.lua row + §4 Tool invocation.

function M.tool_call_begin(name, args)
    emit(A.dim, "─── tool: ", A.reset,
         A.cyan, name, A.reset,
         A.dim, " ───", A.reset, "\n")
    if args and args ~= "" and args ~= "{}" then
        local shown = (#args <= 200) and args or (args:sub(1, 197) .. "...")
        emit(A.dim, shown, A.reset, "\n")
    end
end

function M.tool_call_end(content, is_error)
    if content and content ~= "" then
        emit(content)
        if not content:match("\n$") then emit("\n") end
    end
    if is_error then
        emit(A.dim, "─── ", A.reset,
             A.red, "error", A.reset,
             A.dim, " ───", A.reset, "\n")
    else
        emit(A.dim, "─── ok ───", A.reset, "\n")
    end
end

-- Phase 3: Norris autonomous mode frames. Banner-style on enter/exit,
-- step counter per iteration, red HALT banner when the destructive-op
-- gate fires. The interactive prompt also gets a ⚡ marker when Norris
-- is active (handled in repl.lua's prompt() function per PHASE0.md §9).
-- See docs/PHASE3.md §3 renderer row.

function M.norris_begin(goal)
    emit(A.bold, A.cyan, "─── NORRIS MODE ─────────────────────────",
         A.reset, "\n")
    if goal and goal ~= "" then
        emit(A.dim, "  goal: ", A.reset, goal, "\n")
    end
    emit(A.bold, A.cyan, "─────────────────────────────────────────",
         A.reset, "\n")
end

function M.norris_step(n, max_n, descr)
    emit(A.dim, ("  ─ step %d/%d ─ "):format(n, max_n), A.reset)
    if descr and descr ~= "" then emit(A.dim, descr, A.reset) end
    emit("\n")
end

function M.norris_halt(step_n, max_n, reason, action)
    emit(A.bold, A.red, "─── NORRIS HALT ──────────────────────────",
         A.reset, "\n")
    emit(A.dim, "  step:   ", A.reset, ("%d/%d"):format(step_n, max_n), "\n")
    emit(A.dim, "  reason: ", A.reset, A.red, tostring(reason), A.reset, "\n")
    -- action may be a long string (command line or JSON-serialized tool call);
    -- truncate at 400 chars to keep the banner readable
    local act = tostring(action or ""):gsub("\n", " ")
    if #act > 400 then act = act:sub(1, 397) .. "..." end
    emit(A.dim, "  action: ", A.reset, act, "\n")
    emit(A.bold, A.red, "──────────────────────────────────────────",
         A.reset, "\n")
end

-- Norris loop exit. status ∈ {"done", "aborted", "budget_exhausted",
--                              "stalled", "broker_error"}.
function M.norris_end(status, reason)
    -- Phase 10: "tasks_complete" is a success-ish exit (executor ran
    -- through all preplanned tasks but didn't explicitly say GOAL: done).
    local non_error = (status == "done") or (status == "tasks_complete")
    local color = non_error and A.cyan or A.red
    local label = status:upper():gsub("_", " ")
    emit(A.bold, color, "─── NORRIS ", label, " ──",
         (" "):rep(math.max(0, 28 - #label)),
         A.reset, "\n")
    if reason and reason ~= "" then
        emit(A.dim, "  ", reason, A.reset, "\n")
    end
    emit(A.bold, color, "──────────────────────────────────────────",
         A.reset, "\n")
end

return M
