--- @name         Studio Captures
--- @description  Wrap a selection as a capture. Check before sync
--- @type         oneshot
--- @key          s
--- @requires     document
--- @caps         doc.read, doc.edit, doc.navigate, ui.list, ui.ask, ui.alert

-- Studio captures that need more than typing.
--
--   Starting a capture from nothing is the Expander's job: scard, swiki,
--   snote, stask and Tab lay the header down in one keystroke. This
--   script does the things a shortcut cannot.
--
--   With text SELECTED, Ctrl+T s offers:
--     Comment on selection    [selection] ::comment  -- type the comment
--     Card from selection     ::card above it, blank line below
--     Task list from selection  every line becomes a - item
--     Wiki entry from selection
--     Note from selection
--
--   With nothing selected:
--     Comment on this paragraph
--     File details (meta)     folder, tags, description, each optional
--     Check captures          what Studio will see, and where it will
--                             misread -- run it before you sync
--     Whole file is an outline
--
-- Card and Wiki ask for a handle, then a title. Esc skips either.

local MAX_PROBLEMS = 8      -- shown one at a time after a check
local CARRY_LIMIT  = 400    -- lines a capture block may straddle a join by

local KNOWN = {
  card = true, wiki = true, outline = true, note = true,
  task = true, idea = true, comment = true, meta = true,
}

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

-- Reading the file -----------------------------------------------------------

