--
-- agent.lua - browser-automation chat agent for K-MeleonCCF ME.
-- Drives the live browser via DeepSeek (text-only -> DOM automation): each step it
-- reads a condensed page state, asks DeepSeek for ONE action, executes it, repeats
-- until "done". UI = the floating chat window (agentlib). Fully autonomous.
--
-- require"agent" sets everything up (window + callbacks). See pref\agent\config.md.
--

local json     = require "json"
local agentlib = require "agentlib"
-- prompt(question, title, default) returns the entered string on OK, nil on
-- Cancel. Used by the Settings (...) button in the chat panel header.
require "prompt"

-- Loop driver: luamacro's km.SetTimer (which runs in luamacro's OWN Lua runtime)
-- polls agentlib.take_input()/take_stop() and runs all steps. agentlib never calls
-- Lua (that corrupts the shared state). See [[kmeleonccf-agent-plugin]].

-- Per-page-load compat polyfills (Intl.ListFormat, ipv6-check-perf fetch stub,
-- unhandledrejection swallow) are now landed by the APP via autoinject.js at
-- document-element-inserted (before page scripts) -- see CMfcEmbedApp::Observe
-- in mfcembed.cpp. The polling polyfill that used to live here is removed.

local agent = {}

-------------------------------------------------- config
local AGENT_DIR = PREFPATH .. "agent\\"
local function read_config()
  local cfg = {}
  local f = io.open(AGENT_DIR .. "config.md", "r")
  if f then
    for line in f:lines() do
      if not line:match("^%s*#") then
        local k, v = line:match("^%s*([%w_]+)%s*:%s*(.-)%s*$")
        if k and v then cfg[k] = v end
      end
    end
    f:close()
  end
  return cfg
end
local CFG      = read_config()
local API_KEY  = CFG.deepseek_api_key
local MODEL    = CFG.deepseek_model or "deepseek-chat"
local ENDPOINT = CFG.deepseek_endpoint or "https://api.deepseek.com/chat/completions"

