--- @name         Studio Outline
--- @description  Build an outline; the ^ ^^ --- syntax is written for you
--- @type         oneshot
--- @key          o
--- @requires     document
--- @caps         screen.draw, doc.read, doc.edit, data.read, data.write, ui.list, ui.ask, ui.alert

-- An outline builder. You type items and press Enter for the next one;
-- Right (or Tab) makes an item deeper, Left makes it shallower. Each
-- row shows the mark Studio will get -- ^ for a header, ^^ for a
-- subheader, --- for a detail -- so the syntax is learned by seeing
-- it, never by remembering it.
--
--   Ctrl+T o         start a new outline. Pick: a block here, or the
--                    whole file (only when the file is empty).
--   With text selected that is already an outline (either syntax),
--   it opens for editing and the result replaces the selection.
--
--   Enter            new item below, same depth
--   Right / Tab      deeper (at most one deeper than the item above)
--   Left             shallower
--   Up / Down        move between items
--   Backspace        delete a letter; on an empty item, delete the item
--   Escape           done -- the outline is written to the page
--   Ctrl+.           abandon without writing anything
--
-- The pane cannot edit the page, so the outline is inserted after
-- Escape. A spare copy is kept in studio-outline.dat until that
-- insert succeeds; if it does not, run again and it is restored.

local MARGIN  = 4
local INDENT  = 14
local MAX_ITEMS = 60

local MARK = { "^ ", "^^ ", "--- " }

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

-- Parsing -------------------------------------------------------------------

