--- @name         Screenplay assistant
--- @description  Tab completes names, places, headings
--- @type         watcher
--- @requires     document
--- @caps         doc.edit, data.read, data.write, ui.list, ui.alert, ui.ask

-- Screenplay Assistant, fifth pass. Needs firmware that grants ui.ask to a
-- TAB watcher (the same generation as ui.list-from-Tab).
--
--   Tab means "complete this", anywhere. The word before the tab is checked
--   against the fixed aliases (I, INT, E, EXT, IE, VO, OS, OC, CONT) and then
--   against everything known -- characters, locations, times, transitions.
--   One match completes silently. Several matches put up a list of just
--   those; picking one completes it, backing out puts the word back. No
--   match puts the word back too. Every path replaces the range, as a Tab
--   watcher must, so the spaces Tab left never linger in the prose.
--
--   FORGET then Tab removes a learned entry (or a whole profile): pick the
--   kind, pick the entry. For the day a typo gets learned and starts
--   capturing completions.
--
--   SWITCH then Tab changes screenplay profile. Characters and locations
--   live per profile, so two screenplays keep separate casts; times and
--   transitions are shared, because NIGHT is NIGHT everywhere. Picking
--   "(new profile)" asks for the name in a dialog -- type it, press Enter.
--   The switch is deliberate and manual: scripts have no reliable way to
--   know which document they are hearing events from, and a wrong guess
--   would misfile a cast silently. A forgotten switch is visible in the
--   menus and cheap to fix; a wrong guess is neither.
--
--   LINE events are used only opportunistically. A scene heading is
--   uppercased and teaches its location and time. A line you typed in all
--   caps that is shaped like a character cue is remembered, so B plus Tab
--   brings BRIAN back from then on. Typing the caps yourself, once, is the
--   deliberate signal; a missed LINE event costs a lesson, never correctness.
--
-- State kept between events: an ordering hint (in_heading) that only sorts
-- menus. Going stale costs a suboptimal order and nothing else.

local data = {
  times       = {},
  transitions = {},
}

-- name -> { characters = {}, locations = {} }. MAIN always exists and is
-- where a data file from before profiles lands.
local profiles    = {}
local active_name = "MAIN"

local defaults = {
  times       = { "DAY", "NIGHT", "MORNING", "EVENING", "LATER", "CONTINUOUS" },
  transitions = { "CUT TO:", "DISSOLVE TO:", "MATCH CUT TO:", "FADE IN:", "FADE OUT." },
}

-- Exact-match expansions, checked before the completion pools. The slash form
-- INT./EXT. has no single-letter alias because the firmware breaks words at
-- "/", so a typed "I/E" reaches the script as "E" -- use IE instead.
local aliases = {
  I    = "INT. ",
  INT  = "INT. ",
  E    = "EXT. ",
  EXT  = "EXT. ",
  IE   = "INT./EXT. ",
  VO   = "(V.O.)",
  OS   = "(O.S.)",
  OC   = "(O.C.)",
  CONT = "(CONT'D)",
}

-- The data file tops out at 8 KB. These caps keep several full casts under
-- it; past them the completion experience has degraded anyway.
local MAX_ENTRIES  = 64
local MAX_PROFILES = 8

-- ui.list refuses more than 32 choices outright, and an errored watcher
-- switches itself off. Past this many matches the answer is a longer prefix,
-- not a longer menu.
local MAX_CHOICES = 32

-- False once the data file has proven unsafe to touch: unreadable, too large,
-- or a write has failed. Learning continues for the session; saving stops, so
-- whatever is on the card survives.
local can_save = true

local in_heading = false

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

local function upper(text)
  return string.upper(text)
end

local function profile(name)
  local p = profiles[name]
  if not p then
    p = { characters = {}, locations = {} }
    profiles[name] = p
  end
  return p
end

local function cur()
  return profile(active_name)
end