-- Write/update a single `key: value` line in config.md, preserving comments
-- and other entries. Creates the file if missing. Returns true on success.
local function save_config_value(key, value)
  local path = AGENT_DIR .. "config.md"
  local lines, found = {}, false
  local f = io.open(path, "r")
  if f then
    for line in f:lines() do
      local k = line:match("^%s*([%w_]+)%s*:")
      if k == key then
        lines[#lines + 1] = key .. ": " .. value
        found = true
      else
        lines[#lines + 1] = line
      end
    end
    f:close()
  end
  if not found then lines[#lines + 1] = key .. ": " .. value end
  -- Ensure the agent dir exists (first-run case before any config file written).
  local lfs_ok = pcall(require, "lfs")
  if lfs_ok then require("lfs").mkdir(AGENT_DIR) end
  f = io.open(path, "w")
  if not f then return false end
  f:write(table.concat(lines, "\n") .. "\n"); f:close()
  return true
end

-- Open the settings dialog: prompt for the DeepSeek API key (and optionally
-- model / endpoint), persist to config.md, and refresh the in-memory CFG so
-- the next LLM call uses the new value immediately.
local function open_settings()
  local cur_key = API_KEY or ""
  -- Mask the current key in the default so a casual screen-watcher doesn't
  -- read it back; user can paste a new one or clear + retype.
  local masked = cur_key
  if #masked > 12 then
    masked = masked:sub(1, 7) .. string.rep("*", #masked - 11) .. masked:sub(-4)
  end
  local new_key = prompt(
    "Enter your DeepSeek API key (leave unchanged to keep current).\n" ..
    "Get one at https://platform.deepseek.com/api_keys",
    "K-Meleon Agent Settings",
    masked)
  if not new_key then return end                          -- user cancelled
  if new_key == "" or new_key == masked then return end   -- unchanged
  if not new_key:match("^sk%-") then
    say_err("API key should start with `sk-`. Settings not saved.")
    return
  end
  if save_config_value("deepseek_api_key", new_key) then
    API_KEY = new_key
    CFG.deepseek_api_key = new_key
    say_agent("**Settings saved.** API key updated; ready for new tasks.")
  else
    say_err("Could not write `pref\\agent\\config.md`. Check folder permissions.")
  end
end

-------------------------------------------------- chat output helpers
-- Mirror the transcript to pref\agent\session.log for debugging/inspection.
local function logfile(s)
  local f = io.open(AGENT_DIR .. "session.log", "a")
  if f then f:write(tostring(s) .. "\n"); f:close() end
end
-- Colored append helpers. agentlib.say_* are 5.4-but-actually-5.1 typed appends
-- that render via RichEdit with role-specific colors and (for assistant) basic
-- markdown. Plain `say` falls back to system color for legacy callers.
local A = agentlib
local function say(s)       (A.say_system    or A.append)(tostring(s)); logfile(s) end
local function say_user(s)  (A.say_user      or A.append)(tostring(s)); logfile("> " .. s) end
local function say_agent(s) (A.say_assistant or A.append)(tostring(s)); logfile("< " .. s) end
local function say_err(s)   (A.say_error     or A.append)(tostring(s)); logfile("! " .. s) end
local function status(s) agentlib.status(tostring(s)) end
-- low-level trace to the log file only (pinpoints where a crash occurs); each line
-- is flushed (open/write/close) so the last line survives a hard crash.
local function trace(s) logfile("    [trace] " .. tostring(s)) end

-------------------------------------------------- DeepSeek call (async)
-- The LLM round-trip dominates step latency (5-30 s); doing it on the UI
-- thread freezes K-Meleon. We use km.HttpPostAsync (which spawns a worker
-- thread for the WinHTTP call) + km.HttpPoll. Lua stays on the UI thread,
-- only the network wait moves off. If HttpPostAsync is missing (older
-- luamacro), we fall back to the synchronous km.HttpPost.
local function llm_start(messages)
  if not API_KEY or API_KEY == "" or API_KEY:match("PASTE") then
    return false, "no deepseek_api_key in pref\\agent\\config.md"
  end
  local body = json.encode({
    model = MODEL, messages = messages, max_tokens = 700, temperature = 0, stream = false,
  })
  local headers = "Content-Type: application/json\r\nAuthorization: Bearer " .. API_KEY
  if km.HttpPostAsync then
    local ok, err = km.HttpPostAsync(ENDPOINT, headers, body, "POST", 60000)
    if not ok then return false, "HttpPostAsync: " .. tostring(err) end
    return true
  end
  -- Synchronous fallback (older luamacro without async HTTP). Will freeze UI.
  local st, resp = km.HttpPost(ENDPOINT, headers, body, "POST", 60000)
  task._sync_result = { st = st, resp = resp }
  return true
end

-- llm_poll() -> nil       (still pending)
--          -> "ok", reply (LLM responded with parsed content)
--          -> "err", msg  (transport or HTTP/parse error)
local function llm_poll()
  -- synchronous fallback drops its result in task._sync_result
  if task and task._sync_result then
    local r = task._sync_result; task._sync_result = nil
    if r.st == nil then return "err", "transport: " .. tostring(r.resp) end
    if r.st ~= 200 then return "err", "HTTP " .. tostring(r.st) .. ": " .. tostring(r.resp):sub(1, 300) end
    local ok, parsed = pcall(json.decode, r.resp)
    if not ok or type(parsed) ~= "table" or not parsed.choices or not parsed.choices[1] then
      return "err", "bad response: " .. tostring(r.resp):sub(1, 300)
    end
    return "ok", parsed.choices[1].message.content
  end
  if not km.HttpPoll then return "err", "HttpPoll unavailable" end
  local st, resp = km.HttpPoll()
  if st == nil and resp == nil then return nil end                       -- pending
  if st == nil then return "err", "transport: " .. tostring(resp) end    -- transport error
  if st ~= 200 then return "err", "HTTP " .. tostring(st) .. ": " .. tostring(resp):sub(1, 300) end
  local ok, parsed = pcall(json.decode, resp)
  if not ok or type(parsed) ~= "table" or not parsed.choices or not parsed.choices[1] then
    return "err", "bad response: " .. tostring(resp):sub(1, 300)
  end
  return "ok", parsed.choices[1].message.content
end

-------------------------------------------------- page tools
-- READ: from the engine (km.GetPageHTML), NOT injected JS (which can't return a
-- value here). The model reads the HTML and acts by CSS selector.
-- ACT: injected JS side-effects (el.click(), set .value) — those DO work.
-- Escape for a JS DOUBLE-quoted string literal. CSS selectors often contain
-- single quotes (e.g. `a[href='ask']`), so wrapping them in single quotes on the
-- JS side caused querySelector to either syntax-error or return null. Wrap in
-- double quotes and escape `"`/`\` instead; selectors with embedded `"` should
-- use single quotes inside (CSS-standard) and will pass through unchanged.
local function jsesc(s)
  s = tostring(s or "")
  return (s:gsub("\\", "\\\\"):gsub('"', '\\"'):gsub("\n", "\\n"):gsub("\r", "\\r"))
end

local function read_page()
  -- Read the page's DOM (the WHOLE page, not the viewport). NOTE: do NOT use
  -- textContent here - it includes the SOURCE of every <script>, drowning the real
  -- text in JS. Instead take innerHTML, strip head/script/style, then derive the
  -- VISIBLE TEXT by removing the remaining tags. Also keep the cleaned HTML so the
  -- model can find CSS selectors for click/type.
  local html = km.GetPageHTML and km.GetPageHTML()
  if not (html and #html > 0) then
    return "PAGE_EMPTY (no content read yet; the page may still be loading - try 'wait')"
  end
  -- Was: 5 chained gsubs with [hH][eE][aA][dD]-style classes - each [xX] class
  -- forces backtracking and the `.-` is lazy/rescans. On a 16 KB page in a
  -- 300 ms poll loop this was eating real CPU. Now: lowercase ONCE up front,
  -- run plain-text patterns (no character classes, no backtracking penalty).
  -- We then use the lowercased clean string for tag-stripping, but pull the
  -- VISIBLE-TEXT title out of the lowercase view by index back into the
  -- original `html` so casing is preserved for the model.
  local lc = html:lower()
  local title = ""
  do
    local s, e = lc:find("<title[^>]*>", 1)
    if s then
      local cs = e + 1
      local ce = lc:find("</title>", cs, true)
      if ce then title = html:sub(cs, ce - 1) end
    end
  end
  -- All five strip-passes operate on the lowercased buffer so they're simple
  -- byte patterns. Tag content stripped this way is replaced by a space - the
  -- resulting CLEAN buffer has the same byte offsets as the original, so the
  -- visible text / HTML extracted from it is still meaningful.
  local clean = lc:gsub("<head[^>]*>.-</head>", " ")
                  :gsub("<script.-</script>", " ")
                  :gsub("<style.-</style>", " ")
                  :gsub("<noscript.-</noscript>", " ")
                  :gsub("<!%-%-.-%-%->", " ")
  -- visible text = cleaned HTML with all remaining tags removed + a few entities
  local text = clean:gsub("<[^>]+>", " "):gsub("&nbsp;", " "):gsub("&amp;", "&")
                    :gsub("&#?%w+;", " "):gsub("%s+", " ")
  local htmlc = clean:gsub("%s+", " ")
  return "TITLE: " .. title ..
    "\n\nVISIBLE TEXT (whole page, no scripts, truncated to 9KB):\n" .. text:sub(1, 9000) ..
    "\n\nBODY HTML for element selectors (truncated to 7KB):\n" .. htmlc:sub(1, 7000)
end

local function qs(sel) return 'document.querySelector("' .. jsesc(sel) .. '")' end

-- IMPORTANT - the engine's InjectJS appends a <script> tag and DROPS the result.
-- See plugins.cpp:InjectJS2 -> mozutils.cpp:InjectJS: the CString& result is
-- never written. So km.PageEval is FIRE-AND-FORGET on Goanna 6 - we cannot read
-- JS return values directly. Workaround: the JS writes its result onto a body
-- attribute, then we read it back via km.GetPageHTML (which goes through the
-- engine's HTML serializer, not JS eval, so it works).
--
-- pageeval_result(expr) -> the string value of evaluating `expr` in the page,
-- or nil on failure. `expr` should be a JS expression (no trailing semicolon).
local function pageeval_result(expr)
  local code =
    '(function(){try{var __r=(' .. expr .. ');' ..
    'document.body.setAttribute("data-kma",String(__r==null?"":__r));' ..
    '}catch(e){document.body.setAttribute("data-kma","ERR:"+(e&&e.message||e));}})()'
  km.PageEval(code)
  local html = km.GetPageHTML() or ""
  -- The body tag will appear early; match either quoting style.
  local v = html:match([[<body[^>]-data%-kma="([^"]*)"]])
            or html:match([[<body[^>]-data%-kma='([^']*)']])
  return v
end

-- Probe the selector: return what we found so we can choose how to click.
-- Format: "NF" (not found) | "HREF\turl\ttarget" | "BTN\tdescription".
local function probe_target(sel)
  -- Build the probe as a single JS expression returning a tab-separated string.
  local expr =
    '(function(){' ..
      'var e=' .. qs(sel) .. ';' ..
      'if(!e)return "NF";' ..
      'var a=(e.tagName==="A")?e:(e.closest?e.closest("a[href]"):null);' ..
      'if(a&&a.href&&!/^javascript:/i.test(a.href)&&a.getAttribute("href")!=="#")' ..
        'return "HREF\\t"+a.href+"\\t"+(a.getAttribute("target")||"");' ..
      'var tag=e.tagName.toLowerCase(),id=e.id||"",cls=(e.className||"").slice(0,40);' ..
      'return "BTN\\t"+tag+(id?"#"+id:"")+(cls?"."+cls.replace(/\\s+/g,"."):"");' ..
    '})()'
  local r = pageeval_result(expr) or ""
  logfile("[probe sel=" .. tostring(sel) .. "] raw=" .. tostring(r))
  return r
end

-- Robust click: a plain .click() misses on many sites - SPA links use route
-- handlers bound to pointer/mousedown, native <a> needs proper navigation, and
-- some controls only respond to a full event sequence. Strategy:
--   1. probe the element (selector valid? is it an anchor?)
--   2. if it's an <a href>, use km.NavigateTo on the href directly - bypasses
--      any JS handlers AND any quirk where PageEval-driven location.assign is
--      ignored. This is what fixes "agent clicks loop, page never changes".
--   3. otherwise dispatch the full pointer+mouse+click event sequence on the
--      element (covers React/Vue/Angular components that listen on pointer/mouse).
-- Returns a short status string for the agent loop to feed back to the LLM.
local function js_click(sel)
  local info = probe_target(sel)
  if info == "NF" or info == "" then
    return "NOT_FOUND: selector matched no element; pick a different selector"
  end
  local kind, a, b = info:match("^(%u+)\t([^\t]*)\t?(.*)$")
  if kind == "HREF" then
    -- a = href, b = target attribute
    if b == "_blank" then
      km.NavigateTo(a, OPEN_NEW_BACKGROUND)
    else
      km.NavigateTo(a, OPEN_NORMAL)
    end
    return "navigated -> " .. a
  end
  -- Non-anchor: fire the full event chain. Note the OUTER querySelector arg
  -- uses double quotes too (selectors commonly contain single quotes).
  km.PageEval(([==[(function(){
    var e = document.querySelector("%s"); if (!e) return;
    e.scrollIntoView({block:"center", inline:"center"});
    try { e.focus({preventScroll:true}); } catch(_) {}
    var r = e.getBoundingClientRect();
    var cx = r.left + r.width/2, cy = r.top + r.height/2;
    var opts = {bubbles:true, cancelable:true, view:window,
      button:0, buttons:1, clientX:cx, clientY:cy, screenX:cx, screenY:cy};
    function fire(type, Ctor){ try { e.dispatchEvent(new Ctor(type, opts)); } catch(_) {} }
    fire("pointerdown", window.PointerEvent || MouseEvent);
    fire("mousedown",   MouseEvent);
    fire("pointerup",   window.PointerEvent || MouseEvent);
    fire("mouseup",     MouseEvent);
    fire("click",       MouseEvent);
    try { e.click(); } catch(_) {}
  })()]==]):format(jsesc(sel)))
  return "clicked " .. (info:sub(5) or sel)
end
local function js_type(sel, text, enter)
  -- Set the value via the NATIVE value setter so framework-controlled inputs
  -- (React/Angular/Vue, e.g. Google Flights) register the change; plain
  -- `e.value=...` is silently ignored by those. Then fire input/change + a keyup
  -- so autocompletes that listen on keystrokes populate their dropdowns.
  local code = '(function(){var e=' .. qs(sel) .. ';if(!e)return;e.focus();var v="' .. jsesc(text) .. '";' ..
    'try{var p=(e.tagName==="TEXTAREA")?window.HTMLTextAreaElement.prototype:window.HTMLInputElement.prototype;' ..
    'var d=Object.getOwnPropertyDescriptor(p,"value");if(d&&d.set){d.set.call(e,v);}else{e.value=v;}}catch(_){e.value=v;}' ..
    'e.dispatchEvent(new Event("input",{bubbles:true}));e.dispatchEvent(new Event("change",{bubbles:true}));' ..
    'e.dispatchEvent(new KeyboardEvent("keyup",{key:(v.slice(-1)||"a"),bubbles:true}));'
  if enter then
    code = code .. '["keydown","keypress","keyup"].forEach(function(t){' ..
      'e.dispatchEvent(new KeyboardEvent(t,{key:"Enter",code:"Enter",keyCode:13,which:13,bubbles:true}));});'
  end
  km.PageEval(code .. '})()')
end
local function js_scroll(dir)
  local dy = (dir == "up") and "-800" or "800"
  -- target the actual scroller (some pages scroll documentElement/body, not window)
  km.PageEval('(function(){var y=' .. dy .. ';window.scrollBy(0,y);' ..
    'var s=document.scrollingElement||document.documentElement||document.body;' ..
    'if(s){s.scrollTop+=y;}})()')
end
local function js_press(key)
  km.PageEval('(function(){var e=document.activeElement||document.body;' ..
    '["keydown","keypress","keyup"].forEach(function(t){e.dispatchEvent(' ..
    'new KeyboardEvent(t,{key:"' .. jsesc(key) .. '",bubbles:true}));});})()')
end
local function page_ready()
  local r = km.PageEval("''+document.readyState") or ""
  return r:find("complete") ~= nil
end

-- Browser-function dispatch table for the `browser_action` tool. Each entry
-- is { kmeleon_command_id, navigates? } - if `navigates` is true, the agent
-- will set task.waitload=1 to give the new page time to settle before the
-- next think. IDs come from kmeleon-src/app/resource.h + standard MFC IDs.
local BROWSER_ACTIONS = {
  -- navigation
  back        = { 32773, true  },   -- ID_NAV_BACK
  forward     = { 32774, true  },   -- ID_NAV_FORWARD
  home        = { 32775, true  },   -- ID_NAV_HOME
  stop        = { 32776, false },   -- ID_NAV_STOP
  reload      = { 32777, true  },   -- ID_NAV_RELOAD (cached)
  reload_hard = { 32814, true  },   -- ID_NAV_FORCE_RELOAD (bypass cache)
  search      = { 32778, false },   -- ID_NAV_SEARCH (focus search bar)
  -- tabs
  new_tab     = { 32900, true  },   -- ID_NEW_TAB
  close_tab   = { 32903, false },   -- ID_CLOSE_TAB
  next_tab    = { 32901, false },   -- ID_TAB_NEXT
  prev_tab    = { 32902, false },   -- ID_TAB_PREV
  last_tab    = { 32910, false },   -- ID_TAB_LAST (most-recently-used)
  close_other_tabs = { 32909, false },  -- ID_CLOSE_ALLOTHERTAB
  -- windows
  new_window  = { 32782, false },   -- ID_NEW_BROWSER
  -- find / page
  find        = { 57636, false },   -- ID_EDIT_FIND (open Find bar)
  find_next   = { 32796, false },   -- ID_EDIT_FINDNEXT
  find_prev   = { 32797, false },   -- ID_EDIT_FINDPREV
  close_find  = { 33822, false },   -- ID_CLOSE_FINDBAR
  -- view
  view_source = { 32783, false },   -- ID_VIEW_SOURCE (in new tab)
  page_info   = { 32784, false },   -- ID_VIEW_INFO
  zoom_in     = { 32809, false },   -- ID_FONT_INCREASE
  zoom_out    = { 32810, false },   -- ID_FONT_DECREASE
  -- file
  print       = { 57607, false },   -- ID_FILE_PRINT (standard MFC)
  print_preview = { 32801, false }, -- ID_FILE_PRINTPREVIEW
  save_as     = { 57604, false },   -- ID_FILE_SAVE_AS (standard MFC)
  -- edit
  copy        = { 57634, false },   -- ID_EDIT_COPY (standard MFC)
  paste       = { 57637, false },   -- ID_EDIT_PASTE
  select_all  = { 57644, false },   -- ID_EDIT_SELECT_ALL
}

local function do_browser_action(name)
  local entry = BROWSER_ACTIONS[name]
  if not entry then return nil, "unknown action: " .. tostring(name) end
  if not km.PostCmd then return nil, "km.PostCmd unavailable (luamacro too old)" end
  km.PostCmd(entry[1])
  return entry[2]   -- whether to wait for page load after
end

-------------------------------------------------- md memory
local function safe_name(n) return (tostring(n or "note.md"):gsub("[^%w%._%-]", "_")) end
local function write_md(file, content)
  local f = io.open(AGENT_DIR .. safe_name(file), "w")
  if not f then return "WRITE_FAILED" end
  f:write(tostring(content or "")); f:close(); return "OK"
end
local function read_md(file)
  local f = io.open(AGENT_DIR .. safe_name(file), "r")
  if not f then return nil end
  local c = f:read("*a"); f:close(); return c
end

-------------------------------------------------- system prompt
local SYSTEM_PROMPT = [[
You are a web-browser automation agent controlling a REAL browser to accomplish the user's task.
Each turn you receive the CURRENT PAGE as: (1) VISIBLE TEXT - the whole page's text, use it to READ
content and find answers; and (2) BODY HTML - use it to find interactive elements (a, button, input,
select, textarea) and their CSS selectors.
IMPORTANT: this is the WHOLE page's DOM, not just the visible viewport. Scrolling does NOT reveal new
text - if an answer exists it is already in the VISIBLE TEXT. Do not scroll to "see" content; only
scroll to trigger lazy-loaded results, and only if the text/HTML looks incomplete. If the answer is
present in the VISIBLE TEXT, extract it and return "done".

Respond with EXACTLY ONE JSON object and nothing else (no markdown fences, no prose). Schema:
  {"thought":"one short sentence","tool":"navigate","url":"https://..."}
  {"thought":"...","tool":"click","selector":"CSS selector"}
  {"thought":"...","tool":"type","selector":"CSS selector","text":"...","enter":true}
  {"thought":"...","tool":"press_key","key":"Enter"}
  {"thought":"...","tool":"scroll","dir":"down"}
  {"thought":"...","tool":"wait"}                       (page still loading - wait, then re-read)
  {"thought":"...","tool":"browser_action","action":"<name>"}  (built-in browser commands - see list)
  {"thought":"...","tool":"write_md","file":"name.md","content":"..."}
  {"thought":"...","tool":"read_md","file":"name.md"}
  {"thought":"...","tool":"done","answer":"final answer for the user"}

browser_action available actions:
  Navigation: back, forward, home, stop, reload, reload_hard, search
  Tabs:       new_tab, close_tab, next_tab, prev_tab, last_tab, close_other_tabs
  Windows:    new_window
  Find:       find (opens Find bar), find_next, find_prev, close_find
  View:       view_source, page_info, zoom_in, zoom_out
  File:       print, print_preview, save_as
  Edit:       copy, paste, select_all
Use browser_action for things the user can do via menus/toolbars/keyboard shortcuts -
they're more reliable than clicking the corresponding chrome button.

Guidance:
- Start by "navigate" to the relevant site.
- click/type take a precise CSS SELECTOR you derive from the HTML. Prefer #id, then
  [name='...'], [aria-label='...'], [placeholder='...'], or a unique tag+attribute.
- For a search/autocomplete field: click it to focus, type the text, then in the NEXT
  turn the HTML usually shows a suggestions dropdown - click the matching suggestion.
- VERIFY, don't assume: an action can silently fail. After typing, check the NEXT page
  HTML actually shows your value (e.g. value="chicago"); if it does NOT, the field may
  need a click first, or try a different selector - do not move on as if it worked.
- READ THE RESULT line that follows every action. click results are one of:
    "navigated -> <url>"   (success; page is loading new content)
    "clicked <tag#id.class>" (event fired on a non-anchor; page may or may not change)
    "NOT_FOUND: ..."        (selector matched nothing; pick a DIFFERENT selector,
                              do not retry the same one - look at the actual HTML)
  If you see NOT_FOUND, re-examine the BODY HTML carefully and pick a selector that
  appears VERBATIM in it. Looping on the same NOT_FOUND selector is the #1 failure mode.
- NEVER return "done" until the page HTML actually confirms the task succeeded (e.g. the
  result/price is present). Declaring done without that confirmation is a failure.
- The HTML refreshes each turn; if it looks unchanged or empty after an action, use "wait".
- Use write_md to persist findings/requirements; read_md to recall notes or stored credentials.
]]

-------------------------------------------------- step machine
local task = nil   -- {goal, messages, step, max_steps, waitload}

local function finish(msg)
  if msg then say_agent(msg) end
  task = nil
  status("idle")   -- the poll timer keeps running to accept the next task
end

-- extract the first/last JSON object from a model reply (tolerate ``` fences/prose)
local function extract_action(s)
  s = s:gsub("```json", ""):gsub("```", "")
  local a = s:find("{")
  if not a then return nil end
  -- find matching close by scanning to the last '}'
  local last
  for i = #s, a, -1 do if s:sub(i, i) == "}" then last = i; break end end
  if not last then return nil end
  local ok, obj = pcall(json.decode, s:sub(a, last))
  if ok and type(obj) == "table" then return obj end
  return nil
end

local function trim_history()
  -- keep system + goal + last ~22 turn messages
  while task and #task.messages > 26 do table.remove(task.messages, 3) end
end

-- Dispatch the LLM's chosen action; called only from do_step when the reply
-- arrives. Mutates task (appends messages, sets task.waitload, may finish()).
local function process_reply(reply)
  trace("reply[" .. #reply .. "]: " .. reply:gsub("[\r\n]", " "):sub(1, 400))
  task.messages[#task.messages + 1] = { role = "assistant", content = reply }
  trim_history()

  local act = extract_action(reply)
  if not act or not act.tool then
    say_err("[could not parse action] " .. reply:sub(1, 200))
    task.messages[#task.messages + 1] = { role = "user",
      content = "Your last reply was not a valid single JSON action. Reply with exactly one JSON object." }
    return
  end
  if act.thought and act.thought ~= "" then say_agent("*" .. tostring(act.thought) .. "*") end

  local tool, result = act.tool, "OK"
  -- Reset the consecutive-wait counter on ANY non-wait action so a brief
  -- waits-then-progress-then-waits pattern doesn't accumulate spurious hints.
  if tool ~= "wait" then task.wait_count = 0 end
  if tool == "done" then
    finish("**Done.** " .. tostring(act.answer or ""))
    return
  elseif tool == "navigate" then
    say("→ navigate `" .. tostring(act.url) .. "`")
    km.NavigateTo(tostring(act.url), OPEN_NORMAL); task.waitload = 1
  elseif tool == "click" then
    result = js_click(act.selector)
    say("→ click `" .. tostring(act.selector) .. "` — " .. result)
    if result:match("^NOT_FOUND") then
      task.nf_count = (task.nf_count or 0) + 1
      if task.nf_count >= 3 then
        finish("Stopped: 3 consecutive selectors not found (last: `" ..
               tostring(act.selector) .. "`). The selector you keep picking " ..
               "isn't in the page HTML. Re-read the HTML and pick a verbatim selector.")
        return
      end
    else
      task.nf_count = 0
    end
    if result:match("^navigated") then task.waitload = 1 end
  elseif tool == "type" then
    js_type(act.selector, act.text, act.enter)
    say("→ type `" .. tostring(act.selector) .. "` = \"" .. tostring(act.text) .. "\"" ..
        (act.enter and " +Enter" or ""))
    if act.enter then task.waitload = 1 end
  elseif tool == "press_key" then
    result = tostring(js_press(act.key)); say("→ press `" .. tostring(act.key) .. "`"); task.waitload = 1
  elseif tool == "scroll" then
    js_scroll(act.dir); say("→ scroll " .. tostring(act.dir or "down"))
  elseif tool == "wait" then
    task.waitload = 1; say("→ wait")
    -- After a few "wait" calls in a row the page IS loaded - the LLM's just
    -- stuck in a loop ("Gmail is still loading..." etc.). Force-feed a hint
    -- so the next think tries something different. Reset on any other tool.
    task.wait_count = (task.wait_count or 0) + 1
    if task.wait_count >= 3 then
      task.messages[#task.messages + 1] = { role = "user",
        content = "You've chosen 'wait' " .. task.wait_count .. " times in a row. " ..
                  "document.readyState was 'complete' on each tick. The page IS loaded. " ..
                  "Stop waiting and try a DIFFERENT selector or action - re-read the " ..
                  "page HTML and pick a verbatim selector that actually appears in it." }
    end
  elseif tool == "browser_action" then
    -- Trigger a built-in browser command (back / forward / reload / new_tab /
    -- find / zoom_in / etc.) via WM_COMMAND. See BROWSER_ACTIONS table.
    local navigates, err = do_browser_action(act.action)
    if navigates == nil then
      say_err("→ browser_action `" .. tostring(act.action) .. "` failed: " .. tostring(err))
      result = "ERROR: " .. tostring(err)
    else
      say("→ browser_action `" .. tostring(act.action) .. "`")
      if navigates then task.waitload = 1 end
    end
  elseif tool == "write_md" then
    result = write_md(act.file, act.content); say("→ write_md `" .. tostring(act.file) .. "` " .. result)
  elseif tool == "read_md" then
    local c = read_md(act.file); result = c and ("(" .. #c .. " bytes)") or "MISSING"
    say("→ read_md `" .. tostring(act.file) .. "` " .. result)
    task.messages[#task.messages + 1] = { role = "user",
      content = "FILE " .. tostring(act.file) .. ":\n" .. (c or "(missing)") }
  else
    say_err("[unknown tool] " .. tostring(tool))
    result = "UNKNOWN_TOOL"
  end

  if tool ~= "read_md" and tool ~= "done" then
    task.messages[#task.messages + 1] = { role = "user", content = "RESULT: " .. result }
  end
end

-- State machine driven by the 300ms poll timer. Each tick does AT MOST ONE
-- non-blocking thing and returns, keeping the UI responsive:
--   (a) wait for the previous navigation/click to settle
--   (b) if an LLM request is in flight, poll for completion
--   (c) otherwise, read page + start a new async LLM request
-- The blocking work (HTTP round-trip) happens on the worker thread spawned by
-- km.HttpPostAsync; this timer just polls km.HttpPoll for the reply.
local function do_step()
  if not task then return end

  -- (a) page-load wait
  if task.waitload and task.waitload > 0 then
    if page_ready() or task.waitload > 10 then
      task.waitload = 0
    else
      task.waitload = task.waitload + 1
      return
    end
  end

  -- (b) LLM-pending wait
  if task.thinking then
    local kind, payload = llm_poll()
    if kind == nil then return end          -- still pending; come back next tick
    task.thinking = false
    if kind == "err" then finish("[LLM error] " .. tostring(payload)); return end
    process_reply(payload)
    return
  end

  -- (c) start a new think
  task.step = task.step + 1
  if task.step > task.max_steps then
    finish("[reached step limit of " .. task.max_steps .. "]"); return
  end
  trace("step " .. task.step .. ": reading page")
  local page = read_page()
  trace("step " .. task.step .. ": page read (" .. #page .. " bytes); starting llm")
  -- Append-then-pop instead of full shallow-copy. Was: every 300 ms tick we
  -- iterated task.messages (up to 26 entries after trim) and built a fresh
  -- table. Now: push the per-step page-state onto the live array, hand it
  -- to llm_start, pop it back off. llm_start serialises it synchronously
  -- into the request body (via json.encode), so popping right after is safe.
  -- Keeps the persistent history clean of per-tick page-state entries.
  local idx = #task.messages + 1
  task.messages[idx] = { role = "user",
    content = "CURRENT PAGE STATE (step " .. task.step .. "):\n" .. page .. "\n\nReply with ONE action JSON." }
  status("thinking (step " .. task.step .. ")...")
  local ok, err = llm_start(task.messages)
  task.messages[idx] = nil
  if not ok then finish("[LLM error] " .. tostring(err)); return end
  task.thinking = true
end

function agent.start(goal)
  if task then say_err("a task is running - press Stop first"); return end
  task = {
    goal = goal,
    messages = { { role = "system", content = SYSTEM_PROMPT },
                 { role = "user", content = "TASK: " .. goal } },
    step = 0, max_steps = 50, waitload = 0,
  }
  status("running...")
end

function agent.stop()
  if task then finish("*stopped*") else status("idle") end
end

-- The single timer callback, invoked by luamacro's km.SetTimer in luamacro's OWN
-- Lua runtime (safe to allocate/GC here). Handles Stop, picks up new input, and
-- advances the running task one step. Guarded so any error ends the task cleanly.
local polling = false
function agent.poll()
  if polling then return end       -- no re-entrancy
  polling = true
  local ok, err = pcall(function()
    -- The Settings (...) button: open the config prompt even while a task is
    -- running. prompt() is modal and blocks the timer briefly, but the async
    -- HTTP worker keeps the in-flight LLM call running, so when the prompt
    -- closes we resume mid-step without dropping work.
    if agentlib.take_settings_req and agentlib.take_settings_req() then
      open_settings()
    end
    -- "Get Key" button: open the DeepSeek API-keys page in a new tab so the
    -- user can grab their token without leaving the agent. The sidebar is
    -- per-frame so it follows the new tab automatically.
    -- (Was OPEN_NEW which is new WINDOW; OPEN_NEWTAB is the foreground new tab.)
    if agentlib.take_getkey_req and agentlib.take_getkey_req() then
      km.NavigateTo("https://platform.deepseek.com/api_keys", OPEN_NEWTAB)
      say_agent("Opened the DeepSeek API keys page in a new tab. " ..
                "Copy your key, then click `...` here to paste it in.")
    end
    if agentlib.take_stop() then agent.stop() end
    if not task then
      local inp = agentlib.take_input()
      if inp and inp ~= "" then agent.start(inp) end
      return
    end
    do_step()
  end)
  polling = false
  if not ok then finish("[agent exception] " .. tostring(err)) end
end

-------------------------------------------------- setup (runs on require)
-- Legacy floating-window fallback (for older luamacro w/o SetSideBarPanel).
function agent.open() agentlib.show("K-Meleon Agent") end

local welcomed_in = {}   -- hwnd -> true, so we welcome only the first time per panel
local function welcome()
  say_agent("**K-Meleon Agent ready.**\n" ..
    "Describe a task — for example *find the cheapest flight from Chicago to LA on Google Flights*.")
  if not API_KEY or API_KEY == "" or API_KEY:match("PASTE") then
    say_err("no DeepSeek key found in `pref\\agent\\config.md`")
  end
end
local function init_sidebar_panel(parent)
  local hwnd = agentlib.show_in(parent)
  if hwnd and hwnd ~= 0 and not welcomed_in[hwnd] then
    welcomed_in[hwnd] = true; welcome()
  end
  return hwnd or 0
end

if km.SetSideBarPanel then
  -- Modern path: chat lives in the browser frame's built-in sidebar.
  km.SetSideBarPanel("Agent", init_sidebar_panel)
else
  -- Fallback: floating top-level window (older luamacro without sidebar API).
  agentlib.show("K-Meleon Agent"); welcome()
end

-- Drive everything from luamacro's runtime: poll for input + step the task.
-- 300ms keeps the input lag low.
km.SetTimer(300, agent.poll)

-- global so a toolbar/menu command (luamacro(agent_open)) can re-show the floating window
function agent_open() agent.open() end

-- Toolbar button command: toggle the Agent sidebar panel on the current frame.
-- The sidebar panel command IDs are SIDEBAR_MENU_START_ID (2050) + panel index;
-- "Agent" is registered first, so its toggle id is 2050. (If other plugins later
-- register sidebar panels, swap this for a name-based lookup.)
function agent_toggle_sidebar() if km.PostCmd then km.PostCmd(2050) end end

return agent
