--- @name         Spelling
--- @description  Offers to fix common misspellings, a word at a time
--- @type         oneshot
--- @key          z
--- @requires     document
--- @caps         doc.read, doc.edit, doc.navigate, data.read, ui.alert, screen.draw, sys.log

-- THE SHAPE OF THIS, AND WHY.
--
-- Three steps: count what is there, ask about each word, then do the work. The
-- order is forced, and each step is where it is for a different reason.
--
-- 1. COUNT FIRST, with doc.chunks.
--
--    Most of the list is not in your writing. Twenty-four words, and a given
--    piece holds maybe three -- so counting first means the other twenty-one
--    never cost a search, and every search that lands re-wraps the document.
--    Counting also lets the window say "2 of 3" instead of counting upward into
--    the dark, and it is what makes the safety check below possible at all.
--
-- 2. ASK WHILE THE WINDOW IS OPEN -- AND CHANGE NOTHING.
--
--    The window sits exactly where the document does and holds the screen, and
--    the editor will not accept an edit while that is true. A script that tries
--    has its change quietly refused, which is a thing worth knowing before you
--    write one: doc.insert hands back whether it was taken, and it will say no
--    for the whole time your window is up.
--
--    So this step only collects answers.
--
-- 3. FIX AFTERWARDS, once the window has gone and the page has the screen back.
--
-- A WORD AT A TIME, NOT AN OCCURRENCE AT A TIME.
--
-- Because the window covers your page, there is no way to show you WHICH "teh"
-- is being asked about -- and find reports whether it matched, never where, so
-- the script cannot fetch the sentence either. Asking six times about the same
-- word, with nothing to tell the six apart, is six chances to answer at random.
-- Asking once, with the number of times it appears, is a question you can
-- actually answer.
--
-- It also makes the fixing loop safe. "Replace every one" always ends, because
-- every turn removes a match. It was only saying no to a SINGLE occurrence that
-- needed a counted bound -- find wraps, so a match you decline comes round for
-- ever. The count is still used as a backstop, and loadWords refuses any pair
-- whose correction contains the misspelling, which is the other way that loop
-- could fail to end.
--
-- WHAT IT STILL CANNOT DO.
--
-- Show you the sentence, as above. And it leaves the last word it looked at
-- SELECTED, exactly as Ctrl+F does, so the first thing you type afterwards
-- replaces it. Press an arrow key first if you were about to carry on writing.

-- THE LIST LIVES IN spelling.dat, NOT IN HERE.
--
-- Deliberately, and with no copy kept in the script. A writer who wants to add
-- the two words they always get wrong should be editing a text file, not Lua,
-- and a built-in list would make that worse rather than better: keep both and
-- they drift, let the file replace the list and adding one word means retyping
-- two dozen, let the file extend the list and a default you disagree with
-- cannot be removed.
--
-- So the file is the whole list, and a missing or unreadable one is reported
-- rather than quietly papered over. A script that silently used a different
-- list than the one you edited would be worse than one that did nothing.
local DATA_COMMENT = "#"

