-- UI/HelpPages/Notepad.lua
-- What Farm? :: Notepad page
--
-- Design contracts:
--   ctx.MakePage(id, label)     -> Frame (page root)
--   ctx.MakePanel(parent, bg)   -> Frame with backdrop
--   ctx.MakeMutedText(p, text)  -> FontString
--   ctx.MakeDivider(parent)     -> Texture
--   ctx.Widgets:Button(p, lbl, w, h) -> Button
--   ctx.C4(color, alpha_color)  -> r, g, b, a
--   ctx.ACCENT / TEXT / MUTED / BG  -> color tables
--
-- All module state lives in upvalue locals.
-- Nothing is written to WF.UI or _G.

local ADDON_NAME, ns = ...
local WF = ns.WF

WF.UI           = WF.UI           or {}
WF.UI.HelpPages = WF.UI.HelpPages or {}

-- Module-level flag: global hooks must only be installed once regardless of
-- how many times the page factory function is invoked.
local _linkHooksInstalled = false

WF.UI.HelpPages.Notepad = function(ctx)
  -- ── context bindings ──────────────────────────────────────────────────────
  local UI        = ctx.UI
  local Widgets   = ctx.Widgets
  local C4        = ctx.C4
  local ACCENT    = ctx.ACCENT
  local TEXT      = ctx.TEXT
  local MUTED     = ctx.MUTED
  local BG        = ctx.BG

  local MakePage      = ctx.MakePage
  local MakePanel     = ctx.MakePanel
  local MakeMutedText = ctx.MakeMutedText
  local MakeDivider   = ctx.MakeDivider

  -- ── pre-resolve accent components once ───────────────────────────────────
  -- Avoids repeated C4 calls with fallback math inside layout loops.
  local acR, acG, acB, acA = C4(ACCENT, ACCENT)
  if not acR then
    acR = (ACCENT and ACCENT[1]) or 0.25
    acG = (ACCENT and ACCENT[2]) or 0.75
    acB = (ACCENT and ACCENT[3]) or 1.00
    acA = 1.0
  end

  -- ── string / text helpers ─────────────────────────────────────────────────

  local function NormalizeNewlines(s)
    s = tostring(s or "")
    s = s:gsub("\r\n", "\n")
    s = s:gsub("\r",   "\n")
    return s
  end

  -- Strip WoW display markup down to plain text.
  local function StripMarkup(s)
    s = tostring(s or "")
    s = NormalizeNewlines(s)
    s = s:gsub("|T.-|t",   "")
    s = s:gsub("|A.-|a",   "")
    s = s:gsub("|K.-|k",   "")
    s = s:gsub("|c%x%x%x%x%x%x%x%x", "")
    s = s:gsub("|r",        "")
    s = s:gsub("|H.-|h(.-)|h", "%1")
    s = s:gsub("|n",        " ")
    s = s:gsub("%c",        "")
    s = s:gsub("%s+",       " ")
    return s:match("^%s*(.-)%s*$") or ""
  end

  -- Single-line preview that preserves colour markup on item links.
  local function PreviewText(body)
    body = tostring(body or "")
    body = NormalizeNewlines(body)
    body = body:match("^([^\n]*)") or ""
    body = body:gsub("|T.-|t", "")
    body = body:gsub("|A.-|a", "")
    body = body:gsub("|K.-|k", "")
    body = body:gsub("|n", " ")
    body = body:gsub("|c(%x%x%x%x%x%x%x%x)|H.-|h(.-)|h|r", function(col, lbl)
      return "|c" .. col .. lbl .. "|r"
    end)
    body = body:gsub("|H.-|h(.-)|h", "%1")
    body = body:gsub("%c", "")
    body = body:gsub("%s+", " ")
    body = body:match("^%s*(.-)%s*$") or ""
    return body ~= "" and body or "Empty note"
  end

  local function TruncOneLine(s, maxChars)
    s = StripMarkup(NormalizeNewlines(s))
    s = s:match("^([^\n]*)") or ""
    maxChars = tonumber(maxChars) or 40
    if #s <= maxChars then return s end
    return s:sub(1, maxChars - 1) .. "…"
  end

  local function SearchableText(s)
    return string.lower(StripMarkup(s or ""))
  end

  -- ── stat helpers ──────────────────────────────────────────────────────────

  local function CountWords(s)
    local count = 0
    for _ in StripMarkup(s or ""):gmatch("%S+") do count = count + 1 end
    return count
  end

  local function CountLines(s)
    s = NormalizeNewlines(s or "")
    if s == "" then return 0 end
    local n = 1
    for _ in s:gmatch("\n") do n = n + 1 end
    return n
  end

  local function CountHyperlinks(s)
    s = tostring(s or "")
    local n, pos = 0, 1
    while true do
      local a, b = s:find("|H", pos, true)
      if not a then break end
      n   = n + 1
      pos = b + 1
    end
    return n
  end

  -- ── formatting helpers ────────────────────────────────────────────────────

  local function Plural(n, singular, plural)
    n = tonumber(n) or 0
    return n == 1 and (n .. " " .. singular)
                  or  (n .. " " .. (plural or singular .. "s"))
  end

  local function JoinBullets(...)
    local parts, k = {}, 0
    for i = 1, select("#", ...) do
      local v = select(i, ...)
      if type(v) == "string" and v ~= "" then
        k = k + 1
        parts[k] = v
      end
    end
    return table.concat(parts, " • ")
  end

  local function FormatStamp(ts)
    ts = tonumber(ts) or 0
    return ts > 0 and date("%H:%M:%S", ts) or "--:--:--"
  end

  local function FormatEditorStamp(ts)
    ts = tonumber(ts) or 0
    return ts > 0 and date("%m/%d %H:%M", ts) or "--/-- --:--"
  end

  local function FormatRowStamp(ts)
    ts = tonumber(ts) or 0
    return ts > 0 and date("%m/%d", ts) or "--/--"
  end

  -- ── DB helpers ────────────────────────────────────────────────────────────
  -- These never touch the UI table.

  local function NewNoteId()
    return tostring(time()) .. tostring(math.random(100000, 999999))
  end

  local function GetNotepadData()
    if not (WF.DB and WF.DB.GetSetting) then
      return { v = 2, activeId = nil, notes = {} }
    end

    local d = WF.DB:GetSetting("notepadData")
    if type(d) ~= "table" then
      d = { v = 2, activeId = nil, notes = {} }
    end

    d.v     = 2
    d.notes = d.notes or {}

    -- one-time migration from the old single-string notepadText setting
    local legacy = WF.DB:GetSetting("notepadText")
    if (not d.activeId or #d.notes == 0)
        and type(legacy) == "string" and legacy ~= "" then
      local id = NewNoteId()
      local ts = time()
      d.notes[1] = {
        id = id, title = "Imported Note",
        body = NormalizeNewlines(legacy),
        createdAt = ts, updatedAt = ts,
      }
      d.activeId = id
    end

    -- guarantee at least one note always exists
    if #d.notes == 0 then
      local id = NewNoteId()
      local ts = time()
      d.notes[1] = {
        id = id, title = "My Note", body = "",
        createdAt = ts, updatedAt = ts,
      }
      d.activeId = id
    end

    -- validate activeId
    local valid = false
    for i = 1, #d.notes do
      if d.notes[i] and d.notes[i].id == d.activeId then
        valid = true; break
      end
    end
    if not valid then
      d.activeId = d.notes[1] and d.notes[1].id or nil
    end

    return d
  end

  local function SaveNotepadData(d)
    if WF.DB and WF.DB.SetSetting then
      WF.DB:SetSetting("notepadData", d)
    end
  end

  local function SortNotes(d)
    table.sort(d.notes, function(a, b)
      local au = tonumber(a and a.updatedAt) or 0
      local bu = tonumber(b and b.updatedAt) or 0
      if au == bu then
        return tostring(a and a.title or "") < tostring(b and b.title or "")
      end
      return au > bu
    end)
  end

  local function FindNoteById(d, id)
    if not d or not id then return nil, nil end
    for i = 1, #d.notes do
      if d.notes[i] and d.notes[i].id == id then
        return i, d.notes[i]
      end
    end
    return nil, nil
  end

  local function GetActiveNote(d)
    local _, n = FindNoteById(d, d and d.activeId)
    return n
  end

  -- ── scroll helper ─────────────────────────────────────────────────────────

  local function ScrollClamp(sf, delta, step)
    step = step or 28
    local cur = sf:GetVerticalScroll()      or 0
    local max = sf:GetVerticalScrollRange() or 0
    local nv  = math.min(math.max(cur - delta * step, 0), max)
    sf:SetVerticalScroll(nv)
  end

  -- ── page root ─────────────────────────────────────────────────────────────

  local pNotepad = MakePage("Notepad", "Notepad")

  do
    -- ── module-local state (never touches WF.UI or _G) ────────────────────
    local cached       = nil   -- loaded notepad data table
    local savePending  = false -- debounce flag
    local setting      = false -- guard: suppress OnTextChanged during programmatic SetText
    local searchStr    = ""    -- current search string
    local searchSetting= false -- guard for search box programmatic SetText
    local activeEdit   = nil   -- titleEdit or bodyEdit, whichever last had focus
    local rowMenuId    = nil   -- note id currently shown in context menu

    -- forward declarations for mutual recursion
    local RebuildList
    local CommitActive
    local FillEditorFromActive
    local SelectNoteById
    local DuplicateNoteById
    local DeleteNoteById
    local HideRowMenu
    local ShowRowMenu
    local QueueSave

    -- ── layout shims (required by HelpWindow layout contract) ─────────────
    local shimLeft = CreateFrame("Frame", nil, pNotepad)
    shimLeft:SetAlpha(0)
    shimLeft:EnableMouse(false)
    shimLeft:SetPoint("TOPLEFT",  0, 0)
    shimLeft:SetPoint("BOTTOMLEFT", 0, 0)
    shimLeft:SetWidth(1)

    local shimRight = CreateFrame("Frame", nil, pNotepad)
    shimRight:SetAlpha(0)
    shimRight:EnableMouse(false)
    shimRight:SetPoint("TOPRIGHT",    0, 0)
    shimRight:SetPoint("BOTTOMRIGHT", 0, 0)
    shimRight:SetWidth(1)

    -- ── root / chrome ─────────────────────────────────────────────────────
    local root = CreateFrame("Frame", nil, pNotepad)
    root:SetPoint("TOPLEFT",     8, -26)
    root:SetPoint("BOTTOMRIGHT", -8,  8)

    local chrome = CreateFrame("Frame", nil, root)
    chrome:SetPoint("TOPLEFT",  0,  0)
    chrome:SetPoint("TOPRIGHT", 0,  0)
    chrome:SetHeight(58)

    local topDiv = MakeDivider(chrome)
    topDiv:SetPoint("TOPLEFT",  0, -24)
    topDiv:SetPoint("TOPRIGHT", 0, -24)

    local bNew = Widgets:Button(chrome, "New",    76, 22)
    bNew:SetPoint("TOPLEFT", 0, -32)

    local bDelete = Widgets:Button(chrome, "Delete", 76, 22)
    bDelete:SetPoint("LEFT", bNew, "RIGHT", 10, 0)

    local bSave = Widgets:Button(chrome, "Save", 76, 22)
    bSave:SetPoint("TOPRIGHT", 0, -32)

    local statusLabel = MakeMutedText(chrome, "Saved • --:--:--")
    statusLabel:SetPoint("RIGHT", bSave, "LEFT", -10, 0)

    -- ── content split ─────────────────────────────────────────────────────
    local content = CreateFrame("Frame", nil, root)
    content:SetPoint("TOPLEFT",     chrome, "BOTTOMLEFT",  0, 0)
    content:SetPoint("BOTTOMRIGHT", 0, 0)

    local listPanel   = MakePanel(content, BG)
    local editorPanel = MakePanel(content, BG)

    local GAP    = 12
    local LIST_W = 280

    local midLine = content:CreateTexture(nil, "ARTWORK")
    midLine:SetTexture("Interface/Buttons/WHITE8x8")
    midLine:SetWidth((Widgets and Widgets.PixelSize) and Widgets:PixelSize(content) or 1)
    midLine:SetVertexColor(C4(MUTED, MUTED))
    midLine:SetAlpha(0.08)

    local function LayoutContent()
      local w = content:GetWidth() or 0
      if w <= 10 then return end
      LIST_W = math.min(360, math.max(260, math.floor(w * 0.29)))

      listPanel:ClearAllPoints()
      listPanel:SetPoint("TOPLEFT",    content, "TOPLEFT",    0,  0)
      listPanel:SetPoint("BOTTOMLEFT", content, "BOTTOMLEFT", 0,  0)
      listPanel:SetWidth(LIST_W)

      editorPanel:ClearAllPoints()
      editorPanel:SetPoint("TOPLEFT",     listPanel, "TOPRIGHT",    GAP,  0)
      editorPanel:SetPoint("BOTTOMLEFT",  listPanel, "BOTTOMRIGHT", GAP,  0)
      editorPanel:SetPoint("TOPRIGHT",    content,   "TOPRIGHT",    0,    0)
      editorPanel:SetPoint("BOTTOMRIGHT", content,   "BOTTOMRIGHT", 0,    0)

      midLine:ClearAllPoints()
      local mid = math.floor(GAP * 0.5)
      midLine:SetPoint("TOP",    listPanel, "TOPRIGHT",    mid, -10)
      midLine:SetPoint("BOTTOM", listPanel, "BOTTOMRIGHT", mid,  10)
    end

    content:SetScript("OnSizeChanged", LayoutContent)

    -- ── list panel ────────────────────────────────────────────────────────
    local listHdr = MakeMutedText(listPanel, "Notes")
    listHdr:SetPoint("TOPLEFT", 10, -8)

    local listCount = listPanel:CreateFontString(nil, "OVERLAY", "WF_GameFontDisableSmall")
    listCount:SetPoint("TOPRIGHT", -10, -8)
    listCount:SetTextColor(C4(MUTED, MUTED))
    listCount:SetText("")

    local listDiv = MakeDivider(listPanel)
    listDiv:SetPoint("TOPLEFT",  10, -26)
    listDiv:SetPoint("TOPRIGHT", -10, -26)

    local searchBox = MakePanel(listPanel, BG)
    searchBox:SetPoint("TOPLEFT",  10, -36)
    searchBox:SetPoint("TOPRIGHT", -10, -36)
    searchBox:SetHeight(26)

    local searchEdit = CreateFrame("EditBox", nil, searchBox)
    searchEdit:SetAutoFocus(false)
    searchEdit:SetFontObject("WF_GameFontHighlightSmall")
    searchEdit:SetTextColor(C4(TEXT, TEXT))
    searchEdit:SetJustifyH("LEFT")
    searchEdit:SetTextInsets(8, 8, 5, 5)
    searchEdit:EnableMouse(true)
    searchEdit:EnableKeyboard(true)
    searchEdit:SetPoint("TOPLEFT",     2, -2)
    searchEdit:SetPoint("BOTTOMRIGHT", -2,  2)
    if searchEdit.SetCursorColor    then searchEdit:SetCursorColor(0.35, 0.75, 1.0) end
    if searchEdit.SetHighlightColor then searchEdit:SetHighlightColor(0.20, 0.55, 1.00, 0.35) end

    local searchHint = MakeMutedText(searchBox, "Search notes…")
    searchHint:SetPoint("LEFT", 8, 0)
    searchHint:SetAlpha(0.8)

    local listHint = MakeMutedText(listPanel, "Left-click to open • Right-click for options")
    listHint:SetPoint("TOPLEFT",  searchBox, "BOTTOMLEFT",  0, -10)
    listHint:SetPoint("TOPRIGHT", searchBox, "BOTTOMRIGHT", 0, -10)
    listHint:SetJustifyH("LEFT")

    local listScroll = CreateFrame("ScrollFrame", nil, listPanel, "UIPanelScrollFrameTemplate")
    if Widgets and Widgets.SkinScrollBar and listScroll.ScrollBar then
      Widgets:SkinScrollBar(listScroll.ScrollBar)
    end
    listScroll:SetPoint("TOPLEFT",     6,   -112)
    listScroll:SetPoint("TOPRIGHT",    -28, -112)
    listScroll:SetPoint("BOTTOMLEFT",  6,    10)
    listScroll:SetPoint("BOTTOMRIGHT", -28,  10)
    listScroll:EnableMouseWheel(true)
    listScroll:SetScript("OnMouseWheel", function(sf, delta)
      ScrollClamp(sf, delta, 34)
    end)

    local listContent = CreateFrame("Frame", nil, listScroll)
    listContent:SetSize(1, 1)
    listScroll:SetScrollChild(listContent)

    local emptyListText = MakeMutedText(listContent, "No notes match this search.")
    emptyListText:SetPoint("TOPLEFT",  10, -12)
    emptyListText:SetPoint("TOPRIGHT", -10, -12)
    emptyListText:SetJustifyH("LEFT")
    emptyListText:Hide()

    local function EnsureListWidth()
      local w = listScroll:GetWidth() or 0
      if w > 1 then listContent:SetWidth(w) end
    end
    listScroll:SetScript("OnSizeChanged", EnsureListWidth)

    -- ── editor panel ──────────────────────────────────────────────────────
    local edHdr = MakeMutedText(editorPanel, "Editor")
    edHdr:SetPoint("TOPLEFT", 10, -8)

    local edMeta = editorPanel:CreateFontString(nil, "OVERLAY", "WF_GameFontDisableSmall")
    edMeta:SetPoint("TOPRIGHT", -10, -8)
    edMeta:SetJustifyH("RIGHT")
    edMeta:SetTextColor(C4(MUTED, MUTED))
    edMeta:SetText("")

    local edDiv = MakeDivider(editorPanel)
    edDiv:SetPoint("TOPLEFT",  10, -26)
    edDiv:SetPoint("TOPRIGHT", -10, -26)

    local titleLbl = MakeMutedText(editorPanel, "Title")
    titleLbl:SetPoint("TOPLEFT", 10, -36)

    local titleBox = MakePanel(editorPanel, BG)
    titleBox:SetPoint("TOPLEFT",  10, -56)
    titleBox:SetPoint("TOPRIGHT", -10, -56)
    titleBox:SetHeight(30)

    local titleEdit = CreateFrame("EditBox", nil, titleBox)
    titleEdit:SetAutoFocus(false)
    titleEdit:SetFontObject("WF_GameFontHighlight")
    titleEdit:SetTextColor(C4(TEXT, TEXT))
    titleEdit:SetJustifyH("LEFT")
    titleEdit:SetTextInsets(10, 10, 6, 6)
    titleEdit:EnableMouse(true)
    titleEdit:EnableKeyboard(true)
    titleEdit:SetMaxLetters(96)
    titleEdit:SetPoint("TOPLEFT",     2, -2)
    titleEdit:SetPoint("BOTTOMRIGHT", -2,  2)
    if titleEdit.SetCursorColor    then titleEdit:SetCursorColor(0.35, 0.75, 1.0) end
    if titleEdit.SetHighlightColor then titleEdit:SetHighlightColor(0.20, 0.55, 1.00, 0.35) end

    local titlePlaceholder = MakeMutedText(titleBox, "Untitled note")
    titlePlaceholder:SetPoint("LEFT",  12,  0)
    titlePlaceholder:SetPoint("RIGHT", -12, 0)
    titlePlaceholder:SetJustifyH("LEFT")
    titlePlaceholder:SetAlpha(0.75)

    local editorInfoLeft = editorPanel:CreateFontString(nil, "OVERLAY", "WF_GameFontDisableSmall")
    editorInfoLeft:SetPoint("TOPLEFT",  titleBox,    "BOTTOMLEFT",  0,    -8)
    editorInfoLeft:SetPoint("TOPRIGHT", editorPanel, "TOPRIGHT",    -170, -94)
    editorInfoLeft:SetJustifyH("LEFT")
    editorInfoLeft:SetTextColor(C4(MUTED, MUTED))
    editorInfoLeft:SetText("")

    local editorInfoRight = editorPanel:CreateFontString(nil, "OVERLAY", "WF_GameFontDisableSmall")
    editorInfoRight:SetPoint("TOPRIGHT", -10, -94)
    editorInfoRight:SetJustifyH("RIGHT")
    editorInfoRight:SetTextColor(C4(MUTED, MUTED))
    editorInfoRight:SetText("Focus editor, then shift-click, paste, or drag links")

    local bodyBox = MakePanel(editorPanel, BG)
    bodyBox:SetPoint("TOPLEFT",     10, -112)
    bodyBox:SetPoint("TOPRIGHT",    -10, -112)
    bodyBox:SetPoint("BOTTOMLEFT",  10,  10)
    bodyBox:SetPoint("BOTTOMRIGHT", -10,  10)

    local bodyEdit = CreateFrame("EditBox", nil, bodyBox)
    bodyEdit:SetAutoFocus(false)
    bodyEdit:SetMultiLine(true)
    bodyEdit:SetMaxLetters(0)
    bodyEdit:SetFontObject("WF_GameFontHighlight")
    bodyEdit:SetTextColor(C4(TEXT, TEXT))
    bodyEdit:SetJustifyH("LEFT")
    bodyEdit:SetJustifyV("TOP")
    bodyEdit:SetSpacing(1)
    bodyEdit:SetTextInsets(12, 12, 12, 12)
    bodyEdit:EnableMouse(true)
    bodyEdit:EnableKeyboard(true)
    bodyEdit:SetPoint("TOPLEFT",     bodyBox, "TOPLEFT",     0, 0)
    bodyEdit:SetPoint("BOTTOMRIGHT", bodyBox, "BOTTOMRIGHT", 0, 0)
    if bodyEdit.SetCursorColor         then bodyEdit:SetCursorColor(0.35, 0.75, 1.0) end
    if bodyEdit.SetHighlightColor      then bodyEdit:SetHighlightColor(0.20, 0.55, 1.00, 0.35) end
    if bodyEdit.SetCountInvisibleLetters then bodyEdit:SetCountInvisibleLetters(true) end

    local bodyPlaceholder = MakeMutedText(bodyBox,
      "Write here. Focus the editor, then shift-click, paste, or drag item links to keep references inside the note.")
    bodyPlaceholder:SetPoint("TOPLEFT",     14, -14)
    bodyPlaceholder:SetPoint("TOPRIGHT",    -14, -14)
    bodyPlaceholder:SetJustifyH("LEFT")
    bodyPlaceholder:SetJustifyV("TOP")
    bodyPlaceholder:SetAlpha(0.75)

    -- default active edit target
    activeEdit = bodyEdit

    -- ── status bar helpers ────────────────────────────────────────────────

    local function SetStatus(text, useAccent)
      statusLabel:SetText(text or "")
      if useAccent then
        statusLabel:SetTextColor(C4(ACCENT, ACCENT))
      else
        statusLabel:SetTextColor(C4(MUTED, MUTED))
      end
    end

    local function MarkSaved(ts)
      SetStatus("Saved • " .. FormatStamp(ts or time()), false)
    end

    local function MarkSaving()
      SetStatus("Saving…", true)
    end

    -- ── editor chrome refresh ─────────────────────────────────────────────

    local function UpdateEditorMeta()
      local d = cached or GetNotepadData()
      local n = GetActiveNote(d)
      edMeta:SetText(n and ("Updated " .. FormatEditorStamp(n.updatedAt)) or "")
    end

    local function UpdatePlaceholders()
      if (titleEdit:GetText() or "") == "" then titlePlaceholder:Show() else titlePlaceholder:Hide() end
      if (bodyEdit:GetText()  or "") == "" then bodyPlaceholder:Show()  else bodyPlaceholder:Hide()  end
      if (searchEdit:GetText() or "") == "" then searchHint:Show() else searchHint:Hide() end
    end

    local function UpdateEditorInfo()
      local body  = bodyEdit:GetText()  or ""
      local title = titleEdit:GetText() or ""
      local links = CountHyperlinks(title) + CountHyperlinks(body)
      editorInfoLeft:SetText(JoinBullets(
        Plural(CountWords(body), "word"),
        Plural(CountLines(body), "line"),
        links > 0 and Plural(links, "link") or ""
      ))
    end

    local function RefreshEditorChrome()
      UpdatePlaceholders()
      UpdateEditorInfo()
    end

    -- ── row pool ──────────────────────────────────────────────────────────
    local ROW_H  = 60
    local rowPool = {}

    -- Build one row widget. Scripts are set here once; data is patched in RebuildList.
    local function AcquireRow(i)
      local r = rowPool[i]
      if r then return r end

      r = CreateFrame("Button", nil, listContent, "BackdropTemplate")
      r:SetHeight(ROW_H)
      r:RegisterForClicks("LeftButtonUp", "RightButtonUp")
      r:SetBackdrop({ bgFile = "Interface/Buttons/WHITE8x8" })
      r:SetBackdropColor(0, 0, 0, 0)

      r.bg = r:CreateTexture(nil, "BACKGROUND")
      r.bg:SetAllPoints(r)
      r.bg:SetTexture("Interface/Buttons/WHITE8x8")
      r.bg:SetVertexColor(acR, acG, acB, acA)
      r.bg:SetAlpha(0)

      r.bar = r:CreateTexture(nil, "ARTWORK")
      r.bar:SetTexture("Interface/Buttons/WHITE8x8")
      r.bar:SetPoint("TOPLEFT",    0, -4)
      r.bar:SetPoint("BOTTOMLEFT", 0,  4)
      r.bar:SetWidth(2)
      r.bar:SetVertexColor(acR, acG, acB, acA)
      r.bar:SetAlpha(0)

      if r.SetHighlightTexture then
        r:SetHighlightTexture("Interface\\QuestFrame\\UI-QuestTitleHighlight", "ADD")
        local hl = r:GetHighlightTexture()
        if hl then
          hl:ClearAllPoints()
          hl:SetPoint("TOPLEFT",     r, "TOPLEFT",     6,  0)
          hl:SetPoint("BOTTOMRIGHT", r, "BOTTOMRIGHT", -6, 0)
          if hl.SetTexCoord    then hl:SetTexCoord(0.02, 0.98, 0, 1) end
          if hl.SetDesaturation then hl:SetDesaturation(1)
          elseif hl.SetDesaturated then hl:SetDesaturated(true) end
          hl:SetVertexColor(acR, acG, acB, 0.22)
        end
      end

      r.title = r:CreateFontString(nil, "OVERLAY", "WF_GameFontHighlightSmall")
      r.title:SetPoint("TOPLEFT",  12, -7)
      r.title:SetPoint("TOPRIGHT", -70, -7)
      r.title:SetJustifyH("LEFT")
      r.title:SetTextColor(C4(TEXT, TEXT))
      if r.title.SetMaxLines then r.title:SetMaxLines(1) end

      r.time = r:CreateFontString(nil, "OVERLAY", "WF_GameFontDisableSmall")
      r.time:SetPoint("TOPRIGHT", -10, -7)
      r.time:SetJustifyH("RIGHT")
      r.time:SetTextColor(C4(MUTED, MUTED))

      r.preview = r:CreateFontString(nil, "OVERLAY", "WF_GameFontDisableSmall")
      r.preview:SetPoint("BOTTOMLEFT",  12, 18)
      r.preview:SetPoint("BOTTOMRIGHT", -10, 18)
      r.preview:SetJustifyH("LEFT")
      r.preview:SetTextColor(C4(MUTED, MUTED))
      if r.preview.SetMaxLines then r.preview:SetMaxLines(1) end

      r.sep = r:CreateTexture(nil, "ARTWORK")
      r.sep:SetTexture("Interface/Buttons/WHITE8x8")
      r.sep:SetPoint("BOTTOMLEFT",  10, 0)
      r.sep:SetPoint("BOTTOMRIGHT", -10, 0)
      r.sep:SetHeight((Widgets and Widgets.PixelSize) and Widgets:PixelSize(r) or 1)
      if Widgets and Widgets.MakePixelPerfect then Widgets:MakePixelPerfect(r.sep, r) end
      r.sep:SetAlpha(0.05)

      -- hover highlight (selected state is reflected via _selected on the button)
      r:SetScript("OnEnter", function(btn)
        if not btn._selected then btn.bg:SetAlpha(0.05) end
      end)
      r:SetScript("OnLeave", function(btn)
        if btn._selected then
          btn.bg:SetAlpha(0.11); btn.bar:SetAlpha(0.95)
        else
          btn.bg:SetAlpha(0); btn.bar:SetAlpha(0)
        end
      end)

      -- OnClick is set once here; _noteId is patched before Show() in RebuildList.
      r:SetScript("OnClick", function(btn, mouseButton)
        if mouseButton == "RightButton" then
          CommitActive(false)
          local d2 = cached or GetNotepadData()
          if d2.activeId ~= btn._noteId then
            d2.activeId = btn._noteId
            SaveNotepadData(d2)
            cached = d2
            FillEditorFromActive()
            RebuildList()
          end
          ShowRowMenu(btn._noteId, btn)
          return
        end
        SelectNoteById(btn._noteId, true)
      end)

      rowPool[i] = r
      return r
    end

    local function ClearRows(from)
      for i = from, #rowPool do
        local r = rowPool[i]
        if r then
          r:Hide()
          r._noteId   = nil
          r._selected = nil
          r.bg:SetAlpha(0)
          r.bar:SetAlpha(0)
        end
      end
    end

    -- ── link insertion ────────────────────────────────────────────────────

    local function CanAcceptLinks()
      return pNotepad:IsShown()
         and (activeEdit == titleEdit or activeEdit == bodyEdit)
    end

    local function NormalizeLink(linkText, displayText)
      local function HasMarkup(v)
        return type(v) == "string" and v:find("|H", 1, true) and v:find("|h", 1, true)
      end

      if HasMarkup(displayText) then return displayText end
      if HasMarkup(linkText)    then return linkText    end

      local raw = (type(linkText)    == "string" and linkText    ~= "" and linkText)
               or (type(displayText) == "string" and displayText ~= "" and displayText)
      if not raw then return nil end

      local linkType = raw:match("^([^:]+):")
      if linkType == "item" or linkType == "battlepet"
          or linkType == "keystone" or linkType == "currency" then
        local _, itemLink = GetItemInfo(raw)
        if type(itemLink) == "string" and itemLink ~= "" then return itemLink end
      end

      local lbl = (type(displayText) == "string" and displayText ~= "" and displayText)
               or (raw:match("^%b[]$") and raw)
               or ("[" .. raw .. "]")
      if not lbl:match("^%b[]$") then
        lbl = "[" .. lbl:gsub("^%s+",""):gsub("%s+$","") .. "]"
      end
      return "|H" .. raw .. "|h" .. lbl .. "|h"
    end

    -- Dedupe guard: ignore a second identical insert within 120 ms (double-fire
    -- from hooksecurefunc firing on both ChatEdit_InsertLink and HandleModifiedItemClick).
    local lastInsertText = nil
    local lastInsertAt   = 0

    local function InsertLink(linkText, displayText)
      if not CanAcceptLinks() then return false end
      local text = NormalizeLink(linkText, displayText)
      if type(text) ~= "string" or text == "" then return false end

      local now = GetTime and GetTime() or 0
      if text == lastInsertText and (now - lastInsertAt) <= 0.12 then return false end

      local target = (activeEdit == titleEdit or activeEdit == bodyEdit)
                     and activeEdit or bodyEdit
      target:SetFocus()
      target:Insert(text)
      activeEdit      = target
      lastInsertText  = text
      lastInsertAt    = now

      QueueSave()
      return true
    end

    local function TryInsertCursorLink()
      if not GetCursorInfo then return false end

      local kind, id, link = GetCursorInfo()
      if type(kind) ~= "string" or kind == "" then return false end

      local text = nil
      if kind == "item" then
        text = (type(link) == "string" and link ~= "" and link)
            or (id and ("item:" .. tostring(id)))
      elseif kind == "currency" then
        text = (type(link) == "string" and link ~= "" and link)
            or (id and ("currency:" .. tostring(id)))
      elseif kind == "battlepet" then
        text = (type(link) == "string" and link ~= "" and link)
            or (id and ("battlepet:" .. tostring(id)))
      end

      if type(text) ~= "string" or text == "" then return false end
      if not InsertLink(text, text) then return false end

      if ClearCursor then ClearCursor() end
      return true
    end

    local function SafeHookGlobal(name, callback)
      if type(name) ~= "string" or name == "" then return false end
      if type(callback) ~= "function" then return false end
      if type(hooksecurefunc) ~= "function" then return false end

      local ok = pcall(hooksecurefunc, name, callback)
      return ok and true or false
    end

    local function InstallLinkHooks()
      if _linkHooksInstalled then return end
      _linkHooksInstalled = true

      -- Do not read ChatEdit_InsertLink or HandleModifiedItemClick directly here.
      -- TSM/other addons can taint those globals before this page is created, and
      -- direct reads during HelpWindow creation create noisy taint-log entries.
      -- Hooks are installed lazily only after the user focuses a notepad editor.
      SafeHookGlobal("ChatEdit_InsertLink", function(lt)
        InsertLink(lt, lt)
      end)

      SafeHookGlobal("HandleModifiedItemClick", function(lt)
        InsertLink(lt, nil)
      end)
    end

    -- ── hyperlink tooltip ─────────────────────────────────────────────────

    local function ShowLinkTooltip(self, linkData)
      if not GameTooltip or type(linkData) ~= "string" or linkData == "" then return end
      GameTooltip:SetOwner(self, "ANCHOR_CURSOR")
      if GameTooltip.SetHyperlink then
        local ok = pcall(GameTooltip.SetHyperlink, GameTooltip, linkData)
        if not ok then GameTooltip:Hide(); return end
      else
        GameTooltip:Hide(); return
      end
      GameTooltip:Show()
    end

    local function HideLinkTooltip()
      if GameTooltip then GameTooltip:Hide() end
    end

    -- ── context menu (row right-click) ────────────────────────────────────

    local function GetMenuAnchor(frame)
      if not (frame and frame.GetRight and frame.GetTop) then return nil, nil end
      local fs = frame:GetEffectiveScale()   or 1
      local ps = UIParent:GetEffectiveScale() or 1
      if fs <= 0 then fs = 1 end
      if ps <= 0 then ps = 1 end
      return ((frame:GetRight() or 0) * fs / ps) + 8,
             ((frame:GetTop()   or 0) * fs / ps)
    end

    HideRowMenu = function()
      rowMenuId = nil
      if UI and UI.CloseContextMenu then UI:CloseContextMenu() end
    end

    ShowRowMenu = function(noteId, anchorFrame)
      if not noteId then HideRowMenu(); return end
      if rowMenuId == noteId and UI and UI._ctx
          and UI._ctx.IsShown and UI._ctx:IsShown() then
        HideRowMenu(); return
      end
      rowMenuId = noteId
      if UI and UI.OpenContextMenu then
        local ax, ay = GetMenuAnchor(anchorFrame)
        UI:OpenContextMenu({
          { text = "Open",      fn = function() local id = rowMenuId; if id then SelectNoteById(id, true) end end },
          { text = "Duplicate", fn = function() local id = rowMenuId; if id then DuplicateNoteById(id)    end end },
          { sep  = true },
          { text = "Delete",    fn = function() local id = rowMenuId; if id then DeleteNoteById(id)       end end },
        }, 170, ax, ay)
      end
    end

    -- ── core operations ───────────────────────────────────────────────────

    CommitActive = function(force)
      local d = cached or GetNotepadData()
      local n = GetActiveNote(d)
      if not n then return end

      local title = NormalizeNewlines(titleEdit:GetText() or "")
      local body  = NormalizeNewlines(bodyEdit:GetText()  or "")
      if title == "" then title = "Untitled" end

      local changed = (n.title ~= title) or (n.body ~= body)
      if changed or force then
        n.title     = title
        n.body      = body
        n.updatedAt = time()
        SaveNotepadData(d)
        cached = d
        MarkSaved(n.updatedAt)
        UpdateEditorMeta()
      else
        MarkSaved(tonumber(n.updatedAt) or time())
      end
    end

    FillEditorFromActive = function()
      local d = cached or GetNotepadData()
      local n = GetActiveNote(d)
      setting = true
      titleEdit:SetText(n and n.title or "")
      bodyEdit:SetText(n  and n.body  or "")
      setting = false
      UpdateEditorMeta()
      RefreshEditorChrome()
    end

    RebuildList = function()
      HideRowMenu()
      local d = cached or GetNotepadData()
      SortNotes(d)
      cached = d

      EnsureListWidth()

      local needle  = SearchableText(searchStr)
      local visible = {}

      for i = 1, #d.notes do
        local n = d.notes[i]
        if needle == ""
            or SearchableText(n and n.title or ""):find(needle, 1, true)
            or SearchableText(n and n.body  or ""):find(needle, 1, true) then
          visible[#visible + 1] = n
        end
      end

      listCount:SetText(needle ~= ""
        and string.format("%d shown / %d total", #visible, #d.notes)
        or  string.format("%d notes", #d.notes))

      emptyListText:Hide()
      if #visible == 0 then
        ClearRows(1)
        listContent:SetHeight(1)
        emptyListText:SetText("No notes match this search.")
        emptyListText:Show()
        return
      end

      for i = 1, #visible do
        local n = visible[i]
        local r = AcquireRow(i)

        r._noteId   = n.id
        r._selected = (n.id == d.activeId)

        r:Show()
        r:ClearAllPoints()
        r:SetPoint("TOPLEFT",  listContent, "TOPLEFT",  0, -((i - 1) * ROW_H))
        r:SetPoint("TOPRIGHT", listContent, "TOPRIGHT", 0, -((i - 1) * ROW_H))

        r.title:SetText(TruncOneLine(n.title or "Untitled", 34))
        r.preview:SetText(PreviewText(n.body))
        r.time:SetText(FormatRowStamp(n.updatedAt))

        r.bg:SetAlpha( r._selected and 0.11 or 0)
        r.bar:SetAlpha(r._selected and 0.95 or 0)
      end

      ClearRows(#visible + 1)
      listContent:SetHeight(math.max(1, #visible * ROW_H))
      EnsureListWidth()
    end

    SelectNoteById = function(noteId, focusBody)
      if not noteId then return end
      HideRowMenu()
      CommitActive(false)

      local d = cached or GetNotepadData()
      local _, note = FindNoteById(d, noteId)
      if not note then return end

      d.activeId = noteId
      SaveNotepadData(d)
      cached = d
      FillEditorFromActive()
      RebuildList()

      if focusBody and bodyEdit.SetFocus then
        bodyEdit:SetFocus()
      end
    end

    DuplicateNoteById = function(noteId)
      if not noteId then return end
      HideRowMenu()
      CommitActive(false)

      local d = cached or GetNotepadData()
      local _, note = FindNoteById(d, noteId)
      if not note then return end

      local id = NewNoteId()
      local ts = time()
      table.insert(d.notes, 1, {
        id = id,
        title = (note.title or "Note") .. " (Copy)",
        body  = note.body or "",
        createdAt = ts, updatedAt = ts,
      })
      d.activeId = id
      SaveNotepadData(d)
      cached = d
      FillEditorFromActive()
      RebuildList()
      MarkSaved(ts)

      titleEdit:SetFocus()
      if titleEdit.HighlightText then titleEdit:HighlightText() end
    end

    DeleteNoteById = function(noteId)
      if not noteId then return end
      HideRowMenu()
      CommitActive(false)

      local d   = cached or GetNotepadData()
      local idx, note = FindNoteById(d, noteId)
      if not idx or not note then return end

      local function DoDelete()
        if #d.notes <= 1 then
          -- always keep at least one note; just clear it
          note.title     = "My Note"
          note.body      = ""
          note.updatedAt = time()
          d.activeId     = note.id
        else
          local deletingActive = (d.activeId == noteId)
          table.remove(d.notes, idx)
          if deletingActive or not FindNoteById(d, d.activeId) then
            d.activeId = d.notes[1] and d.notes[1].id or nil
          end
        end
        SaveNotepadData(d)
        cached = d
        FillEditorFromActive()
        RebuildList()
        MarkSaved(time())
      end

      if UI and UI.Confirm then
        UI:Confirm("Delete note", "Delete this note? This cannot be undone.", DoDelete)
      else
        DoDelete()
      end
    end

    QueueSave = function()
      if setting       then return end
      if savePending   then return end
      savePending = true
      MarkSaving()
      C_Timer.After(0.30, function()
        savePending = false
        if not pNotepad:IsShown() then return end
        CommitActive(false)
        RebuildList()
      end)
    end

    -- ── edit box scripts ──────────────────────────────────────────────────

    titleEdit:SetScript("OnEscapePressed", function(self) self:ClearFocus() end)
    titleEdit:SetScript("OnEnterPressed",  function(self) self:ClearFocus() end)
    titleEdit:SetScript("OnMouseDown", function(self, btn)
      HideRowMenu()
      self:SetFocus()
      if EditBox_OnMouseDown then EditBox_OnMouseDown(self, btn) end
    end)
    titleEdit:SetScript("OnMouseUp", function(self, btn)
      activeEdit = self
      if TryInsertCursorLink() then return end
      if EditBox_OnMouseUp then EditBox_OnMouseUp(self, btn) end
    end)
    titleEdit:HookScript("OnEditFocusGained", function(self)
      activeEdit = self
      InstallLinkHooks()
    end)
    titleEdit:SetScript("OnTextChanged", function()
      if setting then return end
      RefreshEditorChrome()
      QueueSave()
    end)
    if titleEdit.SetHyperlinksEnabled then titleEdit:SetHyperlinksEnabled(true) end
    titleEdit:SetScript("OnHyperlinkEnter", ShowLinkTooltip)
    titleEdit:SetScript("OnHyperlinkLeave", HideLinkTooltip)
    titleEdit:SetScript("OnReceiveDrag", function(self)
      activeEdit = self
      TryInsertCursorLink()
    end)

    bodyEdit:SetScript("OnEscapePressed", function(self) self:ClearFocus() end)
    bodyEdit:SetScript("OnTabPressed",    function(self) self:Insert("    ") end)
    bodyEdit:SetScript("OnMouseDown", function(self, btn)
      HideRowMenu()
      self:SetFocus()
      if EditBox_OnMouseDown then EditBox_OnMouseDown(self, btn) end
    end)
    bodyEdit:SetScript("OnMouseUp", function(self, btn)
      activeEdit = self
      if TryInsertCursorLink() then return end
      if EditBox_OnMouseUp then EditBox_OnMouseUp(self, btn) end
    end)
    bodyEdit:HookScript("OnEditFocusGained", function(self)
      activeEdit = self
      InstallLinkHooks()
    end)
    bodyEdit:SetScript("OnTextChanged", function()
      if setting then return end
      RefreshEditorChrome()
      QueueSave()
    end)
    if bodyEdit.SetHyperlinksEnabled then bodyEdit:SetHyperlinksEnabled(true) end
    bodyEdit:SetScript("OnHyperlinkEnter", ShowLinkTooltip)
    bodyEdit:SetScript("OnHyperlinkLeave", HideLinkTooltip)
    bodyEdit:SetScript("OnReceiveDrag", function(self)
      activeEdit = self
      TryInsertCursorLink()
    end)

    searchEdit:SetScript("OnEscapePressed", function(self)
      if (self:GetText() or "") ~= "" then self:SetText("") else self:ClearFocus() end
    end)
    searchEdit:SetScript("OnEnterPressed", function(self) self:ClearFocus() end)
    searchEdit:SetScript("OnMouseDown", function(self, btn)
      self:SetFocus()
      if EditBox_OnMouseDown then EditBox_OnMouseDown(self, btn) end
    end)
    searchEdit:SetScript("OnMouseUp", function(self, btn)
      if EditBox_OnMouseUp then EditBox_OnMouseUp(self, btn) end
    end)
    searchEdit:SetScript("OnEditFocusGained", function()
      HideRowMenu()
      searchHint:Hide()
    end)
    searchEdit:SetScript("OnEditFocusLost", function() UpdatePlaceholders() end)
    searchEdit:SetScript("OnTextChanged", function(self)
      if searchSetting then return end
      searchStr = NormalizeNewlines(self:GetText() or "")
      UpdatePlaceholders()
      HideRowMenu()
      RebuildList()
    end)

    -- ── button scripts ────────────────────────────────────────────────────

    bSave:SetScript("OnClick", function()
      HideRowMenu()
      CommitActive(true)
      RebuildList()
    end)

    bNew:SetScript("OnClick", function()
      HideRowMenu()
      CommitActive(false)
      local d  = cached or GetNotepadData()
      local id = NewNoteId()
      local ts = time()
      table.insert(d.notes, 1, {
        id = id, title = "New Note", body = "",
        createdAt = ts, updatedAt = ts,
      })
      d.activeId = id
      SaveNotepadData(d)
      cached = d
      FillEditorFromActive()
      RebuildList()
      MarkSaved(ts)
      titleEdit:SetFocus()
      if titleEdit.HighlightText then titleEdit:HighlightText() end
    end)

    bDelete:SetScript("OnClick", function()
      HideRowMenu()
      local d = cached or GetNotepadData()
      local n = GetActiveNote(d)
      if n then DeleteNoteById(n.id) end
    end)

    -- ── page lifecycle ────────────────────────────────────────────────────

    pNotepad:HookScript("OnShow", function()
      HideRowMenu()
      cached = GetNotepadData()
      LayoutContent()
      EnsureListWidth()

      searchSetting = true
      searchEdit:SetText(searchStr)
      searchSetting = false
      UpdatePlaceholders()

      FillEditorFromActive()
      RebuildList()
      MarkSaved(tonumber((GetActiveNote(cached) or {}).updatedAt) or time())
    end)

    -- ── expose layout hints for HelpWindow ───────────────────────────────
    UI._layout         = UI._layout or {}
    UI._layout.notepad = {
      page = pNotepad,
      left = shimLeft,
      right = shimRight,
      editorPanel = editorPanel,
    }
  end
end
