-- =====================================================================
-- LiveStrat — Board.lua
-- La fenêtre-tableau : carte de la salle en fond, rendu et modèle des
-- opérations vectorielles, entrée souris selon l'outil actif.
--
-- COORDONNÉES : tout est stocké en millièmes (0-1000) de la surface du
-- tableau — compact à sérialiser, indépendant de la taille des fenêtres
-- de chacun.
--
-- OPÉRATIONS (sérialisation "champ:champ:…", points "x,y;x,y") :
--   P:<id>:<coul>:<points…>      crayon (polyligne)
--   N:<id>:<coul>:<x1,y1;x2,y2>  trait droit
--   A:<id>:<coul>:<x1,y1;x2,y2>  flèche (trait + tête)
--   C:<id>:<coul>:<cx,cy;bord>   cercle (centre ; point du bord)
--   T:<id>:<coul>:<x,y>:<texte>  étiquette
--   M:<id>:<rôle>:<x,y>:<texte>  marqueur (1 tank, 2 heal, 3 dps) + nom
-- =====================================================================
local ADDON, ns = ...
local L = ns.L

local Board = {}
ns.modules.Board = Board

local CANVAS_W, CANVAS_H = 660, 460

-- Palette (indexée pour la sérialisation compacte)
Board.COLORS = {
    { 1.00, 0.25, 0.25 },  -- 1 rouge
    { 0.25, 0.60, 1.00 },  -- 2 bleu
    { 0.30, 0.95, 0.40 },  -- 3 vert
    { 1.00, 0.85, 0.20 },  -- 4 jaune
    { 0.95, 0.45, 0.90 },  -- 5 rose
    { 1.00, 1.00, 1.00 },  -- 6 blanc
    { 1.00, 0.60, 0.20 },  -- 7 orange
    { 0.15, 0.15, 0.15 },  -- 8 noir
}

-- Rôles des marqueurs : icône portrait classique (SetTexCoord éprouvé).
local ROLE_TEXCOORD = {
    [1] = { 0, 19 / 64, 22 / 64, 41 / 64 },       -- tank
    [2] = { 20 / 64, 39 / 64, 1 / 64, 20 / 64 },  -- soigneur
    [3] = { 20 / 64, 39 / 64, 22 / 64, 41 / 64 }, -- dps
}

-- ---------------------------------------------------------------------
-- VUE (zoom/panoramique) — déclarée AVANT toute fonction qui s'en sert
-- (portée Lua). Les coordonnées STOCKÉES restent en millièmes de la
-- carte : la vue n'est qu'un rendu local, chacun zoome comme il veut.
-- ---------------------------------------------------------------------
local view = { zoom = 1, ox = 0, oy = 0 }   -- offsets en pixels zoomés

local function clampView()
    if view.zoom < 1 then view.zoom = 1 end
    if view.zoom > 5 then view.zoom = 5 end
    local maxX = CANVAS_W * (view.zoom - 1)
    local maxY = CANVAS_H * (view.zoom - 1)
    if view.ox < 0 then view.ox = 0 elseif view.ox > maxX then view.ox = maxX end
    if view.oy < 0 then view.oy = 0 elseif view.oy > maxY then view.oy = maxY end
end

local function cursorToBoard(canvas)
    local x, y = GetCursorPosition()
    local s = canvas:GetEffectiveScale()
    x, y = x / s, y / s
    local l, t = canvas:GetLeft(), canvas:GetTop()
    local bx = math.floor((x - l + view.ox) / (CANVAS_W * view.zoom) * 1000 + 0.5)
    local by = math.floor((t - y + view.oy) / (CANVAS_H * view.zoom) * 1000 + 0.5)
    if bx < 0 then bx = 0 elseif bx > 1000 then bx = 1000 end
    if by < 0 then by = 0 elseif by > 1000 then by = 1000 end
    return bx, by
end

local function toPixels(bx, by)
    return bx / 1000 * CANVAS_W * view.zoom - view.ox,
        -(by / 1000 * CANVAS_H * view.zoom) + view.oy
end

-- ---------------------------------------------------------------------
-- Fenêtre
-- ---------------------------------------------------------------------
function Board:IsShown()
    return self.frame and self.frame:IsShown() or false
end

function Board:Toggle()
    if not self.frame then self:_create() end
    if self.frame:IsShown() then
        self.frame:Hide()
        ns.modules.Sync:SendViewing(false)
    else
        self.frame:Show()
        ns.modules.Sync:SendViewing(true)
        -- AUTO-DÉTECTION : en instance → la bonne instance, le bon
        -- étage, et le boss de l'étage s'il y en a un. Sinon : la zone.
        if IsInInstance() then
            self:AutoDetect()
        elseif not self._mapID then
            self:SetMap(C_Map.GetBestMapForUnit("player"))
        end
        self:UpdatePermissionUI()
        if IsInGroup() then
            ns.modules.Sync:Census()   -- qui a l'addon ? (pastilles)
            if not UnitIsGroupLeader("player") then
                ns.modules.Sync:RequestBoard()
            end
        end
    end
end