local function sorted_profile_names()
  local names = {}
  for name in pairs(profiles) do names[#names + 1] = name end
  table.sort(names)
  return names
end

local function contains(list, value)
  local wanted = upper(trim(value))
  for _, item in ipairs(list) do
    if item == wanted then return true end
  end
  return false
end

local function add_unique(list, value)
  value = upper(trim(value))
  if value == "" or #list >= MAX_ENTRIES or contains(list, value) then
    return false
  end
  list[#list + 1] = value
  table.sort(list)
  return true
end

local function remove_from(list, value)
  for i, item in ipairs(list) do
    if item == value then
      table.remove(list, i)
      return true
    end
  end
  return false
end

local function fill_defaults_if_empty(list, fallback)
  if #list ~= 0 then return end
  for _, item in ipairs(fallback or {}) do add_unique(list, item) end
end

-- Data file shape: KIND|VALUE lines, with [SECTION] headers opening a
-- profile. CHARACTER and LOCATION lines belong to the open profile -- or to
-- MAIN before any header, which is exactly where a pre-profile file lands.
-- TIME and TRANSITION lines are global wherever they appear. ACTIVE|NAME
-- records the profile in use.
local function load_data()
  profile("MAIN")

  local text, why = BYOK.data.read()
  if text then
    local section = profile("MAIN")
    for raw in text:gmatch("[^\r\n]+") do
      local entry = trim(raw)
      if entry ~= "" and not entry:match("^#") then
        local header = entry:match("^%[(.+)%]$")
        if header then
          section = profile(upper(trim(header)))
        else
          local kind, value = entry:match("^([^|]+)|(.*)$")
          if kind and value then
            kind = upper(trim(kind))
            if kind == "CHARACTER" then add_unique(section.characters, value) end
            if kind == "LOCATION" then add_unique(section.locations, value) end
            if kind == "TIME" then add_unique(data.times, value) end
            if kind == "TRANSITION" then add_unique(data.transitions, value) end
            if kind == "ACTIVE" then active_name = upper(trim(value)) end
          end
        end
      end
    end
  elseif why ~= "missing" then
    -- The file exists but could not be read. Writing now would replace
    -- something we never saw -- work from defaults and never save.
    can_save = false
  end

  profile(active_name)   -- ACTIVE may name a profile with no entries yet

  fill_defaults_if_empty(data.times, defaults.times)
  fill_defaults_if_empty(data.transitions, defaults.transitions)
end

local function append_lines(lines, kind, list)
  for _, value in ipairs(list) do
    lines[#lines + 1] = kind .. "|" .. value
  end
end

local function save_data()
  if not can_save then return end
  local lines = {
    "# Screenplay Assistant data.",
    "# KIND|VALUE. [SECTIONS] are per-screenplay profiles.",
    "# On the device: SWITCH then Tab changes profile, FORGET then Tab",
    "# removes an entry. Edit by hand while the watcher is off, then",
    "# restart the device.",
    "ACTIVE|" .. active_name,
    "",
  }
  append_lines(lines, "TIME", data.times)
  append_lines(lines, "TRANSITION", data.transitions)
  for _, name in ipairs(sorted_profile_names()) do
    lines[#lines + 1] = ""
    lines[#lines + 1] = "[" .. name .. "]"
    append_lines(lines, "CHARACTER", profiles[name].characters)
    append_lines(lines, "LOCATION", profiles[name].locations)
  end
  if not BYOK.data.write(table.concat(lines, "\n") .. "\n") then
    can_save = false   -- refused once, will be refused again; stop asking
  end
end

-- All entries the typed prefix could mean, deduplicated across pools, plus
-- whether one of them is an exact match (which wins outright: DAY completes
-- to DAY even with DAYCARE CENTER in the list). Characters lead the order --
-- the most retyped thing in a screenplay -- except while a heading is being
-- built, when locations and times lead instead. The in_heading hint only
-- ORDERS the menu, never filters it, so going stale costs a suboptimal
-- ordering and nothing else.
local function matches_for(typed)
  local p = cur()
  local order
  if in_heading then
    order = { p.locations, data.times, p.characters, data.transitions }
  else
    order = { p.characters, p.locations, data.times, data.transitions }
  end

  local found, seen, exact = {}, {}, nil
  for _, pool in ipairs(order) do
    for _, item in ipairs(pool) do
      if item:sub(1, #typed) == typed and not seen[item] then
        seen[item] = true
        found[#found + 1] = item
        if item == typed then exact = item end
      end
    end
  end
  return found, exact
end

local function replace_if_changed(from, to, before, after)
  if before ~= after then BYOK.doc.replaceRange(from, to, after) end
end

local function is_scene_heading(text)
  local value = upper(trim(text))
  return value:match("^INT%./EXT%.")
      or value:match("^EXT%./INT%.")
      or value:match("^INT%.")
      or value:match("^EXT%.")
end

-- A transition is a known one in any case, or an all-caps line ending in
-- "TO:". The caps requirement keeps ordinary dialogue such as
-- "I'm going to:" from being rewritten.
local function is_transition(text)
  local value = trim(text)
  if contains(data.transitions, value) then return true end
  if value ~= upper(value) then return false end
  return value:match("%sTO:$") ~= nil
end

-- The shape of a character cue, on a line the writer already typed in caps:
-- short, at most four words, no sentence-ending punctuation, and nothing but
-- letters, digits, spaces, apostrophes, hyphens and dots ("MR. SMITH" keeps
-- its dot; a sentence is caught by its length, its word count or its
-- trailing period). A parenthesised extension is stripped before testing, so
-- "SARAH (V.O.)" learns SARAH.
local function character_cue(text)
  local value = trim(text)
  if value ~= upper(value) then return nil end
  if not value:match("%a") then return nil end

  local name = trim(value:gsub("%s*%b()$", ""))
  if #name < 2 or #name > 24 then return nil end
  if name:match("[%.!%?,;:]$") then return nil end
  if name:match("[^%w%s'%-%.]") then return nil end

  local words = 0
  for _ in name:gmatch("%S+") do words = words + 1 end
  if words == 0 or words > 4 then return nil end

  return name
end

local function scene_parts(text)
  local rest = upper(trim(text))
  rest = rest:gsub("^INT%./EXT%.%s*", "")
  rest = rest:gsub("^EXT%./INT%.%s*", "")
  rest = rest:gsub("^INT%.%s*", "")
  rest = rest:gsub("^EXT%.%s*", "")

  -- Greedy first capture means the last spaced hyphen is the separator, so a
  -- hyphen inside a location name stays part of the location.
  local location, time = rest:match("^(.*)%s+%-%s+(.+)$")
  if not location then return trim(rest), nil end
  return trim(location), trim(time)
end

local function learn_scene_heading(text)
  local location, time = scene_parts(text)
  local changed = false
  if location and location ~= "" and location ~= "LOCATION" and #location <= 40 then
    changed = add_unique(cur().locations, location) or changed
  end
  if time and time ~= "" and #time <= 20 then
    changed = add_unique(data.times, time) or changed
  end
  if changed then save_data() end
end

local function handle_line(line, from, to)
  -- Any finished line ends the heading being built. (Stale-flag cost: a
  -- menu ordered for headings. Nothing worse.)
  in_heading = false

  local value = trim(line)
  if value == "" then return end

  if is_scene_heading(value) then
    local formatted = upper(value)
    replace_if_changed(from, to, line, formatted)
    learn_scene_heading(formatted)
    return
  end

  if is_transition(value) then
    replace_if_changed(from, to, line, upper(value))
    return
  end

  local name = character_cue(value)
  if name and add_unique(cur().characters, name) then save_data() end
end

-- FORGET + Tab: remove a learned entry (or a whole profile) from the device,
-- for the day a typo like BE gets learned in place of BEN. That matters more
-- than clutter -- exact match beats prefix match, so a learned BE captures
-- BE+Tab and BEN becomes unreachable behind it. Back out anywhere and
-- nothing changes, the word comes back.
--
-- FORGET and SWITCH are checked before completion, so characters named
-- FORGET or SWITCH cannot be completed. Nobody names a character that.
local function forget_flow(word, from, to)
  local kinds = {
    { label = "Characters", list = cur().characters },
    { label = "Locations",  list = cur().locations },
    { label = "Times",      list = data.times },
    { label = "Profiles" },
  }

  local labels = {}
  for i, kind in ipairs(kinds) do labels[i] = kind.label end

  local kind_choice = BYOK.ui.list(labels)
  if not kind_choice then
    BYOK.doc.replaceRange(from, to, word)
    return
  end

  local shown
  if kinds[kind_choice].list then
    -- Alphabetical already; past 32 the list API refuses, so show the first
    -- 32 and leave the rest to editing the data file on a computer.
    local list = kinds[kind_choice].list
    shown = {}
    for i = 1, math.min(#list, MAX_CHOICES) do shown[i] = list[i] end
  else
    -- Profiles: every profile except the active one, which cannot be
    -- deleted out from under itself.
    shown = {}
    for _, name in ipairs(sorted_profile_names()) do
      if name ~= active_name then shown[#shown + 1] = name end
    end
  end

  local entry_choice = #shown > 0 and BYOK.ui.list(shown) or nil
  if not entry_choice then
    BYOK.doc.replaceRange(from, to, word)
    return
  end

  if kinds[kind_choice].list then
    if remove_from(kinds[kind_choice].list, shown[entry_choice]) then save_data() end
  else
    profiles[shown[entry_choice]] = nil
    save_data()
  end
  BYOK.doc.replaceRange(from, to, "")
end

-- The name from the new-profile dialog: letters, digits and spaces, short
-- enough to read in a menu, not a reserved keyword. An existing name just
-- switches to it -- typing MAIN should not invent a second MAIN. Cancel
-- and a refused name both put SWITCH back, so the next try starts from the
-- same place.
local function create_profile(name)
  name = upper(trim(name or ""))
  name = name:gsub("%s+", " ")
  if name == "" or #name > 24 or not name:match("^[%w ]+$")
      or name == "FORGET" or name == "SWITCH" then
    BYOK.ui.alert("Not created", "Letters and spaces only")
    return nil
  end
  profile(name)
  return name
end

-- SWITCH + Tab: pick the screenplay profile to write in. "(new profile)"
-- asks for the name in a dialog, then creates it and switches to it.
local function switch_flow(word, from, to)
  local names = sorted_profile_names()

  local labels = { "(new profile)" }
  for _, name in ipairs(names) do
    labels[#labels + 1] = (name == active_name and "* " or "") .. name
  end

  local choice = BYOK.ui.list(labels)
  if not choice then
    BYOK.doc.replaceRange(from, to, word)
    return
  end

  if choice == 1 then
    if #names >= MAX_PROFILES then
      BYOK.ui.alert("No room", "At most 8 profiles")
      BYOK.doc.replaceRange(from, to, word)
      return
    end
    local typed = BYOK.ui.ask("Profile name")
    if not typed then
      BYOK.doc.replaceRange(from, to, word)
      return
    end
    local name = create_profile(typed)
    if not name then
      BYOK.doc.replaceRange(from, to, word)
      return
    end
    active_name = name
    save_data()
    BYOK.doc.replaceRange(from, to, "")
    return
  end

  active_name = names[choice - 1]
  save_data()
  BYOK.doc.replaceRange(from, to, "")
end

-- Every path out of here replaces the range, as a Tab watcher must -- the
-- range includes the spaces Tab just left, and leaving them behind plants
-- invisible spaces in the prose.
local function handle_tab(word, from, to)
  local typed = upper(trim(word))

  -- Tab with no word before it: nothing to complete, take the spaces back.
  if typed == "" then
    BYOK.doc.replaceRange(from, to, "")
    return
  end

  if typed == "FORGET" then
    forget_flow(word, from, to)
    return
  end

  if typed == "SWITCH" then
    switch_flow(word, from, to)
    return
  end

  local alias = aliases[typed]
  if alias then
    BYOK.doc.replaceRange(from, to, alias)
    if alias:match("^INT%.") or alias:match("^EXT%.") then
      in_heading = true
    end
    return
  end

  local found, exact = matches_for(typed)

  if exact then
    BYOK.doc.replaceRange(from, to, exact)
    return
  end

  if #found == 1 then
    BYOK.doc.replaceRange(from, to, found[1])
    return
  end

  if #found >= 2 and #found <= MAX_CHOICES then
    -- Several things this could mean: Tab was a deliberate ask, so asking
    -- back is a fair answer. Backing out restores the word exactly.
    local choice = BYOK.ui.list(found)
    BYOK.doc.replaceRange(from, to, choice and found[choice] or word)
    return
  end

  -- Nothing known (or far too many): give the word back, spaces gone.
  BYOK.doc.replaceRange(from, to, word)
end

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

function listen(event, text, from, to)
  if event == "LINE" then handle_line(text, from, to) end
  if event == "TAB" then handle_tab(text, from, to) end
end
