local ADDON_NAME = ...
local AN = _G.AmmeNotepad or {}
_G.AmmeNotepad = AN

local function SafeNumber(v, fallback)
  local n = tonumber(v)
  if not n then
    return fallback or 0
  end
  return n
end

local function Clamp(v, lo, hi)
  if v < lo then return lo end
  if v > hi then return hi end
  return v
end

local function SafeMeta(field, fallback)
  if C_AddOns and C_AddOns.GetAddOnMetadata then
    return C_AddOns.GetAddOnMetadata(ADDON_NAME, field) or fallback
  end
  return fallback
end

AN.META = {
  Name = ADDON_NAME,
  Title = SafeMeta("Title", ADDON_NAME),
  Version = SafeMeta("Version", "?.?.?"),
  Author = SafeMeta("Author", "Unknown"),
}

local SHARE_LINK_TYPE = "ammenotepad"
local SHARE_PAYLOAD_VERSION = 1
local SHARE_CHAT_TOKEN_PREFIX = "ANP1" -- legacy inline payload token
local SHARE_CHAT_TOKEN_REMOTE_PREFIX = "ANP2" -- metadata-only token, payload over addon comm
local SHARE_COMM_PREFIX = "ANPComm1"
local MAX_SHARED_NOTES = 10
local MAX_TEXT_SHARE_LINES = 10
local SHARE_CACHE_TTL = 1800
local SHARE_CACHE_MAX = 50
local SHARE_REQUEST_TIMEOUT = 20
local SHARE_COMM_CHUNK_SIZE = 1800

local AceSerializer = LibStub and LibStub("AceSerializer-3.0", true)
local LibDeflate = LibStub and LibStub("LibDeflate", true)
local AceCommLib = LibStub and LibStub("AceComm-3.0", true)

local function GetCharacterLabel()
  local name = UnitName("player") or "Unknown"
  local realm = GetNormalizedRealmName() or GetRealmName() or "UnknownRealm"
  return string.format("%s-%s", name, realm)
end

local function TrimTitleText(s)
  s = tostring(s or "")
  s = s:gsub("^%s+", "")
  s = s:gsub("%s+$", "")
  return s
end

local function GetTitleBase(title)
  title = TrimTitleText(title)
  local root = title:match("^(.*)%((%d+)%)$")
  if root then
    root = TrimTitleText(root)
    if root ~= "" then
      return root
    end
  end
  return title
end

local function NextAvailableTitle(base, used)
  base = TrimTitleText(base)
  if base == "" then
    base = "untitled"
  end

  local baseKey = base:lower()
  if not used[baseKey] then
    return base
  end

  local idx = 1
  while true do
    local candidate = string.format("%s(%d)", base, idx)
    if not used[candidate:lower()] then
      return candidate
    end
    idx = idx + 1
  end
end

local function BuildUsedTitleSet(db, excludeID)
  local used = {}
  for _, id in ipairs(db.noteOrder or {}) do
    if id ~= excludeID then
      local note = db.notes and db.notes[id]
      if note and note.title then
        used[tostring(note.title):lower()] = true
      end
    end
  end
  return used
end