function Board:_create()
    -- CADRE BLIZZARD MODERNE : fenêtre à portrait (même squelette que
    -- les fenêtres officielles — journal, collections…), encart sombre
    -- pour la carte, titre natif, croix native.
    local f = CreateFrame("Frame", "LiveStratBoard", UIParent, "PortraitFrameTemplate")
    f:SetSize(CANVAS_W + 348, CANVAS_H + 96)
    f:SetPoint("CENTER")
    f:SetMovable(true)
    f:EnableMouse(true)
    f:RegisterForDrag("LeftButton")
    f:SetScript("OnDragStart", f.StartMoving)
    f:SetScript("OnDragStop", f.StopMovingOrSizing)
    f:SetClampedToScreen(true)
    f:SetFrameStrata("HIGH")
    tinsert(UISpecialFrames, "LiveStratBoard")   -- Échap ferme
    pcall(function()
        f:SetTitle("LiveStrat — " .. L["Tableau tactique"])
        f:SetPortraitToAsset("Interface\\AddOns\\LiveStrat\\Portrait.png")
    end)

    -- Barre de SÉLECTION : instance puis boss (Journal des rencontres).
    f.instBtn = self:_dropdown(f, 190, function() return self:_instanceMenu() end)
    f.instBtn:SetPoint("TOPLEFT", 168, -32)
    f.bossBtn = self:_dropdown(f, 190, function() return self:_bossMenu() end)
    f.bossBtn:SetPoint("LEFT", f.instBtn, "RIGHT", 8, 0)
    f.instBtn.text:SetText(L["Instance…"])
    f.bossBtn.text:SetText(L["Boss…"])

    -- COLONNE DE GAUCHE : la « régie » — qui regarde le tableau en
    -- direct, qui a l'addon, et les droits d'édition à un clic.
    local side = CreateFrame("Frame", nil, f, "InsetFrameTemplate")
    side:SetPoint("TOPLEFT", 8, -60)
    side:SetSize(148, CANVAS_H + 8)
    f.side = side
    side.title = side:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    side.title:SetPoint("TOPLEFT", 8, -8)
    side.title:SetText(L["En direct"])
    side.hint = side:CreateFontString(nil, "OVERLAY", "GameFontDisableSmall")
    side.hint:SetPoint("BOTTOMLEFT", 8, 8)
    side.hint:SetPoint("BOTTOMRIGHT", -8, 8)
    side.hint:SetJustifyH("LEFT")
    side.rows = {}

    -- PAGES DE PHASES : P1-P4 par boss — un plan par phase, la bascule
    -- du chef est suivie par tout le groupe.
    self._page = 1
    f.pageLabel = f:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    f.pageLabel:SetPoint("LEFT", f.bossBtn, "RIGHT", 14, 0)
    f.pageLabel:SetText(L["Phases :"])
    f.pageBtns = {}
    local prevPage
    for p = 1, 4 do
        local pb = ns.CreateModernButton(f, 26, 24, tostring(p))
        if prevPage then pb:SetPoint("LEFT", prevPage, "RIGHT", 4, 0)
        else pb:SetPoint("LEFT", f.pageLabel, "RIGHT", 8, 0) end
        pb:SetScript("OnClick", function() Board:SwitchPage(p) end)
        f.pageBtns[p] = pb
        prevPage = pb
    end

    -- MODE COMBAT (compact) : bouton réduire/agrandir à côté de la croix.
    f.compactBtn = ns.CreateModernButton(f, 26, 20, "><")
    f.compactBtn:SetPoint("TOPRIGHT", -34, -28)
    f.compactBtn:SetScript("OnClick", function()
        Board:SetCompact(not Board._compact)
    end)
    f.compactBtn:SetScript("OnEnter", function(s)
        s._hover = true s:Repaint()
        GameTooltip:SetOwner(s, "ANCHOR_LEFT")
        GameTooltip:SetText(L["Mode combat : carte seule, fenêtre réduite."])
        GameTooltip:Show()
    end)
    f.compactBtn:SetScript("OnLeave", function(s)
        s._hover = nil s:Repaint()
        GameTooltip:Hide()
    end)

    -- Encart sombre façon Blizzard autour de la carte.
    local inset = CreateFrame("Frame", nil, f, "InsetFrameTemplate")
    inset:SetPoint("TOPLEFT", 160, -60)
    inset:SetSize(CANVAS_W + 8, CANVAS_H + 8)
    f.inset = inset

    -- Zone de dessin (la carte de la salle en dessous, les traits dessus)
    local c = CreateFrame("Frame", nil, f)
    c:SetSize(CANVAS_W, CANVAS_H)
    c:SetPoint("TOPLEFT", inset, 4, -4)
    c:SetClipsChildren(true)
    f.canvas = c

    -- ⚠️ SetClipsChildren ne rogne QUE les cadres ENFANTS — les régions
    -- posées sur le canvas lui-même débordent (vécu : la carte zoomée
    -- recouvrait le cadre). TOUT le rendu vit donc sur `content`.
    local content = CreateFrame("Frame", nil, c)
    content:SetAllPoints(c)
    c.content = content

    c.bg = content:CreateTexture(nil, "BACKGROUND")
    c.bg:SetAllPoints()
    c.bg:SetColorTexture(0.10, 0.11, 0.13, 1)
    c.tiles = {}

    c:EnableMouse(true)
    c:SetScript("OnMouseDown", function(_, btn) Board:_onMouseDown(btn) end)
    c:SetScript("OnMouseUp", function(_, btn) Board:_onMouseUp(btn) end)
    c:SetScript("OnUpdate", function() Board:_onUpdate() end)
    -- ZOOM à la molette, CENTRÉ sur le curseur ; Maj-glisser = déplacer
    -- la vue. (logique dans _onWheel : les locales de vue sont déclarées
    -- plus bas dans le fichier — portée Lua)
    c:SetScript("OnMouseWheel", function(_, delta) Board:_onWheel(delta) end)

    -- POIGNÉE de redimensionnement (coin bas-droit) : tirer = changer
    -- l'ÉCHELLE de toute la fenêtre (le contenu suit), mémorisée par mode.
    local grip = CreateFrame("Button", nil, f)
    grip:SetSize(16, 16)
    grip:SetPoint("BOTTOMRIGHT", -4, 4)
    grip:SetFrameLevel(f:GetFrameLevel() + 20)   -- au-dessus de la carte
    grip:SetNormalTexture("Interface\\ChatFrame\\UI-ChatIM-SizeGrabber-Up")
    grip:SetHighlightTexture("Interface\\ChatFrame\\UI-ChatIM-SizeGrabber-Down")
    grip:SetScript("OnMouseDown", function(g)
        g._startX = GetCursorPosition()
        g._startScale = f:GetScale()
        g:SetScript("OnUpdate", function()
            local x = GetCursorPosition()
            local sc = g._startScale * (1 + (x - g._startX) / 600)
            if sc < 0.45 then sc = 0.45 elseif sc > 1.6 then sc = 1.6 end
            f:SetScale(sc)
        end)
    end)
    grip:SetScript("OnMouseUp", function(g)
        g:SetScript("OnUpdate", nil)
        if Board._compact then LiveStratDB.scaleCompact = f:GetScale()
        else LiveStratDB.scaleFull = f:GetScale() end
    end)
    f.grip = grip

    -- Fermeture par la CROIX ou Échap (UISpecialFrames) : Toggle n'est
    -- pas appelé — l'annonce « je ne regarde plus » part d'ici.
    f:SetScript("OnHide", function()
        ns.modules.Sync:SendViewing(false)
    end)

    -- Ctrl+Z = annuler. (Propagation clavier protégée EN COMBAT → on ne
    -- touche à rien pendant ; le mode combat n'édite pas de toute façon.)
    f:EnableKeyboard(true)
    f:SetScript("OnKeyDown", function(_, key)
        if InCombatLockdown() then return end
        if key == "Z" and IsControlKeyDown() and not Board._compact then
            Board:Undo()
            pcall(f.SetPropagateKeyboardInput, f, false)
        else
            pcall(f.SetPropagateKeyboardInput, f, true)
        end
    end)
    pcall(f.SetPropagateKeyboardInput, f, true)

    self.frame = f
    self._linePool, self._texPool, self._fsPool = {}, {}, {}
    if ns.modules.Tools then ns.modules.Tools:Attach(f) end

    -- Tableau local sauvegardé pour cette carte ? (v1 : par uiMapID)
    self:_restoreLocal()
    -- Mode combat mémorisé (reload en plein raid)
    if LiveStratDB.compact then self:SetCompact(true) end
end

-- ---------------------------------------------------------------------
-- Menus déroulants maison (robustes à travers les refontes Blizzard) :
-- un bouton, une liste déroulée en dessous, fermée au choix/à l'Échap.
-- ---------------------------------------------------------------------
function Board:_dropdown(parent, width, itemsFn)
    local b = ns.CreateModernButton(parent, width, 24)
    b:SetScript("OnClick", function()
        if b.list and b.list:IsShown() then b.list:Hide() return end
        local items = itemsFn()
        if not items or #items == 0 then return end
        if not b.list then
            b.list = CreateFrame("Frame", nil, b, "BackdropTemplate")
            b.list:SetBackdrop({ bgFile = "Interface\\Buttons\\WHITE8x8",
                edgeFile = "Interface\\Buttons\\WHITE8x8", edgeSize = 1 })
            b.list:SetBackdropColor(0.06, 0.07, 0.09, 0.97)
            b.list:SetBackdropBorderColor(0.35, 0.35, 0.4, 1)
            b.list:SetFrameStrata("DIALOG")
            b.list.btns = {}
        end
        local list = b.list
        list:ClearAllPoints()
        list:SetPoint("TOPLEFT", b, "BOTTOMLEFT", 0, -2)
        local shown = math.min(#items, 18)
        list:SetSize(width + 40, shown * 20 + 8)
        for i, bt in ipairs(list.btns) do bt:Hide() end
        for i = 1, shown do
            local it = items[i]
            local bt = list.btns[i]
            if not bt then
                bt = CreateFrame("Button", nil, list)
                bt:SetHeight(20)
                bt:SetPoint("LEFT", 4, 0)
                bt:SetPoint("RIGHT", -4, 0)
                bt.fs = bt:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
                bt.fs:SetPoint("LEFT", 4, 0)
                bt.fs:SetJustifyH("LEFT")
                bt:SetHighlightTexture("Interface\\QuestFrame\\UI-QuestTitleHighlight")
                list.btns[i] = bt
            end
            bt:SetPoint("TOP", list, "TOP", 0, -4 - (i - 1) * 20)
            bt.fs:SetText(it.text)
            bt:SetScript("OnClick", function()
                list:Hide()
                if it.onClick then it.onClick() end
            end)
            bt:Show()
        end
        list:Show()
    end)
    return b
end

function Board:_instanceMenu()
    local items = {}
    for _, inst in ipairs(ns.modules.Bosses:Instances()) do
        items[#items + 1] = {
            text = (inst.raid and "|cffffd100" or "|cff40c8ff") .. inst.name .. "|r",
            onClick = function() self:SelectInstance(inst) end,
        }
    end
    return items
end

function Board:_bossMenu()
    if not self._instance then return {} end
    local items = {}
    for _, e in ipairs(ns.modules.Bosses:Encounters(self._instance.id)) do
        items[#items + 1] = {
            text = e.name .. (e.mapID and "" or " |cff777777(sans carte)|r"),
            onClick = function() self:SelectBoss(e) end,
        }
    end
    return items
end

-- ---------------------------------------------------------------------
-- Sélection instance/boss + tête de mort
-- ---------------------------------------------------------------------
function Board:SelectInstance(inst)
    self._instance = inst
    self._boss = nil
    self.frame.instBtn.text:SetText(inst.name)
    self.frame.bossBtn.text:SetText(L["Boss…"])
    -- première carte de l'instance en attendant le choix du boss
    local _, _, _, _, _, _, instMapID = EJ_GetInstanceInfo(inst.id)
    if instMapID and instMapID > 0 then self:SetMap(instMapID) end
    self:_broadcastContext()
end

function Board:SelectBoss(e)
    self._boss = e
    self.frame.bossBtn.text:SetText(e.name)
    if e.mapID then self:SetMap(e.mapID) end
    -- CARTE PARTAGÉE entre plusieurs boss (le cas courant : position
    -- excentrée, minuscule) → ZOOM AUTO ×2.6 centré sur le boss. Carte
    -- dédiée → vue entière. La molette/Maj-glisser restent maîtres.
    if e.x and self._instance then
        local sharing = 0
        for _, other in ipairs(ns.modules.Bosses:Encounters(self._instance.id)) do
            if other.mapID == e.mapID then sharing = sharing + 1 end
        end
        if sharing > 1 then
            self:SetView(2.6, e.x * 1000, e.y * 1000)
        else
            self:SetView(1)
        end
    else
        self:SetView(1)
    end
    -- CONTEXTE D'ABORD (vécu : le marqueur partait AVANT le changement
    -- de carte — chez les autres, la carte arrivait ensuite et EFFAÇAIT
    -- le marqueur ; clics rapides = clients à la traîne).
    self:_broadcastContext()
    -- Le boss devient un MARQUEUR NOMMÉ (déplaçable, effaçable,
    -- synchronisé comme tout le monde) — créé une seule fois par boss
    -- et par carte, à sa position officielle si le journal la connaît.
    if ns.CanDraw() and e.encID then
        local tag = "#B" .. e.encID
        local exists = false
        for id in pairs(ns.board.byId) do
            if id:sub(-#tag) == tag then exists = true break end
        end
        if not exists then
            local op = { type = "M", id = ns.me .. tag, author = ns.me,
                role = 4, text = e.name,
                points = { { math.floor((e.x or 0.5) * 1000),
                    math.floor((e.y or 0.5) * 1000) } } }
            self:ApplyOp(op)
            ns.modules.Sync:SendOp(self:_serializeLocal(op))
            self:_pushUndo({ op.id })
        end
    end
    self:_updateBossPin()
    self:_broadcastView()
end

function Board:SetMap(mapID)
    if self._mapID == mapID then return end
    self._mapID = mapID
    self._page = 1   -- nouvelle carte = phase 1
    self:ClearAll(true, true)
    self:_updateMap(mapID)
    self:_restoreLocal(mapID)
    self:_updateBossPin()
    self:UpdatePageUI()
end

-- ---------------------------------------------------------------------
-- Pages de phases : le tableau actif = (carte, page). La bascule d'un
-- AUTORISÉ est diffusée (PAG dans le contexte) et suivie du plan complet
-- de la page — les invités ne basculent pas eux-mêmes (ils suivent).
-- ---------------------------------------------------------------------
function Board:SwitchPage(p, remote)
    if p == self._page then return end
    if not remote then
        if IsInGroup() and not ns.CanDraw() then
            UIErrorsFrame:AddMessage(L["Seul le chef (ou un joueur autorisé) peut changer de phase."], 1, 0.4, 0.4)
            return
        end
        self:_saveLocal()
    end
    self._page = p
    self:ClearAll(true, true)
    if not remote then
        self:_restoreLocal()
        -- la page du chef part avec son contenu (PAG implicite via MAP)
        if IsInGroup() and ns.CanDraw() then
            self:_broadcastContext()
            ns.modules.Sync:SendFullBoard()
        end
    end
    self:_rerenderAll()
    self:UpdatePageUI()
end

function Board:UpdatePageUI()
    if not (self.frame and self.frame.pageBtns) then return end
    for p, pb in ipairs(self.frame.pageBtns) do
        pb:SetActive(p == self._page)
    end
end

-- ---------------------------------------------------------------------
-- MODE COMBAT (compact) : carte seule, fenêtre réduite (échelle 0.72),
-- colonnes et sélecteurs masqués. Les boutons de PHASES restent visibles
-- pour le chef/les autorisés — bascule de plan en plein combat.
-- ---------------------------------------------------------------------
function Board:SetCompact(on)
    local f = self.frame
    if not f then return end
    self._compact = on and true or false
    LiveStratDB.compact = self._compact or nil
    local T = ns.modules.Tools
    f.inset:ClearAllPoints()
    if self._compact then
        f.side:Hide()
        if T and T.col then T.col:Hide() end
        f.instBtn:Hide()
        f.bossBtn:Hide()
        f.inset:SetPoint("TOPLEFT", 8, -60)
        f:SetSize(CANVAS_W + 24, CANVAS_H + 76)
        f:SetScale(LiveStratDB.scaleCompact or 0.72)
        f.pageLabel:ClearAllPoints()
        f.pageLabel:SetPoint("TOPLEFT", 14, -38)
        f.compactBtn.text:SetText("<>")
    else
        f.side:Show()
        if T and T.col then T.col:Show() end
        f.instBtn:Show()
        f.bossBtn:Show()
        f.inset:SetPoint("TOPLEFT", 160, -60)
        f:SetSize(CANVAS_W + 348, CANVAS_H + 96)
        f:SetScale(LiveStratDB.scaleFull or 1)
        f.pageLabel:ClearAllPoints()
        f.pageLabel:SetPoint("LEFT", f.bossBtn, "RIGHT", 14, 0)
        f.compactBtn.text:SetText("><")
    end
    self:UpdateCompactUI()
end

-- Phases en compact : seulement pour qui peut dessiner (le raid suit).
-- En combat, MÊME le chef n'édite pas — il ne fait que changer de phase.
function Board:UpdateCompactUI()
    local f = self.frame
    if not f then return end
    local showPages = (not self._compact) or ns.CanDraw()
    f.pageLabel:SetShown(showPages)
    for _, pb in ipairs(f.pageBtns) do pb:SetShown(showPages) end
    local T = ns.modules.Tools
    if T and T.mini then T.mini:Hide() end
end

function Board:_updateBossPin()
    -- L epingle FANTOME est RETIREE (vecu : elle reapparaissait
    -- translucide sur les phases 2-4 — insaisissable et ineffacable,
    -- puisque simple texture-indice et pas un element du plan). Le boss
    -- est un VRAI marqueur nomme depuis la selection ; s il est efface,
    -- le tampon Boss le repose a la main.
    local c = self.frame and self.frame.canvas
    if c and c.bossPin then c.bossPin:Hide() end
end

-- CONTEXTE reçu du chef : instance/boss/étage imposés (l'invité voyait
-- les dessins SANS la carte, vécu). Pas de création de marqueur ni de
-- changement de vue ici — la vue arrive par VW, les ops par OP/FUL.
function Board:ApplyMapContext(mapID, instID, encID, page)
    if not self.frame then self:_create() end
    if instID and instID > 0 then
        for _, inst in ipairs(ns.modules.Bosses:Instances()) do
            if inst.id == instID then
                self._instance = inst
                self.frame.instBtn.text:SetText(inst.name)
                break
            end
        end
    end
    if encID and encID > 0 and self._instance then
        for _, e in ipairs(ns.modules.Bosses:Encounters(self._instance.id)) do
            if e.encID == encID then
                self._boss = e
                self.frame.bossBtn.text:SetText(e.name)
                break
            end
        end
    end
    if mapID and mapID > 0 and mapID ~= self._mapID then
        self:SetMap(mapID)
    end
    if page and page ~= self._page then
        self:SwitchPage(page, true)   -- remote : le contenu suit (FUL)
    end
    self:_updateBossPin()
end

-- Vue reçue du chef (zoom/panoramique suivis).
function Board:ApplyRemoteView(zoom, ox, oy)
    if not self.frame then return end
    view.zoom, view.ox, view.oy = zoom, ox, oy
    clampView()
    self:_rerenderAll()
end

-- Diffusion de MA vue (chef uniquement, débouncée — la molette rafale).
function Board:_broadcastView()
    if not (IsInGroup() and UnitIsGroupLeader("player")) then return end
    if self._viewPending then return end
    self._viewPending = true
    C_Timer.After(0.3, function()
        self._viewPending = nil
        ns.modules.Sync:SendView(view.zoom, view.ox, view.oy)
    end)
end

-- Diffusion du CONTEXTE (chef/autorisé, après sélection de carte/boss).
function Board:_broadcastContext()
    if IsInGroup() and ns.CanDraw() then
        ns.modules.Sync:SendMapContext()
    end
end

-- AUTO-DÉTECTION à l'ouverture / au pull.
function Board:AutoDetect(encounterID)
    local Bosses = ns.modules.Bosses
    local inst = Bosses:CurrentInstance()
    if not inst then return end
    self._instance = inst
    self.frame.instBtn.text:SetText(inst.name)
    if encounterID then
        local _, e = Bosses:FindByEncounterID(encounterID)
        if e then self:SelectBoss(e) return end
    end
    -- sans boss engagé : l'étage courant du joueur, et si un boss du
    -- journal vit sur cet étage, on le présélectionne.
    local here = C_Map.GetBestMapForUnit("player")
    if here then
        for _, e in ipairs(Bosses:Encounters(inst.id)) do
            if e.mapID == here then self:SelectBoss(e) return end
        end
        self:SetMap(here)
        self:_broadcastContext()
    end
end

-- ---------------------------------------------------------------------
-- Carte de fond (étage courant du joueur — les salles de boss ont leur
-- propre uiMapID d'étage sur les instances modernes)
-- ---------------------------------------------------------------------
function Board:_updateMap(mapID)
    local c = self.frame.canvas
    for _, t in ipairs(c.tiles) do t:Hide() end
    mapID = mapID or self._mapID
    if not mapID then return end
    local ok = pcall(function()
        local layers = C_Map.GetMapArtLayers(mapID)
        local layer = layers and layers[1]
        if not layer then return end
        local tex = C_Map.GetMapArtLayerTextures(mapID, 1)
        if not tex or #tex == 0 then return end
        local cols = math.ceil(layer.layerWidth / layer.tileWidth)
        local rows = math.ceil(layer.layerHeight / layer.tileHeight)
        local sx = CANVAS_W / layer.layerWidth * view.zoom
        local sy = CANVAS_H / layer.layerHeight * view.zoom
        local i = 0
        for r = 0, rows - 1 do
            for col = 0, cols - 1 do
                i = i + 1
                local fid = tex[i]
                if fid then
                    local t = c.tiles[i]
                    if not t then
                        t = c.content:CreateTexture(nil, "BORDER")
                        c.tiles[i] = t
                    end
                    t:SetTexture(fid)
                    t:SetSize(layer.tileWidth * sx, layer.tileHeight * sy)
                    t:SetPoint("TOPLEFT", col * layer.tileWidth * sx - view.ox,
                        -r * layer.tileHeight * sy + view.oy)
                    t:Show()
                end
            end
        end
    end)
    if not ok then ns.Log("carte indisponible pour", tostring(mapID)) end
end

-- ---------------------------------------------------------------------
-- Vue : recentrage programmatique (les locales de vue sont en tête de
-- fichier).
-- ---------------------------------------------------------------------
-- Recentre la vue sur un point (millièmes) à un zoom donné.
function Board:SetView(zoom, bx, by)
    view.zoom = zoom or 1
    if bx then
        view.ox = bx / 1000 * CANVAS_W * view.zoom - CANVAS_W / 2
        view.oy = by / 1000 * CANVAS_H * view.zoom - CANVAS_H / 2
    else
        view.ox, view.oy = 0, 0
    end
    clampView()
    self:_rerenderAll()
end

function Board:_rerenderAll()
    if not self.frame then return end
    self:_updateMap()
    for _, op in ipairs(ns.board.ops) do self:_render(op) end
    self:_updateBossPin()
end

-- Molette : zoom centré sur le curseur (le point sous la souris reste
-- sous la souris).
function Board:_onWheel(delta)
    local c = self.frame.canvas
    local bx, by = cursorToBoard(c)
    view.zoom = view.zoom * (delta > 0 and 1.3 or 1 / 1.3)
    if view.zoom < 1.05 then view.zoom = 1 end
    clampView()
    local x, y = GetCursorPosition()
    local s = c:GetEffectiveScale()
    local cx = x / s - c:GetLeft()
    local cy = c:GetTop() - y / s
    view.ox = bx / 1000 * CANVAS_W * view.zoom - cx
    view.oy = by / 1000 * CANVAS_H * view.zoom - cy
    clampView()
    self:_rerenderAll()
    self:_broadcastView()
end

-- ---------------------------------------------------------------------
-- Pools de rendu
-- ---------------------------------------------------------------------
function Board:_line()
    local ln = table.remove(self._linePool)
    if not ln then
        ln = self.frame.canvas.content:CreateLine(nil, "ARTWORK")
        ln:SetTexture("Interface\\Buttons\\WHITE8x8")
    end
    ln:Show()
    return ln
end

function Board:_tex()
    local t = table.remove(self._texPool)
    if not t then t = self.frame.canvas.content:CreateTexture(nil, "OVERLAY") end
    t:Show()
    return t
end

function Board:_fs()
    local s = table.remove(self._fsPool)
    if not s then
        s = self.frame.canvas.content:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    end
    s:Show()
    return s
end

local function releaseRegions(self, op)
    for _, r in ipairs(op._regions or {}) do
        r:Hide()
        r:ClearAllPoints()
        if r.SetStartPoint then table.insert(self._linePool, r)
        elseif r.SetText then r:SetText(""); table.insert(self._fsPool, r)
        else
            -- PURGE COMPLÈTE au retour dans le pool (vécu : les disques
            -- soak/flaque teintent avec un ALPHA 0.4-0.55 — réutilisée
            -- sans purge, la texture rendait des icônes translucides
            -- « au hasard »).
            r:SetTexCoord(0, 1, 0, 1)
            r:SetVertexColor(1, 1, 1, 1)
            r:SetAlpha(1)
            table.insert(self._texPool, r)
        end
    end
    op._regions = nil
end

-- ---------------------------------------------------------------------
-- Rendu d'une opération
-- ---------------------------------------------------------------------
local function drawSegment(self, regions, x1, y1, x2, y2, r, g, b, thick)
    local ln = self:_line()
    ln:SetVertexColor(r, g, b, 0.95)
    ln:SetThickness(thick or 3)
    local c = self.frame.canvas
    ln:SetStartPoint("TOPLEFT", c, x1, y1)
    ln:SetEndPoint("TOPLEFT", c, x2, y2)
    regions[#regions + 1] = ln
end

function Board:_render(op)
    if op._regions then releaseRegions(self, op) end
    local regions = {}
    op._regions = regions
    local col = self.COLORS[op.color or 6] or self.COLORS[6]
    local r, g, b = col[1], col[2], col[3]

    if op.type == "P" or op.type == "N" or op.type == "A" then
        local pts = op.points
        for i = 1, #pts - 1 do
            local x1, y1 = toPixels(pts[i][1], pts[i][2])
            local x2, y2 = toPixels(pts[i + 1][1], pts[i + 1][2])
            drawSegment(self, regions, x1, y1, x2, y2, r, g, b)
        end
        if op.type == "A" and #pts >= 2 then
            -- tête de flèche : deux barbes à 30° du dernier segment
            local ax, ay = toPixels(pts[#pts][1], pts[#pts][2])
            local px, py = toPixels(pts[#pts - 1][1], pts[#pts - 1][2])
            local dx, dy = ax - px, ay - py
            local len = math.sqrt(dx * dx + dy * dy)
            if len > 1 then
                dx, dy = dx / len, dy / len
                local sz = 14
                for _, ang in ipairs({ 2.6, -2.6 }) do
                    local ca, sa = math.cos(ang), math.sin(ang)
                    drawSegment(self, regions, ax, ay,
                        ax + (dx * ca - dy * sa) * sz,
                        ay + (dx * sa + dy * ca) * sz, r, g, b)
                end
            end
        end
    elseif op.type == "C" then
        local cx, cy = toPixels(op.points[1][1], op.points[1][2])
        local ex, ey = toPixels(op.points[2][1], op.points[2][2])
        local rad = math.sqrt((ex - cx) ^ 2 + (ey - cy) ^ 2)
        local SEG = 28
        local px, py
        for i = 0, SEG do
            local a = i / SEG * 2 * math.pi
            local x = cx + math.cos(a) * rad
            local y = cy + math.sin(a) * rad
            if px then drawSegment(self, regions, px, py, x, y, r, g, b) end
            px, py = x, y
        end
    elseif op.type == "T" then
        local fs = self:_fs()
        fs:SetText(op.text or "?")
        fs:SetTextColor(r, g, b)
        local x, y = toPixels(op.points[1][1], op.points[1][2])
        fs:SetPoint("CENTER", self.frame.canvas.content, "TOPLEFT", x, y)
        regions[#regions + 1] = fs
    elseif op.type == "M" then
        local t = self:_tex()
        -- Masque CIRCULAIRE géré à la demande sur les textures de pool
        -- (disques de zones, icônes rondes) — retiré pour les usages
        -- rectangulaires (logos de rôle, têtes de mort).
        local function setRound(on)
            if on then
                if not t._mask then
                    t._mask = self.frame.canvas.content:CreateMaskTexture()
                    t._mask:SetTexture("Interface\\CharacterFrame\\TempPortraitAlphaMask",
                        "CLAMPTOBLACKADDITIVE", "CLAMPTOBLACKADDITIVE")
                    t._mask:SetAllPoints(t)
                end
                t:AddMaskTexture(t._mask)
            elseif t._mask then
                t:RemoveMaskTexture(t._mask)
            end
        end
        -- TAMPONS MÉCANIQUES (demande utilisateur) : zones translucides
        -- et icônes d'action, vocabulaire standard du raid.
        if op.role == 6 then          -- SOAK : disque vert translucide
            setRound(true)
            t:SetTexture("Interface\\Buttons\\WHITE8x8")
            t:SetVertexColor(0.25, 0.95, 0.45, 0.40)
            t:SetSize(36, 36)
        elseif op.role == 8 then      -- FLAQUE : disque violet sombre
            setRound(true)
            t:SetTexture("Interface\\Buttons\\WHITE8x8")
            t:SetVertexColor(0.50, 0.20, 0.75, 0.55)
            t:SetSize(38, 38)
        elseif op.role == 7 then      -- DISPEL : icône ronde
            setRound(true)
            t:SetVertexColor(1, 1, 1, 1)
            t:SetTexture("Interface\\Icons\\Spell_Holy_DispelMagic")
            t:SetSize(24, 24)
        elseif op.role == 9 then      -- KICK : icône ronde
            setRound(true)
            t:SetVertexColor(1, 1, 1, 1)
            t:SetTexture("Interface\\Icons\\Ability_Kick")
            t:SetSize(24, 24)
        elseif op.role and op.role >= 11 and op.role <= 18 then
            -- ICÔNES DE RAID officielles (étoile → tête de mort) : la
            -- planche 4×4 standard, découpée par TexCoord.
            setRound(false)
            t:SetVertexColor(1, 1, 1, 1)
            t:SetTexture("Interface\\TargetingFrame\\UI-RaidTargetingIcons")
            local idx = op.role - 10
            local l = ((idx - 1) % 4) * 0.25
            local tp = math.floor((idx - 1) / 4) * 0.25
            t:SetTexCoord(l, l + 0.25, tp, tp + 0.25)
            t:SetSize(24, 24)
        elseif op.role == 4 or op.role == 5 then
            setRound(false)
            -- Tête de mort : BOSS (32 px, pleine) ou ADD (petite, grise).
            t:SetTexCoord(0, 1, 0, 1)
            t:SetVertexColor(1, 1, 1, 1)
            local okA = pcall(t.SetAtlas, t, "DungeonSkull")
            if not okA then
                t:SetTexture("Interface\\TargetingFrame\\UI-RaidTargetingIcon_8")
            end
            if op.role == 5 then
                t:SetSize(18, 18)
                t:SetVertexColor(0.75, 0.75, 0.75)
            else
                t:SetSize(32, 32)
            end
        else
            setRound(false)
            t:SetVertexColor(1, 1, 1)   -- (texture de pool : purge d'état)
            t:SetTexture("Interface\\LFGFrame\\UI-LFG-ICON-PORTRAITROLES")
            local tc = ROLE_TEXCOORD[op.role or 3] or ROLE_TEXCOORD[3]
            t:SetTexCoord(tc[1], tc[2], tc[3], tc[4])
            t:SetSize(26, 26)
        end
        local x, y = toPixels(op.points[1][1], op.points[1][2])
        t:SetPoint("CENTER", self.frame.canvas.content, "TOPLEFT", x, y)
        regions[#regions + 1] = t
        if op.text and op.text ~= "" then
            local fs = self:_fs()
            fs:SetText(op.text)
            fs:SetTextColor(1, 1, 1)
            fs:SetPoint("TOP", t, "BOTTOM", 0, -1)
            regions[#regions + 1] = fs
        end
    end
end

-- ---------------------------------------------------------------------
-- Modèle : appliquer / retirer / effacer
-- ---------------------------------------------------------------------
-- EMPREINTE du tableau (« à jour ? ») : somme de hachages simples des
-- opérations sérialisées — indépendante de l'ordre de réception.
function Board:Checksum()
    local sum = 0
    for _, op in ipairs(ns.board.ops) do
        local s = self:SerializeOp(op) or ""
        local h = 5381
        for i = 1, #s do
            h = (h * 33 + s:byte(i)) % 2147483647
        end
        sum = (sum + h) % 2147483647
    end
    return tostring(sum)
end

function Board:ApplyOp(op)
    local existing = ns.board.byId[op.id]
    if existing then
        -- MÊME id = MISE À JOUR (déplacement d'un marqueur re-synchronisé)
        existing.points = op.points
        existing.text = op.text
        existing.role = op.role or existing.role
        existing.color = op.color or existing.color
        if self.frame then self:_render(existing) end
        self:_saveLocal()
        ns.modules.Sync:AnnounceState()
        return
    end
    ns.board.ops[#ns.board.ops + 1] = op
    ns.board.byId[op.id] = op
    if self.frame then self:_render(op) end
    self:_saveLocal()
    ns.modules.Sync:AnnounceState()
end

function Board:RemoveOp(id)
    local op = ns.board.byId[id]
    if not op then return end
    ns.board.byId[id] = nil
    for i, o in ipairs(ns.board.ops) do
        if o == op then table.remove(ns.board.ops, i) break end
    end
    if op._regions then releaseRegions(self, op) end
    self:_saveLocal()
    ns.modules.Sync:AnnounceState()
end

-- `noSave` : nettoyage de TRANSITION (changement de page/carte, avant
-- une restauration) — SANS sauvegarder. Vécu : le clear sauvegardait un
-- tableau VIDE sur la clé de DESTINATION juste avant de la restaurer,
-- effaçant les plans des autres phases.
function Board:ClearAll(silent, noSave)
    for _, op in ipairs(ns.board.ops) do
        if op._regions then releaseRegions(self, op) end
    end
    wipe(ns.board.ops)
    wipe(ns.board.byId)
    if not noSave then
        self:_saveLocal()
        ns.modules.Sync:AnnounceState()
    end
end

-- ---------------------------------------------------------------------
-- Sérialisation (voir en-tête pour le format)
-- ---------------------------------------------------------------------
local function packPoints(pts)
    local out = {}
    for _, p in ipairs(pts) do out[#out + 1] = p[1] .. "," .. p[2] end
    return table.concat(out, ";")
end

local function unpackPoints(s)
    local pts = {}
    for x, y in s:gmatch("(%d+),(%d+)") do
        pts[#pts + 1] = { tonumber(x), tonumber(y) }
    end
    return pts
end

function Board:SerializeOp(op)
    if op.type == "T" or op.type == "M" then
        return table.concat({ op.type, op.id,
            (op.type == "M") and (op.role or 3) or (op.color or 6),
            packPoints(op.points), op.text or "" }, ":")
    end
    return table.concat({ op.type, op.id, op.color or 6,
        packPoints(op.points) }, ":")
end

function Board:DeserializeOp(s, sender)
    local t, id, cr, rest = s:match("^(%u):([^:]+):(%d+):(.*)$")
    if not t then return nil end
    -- Id COURT (op envoyée en direct) → préfixé du nom d'émetteur (la
    -- signature du canal). Id COMPLET « Auteur#n » (tableau RELAYÉ par
    -- le chef) → conservé tel quel, l'auteur d'origine reste crédité.
    local op
    if id:find("#", 1, true) then
        op = { type = t, id = id, author = id:match("^([^#]+)") or sender }
    else
        op = { type = t, id = sender .. "#" .. id, author = sender }
    end
    if t == "T" or t == "M" then
        local pts, txt = rest:match("^([^:]*):?(.*)$")
        op.points = unpackPoints(pts or "")
        op.text = txt
        if t == "M" then op.role = tonumber(cr) else op.color = tonumber(cr) end
    else
        op.color = tonumber(cr)
        op.points = unpackPoints(rest)
    end
    if #op.points == 0 then return nil end
    return op
end

-- ---------------------------------------------------------------------
-- Entrée souris (outils : voir Tools.lua — ns.tool = { mode, color, role })
-- ---------------------------------------------------------------------
local drawing = nil   -- op en cours de tracé

function Board:_onMouseDown(btn)
    -- MODE COMBAT : AUCUNE édition, même pour le chef (demande
    -- utilisateur — en combat on ne retouche pas le plan, on le suit).
    -- Seuls restent le panoramique (Maj-glisser) et le zoom molette.
    if self._compact then
        if btn == "LeftButton" and IsShiftKeyDown() then
            local x, y = GetCursorPosition()
            self._panning = { x = x, y = y, ox = view.ox, oy = view.oy }
        end
        return
    end
    local tool = ns.tool or { mode = "P", color = 1 }
    if btn == "RightButton" then
        self:_deleteAtCursor()
        return
    end
    -- Maj-glisser : PANORAMIQUE de la vue (aucun dessin).
    if IsShiftKeyDown() then
        local x, y = GetCursorPosition()
        self._panning = { x = x, y = y, ox = view.ox, oy = view.oy }
        return
    end
    -- MANIPULATION DIRECTE : UNIQUEMENT avec l'outil DÉPLACER (« S »).
    -- (v1 : les marqueurs se saisissaient avec tout outil — impossible
    -- de tamponner/dessiner À CÔTÉ d'un marqueur sans le bouger, vécu.)
    if tool.mode == "S" then
        local x, y = cursorToBoard(self.frame.canvas)
        local grabR = 18 / (CANVAS_W * view.zoom) * 1000
        local best, bestD
        for _, op in ipairs(ns.board.ops) do
            for _, p in ipairs(op.points) do
                local d = math.sqrt((p[1] - x) ^ 2 + (p[2] - y) ^ 2)
                if d <= grabR and (not bestD or d < bestD) then
                    best, bestD = op, d
                end
            end
        end
        if best then
            local mine = best.author == ns.me
            if ns.CanDraw() and (mine or UnitIsGroupLeader("player") or not IsInGroup()) then
                self._dragging = { op = best, lx = x, ly = y }
            end
        end
        return   -- l'outil déplacer ne dessine jamais
    end
    if not ns.CanDraw() then
        UIErrorsFrame:AddMessage(L["Seul le chef (ou un joueur autorisé) peut dessiner."], 1, 0.4, 0.4)
        return
    end
    local x, y = cursorToBoard(self.frame.canvas)
    ns.board.seq = ns.board.seq + 1
    local id = ns.me .. "#" .. ns.board.seq

    -- GOMME : effacer en continu tant que le clic est maintenu.
    if tool.mode == "E" then
        self._erasing = true
        self:_deleteAtCursor(300)
        return
    end
    if tool.mode == "T" then
        self:_askText(function(txt)
            if txt == "" then return end
            local op = { type = "T", id = id, author = ns.me,
                color = tool.color, points = { { x, y } }, text = txt }
            self:ApplyOp(op)
            ns.modules.Sync:SendOp(self:_serializeLocal(op))
            self:_pushUndo({ op.id })
        end)
        return
    end
    if tool.mode == "M" then
        local op = { type = "M", id = id, author = ns.me,
            role = tool.role or 3, points = { { x, y } }, text = tool.label or "" }
        self:ApplyOp(op)
        ns.modules.Sync:SendOp(self:_serializeLocal(op))
        self:_pushUndo({ op.id })
        return
    end

    drawing = { type = tool.mode, id = id, author = ns.me,
        color = tool.color, points = { { x, y } } }
    if tool.mode ~= "P" then drawing.points[2] = { x, y } end
end

function Board:_onUpdate()
    if self._dragging then
        local x, y = cursorToBoard(self.frame.canvas)
        local d = self._dragging
        local dx, dy = x - d.lx, y - d.ly
        if dx ~= 0 or dy ~= 0 then
            for _, p in ipairs(d.op.points) do
                p[1] = p[1] + dx
                p[2] = p[2] + dy
            end
            d.lx, d.ly = x, y
            self:_render(d.op)
        end
        return
    end
    if self._panning then
        local x, y = GetCursorPosition()
        local s = self.frame.canvas:GetEffectiveScale()
        view.ox = self._panning.ox - (x - self._panning.x) / s
        view.oy = self._panning.oy + (y - self._panning.y) / s
        clampView()
        self:_rerenderAll()
        return
    end
    if self._erasing then
        self:_deleteAtCursor(300)
        return
    end
    if not drawing then return end
    local x, y = cursorToBoard(self.frame.canvas)
    if drawing.type == "P" then
        local last = drawing.points[#drawing.points]
        -- échantillonnage : un point tous les ~8 millièmes (compacité)
        if math.abs(x - last[1]) + math.abs(y - last[2]) >= 8 then
            drawing.points[#drawing.points + 1] = { x, y }
        end
    else
        drawing.points[2] = { x, y }
    end
    self:_render(drawing)   -- aperçu immédiat local
end

function Board:_onMouseUp(btn)
    if self._erasing then self._erasing = nil return end
    if self._panning then
        self._panning = nil
        self:_broadcastView()
        return
    end
    if self._dragging then
        -- Lâché : position finale re-synchronisée (même id → les autres
        -- clients METTENT À JOUR au lieu de dupliquer).
        local op = self._dragging.op
        self._dragging = nil
        self:_saveLocal()
        ns.modules.Sync:AnnounceState()
        ns.modules.Sync:SendOp(self:_serializeLocal(op))
        return
    end
    if btn ~= "LeftButton" or not drawing then return end
    local op = drawing
    drawing = nil
    if #op.points < 2 then
        if op._regions then releaseRegions(self, op) end
        return
    end
    -- Un crayon très long dépasse un message → tronçons de 24 points
    -- (annulés d'un BLOC par Ctrl+Z : un geste = une entrée d'annulation).
    if op.type == "P" and #op.points > 24 then
        local chunk, chunkIds = nil, {}
        for i = 1, #op.points do
            if not chunk then
                ns.board.seq = ns.board.seq + 1
                chunk = { type = "P", id = ns.me .. "#" .. ns.board.seq,
                    author = ns.me, color = op.color,
                    points = { op.points[math.max(1, i - 1)] } }
            end
            chunk.points[#chunk.points + 1] = op.points[i]
            if #chunk.points >= 24 or i == #op.points then
                self:ApplyOp(chunk)
                ns.modules.Sync:SendOp(self:_serializeLocal(chunk))
                chunkIds[#chunkIds + 1] = chunk.id
                chunk = nil
            end
        end
        self:_pushUndo(chunkIds)
        if op._regions then releaseRegions(self, op) end
        return
    end
    self:ApplyOp(op)
    ns.modules.Sync:SendOp(self:_serializeLocal(op))
    self:_pushUndo({ op.id })
end

-- L'op locale porte un id complet "Moi#n" ; sur le fil on n'envoie que
-- "n" (le récepteur préfixe par le nom d'émetteur du canal — signature).
function Board:_serializeLocal(op)
    local shortId = op.id:match("#(%d+)$") or op.id
    local clone = { type = op.type, id = shortId, color = op.color,
        role = op.role, points = op.points, text = op.text }
    return self:SerializeOp(clone)
end

function Board:_deleteAtCursor(radius)
    local x, y = cursorToBoard(self.frame.canvas)
    local best, bestD
    for _, op in ipairs(ns.board.ops) do
        for _, p in ipairs(op.points) do
            local d = (p[1] - x) ^ 2 + (p[2] - y) ^ 2
            if not bestD or d < bestD then best, bestD = op, d end
        end
    end
    -- rayon de prise ~20 millièmes ; chef = tout, sinon ses propres traits
    if best and bestD and bestD <= (radius or 400) then
        local mine = best.author == ns.me
        if mine or UnitIsGroupLeader("player") or not IsInGroup() then
            self:RemoveOp(best.id)
            ns.modules.Sync:SendDelete(best.id)
            return true
        end
    end
end

-- ---------------------------------------------------------------------
-- ANNULER (Ctrl+Z / bouton) : pile de MES créations — un crayon
-- tronçonné s'annule d'un bloc.
-- ---------------------------------------------------------------------
function Board:_pushUndo(ids)
    self._undo = self._undo or {}
    self._undo[#self._undo + 1] = ids
    if #self._undo > 30 then table.remove(self._undo, 1) end
end

function Board:Undo()
    if self._compact or not ns.CanDraw() then return end
    local grp
    repeat
        grp = self._undo and table.remove(self._undo)
    until not grp or (function()
        for _, id in ipairs(grp) do
            if ns.board.byId[id] then return true end
        end
    end)()
    if not grp then return end
    for _, id in ipairs(grp) do
        if ns.board.byId[id] then
            self:RemoveOp(id)
            ns.modules.Sync:SendDelete(id)
        end
    end
end

-- ---------------------------------------------------------------------
-- EXPORT / IMPORT : le plan de la page courante en chaîne de texte
-- (partage Discord/wago entre raid leaders). Encodage hexadécimal =
-- collable partout sans caractère de contrôle.
-- ---------------------------------------------------------------------
local function toHex(s)
    return (s:gsub(".", function(ch)
        return ("%02x"):format(ch:byte())
    end))
end

local function fromHex(h)
    if h:match("[^0-9a-fA-F]") or #h % 2 ~= 0 then return nil end
    return (h:gsub("%x%x", function(b)
        return string.char(tonumber(b, 16))
    end))
end

function Board:ExportString()
    local parts = {}
    for _, op in ipairs(ns.board.ops) do
        local s = self:SerializeOp(op)
        if s then parts[#parts + 1] = s end
    end
    local payload = table.concat({
        tostring(self._mapID or 0),
        tostring((self._instance and self._instance.id) or 0),
        tostring((self._boss and self._boss.encID) or 0),
        table.concat(parts, "\2"),
    }, "\1")
    return "LS1:" .. toHex(payload)
end

function Board:ImportString(str)
    local hex = tostring(str or ""):gsub("%s", ""):match("^LS1:(.+)$")
    local payload = hex and fromHex(hex)
    if not payload then
        print("LiveStrat : " .. L["chaîne d'import invalide."])
        return
    end
    local mapID, instID, encID, opsBlob =
        payload:match("^(%d+)\1(%d+)\1(%d+)\1(.*)$")
    if not mapID then
        print("LiveStrat : " .. L["chaîne d'import invalide."])
        return
    end
    self:ApplyMapContext(tonumber(mapID), tonumber(instID), tonumber(encID))
    self:ClearAll(true, true)
    local n, ids = 0, {}
    for s in opsBlob:gmatch("[^\2]+") do
        local op = self:DeserializeOp(s, ns.me)
        if op then
            -- L'IMPORTEUR devient l'auteur (droits de suppression/sync).
            ns.board.seq = ns.board.seq + 1
            op.id = ns.me .. "#" .. ns.board.seq
            op.author = ns.me
            self:ApplyOp(op)
            ids[#ids + 1] = op.id
            n = n + 1
        end
    end
    if n > 0 then self:_pushUndo(ids) end
    print(("LiveStrat : " .. L["plan importé (%d éléments)."]):format(n))
    if IsInGroup() and ns.CanDraw() then
        self:_broadcastContext()
        ns.modules.Sync:SendFullBoard()
    end
end

function Board:_askText(cb)
    if not StaticPopupDialogs.LIVESTRAT_TEXT then
        StaticPopupDialogs.LIVESTRAT_TEXT = {
            text = L["Étiquette :"],
            button1 = OKAY, button2 = CANCEL,
            hasEditBox = true, timeout = 0, whileDead = true,
            hideOnEscape = true,
            OnAccept = function(self)
                local eb = self.EditBox or self.editBox
                local t = eb:GetText() or ""
                if self.data then self.data(t) end
            end,
            EditBoxOnEnterPressed = function(self)
                local parent = self:GetParent()
                local t = self:GetText() or ""
                if parent.data then parent.data(t) end
                parent:Hide()
            end,
        }
    end
    local dlg = StaticPopup_Show("LIVESTRAT_TEXT")
    if dlg then dlg.data = cb end
end

-- ---------------------------------------------------------------------
-- Persistance locale (par carte — v2 : par rencontre du journal)
-- ---------------------------------------------------------------------
function Board:_saveLocal()
    if not self._mapID then return end
    local out = {}
    for _, op in ipairs(ns.board.ops) do
        out[#out + 1] = self:SerializeOp({
            type = op.type, id = op.id, color = op.color,
            role = op.role, points = op.points, text = op.text,
        })
    end
    LiveStratDB.boards[tostring(self._mapID) .. "/" .. (self._page or 1)] = table.concat(out, "\2")
end

function Board:_restoreLocal(mapID)
    self._mapID = mapID or self._mapID or C_Map.GetBestMapForUnit("player")
    -- INVITÉ en groupe : ne PAS restaurer ses vieux gribouillages locaux
    -- — ils divergeaient du plan du chef et l'indicateur « à jour »
    -- restait [!] en permanence (vécu). Le plan vient du chef.
    if IsInGroup() and not UnitIsGroupLeader("player") then return end
    -- clé (carte, page) — repli sur l'ancienne clé sans page (migration)
    local saved = self._mapID
        and (LiveStratDB.boards[tostring(self._mapID) .. "/" .. (self._page or 1)]
            or ((self._page or 1) == 1 and LiveStratDB.boards[tostring(self._mapID)]))
    if not saved or saved == "" then return end
    for s in saved:gmatch("[^\2]+") do
        local t, id, cr, rest = s:match("^(%u):([^:]+):(%d+):(.*)$")
        if t then
            -- l'id sauvegardé est déjà complet (Nom#n)
            local op = { type = t, id = id, author = id:match("^([^#]+)") or ns.me }
            if t == "T" or t == "M" then
                local pts, txt = rest:match("^([^:]*):?(.*)$")
                op.points = unpackPoints(pts or "")
                op.text = txt
                if t == "M" then op.role = tonumber(cr) else op.color = tonumber(cr) end
            else
                op.color = tonumber(cr)
                op.points = unpackPoints(rest)
            end
            if #op.points > 0 then
                ns.board.ops[#ns.board.ops + 1] = op
                ns.board.byId[op.id] = op
                self:_render(op)
            end
        end
    end
end

function Board:UpdatePermissionUI()
    if ns.modules.Tools then ns.modules.Tools:UpdatePermissionUI() end
    self:UpdateViewersUI()
    self:UpdateCompactUI()
end

-- ---------------------------------------------------------------------
-- Colonne « EN DIRECT » : tout le groupe, avec l'état de chacun —
-- ● vert = tableau OUVERT, ● jaune = a l'addon, ● gris = ne l'a pas.
-- Le CHEF clique un nom pour donner/retirer le droit d'édition ([OK]).
-- ---------------------------------------------------------------------
function Board:UpdateViewersUI()
    local f = self.frame
    if not (f and f.side) then return end
    for _, r in ipairs(f.side.rows) do r:Hide() end

    local entries = {}
    entries[#entries + 1] = { name = ns.me, short = UnitName("player"), me = true }
    -- Faux membres du mode simulation (/tac simu)
    for _, m in ipairs(ns.simuMembers or {}) do
        entries[#entries + 1] = m
    end
    if IsInGroup() then
        local prefix = IsInRaid() and "raid" or "party"
        for i = 1, GetNumGroupMembers() do
            local unit = prefix .. i
            if UnitExists(unit) and not UnitIsUnit(unit, "player") then
                entries[#entries + 1] = {
                    name = ns.FullName(unit), short = UnitName(unit),
                    class = select(2, UnitClass(unit)),
                }
            end
        end
    end

    local isLeader = (not IsInGroup()) or UnitIsGroupLeader("player")
    local shown = 0
    for _, ent in ipairs(entries) do
        if shown >= 20 then break end
        shown = shown + 1
        local r = f.side.rows[shown]
        if not r then
            r = ns.CreateModernButton(f.side, 132, 20)
            r.text:ClearAllPoints()
            r.text:SetPoint("LEFT", 6, 0)
            r.text:SetJustifyH("LEFT")
            f.side.rows[shown] = r
        end
        r:ClearAllPoints()
        if shown == 1 then
            r:SetPoint("TOPLEFT", f.side.title, "BOTTOMLEFT", -2, -6)
        else
            r:SetPoint("TOPLEFT", f.side.rows[shown - 1], "BOTTOMLEFT", 0, -2)
        end
        local watching = ns.viewing[ent.name]
        local hasAddon = ns.present[ent.name]
        local dot = watching and "|cff40ff40o|r"
            or hasAddon and "|cffffd100o|r" or "|cff555555o|r"
        -- « À JOUR ? » : son empreinte de tableau comparée à la mienne.
        if hasAddon and not ent.me then
            local st = ns.boardState[ent.name]
            if st == self:Checksum() then
                dot = dot .. "|cff40ff40[=]|r"
            elseif st then
                dot = dot .. "|cffff9020[!]|r"
            end
        end
        local cc = ent.class and RAID_CLASS_COLORS[ent.class]
        local nameTxt = cc and ("|c" .. cc.colorStr .. ent.short .. "|r")
            or (ent.short or "?")
        local right = ""
        if ent.me or ent.name == ns.GroupLeaderName() then
            right = " |cffffd100(chef)|r"
            if ent.me and IsInGroup() and not UnitIsGroupLeader("player") then
                right = ""
            end
        end
        if ns.acl[ent.name] then right = " |cff40ff40[OK]|r" end
        r.text:SetText(dot .. " " .. nameTxt .. right)
        if isLeader and not ent.me then
            r:SetScript("OnClick", function()
                if ns.acl[ent.name] then
                    ns.acl[ent.name] = nil
                    print(("LiveStrat : " .. L["%s ne peut plus dessiner."]):format(ent.short))
                else
                    ns.acl[ent.name] = true
                    print(("LiveStrat : " .. L["%s peut maintenant dessiner."]):format(ent.short))
                end
                ns.modules.Sync:SendACL()
                Board:UpdateViewersUI()
            end)
        else
            r:SetScript("OnClick", nil)
        end
        r:Show()
    end
    -- LÉGENDE complète (demande utilisateur) : chaque symbole expliqué,
    -- avec les mêmes codes couleur que les lignes au-dessus.
    local legend = {
        "|cff40ff40o|r " .. L["regarde le tableau"],
        "|cffffd100o|r " .. L["a l'addon (tableau fermé)"],
        "|cff555555o|r " .. L["n'a pas l'addon"],
        "|cff40ff40[=]|r " .. L["plan à jour"],
        "|cffff9020[!]|r " .. L["plan différent (se resynchronise)"],
        "|cff40ff40[OK]|r " .. L["peut dessiner"],
    }
    if isLeader and IsInGroup() then
        legend[#legend + 1] = "|cffffd100" .. L["Clique un nom : autoriser/retirer l'édition."] .. "|r"
    end
    f.side.hint:SetText(table.concat(legend, "\n"))
end