-- Chunks are a few thousand characters each and repeat one line at every
-- join. Whole documents are never held at once: a long manuscript would
-- not fit in the memory a script is given.
--
-- `fn(lines, final)` is handed each chunk's lines (the repeated join
-- line already removed) and returns how many it consumed, or false to
-- stop. Lines it did not consume -- a capture block still open at the
-- end of the chunk -- are carried into the next chunk, so a block that
-- straddles a join is read whole. The last call has `final` set.
local function stream_lines(fn)
  local carry, first = {}, true
  for chunk in BYOK.doc.chunks() do
    local lines = carry
    local skip = not first
    for line in (chunk .. "\n"):gmatch("([^\n]*)\n") do
      if skip then skip = false
      else lines[#lines + 1] = (line:gsub("\r$", "")) end
    end
    -- gmatch leaves a trailing "" when the chunk already ended in a
    -- newline; drop it so the last line is a real line.
    if #lines > 0 and lines[#lines] == "" then lines[#lines] = nil end
    first = false
    local used = fn(lines, false)
    if used == false then return end
    carry = {}
    for k = used + 1, #lines do carry[#carry + 1] = lines[k] end
  end
  if #carry > 0 then fn(carry, true) end
end

local function first_content_line()
  local found
  stream_lines(function(lines)
    for _, line in ipairs(lines) do
      if trim(line) ~= "" then found = line return false end
    end
    return #lines
  end)
  return found
end

-- How many times `text` appears in the document. Counted per chunk, so
-- a phrase that itself spans a join is missed; that reads as "not
-- found" and the writer is told, never as a wrong replacement.
local function occurrences(text)
  local n = 0
  stream_lines(function(lines)
    local body = table.concat(lines, "\n")
    local at = 1
    while true do
      local s, e = body:find(text, at, true)
      if not s then break end
      n = n + 1
      at = e + 1
    end
    return #lines
  end)
  return n
end

-- Any dialog the script opens drops the editor's selection, so by the
-- time the menu has been answered nothing is selected any more and an
-- insert would land beside the text instead of replacing it. Find the
-- text again -- find() selects what it lands on -- but only when it
-- occurs once, or the wrong copy could be replaced.
local function reselect(selection)
  local n = occurrences(selection)
  if n == 1 and BYOK.doc.find(selection) then return true end
  if n > 1 then
    BYOK.ui.alert("Selection not unique", "It appears " .. n .. " times")
  else
    BYOK.ui.alert("Selection lost", "Select it again and retry")
  end
  return false
end

local function put(text)
  if not BYOK.doc.insert(text) then
    BYOK.ui.alert("Nothing added", "The page refused the edit")
    return false
  end
  return true
end

-- Wrapping a selection ---------------------------------------------------------

-- Every non-empty line of a task body must be a dash item, or Studio
-- ends the list there. Prefix what is not already prefixed.
local function as_task_items(text)
  local out = {}
  for line in (text .. "\n"):gmatch("([^\n]*)\n") do
    local t = trim(line)
    if t ~= "" then
      if t:match("^%-%s*%S") then out[#out + 1] = t
      else out[#out + 1] = "- " .. t end
    end
  end
  return table.concat(out, "\n")
end

-- Two short prompts, both skippable. A handle is letters, digits, -
-- and _; anything else on the command line makes Studio file the whole
-- thing as a plain note, so a bad handle is refused rather than written.
local function ask_handle_title()
  local handle = BYOK.ui.ask("Handle (Esc = none)")
  handle = handle and trim(handle):gsub("^@", "") or ""
  if handle ~= "" and not handle:match("^[%w][%w_%-]*$") then
    BYOK.ui.alert("Handle not used", "Letters, digits, - and _")
    handle = ""
  end
  local title = BYOK.ui.ask("Title (Esc = none)")
  title = title and trim(title) or ""
  return handle, title
end

-- The block. A line break first so ::command sits at column 0 even when
-- the selection began mid-line. The blank line that ends the block is
-- included, so the prose after it cannot be swallowed.
local function wrap(token, selection)
  local handle, title = "", ""
  if token == "card" or token == "wiki" then
    handle, title = ask_handle_title()
  end
  local body = (token == "task") and as_task_items(selection) or selection

  local out = "\n::" .. token
  if handle ~= "" then out = out .. " @" .. handle end
  out = out .. "\n"
  if title ~= "" then out = out .. "title: " .. title .. "\n" end
  out = out .. body .. "\n\n"

  if not reselect(selection) then return end
  put(out)
end

local function comment(selection)
  if selection then
    if not reselect(selection) then return end
    -- The comment reads to the end of its line. If prose follows on
    -- the same line, the writer presses Enter after the comment.
    put("[" .. selection .. "] ::comment ")
  else
    put("\n::comment ")
  end
end

-- File details -------------------------------------------------------------------

-- Only the keys that were answered are written. An empty "description:"
-- would be applied as a blank description on sync, so it is never left
-- behind. When a description is given it goes last with no line break
-- after it, so the cursor sits at its end and a long one can be
-- finished on the page.
local function meta()
  local folder = BYOK.ui.ask("Folder (Esc = skip)")
  local tags   = BYOK.ui.ask("Tags, comma separated")
  local desc   = BYOK.ui.ask("Description (Esc = skip)")
  folder = folder and trim(folder) or ""
  tags   = tags and trim(tags) or ""
  desc   = desc and trim(desc) or ""
  if folder == "" and tags == "" and desc == "" then return end

  local out = "\n::meta\n"
  if folder ~= "" then out = out .. "folder: " .. folder .. "\n" end
  if tags   ~= "" then out = out .. "tags: " .. tags .. "\n" end
  if desc   ~= "" then out = out .. "description: " .. desc
  else out = out .. "\n" end
  put(out)
end

-- Whole file -----------------------------------------------------------------------

local function as_outline()
  local first = first_content_line()
  if first and first:lower():match("^::%s*as%s+outline%s*$") then
    BYOK.ui.alert("Already set", "This file is an outline")
    return
  end
  if first then
    BYOK.ui.alert("Needs an empty file", "::as outline must be line 1")
    return
  end
  local title = BYOK.ui.ask("Outline title (Esc = none)")
  local out = "::as outline\n"
  if title and trim(title) ~= "" then out = out .. "title: " .. trim(title) .. "\n" end
  if put(out .. "\n") then
    BYOK.ui.alert("Outline lines", "^ head  ^^ sub  --- detail")
  end
end

-- Check captures -------------------------------------------------------------

local function outline_line_ok(raw)
  local line = trim(raw)
  if line:match("^%-%-%-%s*%S") or line:match("^%^%^%s*%S") or line:match("^%^%s*%S") then
    return true
  end
  return raw:match("^%s*[%-%*]%s+%S") ~= nil
end

local function meta_line_ok(raw)
  return trim(raw):match("^[%a_][%w_%-]*%s*:") ~= nil
end

-- Mirrors capture_parser.py closely enough to predict the inbox. Runs
-- over one chunk's lines at a time; `st` carries counts and problems
-- across chunks. Returns how many lines it consumed: a block that runs
-- off the end of the chunk is left for the next one (see stream_lines)
-- unless this is the final chunk, or the block is absurdly long.
local function check_lines(lines, st, final)
  local function problem(text, line)
    if #st.problems < MAX_PROBLEMS then
      st.problems[#st.problems + 1] = { text = text, line = line }
    end
  end

  local i, n = 1, #lines

  -- File Mode is decided by the first content line of the document.
  if st.mode == nil then
    st.mode = "block"
    for k = 1, n do
      if trim(lines[k]) ~= "" then
        if lines[k]:lower():match("^::%s*as%s+outline%s*$") then
          st.mode = "file"
          i = k + 1
          if i <= n and trim(lines[i]):lower():match("^title%s*:") then i = i + 1 end
        end
        break
      end
    end
  end

  if st.mode == "file" then
    for k = i, n do
      local raw = lines[k]
      if trim(raw) ~= "" and not outline_line_ok(raw) then
        problem("Not an outline line", raw)
      end
    end
    return n
  end

  while i <= n do
    local raw = lines[i]
    if raw:sub(1, 2) == "::" then
      local word, rest = raw:match("^::%s*([%a][%w_%-]*)(.*)$")
      local wl = word and word:lower() or nil
      local handle = rest and rest:match("^%s*@([%w][%w_%-]*)%s*$")
      local bare = rest and trim(rest) == ""
      local kind = (wl and KNOWN[wl]) and wl or nil

      if wl == "comment" then
        if trim(rest) == "" then problem("Comment with no text", raw) end
        st.counts.comment = (st.counts.comment or 0) + 1
        i = i + 1
      elseif kind and (bare or handle) then
        st.counts[kind] = (st.counts[kind] or 0) + 1
        -- Body: to the first blank line, next :: line, or the end.
        -- Blank lines BEFORE the body are skipped, exactly as Studio
        -- does -- which means a command left empty swallows the next
        -- paragraph. Worth a warning.
        local j = i + 1
        local gap = 0
        while j <= n and trim(lines[j]) == "" do j = j + 1 gap = gap + 1 end
        local body_n = 0
        while j <= n and lines[j]:sub(1, 2) ~= "::" and trim(lines[j]) ~= "" do
          local b = lines[j]
          if kind == "task" and not trim(b):match("^%-%s*%S") then break end
          body_n = body_n + 1
          j = j + 1
        end
        -- Ran off the end without seeing where the block stops: the
        -- rest is in the next chunk. Hand this block back untouched.
        if j > n and not final and (n - i) < CARRY_LIMIT then
          st.counts[kind] = st.counts[kind] - 1
          return i - 1
        end
        for k = i + 1 + gap, j - 1 do
          local b = lines[k]
          if kind == "outline" and not outline_line_ok(b) then
            problem("Not an outline line", b)
          elseif kind == "meta" and not meta_line_ok(b) then
            problem("Not a key: value line", b)
          end
        end
        if body_n == 0 then
          problem("Command with no body", raw)
        elseif gap > 0 then
          problem("Blank line, then body taken", raw)
        end
        i = j
      elseif kind then
        -- ::card The betrayal  -- Studio files this as a plain note.
        problem("Text after command = note", raw)
        st.counts.note = (st.counts.note or 0) + 1
        i = i + 1
      else
        -- An unknown ::word (a project custom command, or a plain
        -- note the old way). Counted as a note; the device cannot
        -- know which.
        st.counts.note = (st.counts.note or 0) + 1
        i = i + 1
      end
    else
      i = i + 1
    end
  end
  return n
end

-- Alert lines hold about 26 characters. Pack the counts into as many
-- lines as they need rather than cutting the list short.
local function count_lines(counts)
  local parts = {}
  for _, k in ipairs({ "card", "wiki", "outline", "note", "task", "comment", "meta" }) do
    if counts[k] then parts[#parts + 1] = counts[k] .. " " .. k end
  end
  local out, line = {}, ""
  for _, p in ipairs(parts) do
    local try = (line == "") and p or (line .. ", " .. p)
    if #try <= 26 then line = try
    else out[#out + 1] = line line = p end
  end
  if line ~= "" then out[#out + 1] = line end
  if #out == 0 then out[1] = "No captures" end
  return out
end

local function check()
  local st = { counts = {}, problems = {} }
  stream_lines(function(lines, final) return check_lines(lines, st, final) end)

  if st.mode == "file" then
    BYOK.ui.alert("Studio will see", "One outline (whole file)")
  else
    for k, l in ipairs(count_lines(st.counts)) do
      BYOK.ui.alert(k == 1 and "Studio will see" or "...and", l)
    end
  end

  local nbad = #st.problems
  if nbad == 0 then
    BYOK.ui.alert("Check captures", "No problems found")
    return
  end
  BYOK.ui.alert("Check captures", nbad .. " problem" .. (nbad == 1 and "" or "s") .. " -- Enter to see")
  for k, p in ipairs(st.problems) do
    BYOK.ui.alert(k .. "/" .. nbad .. " " .. p.text, trim(p.line):sub(1, 26))
    if not BYOK.doc.find(trim(p.line)) then
      BYOK.ui.alert("Could not jump", "Look for it by hand")
    end
  end
end

-- Menu -----------------------------------------------------------------------

function main()
  -- Read before anything opens: a list on screen loses the selection,
  -- and the selection decides which menu this is.
  local selection = BYOK.doc.selection()

  local rows
  if selection then
    rows = {
      { label = "Comment on selection",     run = function() comment(selection) end },
      { label = "Card from selection",      run = function() wrap("card", selection) end },
      { label = "Task list from selection", run = function() wrap("task", selection) end },
      { label = "Wiki entry from selection",run = function() wrap("wiki", selection) end },
      { label = "Note from selection",      run = function() wrap("note", selection) end },
    }
  else
    rows = {
      { label = "Comment on this paragraph", run = function() comment(nil) end },
      { label = "File details (meta)",       run = meta },
      { label = "Check captures",            run = check },
      { label = "Whole file is an outline",  run = as_outline },
    }
  end

  local labels = {}
  for i, r in ipairs(rows) do labels[i] = r.label end
  local choice = BYOK.ui.list(labels)
  if not choice then return end
  rows[choice].run()
end
