-- router.lua — task classifier: meta / shell / AI / model-routing.
-- See docs/PHASE0.md §5 and docs/PHASE5.md §4 for Phase 5 additions.
--
-- M.classify(line, config) → (kind, payload) for input dispatch (Phase 0).
-- M.classify_model(text, cfg) → name | nil for per-request model routing
--                               (Phase 5; pure-Lua heuristics, no IO).

local M = {}

local function trim(s)
    return (s:gsub("^%s+", ""):gsub("%s+$", ""))
end

local function first_word(s)
    return s:match("^(%S+)") or ""
end

local function known_commands_set(config)
    local set = {}
    local list = config and config.shell and config.shell.known_commands or {}
    for _, c in ipairs(list) do set[c] = true end
    return set
end

-- §5.1 path-like: ./foo, ../foo, /usr/bin/foo, ~/foo, bare ~. Quoted /
-- escaped paths are intentionally out of scope in Phase 0. ~ is included
-- for symmetry with executor.maybe_chdir, which expands ~ on `cd ~/foo`.
local function path_like(token)
    return token            == "~"
        or token:sub(1, 1)  == "/"
        or token:sub(1, 2)  == "./"
        or token:sub(1, 2)  == "~/"
        or token:sub(1, 3)  == "../"
end

function M.classify(line, config)
    line = trim(line or "")
    if line == "" then return "ai", "" end

    -- meta: ":" prefix
    if line:sub(1, 1) == ":" then
        return "meta", line:sub(2)
    end

    -- shell explicit override: "$" prefix
    if line:sub(1, 1) == "$" then
        return "shell", trim(line:sub(2))
    end

    local first = first_word(line)
    local known = known_commands_set(config)

    -- known-command allowlist
    if known[first] then return "shell", line end

    -- path-like first token
    if path_like(first) then return "shell", line end

    -- everything else -> AI
    return "ai", line
end

-- ---------------------------------------------------------------- classify_model
-- Phase 5: per-request model routing heuristic. Pure-Lua, no IO.
-- Returns the NAME of a model preset (string) to switch to for this
-- request, or nil to keep the active model unchanged.
--
-- The mapping from class to model name lives in `cfg.routing.classes`.
-- A class with value `nil` means "keep current" — even though the
-- heuristic fires, no override happens (used by default for the
-- `reasoning` class per R-N2 cost-safety policy).
--
-- This function ALWAYS evaluates the heuristic regardless of
-- `cfg.routing.auto` — the caller (repl.ask_ai) gates on the flag.
-- This separation lets `:route check <text>` introspect the heuristic
-- even when routing is disabled (N1).

local function classify_class(text)
    if not text or text == "" then return "default" end

    -- ── Code class — looks like a paste or contains code markers
    if text:find("```", 1, true) then return "code" end
    local lower = text:lower()
    if lower:find("traceback", 1, true)
       or lower:find("stacktrace", 1, true)
       or lower:find("stack trace", 1, true) then
        return "code"
    end
    -- exception/error markers near beginning (first 60 chars)
    if lower:sub(1, 60):find("error:", 1, true)
       or lower:sub(1, 60):find("exception:", 1, true) then
        return "code"
    end
    -- path with code-extension token
    if text:match("[%./~][%w%-_/.]+%.([%w]+)") then
        local ext = text:match("[%./~][%w%-_/.]+%.([%w]+)")
        if ext == "py"  or ext == "lua" or ext == "c"
           or ext == "js" or ext == "go"  or ext == "rs"
           or ext == "cpp" or ext == "h"   or ext == "ts" then
            return "code"
        end
    end
    -- multi-line + indented (looks like a code paste)
    local nlines = 0
    for _ in (text .. "\n"):gmatch("[^\n]*\n") do nlines = nlines + 1 end
    if nlines > 4 and text:find("\n%s+%S") then return "code" end

    -- ── Reasoning class
    -- Min length 15 — catches "how does X work" but excludes bare "why" / "explain"
    if #text >= 15 then
        if lower:find("explain", 1, true)
           or lower:find("why ", 1, true)        -- trailing space (not "whyever")
           or lower:find("how does", 1, true)
           or lower:find("compare", 1, true) then
            return "reasoning"
        end
    end
    if text:find("?", 1, true) and #text > 100 then
        return "reasoning"
    end

    return "default"
end

-- Public API.
function M.classify_model(text, cfg)
    local class = classify_class(text)
    local classes = (cfg and cfg.routing and cfg.routing.classes) or {}
    local target = classes[class]
    -- nil target = keep current (this is the R-N2 default for "reasoning")
    if target == nil then return nil, class end
    -- Caller may want the class label for the status line; return both.
    return target, class
end

-- Exposed for `:route check` introspection (N1).
M._classify_class = classify_class

return M
