local ADDON_NAME, RPAL = ...

-- ============================================================
-- RPAL INJURY SYSTEM
-- Persistent, draggable injury markers + public inspect snapshot.
-- ============================================================

local injuryUI = {
    frame=nil,body=nil,backBody=nil,left=nil,right=nil,drag=nil,
    timerTicker=0,cosmeticTicker=0,timerLabels={},hasActiveTimers=false,
    markerPool={},activeMarkers={},rightPools={},rightActive={},collapsedGroups={}
}
RPAL.InjuryUI = injuryUI
local injuryDBCache
local INJURY_SCHEMA_VERSION=6

local BODY_TEX = "Interface\\AddOns\\"..tostring(ADDON_NAME or "ODYN").."\\Textures\\humanbody.png"
local PALETTE = {
    gold={0.86,0.63,0.28,1}, pale={0.90,0.86,0.76,1}, muted={0.55,0.51,0.45,1},
    panel={0.055,0.045,0.035,0.98}, panel2={0.085,0.070,0.055,0.98}, border={0.30,0.24,0.16,1},
    red={0.78,0.20,0.16,1}, heal={0.24,0.68,0.40,1}, alteration={0.72,0.50,0.96,1}, white={1,1,1,1},
}

RPAL.INJURY_TYPES = {"Cut","Chewed","Slashed","Bludgeoned","Broken","Shattered","Bullet Hole","Burned","Freezed","Torn Off","Bleeding","Blown Off"}
RPAL.HEALING_STATUSES = {"Healing","Bandaged","Operated On","Stitched","Splint On"}
RPAL.BODY_ALTERATIONS = {"Prosthetic","Amputated"}

local function P()
    if RPAL.GetCurrentThemePalette then
        local p=RPAL.GetCurrentThemePalette()
        if p then return p end
    end
    return PALETTE
end

local function Backdrop(frame,bg,border)
    frame:SetBackdrop({bgFile="Interface\\Buttons\\WHITE8X8",edgeFile="Interface\\Buttons\\WHITE8X8",tile=false,edgeSize=1,insets={left=0,right=0,top=0,bottom=0}})
    frame:SetBackdropColor(unpack(bg)); frame:SetBackdropBorderColor(unpack(border))
end

local function Font(parent,text,size,color)
    local fs=parent:CreateFontString(nil,"ARTWORK","GameFontNormal")
    local currentFont=select(1,fs:GetFont()) or "Fonts\\FRIZQT__.TTF";local base=size or 10;local final=RPAL.GetScaledFontSize and RPAL.GetScaledFontSize(base) or base
    fs:SetFont(currentFont,final,"OUTLINE");if RPAL.RegisterScalableFontObject then RPAL.RegisterScalableFontObject(fs,currentFont,base,"OUTLINE") end
    fs:SetText(text or "");fs:SetTextColor(unpack(color or P().pale));return fs
end

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

local function NormalizeNote(value)
    local note=tostring(value or ""):gsub("\r","")
    if #note>600 then note=note:sub(1,600) end
    if Trim(note)=="" then return nil end
    return note
end

