--- @name         Expander
--- @description  Shortcut then Tab expands. xx then Tab manages
--- @type         watcher
--- @requires     document
--- @caps         doc.edit, data.read, data.write, ui.list, ui.ask, ui.alert

-- Expander. Type a short form, press Tab, get the long form.
--
--   dim then Tab   -- becomes whatever dim stands for.
--   Dim then Tab   -- the same, with its first letter capitalised.
--   xx then Tab    -- the menu: add a shortcut, remove one, or pick one
--                     from the list to insert it (for when you forget).
--
--   Tab after anything that is not a shortcut is an ordinary Tab. Nothing
--   is touched, so an indent at the start of a line stays an indent.
--
-- Adding one: xx, Tab, Add, name the shortcut -- then type its text
-- right there on the page, as long as you like, and press Enter. The
-- text is saved as the shortcut and lifted off the page. Nothing to
-- learn but Enter. (Firmware d3bb518 or later: it hands a watcher the
-- whole paragraph on Enter, up to about 1000 characters. Longer than
-- that and Enter does nothing; xx then Tab cancels and the text stays
-- where you typed it.)
--
-- Shortcuts live in expander.dat next to this script, one per line as
-- shortcut|expansion. Write \n in the file for a line break inside an
-- expansion. Edit it on a computer whenever.
--
-- Tab is the only event a watcher may ask or list from, which is why
-- the menu lives there and why this expands on Tab rather than on the
-- space after a word. It also means this and any other Tab-editing
-- watcher (Screenplay assistant) cannot be on at the same time -- and,
-- because it edits on Enter too, neither can any other watcher that
-- edits on Enter.

local COMMAND       = "xx"
local MAX_SHORTCUTS = 30      -- ui.list caps at 32; two rows are Add and Remove
local MAX_KEY_LEN   = 24
local LABEL_LEN     = 26      -- about what one list row shows

-- key -> expansion. Keys kept as written; matching is case-aware below.
local shortcuts = {}
local can_save  = true

-- While a shortcut's text is being typed on the page: its name, and
-- where on the page the text begins (where xx was, before it was
-- removed). nil the rest of the time.
local capturing = nil

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

