--- @name         Focus
--- @description  One line at a time, in the middle
--- @type         oneshot
--- @key          f
--- @requires     document
--- @caps         screen.draw, doc.edit, data.read, data.write

-- One visual line, centred. The rest of the page is gone. They type as
-- usual: the line fills, it leaves, the next one takes its place. Enter
-- still means a new paragraph. Escape gives the page back.
--
-- The open file is the book, but the editor will not take inserts while
-- this pane has the screen. So the sitting is kept in memory, and the
-- whole of it is written to the book only after the pane closes. Inserts
-- are capped at 8 KB each, so a long sitting is written in pieces.
--
-- The data file is a spare copy of this sitting only — never the book —
-- in case the script is killed before Escape. It is also capped at 8 KB.
-- Crossing that only stops the spare; the book still gets everything.

local MARGIN       = 12
local SAVE_KEYS    = 8
local INSERT_CHUNK = 8192
local BLINK_MS     = 800

local function textAt(s, x, top, str)
  s.text(x, top + s.lineHeight(), str)
end

local function wrap_lines(s, text, width)
  local lines = {}
  local row = ""
  for word in text:gmatch("%S+") do
    local try = (row == "") and word or (row .. " " .. word)
    if s.textWidth(try) <= width then
      row = try
    else
      if row ~= "" then lines[#lines + 1] = row end
      row = word
    end
  end
  if row ~= "" then lines[#lines + 1] = row end
  if text:match("%s$") then
    local last = lines[#lines]
    if not last then
      lines[1] = ""
    elseif s.textWidth(last .. " ") <= width then
      lines[#lines] = last .. " "
    else
      lines[#lines + 1] = ""
    end
  end
  if #lines == 0 then lines[1] = "" end
  return lines
end

-- Spare file is a comment, then the sitting, newlines kept. The extra
-- newline on write is stripped here so a sitting that ended on Enter
-- does not grow a blank paragraph every time Focus is reopened.
local function load_backup()
  local text, why = BYOK.data.read()
  if not text then
    if why ~= "missing" then return "", false end
    return "", true
  end
  local body = text
  while true do
    local rest = body:match("^#[^\r\n]*\r?\n(.*)$")
    if not rest then break end
    body = rest
  end
  if body:match("^#") then return "", true end
  if body:sub(-1) == "\n" then
    body = body:sub(1, -2)
  end
  return body, true
end

local function split_draft(text)
  local at = text:match("^.*()\n")
  if not at then return "", text end
  return text:sub(1, at), text:sub(at + 1)
end

local function save_backup(text)
  local body = text or ""
  local out = {
    "# Focus. Session draft. Safe to delete when Focus is closed.",
    body,
  }
  return BYOK.data.write(table.concat(out, "\n") .. "\n")
end

function main()
  local restored, file_ok = load_backup()
  local committed, paragraph = split_draft(restored)
  local spare_ok = file_ok
  local dirty = 0

  if not BYOK.screen.open() then return end
  local s = BYOK.screen
  s.setFont("default")

  -- The pane keeps its pixels. Clearing the line and rebuilding it on
  -- every key — or every cursor blink — is what made the line flash.
  -- Blink only inverts the bar. Typing only draws the letters that
  -- changed. A full rebuild is for wrap, Enter, and a damaged pane.
  --
  -- Geometry is measured on every full rebuild. A pane covered by an
  -- alert can measure 0; we retry rather than draw at a made-up spot.
  local lh, top, width = 0, 0, 0
  local shown_now = ""
  local cursor_on = false
  local cx = 0

  local function measure()
    local h = s.height()
    if h <= 0 then return false end
    lh = s.lineHeight()
    top = (h // 2) - lh
    width = s.width() - MARGIN * 2
    if width < 8 then width = s.width() end
    return true
  end

  local function draft()
    return committed .. paragraph
  end

  local function shown()
    local lines = wrap_lines(s, paragraph, width)
    return lines[#lines]
  end

  local function cursor_x_for(str)
    local x = MARGIN + s.textWidth(str)
    local max = s.width() - MARGIN - 2
    if x > max then x = max end
    return x
  end

  local function hide_cursor()
    if not cursor_on then return end
    s.invert(cx, top + 2, cx + 1, top + lh)
    cursor_on = false
  end

  local function show_cursor()
    if cursor_on then return end
    cx = cursor_x_for(shown_now)
    s.invert(cx, top + 2, cx + 1, top + lh)
    cursor_on = true
  end

  local function paint_shown(new_shown)
    hide_cursor()
    local old = shown_now
    if new_shown == old then
      show_cursor()
      return
    end

    if #new_shown > #old and new_shown:sub(1, #old) == old then
      textAt(s, MARGIN + s.textWidth(old), top, new_shown:sub(#old + 1))
    elseif #old > #new_shown and old:sub(1, #new_shown) == new_shown then
      local x0 = MARGIN + s.textWidth(new_shown)
      local x1 = MARGIN + s.textWidth(old)
      s.fill(x0, top, x1 + 2, top + lh, "clear")
    else
      local erase = math.max(s.textWidth(old), s.textWidth(new_shown)) + 3
      s.fill(MARGIN, top, MARGIN + erase, top + lh, "clear")
      if new_shown ~= "" then
        textAt(s, MARGIN, top, new_shown)
      end
    end

    shown_now = new_shown
    show_cursor()
  end

  -- Returns false if the pane could not be measured; the caller tries
  -- again on the next tick rather than drawing at a made-up position.
  local function paint_all()
    if not measure() then return false end
    s.clear()
    shown_now = ""
    cursor_on = false
    paint_shown(shown())
    return true
  end

  local function persist()
    if spare_ok then
      spare_ok = save_backup(draft())
    end
    dirty = 0
  end

  -- Pane first: inserts are dropped while it still has the screen.
  -- Then the sitting, in pieces the editor will accept. A failed
  -- piece leaves the rest in the spare so a later Focus does not
  -- write the prefix twice. A full write always clears the spare,
  -- even if mid-session backups had already stopped.
  local function flush()
    local text = draft()
    -- Spare first, so a script killed between here and the last insert
    -- still leaves the sitting on the card.
    persist()
    if s.isOpen() then s.close() end
    local i = 1
    local n = #text
    while i <= n do
      local last = i + INSERT_CHUNK - 1
      if last > n then last = n end
      if not BYOK.doc.insert(text:sub(i, last)) then
        if file_ok then save_backup(text:sub(i)) end
        return
      end
      i = last + 1
    end
    -- Clear even if mid-session spares had already stopped: the sitting
    -- is in the book, and a leftover prefix must not come back next time.
    if file_ok then save_backup("") end
    committed = ""
    paragraph = ""
  end

  local need_paint = not paint_all()

  while s.isOpen() do
    local k = s.key(BLINK_MS)
    if not s.isOpen() then break end

    if s.damaged() then need_paint = true end
    if need_paint then need_paint = not paint_all() end

    if k == nil then
      if cursor_on then hide_cursor() else show_cursor() end
    elseif k == "enter" then
      committed = committed .. paragraph .. "\n"
      paragraph = ""
      dirty = dirty + 1
      paint_shown(shown())
    elseif k == "backspace" or k == "delete" then
      if #paragraph > 0 then
        paragraph = paragraph:sub(1, #paragraph - 1)
        dirty = dirty + 1
        paint_shown(shown())
      end
    elseif k == "tab" then
      paragraph = paragraph .. "     "
      dirty = dirty + 1
      paint_shown(shown())
    elseif type(k) == "string" and #k == 1 then
      paragraph = paragraph .. k
      dirty = dirty + 1
      paint_shown(shown())
    end

    if dirty >= SAVE_KEYS then persist() end
  end

  flush()
end