local function DB()
    RPAL_CHAR_DB=RPAL_CHAR_DB or {}
    RPAL_CHAR_DB.injuries=RPAL_CHAR_DB.injuries or {version=INJURY_SCHEMA_VERSION,nextID=0,nextGroupID=0,items={},groups={}}
    local d=RPAL_CHAR_DB.injuries
    if injuryDBCache==d and tonumber(d.version)==INJURY_SCHEMA_VERSION then return d end

    d.items=type(d.items)=="table" and d.items or {}
    d.groups=type(d.groups)=="table" and d.groups or {}
    local cleanItems={}
    local maxItemID=math.max(0,math.floor(tonumber(d.nextID) or 0))
    for key,item in pairs(d.items) do
        if type(item)=="table" then
            local id=tostring(item.id or key or "")
            if id~="" and not cleanItems[id] then
                item.id=id
                -- Category is persisted data. Moving/scaling/rotating a node must
                -- never infer or rewrite its type from UI state.
                item.category=(item.category=="healing" and "healing") or (item.category=="alteration" and "alteration") or "injury"
                item.side=item.side=="back" and "back" or "front"
                local x=tonumber(item.x); local y=tonumber(item.y)
                if not x or x~=x or x==math.huge or x==-math.huge then x=.5 end
                if not y or y~=y or y==math.huge or y==-math.huge then y=.5 end
                item.x=math.max(.02,math.min(.98,x)); item.y=math.max(.02,math.min(.98,y))
                local scale=tonumber(item.scale) or 1; if scale~=scale or scale==math.huge or scale==-math.huge then scale=1 end
                item.scale=math.max(.65,math.min(1.55,scale))
                local rotation=tonumber(item.rotation) or 0;if rotation~=rotation or rotation==math.huge or rotation==-math.huge then rotation=0 end
                item.rotation=rotation%360
                item.note=NormalizeNote(item.note)
                if item.category=="alteration" then
                    item.customName=Trim(item.customName)
                    if item.customName=="" then item.customName=nil end
                else
                    item.customName=nil
                end
                if item.category~="healing" then item.expiresAt=nil end
                cleanItems[id]=item
                maxItemID=math.max(maxItemID,tonumber(id:match("(%d+)$")) or 0)
            end
        end
    end

    for _,item in pairs(cleanItems) do item.groupId=nil end
    local cleanGroups={}
    local claimedItems={}
    local maxGroupID=math.max(0,math.floor(tonumber(d.nextGroupID) or 0))
    for key,group in pairs(d.groups) do
        if type(group)=="table" then
            local id=tostring(group.id or key or "")
            local members,seen,groupSide={},{},(group.side=="back" and "back" or (group.side=="front" and "front" or nil))
            for _,memberID in ipairs(type(group.itemIds)=="table" and group.itemIds or {}) do
                local item=cleanItems[tostring(memberID)]
                if item and not seen[item.id] and not claimedItems[item.id] then
                    groupSide=groupSide or item.side
                    if item.side==groupSide then
                        seen[item.id]=true; members[#members+1]=item.id
                    end
                end
            end
            -- Health groups are valid with ONE member. A record becomes a card in
            -- the right rail immediately instead of waiting for a second marker.
            if id~="" and #members>=1 and not cleanGroups[id] then
                group.id=id; group.itemIds=members; group.side=groupSide or "front"; group.expiresAt=nil
                cleanGroups[id]=group
                for _,memberID in ipairs(members) do claimedItems[memberID]=true;cleanItems[memberID].groupId=id end
                maxGroupID=math.max(maxGroupID,tonumber(id:match("(%d+)$")) or 0)
            end
        end
    end

    -- Injuries and Permanent Changes always own a Health card, even when they
    -- are the only node. Healing statuses may remain standalone until linked.
    for _,item in pairs(cleanItems) do
        if item.category~="healing" and not item.groupId then
            local gid
            repeat maxGroupID=maxGroupID+1;gid="grp"..tostring(maxGroupID) until not cleanGroups[gid]
            local label=(item.category=="alteration" and (item.customName or item.type or "Permanent Change")) or (item.type or "Injury")
            cleanGroups[gid]={id=gid,name=label,itemIds={item.id},side=item.side or "front",anchorX=item.x,anchorY=item.y}
            item.groupId=gid
        end
    end

    d.items=cleanItems; d.groups=cleanGroups
    d.nextID=maxItemID; d.nextGroupID=maxGroupID; d.version=INJURY_SCHEMA_VERSION
    injuryDBCache=d
    return d
end
RPAL.EnsureInjuryDB=DB

local function NewID(prefix)
    local d=DB(); local id
    repeat d.nextID=d.nextID+1; id=(prefix or "inj")..tostring(d.nextID) until not d.items[id]
    return id
end

local function GetItem(id)
    return DB().items[tostring(id)]
end

local function GetGroup(id)
    return id and DB().groups[tostring(id)] or nil
end

local function Clamp(v, lo, hi)
    v=tonumber(v) or 0
    return math.max(lo, math.min(hi, v))
end

local function RemoveItem(id)
    local d=DB()
    local item=d.items[tostring(id)]
    if not item then return end
    local gid=item.groupId and tostring(item.groupId) or nil
    d.items[tostring(id)]=nil
    if gid and d.groups[gid] then
        local g=d.groups[gid]
        local kept={}
        for _,memberID in ipairs(g.itemIds or {}) do
            if tostring(memberID)~=tostring(id) and d.items[tostring(memberID)] then kept[#kept+1]=memberID end
        end
        g.itemIds=kept
        if #kept==0 then
            d.groups[gid]=nil
        else
            local first=d.items[tostring(kept[1])]
            if first then g.side=first.side;g.anchorX=first.x;g.anchorY=first.y end
        end
    end
end

local function LayoutGroup(group)
    if not group then return end
    local d=DB()
    local members={}
    local side=nil
    for _,id in ipairs(group.itemIds or {}) do
        local item=d.items[tostring(id)]
        if item then
            side=side or item.side or "front"
            if (item.side or "front")==side then members[#members+1]=item end
        end
    end
    table.sort(members,function(a,b)
        local ac=(a.category=="healing") and 3 or ((a.category=="alteration") and 2 or 1)
        local bc=(b.category=="healing") and 3 or ((b.category=="alteration") and 2 or 1)
        if ac~=bc then return ac<bc end
        return tostring(a.id)<tostring(b.id)
    end)
    group.itemIds={}
    for _,item in ipairs(members) do
        group.itemIds[#group.itemIds+1]=item.id
        item.groupId=group.id
    end
    if #members==0 then d.groups[tostring(group.id)]=nil;return end
    group.side=side or "front"
    -- Grouping is metadata only. It NEVER changes x/y: the exact drop point is
    -- authoritative, so magnet/group operations cannot make a node jump.
    local sx,sy=0,0
    for _,item in ipairs(members) do sx=sx+(tonumber(item.x) or .5);sy=sy+(tonumber(item.y) or .5) end
    group.anchorX=sx/#members;group.anchorY=sy/#members
end


local function EnsureGroupName(g)
    if not g then return "" end
    local name=Trim(g.name)
    return name~="" and name or "Injury Group"
end

local function AddToGroup(item,group)
    if not item or not group then return end
    item.groupId=group.id
    for _,id in ipairs(group.itemIds or {}) do if tostring(id)==tostring(item.id) then return end end
    group.itemIds=group.itemIds or {}; group.itemIds[#group.itemIds+1]=item.id
end

local function DefaultGroupName(item)
    if not item then return "Health Record" end
    local custom=Trim(item.customName);local kind=Trim(item.type);local healing=Trim(item.healing)
    if item.category=="alteration" then return custom~="" and custom or (kind~="" and kind or "Permanent Change") end
    if item.category=="injury" then return kind~="" and kind or "Injury" end
    return healing~="" and healing or (kind~="" and kind or "Recovery")
end

local function NewGroup(items,name)
    local d=DB(); local id
    repeat d.nextGroupID=d.nextGroupID+1; id="grp"..tostring(d.nextGroupID) until not d.groups[id]
    local first=items and items[1] or nil
    local g={id=id,name=name or DefaultGroupName(first),itemIds={},expiresAt=nil,anchorX=first and first.x or nil,anchorY=first and first.y or nil,side=first and first.side or nil}
    d.groups[g.id]=g
    for _,item in ipairs(items or {}) do AddToGroup(item,g) end
    LayoutGroup(g)
    return g
end

local function EnsureItemGroup(item)
    if not item or item.category=="healing" then return item and GetGroup(item.groupId) or nil end
    local existing=GetGroup(item.groupId)
    if existing then return existing end
    return NewGroup({item},DefaultGroupName(item))
end

local function GroupMemberCount(group)
    local n=0
    for _,id in ipairs((group and group.itemIds) or {}) do if GetItem(id) then n=n+1 end end
    return n
end

local function MergeGroups(a,b)
    if not a then return b end
    if not b or a.id==b.id then return a end
    local d=DB()
    for _,id in ipairs(b.itemIds or {}) do local item=GetItem(id); if item then AddToGroup(item,a) end end
    if not a.name or a.name=="Injury Group" then a.name=EnsureGroupName(b) end
    if a.expiresAt==nil then a.expiresAt=b.expiresAt end
    a.anchorX=a.anchorX or b.anchorX
    a.anchorY=a.anchorY or b.anchorY
    d.groups[b.id]=nil
    return a
end

local function RemoveExpired()
    local now=time()
    local d=DB()
    local removeItems={}
    local healingByGroup={}

    -- Injury Types never own a recovery timer. Only Healing Status entries
    -- can expire. The longest active timed Healing Status is the recovery gate.
    for _,item in pairs(d.items) do
        if item.category=="healing" and item.groupId then
            local gid=tostring(item.groupId)
            healingByGroup[gid]=healingByGroup[gid] or {}
            healingByGroup[gid][#healingByGroup[gid]+1]=item
        elseif item.category=="healing" and item.expiresAt and tonumber(item.expiresAt)<=now then
            removeItems[#removeItems+1]=item.id
        end
    end

    for gid,items in pairs(healingByGroup) do
        local latest=nil
        local expired={}
        for _,item in ipairs(items) do
            local exp=tonumber(item.expiresAt)
            if exp and exp<=now then
                expired[#expired+1]=item
            elseif exp and (not latest or exp>latest) then
                latest=exp
            end
        end
        for _,item in ipairs(expired) do removeItems[#removeItems+1]=item.id end

        -- Health 2.0: expiry completes the recovery stage instead of deleting
        -- the injury.  The player can then choose Remove, Scar or Permanent
        -- Change from the record editor, preserving RP history.
        if #expired>0 and not latest then
            local g=d.groups[gid]
            if g then
                for _,id in ipairs(g.itemIds or {}) do
                    local item=d.items[tostring(id)]
                    if item and item.category=="injury" then
                        item.stage="healed"
                        item.healedAt=time()
                        if RPAL.HealthAddHistory then RPAL.HealthAddHistory("recovery",item,"Recovering","Healed",nil,"Recovery timer completed") end
                    end
                end
            end
        end
    end

    local seen={}
    for _,id in ipairs(removeItems) do
        if not seen[tostring(id)] and d.items[tostring(id)] then
            seen[tostring(id)]=true
            RemoveItem(id)
        end
    end
    return next(seen)~=nil
end

local function Remaining(expiresAt)
    if not expiresAt then return nil end
    local value=tonumber(expiresAt)
    if not value or value~=value or value==math.huge or value==-math.huge then return nil end
    return math.max(0,value-time())
end

RPAL.GetPublicInjurySnapshot=function()
    RemoveExpired()
    local d=DB(); local out={items={},groups={}}
    for gid,g in pairs(d.groups) do
        local itemIds={}
        for _,id in ipairs(g.itemIds or {}) do if d.items[tostring(id)] then itemIds[#itemIds+1]=tostring(id) end end
        if #itemIds>0 then
            out.groups[tostring(gid)]={id=tostring(gid),name=EnsureGroupName(g),itemIds=itemIds}
        end
    end
    for id,item in pairs(d.items) do
        out.items[tostring(id)]={id=tostring(id),type=item.type or "Cut",healing=item.healing,category=item.category or "injury",customName=item.customName,x=tonumber(item.x) or .5,y=tonumber(item.y) or .5,side=item.side or "front",groupId=item.groupId and tostring(item.groupId) or nil,scale=tonumber(item.scale) or 1,rotation=tonumber(item.rotation) or 0,note=item.note,remaining=(item.category=="healing") and Remaining(item.expiresAt) or nil}
    end
    return out
end

local function SaveGroupName(group,newName)
    local n=Trim(newName); if n=="" then n="Injury Group" end
    group.name=n
end

local function FindMagnetTarget(x,y,excludeID,side)
    local d=DB()
    local best,bestDist=nil,nil
    side=side or "front"

    -- IMPORTANT: grouping is based only on the normalized coordinates stored
    -- on the body.  It is deliberately independent of frame strata, parent,
    -- hit rects and WoW's drag system.
    --
    -- The radius is intentionally generous relative to the small body image so
    -- that dropping "near" another marker is enough to trigger the magnet.
    local R=.145
    local R2=R*R
    for id,item in pairs(d.items) do
        if tostring(id)~=tostring(excludeID) and (item.side or "front")==side then
            local dx=(tonumber(item.x) or .5)-x
            local dy=(tonumber(item.y) or .5)-y
            local dist=dx*dx+dy*dy
            if dist<=R2 and (not bestDist or dist<bestDist) then
                best=item
                bestDist=dist
            end
        end
    end
    return best
end

local function RemoveFromGroup(item)
    if not item or not item.groupId then return end
    local d=DB();local gid=tostring(item.groupId);local g=d.groups[gid]
    item.groupId=nil
    if not g then return end
    local kept={}
    for _,id in ipairs(g.itemIds or {}) do
        if tostring(id)~=tostring(item.id) and d.items[tostring(id)] then kept[#kept+1]=id end
    end
    g.itemIds=kept
    if #kept==0 then d.groups[gid]=nil else LayoutGroup(g) end
end

local function CreateOrMoveItem(itemType,healing,x,y,movingID,category,side)
    local d=DB()
    side=side=="back" and "back" or "front"
    x=Clamp(x,.02,.98);y=Clamp(y,.02,.98)
    local item=movingID and GetItem(movingID) or nil
    local oldGroup=item and GetGroup(item.groupId) or nil
    local oldCount=GroupMemberCount(oldGroup)
    local originalSide=item and (item.side or "front") or nil

    if not item then
        local fixedCategory=(category=="healing" and "healing") or (category=="alteration" and "alteration") or "injury"
        item={id=NewID("inj"),type=itemType,healing=healing,x=x,y=y,side=side,scale=1,rotation=0,createdAt=time(),category=fixedCategory,note=nil}
        d.items[item.id]=item
        if fixedCategory~="healing" then EnsureItemGroup(item) end
    else
        -- MOVE means position only. Type/category/name/note/transform are immutable
        -- here, which prevents a Prosthetic from ever becoming an Injury because
        -- of drag-source state or a recycled frame.
        item.x=x;item.y=y;item.side=side
    end

    -- Crossing FRONT/BACK detaches a member only when it was actually linked to
    -- other nodes. A one-node Health card simply follows the marker.
    if oldGroup and originalSide~=side then
        if oldCount>1 then RemoveFromGroup(item) else oldGroup.side=side;oldGroup.anchorX=x;oldGroup.anchorY=y end
    end

    local target=FindMagnetTarget(x,y,item.id,side)
    if target then
        local g1=GetGroup(item.groupId)
        local g2=GetGroup(target.groupId)
        local g
        if g1 and g2 then
            g=(g1.id==g2.id) and g1 or MergeGroups(g1,g2)
        elseif g1 then
            g=g1;AddToGroup(target,g)
        elseif g2 then
            g=g2;AddToGroup(item,g)
        else
            g=NewGroup({target,item},DefaultGroupName(target))
        end
        if g then g.side=side;LayoutGroup(g) end
    else
        local current=GetGroup(item.groupId)
        if current and GroupMemberCount(current)>1 then RemoveFromGroup(item) end
        if item.category~="healing" then EnsureItemGroup(item) end
        local solo=GetGroup(item.groupId)
        if solo and GroupMemberCount(solo)==1 then solo.side=side;solo.anchorX=x;solo.anchorY=y end
    end
    return item
end

-- Canonical drag geometry. Saved positions are normalized body coordinates and
-- are independent from marker scale/rotation.  We deliberately avoid
-- GetScaledRect here: cursor coordinates are physical screen pixels while
-- GetScaledRect can be expressed in a different scaled coordinate space.  Using
-- the region's effective scale explicitly gives us one unambiguous screen-pixel
-- space for both hit testing and drop math.
local function UIParentScale()
    local scale=(UIParent and UIParent.GetEffectiveScale and UIParent:GetEffectiveScale()) or 1
    if not scale or scale<=0 then scale=1 end
    return scale
end

local function CursorScreenPosition()
    local cx,cy=GetCursorPosition();local scale=UIParentScale()
    return (cx or 0)/scale,(cy or 0)/scale
end

local function RegionScreenRect(region)
    if not region then return nil end
    local l,b,w,h=region:GetLeft(),region:GetBottom(),region:GetWidth(),region:GetHeight()
    if not l or not b or not w or not h or w<=0 or h<=0 then return nil end
    local scale=(region.GetEffectiveScale and region:GetEffectiveScale()) or 1
    if not scale or scale<=0 then scale=1 end
    return l*scale,b*scale,w*scale,h*scale
end

local function BodyScreenRect(body)
    return RegionScreenRect(body)
end

local function BodyPointRaw(body)
    local left,bottom,w,h=BodyScreenRect(body);if not left then return nil,nil end
    local sx,sy=GetCursorPosition();if sx==nil or sy==nil then return nil,nil end
    return (sx-left)/w,1-((sy-bottom)/h)
end

local function RegionCenterInUIParent(region)
    local left,bottom,w,h=RegionScreenRect(region);if not left then return CursorScreenPosition() end
    local scale=UIParentScale()
    return (left+w*.5)/scale,(bottom+h*.5)/scale
end

local function BodyPointToUIParent(body,x,y)
    local left,bottom,w,h=BodyScreenRect(body);if not left then return CursorScreenPosition() end
    local px=left+Clamp(x,0,1)*w;local py=bottom+(1-Clamp(y,0,1))*h
    local scale=UIParentScale()
    return px/scale,py/scale
end

local function GetDropBody()
    local x,y=BodyPointRaw(injuryUI.body)
    if x and y and x>=0 and x<=1 and y>=0 and y<=1 then return injuryUI.body,x,y end
    local bx,by=BodyPointRaw(injuryUI.backBody)
    if bx and by and bx>=0 and bx<=1 and by>=0 and by<=1 then return injuryUI.backBody,bx,by end
    return nil
end

local function MarkerColor(item)
    local p=P();
    if item.category=="healing" then return p.heal or PALETTE.heal end
    if item.category=="alteration" then return p.alteration or PALETTE.alteration end
    local red=p.red or PALETTE.red
    local heal=p.heal or PALETTE.heal
    local gold=p.gold or PALETTE.gold or {0.90,0.70,0.25,1}
    local muted=p.muted or {0.55,0.52,0.48,1}
    local stage=tostring(item.stage or "fresh"):lower()
    if stage=="treated" then return {(red[1]*.55)+(gold[1]*.45),(red[2]*.55)+(gold[2]*.45),(red[3]*.55)+(gold[3]*.45),red[4] or 1} end
    if stage=="recovering" then return {(red[1]*.28)+(heal[1]*.72),(red[2]*.28)+(heal[2]*.72),(red[3]*.28)+(heal[3]*.72),red[4] or 1} end
    if stage=="healed" then return {(muted[1]*.72)+(heal[1]*.28),(muted[2]*.72)+(heal[2]*.28),(muted[3]*.72)+(heal[3]*.28),.82} end
    return red
end

local function ItemLabel(item)
    if not item then return "Injury" end
    local kind=Trim(item.type);local healing=Trim(item.healing)
    if item.category=="healing" then return healing~="" and healing or (kind~="" and kind or "Healing") end
    if item.category=="alteration" then
        local custom=Trim(item.customName)
        if custom~="" then return custom end
        return kind~="" and kind or "Permanent Change"
    end
    return kind~="" and kind or "Injury"
end

-- Marker transforms are presentation-only.  The clickable parent frame always
-- stays centred on the saved body coordinate; every visible region is rotated
-- together around that centre.  This keeps scale/rotation completely separate
-- from drag/drop data while still making the whole card (box, text and close
-- glyph) visibly rotate as one object.
local function RotateXY(x,y,radians)
    local c,s=math.cos(radians),math.sin(radians)
    return x*c-y*s,x*s+y*c
end

local function BuildMarkerArtwork(host,w,h,titleSize,subSize,withSub,withClose)
    host._markerW=w;host._markerH=h
    host.bgTex=host:CreateTexture(nil,"BACKGROUND",nil,-2);host.bgTex:SetTexture("Interface\\Buttons\\WHITE8X8");host.bgTex:SetSize(w,h);host.bgTex:SetPoint("CENTER")
    host.tintTex=host:CreateTexture(nil,"BACKGROUND",nil,-1);host.tintTex:SetTexture("Interface\\Buttons\\WHITE8X8");host.tintTex:SetSize(math.max(1,w-10),math.max(1,h-10));host.tintTex:SetPoint("CENTER");host.tintTex:SetBlendMode("ADD")
    host.edgeTop=host:CreateTexture(nil,"ARTWORK");host.edgeBottom=host:CreateTexture(nil,"ARTWORK");host.edgeLeft=host:CreateTexture(nil,"ARTWORK");host.edgeRight=host:CreateTexture(nil,"ARTWORK")
    for _,edge in ipairs({host.edgeTop,host.edgeBottom,host.edgeLeft,host.edgeRight}) do edge:SetTexture("Interface\\Buttons\\WHITE8X8") end
    host.title=Font(host,"",titleSize or 10.5,P().pale);host.title:SetWidth(math.max(20,w-14));host.title:SetHeight(math.max(12,h*.42));host.title:SetJustifyH("CENTER");host.title:SetWordWrap(false)
    if withSub then host.sub=Font(host,"",subSize or 6.8,P().muted);host.sub:SetWidth(math.max(20,w-14));host.sub:SetHeight(math.max(9,h*.28));host.sub:SetJustifyH("CENTER");host.sub:SetWordWrap(false) end
    if withClose then host.close=Font(host,"×",12,P().red or PALETTE.red);host.close:SetWidth(14);host.close:SetHeight(14);host.close:SetJustifyH("CENTER") end
end

local function SetRotatedRegionPoint(region,host,x,y,radians)
    if not region then return end
    local rx,ry=RotateXY(x,y,radians)
    region:ClearAllPoints();region:SetPoint("CENTER",host,"CENTER",rx,ry)
    if region.SetRotation then region:SetRotation(radians) end
end

local function ApplyMarkerArtwork(host,title,sub,color,rotation,bg)
    if not host or not host._markerW then return end
    local radians=math.rad(tonumber(rotation) or 0)
    local w,h=host._markerW,host._markerH
    local background=bg or P().panel2 or PALETTE.panel2
    host.bgTex:SetVertexColor(background[1] or .08,background[2] or .06,background[3] or .05,background[4] or .96)
    host.tintTex:SetVertexColor(color[1],color[2],color[3],.15)
    if host.bgTex.SetRotation then host.bgTex:SetRotation(radians) end
    if host.tintTex.SetRotation then host.tintTex:SetRotation(radians) end

    host.edgeTop:SetSize(w,1);host.edgeBottom:SetSize(w,1);host.edgeLeft:SetSize(1,h);host.edgeRight:SetSize(1,h)
    for _,edge in ipairs({host.edgeTop,host.edgeBottom,host.edgeLeft,host.edgeRight}) do edge:SetVertexColor(color[1],color[2],color[3],1) end
    SetRotatedRegionPoint(host.edgeTop,host,0,h*.5,radians)
    SetRotatedRegionPoint(host.edgeBottom,host,0,-h*.5,radians)
    SetRotatedRegionPoint(host.edgeLeft,host,-w*.5,0,radians)
    SetRotatedRegionPoint(host.edgeRight,host,w*.5,0,radians)

    if host.title then
        host.title:SetText(title or "")
        SetRotatedRegionPoint(host.title,host,0,host.sub and 7 or 0,radians)
    end
    if host.sub then
        host.sub:SetText(sub or "")
        host.sub:SetTextColor(unpack(color))
        SetRotatedRegionPoint(host.sub,host,0,-10,radians)
    end
    if host.close then
        host.close:SetTextColor(unpack(color))
        SetRotatedRegionPoint(host.close,host,w*.5-5,h*.5-6,radians)
    end
end

local function UpdateMarkerHitbox(button,visualScale,rotation,w,h)
    if not button then return end
    local scale=math.max(.65,math.min(1.55,tonumber(visualScale) or 1))
    local radians=math.rad(tonumber(rotation) or 0)
    local c,s=math.abs(math.cos(radians)),math.abs(math.sin(radians))
    local bw=(w or 104)*scale*c+(h or 40)*scale*s
    local bh=(w or 104)*scale*s+(h or 40)*scale*c
    -- A little padding keeps the entire rotated/scaled visual interactive.
    button:SetSize(math.max(34,bw+12),math.max(28,bh+12))
end

local function MakeSmallButton(parent,text,w,h)
    local p=P(); local b=CreateFrame("Button",nil,parent,"BackdropTemplate"); b:SetSize(w or 90,h or 28); Backdrop(b,p.panel2,p.border)
    local t=Font(b,text,9,p.pale);t:SetPoint("CENTER");b.label=t
    b:SetScript("OnEnter",function(self)self:SetBackdropColor(p.buttonHover and p.buttonHover[1] or .16,p.buttonHover and p.buttonHover[2] or .11,p.buttonHover and p.buttonHover[3] or .07,1)end)
    b:SetScript("OnLeave",function(self)self:SetBackdropColor(unpack(p.panel2))end)
    return b
end

local function MakeEdit(parent,w,h,text)
    local p=P(); local e=CreateFrame("EditBox",nil,parent,"InputBoxTemplate");e:SetSize(w,h);e:SetAutoFocus(false);e:SetText(text or "");e:SetFont(STANDARD_TEXT_FONT,9,"OUTLINE");e:SetTextColor(unpack(p.pale));e:SetTextInsets(7,7,0,0);e:EnableMouse(true);e:SetScript("OnMouseDown",function(self)self:SetFocus()end);e:SetScript("OnEscapePressed",function(self)self:ClearFocus()end);return e
end

local function Format(sec)
    if not sec then return "—" end
    if sec~=sec or sec==math.huge or sec==-math.huge then return "—" end
    sec=math.max(0,math.floor(sec))
    if sec<60 then return tostring(sec).."s" end
    local m=math.floor(sec/60);local s=sec%60
    if m<60 then return string.format("%dm %02ds",m,s) end
    return string.format("%dh %02dm",math.floor(m/60),m%60)
end

local function ParseDurationFields(dayEdit,hourEdit,minuteEdit)
    local function Part(edit,maximum)
        local value=tonumber(edit and edit:GetText()) or 0
        if value~=value or value==math.huge or value==-math.huge then value=0 end
        return math.max(0,math.min(maximum,math.floor(value)))
    end
    local days=Part(dayEdit,3650)
    local hours=Part(hourEdit,23)
    local minutes=Part(minuteEdit,59)
    if days==0 and hours==0 and minutes==0 then return nil end
    return days*86400+hours*3600+minutes*60
end

local function DurationParts(seconds)
    seconds=math.max(0,math.floor(tonumber(seconds) or 0))
    local d=math.floor(seconds/86400); seconds=seconds%86400
    local h=math.floor(seconds/3600); seconds=seconds%3600
    local m=math.floor(seconds/60)
    return d,h,m
end

local function SetTimerFields(dayEdit,hourEdit,minuteEdit,callback)
    local seconds=ParseDurationFields(dayEdit,hourEdit,minuteEdit)
    callback(seconds and (time()+seconds) or nil)
end

local function DestroyFloatingMarker(marker)
    if not marker then return end
    marker:Hide();marker:ClearAllPoints();marker:SetParent(nil)
end

local function CancelActiveDrag()
    local drag=injuryUI.drag;injuryUI.drag=nil
    if not drag then return end
    if drag.source then drag.source:SetAlpha(1) end
    DestroyFloatingMarker(drag.ghost)
end

local function MakeDragGhost(kind,healing,category,visualScale,rotation)
    local p=P();local c=(category=="alteration" and (p.alteration or PALETTE.alteration)) or (category=="healing" and (p.heal or PALETTE.heal)) or (p.red or PALETTE.red)
    local g=CreateFrame("Frame",nil,UIParent);g:SetSize(112,48);g:SetFrameStrata("TOOLTIP");g:SetFrameLevel(10000);g:EnableMouse(false)
    g.visual=CreateFrame("Frame",nil,g);g.visual:SetSize(104,40);g.visual:SetPoint("CENTER");g.visual:EnableMouse(false);g.visual:SetScale(math.max(.65,math.min(1.55,tonumber(visualScale) or 1)))
    BuildMarkerArtwork(g.visual,104,40,9,6.2,true,false)
    local sub=(category=="alteration" and "PERMANENT CHANGE") or (category=="healing" and "HEALING STATUS") or "INJURY TYPE"
    ApplyMarkerArtwork(g.visual,kind or healing or "Health",sub,c,rotation or 0,{p.panel2[1],p.panel2[2],p.panel2[3],.90})
    UpdateMarkerHitbox(g,visualScale,rotation,104,40)
    g:Show();return g
end

local function BeginHealthDrag(spec)
    if injuryUI.drag then return false end
    spec=spec or {};local item=spec.movingID and GetItem(spec.movingID) or nil
    local body=item and ((item.side=="back" and injuryUI.backBody) or injuryUI.body) or nil
    local grabDX,grabDY=0,0
    if item and body then
        local cx,cy=BodyPointRaw(body)
        if cx and cy then grabDX=(tonumber(item.x) or .5)-cx;grabDY=(tonumber(item.y) or .5)-cy end
    end

    -- Visual drag tracking is intentionally separate from drop geometry.  The
    -- ghost keeps the exact cursor-to-marker-centre offset captured on mouse
    -- down, so resizing/rotating a node can never make it drift away while the
    -- normalized body-space drop calculation remains untouched.
    local ghostDX,ghostDY=0,0
    if item and spec.source then
        local mx,my=RegionCenterInUIParent(spec.source)
        local cx,cy=CursorScreenPosition()
        ghostDX=(mx or cx)-cx;ghostDY=(my or cy)-cy
    end

    local category=item and item.category or ((spec.category=="healing" and "healing") or (spec.category=="alteration" and "alteration") or "injury")
    injuryUI.drag={movingID=item and item.id or nil,kind=item and item.type or spec.kind,healing=item and item.healing or spec.healing,category=category,source=spec.source,newItem=not item,originalSide=item and item.side or nil,grabDX=grabDX,grabDY=grabDY,ghostDX=ghostDX,ghostDY=ghostDY,ghost=MakeDragGhost(item and ItemLabel(item) or spec.kind,spec.healing,category,item and item.scale or 1,item and item.rotation or 0)}
    if spec.source then spec.source:SetAlpha(item and .18 or .45) end
    return true
end

local function UpdateHealthDrag()
    local drag=injuryUI.drag;if not drag then return end
    local body,rawX,rawY=GetDropBody();local finalX,finalY,target
    if body then
        finalX=Clamp((rawX or .5)+(drag.grabDX or 0),.02,.98)
        finalY=Clamp((rawY or .5)+(drag.grabDY or 0),.02,.98)
        local side=(body==injuryUI.backBody) and "back" or "front"
        target=FindMagnetTarget(finalX,finalY,drag.movingID,side)
        if target then finalX=tonumber(target.x) or finalX;finalY=tonumber(target.y) or finalY end
        drag.previewBody=body;drag.previewX=finalX;drag.previewY=finalY;drag.snapTargetID=target and target.id or nil
    else
        drag.previewBody=nil;drag.previewX=nil;drag.previewY=nil;drag.snapTargetID=nil
    end
    if drag.ghost then
        local gx,gy=CursorScreenPosition()
        gx=gx+(drag.ghostDX or 0);gy=gy+(drag.ghostDY or 0)
        drag.ghost:ClearAllPoints();drag.ghost:SetPoint("CENTER",UIParent,"BOTTOMLEFT",gx,gy)
    end
end

local function FinishHealthDrag()
    local drag=injuryUI.drag;if not drag then return end
    UpdateHealthDrag();injuryUI.drag=nil
    if drag.source then drag.source:SetAlpha(1) end
    DestroyFloatingMarker(drag.ghost)
    local body,x,y=drag.previewBody,drag.previewX,drag.previewY
    if body and x and y then
        local side=(body==injuryUI.backBody) and "back" or "front"
        if drag.movingID then
            local item=GetItem(drag.movingID)
            if item then CreateOrMoveItem(nil,nil,x,y,item.id,nil,side) end
        else
            CreateOrMoveItem(drag.kind,drag.healing,x,y,nil,drag.category,side)
        end
    end
    RPAL.RefreshInjuryUI()
end

local function MakeDragSource(parent,label,kind,healing,category)
    local p=P(); local b=CreateFrame("Button",nil,parent,"BackdropTemplate")
    b:SetSize(144,34);Backdrop(b,p.panel2,p.border);b:EnableMouse(true)
    local accent=b:CreateTexture(nil,"ARTWORK");accent:SetTexture("Interface\\Buttons\\WHITE8X8");accent:SetSize(3,24);accent:SetPoint("LEFT",5,0)
    local c=(category=="alteration" and (p.alteration or PALETTE.alteration)) or (category=="healing" and (p.heal or PALETTE.heal)) or (p.red or PALETTE.red);accent:SetVertexColor(unpack(c))
    local t=Font(b,label,11,p.pale);t:SetPoint("LEFT",16,0);t:SetJustifyH("LEFT")
    b:SetScript("OnEnter",function()b:SetBackdropBorderColor(unpack(c))end);b:SetScript("OnLeave",function()b:SetBackdropBorderColor(unpack(p.border))end)
    b:SetScript("OnMouseDown",function(self,button)if button=="LeftButton" then BeginHealthDrag({kind=kind,healing=healing,category=category,source=self}) end end)
    b:SetScript("OnMouseUp",function(self,button)if button=="LeftButton" and injuryUI.drag and injuryUI.drag.source==self then FinishHealthDrag() end end)
    return b
end

local function AcquireMarker(body)
    local b=table.remove(injuryUI.markerPool)
    if not b then
        -- The parent button is an interaction-only bounding box. Its centre is
        -- the saved body coordinate and never participates in scale/rotation
        -- math; its size merely grows to cover the transformed artwork so no
        -- part of a large/rotated marker loses mouse interaction.
        b=CreateFrame("Button",nil,body);b:SetSize(112,48);b:SetScale(1);b:EnableMouse(true);b:RegisterForClicks("LeftButtonUp","RightButtonUp")
        b.visual=CreateFrame("Frame",nil,b);b.visual:SetSize(104,40);b.visual:SetPoint("CENTER");b.visual:EnableMouse(false)
        BuildMarkerArtwork(b.visual,104,40,10.5,6.8,true,true)
        b.title=b.visual.title;b.sub=b.visual.sub;b.close=b.visual.close
        b:SetScript("OnEnter",function(self)
            local item=GetItem(self.itemID);if not item then return end
            local color=MarkerColor(item)
            local group=item.groupId and GetGroup(item.groupId)
            if RPAL.ShowCursorTooltip then RPAL.ShowCursorTooltip({eyebrow=(item.category=="alteration" and "PERMANENT CHANGE") or (item.category=="healing" and "HEALING STATUS") or "INJURY",title=ItemLabel(item),body=(item.note and item.note~="") and item.note or ((item.category=="alteration" and (item.type or "Permanent Change")) or (item.category=="healing" and "Healing Status") or "Injury Type"),sectionTitle=group and "HEALTH GROUP" or nil,section=group and EnsureGroupName(group) or "",accent=color,width=320}) end
        end)
        b:SetScript("OnLeave",function()if RPAL.HideCursorTooltip then RPAL.HideCursorTooltip() end end)
        b:SetScript("OnClick",function(self,button)if button=="RightButton" and self.itemID then RemoveItem(self.itemID);RPAL.RefreshInjuryUI() end end)
        b:SetScript("OnMouseDown",function(self,button)
            local item=GetItem(self.itemID);if button~="LeftButton" or not item then return end
            BeginHealthDrag({movingID=item.id,source=self})
        end)
        b:SetScript("OnMouseUp",function(self,button)if button=="LeftButton" and injuryUI.drag and injuryUI.drag.source==self then FinishHealthDrag() end end)
    else
        b:SetParent(body)
    end
    injuryUI.activeMarkers[#injuryUI.activeMarkers+1]=b;return b
end

local function ReleaseMarkers()
    for _,marker in ipairs(injuryUI.activeMarkers or {}) do
        marker:Hide();marker:ClearAllPoints();marker:SetAlpha(1);marker:SetScale(1);marker.itemID=nil
        if marker.visual then marker.visual:SetScale(1) end
        injuryUI.markerPool[#injuryUI.markerPool+1]=marker
    end
    injuryUI.activeMarkers={}
end

local function CreateMarker(body,item)
    local p=P();local c=MarkerColor(item);local b=AcquireMarker(body);b.itemID=item.id;b:SetScale(1)
    local visualScale=math.max(.65,math.min(1.55,tonumber(item.scale) or 1))
    local rotation=tonumber(item.rotation) or 0
    if b.visual then
        b.visual:SetScale(visualScale)
        b.title:SetTextColor(unpack(p.pale))
        ApplyMarkerArtwork(b.visual,ItemLabel(item),(item.category=="healing") and "HEALING STATUS" or ((item.category=="alteration") and "PERMANENT CHANGE" or "INJURY TYPE"),c,rotation,{p.panel2[1],p.panel2[2],p.panel2[3],.96})
    end
    UpdateMarkerHitbox(b,visualScale,rotation,104,40)
    b:SetPoint("CENTER",body,"TOPLEFT",(tonumber(item.x) or .5)*body:GetWidth(),-(tonumber(item.y) or .5)*body:GetHeight());b:Show();return b
end

local function IsGroupCollapsed(groupID)
    return injuryUI.collapsedGroups and injuryUI.collapsedGroups[tostring(groupID)] == true
end

local function SetGroupCollapsed(groupID, collapsed)
    injuryUI.collapsedGroups = injuryUI.collapsedGroups or {}
    injuryUI.collapsedGroups[tostring(groupID)] = collapsed and true or nil
end

local function NewRightRow(kind,host)
    local row=CreateFrame("Frame",nil,host,"BackdropTemplate")
    row._poolKind=kind
    if kind=="section" then
        row:SetSize(274,20);row.label=Font(row,"",9,P().gold);row.label:SetPoint("TOPLEFT",8,0)
    elseif kind=="group" then
        row:SetSize(274,46)
        row.accent=row:CreateTexture(nil,"ARTWORK");row.accent:SetTexture("Interface\\Buttons\\WHITE8X8");row.accent:SetSize(3,46);row.accent:SetPoint("LEFT",0,0)
        row.toggle=CreateFrame("Button",nil,row);row.toggle:SetSize(28,30);row.toggle:SetPoint("LEFT",6,0);row.toggle:EnableMouse(true)
        row.toggleText=Font(row.toggle,"−",15,P().gold);row.toggleText:SetPoint("CENTER")
        row.nameButton=CreateFrame("Button",nil,row);row.nameButton:SetSize(118,26);row.nameButton:SetPoint("LEFT",row.toggle,"RIGHT",3,0);row.nameButton:EnableMouse(true)
        row.nameLabel=Font(row.nameButton,"",11.5,P().pale);row.nameLabel:SetAllPoints();row.nameLabel:SetJustifyH("LEFT");row.nameLabel:SetWordWrap(false)
        row.nameEdit=CreateFrame("EditBox",nil,row,"BackdropTemplate");row.nameEdit:SetSize(118,26);row.nameEdit:SetPoint("LEFT",row.nameButton,"LEFT");row.nameEdit:SetAutoFocus(false);row.nameEdit:SetFont(STANDARD_TEXT_FONT,11.5,"OUTLINE");row.nameEdit:SetTextInsets(6,6,0,0);row.nameEdit:Hide()
        row.count=Font(row,"",8.5,P().muted);row.count:SetPoint("RIGHT",-8,0);row.count:SetWidth(116);row.count:SetJustifyH("RIGHT");row.count:SetWordWrap(false)
        local function FinishName(commit)
            if row._finishing then return end
            row._finishing=true
            if row._group and commit then SaveGroupName(row._group,row.nameEdit:GetText()) end
            local name=row._group and EnsureGroupName(row._group) or "Injury Group"
            row.nameLabel:SetText(name);row.nameEdit:SetText(name);row.nameEdit:ClearFocus();row.nameEdit:Hide();row.nameLabel:Show()
            row._finishing=nil
        end
        row.toggle:SetScript("OnClick",function()
            if row._group then SetGroupCollapsed(row._group.id,not IsGroupCollapsed(row._group.id));RPAL.RefreshInjuryUI() end
        end)
        row.nameButton:SetScript("OnClick",function()
            if not row._group then return end
            row.nameLabel:Hide();row.nameEdit:SetText(EnsureGroupName(row._group));row.nameEdit:Show();row.nameEdit:SetFocus();row.nameEdit:HighlightText()
        end)
        row.nameEdit:SetScript("OnEnterPressed",function()FinishName(true)end)
        row.nameEdit:SetScript("OnEscapePressed",function()FinishName(false)end)
        row.nameEdit:SetScript("OnEditFocusLost",function()FinishName(true)end)
    elseif kind=="healing" then
        row:SetSize(274,142)
        row.dot=row:CreateTexture(nil,"ARTWORK");row.dot:SetTexture("Interface\\Buttons\\WHITE8X8");row.dot:SetSize(3,126);row.dot:SetPoint("LEFT",0,0)
        row.title=Font(row,"",12,P().pale);row.title:SetPoint("TOPLEFT",10,-9)
        row.status=Font(row,"HEALING STATUS",8.5,P().heal or PALETTE.heal);row.status:SetPoint("TOPLEFT",10,-27)
        row.day=MakeEdit(row,38,20,"0");row.day:SetPoint("TOPLEFT",8,-46);row.hour=MakeEdit(row,38,20,"0");row.hour:SetPoint("TOPLEFT",51,-46);row.minute=MakeEdit(row,38,20,"0");row.minute:SetPoint("TOPLEFT",94,-46)
        row.dayLabel=Font(row,"DAYS",6.5,P().muted);row.dayLabel:SetPoint("TOP",row.day,"BOTTOM",0,-2);row.hourLabel=Font(row,"HOURS",6.5,P().muted);row.hourLabel:SetPoint("TOP",row.hour,"BOTTOM",0,-2);row.minuteLabel=Font(row,"MIN",6.5,P().muted);row.minuteLabel:SetPoint("TOP",row.minute,"BOTTOM",0,-2)
        row.set=MakeSmallButton(row,"SET",43,20);row.set:SetPoint("TOPLEFT",137,-46);row.remaining=Font(row,"—",8,P().gold);row.remaining:SetPoint("TOPRIGHT",-9,-50)
        row.noteLabel=Font(row,"NOTE · VISIBLE ON INSPECT",6.8,P().muted);row.noteLabel:SetPoint("TOPLEFT",10,-82)
        row.noteBox=CreateFrame("EditBox",nil,row,"BackdropTemplate");row.noteBox:SetSize(254,40);row.noteBox:SetPoint("TOPLEFT",8,-96);row.noteBox:SetAutoFocus(false);row.noteBox:SetMultiLine(true);row.noteBox:SetMaxLetters(600);row.noteBox:SetFont(STANDARD_TEXT_FONT,8.5,"OUTLINE");row.noteBox:SetTextInsets(6,6,5,5);Backdrop(row.noteBox,P().panel,P().border)
        row.noteBox:SetScript("OnEscapePressed",function(self)self:ClearFocus()end);row.noteBox:SetScript("OnEditFocusLost",function(self)local item=GetItem(row.itemID);if item then item.note=NormalizeNote(self:GetText()) end end)
        row.set:SetScript("OnClick",function()local item=GetItem(row.itemID);if not item then return end;SetTimerFields(row.day,row.hour,row.minute,function(expiresAt)item.expiresAt=expiresAt end);RPAL.RefreshInjuryUI()end)
    elseif kind=="injury" then
        row:SetSize(274,132)
        row.dot=row:CreateTexture(nil,"ARTWORK");row.dot:SetTexture("Interface\\Buttons\\WHITE8X8");row.dot:SetSize(3,116);row.dot:SetPoint("LEFT",0,0)
        row.title=Font(row,"",11.5,P().pale);row.title:SetPoint("TOPLEFT",10,-7);row.title:SetWidth(172);row.title:SetJustifyH("LEFT");row.title:SetWordWrap(false)
        row.status=Font(row,"INJURY TYPE",7.6,P().muted);row.status:SetPoint("TOPLEFT",10,-27);row.status:SetWidth(252);row.status:SetJustifyH("LEFT");row.status:SetWordWrap(false)
        row.details=MakeSmallButton(row,"...",28,22);row.details:SetPoint("TOPRIGHT",-8,-6);row.details:SetScript("OnClick",function()if row.itemID and RPAL.OpenHealthRecordEditor then RPAL.OpenHealthRecordEditor(row.itemID) end end)
        row.rename=MakeSmallButton(row,"NAME",54,22);row.rename:SetPoint("TOPRIGHT",-40,-6);row.rename:Hide()
        row.nameEdit=MakeEdit(row,170,22,"");row.nameEdit:SetPoint("TOPLEFT",8,-5);row.nameEdit:Hide()
        local function FinishItemName(commit)
            if row._nameFinishing then return end;row._nameFinishing=true;local item=GetItem(row.itemID)
            if item and item.category=="alteration" and commit then local value=Trim(row.nameEdit:GetText());item.customName=(value~="" and value) or nil end
            row.nameEdit:ClearFocus();row.nameEdit:Hide();row.title:Show();row.rename:Show();row._nameFinishing=nil;if RPAL.RefreshInjuryUI then RPAL.RefreshInjuryUI() end
        end
        row.rename:SetScript("OnClick",function()local item=GetItem(row.itemID);if not item or item.category~="alteration" then return end;row.title:Hide();row.rename:Hide();row.nameEdit:SetText(ItemLabel(item));row.nameEdit:Show();row.nameEdit:SetFocus();row.nameEdit:HighlightText()end)
        row.nameEdit:SetScript("OnEnterPressed",function()FinishItemName(true)end);row.nameEdit:SetScript("OnEscapePressed",function()FinishItemName(false)end);row.nameEdit:SetScript("OnEditFocusLost",function()if row.nameEdit:IsShown() then FinishItemName(true) end end)
        row.transformInfo=Font(row,"SIZE 100%  •  ROT 0 deg",7.1,P().muted);row.transformInfo:SetPoint("TOPLEFT",10,-48)
        row.sizeDown=MakeSmallButton(row,"S-",30,19);row.sizeDown:SetPoint("TOPRIGHT",-111,-42);row.sizeUp=MakeSmallButton(row,"S+",30,19);row.sizeUp:SetPoint("LEFT",row.sizeDown,"RIGHT",3,0);row.rotDown=MakeSmallButton(row,"R-",30,19);row.rotDown:SetPoint("LEFT",row.sizeUp,"RIGHT",3,0);row.rotUp=MakeSmallButton(row,"R+",30,19);row.rotUp:SetPoint("LEFT",row.rotDown,"RIGHT",3,0)
        row.noteLabel=Font(row,"NOTE · VISIBLE ON INSPECT",6.8,P().muted);row.noteLabel:SetPoint("TOPLEFT",10,-77)
        row.noteBox=CreateFrame("EditBox",nil,row,"BackdropTemplate");row.noteBox:SetSize(254,38);row.noteBox:SetPoint("TOPLEFT",8,-90);row.noteBox:SetAutoFocus(false);row.noteBox:SetMultiLine(true);row.noteBox:SetMaxLetters(600);row.noteBox:SetFont(STANDARD_TEXT_FONT,8.5,"OUTLINE");row.noteBox:SetTextInsets(6,6,5,5);Backdrop(row.noteBox,P().panel,P().border)
        row.noteBox:SetScript("OnEscapePressed",function(self)self:ClearFocus()end);row.noteBox:SetScript("OnEditFocusLost",function(self)local item=GetItem(row.itemID);if item then item.note=NormalizeNote(self:GetText()) end end)
        local function AdjustTransform(ds,dr)
            local item=GetItem(row.itemID);if not item then return end
            if row.noteBox then item.note=NormalizeNote(row.noteBox:GetText()) end
            item.scale=math.max(.65,math.min(1.55,(tonumber(item.scale) or 1)+(ds or 0)));item.rotation=((tonumber(item.rotation) or 0)+(dr or 0))%360;RPAL.RefreshInjuryUI()
        end
        row.sizeDown:SetScript("OnClick",function()AdjustTransform(-.10,0)end);row.sizeUp:SetScript("OnClick",function()AdjustTransform(.10,0)end);row.rotDown:SetScript("OnClick",function()AdjustTransform(0,-15)end);row.rotUp:SetScript("OnClick",function()AdjustTransform(0,15)end)
    elseif kind=="empty" then
        row:SetSize(274,92)
        row.title=Font(row,"NO ACTIVE RECOVERY",11,P().pale);row.title:SetPoint("TOPLEFT",12,-12)
        row.body=Font(row,"Add a Healing Status and assign a duration to begin recovery.",9,P().muted);row.body:SetPoint("TOPLEFT",12,-34);row.body:SetWidth(248);row.body:SetWordWrap(true)
    end
    return row
end

local function ReleaseRightRows()
    for _,row in ipairs(injuryUI.rightActive or {}) do
        if row.noteBox then row.noteBox:ClearFocus() end
        if row.nameEdit then row.nameEdit:ClearFocus();row.nameEdit:Hide();if row.nameLabel then row.nameLabel:Show() end;if row.title then row.title:Show() end;if row.rename then row.rename:Hide() end end
        row._group=nil;row.itemID=nil;row:Hide();row:ClearAllPoints()
        local pool=injuryUI.rightPools[row._poolKind] or {}
        injuryUI.rightPools[row._poolKind]=pool;pool[#pool+1]=row
    end
    injuryUI.rightActive={}
end

local function AcquireRightRow(kind,host)
    local pool=injuryUI.rightPools[kind] or {};injuryUI.rightPools[kind]=pool
    local row=table.remove(pool) or NewRightRow(kind,host)
    row:SetParent(host);row:Show();injuryUI.rightActive[#injuryUI.rightActive+1]=row
    return row
end

local function BuildRight()
    local host=injuryUI.right
    if not host then return end
    ReleaseRightRows();injuryUI.timerLabels={};injuryUI.hasActiveTimers=false
    local p=P();local d=DB();local y=-8;local any=false

    if not host.recoveryTitle then
        host.recoveryTitle=Font(host,"RECOVERY",16,p.gold);host.recoveryTitle:SetPoint("TOPLEFT",8,-8)
        host.recoveryRule=host:CreateTexture(nil,"ARTWORK");host.recoveryRule:SetTexture("Interface\\Buttons\\WHITE8X8");host.recoveryRule:SetSize(258,1);host.recoveryRule:SetPoint("TOPLEFT",8,-31)
        host.recoveryIntro=Font(host,"Only Healing Status can have a recovery timer. Injury Types recover when their group's final timed Healing Status expires. Permanent Changes never count as an injury/debuff.",9,p.muted)
        host.recoveryIntro:SetPoint("TOPLEFT",8,-42);host.recoveryIntro:SetWidth(258);host.recoveryIntro:SetJustifyH("LEFT");host.recoveryIntro:SetWordWrap(true)
    end
    host.recoveryTitle:SetTextColor(unpack(p.gold));host.recoveryRule:SetVertexColor(p.gold[1],p.gold[2],p.gold[3],.55);host.recoveryIntro:SetTextColor(unpack(p.muted))
    -- The explanatory copy is multi-line and its height changes with font/UI
    -- scale.  Never hard-code the first Recovery card at -84: on larger text
    -- it overlaps the intro.  Reserve the actual wrapped text height plus a
    -- stable gutter before laying out cards.
    local introH=math.max(64,math.ceil(tonumber(host.recoveryIntro:GetStringHeight()) or 0))
    y=-42-introH-14

    local groups={}
    for _,group in pairs(d.groups) do groups[#groups+1]=group end
    table.sort(groups,function(a,b)
        local an=EnsureGroupName(a):lower();local bn=EnsureGroupName(b):lower()
        if an==bn then return tostring(a.id)<tostring(b.id) end
        return an<bn
    end)

    local function AddHealingRow(item)
        local row=AcquireRightRow("healing",host);row:SetPoint("TOPLEFT",0,y);Backdrop(row,p.panel2,p.border);row.itemID=item.id
        local healingColor=p.heal or PALETTE.heal;row.dot:SetVertexColor(unpack(healingColor));row.title:SetText(item.healing or item.type or "Healing")
        row.title:SetTextColor(unpack(p.pale));row.status:SetTextColor(unpack(healingColor));row.remaining:SetTextColor(unpack(p.gold))
        local days,hours,minutes=DurationParts(Remaining(item.expiresAt));row.day:SetText(tostring(days));row.hour:SetText(tostring(hours));row.minute:SetText(tostring(minutes))
        row.remaining:SetText(Format(Remaining(item.expiresAt)));injuryUI.timerLabels[#injuryUI.timerLabels+1]={id=item.id,label=row.remaining}
        if row.noteBox then row.noteBox:SetText(item.note or "");row.noteBox:SetTextColor(unpack(p.pale));Backdrop(row.noteBox,p.panel,p.border) end
        if row.noteLabel then row.noteLabel:SetTextColor(unpack(p.muted)) end
        if item.expiresAt and Remaining(item.expiresAt)~=nil then injuryUI.hasActiveTimers=true end
        y=y-148
    end

    local function AddInjuryRow(item)
        local row=AcquireRightRow("injury",host);row:SetPoint("TOPLEFT",0,y);Backdrop(row,p.panel2,p.border);row.itemID=item.id
        local isAlteration=item and item.category=="alteration"
        local injuryColor=isAlteration and (p.alteration or PALETTE.alteration) or (p.red or PALETTE.red)
        row.dot:SetVertexColor(unpack(injuryColor));row.title:SetText(ItemLabel(item));row.title:Show()
        row.title:SetTextColor(unpack(p.pale))
        if isAlteration then
            row.status:SetText(string.upper(item.type or "PERMANENT CHANGE"))
        else
            local stage=string.upper(tostring(item.stage or "FRESH"))
            local severity=string.upper(tostring(item.severity or "MODERATE"))
            row.status:SetText("INJURY  •  "..stage.."  •  "..severity)
        end
        row.status:SetTextColor(unpack(injuryColor))
        if row.rename then row.rename:SetShown(isAlteration);row.rename.label:SetText((item.customName and Trim(item.customName)~="") and "RENAME" or "NAME") end
        if row.nameEdit then row.nameEdit:Hide() end
        if row.transformInfo then row.transformInfo:SetText(string.format("SIZE %d%%  •  ROT %d deg",math.floor((tonumber(item.scale) or 1)*100+.5),math.floor((tonumber(item.rotation) or 0)+.5)%360)) end
        if row.noteBox then row.noteBox:SetText(item.note or "");row.noteBox:SetTextColor(unpack(p.pale));Backdrop(row.noteBox,p.panel,p.border) end
        if row.noteLabel then row.noteLabel:SetTextColor(unpack(p.muted)) end
        y=y-138
    end

    for _,group in ipairs(groups) do
        any=true
        local collapsed=IsGroupCollapsed(group.id)
        local card=AcquireRightRow("group",host);card:SetPoint("TOPLEFT",0,y);Backdrop(card,p.panel2,p.gold);card._group=group
        card.accent:SetVertexColor(unpack(p.gold));card.toggleText:SetText(collapsed and "+" or "−");card.toggleText:SetTextColor(unpack(p.gold))
        card.nameLabel:SetText(EnsureGroupName(group));card.nameLabel:SetTextColor(unpack(p.pale));card.nameEdit:SetTextColor(unpack(p.pale));Backdrop(card.nameEdit,p.panel2,p.border)
        local injuryCount,alterationCount,healingCount=0,0,0;local injuryMembers,healingMembers={},{}
        for _,id in ipairs(group.itemIds or {}) do
            local item=GetItem(id)
            if item and item.category=="healing" then healingCount=healingCount+1;healingMembers[#healingMembers+1]=item
            elseif item and item.category=="alteration" then alterationCount=alterationCount+1;injuryMembers[#injuryMembers+1]=item
            elseif item then injuryCount=injuryCount+1;injuryMembers[#injuryMembers+1]=item end
        end
        local counts={}
        if injuryCount>0 then counts[#counts+1]=tostring(injuryCount).." INJURY" end
        if alterationCount>0 then counts[#counts+1]=tostring(alterationCount).." CHANGE" end
        if healingCount>0 then counts[#counts+1]=tostring(healingCount).." HEALING" end
        card.count:SetText(table.concat(counts,"  •  "));card.count:SetTextColor(unpack(p.muted));y=y-52
        table.sort(injuryMembers,function(a,b)return tostring(a.id)<tostring(b.id)end);table.sort(healingMembers,function(a,b)return tostring(a.id)<tostring(b.id)end)
        if not collapsed then
            for _,item in ipairs(injuryMembers) do AddInjuryRow(item) end
            for _,item in ipairs(healingMembers) do AddHealingRow(item) end
        else
            for _,item in ipairs(healingMembers) do if item.expiresAt and Remaining(item.expiresAt)~=nil then injuryUI.hasActiveTimers=true end end
        end
    end

    -- Standalone placed markers need the same management controls as grouped
    -- markers.  Previously only ungrouped Healing Status entries were added to
    -- the right panel, which made a lone Injury Type (and, in particular, a
    -- Permanent Change) impossible to rename/resize/rotate from this UI.
    local standaloneInjuries,standaloneAlterations,standaloneHealing={},{},{}
    for _,item in pairs(d.items) do
        if not item.groupId then
            if item.category=="healing" then
                standaloneHealing[#standaloneHealing+1]=item
            elseif item.category=="alteration" then
                standaloneAlterations[#standaloneAlterations+1]=item
            else
                standaloneInjuries[#standaloneInjuries+1]=item
            end
        end
    end
    local function SortStandalone(list)
        table.sort(list,function(a,b)return tostring(a.id)<tostring(b.id)end)
    end
    SortStandalone(standaloneInjuries);SortStandalone(standaloneAlterations);SortStandalone(standaloneHealing)

    if #standaloneInjuries>0 then
        local section=AcquireRightRow("section",host);section:SetPoint("TOPLEFT",0,y);section.label:SetText("INJURIES");section.label:SetTextColor(unpack(p.gold));y=y-24
        for _,item in ipairs(standaloneInjuries) do any=true;AddInjuryRow(item) end
    end
    if #standaloneAlterations>0 then
        local alterationColor=p.alteration or PALETTE.alteration
        local section=AcquireRightRow("section",host);section:SetPoint("TOPLEFT",0,y);section.label:SetText("PERMANENT CHANGES");section.label:SetTextColor(unpack(alterationColor));y=y-24
        for _,item in ipairs(standaloneAlterations) do any=true;AddInjuryRow(item) end
    end
    if #standaloneHealing>0 then
        local section=AcquireRightRow("section",host);section:SetPoint("TOPLEFT",0,y);section.label:SetText("UNGROUPED HEALING");section.label:SetTextColor(unpack(p.gold));y=y-24
        for _,item in ipairs(standaloneHealing) do any=true;AddHealingRow(item) end
    end

    if not any then
        local empty=AcquireRightRow("empty",host);empty:SetPoint("TOPLEFT",0,y);Backdrop(empty,p.panel2,p.border);empty.title:SetTextColor(unpack(p.pale));empty.body:SetTextColor(unpack(p.muted))
    end
    host:SetHeight(math.max(1,-y+20))
end

local function BuildBody()
    local body=injuryUI.body
    if not body then return end
    ReleaseMarkers()
    local d=DB()
    for _,g in pairs(d.groups) do LayoutGroup(g) end
    for _,item in pairs(d.items) do
        local target=(item.side=="back" and injuryUI.backBody) or body
        if target then CreateMarker(target,item) end
    end
    if RPAL.RefreshHealthAnatomyVisual then RPAL.RefreshHealthAnatomyVisual() end
end

local function InjuryCosmeticSignature(cosmetics,palette)
    return table.concat({
        tostring(cosmetics.background or "None"),tostring(cosmetics.uiOrnament or "Classic"),
        tostring(cosmetics.effectPack or "None"),tostring(palette.gold[1]),
        tostring(palette.gold[2]),tostring(palette.gold[3]),
    },"|")
end

local function RefreshInjuryCosmetics(force)
    local frame=injuryUI.frame
    if not frame then return false end
    local cosmetics=RPAL.GetCosmeticSelection and RPAL.GetCosmeticSelection() or {}
    local palette=P()
    local signature=InjuryCosmeticSignature(cosmetics,palette)
    if not force and injuryUI.cosmeticSignature==signature then return false end
    if injuryUI.ornament then injuryUI.ornament:Hide();injuryUI.ornament:SetParent(nil);injuryUI.ornament=nil end
    if injuryUI.effect then injuryUI.effect:Hide();injuryUI.effect:SetParent(nil);injuryUI.effect=nil end
    if RPAL.RenderAtmosphere then RPAL.RenderAtmosphere(frame,cosmetics.background or "None",palette.gold,0.72,true) end
    if RPAL.RenderUIOrnament then injuryUI.ornament=RPAL.RenderUIOrnament(frame,cosmetics.uiOrnament or "Classic",palette.gold,9) end
    if RPAL.RenderEffectPack then injuryUI.effect=RPAL.RenderEffectPack(frame,cosmetics.effectPack or "None",palette.gold,{frameLevelOffset=6}) end
    injuryUI.cosmeticSignature=signature
    return true
end

function RPAL.RefreshInjuryUI()
    if not injuryUI.frame or not injuryUI.frame:IsShown() then return end
    RemoveExpired(); BuildBody(); BuildRight()
    if RPAL.RefreshHealth2UI then RPAL.RefreshHealth2UI() end
    if RPAL.RefreshHealthHUD then RPAL.RefreshHealthHUD() end
end

local function AddSectionTitle(parent,text,y,color)
    local t=Font(parent,text,10,color or P().gold);t:SetPoint("TOPLEFT",6,y);return t
end

local function BuildInjuryWindow()
    if injuryUI.frame then return injuryUI.frame end
    local p=P()
    local main=RPAL.UI and RPAL.UI.main
    if not main then return end

    -- Match the Customization screen exactly: Injury Record is a child page
    -- of the main RPAL window, with the same inset bounds and frame-level
    -- relationship. This prevents it from ever being raised behind the book.
    local f=CreateFrame("Frame",nil,main,"BackdropTemplate")
    injuryUI.frame=f
    f:SetPoint("TOPLEFT",16,-16)
    f:SetPoint("BOTTOMRIGHT",-16,16)
    f:SetFrameLevel((main:GetFrameLevel() or 100)+400)
    f:SetToplevel(true)
    f:EnableMouse(true)
    Backdrop(f,p.panel,p.gold)

    -- Reuse the live RPAL customization surface so Injury Record inherits the
    -- equipped atmosphere, ornaments and ambient effects just like the main
    -- book. These are visual-only layers and never interfere with dragging.
    RefreshInjuryCosmetics(true)
    local shade=f:CreateTexture(nil,"BACKGROUND");shade:SetTexture("Interface\\Buttons\\WHITE8X8");shade:SetAllPoints();shade:SetVertexColor(0,0,0,.55)
    local topRule=f:CreateTexture(nil,"ARTWORK");topRule:SetTexture("Interface\\Buttons\\WHITE8X8");topRule:SetVertexColor(p.gold[1],p.gold[2],p.gold[3],.42);topRule:SetSize(1180,1);topRule:SetPoint("TOP",0,-58)

    local header=Font(f,"HEALTH",20,p.gold);header:SetPoint("TOPLEFT",20,-16)
    local sub=Font(f,"Place injuries, permanent changes and healing states. Every injury opens a Health card immediately; nearby nodes can be linked.",10,p.muted);sub:SetPoint("TOPLEFT",21,-43)
    local close=MakeSmallButton(f,"CLOSE",72,28);close:SetPoint("TOPRIGHT",-18,-15);close:SetScript("OnClick",function()if RPAL.CloseInjuryUI then RPAL.CloseInjuryUI(nil,true) else f:Hide() end end)
    injuryUI.closeButton=close

    local left=CreateFrame("Frame",nil,f,"BackdropTemplate");left:SetWidth(178);left:SetPoint("TOPLEFT",14,-76);left:SetPoint("BOTTOMLEFT",14,18);Backdrop(left,p.panel2,p.border);injuryUI.left=left
    local leftScroll=CreateFrame("ScrollFrame",nil,left,"UIPanelScrollFrameTemplate");leftScroll:SetPoint("TOPLEFT",5,-8);leftScroll:SetPoint("BOTTOMRIGHT",-25,54)
    if RPAL.SkinScrollFrame then RPAL.SkinScrollFrame(leftScroll) end
    local leftContent=CreateFrame("Frame",nil,leftScroll);leftContent:SetWidth(145);leftContent:SetHeight(1);leftScroll:SetScrollChild(leftContent);injuryUI.leftContent=leftContent;injuryUI.leftScroll=leftScroll
    local bodyPanel=CreateFrame("Frame",nil,f,"BackdropTemplate");bodyPanel:SetWidth(650);bodyPanel:SetPoint("TOP",f,"TOP",-45,-76);bodyPanel:SetHeight(650);Backdrop(bodyPanel,{.035,.03,.025,.98},p.border);injuryUI.bodyPanel=bodyPanel
    local wrapH=566
    local frontWrap=CreateFrame("Frame",nil,bodyPanel,"BackdropTemplate");frontWrap:SetSize(286,wrapH);frontWrap:SetPoint("LEFT",14,0);Backdrop(frontWrap,{.025,.022,.019,.98},p.border);injuryUI.frontWrap=frontWrap
    local backWrap=CreateFrame("Frame",nil,bodyPanel,"BackdropTemplate");backWrap:SetSize(286,wrapH);backWrap:SetPoint("RIGHT",-14,0);Backdrop(backWrap,{.025,.022,.019,.98},p.border);injuryUI.backWrap=backWrap
    local body=CreateFrame("Frame",nil,frontWrap);body:SetSize(220,396);body:SetPoint("CENTER",0,-2);injuryUI.body=body
    local image=body:CreateTexture(nil,"BACKGROUND");image:SetAllPoints();image:SetTexture(BODY_TEX);image:SetTexCoord(0,1,0,1);image:SetVertexColor(0.70+p.gold[1]*0.30,0.70+p.gold[2]*0.30,0.70+p.gold[3]*0.30,1);injuryUI.frontImage=image
    local backBody=CreateFrame("Frame",nil,backWrap);backBody:SetSize(220,396);backBody:SetPoint("CENTER",0,-2);injuryUI.backBody=backBody
    local backImage=backBody:CreateTexture(nil,"BACKGROUND");backImage:SetAllPoints();backImage:SetTexture(BODY_TEX);backImage:SetTexCoord(1,0,0,1);backImage:SetVertexColor(0.70+p.gold[1]*0.30,0.70+p.gold[2]*0.30,0.70+p.gold[3]*0.30,1);injuryUI.backImage=backImage
    local frontLabel=Font(frontWrap,"FRONT",10,p.gold);frontLabel:SetPoint("TOP",0,-10)
    local backLabel=Font(backWrap,"BACK",10,p.gold);backLabel:SetPoint("TOP",0,-10)
    local bodyHint=Font(bodyPanel,"DROP INJURIES ON EITHER VIEW",8,p.muted);bodyHint:SetPoint("TOP",0,-10);injuryUI.bodyHint=bodyHint
    local bodyRule=bodyPanel:CreateTexture(nil,"ARTWORK");bodyRule:SetTexture("Interface\\Buttons\\WHITE8X8");bodyRule:SetVertexColor(p.gold[1],p.gold[2],p.gold[3],.25);bodyRule:SetSize(620,1);bodyRule:SetPoint("TOP",0,-29)

    local right=CreateFrame("ScrollFrame",nil,f,"UIPanelScrollFrameTemplate");right:SetWidth(292);right:SetPoint("TOPRIGHT",-16,-76);right:SetPoint("BOTTOMRIGHT",-28,18);injuryUI.rightScroll=right
    if RPAL.SkinScrollFrame then RPAL.SkinScrollFrame(right) end
    local rightContent=CreateFrame("Frame",nil,right);rightContent:SetWidth(274);rightContent:SetHeight(1);right:SetScrollChild(rightContent);injuryUI.right=rightContent

    AddSectionTitle(leftContent,"INJURY TYPES",-2,p.gold)
    local y=-26
    for _,name in ipairs(RPAL.INJURY_TYPES) do local b=MakeDragSource(leftContent,name,name,nil,"injury");b:SetPoint("TOPLEFT",0,y);y=y-39 end
    AddSectionTitle(leftContent,"PERMANENT CHANGES",y-5,p.alteration or PALETTE.alteration);y=y-31
    for _,name in ipairs(RPAL.BODY_ALTERATIONS or {}) do local b=MakeDragSource(leftContent,name,name,nil,"alteration");b:SetPoint("TOPLEFT",0,y);y=y-39 end
    for _,name in ipairs((RPAL.GetHealthCustomAlterations and RPAL.GetHealthCustomAlterations()) or {}) do local b=MakeDragSource(leftContent,name,name,nil,"alteration");b:SetPoint("TOPLEFT",0,y);y=y-39 end
    local custom=MakeSmallButton(leftContent,"+ CUSTOM",145,24);custom:SetPoint("TOPLEFT",0,y);custom:SetScript("OnClick",function()if RPAL.PromptCustomPermanentChange then RPAL.PromptCustomPermanentChange() end end);y=y-31
    AddSectionTitle(leftContent,"HEALING STATUS",y-5,p.gold);y=y-31
    for _,name in ipairs(RPAL.HEALING_STATUSES) do local b=MakeDragSource(leftContent,name,name,name,"healing");b:SetPoint("TOPLEFT",0,y);y=y-39 end
    leftContent:SetHeight(math.max(1,-y+12))

    local hint=Font(left,"Right-click a node to remove it. Resize/rotate never changes its hitbox; drag position stays exact at every scale.",8,p.muted);hint:SetPoint("BOTTOMLEFT",12,10);hint:SetPoint("BOTTOMRIGHT",-12,10);hint:SetWordWrap(true);hint:SetJustifyH("LEFT")

    f:SetScript("OnUpdate",function(self,elapsed)
        elapsed=elapsed or 0
        injuryUI.cosmeticTicker=(injuryUI.cosmeticTicker or 0)+elapsed
        if injuryUI.cosmeticTicker>=1 then
            injuryUI.cosmeticTicker=0
            if RefreshInjuryCosmetics(false) then RPAL.RefreshInjuryUI() end
        end
        if injuryUI.drag then
            UpdateHealthDrag()
            if not IsMouseButtonDown("LeftButton") then FinishHealthDrag() end
        end
        if injuryUI.hasActiveTimers then
            injuryUI.timerTicker=(injuryUI.timerTicker or 0)+elapsed
            if injuryUI.timerTicker>=1 then
                injuryUI.timerTicker=0
                if RemoveExpired() then
                    RPAL.RefreshInjuryUI()
                else
                    for _,entry in ipairs(injuryUI.timerLabels or {}) do
                        local obj=GetItem(entry.id)
                        if obj and obj.category=="healing" and entry.label then entry.label:SetText(Format(Remaining(obj.expiresAt))) end
                    end
                end
            end
        else
            injuryUI.timerTicker=0
        end
    end)
    f:SetScript("OnShow",function(self)
        local parent=RPAL.UI and RPAL.UI.main
        if parent then self:SetFrameLevel((parent:GetFrameLevel() or 100)+400) end
        self:SetToplevel(true)
        injuryUI.timerTicker=0;injuryUI.cosmeticTicker=0
        RefreshInjuryCosmetics(false)

        -- Closing the parent ODYN book also fires this page's OnHide, which
        -- deliberately releases marker/card rows back into their pools.  When
        -- the parent is shown again the Health child can become visible without
        -- going through OpenInjurySystem(), so those rows must be rebuilt here
        -- as well.  Otherwise existing statuses/groups appear empty until some
        -- later edit happens to call RefreshInjuryUI.
        if RPAL.RefreshInjuryUI then RPAL.RefreshInjuryUI() end
        if C_Timer and C_Timer.After then
            C_Timer.After(0,function()
                if self and self.IsShown and self:IsShown() and RPAL.RefreshInjuryUI then
                    RPAL.RefreshInjuryUI()
                end
            end)
        end
    end)
    f:SetScript("OnHide",function()
        if RPAL.HideCursorTooltip then RPAL.HideCursorTooltip() end
        CancelActiveDrag()
        ReleaseMarkers();ReleaseRightRows()
        injuryUI.timerLabels={};injuryUI.hasActiveTimers=false
        injuryUI.timerTicker=0;injuryUI.cosmeticTicker=0
    end)
    return f
end

function RPAL.ResetInjuryUI()
    local frame=injuryUI.frame
    if frame then
        frame:Hide()
        frame:SetScript("OnUpdate",nil);frame:SetScript("OnShow",nil);frame:SetScript("OnHide",nil)
        frame:SetParent(nil)
    end
    injuryUI.frame=nil;injuryUI.body=nil;injuryUI.backBody=nil;injuryUI.left=nil;injuryUI.right=nil
    injuryUI.bodyPanel=nil;injuryUI.frontWrap=nil;injuryUI.backWrap=nil;injuryUI.frontImage=nil;injuryUI.backImage=nil;injuryUI.rightScroll=nil;injuryUI.leftContent=nil;injuryUI.leftScroll=nil
    injuryUI.health2Chrome=nil;injuryUI.activeTab=nil;injuryUI.historyTab=nil;injuryUI.historyFrame=nil;injuryUI.historyContent=nil;injuryUI.anatomyButton=nil;injuryUI.traitButtons=nil;injuryUI.bodyHint=nil;injuryUI.bodyRule=nil
    injuryUI.drag=nil;injuryUI.ornament=nil;injuryUI.effect=nil;injuryUI.cosmeticSignature=nil
    injuryUI.markerPool={};injuryUI.activeMarkers={};injuryUI.rightPools={};injuryUI.rightActive={}
    injuryUI.timerLabels={};injuryUI.hasActiveTimers=false;injuryUI.timerTicker=0;injuryUI.cosmeticTicker=0
    -- Reset Data swaps RPAL_CHAR_DB without /reload.  Never retain the old
    -- character's normalized injury table across that live swap.
    injuryDBCache=nil
end

function RPAL.OpenInjurySystem()
    if RPAL.SetSkillSetMode then RPAL.SetSkillSetMode(false) end
    if RPAL.HideChronicle then RPAL.HideChronicle() end
    if not RPAL.UI or not RPAL.UI.main then return end
    local main=RPAL.UI.main
    if not main:IsShown() then main:Show() end
    if injuryUI.frame and injuryUI.frame:GetParent()~=main then RPAL.ResetInjuryUI() end

    local f=BuildInjuryWindow()
    if not f then return end
    local wasShown=f:IsShown()
    if RPAL.CloseTransientUI then RPAL.CloseTransientUI(f) end
    f:Show()
    RPAL.RefreshInjuryUI()
    f:Raise()
    if not wasShown and RPAL.AnimateFrameIn then RPAL.AnimateFrameIn(f,0.14,0) end
end

function RPAL.CloseInjuryUI(frame,restoreOverview)
    frame=frame or injuryUI.frame
    local wasShown=frame and frame:IsShown()
    if wasShown then frame:Hide() end
    if restoreOverview and RPAL.ShowOverview then RPAL.ShowOverview();return end
    if wasShown and RPAL.RefreshBookChrome then RPAL.RefreshBookChrome(true) end
end

function RPAL.IsInjuryOpen() return injuryUI.frame and injuryUI.frame:IsShown() or false end

-- Read-only renderer used by remote inspect and self-preview.
function RPAL.RenderInjuryReadOnly(parent,data,accent)
    if not parent then return end
    accent=accent or P().gold
    local items=data and data.items or {}; local groups=data and data.groups or {}
    local parentWidth=parent:GetWidth() or 760
    -- One owned root makes the whole read-only page disposable.  Remote
    -- inspect reuses its scroll content between tabs; previously the body
    -- panels and labels were direct untracked children and survived the tab
    -- clear, leaving Injuries drawn over Skill Set or Statistics.
    local host=CreateFrame("Frame",nil,parent)
    host:SetSize(parentWidth,1)
    host:SetPoint("TOPLEFT")
    if parent.GetFrameLevel then host:SetFrameLevel((parent:GetFrameLevel() or 0)+2) end
    local title=Font(host,"HEALTH",15,accent);title:SetPoint("TOPLEFT",22,-18)

    -- Remote Health must use the *same coordinate canvas* as the editable Health
    -- page (220x396). Older inspect builds rebuilt the silhouette at a different
    -- size/aspect ratio, so normalized hand/chest/etc. coordinates looked shifted.
    -- Keep the source canvas exact and enlarge it only with a uniform parent scale.
    local sourceBodyW,sourceBodyH=220,396
    local bodyScale=(parentWidth>=900) and 1.18 or math.max(.84,math.min(1.18,(parentWidth-330)/560))
    local displayBodyW=math.floor(sourceBodyW*bodyScale+.5)
    local displayBodyH=math.floor(sourceBodyH*bodyScale+.5)
    local wrapW=displayBodyW+16
    local wrapH=displayBodyH+42
    local bodyPanelW=wrapW*2+34
    local bodyPanelH=wrapH+12
    local listLeft=bodyPanelW+24
    local listWidth=parentWidth-listLeft-12
    if listWidth<300 then
        bodyScale=math.max(.80,bodyScale-((300-listWidth)/440))
        displayBodyW=math.floor(sourceBodyW*bodyScale+.5)
        displayBodyH=math.floor(sourceBodyH*bodyScale+.5)
        wrapW=displayBodyW+16;wrapH=displayBodyH+42
        bodyPanelW=wrapW*2+34;bodyPanelH=wrapH+12
        listLeft=bodyPanelW+24;listWidth=math.max(280,parentWidth-listLeft-12)
    end

    local bodyPanel=CreateFrame("Frame",nil,host,"BackdropTemplate");bodyPanel:SetSize(bodyPanelW,bodyPanelH);bodyPanel:SetPoint("TOPLEFT",10,-52);Backdrop(bodyPanel,{.035,.03,.025,.98},accent)
    local front=CreateFrame("Frame",nil,bodyPanel,"BackdropTemplate");front:SetSize(wrapW,wrapH);front:SetPoint("LEFT",9,0);Backdrop(front,{.025,.022,.019,.98},accent)
    local back=CreateFrame("Frame",nil,bodyPanel,"BackdropTemplate");back:SetSize(wrapW,wrapH);back:SetPoint("RIGHT",-9,0);Backdrop(back,{.025,.022,.019,.98},accent)
    local frontBody=CreateFrame("Frame",nil,front);frontBody:SetSize(sourceBodyW,sourceBodyH);frontBody:SetScale(bodyScale);frontBody:SetPoint("CENTER",0,-10)
    local backBody=CreateFrame("Frame",nil,back);backBody:SetSize(sourceBodyW,sourceBodyH);backBody:SetScale(bodyScale);backBody:SetPoint("CENTER",0,-10)
    local ft=Font(front,"FRONT",8.5,accent);ft:SetPoint("TOP",0,-7);local bt=Font(back,"BACK",8.5,accent);bt:SetPoint("TOP",0,-7)
    local tex=frontBody:CreateTexture(nil,"BACKGROUND");tex:SetAllPoints();tex:SetTexture(BODY_TEX)
    local btex=backBody:CreateTexture(nil,"BACKGROUND");btex:SetAllPoints();btex:SetTexture(BODY_TEX);btex:SetTexCoord(1,0,0,1)
    for _,item in pairs(items) do
        local target=(item.side=="back") and backBody or frontBody
        local marker=CreateFrame("Frame",nil,target);marker:SetSize(90,36);marker:SetScale(1);marker:SetPoint("CENTER",target,"TOPLEFT",(tonumber(item.x) or .5)*sourceBodyW,-(tonumber(item.y) or .5)*sourceBodyH);local c=(item.category=="healing") and (P().heal or PALETTE.heal) or ((item.category=="alteration") and (P().alteration or PALETTE.alteration) or (P().red or PALETTE.red))
        local markerVisual=CreateFrame("Frame",nil,marker);markerVisual:SetSize(82,29);markerVisual:SetPoint("CENTER");markerVisual:SetScale(math.max(.65,math.min(1.55,tonumber(item.scale) or 1)))
        BuildMarkerArtwork(markerVisual,82,29,8.7,6,false,false)
        markerVisual.title:SetTextColor(unpack(P().pale));ApplyMarkerArtwork(markerVisual,ItemLabel(item),nil,c,tonumber(item.rotation) or 0,{.08,.06,.05,.94})
    end
    -- Give the silhouette most of the window; the text column is intentionally
    -- narrower and scrollable instead of consuming half the inspect surface.
    local list=CreateFrame("ScrollFrame",nil,host,"UIPanelScrollFrameTemplate");list:SetPoint("TOPLEFT",listLeft,-52);list:SetPoint("TOPRIGHT",-12,-52);list:SetHeight(bodyPanelH)
    if RPAL.SkinScrollFrame then RPAL.SkinScrollFrame(list) end
    local content=CreateFrame("Frame",nil,list);content:SetWidth(math.max(160,listWidth-18));content:SetHeight(1);list:SetScrollChild(content)
    local y=-4;local any=false
    local sortedGroups={}
    for _,g in pairs(groups) do sortedGroups[#sortedGroups+1]=g end
    table.sort(sortedGroups,function(a,b)return EnsureGroupName(a):lower()<EnsureGroupName(b):lower() end)
    for _,g in ipairs(sortedGroups) do
        any=true
        local row=CreateFrame("Frame",nil,content,"BackdropTemplate");row:SetSize(listWidth-18,48);row:SetPoint("TOPLEFT",0,y);Backdrop(row,P().panel2,accent)
        local n=Font(row,EnsureGroupName(g),10,P().pale);n:SetPoint("TOPLEFT",8,-7)
        local rt=Font(row,"HEALTH GROUP",8,accent);rt:SetPoint("RIGHT",-8,-8)
        local members=Font(row,tostring(#(g.itemIds or {})).." node"..(#(g.itemIds or {})==1 and "" or "s"),7,P().muted);members:SetPoint("BOTTOMLEFT",8,7)
        y=y-54
        for _,id in ipairs(g.itemIds or {}) do
            local item=items[tostring(id)]
            if item then
                local hasNote=item.note and Trim(item.note)~="";local h=hasNote and 62 or 36
                local r=CreateFrame("Frame",nil,content,"BackdropTemplate");r:SetSize(listWidth-18,h);r:SetPoint("TOPLEFT",0,y);Backdrop(r,P().panel,P().border)
                local t=Font(r,ItemLabel(item),8.5,P().pale);t:SetPoint("TOPLEFT",8,-8)
                local kind=Font(r,(item.category=="alteration" and "PERMANENT CHANGE") or (item.category=="healing" and "HEALING") or "INJURY",6.8,accent);kind:SetPoint("TOPRIGHT",-8,-9)
                if item.category=="healing" and item.remaining then local rem=Font(r,Format(item.remaining),7.5,P().gold);rem:SetPoint("BOTTOMRIGHT",-8,7) end
                if hasNote then local note=Font(r,item.note,7.6,P().muted);note:SetPoint("TOPLEFT",8,-27);note:SetPoint("RIGHT",-8,-27);note:SetJustifyH("LEFT");note:SetWordWrap(true) end
                y=y-h-5
            end
        end
    end
    for _,item in pairs(items) do if not item.groupId then
        any=true;local hasNote=item.note and Trim(item.note)~="";local h=hasNote and 64 or 42
        local r=CreateFrame("Frame",nil,content,"BackdropTemplate");r:SetSize(listWidth-18,h);r:SetPoint("TOPLEFT",0,y);Backdrop(r,P().panel2,P().border)
        local t=Font(r,ItemLabel(item),9,P().pale);t:SetPoint("TOPLEFT",8,-8)
        if hasNote then local note=Font(r,item.note,7.6,P().muted);note:SetPoint("TOPLEFT",8,-27);note:SetPoint("RIGHT",-8,-27);note:SetWordWrap(true) end
        if item.category=="healing" and item.remaining then local rem=Font(r,Format(item.remaining),8,P().gold);rem:SetPoint("TOPRIGHT",-8,-9) end
        y=y-h-5
    end end
    if not any then local e=Font(content,"No health records have been added.",10,P().muted);e:SetPoint("TOPLEFT",4,y) end
    content:SetHeight(math.max(1,-y+20))
    host:SetHeight(math.max(575,bodyPanelH+68))
    return host,bodyPanel,list
end

-- Slash-command convenience.
RPAL.OpenInjuries=RPAL.OpenInjurySystem