-- Either syntax, plus the header lines a block or file carries.
-- Returns items, and what the header said so it can be rebuilt.
local function parse(text)
  local items = {}
  local head = { mode = "block", handle = "", title = "" }
  for raw in (text .. "\n"):gmatch("([^\n]*)\n") do
    local line = raw:gsub("\r$", "")
    local t = trim(line)
    local h
    if t == "" then
      -- spacing
    elseif t:lower():match("^::%s*as%s+outline%s*$") then
      head.mode = "file"
    elseif t:match("^::%s*[Oo][Uu][Tt][Ll][Ii][Nn][Ee]") then
      h = t:match("@([%w][%w_%-]*)")
      head.handle = h or ""
    elseif t:lower():match("^title%s*:") then
      head.title = trim(t:match("^[^:]*:(.*)$") or "")
    elseif t:match("^%-%-%-") then
      items[#items + 1] = { text = trim(t:sub(4)), level = 3 }
    elseif t:match("^%^%^") then
      items[#items + 1] = { text = trim(t:sub(3)), level = 2 }
    elseif t:match("^%^") then
      items[#items + 1] = { text = trim(t:sub(2)), level = 1 }
    else
      local spaces, body = line:match("^(%s*)[%-%*]%s+(.+)$")
      if body then
        local n = #(spaces:gsub("\t", "  "))
        local level = (n <= 0) and 1 or (n <= 2 and 2 or 3)
        items[#items + 1] = { text = trim(body), level = level }
      else
        items[#items + 1] = { text = t, level = 1 }   -- plain line: a header
      end
    end
  end
  return items, head
end

local function render(items, head)
  local out = {}
  if head.mode == "file" then
    out[#out + 1] = "::as outline"
    if head.title ~= "" then out[#out + 1] = "title: " .. head.title end
    out[#out + 1] = ""
  else
    out[#out + 1] = ""
    out[#out + 1] = "::outline" .. (head.handle ~= "" and (" @" .. head.handle) or "")
  end
  local any = false
  for _, it in ipairs(items) do
    if trim(it.text) ~= "" then
      out[#out + 1] = MARK[it.level] .. trim(it.text)
      any = true
    end
  end
  if not any then return nil end
  out[#out + 1] = ""
  if head.mode == "block" then out[#out + 1] = "" end
  return table.concat(out, "\n")
end

-- Spare copy ------------------------------------------------------------------

local function save_spare(text)
  return BYOK.data.write("# Studio: Outline. Unsent outline, restored on next run.\n" .. (text or ""))
end

local function load_spare()
  local text, why = BYOK.data.read()
  if not text then return nil, (why ~= "missing") end
  local body = text:gsub("^#[^\n]*\n", "")
  if trim(body) == "" then return nil, false end
  return body, false
end

-- Drawing ---------------------------------------------------------------------

local function fit_tail(s, text, width)
  -- Show the end of a long item, where typing happens.
  if s.textWidth(text) <= width then return text end
  local cut = text
  while #cut > 1 and s.textWidth(".." .. cut) > width do
    cut = cut:sub(2)
  end
  return ".." .. cut
end

local function draw_row(s, items, i, row, cur, lh, w)
  local it = items[i]
  local top = row * lh
  s.fill(0, top, w - 1, top + lh - 1, "clear")
  if not it then return end
  local x = MARGIN + (it.level - 1) * INDENT
  local avail = w - x - MARGIN - 3
  local shown = fit_tail(s, MARK[it.level] .. it.text, avail)
  s.text(x, top + lh - 1, shown)
  if i == cur then
    local cx = x + s.textWidth(shown) + 1
    s.fill(cx, top + 1, cx + 1, top + lh - 2, "solid")
  end
end

local function draw_all(s, items, cur, top_i, rows, lh, w)
  s.clear()
  for r = 0, rows - 1 do
    draw_row(s, items, top_i + r, r, cur, lh, w)
  end
  s.setFont("small")
  local hint = "Enter new  Right/Left depth  Esc done"
  s.text(MARGIN, s.height() - 2, hint)
  s.setFont("default")
end

-- Editing ---------------------------------------------------------------------

-- Deepest an item may be: one more than the item above it.
local function max_level(items, i)
  if i == 1 then return 1 end
  local m = items[i - 1].level + 1
  return (m > 3) and 3 or m
end

local function clamp_levels(items)
  for i = 1, #items do
    local m = max_level(items, i)
    if items[i].level > m then items[i].level = m end
  end
end

local function edit(s, items)
  if #items == 0 then items[1] = { text = "", level = 1 } end
  clamp_levels(items)

  local lh   = s.lineHeight()
  local w    = s.width()
  local rows = (s.height() // lh) - 1      -- one row for the hint
  if rows < 2 then rows = 2 end
  local cur, top_i = #items, 1
  if cur > rows then top_i = cur - rows + 1 end

  local function keep_visible()
    if cur < top_i then top_i = cur end
    if cur > top_i + rows - 1 then top_i = cur - rows + 1 end
  end

  draw_all(s, items, cur, top_i, rows, lh, w)

  while s.isOpen() do
    local k = s.key(2000)
    if not s.isOpen() then break end
    if s.damaged() then draw_all(s, items, cur, top_i, rows, lh, w) end
    if k == nil then
      -- nothing; the pane keeps its pixels
    elseif k == "enter" then
      if #items < MAX_ITEMS then
        table.insert(items, cur + 1, { text = "", level = items[cur].level })
        cur = cur + 1
        keep_visible()
        draw_all(s, items, cur, top_i, rows, lh, w)
      end
    elseif k == "backspace" or k == "delete" then
      local it = items[cur]
      if it.text ~= "" then
        it.text = it.text:sub(1, #it.text - 1)
        draw_row(s, items, cur, cur - top_i, cur, lh, w)
      elseif #items > 1 then
        table.remove(items, cur)
        if cur > #items then cur = #items end
        clamp_levels(items)
        keep_visible()
        draw_all(s, items, cur, top_i, rows, lh, w)
      end
    elseif k == "right" or k == "tab" then
      local m = max_level(items, cur)
      if items[cur].level < m then
        items[cur].level = items[cur].level + 1
        clamp_levels(items)
        draw_all(s, items, cur, top_i, rows, lh, w)
      end
    elseif k == "left" then
      if items[cur].level > 1 then
        items[cur].level = items[cur].level - 1
        clamp_levels(items)
        draw_all(s, items, cur, top_i, rows, lh, w)
      end
    elseif k == "up" then
      if cur > 1 then
        cur = cur - 1
        keep_visible()
        draw_all(s, items, cur, top_i, rows, lh, w)
      end
    elseif k == "down" then
      if cur < #items then
        cur = cur + 1
        keep_visible()
        draw_all(s, items, cur, top_i, rows, lh, w)
      end
    elseif type(k) == "string" and #k == 1 then
      if k ~= " " or items[cur].text ~= "" then
        items[cur].text = items[cur].text .. k
        draw_row(s, items, cur, cur - top_i, cur, lh, w)
      end
    end
  end
end

-- Page -------------------------------------------------------------------------

local function file_has_content()
  for chunk in BYOK.doc.chunks() do
    if trim(chunk) ~= "" then return true end
  end
  return false
end

function main()
  local selection = BYOK.doc.selection()
  local items, head

  local spare, spare_bad = load_spare()
  if spare_bad then
    BYOK.ui.alert("Outline", "Cannot read the spare file")
    return
  end

  if selection then
    items, head = parse(selection)
    if #items == 0 then
      BYOK.ui.alert("Outline", "Selection is not an outline")
      return
    end
  elseif spare then
    items, head = parse(spare)
    BYOK.ui.alert("Restored", "Your unsent outline is back")
  else
    local choice = BYOK.ui.list({ "Outline block here", "Whole file is an outline" })
    if not choice then return end
    head = { mode = "block", handle = "", title = "" }
    if choice == 2 then
      if file_has_content() then
        BYOK.ui.alert("Needs an empty file", "::as outline must be line 1")
        return
      end
      head.mode = "file"
      local title = BYOK.ui.ask("Outline title (Esc = none)")
      head.title = title and trim(title) or ""
    else
      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
      head.handle = handle
    end
    items = {}
  end

  if not BYOK.screen.open() then return end
  local s = BYOK.screen
  edit(s, items)

  local text = render(items, head)
  if not text then
    save_spare("")
    return                       -- nothing typed; nothing to write
  end

  -- The page will not take edits until the pane is gone. Spare first.
  save_spare(text)
  if s.isOpen() then s.close() end

  if BYOK.doc.insert(text) then
    save_spare("")
  else
    BYOK.ui.alert("Not written", "Kept. Run again to retry")
  end
end