-- Returns the list, or nil and something short enough to show the writer.
local function loadWords()
  local text, why = BYOK.data.read()
  if not text then
    -- Kept under 26 characters: an alert line longer than that is cut, and a
    -- truncated explanation is worse than a short one.
    if why == "missing"    then return nil, "No spelling.dat found" end
    if why == "toolarge"   then return nil, "spelling.dat is too big" end
    return nil, "Cannot read spelling.dat"
  end

  local words, skipped = {}, 0
  for line in text:gmatch("[^\r\n]+") do
    if not line:match("^%s*$") and not line:match("^%s*" .. DATA_COMMENT) then
      local wrong, right = line:match("^%s*(.-)%s*|%s*(.-)%s*$")
      if not wrong or wrong == "" or right == "" then
        -- Counted, not ignored: a mistyped line is the writer's edit failing to
        -- do anything, and that should not be invisible.
        skipped = skipped + 1
        BYOK.sys.log("spelling.dat: cannot read line: " .. line)
      elseif right:find(wrong, 1, true) then
        -- "hte|hte " would never run out of matches. Refused here rather than
        -- guarded against later, because there is no version of this pair that
        -- makes sense.
        skipped = skipped + 1
        BYOK.sys.log("spelling.dat: " .. right .. " still contains " .. wrong .. " -- skipped")
      else
        words[#words + 1] = { wrong, right }
      end
    end
  end

  if #words == 0 then
    return nil, skipped > 0 and "No usable lines found" or "spelling.dat has no words"
  end
  if skipped > 0 then
    BYOK.sys.log("spelling.dat: " .. skipped .. " line(s) skipped")
  end
  return words
end

--------------------------------------------------------------------------
-- Step one: count, and decide what is safe to offer
--------------------------------------------------------------------------

-- TWO THINGS THIS COUNT RESTS ON. Both are about the survey and the fix seeing
-- the same document; break either and the safety check below becomes a lie that
-- still passes.
--
-- 1. THE SAME BYTES. The count comes from doc.chunks(), which reads the file;
--    the fix runs against the live buffer. Those agree because the BYOK writes
--    every dirty page out before it starts a script that declares doc.read --
--    unconditionally, at both invocation points. It is not "the last time you
--    saved"; it is the moment the script began.
--
--    The one gap: a keystroke landing between that write and the end of the
--    survey would not be counted. That is a window of milliseconds, and it
--    would have to be a word CONTAINING one of these misspellings. Named here
--    rather than guarded, because the guard would cost a second pass over the
--    whole document.
--
-- 2. THE SAME KIND OF MATCH. chunk:find(term, i, true) is plain text and
--    case-sensitive, and so is the editor's own search. So "Teh" at the start
--    of a sentence is neither counted nor found -- it is left alone, which is
--    the safe direction. If the editor's search ever became case-insensitive,
--    the fix loop would hit matches this count never saw and the bound would be
--    wrong: it would lower-case the first word of someone's sentence. Change
--    both sides or neither.
--
-- find matches LETTERS, not words: searching for "ot" stops inside lot and
-- another. So every occurrence is counted twice over -- once as a plain match,
-- once as a match standing on its own -- and a word whose two counts disagree
-- is dropped. It cannot be corrected safely, because at the moment of the edit
-- there is no way to tell one kind of match from the other.
local function countIn(chunk, term)
  local plain, standalone = 0, 0
  local i = 1
  while true do
    -- Fourth argument true: treat the term as text, not as a pattern, so a
    -- word list containing a dot or a dash cannot quietly mean something else.
    local from, to = chunk:find(term, i, true)
    if not from then break end
    plain = plain + 1

    -- An empty string at the edge of a chunk counts as a boundary, which is
    -- right: chunks are cut at the ends of lines, never inside a word.
    local before = (from > 1) and chunk:sub(from - 1, from - 1) or ""
    local after  = chunk:sub(to + 1, to + 1)
    if not before:match("%w") and not after:match("%w") then
      standalone = standalone + 1
    end

    i = to + 1        -- past this match, the way find itself moves on
  end
  return plain, standalone
end

-- A word that appears BOTH on its own and buried inside something longer cannot
-- be corrected by searching for the word: find stops at both and there is no way
-- to tell which one you are on. Refusing the word outright is safe and close to
-- useless -- three perfectly good corrections thrown away to avoid one bad one,
-- and the writer told "nothing to fix" while looking straight at the mistakes.
--
-- So when that happens, search for the word WITH ITS SPACES instead. " teh "
-- matches only where it stands alone, and cannot match inside a longer word
-- because there is no space in the middle of one. The spaces go back with the
-- replacement, so the line is unchanged apart from the word.
--
-- What it misses: an occurrence at the start of a line, or against a comma or a
-- full stop. Those are left, and the count shown says so. Two of the same word
-- separated by a single space also only take one pass, because the space between
-- them belongs to whichever match is found first -- running it again gets the
-- rest. Under-correcting is the right way to be wrong here.
local function padded(word) return " " .. word .. " " end

local function survey(words)
  local tally = {}
  for _, pair in ipairs(words) do
    tally[pair[1]] = { plain = 0, standalone = 0, spaced = 0 }
  end

  -- Chunks do not overlap, so these sums are the document's real totals. A
  -- single line longer than a chunk is the one exception and can hide a match
  -- at the join; that undercounts, which costs a correction rather than
  -- breaking anything.
  for chunk in BYOK.doc.chunks() do
    for _, pair in ipairs(words) do
      local plain, standalone = countIn(chunk, pair[1])
      local spaced = countIn(chunk, padded(pair[1]))
      local t = tally[pair[1]]
      t.plain = t.plain + plain
      t.standalone = t.standalone + standalone
      t.spaced = t.spaced + spaced
    end
  end

  local todo, total, unsafe = {}, 0, 0
  for _, pair in ipairs(words) do
    local bad, good = pair[1], pair[2]
    local t = tally[bad]
    if t.plain > 0 then
      if t.standalone == t.plain then
        -- Every one of them stands alone, so the word itself is a safe thing
        -- to search for and all of them can be fixed.
        todo[#todo + 1] = { bad = bad, good = good, term = bad, repl = good,
                            count = t.plain, of = t.plain }
        total = total + t.plain
      elseif t.spaced > 0 then
        -- Some are buried. Go after the spaced ones only, and say how many of
        -- the total that is -- "3 of 4" is the honest answer, not "3".
        todo[#todo + 1] = { bad = bad, good = good,
                            term = padded(bad), repl = padded(good),
                            count = t.spaced, of = t.plain }
        total = total + t.spaced
        BYOK.sys.log(bad .. ": " .. t.spaced .. " of " .. t.plain ..
                     " stand alone -- fixing those only")
      else
        unsafe = unsafe + 1
        BYOK.sys.log(bad .. ": all " .. t.plain ..
                     " inside longer words -- skipped")
      end
    end
  end
  return todo, total, unsafe
end

--------------------------------------------------------------------------
-- Step two: ask about each word. Nothing here changes the document.
--------------------------------------------------------------------------

local s = BYOK.screen

local function frame()
  s.clear()
  s.rect(0, 0, s.width() - 1, s.height() - 1)
end

-- Rub out the middle and leave the border. Rebuilding the whole window every
-- time would make it blink once per word.
local function draw(item, nth, count)
  local lh = s.lineHeight()
  s.fill(2, 2, s.width() - 3, s.height() - 3, "clear")
  s.text(6, lh + 4, item.bad .. "  ->  " .. item.good)
  -- "3 of 4 times" when some occurrences are buried in longer words and cannot
  -- be reached. Saying plain "3" would hide that one is being left behind.
  local howMany
  if item.count < item.of then
    howMany = item.count .. " of " .. item.of .. " times"
  elseif item.count == 1 then
    howMany = "once"
  else
    howMany = item.count .. " times"
  end
  s.text(6, lh * 2 + 10, howMany)
  s.text(6, lh * 3 + 16, "word " .. nth .. " of " .. count)
  s.text(6, s.height() - 4, "Y fix   N leave   ESC stop")
end

-- true to fix, false to leave, nil if the writer pressed escape.
local function ask(item, nth, count)
  draw(item, nth, count)
  while s.isOpen() do
    local k = s.key(400)
    if not s.isOpen() then return nil end
    if s.damaged() then
      frame()
      draw(item, nth, count)
    end
    if k == "y" or k == "Y" then return true end
    if k == "n" or k == "N" then return false end
  end
  return nil
end

--------------------------------------------------------------------------
-- Step three: with the window gone, do the work
--------------------------------------------------------------------------

-- Returns how many the EDITOR took, which is not the same as how many were
-- asked for -- an edit made at the wrong moment is refused, and counting the
-- request rather than the answer is how a script comes to report work it did
-- not do.
local function fixAll(item)
  local done = 0
  for _ = 1, item.count do          -- backstop; the loop ends on its own
    -- term and repl, NOT bad and good: for an ambiguous word these carry the
    -- surrounding spaces, which is what keeps the search off the copies buried
    -- inside longer words. The spaces go back with the replacement.
    if not BYOK.doc.find(item.term) then break end
    if not BYOK.doc.insert(item.repl) then
      BYOK.sys.log("spelling: editor refused " .. item.bad .. " -- stopping")
      break
    end
    done = done + 1
  end
  return done
end

function main()
  local words, problem = loadWords()
  if not words then
    BYOK.ui.alert("Spelling", problem)
    return
  end

  local todo, total, unsafe = survey(words)
  BYOK.sys.log("spelling: " .. #todo .. " word(s), " .. total ..
               " occurrence(s), " .. unsafe .. " skipped as unsafe")

  if #todo == 0 then
    BYOK.ui.alert("Spelling", unsafe > 0 and "Nothing safe to fix" or "Nothing to fix")
    return
  end

  -- Ask ------------------------------------------------------------------
  if not s.open() then return end
  frame()

  local accepted, stopped = {}, false
  for i, item in ipairs(todo) do
    local answer = ask(item, i, #todo)
    if answer == nil then stopped = true break end
    if answer then accepted[#accepted + 1] = item end
  end

  -- Escape has already closed it; this is for finishing the list normally.
  -- Either way the next line runs with the page back in front.
  if s.isOpen() then s.close() end

  -- Fix ------------------------------------------------------------------
  --
  -- The denominator is what was SAID YES TO, not everything on offer. Counting
  -- against the whole survey turns "you declined two words" into "eight
  -- failures", which is the same error as counting requests instead of answers
  -- -- just moved to the other side of the fraction. Declining is a correct
  -- outcome and must not read as a fault.
  local wanted, fixed = 0, 0
  for _, item in ipairs(accepted) do
    wanted = wanted + item.count
    fixed = fixed + fixAll(item)
  end
  local left = total - wanted

  local report
  if wanted == 0 then
    report = "Nothing changed"
  else
    report = fixed .. " of " .. wanted .. " fixed"
  end
  -- One suffix only; the alert line is cut past about 26 characters.
  if stopped then
    report = report .. " (stopped)"
  elseif left > 0 then
    report = report .. ", " .. left .. " left"
  end

  BYOK.sys.log("spelling: " .. report .. " (" .. total .. " offered)")
  BYOK.ui.alert("Spelling", report)
end