local function SplitLines(text)
  text = tostring(text or ""):gsub("\r\n", "\n"):gsub("\r", "\n")
  if text == "" then
    return {}
  end

  local lines = {}
  local start = 1
  while true do
    local nl = text:find("\n", start, true)
    if not nl then
      lines[#lines + 1] = text:sub(start)
      break
    end
    lines[#lines + 1] = text:sub(start, nl - 1)
    start = nl + 1
  end
  return lines
end

local function EncodeSharePayload(payload)
  if not (AceSerializer and LibDeflate) then
    return nil, "Required sharing libraries are unavailable."
  end

  local serialized = AceSerializer:Serialize(payload)
  local compressed = LibDeflate:CompressDeflate(serialized, { level = 6 })
  if not compressed then
    return nil, "Compression failed."
  end

  local encoded = LibDeflate:EncodeForPrint(compressed)
  if not encoded or encoded == "" then
    return nil, "Encoding failed."
  end

  return encoded
end

local function DecodeSharePayload(encoded)
  if not (AceSerializer and LibDeflate) then
    return nil, "Required sharing libraries are unavailable."
  end

  local decoded = LibDeflate:DecodeForPrint(tostring(encoded or ""))
  if not decoded then
    return nil, "Could not decode link payload."
  end

  local decompressed = LibDeflate:DecompressDeflate(decoded)
  if not decompressed then
    return nil, "Could not decompress link payload."
  end

  local ok, payload = AceSerializer:Deserialize(decompressed)
  if not ok or type(payload) ~= "table" then
    return nil, "Could not deserialize link payload."
  end

  return payload
end

local function EncodeSharePayloadForComm(payload)
  if not (AceSerializer and LibDeflate) then
    return nil, "Required sharing libraries are unavailable."
  end

  local serialized = AceSerializer:Serialize(payload)
  local compressed = LibDeflate:CompressDeflate(serialized, { level = 6 })
  if not compressed then
    return nil, "Compression failed."
  end

  local encoded = LibDeflate:EncodeForWoWAddonChannel(compressed)
  if not encoded or encoded == "" then
    return nil, "Addon channel encoding failed."
  end

  return encoded
end

local function DecodeSharePayloadFromComm(encoded)
  if not (AceSerializer and LibDeflate) then
    return nil, "Required sharing libraries are unavailable."
  end

  local decoded = LibDeflate:DecodeForWoWAddonChannel(tostring(encoded or ""))
  if not decoded then
    return nil, "Could not decode addon payload."
  end

  local decompressed = LibDeflate:DecompressDeflate(decoded)
  if not decompressed then
    return nil, "Could not decompress addon payload."
  end

  local ok, payload = AceSerializer:Deserialize(decompressed)
  if not ok or type(payload) ~= "table" then
    return nil, "Could not deserialize addon payload."
  end

  return payload
end

local function NormalizeFullName(name)
  name = tostring(name or "")
  if name == "" then
    return ""
  end
  if name:find("-", 1, true) then
    return name
  end
  local realm = GetNormalizedRealmName() or GetRealmName() or ""
  if realm == "" then
    return name
  end
  return name .. "-" .. realm
end

local function CurrentDayKey()
  return date("%Y-%m-%d")
end

local MAX_FOLDER_NAME_LEN = 36
local NOTE_FONT_MIN = 8
local NOTE_FONT_MAX = 24
local NOTE_FONT_DEFAULT_NAME = "Friz Quadrata TT"
local NOTE_FONT_DEFAULT_SIZE = 12
local NOTE_FONT_DEFAULT_PATH = "Fonts\\FRIZQT__.TTF"
local NOTE_FONT_DEFAULT_COLOR = { r = 0.95, g = 0.95, b = 0.95 }
local NOTE_FONT_SIZE_CHOICES = { 8, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24 }

local FALLBACK_FONTS = {
  { name = "Friz Quadrata TT", path = "Fonts\\FRIZQT__.TTF" },
  { name = "Arial Narrow", path = "Fonts\\ARIALN.TTF" },
  { name = "Morpheus", path = "Fonts\\MORPHEUS.TTF" },
  { name = "Skurri", path = "Fonts\\SKURRI.TTF" },
}

local function GetLSM()
  if LibStub then
    return LibStub("LibSharedMedia-3.0", true)
  end
  return nil
end

local function NormalizeColor(input)
  local src = type(input) == "table" and input or {}
  local r = Clamp(SafeNumber(src.r or src[1], NOTE_FONT_DEFAULT_COLOR.r), 0, 1)
  local g = Clamp(SafeNumber(src.g or src[2], NOTE_FONT_DEFAULT_COLOR.g), 0, 1)
  local b = Clamp(SafeNumber(src.b or src[3], NOTE_FONT_DEFAULT_COLOR.b), 0, 1)
  return { r = r, g = g, b = b }
end

local function NormalizeNoteAppearance(note)
  if type(note) ~= "table" then return end
  local fontName = TrimTitleText(note.fontName)
  if fontName == "" then
    fontName = NOTE_FONT_DEFAULT_NAME
  end
  note.fontName = fontName
  note.fontSize = Clamp(SafeNumber(note.fontSize, NOTE_FONT_DEFAULT_SIZE), NOTE_FONT_MIN, NOTE_FONT_MAX)
  note.fontColor = NormalizeColor(note.fontColor)
end

local function SanitizeFolderName(name)
  name = tostring(name or "")
  -- Strip control characters and WoW escape marker pipes.
  name = name:gsub("[%c]", " ")
  name = name:gsub("|", "")
  -- Normalize whitespace to keep labels readable/stable.
  name = name:gsub("%s+", " ")
  name = TrimTitleText(name)
  if #name > MAX_FOLDER_NAME_LEN then
    name = TrimTitleText(name:sub(1, MAX_FOLDER_NAME_LEN))
  end
  return name
end

local function NormalizeFolderName(name)
  name = SanitizeFolderName(name)
  if name == "" then
    name = "default"
  end
  return name
end

function AN:SanitizeFolderNameInput(name)
  return SanitizeFolderName(name)
end

function AN:GetMaxFolderNameLength()
  return MAX_FOLDER_NAME_LEN
end

local function NormalizeDuplicateTitles(db)
  local used = {}
  for _, id in ipairs(db.noteOrder or {}) do
    local note = db.notes and db.notes[id]
    if note then
      local original = TrimTitleText(note.title)
      if original == "" then
        original = "untitled"
      end

      local key = original:lower()
      if used[key] then
        local base = GetTitleBase(original)
        local unique = NextAvailableTitle(base, used)
        note.title = unique
        used[unique:lower()] = true
      else
        note.title = original
        used[key] = true
      end
    end
  end
end

local function EnsureDefaults()
  AmmeNotepadDB = AmmeNotepadDB or {}
  local db = AmmeNotepadDB

  db.minimap = db.minimap or { hide = false }
  db.notes = db.notes or {}
  db.noteOrder = db.noteOrder or {}
  db.folders = db.folders or {}
  db.settings = db.settings or {}

  local s = db.settings
  s.maxNotes = Clamp(SafeNumber(s.maxNotes, 100), 20, 200)
  s.autosaveEnabled = (s.autosaveEnabled ~= false)
  s.autosaveIntervalMins = Clamp(SafeNumber(s.autosaveIntervalMins, 5), 5, 30)
  s.characterSpecific = (s.characterSpecific == true)
  s.searchMode = (s.searchMode == "content") and "content" or "name"
  s.uiScale = Clamp(SafeNumber(s.uiScale, 0.7), 0.5, 1.8)
  s.nextUntitled = math.max(2, SafeNumber(s.nextUntitled, 2))
  s.folderPanelExpanded = (s.folderPanelExpanded == true)
  s.window = s.window or {
    point = "CENTER",
    x = 0,
    y = 0,
    width = 520,
    height = 760,
    editorHeight = 500,
  }
  s.pinned = s.pinned or {
    noteID = nil,
    point = "CENTER",
    x = 0,
    y = 0,
    width = 360,
    height = 260,
    scale = 1.0,
    locked = true,
    showTitle = true,
    hideScrollbar = true,
    parchmentAlpha = 1.0,
    borderAlpha = 0.4,
  }

  local now = time()
  local char = GetCharacterLabel()

  local folders = {}
  local seenFolders = {}
  for _, rawName in ipairs(db.folders) do
    local name = NormalizeFolderName(rawName)
    local key = name:lower()
    if not seenFolders[key] then
      seenFolders[key] = true
      folders[#folders + 1] = name
    end
  end
  if #folders == 0 then
    folders[1] = "default"
  end
  db.folders = folders

  local activeFolder = NormalizeFolderName(s.activeFolder or folders[1] or "default")
  local activeFound = false
  for _, name in ipairs(folders) do
    if name:lower() == activeFolder:lower() then
      activeFolder = name
      activeFound = true
      break
    end
  end
  if not activeFound then
    activeFolder = folders[1]
  end
  s.activeFolder = activeFolder

  for _, id in ipairs(db.noteOrder) do
    local note = db.notes[id]
    if note then
      note.id = id
      note.title = tostring(note.title or "untitled")
      note.body = tostring(note.body or "")
      note.createdAt = SafeNumber(note.createdAt, now)
      note.updatedAt = SafeNumber(note.updatedAt, now)
      note.createdBy = tostring(note.createdBy or char)
      note.updatedBy = tostring(note.updatedBy or char)
      note.isUntitled = (note.isUntitled ~= false)
      note.isShared = (note.isShared == true)
      note.userSaved = (note.userSaved ~= false)
      note.dirty = (note.dirty == true)
      if note.isShared then
        note.sharedFrom = tostring(note.sharedFrom or note.createdBy or "Unknown")
        note.sharedImportedAt = SafeNumber(note.sharedImportedAt, now)
        note.userSaved = true
        note.dirty = false
      else
        note.sharedFrom = nil
      end

      local folderName = NormalizeFolderName(note.folder)
      local folderExists = false
      for _, candidate in ipairs(folders) do
        if candidate:lower() == folderName:lower() then
          note.folder = candidate
          folderExists = true
          break
        end
      end
      if not folderExists then
        note.folder = folders[1]
      end

      NormalizeNoteAppearance(note)
    end
  end

  do
    local p = s.pinned or {}
    local noteID = tostring(p.noteID or "")
    if noteID == "" or not (db.notes and db.notes[noteID]) then
      p.noteID = nil
    else
      p.noteID = noteID
    end
    p.point = tostring(p.point or "CENTER")
    p.x = SafeNumber(p.x, 0)
    p.y = SafeNumber(p.y, 0)
    p.width = Clamp(SafeNumber(p.width, 360), 180, 1100)
    p.height = Clamp(SafeNumber(p.height, 260), 100, 1200)
    p.scale = Clamp(SafeNumber(p.scale, 1.0), 0.5, 2.0)
    p.locked = (p.locked ~= false)
    p.showTitle = (p.showTitle ~= false)
    p.hideScrollbar = (p.hideScrollbar ~= false)
    p.parchmentAlpha = Clamp(SafeNumber(p.parchmentAlpha, 1.0), 0, 1)
    p.borderAlpha = Clamp(SafeNumber(p.borderAlpha, 0.4), 0, 1)
    s.pinned = p
  end

  NormalizeDuplicateTitles(db)

  if #db.noteOrder == 0 then
    local id = string.format("note-%d", now)
    db.notes[id] = {
      id = id,
      title = "untitled",
      body = "",
      createdAt = now,
      updatedAt = now,
      createdBy = char,
      updatedBy = char,
      isUntitled = true,
      isShared = false,
      userSaved = true,
      dirty = false,
      folder = folders[1],
      fontName = NOTE_FONT_DEFAULT_NAME,
      fontSize = NOTE_FONT_DEFAULT_SIZE,
      fontColor = NormalizeColor(nil),
    }
    table.insert(db.noteOrder, id)
  end

  AN.DB = db
  AN.SessionPendingDiskSave = false
  AN.SessionSuppressDiskSaveWarning = false
  AN.SessionSuppressPromptDay = CurrentDayKey()
end

function AN:GetCharacterLabel()
  return GetCharacterLabel()
end

function AN:RefreshSessionWarningState()
  local today = CurrentDayKey()
  if self.SessionSuppressPromptDay ~= today then
    self.SessionSuppressPromptDay = today
    self.SessionSuppressDiskSaveWarning = false
  end
end

function AN:GetFolders()
  if not self.DB then
    return { "default" }
  end
  self.DB.folders = self.DB.folders or { "default" }
  if #self.DB.folders == 0 then
    self.DB.folders[1] = "default"
  end
  return self.DB.folders
end

function AN:IsFolderNameInUse(name, excludeName)
  local wanted = NormalizeFolderName(name):lower()
  local exclude = excludeName and NormalizeFolderName(excludeName):lower() or nil
  for _, folderName in ipairs(self:GetFolders()) do
    local key = tostring(folderName):lower()
    if key == wanted and key ~= exclude then
      return true
    end
  end
  return false
end

function AN:GetActiveFolder()
  if not self.DB then
    return "default"
  end
  self.DB.settings = self.DB.settings or {}
  local folders = self:GetFolders()
  local active = NormalizeFolderName(self.DB.settings.activeFolder or folders[1])
  for _, folderName in ipairs(folders) do
    if folderName:lower() == active:lower() then
      self.DB.settings.activeFolder = folderName
      return folderName
    end
  end
  self.DB.settings.activeFolder = folders[1]
  return folders[1]
end

function AN:SetActiveFolder(name)
  if not self.DB then return false end
  local wanted = NormalizeFolderName(name)
  for _, folderName in ipairs(self:GetFolders()) do
    if folderName:lower() == wanted:lower() then
      self.DB.settings.activeFolder = folderName
      return true
    end
  end
  return false
end

function AN:CreateFolder(name)
  if not self.DB then return nil, "Database unavailable." end
  if self:IsAtMaxNotes() then
    return nil, "Maximum notes reached. A new folder needs a starter note."
  end

  local folderName = NormalizeFolderName(name)
  if self:IsFolderNameInUse(folderName) then
    return nil, "That folder name is already in use."
  end

  local folders = self:GetFolders()
  folders[#folders + 1] = folderName
  self:SetActiveFolder(folderName)
  self.SessionPendingDiskSave = true
  return folderName
end

function AN:RenameFolder(oldName, newName)
  if not self.DB then return false, "Database unavailable." end
  local oldNorm = NormalizeFolderName(oldName)
  local newNorm = NormalizeFolderName(newName)
  if newNorm == "" then
    return false, "Folder name cannot be empty."
  end
  if oldNorm:lower() ~= newNorm:lower() and self:IsFolderNameInUse(newNorm) then
    return false, "That folder name is already in use."
  end

  local folders = self:GetFolders()
  local idx
  for i, folderName in ipairs(folders) do
    if tostring(folderName):lower() == oldNorm:lower() then
      idx = i
      break
    end
  end
  if not idx then
    return false, "Folder not found."
  end

  folders[idx] = newNorm
  for _, noteID in ipairs(self.DB.noteOrder or {}) do
    local note = self.DB.notes and self.DB.notes[noteID]
    if note and tostring(note.folder or ""):lower() == oldNorm:lower() then
      note.folder = newNorm
    end
  end

  if tostring(self.DB.settings.activeFolder or ""):lower() == oldNorm:lower() then
    self.DB.settings.activeFolder = newNorm
  end

  self.SessionPendingDiskSave = true
  return true
end

function AN:GetFolderDeleteInfo(name)
  if not self.DB then return nil, "Database unavailable." end
  local folderName = NormalizeFolderName(name)
  local folders = self:GetFolders()
  if #folders <= 1 then
    return nil, "You cannot delete the last remaining folder."
  end

  local idx
  for i, fname in ipairs(folders) do
    if tostring(fname):lower() == folderName:lower() then
      idx = i
      folderName = fname
      break
    end
  end
  if not idx then
    return nil, "Folder not found."
  end

  local destination = folders[(idx == 1) and 2 or 1]
  local totalCount = 0
  local nonEmptyCount = 0
  local emptyCount = 0
  local ids = {}
  local nonEmptyIDs = {}
  local emptyIDs = {}
  for _, noteID in ipairs(self.DB.noteOrder or {}) do
    local note = self.DB.notes and self.DB.notes[noteID]
    if note and tostring(note.folder or ""):lower() == folderName:lower() then
      totalCount = totalCount + 1
      ids[#ids + 1] = noteID
      if not tostring(note.body or ""):match("^%s*$") then
        nonEmptyCount = nonEmptyCount + 1
        nonEmptyIDs[#nonEmptyIDs + 1] = noteID
      else
        emptyCount = emptyCount + 1
        emptyIDs[#emptyIDs + 1] = noteID
      end
    end
  end

  return {
    folder = folderName,
    destination = destination,
    totalCount = totalCount,
    nonEmptyCount = nonEmptyCount,
    emptyCount = emptyCount,
    noteIDs = ids,
    nonEmptyNoteIDs = nonEmptyIDs,
    emptyNoteIDs = emptyIDs,
  }
end

function AN:DeleteFolder(name, opts)
  if not self.DB then return false, "Database unavailable." end
  local info, err = self:GetFolderDeleteInfo(name)
  if not info then
    return false, err
  end
  opts = opts or {}
  local moveNonEmpty = (opts.moveNonEmpty ~= false)

  local function removeNoteByID(noteID)
    self.DB.notes[noteID] = nil
    for i, existingID in ipairs(self.DB.noteOrder or {}) do
      if existingID == noteID then
        table.remove(self.DB.noteOrder, i)
        break
      end
    end
  end

  local folders = self:GetFolders()
  for i = #folders, 1, -1 do
    if tostring(folders[i]):lower() == info.folder:lower() then
      table.remove(folders, i)
      break
    end
  end

  if moveNonEmpty and info.nonEmptyCount > 0 then
    for _, noteID in ipairs(info.nonEmptyNoteIDs or {}) do
      local note = self.DB.notes and self.DB.notes[noteID]
      if note then
        note.folder = info.destination
      end
    end
  else
    for _, noteID in ipairs(info.nonEmptyNoteIDs or {}) do
      removeNoteByID(noteID)
    end
  end
  for _, noteID in ipairs(info.emptyNoteIDs or {}) do
    removeNoteByID(noteID)
  end

  if #self.DB.noteOrder == 0 then
    self:CreateNote("untitled", info.destination)
  end

  if tostring(self.DB.settings.activeFolder or ""):lower() == info.folder:lower() then
    self.DB.settings.activeFolder = info.destination
  end

  self.SessionPendingDiskSave = true
  return true, info.destination, info
end

function AN:IsSharedNote(note)
  return type(note) == "table" and note.isShared == true
end

function AN:IsReadOnlyNote(note)
  return self:IsSharedNote(note)
end

function AN:GetRegularNoteCount()
  local db = self.DB
  if not db then return 0 end

  local count = 0
  for _, id in ipairs(db.noteOrder or {}) do
    local note = db.notes and db.notes[id]
    if note and not note.isShared then
      count = count + 1
    end
  end
  return count
end

function AN:GetSharedNoteIDs()
  local out = {}
  local db = self.DB
  if not db then return out end

  for _, id in ipairs(db.noteOrder or {}) do
    local note = db.notes and db.notes[id]
    if note and note.isShared then
      out[#out + 1] = id
    end
  end
  return out
end

function AN:GetSharedNoteCount()
  return #self:GetSharedNoteIDs()
end

function AN:GetOldestSharedNote()
  local db = self.DB
  if not db then return nil, nil end

  local oldestID, oldestNote
  for _, id in ipairs(db.noteOrder or {}) do
    local note = db.notes and db.notes[id]
    if note and note.isShared then
      if not oldestNote then
        oldestID = id
        oldestNote = note
      elseif (tonumber(note.createdAt) or 0) < (tonumber(oldestNote.createdAt) or 0) then
        oldestID = id
        oldestNote = note
      end
    end
  end

  return oldestID, oldestNote
end

function AN:IsTitleInUse(name, excludeID)
  if not self.DB then return false end
  local wanted = TrimTitleText(name):lower()
  if wanted == "" then return false end

  for _, noteID in ipairs(self.DB.noteOrder or {}) do
    if noteID ~= excludeID then
      local n = self.DB.notes and self.DB.notes[noteID]
      if n and tostring(n.title or ""):lower() == wanted then
        return true
      end
    end
  end
  return false
end

function AN:MakeUniqueTitle(baseTitle, excludeID)
  if not self.DB then
    return TrimTitleText(baseTitle)
  end
  local used = BuildUsedTitleSet(self.DB, excludeID)
  return NextAvailableTitle(baseTitle, used)
end

function AN:GetVisibleNoteIDs()
  local ids = {}
  local db = self.DB
  if not db then return ids end

  local charOnly = db.settings and db.settings.characterSpecific
  local me = GetCharacterLabel()
  local activeFolder = self:GetActiveFolder()
  for _, id in ipairs(db.noteOrder) do
    local note = db.notes[id]
    if note then
      local noteFolder = NormalizeFolderName(note.folder or activeFolder)
      if noteFolder:lower() == tostring(activeFolder):lower()
        and (note.isShared or (not charOnly) or note.createdBy == me) then
        table.insert(ids, id)
      end
    end
  end
  return ids
end

function AN:GetNoteByID(noteID)
  if not self.DB or not noteID then return nil end
  return self.DB.notes[noteID]
end

function AN:GetNoteFontSizeChoices()
  return NOTE_FONT_SIZE_CHOICES
end

function AN:GetAvailableFonts()
  local out = {}
  local seen = {}
  local lsm = GetLSM()
  if lsm and lsm.List then
    local listed = lsm:List("font")
    if type(listed) == "table" then
      for _, name in ipairs(listed) do
        name = TrimTitleText(name)
        local key = name:lower()
        if name ~= "" and not seen[key] then
          seen[key] = true
          out[#out + 1] = name
        end
      end
    end
  end

  for _, info in ipairs(FALLBACK_FONTS) do
    local name = tostring(info.name or "")
    local key = name:lower()
    if name ~= "" and not seen[key] then
      seen[key] = true
      out[#out + 1] = name
    end
  end

  table.sort(out, function(a, b)
    return tostring(a):lower() < tostring(b):lower()
  end)
  return out
end

function AN:ResolveFontPath(fontName)
  fontName = TrimTitleText(fontName)
  if fontName == "" then
    fontName = NOTE_FONT_DEFAULT_NAME
  end
  if fontName:find("\\", 1, true) or fontName:find("/", 1, true) then
    return fontName
  end

  local lsm = GetLSM()
  if lsm and lsm.Fetch then
    local path = lsm:Fetch("font", fontName)
    if type(path) == "string" and path ~= "" then
      return path
    end
  end

  for _, info in ipairs(FALLBACK_FONTS) do
    if tostring(info.name):lower() == fontName:lower() then
      return tostring(info.path or NOTE_FONT_DEFAULT_PATH)
    end
  end
  return NOTE_FONT_DEFAULT_PATH
end

function AN:GetNoteAppearance(note)
  if type(note) ~= "table" then
    return {
      fontName = NOTE_FONT_DEFAULT_NAME,
      fontSize = NOTE_FONT_DEFAULT_SIZE,
      fontColor = NormalizeColor(nil),
    }
  end

  NormalizeNoteAppearance(note)
  return {
    fontName = note.fontName,
    fontSize = note.fontSize,
    fontColor = NormalizeColor(note.fontColor),
  }
end

function AN:SetNoteAppearance(noteID, changes)
  local note = self:GetNoteByID(noteID)
  if not note then
    return false, "Note not found."
  end
  if note.isShared then
    return false, "Shared notes are read-only."
  end
  if type(changes) ~= "table" then
    return false, "No appearance changes provided."
  end

  NormalizeNoteAppearance(note)

  local changed = false
  if changes.fontName ~= nil then
    local fontName = TrimTitleText(changes.fontName)
    if fontName == "" then
      fontName = NOTE_FONT_DEFAULT_NAME
    end
    if note.fontName ~= fontName then
      note.fontName = fontName
      changed = true
    end
  end

  if changes.fontSize ~= nil then
    local fontSize = Clamp(SafeNumber(changes.fontSize, NOTE_FONT_DEFAULT_SIZE), NOTE_FONT_MIN, NOTE_FONT_MAX)
    if note.fontSize ~= fontSize then
      note.fontSize = fontSize
      changed = true
    end
  end

  if changes.fontColor ~= nil then
    local color = NormalizeColor(changes.fontColor)
    local existing = NormalizeColor(note.fontColor)
    local diff = (math.abs(existing.r - color.r) > 0.0001)
      or (math.abs(existing.g - color.g) > 0.0001)
      or (math.abs(existing.b - color.b) > 0.0001)
    if diff then
      note.fontColor = color
      changed = true
    end
  end

  if changed then
    note.updatedAt = time()
    note.updatedBy = GetCharacterLabel()
    if note.userSaved == true then
      note.dirty = true
    end
    self.SessionPendingDiskSave = true
  end

  return changed
end

local function GenerateUntitledTitle(db)
  local used = {}
  for _, id in ipairs(db.noteOrder or {}) do
    local note = db.notes and db.notes[id]
    if note and note.title then
      used[tostring(note.title):lower()] = true
    end
  end

  if not used["untitled"] then
    return "untitled", true
  end

  local idx = 1
  while true do
    local title = string.format("untitled(%d)", idx)
    if not used[title:lower()] then
      return title, true
    end
    idx = idx + 1
  end
end

function AN:IsAtMaxNotes()
  if not self.DB then return true end
  local maxNotes = SafeNumber(self.DB.settings.maxNotes, 100)
  return self:GetRegularNoteCount() >= maxNotes
end

function AN:CreateNote(optionalTitle, optionalFolder)
  if not self.DB then return nil, "Database unavailable." end
  if self:IsAtMaxNotes() then
    return nil, "Maximum notes reached."
  end

  local db = self.DB
  local now = time()
  local noteID = string.format("note-%d-%d", now, math.random(1000, 9999))
  local title, isUntitled
  local folderName = NormalizeFolderName(optionalFolder or self:GetActiveFolder() or (db.folders and db.folders[1]) or "default")
  local folderValid = false
  for _, fname in ipairs(self:GetFolders()) do
    if tostring(fname):lower() == folderName:lower() then
      folderName = fname
      folderValid = true
      break
    end
  end
  if not folderValid then
    folderName = self:GetFolders()[1] or "default"
  end

  if optionalTitle and optionalTitle ~= "" then
    title = self:MakeUniqueTitle(optionalTitle)
    isUntitled = false
  else
    title, isUntitled = GenerateUntitledTitle(db)
  end

  db.notes[noteID] = {
    id = noteID,
    title = title,
    body = "",
    createdAt = now,
    updatedAt = now,
    createdBy = GetCharacterLabel(),
    updatedBy = GetCharacterLabel(),
    isUntitled = isUntitled,
    isShared = false,
    userSaved = false,
    dirty = false,
    folder = folderName,
    fontName = NOTE_FONT_DEFAULT_NAME,
    fontSize = NOTE_FONT_DEFAULT_SIZE,
    fontColor = NormalizeColor(nil),
  }

  table.insert(db.noteOrder, noteID)

  if self.RefreshAllUI then
    self:RefreshAllUI()
  end

  return noteID
end

local function Trim(s)
  s = tostring(s or "")
  s = s:gsub("^%s+", "")
  s = s:gsub("%s+$", "")
  return s
end

function AN:RenameNote(noteID, newName)
  local note = self:GetNoteByID(noteID)
  if not note then return false end
  if note.isShared then return false end
  newName = Trim(newName)
  if newName == "" then return false end
  if self:IsTitleInUse(newName, noteID) then return false end

  note.title = newName
  note.isUntitled = false
  note.updatedAt = time()
  note.updatedBy = GetCharacterLabel()
  if note.userSaved == true then
    note.dirty = true
  end
  self.SessionPendingDiskSave = true

  if self.RefreshAllUI then
    self:RefreshAllUI()
  end

  return true
end

function AN:DeleteNote(noteID)
  if not self.DB then return false end
  local db = self.DB

  if not db.notes[noteID] then return false end
  db.notes[noteID] = nil

  for i, id in ipairs(db.noteOrder) do
    if id == noteID then
      table.remove(db.noteOrder, i)
      break
    end
  end

  if #db.noteOrder == 0 then
    local id = self:CreateNote("untitled")
    local note = id and db.notes[id]
    if note then
      note.isUntitled = true
      note.title = "untitled"
    end
  end

  if db.settings and db.settings.pinned and db.settings.pinned.noteID == noteID then
    db.settings.pinned.noteID = nil
  end

  if self.RefreshAllUI then
    self:RefreshAllUI()
  end
  if self.RefreshPinnedNoteFrame then
    self:RefreshPinnedNoteFrame()
  end

  return true
end

function AN:GetPinnedSettings()
  if not self.DB then return nil end
  self.DB.settings = self.DB.settings or {}
  self.DB.settings.pinned = self.DB.settings.pinned or {
    noteID = nil,
    point = "CENTER",
    x = 0,
    y = 0,
    width = 360,
    height = 260,
    scale = 1.0,
    locked = true,
    showTitle = true,
    hideScrollbar = true,
    parchmentAlpha = 1.0,
    borderAlpha = 0.4,
  }
  return self.DB.settings.pinned
end

function AN:IsNotePinned(noteID)
  local p = self:GetPinnedSettings()
  if not p then return false end
  return tostring(p.noteID or "") == tostring(noteID or "")
end

function AN:SetPinnedNote(noteID)
  local note = self:GetNoteByID(noteID)
  if not note then
    return false, "Note not found."
  end
  local p = self:GetPinnedSettings()
  if not p then
    return false, "Pinned settings unavailable."
  end
  p.noteID = tostring(noteID)
  self.SessionPendingDiskSave = true
  if self.RefreshPinnedNoteFrame then
    self:RefreshPinnedNoteFrame()
  end
  if self.RefreshTabs then
    self:RefreshTabs()
  end
  if self.RefreshNoteActionStates then
    self:RefreshNoteActionStates()
  end
  return true
end

function AN:ClearPinnedNote()
  local p = self:GetPinnedSettings()
  if not p then return false end
  p.noteID = nil
  self.SessionPendingDiskSave = true
  if self.RefreshPinnedNoteFrame then
    self:RefreshPinnedNoteFrame()
  end
  if self.RefreshTabs then
    self:RefreshTabs()
  end
  if self.RefreshNoteActionStates then
    self:RefreshNoteActionStates()
  end
  return true
end

function AN:SaveNote(noteID, body, isAutosave)
  local note = self:GetNoteByID(noteID)
  if not note then return false end
  if note.isShared then return false end

  note.body = tostring(body or "")
  note.updatedAt = time()
  note.updatedBy = GetCharacterLabel()

  if not isAutosave and note.isUntitled then
    -- keep untitled until user picks a real name
  end

  return true
end

function AN:PruneUnsavedNotesForLogout()
  if not self.DB then return end
  local db = self.DB
  if type(db.noteOrder) ~= "table" or type(db.notes) ~= "table" then return end

  for i = #db.noteOrder, 1, -1 do
    local noteID = db.noteOrder[i]
    local note = db.notes[noteID]
    if not note then
      table.remove(db.noteOrder, i)
    elseif (not note.isShared) and (note.userSaved ~= true) then
      db.notes[noteID] = nil
      table.remove(db.noteOrder, i)
    end
  end
end

function AN:GetMaxSharedNotes()
  return MAX_SHARED_NOTES
end

function AN:GetMaxTextShareLines()
  return MAX_TEXT_SHARE_LINES
end

function AN:GetShareLinkType()
  return SHARE_LINK_TYPE
end

function AN:GetNoteLines(noteID)
  local note = self:GetNoteByID(noteID)
  if not note then return {} end
  return SplitLines(note.body or "")
end

function AN:CanShareNoteAsText(noteID)
  local lines = self:GetNoteLines(noteID)
  local count = #lines
  return (count > 0 and count <= MAX_TEXT_SHARE_LINES), count, lines
end

function AN:GetPreferredShareChannel()
  if IsInRaid() then
    return "RAID"
  end
  if IsInGroup(LE_PARTY_CATEGORY_INSTANCE) then
    return "INSTANCE_CHAT"
  end
  if IsInGroup() then
    return "PARTY"
  end
  return "SAY"
end

function AN:ShareNoteAsText(noteID)
  local ok, count, lines = self:CanShareNoteAsText(noteID)
  if not ok then
    if count <= 0 then
      return false, "This note has no lines to share."
    end
    return false, string.format("This note has %d lines. Text chat sharing is limited to %d lines.", count, MAX_TEXT_SHARE_LINES)
  end

  local channel = self:GetPreferredShareChannel()
  for i = 1, #lines do
    SendChatMessage(lines[i], channel)
  end
  return true
end

local function SanitizeLinkLabel(text)
  text = tostring(text or "untitled")
  text = text:gsub("|c%x%x%x%x%x%x%x%x", "")
  text = text:gsub("|r", "")
  text = text:gsub("[%[%]|]", "")
  text = TrimTitleText(text)
  if text == "" then
    text = "untitled"
  end
  if #text > 32 then
    text = text:sub(1, 29) .. "..."
  end
  return text
end

local function SanitizeTokenLabel(text)
  text = SanitizeLinkLabel(text)
  text = text:gsub(":", "-")
  return text
end

function AN:CleanupShareCache()
  local cache = self.ShareCache
  if type(cache) ~= "table" then
    self.ShareCache = {}
    return
  end

  local now = time()
  local ids = {}
  for shareID, entry in pairs(cache) do
    if type(entry) ~= "table" then
      cache[shareID] = nil
    elseif (now - SafeNumber(entry.createdAt, now)) > SHARE_CACHE_TTL then
      cache[shareID] = nil
    else
      ids[#ids + 1] = shareID
    end
  end

  if #ids <= SHARE_CACHE_MAX then
    return
  end

  table.sort(ids, function(a, b)
    local ea = cache[a]
    local eb = cache[b]
    return SafeNumber(ea and ea.createdAt, 0) < SafeNumber(eb and eb.createdAt, 0)
  end)

  for i = 1, (#ids - SHARE_CACHE_MAX) do
    cache[ids[i]] = nil
  end
end

function AN:SetupShareComm()
  if self._ShareCommReady then
    return true
  end
  if not (AceCommLib and AceSerializer) then
    return false
  end

  local okEmbed = pcall(function()
    AceCommLib:Embed(self)
  end)
  if not okEmbed or type(self.RegisterComm) ~= "function" then
    return false
  end

  local okRegister = pcall(function()
    self:RegisterComm(SHARE_COMM_PREFIX, "OnShareCommReceived")
  end)
  if not okRegister then
    return false
  end

  self._ShareCommReady = true
  self.ShareCache = self.ShareCache or {}
  self.PendingShareRequests = self.PendingShareRequests or {}
  return true
end

function AN:SendShareCommEnvelope(target, envelope, priority)
  if not self:SetupShareComm() then
    return false, "Addon communication is unavailable."
  end

  target = NormalizeFullName(target)
  if target == "" then
    return false, "Invalid share target."
  end

  local serialized = AceSerializer:Serialize(envelope)
  local ok, err = pcall(function()
    self:SendCommMessage(SHARE_COMM_PREFIX, serialized, "WHISPER", target, priority or "NORMAL")
  end)
  if not ok then
    return false, tostring(err or "Failed to send addon message.")
  end
  return true
end

function AN:ImportSharedPayloadToUI(data)
  local ok, id, replaced, err = self:ImportSharedNote(data)
  if not ok then
    if self.ShowFeedbackMessage then
      self:ShowFeedbackMessage("Import failed. See chat.", { r = 1, g = 0.4, b = 0.4, hold = 4 })
    end
    self:PrintMessage("Import shared note failed: " .. tostring(err or "unknown error"), true)
    return false
  end

  if not self.UI.main and self.CreateMainWindow then
    self:CreateMainWindow()
  end
  if self.UI.main and not self.UI.main:IsShown() then
    self.UI.main:Show()
  end
  if id and self.SelectNote then
    self:SelectNote(id)
  end
  if self.RefreshAllUI then
    self:RefreshAllUI()
  end

  if self.ShowFeedbackMessage then
    if replaced then
      self:ShowFeedbackMessage("Imported shared note (oldest shared note replaced).", { hold = 4 })
    else
      self:ShowFeedbackMessage("Imported shared note.", { hold = 3 })
    end
  end

  return true
end

function AN:RequestSharedNote(sender, shareID, title, opts)
  sender = NormalizeFullName(sender)
  if sender == "" or tostring(shareID or "") == "" then
    return false, "Invalid share request."
  end
  if not self:SetupShareComm() then
    return false, "Addon communication is unavailable."
  end
  opts = type(opts) == "table" and opts or {}

  local reqID = string.format("%d-%04d", time(), math.random(0, 9999))
  self.PendingShareRequests[reqID] = {
    id = reqID,
    sender = sender,
    shareID = tostring(shareID),
    title = SanitizeLinkLabel(title),
    requestedAt = time(),
    onProgress = opts.onProgress,
    onError = opts.onError,
    onComplete = opts.onComplete,
    totalChunks = 0,
    receivedChunks = 0,
    totalBytes = 0,
    receivedBytes = 0,
    chunks = nil,
  }

  local ok, err = self:SendShareCommEnvelope(sender, {
    t = "REQ",
    req = reqID,
    share = tostring(shareID),
    chunk = 1,
  }, "NORMAL")

  if not ok then
    self.PendingShareRequests[reqID] = nil
    return false, err, nil
  end

  if self.ShowFeedbackMessage then
    self:ShowFeedbackMessage("Requesting shared note from " .. sender .. "...", { hold = 3.5 })
  end

  if C_Timer and C_Timer.After then
    C_Timer.After(SHARE_REQUEST_TIMEOUT, function()
      local pending = AN and AN.PendingShareRequests and AN.PendingShareRequests[reqID]
      if not pending then return end
      AN.PendingShareRequests[reqID] = nil
      if type(pending.onError) == "function" then
        pcall(pending.onError, reqID, "Shared note request timed out.")
      end
      if AN.ShowFeedbackMessage then
        AN:ShowFeedbackMessage("Shared note request timed out. See chat.", { r = 1, g = 0.4, b = 0.4, hold = 4 })
      end
      if AN.PrintMessage then
        AN:PrintMessage("Shared note request timed out. The sender may be offline or unavailable.", true)
      end
    end)
  end

  return true, nil, reqID
end

function AN:CancelShareRequest(reqID)
  reqID = tostring(reqID or "")
  if reqID == "" then return false end
  if not self.PendingShareRequests then return false end
  if not self.PendingShareRequests[reqID] then return false end
  self.PendingShareRequests[reqID] = nil
  return true
end

function AN:BuildShareImportLink(noteID)
  local note = self:GetNoteByID(noteID)
  if not note then
    return nil, "No note selected."
  end
  local appearance = self:GetNoteAppearance(note)

  local payload = {
    kind = "note",
    v = SHARE_PAYLOAD_VERSION,
    title = tostring(note.title or "untitled"),
    body = tostring(note.body or ""),
    createdAt = SafeNumber(note.createdAt, time()),
    updatedAt = SafeNumber(note.updatedAt, time()),
    createdBy = tostring(note.createdBy or GetCharacterLabel()),
    updatedBy = tostring(note.updatedBy or GetCharacterLabel()),
    fontName = tostring(appearance.fontName or NOTE_FONT_DEFAULT_NAME),
    fontSize = SafeNumber(appearance.fontSize, NOTE_FONT_DEFAULT_SIZE),
    fontColor = NormalizeColor(appearance.fontColor),
    from = GetCharacterLabel(),
    exportedAt = time(),
  }

  local encoded, err = EncodeSharePayloadForComm(payload)
  if not encoded then
    return nil, err
  end

  if not self:SetupShareComm() then
    return nil, "Addon communication is unavailable."
  end

  self:CleanupShareCache()
  local shareID
  repeat
    shareID = string.format("%d%04d", time(), math.random(0, 9999))
  until not self.ShareCache[shareID]

  local from = GetCharacterLabel()
  local label = "AmmeNotepad: " .. SanitizeTokenLabel(note.title)
  self.ShareCache[shareID] = {
    id = shareID,
    createdAt = time(),
    from = from,
    title = SanitizeLinkLabel(note.title),
    payload = encoded,
  }

  local token = string.format("[%s:%s:%s:%s]", SHARE_CHAT_TOKEN_REMOTE_PREFIX, from, shareID, label)
  if #token > 255 then
    self.ShareCache[shareID] = nil
    return nil, "Generated share token is too large for chat."
  end

  return token
end

function AN:ShareNoteAsImportLink(noteID)
  local link, err = self:BuildShareImportLink(noteID)
  if not link then
    return false, err
  end

  local ok, err = pcall(SendChatMessage, link, self:GetPreferredShareChannel())
  if not ok then
    local msg = tostring(err or "")
    if msg:lower():find("message too long", 1, true) then
      return false, "The share token could not be sent in chat (message too long)."
    end
    if msg:lower():find("invalid escape code", 1, true) then
      return false, "This note could not be encoded into a valid chat link payload."
    end
    return false, "Failed to send import link: " .. msg
  end
  return true
end

function AN:DecodeImportLink(link)
  local mode, from, shareID, label = tostring(link or ""):match("^" .. SHARE_LINK_TYPE .. ":(remote):([^:]+):([^:]+):(.+)$")
  if mode == "remote" then
    return {
      mode = "remote",
      from = NormalizeFullName(from),
      shareID = tostring(shareID or ""),
      title = SanitizeLinkLabel(label),
    }
  end

  local payload = tostring(link or ""):match("^" .. SHARE_LINK_TYPE .. ":(.+)$")
  if not payload or payload == "" then
    return nil, "Not an AmmeNotepad share link."
  end

  local data, err = DecodeSharePayload(payload)
  if not data then
    return nil, err
  end
  if data.kind ~= "note" then
    return nil, "Unsupported share payload."
  end

  local out = {
    v = SafeNumber(data.v, 1),
    title = TrimTitleText(data.title),
    body = tostring(data.body or ""),
    createdAt = SafeNumber(data.createdAt, time()),
    updatedAt = SafeNumber(data.updatedAt, time()),
    createdBy = tostring(data.createdBy or data.from or "Unknown"),
    updatedBy = tostring(data.updatedBy or data.from or "Unknown"),
    fontName = TrimTitleText(data.fontName or NOTE_FONT_DEFAULT_NAME),
    fontSize = Clamp(SafeNumber(data.fontSize, NOTE_FONT_DEFAULT_SIZE), NOTE_FONT_MIN, NOTE_FONT_MAX),
    fontColor = NormalizeColor(data.fontColor),
    from = tostring(data.from or data.createdBy or "Unknown"),
    exportedAt = SafeNumber(data.exportedAt, time()),
  }

  if out.title == "" then
    out.title = "untitled"
  end
  if #out.body > 250000 then
    return nil, "Shared note is too large."
  end

  return { mode = "inline", data = out }
end

function AN:ImportSharedNote(data)
  if not self.DB then
    return false, nil, nil, "Database unavailable."
  end
  if type(data) ~= "table" then
    return false, nil, nil, "Invalid import data."
  end

  local sharedCount = self:GetSharedNoteCount()
  local replaceID, replaceNote
  if sharedCount >= MAX_SHARED_NOTES then
    replaceID, replaceNote = self:GetOldestSharedNote()
    if replaceID then
      self:DeleteNote(replaceID)
    end
  end

  local now = time()
  local noteID = string.format("shared-%d-%d", now, math.random(1000, 9999))
  local uniqueTitle = self:MakeUniqueTitle(data.title or "shared note")
  local sharedFrom = tostring(data.from or data.createdBy or "Unknown")
  local createdBy = tostring(data.createdBy or sharedFrom)
  local updatedBy = tostring(data.updatedBy or sharedFrom)
  local folderName = self:GetActiveFolder()
  local importedAppearance = {
    fontName = data.fontName,
    fontSize = data.fontSize,
    fontColor = data.fontColor,
  }
  NormalizeNoteAppearance(importedAppearance)

  self.DB.notes[noteID] = {
    id = noteID,
    title = uniqueTitle,
    body = tostring(data.body or ""),
    createdAt = SafeNumber(data.createdAt, now),
    updatedAt = SafeNumber(data.updatedAt, now),
    createdBy = createdBy,
    updatedBy = updatedBy,
    isUntitled = false,
    isShared = true,
    userSaved = true,
    dirty = false,
    sharedFrom = sharedFrom,
    sharedImportedAt = now,
    folder = folderName,
    fontName = importedAppearance.fontName,
    fontSize = importedAppearance.fontSize,
    fontColor = NormalizeColor(importedAppearance.fontColor),
  }
  table.insert(self.DB.noteOrder, noteID)
  self.SessionPendingDiskSave = true

  if self.RefreshAllUI then
    self:RefreshAllUI()
  end

  return true, noteID, replaceNote
end

function AN:HandleImportLinkClick(link)
  local decoded, err = self:DecodeImportLink(link)
  if not decoded then
    print("|cffd9b96eAmmeNotepad:|r " .. tostring(err or "Unable to import note link."))
    return true
  end

  if decoded.mode == "remote" then
    if self.ShowRemoteImportPrompt then
      self:ShowRemoteImportPrompt(decoded.from, decoded.title, decoded.shareID)
    else
      self._PendingRemoteImport = decoded
    end
    return true
  end

  local data = decoded.data
  if self.ShowSharedImportPrompt and type(data) == "table" then
    self:ShowSharedImportPrompt(data)
  else
    self._PendingSharedImport = data
  end
  return true
end

function AN:OnShareCommReceived(_, message, _, sender)
  if not (AceSerializer and type(message) == "string") then
    return
  end

  local ok, envelope = AceSerializer:Deserialize(message)
  if not ok or type(envelope) ~= "table" then
    return
  end

  local senderFull = NormalizeFullName(sender)
  local msgType = tostring(envelope.t or "")

  local function failPending(pending, reqID, msg)
    msg = tostring(msg or "Shared note import failed.")
    if type(pending.onError) == "function" then
      pcall(pending.onError, reqID, msg)
    end
    if type(pending.onComplete) == "function" then
      pcall(pending.onComplete, reqID, false, msg)
    end
    if self.ShowFeedbackMessage then
      self:ShowFeedbackMessage("Import failed. See chat.", { r = 1, g = 0.4, b = 0.4, hold = 4 })
    end
    self:PrintMessage("Shared note import failed: " .. msg, true)
  end

  local function completePendingFromEncodedPayload(pending, reqID, encodedPayload, envelopeFrom, envelopeTitle)
    local payloadData, decodeErr = DecodeSharePayloadFromComm(encodedPayload)
    if not payloadData then
      failPending(pending, reqID, tostring(decodeErr or "Invalid payload."))
      return
    end

    payloadData.from = tostring(envelopeFrom or payloadData.from or senderFull)
    payloadData.title = tostring(payloadData.title or envelopeTitle or pending.title or "untitled")
    local imported = self:ImportSharedPayloadToUI(payloadData)
    if type(pending.onComplete) == "function" then
      pcall(pending.onComplete, reqID, imported == true, imported and nil or "Import failed.")
    end
  end

  if msgType == "REQ" then
    self:CleanupShareCache()
    local reqID = tostring(envelope.req or "")
    local shareID = tostring(envelope.share or "")
    if reqID == "" or shareID == "" then
      return
    end

    local cache = self.ShareCache and self.ShareCache[shareID]
    if not cache then
      self:SendShareCommEnvelope(senderFull, {
        t = "ERR",
        req = reqID,
        err = "Shared note is no longer available.",
      }, "NORMAL")
      return
    end

    local wantsChunked = (SafeNumber(envelope.chunk, 0) == 1)
    if not wantsChunked then
      self:SendShareCommEnvelope(senderFull, {
        t = "DAT",
        req = reqID,
        share = shareID,
        data = cache.payload,
        from = tostring(cache.from or GetCharacterLabel()),
        title = tostring(cache.title or "untitled"),
      }, "BULK")
      return
    end

    local encoded = tostring(cache.payload or "")
    local totalBytes = #encoded
    local chunkSize = math.max(256, SafeNumber(SHARE_COMM_CHUNK_SIZE, 1800))
    local totalChunks = math.max(1, math.ceil(totalBytes / chunkSize))

    local okHead = self:SendShareCommEnvelope(senderFull, {
      t = "DHS",
      req = reqID,
      share = shareID,
      from = tostring(cache.from or GetCharacterLabel()),
      title = tostring(cache.title or "untitled"),
      total = totalChunks,
      bytes = totalBytes,
    }, "NORMAL")
    if not okHead then
      return
    end

    for idx = 1, totalChunks do
      local startPos = ((idx - 1) * chunkSize) + 1
      local endPos = math.min(idx * chunkSize, totalBytes)
      local part = encoded:sub(startPos, endPos)
      local okChunk = self:SendShareCommEnvelope(senderFull, {
        t = "DHC",
        req = reqID,
        idx = idx,
        data = part,
      }, "BULK")
      if not okChunk then
        self:SendShareCommEnvelope(senderFull, {
          t = "ERR",
          req = reqID,
          err = "Failed to send shared note data.",
        }, "NORMAL")
        return
      end
    end
    return
  end

  if msgType == "ERR" then
    local reqID = tostring(envelope.req or "")
    local pending = self.PendingShareRequests and self.PendingShareRequests[reqID]
    if not pending then
      return
    end
    self.PendingShareRequests[reqID] = nil
    failPending(pending, reqID, tostring(envelope.err or "The sender could not provide this shared note."))
    return
  end

  if msgType == "DAT" then
    local reqID = tostring(envelope.req or "")
    local pending = self.PendingShareRequests and self.PendingShareRequests[reqID]
    if not pending then
      return
    end

    if pending.sender ~= senderFull then
      return
    end

    self.PendingShareRequests[reqID] = nil
    local dataText = tostring(envelope.data or "")
    if type(pending.onProgress) == "function" then
      pcall(pending.onProgress, reqID, 1, 1, #dataText, #dataText)
    end
    completePendingFromEncodedPayload(pending, reqID, dataText, envelope.from, envelope.title)
    return
  end

  if msgType == "DHS" then
    local reqID = tostring(envelope.req or "")
    local pending = self.PendingShareRequests and self.PendingShareRequests[reqID]
    if not pending then
      return
    end
    if pending.sender ~= senderFull then
      return
    end

    pending.totalChunks = math.max(1, SafeNumber(envelope.total, 1))
    pending.totalBytes = math.max(0, SafeNumber(envelope.bytes, 0))
    pending.receivedChunks = 0
    pending.receivedBytes = 0
    pending.chunks = {}
    pending.from = tostring(envelope.from or pending.sender)
    pending.title = tostring(envelope.title or pending.title or "untitled")

    if type(pending.onProgress) == "function" then
      pcall(pending.onProgress, reqID, 0, pending.totalChunks, 0, pending.totalBytes)
    end
    return
  end

  if msgType == "DHC" then
    local reqID = tostring(envelope.req or "")
    local pending = self.PendingShareRequests and self.PendingShareRequests[reqID]
    if not pending then
      return
    end
    if pending.sender ~= senderFull then
      return
    end
    if type(pending.chunks) ~= "table" then
      return
    end

    local idx = SafeNumber(envelope.idx, 0)
    local total = math.max(1, SafeNumber(pending.totalChunks, 1))
    if idx < 1 or idx > total then
      return
    end

    if pending.chunks[idx] == nil then
      local part = tostring(envelope.data or "")
      pending.chunks[idx] = part
      pending.receivedChunks = SafeNumber(pending.receivedChunks, 0) + 1
      pending.receivedBytes = SafeNumber(pending.receivedBytes, 0) + #part
      if type(pending.onProgress) == "function" then
        pcall(
          pending.onProgress,
          reqID,
          pending.receivedChunks,
          total,
          pending.receivedBytes,
          math.max(0, SafeNumber(pending.totalBytes, 0))
        )
      end
    end

    if pending.receivedChunks >= total then
      local parts = {}
      for i = 1, total do
        if pending.chunks[i] == nil then
          return
        end
        parts[i] = pending.chunks[i]
      end
      self.PendingShareRequests[reqID] = nil
      local encodedPayload = table.concat(parts)
      completePendingFromEncodedPayload(pending, reqID, encodedPayload, pending.from, pending.title)
    end
    return
  end
end

local SHARE_CHAT_EVENTS = {
  "CHAT_MSG_SAY",
  "CHAT_MSG_YELL",
  "CHAT_MSG_PARTY",
  "CHAT_MSG_PARTY_LEADER",
  "CHAT_MSG_RAID",
  "CHAT_MSG_RAID_LEADER",
  "CHAT_MSG_INSTANCE_CHAT",
  "CHAT_MSG_INSTANCE_CHAT_LEADER",
  "CHAT_MSG_GUILD",
  "CHAT_MSG_OFFICER",
  "CHAT_MSG_WHISPER",
  "CHAT_MSG_WHISPER_INFORM",
  "CHAT_MSG_BN_WHISPER",
  "CHAT_MSG_BN_WHISPER_INFORM",
  "CHAT_MSG_CHANNEL",
}

function AN:SetupShareChatFilter()
  if self._ShareFilterInstalled then
    return
  end

  local ChatFrame_AddMessageEventFilter = ChatFrame_AddMessageEventFilter or (ChatFrameUtil and ChatFrameUtil.AddMessageEventFilter)
  if not ChatFrame_AddMessageEventFilter then
    return
  end

  local function shareFilter(_, _, msg, ...)
    if type(msg) ~= "string" then
      return false
    end

    local changed = false
    local out = msg:gsub("%[" .. SHARE_CHAT_TOKEN_REMOTE_PREFIX .. ":([^:%]]+):([^:%]]+):([^%]]+)%]", function(from, shareID, label)
      changed = true
      local sender = NormalizeFullName(from)
      local shown = SanitizeLinkLabel(label)
      return string.format("|H%s:remote:%s:%s:%s|h[%s]|h", SHARE_LINK_TYPE, sender, tostring(shareID or ""), shown, shown)
    end)

    out = out:gsub("%[" .. SHARE_CHAT_TOKEN_PREFIX .. ":([^:]+):([^%]]+)%]", function(payload, label)
      changed = true
      local shown = SanitizeLinkLabel(label)
      return string.format("|H%s:%s|h[%s]|h", SHARE_LINK_TYPE, payload, shown)
    end)

    if changed then
      return false, out, ...
    end
  end

  for _, eventName in ipairs(SHARE_CHAT_EVENTS) do
    ChatFrame_AddMessageEventFilter(eventName, shareFilter)
  end

  self._ShareFilterInstalled = true
end

function AN:SetupImportLinkHook()
  if self._ItemRefHooked then
    return
  end
  self._ItemRefHooked = true

  hooksecurefunc("SetItemRef", function(link)
    if type(link) ~= "string" then
      return
    end
    if link:match("^" .. SHARE_LINK_TYPE .. ":") then
      AN:HandleImportLinkClick(link)
    end
  end)
end

function AN:SetMinimapHidden(hidden)
  self.DB = self.DB or {}
  self.DB.minimap = self.DB.minimap or { hide = false }
  self.DB.minimap.hide = hidden and true or false

  local icon = LibStub and LibStub("LibDBIcon-1.0", true)
  if not icon then return end

  if self.DB.minimap.hide then
    icon:Hide("AmmeNotepad")
  else
    icon:Show("AmmeNotepad")
  end
end

function AN:PrintMessage(msg, isError)
  local text = tostring(msg or "")
  if text == "" then return end
  if isError then
    print("|cffd9b96eAmmeNotepad:|r |cffff6666" .. text .. "|r")
  else
    print("|cffd9b96eAmmeNotepad:|r " .. text)
  end
end

function AN:CreateMinimapButton()
  if self.MinimapButton then return end
  if not LibStub then return end

  self.DB = self.DB or {}
  self.DB.minimap = self.DB.minimap or { hide = false }

  local ldb = LibStub("LibDataBroker-1.1"):NewDataObject("AmmeNotepad", {
    type = "data source",
    text = "AmmeNotepad",
    icon = "Interface\\AddOns\\AmmeNotepad\\Art\\quill.png",
    OnClick = function()
      if AN.ToggleMainWindow then
        AN:ToggleMainWindow()
      end
    end,
    OnTooltipShow = function(tt)
      tt:AddLine("AmmeNotepad")
      tt:AddLine("Click: Toggle notepad", 0.9, 0.9, 0.9)
      tt:AddLine("Minimap icon visibility: Esc > Options > AddOns > AmmeNotepad", 0.9, 0.9, 0.9)
      tt:AddLine("Slash: /ammenotepad", 0.9, 0.9, 0.9)
    end,
  })

  local icon = LibStub("LibDBIcon-1.0", true)
  if icon then
    icon:Register("AmmeNotepad", ldb, self.DB.minimap)
    self.MinimapButton = icon:GetMinimapButton("AmmeNotepad")
    if self.DB.minimap.hide then
      icon:Hide("AmmeNotepad")
    end
  end
end

local EventFrame = CreateFrame("Frame")
EventFrame:RegisterEvent("ADDON_LOADED")
EventFrame:RegisterEvent("PLAYER_LOGIN")
EventFrame:RegisterEvent("PLAYER_TARGET_CHANGED")
EventFrame:RegisterEvent("GROUP_ROSTER_UPDATE")
EventFrame:RegisterEvent("PLAYER_ENTERING_WORLD")
EventFrame:RegisterEvent("PLAYER_LOGOUT")

EventFrame:SetScript("OnEvent", function(_, event, ...)
  if event == "ADDON_LOADED" then
    local addon = ...
    if addon ~= ADDON_NAME then return end
    EnsureDefaults()
    return
  end

  if event == "PLAYER_LOGIN" then
    if math and math.randomseed then
      math.randomseed(time())
    end
    if AN.RefreshSessionWarningState then
      AN:RefreshSessionWarningState()
    end
    if AN.SetupShareComm then
      AN:SetupShareComm()
    end
    if AN.SetupShareChatFilter then
      AN:SetupShareChatFilter()
    end
    if AN.SetupImportLinkHook then
      AN:SetupImportLinkHook()
    end
    if AN.CreateMinimapButton then
      AN:CreateMinimapButton()
    end
    if AN.CreateOptionsPanel then
      AN:CreateOptionsPanel()
    end
    if AN.SetupEditModeHooks then
      AN:SetupEditModeHooks()
    end
    if AN.InitializePinnedNote then
      AN:InitializePinnedNote()
    end
    return
  end

  if event == "PLAYER_TARGET_CHANGED" then
    if AN.UpdateTargetButtonState then
      AN:UpdateTargetButtonState()
    end
    return
  end

  if event == "GROUP_ROSTER_UPDATE" or event == "PLAYER_ENTERING_WORLD" then
    if AN.RefreshFolderPanel then
      AN:RefreshFolderPanel()
    end
    return
  end

  if event == "PLAYER_LOGOUT" then
    if AN.PruneUnsavedNotesForLogout then
      AN:PruneUnsavedNotesForLogout()
    end
    return
  end
end)

SLASH_AMMENOTEPAD1 = "/ammenotepad"
SLASH_AMMENOTEPAD2 = "/anote"
SlashCmdList["AMMENOTEPAD"] = function(msg)
  msg = tostring(msg or ""):lower():gsub("^%s+", ""):gsub("%s+$", "")

  if msg == "show minimap" or msg == "minimap show" then
    AN:SetMinimapHidden(false)
    print("|cffd9b96eAmmeNotepad:|r Minimap button shown.")
    return
  end

  if msg == "hide minimap" or msg == "minimap hide" then
    AN:SetMinimapHidden(true)
    print("|cffd9b96eAmmeNotepad:|r Minimap button hidden.")
    return
  end

  if msg == "debug" then
    AN.Debug = not AN.Debug
    print(string.format("|cffd9b96eAmmeNotepad:|r Debug %s.", AN.Debug and "enabled" or "disabled"))
    return
  end

  if msg == "debug on" then
    AN.Debug = true
    print("|cffd9b96eAmmeNotepad:|r Debug enabled.")
    return
  end

  if msg == "debug off" then
    AN.Debug = false
    print("|cffd9b96eAmmeNotepad:|r Debug disabled.")
    return
  end

  if AN.ToggleMainWindow then
    AN:ToggleMainWindow()
  end
end