local function sorted_keys()
  local keys = {}
  for k in pairs(shortcuts) do keys[#keys + 1] = k end
  table.sort(keys, function(a, b) return a:lower() < b:lower() end)
  return keys
end

local function count()
  local n = 0
  for _ in pairs(shortcuts) do n = n + 1 end
  return n
end

-- File ------------------------------------------------------------------

local function decode(text)
  return (text:gsub("\\n", "\n"))
end

local function encode(text)
  return (text:gsub("\n", "\\n"))
end

local function load_data()
  local text, why = BYOK.data.read()
  if not text then
    -- Only "missing" is a safe first run. Anything else means a file is
    -- there that we could not read, and saving would replace it.
    if why ~= "missing" then can_save = false end
    return
  end
  for raw in text:gmatch("[^\r\n]+") do
    if not raw:match("^%s*#") then
      local key, body = raw:match("^%s*([^|]-)%s*|(.*)$")
      if key and key ~= "" and body ~= "" then
        shortcuts[key] = decode(body)
      end
    end
  end
end

local function save_data()
  if not can_save then return false end
  local lines = {
    "# Expander. One per line: shortcut|expansion. \\n is a line break.",
    "# On the device: shortcut then Tab expands. xx then Tab manages.",
    "",
  }
  for _, key in ipairs(sorted_keys()) do
    lines[#lines + 1] = key .. "|" .. encode(shortcuts[key])
  end
  if not BYOK.data.write(table.concat(lines, "\n") .. "\n") then
    return false
  end
  return true
end

-- Matching ---------------------------------------------------------------

-- Exact first. Then any-case, and if the typed word began with a capital
-- the expansion gets one too -- so a shortcut works at the start of a
-- sentence without a second entry for it.
local function expansion_for(typed)
  local exact = shortcuts[typed]
  if exact then return exact end

  local lowered = typed:lower()
  for key, text in pairs(shortcuts) do
    if key:lower() == lowered then
      if typed:sub(1, 1):match("%u") then
        return text:sub(1, 1):upper() .. text:sub(2)
      end
      return text
    end
  end
  return nil
end

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

local function label_for(key)
  local body = shortcuts[key]:gsub("\n", " / ")
  local label = key .. " > " .. body
  if #label > LABEL_LEN then label = label:sub(1, LABEL_LEN - 2) .. ".." end
  return label
end

local function valid_key(key)
  if key == "" or #key > MAX_KEY_LEN then return false end
  if key:lower() == COMMAND then return false end
  -- What Tab hands us is letters and digits only, so anything else could
  -- never be typed as a shortcut.
  return key:match("^[%w\128-\255]+$") ~= nil
end

-- Names the shortcut, then hands the page back for the text. The text
-- itself arrives with the next Enter -- see on_line.
local function add_flow(from)
  local key = BYOK.ui.ask("Shortcut (letters/digits)")
  if not key then return end
  key = trim(key)
  if not valid_key(key) then
    BYOK.ui.alert("Not added", "Letters and digits only")
    return
  end
  if not shortcuts[key] and count() >= MAX_SHORTCUTS then
    BYOK.ui.alert("No room", "At most " .. MAX_SHORTCUTS .. " shortcuts")
    return
  end
  capturing = { key = key, from = from }
  BYOK.ui.alert("Type " .. key .. "'s text", "then press Enter")
end

local function cancel_capture(why)
  capturing = nil
  BYOK.ui.alert("Shortcut not added", why)
end

-- Enter, while a shortcut's text is being typed. The paragraph that
-- ended is handed over whole; the shortcut's text is the part from
-- where xx was to the end, so prose that was already on the line
-- before xx is left alone.
--
-- Save first, remove second: if the save fails the text is still on
-- the page, and nothing the writer typed is lost.
local function on_line(text, lfrom, lto)
  local cap = capturing
  if cap.from < lfrom or cap.from > lto then
    cancel_capture("Enter was on another line")
    return
  end
  local body = trim(text:sub(cap.from - lfrom + 1))
  if body == "" then
    cancel_capture("Nothing was typed")
    return
  end

  local replacing = shortcuts[cap.key] ~= nil
  shortcuts[cap.key] = body
  capturing = nil
  if not save_data() then
    BYOK.ui.alert("Not saved -- file full?", "Text left on the page")
    return
  end
  -- The range excludes the newline Enter made; it sits at lto.
  if BYOK.doc.replaceRange(cap.from, lto + 1, "") then
    BYOK.ui.alert(replacing and "Replaced" or "Added", cap.key .. " > " .. body)
  else
    BYOK.ui.alert("Added", "Could not lift the text")
  end
end

local function remove_flow()
  local keys = sorted_keys()
  if #keys == 0 then
    BYOK.ui.alert("Nothing to remove", "No shortcuts yet")
    return
  end
  local labels = {}
  for i, key in ipairs(keys) do labels[i] = label_for(key) end
  local choice = BYOK.ui.list(labels)
  if not choice then return end

  local key = keys[choice]
  shortcuts[key] = nil
  if save_data() then
    BYOK.ui.alert("Removed", key)
  else
    BYOK.ui.alert("Not saved", "Removed until restart")
  end
end

-- xx then Tab. The command word and Tab's spaces go on every path out:
-- xx is not prose, and backing out of a menu should not leave it behind.
local function menu_flow(from, to)
  BYOK.doc.replaceRange(from, to, "")

  local keys = sorted_keys()
  local items = { "+ Add a shortcut", "- Remove a shortcut" }
  for _, key in ipairs(keys) do items[#items + 1] = label_for(key) end

  local choice = BYOK.ui.list(items)
  if not choice then return end

  if choice == 1 then add_flow(from) return end
  if choice == 2 then remove_flow() return end

  -- Picked from the list: insert its expansion where xx was.
  BYOK.doc.insert(shortcuts[keys[choice - 2]])
end

-- Events -----------------------------------------------------------------

function init()
  load_data()
  BYOK.sys.registerEvent("TAB")
  BYOK.sys.registerEvent("LINE")
end

function listen(event, word, from, to)
  if event == "LINE" then
    if capturing then on_line(word, from, to) end
    return
  end
  if event ~= "TAB" then return end

  local typed = trim(word)
  if typed == "" then return end          -- a plain Tab: leave it alone

  if typed:lower() == COMMAND then
    if capturing then
      -- xx, Tab again while typing a shortcut's text: change of mind.
      -- The text stays on the page for them to deal with.
      BYOK.doc.replaceRange(from, to, "")
      cancel_capture("Cancelled")
      return
    end
    menu_flow(from, to)
    return
  end

  -- While a shortcut's text is being typed, Tab is just Tab. Expanding
  -- inside the text would be a surprise, and the spaces are theirs.
  if capturing then return end

  local text = expansion_for(typed)
  if text then
    BYOK.doc.replaceRange(from, to, text)
  end
  -- Not a shortcut: the writer pressed Tab and gets a Tab.
end
