local _, GF = ...

GF.NavFlyout = {}
local NF = GF.NavFlyout

local PANEL_COUNT = 3
local SIG_SEP = "\31"
local QUICK_SEARCH_PANEL_WIDTH = 260
local QUICK_SEARCH_HEADER_HEIGHT = 70
local QUICK_SEARCH_TITLE_HEIGHT = 22
local QUICK_SEARCH_BOX_HEIGHT = 26
local QUICK_SEARCH_HOVER_DELAY = 0.15
local FAVORITE_REORDER_FOOTER_HEIGHT = 54
local FAVORITE_REORDER_BUTTON_SIZE = 20
local FAVORITE_REORDER_BUTTON_GAP = 2
local FAVORITE_REORDER_ACTION_BUTTON_WIDTH = 72
local FAVORITE_REORDER_ACTION_BUTTON_GAP = 8

local function invoke(owner, methodName, ...)
	local method = owner and owner[methodName]
	if method then
		return method(owner, ...)
	end
end

local function flyoutMinPanelWidth()
	return GF.NAV_FLYOUT_PANEL_MIN_W or 120
end

local function flyoutMaxPanelWidth()
	return math.max(GF.NAV_FLYOUT_PANEL_MAX_W or 260, flyoutMinPanelWidth())
end

local function flyoutPanelGap(panelIdx)
	if panelIdx == 1 then
		return GF.NAV_FLYOUT_ROOT_GAP or 0
	end
	return GF.NAV_FLYOUT_PANEL_GAP or 0
end

local function flyoutPanelAnchorGap(panelIdx)
	return flyoutPanelGap(panelIdx)
end

local function flyoutRootAnchorGapX()
	return GF.NAV_FLYOUT_ROOT_ANCHOR_GAP_X or 4
end

local function flyoutRootAnchorGapY()
	return GF.NAV_FLYOUT_ROOT_ANCHOR_GAP_Y or 2
end

local function flyoutStairStepY()
	return GF.NAV_FLYOUT_STAIR_STEP_Y or 0
end

local function flyoutRowHeight(level)
	local heights = GF.NAV_FLYOUT_ROW_H or GF.NAV_ROW_H
	return (heights and heights[level]) or GF.NAV_FLYOUT_ROW_H_DEFAULT or GF.NAV_ROW_H_DEFAULT or 30
end

local function flyoutContentInsets()
	return GF.NAV_FLYOUT_CONTENT_INSET_L or 8,
		GF.NAV_FLYOUT_CONTENT_INSET_R or 8,
		GF.NAV_FLYOUT_CONTENT_INSET_T or 8,
		GF.NAV_FLYOUT_CONTENT_INSET_B or 15
end

local function applyFlyoutPanelChrome(panel)
	if not panel then
		return
	end
	if not panel.menuBg then
		panel.menuBg = panel:CreateTexture(nil, "BACKGROUND", nil, -5)
	end
	local atlas = GF.NAV_FLYOUT_BG_ATLAS or "common-dropdown-bg"
	local success = false
	if panel.menuBg.SetAtlas then
		local callOK, atlasOK = pcall(panel.menuBg.SetAtlas, panel.menuBg, atlas)
		success = callOK and atlasOK ~= false
	end
	if not success then
		panel.menuBg:SetColorTexture(0, 0, 0, GF.NAV_FLYOUT_BG_ALPHA or 0.925)
	end
	panel.menuBg:ClearAllPoints()
	panel.menuBg:SetPoint("TOPLEFT", panel, "TOPLEFT", -(GF.NAV_FLYOUT_BG_EXTEND_X or 10), GF.NAV_FLYOUT_BG_EXTEND_TOP or 3)
	panel.menuBg:SetPoint(
		"BOTTOMRIGHT",
		panel,
		"BOTTOMRIGHT",
		GF.NAV_FLYOUT_BG_EXTEND_X or 10,
		-(GF.NAV_FLYOUT_BG_EXTEND_BOTTOM or 3)
	)
	panel.menuBg:SetAlpha(GF.NAV_FLYOUT_BG_ALPHA or 0.925)
	panel.menuBg:Show()
end

local function getFlyoutWindowBounds()
	local top = UIParent and UIParent.GetTop and UIParent:GetTop()
	local bottom = UIParent and UIParent.GetBottom and UIParent:GetBottom()
	if not top and UIParent and UIParent.GetHeight then
		top = UIParent:GetHeight()
	end
	top = top or (GetScreenHeight and GetScreenHeight()) or 768
	bottom = bottom or 0
	local mainFrame = GF.MainFrame
	local boundFrame = mainFrame and mainFrame.layoutHost
	boundFrame = (boundFrame and boundFrame._gfPanelBackground) or boundFrame or (mainFrame and mainFrame.frame)
	if boundFrame then
		local frameTop = boundFrame.GetTop and boundFrame:GetTop()
		local frameBottom = boundFrame.GetBottom and boundFrame:GetBottom()
		if frameTop then
			top = math.min(top, frameTop)
		end
		if frameBottom then
			bottom = math.max(bottom, frameBottom + (GF.NAV_FLYOUT_WINDOW_BOTTOM_GAP or 0))
		end
	end
	return top - (GF.NAV_FLYOUT_SCREEN_MARGIN_TOP or 12), bottom
end

local function setFlyoutPanelVisibleHeight(panel, height, resetScroll)
	if not panel then
		return
	end
	local naturalH = math.max(panel._fullHeight or height or 1, 1)
	local visibleH = math.min(math.max(height or naturalH, 1), naturalH)
	panel._visibleHeight = visibleH
	panel:SetHeight(visibleH)
	if not panel.scroll then
		return
	end
	if resetScroll then
		panel.scroll:SetVerticalScroll(0)
	end
	GF.UI.UpdateScrollFrame(panel.scroll)
	local range = panel.scroll.GetVerticalScrollRange and (panel.scroll:GetVerticalScrollRange() or 0) or 0
	if range <= 0 then
		panel.scroll:SetVerticalScroll(0)
	else
		local current = panel.scroll:GetVerticalScroll() or 0
		if current > range then
			panel.scroll:SetVerticalScroll(range)
		end
	end
end

local function resolvePanelHeightAndTop(panel, desiredTop)
	local naturalH = math.max(panel and panel._fullHeight or 1, 1)
	local windowTop, windowBottom = getFlyoutWindowBounds()
	desiredTop = math.min(desiredTop or windowTop, windowTop)
	local availableH = math.max(desiredTop - windowBottom, 1)
	local visibleH = math.min(naturalH, availableH)
	return visibleH, desiredTop
end

local function getEffectiveScale(frame)
	local scale = frame and frame.GetEffectiveScale and frame:GetEffectiveScale()
	if type(scale) ~= "number" or scale <= 0 then
		return nil
	end
	return scale
end

local function getScaledFrameRect(frame)
	if not frame then
		return nil
	end
	if frame.GetScaledRect then
		local left, bottom, width, height = frame:GetScaledRect()
		if type(left) == "number" and type(bottom) == "number"
			and type(width) == "number" and type(height) == "number" then
			return left, bottom, width, height
		end
	end
	local scale = getEffectiveScale(frame)
	local left = frame.GetLeft and frame:GetLeft()
	local bottom = frame.GetBottom and frame:GetBottom()
	local width = frame.GetWidth and frame:GetWidth()
	local height = frame.GetHeight and frame:GetHeight()
	if scale and type(left) == "number" and type(bottom) == "number"
		and type(width) == "number" and type(height) == "number" then
		return left * scale, bottom * scale, width * scale, height * scale
	end
	return nil
end

local function resolveDeepPanelVerticalPlacement(defaultTop, naturalHeight, uiBottom, uiTop, bottomMargin)
	if type(defaultTop) ~= "number" or type(naturalHeight) ~= "number"
		or type(uiBottom) ~= "number" or type(uiTop) ~= "number" then
		return nil
	end
	naturalHeight = math.max(naturalHeight, 1)
	bottomMargin = math.max(tonumber(bottomMargin) or 0, 0)
	local safeBottom = uiBottom + bottomMargin
	local availableHeight = math.max(uiTop - safeBottom, 1)
	if naturalHeight > availableHeight then
		return availableHeight, uiTop, true
	end
	local minimumTop = safeBottom + naturalHeight
	return naturalHeight, math.max(defaultTop, minimumTop), false
end

NF.ResolveDeepPanelVerticalPlacement = resolveDeepPanelVerticalPlacement

local function shouldAlignDeepPanelToParentRow(panelIdx)
	if type(panelIdx) ~= "number" or panelIdx < 2 then
		return false
	end
	local mainFrame = GF.MainFrame
	local tabID = mainFrame and mainFrame.GetCurrentTabID and mainFrame:GetCurrentTabID()
	if tabID ~= GF.TAB_BROWSE and tabID ~= GF.TAB_CREATE then
		return false
	end
	local workspaceID = mainFrame.GetCurrentWorkspaceID and mainFrame:GetCurrentWorkspaceID()
	return workspaceID == (GF.WORKSPACE_MEETING_STONE or "meeting_stone")
end

NF.ShouldAlignDeepPanelToParentRow = shouldAlignDeepPanelToParentRow

local function resolveDeepPanelHeightAndOffset(panel, anchorRow, parentPanel, maxVisibleHeight)
	local panelScale = getEffectiveScale(panel)
	local uiScale = getEffectiveScale(UIParent)
	local _, uiBottom, _, uiHeight = getScaledFrameRect(UIParent)
	local _, rowBottom, _, rowHeight = getScaledFrameRect(anchorRow)
	local _, parentBottom, _, parentHeight = getScaledFrameRect(parentPanel)
	if not (panelScale and uiScale and uiBottom and uiHeight
		and rowBottom and rowHeight and parentBottom and parentHeight) then
		return nil
	end
	local naturalHeight = math.max(panel._fullHeight or panel:GetHeight() or 1, 1)
	local cappedHeight = math.min(naturalHeight, math.max(tonumber(maxVisibleHeight) or naturalHeight, 1))
	local visibleHeightScaled, resolvedTopScaled = resolveDeepPanelVerticalPlacement(
		rowBottom + rowHeight,
		cappedHeight * panelScale,
		uiBottom,
		uiBottom + uiHeight,
		(GF.NAV_FLYOUT_SCREEN_MARGIN_BOTTOM or 12) * uiScale
	)
	if not (visibleHeightScaled and resolvedTopScaled) then
		return nil
	end
	local parentTopScaled = parentBottom + parentHeight
	return visibleHeightScaled / panelScale, (resolvedTopScaled - parentTopScaled) / panelScale
end

NF.ResolveDeepPanelHeightAndOffset = resolveDeepPanelHeightAndOffset

local function frameHasBounds(frame)
	return frame and frame.GetLeft and frame:GetLeft() and frame.GetRight and frame:GetRight()
		and frame.GetTop and frame:GetTop() and frame.GetBottom and frame:GetBottom()
end

local function frameIsUsable(frame)
	if not frameHasBounds(frame) then
		return false
	end
	if frame.IsVisible then
		return frame:IsVisible()
	end
	if frame.IsShown then
		return frame:IsShown()
	end
	return true
end

local function getVisibleHeaderFrame(frame)
	if not frameIsUsable(frame) then
		return nil
	end
	local background = frame._gfBrowseHeaderBackgroundFrame
	if frameHasBounds(background) then
		return background
	end
	return frame
end

local function getRootFlyoutHeaderFrame()
	local tabID = GF.MainFrame and GF.MainFrame.GetCurrentTabID and GF.MainFrame:GetCurrentTabID()
	local browseHeader = GF.SubtitleBar and getVisibleHeaderFrame(GF.SubtitleBar.columnHeaderHost)
	local applicantHeader = GF.ApplicantsPanel and getVisibleHeaderFrame(GF.ApplicantsPanel.columnHeaderHost)
	if tabID == GF.TAB_CREATE then
		return applicantHeader or browseHeader
	end
	return browseHeader or applicantHeader
end

local function resolveRootFlyoutAnchor(navTree)
	local header = getRootFlyoutHeaderFrame()
	local divider = navTree and navTree.dividerHost
	if not (frameHasBounds(header) and frameHasBounds(divider)) then
		return nil
	end
	local dividerRight = divider:GetRight()
	local headerLeft = header:GetLeft()
	local headerBottom = header:GetBottom()
	if not (dividerRight and headerLeft and headerBottom) then
		return nil
	end
	local baseLeft = dividerRight + flyoutRootAnchorGapX()
	local baseTop = headerBottom - flyoutRootAnchorGapY()
	return {
		frame = header,
		baseLeft = baseLeft,
		baseTop = baseTop,
		offsetX = baseLeft - headerLeft,
		offsetY = -flyoutRootAnchorGapY(),
	}
end

local function resolveSecondLevelMaxVisibleHeight(navTree)
	local rootAnchor = resolveRootFlyoutAnchor(navTree)
	if not rootAnchor then
		return nil
	end
	local windowTop, windowBottom = getFlyoutWindowBounds()
	local resolvedTop = math.min(rootAnchor.baseTop, windowTop)
	return math.max(resolvedTop - windowBottom, 1)
end

NF.ResolveSecondLevelMaxVisibleHeight = resolveSecondLevelMaxVisibleHeight

local function resolveQuickSearchVisibleHeight(naturalHeight, maxVisibleHeight)
	naturalHeight = math.max(tonumber(naturalHeight) or 1, 1)
	maxVisibleHeight = tonumber(maxVisibleHeight)
	if not maxVisibleHeight or maxVisibleHeight <= 0 then
		return naturalHeight
	end
	return math.min(naturalHeight, maxVisibleHeight)
end

NF.ResolveQuickSearchVisibleHeight = resolveQuickSearchVisibleHeight

local function nodeCanExpand(node)
	local expansionTest = GF.NavTree and GF.NavTree.NodeCanExpand
	return expansionTest and expansionTest(node) == true
end

local function nodeInteractionBlocked(node, navTree)
	if node == nil then
		return true
	end
	local checker = navTree and navTree.IsNodeInteractionBlocked
	if checker then
		return checker(navTree, node) == true
	end
	return node.disabled == true
end

local function panelIndexForNode(node)
	local depth = node and (node.level or 0)
	return depth and depth < PANEL_COUNT and (depth + 1) or nil
end

local function panelLayoutSig(navTree, children, contentW)
	local fields = {
		navTree and navTree.selectedKey or "",
		tostring(contentW),
		tostring(#children),
		children and children[1] and children[1]._gfQuickSearchProxy and "quick" or "standard",
		NF.favoriteReorderMode and "reorder" or "normal",
	}
	for _, child in ipairs(children) do
		fields[#fields + 1] = child and child.key or ""
		fields[#fields + 1] = child and child.disabled and "1" or "0"
	end
	return table.concat(fields, SIG_SEP)
end

local function nodeVisibleForCurrentTab(node)
	if node == nil then
		return false
	end
	local workspaceView = GF.LFGWorkspaceView
	if workspaceView and workspaceView.IsNodeAllowed and not workspaceView:IsNodeAllowed(node) then
		return false
	end
	local creationTab = invoke(GF.MainFrame, "GetCurrentTabID") == GF.TAB_CREATE
	return not (creationTab and node.browseOnly)
end

local function filterChildrenForCurrentTab(children)
	local visible = {}
	for _, child in ipairs(children or {}) do
		if nodeVisibleForCurrentTab(child) then
			visible[#visible + 1] = child
		end
	end
	return visible
end

local function measureLabelWidth(panel, text)
	if not panel then
		return 0
	end
	if not panel.measureLabel then
		panel.measureLabel = GF.UI.CreateFontString(panel, "BACKGROUND", "GameFontNormal")
		panel.measureLabel:Hide()
	end
	GF.Font.ApplyToFontString(panel.measureLabel, "GameFontNormal")
	panel.measureLabel:SetText(text or "")
	return panel.measureLabel:GetStringWidth() or 0
end

local function resolvePanelWidth(panel, children)
	local insetL, insetR = flyoutContentInsets()
	local maxLabelW = 0
	local hasExpandable = false
	local hasFavoriteIcon = false
	for i = 1, #(children or {}) do
		local n = children[i]
		maxLabelW = math.max(maxLabelW, measureLabelWidth(panel, n and n.label))
		if nodeCanExpand(n) then
			hasExpandable = true
		end
		hasFavoriteIcon = hasFavoriteIcon or n and n.favoriteResult == true
	end
	local rowExtra = (GF.NAV_FLYOUT_ROW_TEXT_L or 12)
		+ (GF.NAV_FLYOUT_ROW_TEXT_R or 10)
		+ (GF.NAV_FLYOUT_LABEL_EXTRA_W or 8)
	if hasExpandable then
		rowExtra = rowExtra + (GF.NAV_FLYOUT_ARROW_RESERVE_W or 28)
	end
	if hasFavoriteIcon then
		rowExtra = rowExtra + (GF.FAVORITE_INSTANCE_ICON_SIZE or 14)
			+ (GF.FAVORITE_INSTANCE_ICON_GAP or 3)
	end
	if NF.favoriteReorderMode and hasFavoriteIcon then
		rowExtra = rowExtra + (FAVORITE_REORDER_BUTTON_SIZE * 2)
			+ FAVORITE_REORDER_BUTTON_GAP + 4
	end
	local desired = insetL + insetR + maxLabelW + rowExtra
	if NF.favoriteReorderMode and hasFavoriteIcon then
		desired = math.max(desired, 210)
	end
	return math.min(math.max(desired, flyoutMinPanelWidth()), flyoutMaxPanelWidth())
end

local function ensureFlyoutRowParts(row, prefix, drawLayer, subLevel)
	if not row then
		return nil
	end
	if row.hover then
		row.hover:Hide()
	end
	local partsKey = prefix .. "Parts"
	if not row[partsKey] then
		if GF.UI and GF.UI.CreateRowBackgroundPieces then
			row[partsKey] = GF.UI.CreateRowBackgroundPieces(row, drawLayer or "BORDER", subLevel or 2)
		else
			row[partsKey] = {
				left = row:CreateTexture(nil, drawLayer or "BORDER", nil, subLevel or 2),
				middle = row:CreateTexture(nil, drawLayer or "BORDER", nil, subLevel or 2),
				right = row:CreateTexture(nil, drawLayer or "BORDER", nil, subLevel or 2),
			}
		end
		for _, tex in pairs(row[partsKey]) do
			if tex.SetBlendMode then
				tex:SetBlendMode("BLEND")
			end
			tex:Hide()
		end
	end
	return row[partsKey]
end

local function setFlyoutRowPartsShown(row, prefix, shown)
	local parts = row and row[prefix .. "Parts"]
	if not parts then
		return
	end
	shown = shown == true
	for _, tex in pairs(parts) do
		tex:SetShown(shown)
	end
end

local function getFlyoutHighlightAtlasInfo()
	local atlas = GF.NAV_FLYOUT_HIGHLIGHT_ATLAS or GF.ROW_BACKGROUND_ATLAS or "UI-QuestTracker-Secondary-Objective-Header"
	if C_Texture and C_Texture.GetAtlasInfo then
		local info = C_Texture.GetAtlasInfo(atlas)
		if info
			and type(info.leftTexCoord) == "number"
			and type(info.rightTexCoord) == "number"
			and type(info.topTexCoord) == "number"
			and type(info.bottomTexCoord) == "number" then
			return atlas, info
		end
	end
	return atlas, nil
end

local function applyFlyoutHighlightPiece(tex, atlas, info, u1, u2)
	if not tex then
		return
	end
	if tex.SetBlendMode then
		tex:SetBlendMode("BLEND")
	end
	if tex.SetDesaturated then
		tex:SetDesaturated(false)
	end
	tex:SetAlpha(1)
	tex:SetVertexColor(1, 1, 1, 1)
	if info then
		local file = info.file or info.filename
		if file then
			local left = info.leftTexCoord or 0
			local right = info.rightTexCoord or 1
			local top = info.topTexCoord or 0
			local bottom = info.bottomTexCoord or 1
			local atlasW = right - left
			tex:SetTexture(file)
			tex:SetTexCoord(left + (atlasW * u1), left + (atlasW * u2), top, bottom)
			return
		end
	end
	if tex.SetAtlas and pcall(tex.SetAtlas, tex, atlas, false) then
		tex:SetTexCoord(u1, u2, 0, 1)
		return
	end
	tex:SetTexture(GF.WHITE_TEXTURE)
	tex:SetTexCoord(0, 1, 0, 1)
end

local function layoutFlyoutRowParts(row, prefix, width, height, offsetX, offsetY, drawLayer, subLevel)
	local parts = ensureFlyoutRowParts(row, prefix, drawLayer, subLevel)
	if not parts then
		return
	end
	width = math.max(width or 1, 1)
	height = math.max(height or 1, 1)
	offsetX = offsetX or 0
	offsetY = offsetY or 0
	local displayH = math.max(1, math.min(height, GF.NAV_FLYOUT_HIGHLIGHT_DISPLAY_H or height))
	local sourceW = GF.ROW_BACKGROUND_SOURCE_WIDTH or 564
	local sourceH = GF.ROW_BACKGROUND_SOURCE_HEIGHT or 52
	local sourceCapW = GF.ROW_BACKGROUND_SOURCE_CAP_WIDTH or 18
	local capUV = math.max(0.001, math.min(0.45, sourceCapW / sourceW))
	local capW = math.max(1, math.min(width * 0.5, math.floor((sourceCapW * displayH / sourceH) + 0.5)))
	local left = parts.left
	local mid = parts.middle
	local right = parts.right
	local atlas, atlasInfo = getFlyoutHighlightAtlasInfo()
	applyFlyoutHighlightPiece(left, atlas, atlasInfo, 0, capUV)
	applyFlyoutHighlightPiece(mid, atlas, atlasInfo, capUV, 1 - capUV)
	applyFlyoutHighlightPiece(right, atlas, atlasInfo, 1 - capUV, 1)

	left:ClearAllPoints()
	left:SetPoint("LEFT", row, "LEFT", offsetX, offsetY)
	left:SetSize(capW, displayH)

	right:ClearAllPoints()
	right:SetPoint("RIGHT", row, "LEFT", offsetX + width, offsetY)
	right:SetSize(capW, displayH)

	mid:ClearAllPoints()
	mid:SetPoint("LEFT", left, "RIGHT", 0, 0)
	mid:SetPoint("RIGHT", right, "LEFT", 0, 0)
	mid:SetHeight(displayH)
end

local function setFlyoutHoverShown(row, shown)
	if row and (row._flyoutSelected or row._flyoutDisabled) then
		shown = false
	end
	setFlyoutRowPartsShown(row, "flyoutHover", shown)
end

local function setFlyoutSelectedShown(row, shown)
	if row and row._flyoutDisabled then
		shown = false
	end
	setFlyoutRowPartsShown(row, "flyoutSelected", shown)
end

local function layoutFlyoutHover(row, width, height, offsetX, offsetY)
	layoutFlyoutRowParts(row, "flyoutHover", width, height, offsetX, offsetY, "BORDER", 2)
end

local function layoutFlyoutSelected(row, width, height, offsetX, offsetY)
	layoutFlyoutRowParts(row, "flyoutSelected", width, height, offsetX, offsetY, "BORDER", 3)
end

function NF:RowIsAnchor(row)
	for index = 1, #(self.panels or {}) do
		if row and self.panels[index]._anchorRow == row then
			return true
		end
	end
	return false
end

local function rowIsDescendantOf(row, frame)
	local cursor = row and row:GetParent()
	while cursor and frame do
		if cursor == frame then
			return true
		end
		cursor = cursor:GetParent()
	end
	return false
end

function NF:BeginPanelLayout(panel)
	if panel == nil then
		return
	end
	panel._positioned = nil
	panel:Hide()
	panel:ClearAllPoints()
end

function NF:HideFromLevel(level)
	local panels = self.panels
	if panels == nil then
		return
	end
	level = level or 1
	if self.openKeys then
		local nodeDepth = math.max(0, level - 1)
		while nodeDepth <= PANEL_COUNT do
			self.openKeys[nodeDepth] = nil
			nodeDepth = nodeDepth + 1
		end
	end
	for panelIndex = PANEL_COUNT, level, -1 do
		local panel = panels[panelIndex]
		if panel then
			if panel.quickSearchBox and panel.quickSearchBox.ClearFocus then
				panel.quickSearchBox:ClearFocus()
			end
			panel._openKey, panel._anchorRow = nil, nil
			panel._layoutSig, panel._positioned = nil, nil
			panel:Hide()
			self:ReleasePanelRows(panel)
		end
	end
end

function NF:HideAll()
	if self.ExitFavoriteReorderMode then
		self:ExitFavoriteReorderMode(false)
	end
	local transientRootKey
	local quickPanel = self.panels and self.panels[1]
	if quickPanel and quickPanel._quickSearchMode
		and quickPanel.IsShown and quickPanel:IsShown()
	then
		transientRootKey = quickPanel._quickSearchRoot
			and quickPanel._quickSearchRoot.key or nil
	elseif quickPanel and quickPanel._openKey
		and quickPanel.IsShown and quickPanel:IsShown()
	then
		local finder = GF.NavData and GF.NavData.FindNodeByKey
		local root = type(finder) == "function"
			and finder(quickPanel._openKey) or nil
		if root and root.favoriteInstancesRoot then
			transientRootKey = root.key
		end
	end
	self:CancelQuickSearchHover()
	self:HideFromLevel(1)
	self.openKeys = nil
	if transientRootKey and self.navTree
		and self.navTree.activeRootKey == transientRootKey
	then
		self.navTree.activeRootKey = nil
	end
end

function NF:IsNodeOpen(node)
	local keys = self.openKeys
	return node ~= nil and keys ~= nil and keys[node.level or 0] == node.key
end

function NF:CloseChildPanelsForRow(row)
	local node = row and row.nodeData
	if node == nil then
		return
	end
	local firstChildPanel = panelIndexForNode(node)
	if firstChildPanel then
		self:HideFromLevel(firstChildPanel)
	end
	invoke(self, "RefreshVisibleRows")
end

local function setDisabledLabelColor(label)
	local state = GF.BUTTON_VISUAL_STATE and GF.BUTTON_VISUAL_STATE.DISABLED or "disabled"
	local visual = GF.COMMON_BUTTON_VISUALS and GF.COMMON_BUTTON_VISUALS[state]
	local color = visual and visual.textColor
	if color then
		label:SetTextColor(unpack(color))
	else
		label:SetTextColor(0.55, 0.55, 0.55, 1)
	end
end

local function disabledTooltipText(node, navTree)
	if not (node and node.disabled == true and node.disabledTooltip == true)
		or node._gfLfgRestrictionState ~= nil
	then
		return nil
	end
	if navTree and navTree.IsInteractionEnabled
		and navTree:IsInteractionEnabled() == false
	then
		return nil
	end
	local text = node.disabledReason or node.tooltip
	return type(text) == "string" and text ~= "" and text or nil
end

local function showDisabledTooltip(owner, node, navTree)
	local text = disabledTooltipText(node, navTree)
	if not text or not (GF.UI and GF.UI.BeginGameTooltip) or not GameTooltip then
		return false
	end
	GF.UI.BeginGameTooltip(owner, "ANCHOR_RIGHT")
	GameTooltip:SetText(text, 1, 0.82, 0, 1, true)
	GameTooltip:Show()
	return true
end

local function createFavoriteOrderButton(row, direction)
	local button = CreateFrame("Button", nil, row)
	if GF.UI and GF.UI.ApplyCommonArrowButtonSkin then
		GF.UI.ApplyCommonArrowButtonSkin(button, direction, {
			hitSize = FAVORITE_REORDER_BUTTON_SIZE,
			visualSize = 16,
		})
	else
		button:SetSize(FAVORITE_REORDER_BUTTON_SIZE, FAVORITE_REORDER_BUTTON_SIZE)
	end
	button._gfFavoriteOrderDirection = direction
	button:SetScript("OnClick", function(owner)
		local node = row.nodeData
		if node and node.favoriteIdentity then
			NF:MoveFavoriteDraft(
				node.favoriteIdentity,
				owner._gfFavoriteOrderDirection)
		end
	end)
	button:SetScript("OnEnter", function(owner)
		if not (GF.UI and GF.UI.BeginGameTooltip and GameTooltip) then
			return
		end
		GF.UI.BeginGameTooltip(owner, "ANCHOR_RIGHT")
		local L = GF.L or {}
		GameTooltip:SetText(direction < 0
			and (L.FAVORITE_INSTANCE_MOVE_UP or "Move Up")
			or (L.FAVORITE_INSTANCE_MOVE_DOWN or "Move Down"),
			1, 0.82, 0, 1, true)
		GameTooltip:Show()
	end)
	button:SetScript("OnLeave", function()
		if GameTooltip_Hide then
			GameTooltip_Hide()
		end
	end)
	button:Hide()
	return button
end

local function layoutFavoriteOrderButtons(row, node, label)
	local visible = NF.favoriteReorderMode and node.favoriteResult == true
	if not row.favoriteMoveUp then
		row.favoriteMoveUp = createFavoriteOrderButton(row, -1)
		row.favoriteMoveDown = createFavoriteOrderButton(row, 1)
		row.favoriteMoveDown:SetPoint(
			"RIGHT", row, "RIGHT", -(GF.NAV_FLYOUT_ROW_TEXT_R or 10), 0)
		row.favoriteMoveUp:SetPoint(
			"RIGHT", row.favoriteMoveDown, "LEFT", -FAVORITE_REORDER_BUTTON_GAP, 0)
	end
	row.favoriteMoveUp:SetShown(visible)
	row.favoriteMoveDown:SetShown(visible)
	if not visible then
		return false
	end
	local order = tonumber(node.favoriteOrder) or 1
	local count = NF._favoriteReorderOrder and #NF._favoriteReorderOrder or order
	row.favoriteMoveUp:SetEnabled(order > 1)
	row.favoriteMoveDown:SetEnabled(order < count)
	row.favoriteMoveUp:SetFrameLevel(row:GetFrameLevel() + 5)
	row.favoriteMoveDown:SetFrameLevel(row:GetFrameLevel() + 5)
	if row.arrow then
		row.arrow:Hide()
	end
	label:SetPoint("RIGHT", row.favoriteMoveUp, "LEFT", -4, 0)
	return true
end

local function layoutFlyoutRow(row, n, navTree, width, h)
	local disabled = nodeInteractionBlocked(n, navTree)
	local label = row.label
	row.bg:SetShown(false)
	row.cover:SetShown(false)
	label:ClearAllPoints()
	local textLeft = GF.NAV_FLYOUT_ROW_TEXT_L or 12
	local favoriteIcon = row.favoriteIcon
	if n.favoriteResult then
		if not favoriteIcon then
			favoriteIcon = row:CreateTexture(nil, "OVERLAY", nil, 6)
			row.favoriteIcon = favoriteIcon
		end
		favoriteIcon:ClearAllPoints()
		favoriteIcon:SetPoint("LEFT", row, "LEFT", textLeft, 0)
		local iconSize = GF.FAVORITE_INSTANCE_ICON_SIZE or 14
		local atlas = GF.FAVORITE_INSTANCE_ICON_ATLAS
			or "campcollection-icon-star"
		local hasIcon = GF.UI and GF.UI.SetAtlasFit
			and GF.UI.SetAtlasFit(favoriteIcon, atlas, iconSize, iconSize)
		if hasIcon then
			favoriteIcon:SetDesaturated(disabled)
			favoriteIcon:SetAlpha(disabled and 0.55 or 1)
			favoriteIcon:SetVertexColor(1, 1, 1, 1)
			favoriteIcon:Show()
			label:SetPoint(
				"LEFT",
				favoriteIcon,
				"RIGHT",
				GF.FAVORITE_INSTANCE_ICON_GAP or 3,
				0)
		else
			favoriteIcon:Hide()
			label:SetPoint("LEFT", row, "LEFT", textLeft, 0)
		end
	else
		if favoriteIcon then
			favoriteIcon:Hide()
		end
		label:SetPoint("LEFT", row, "LEFT", textLeft, 0)
	end
	local applyArrow = GF.NavTree and GF.NavTree.ApplyExpandArrow
	local hasArrow = applyArrow and applyArrow(row, n)
	if hasArrow then
		row.arrow:ClearAllPoints()
		row.arrow:SetPoint("RIGHT", row, "RIGHT", -(GF.NAV_FLYOUT_ARROW_R or 8), 0)
		label:SetPoint("RIGHT", row.arrow, "LEFT", -4, 0)
	else
		label:SetPoint("RIGHT", row, "RIGHT", -(GF.NAV_FLYOUT_ROW_TEXT_R or 10), 0)
	end
	label:SetText(n.label or "")
	label:SetJustifyH("LEFT")
	label:SetJustifyV("MIDDLE")
	label:SetWordWrap(false)
	label:Show()
	GF.Font.ApplyToFontString(label, "GameFontNormal")
	if disabled then
		setDisabledLabelColor(label)
	else
		label:SetTextColor(1, 1, 1)
	end
	local isSel = not disabled
		and ((navTree and navTree.selectedKey == n.key) or (GF.NavFlyout and GF.NavFlyout:IsNodeOpen(n)))
	local hlX = GF.NAV_FLYOUT_HIGHLIGHT_INSET_X or 6
	local hlTop = GF.NAV_FLYOUT_HIGHLIGHT_INSET_TOP or 3
	local hlBottom = GF.NAV_FLYOUT_HIGHLIGHT_INSET_BOTTOM or 1
	local textureExtendX = GF.NAV_FLYOUT_ROW_TEXTURE_EXTEND_X or 0
	local hlW = width - (hlX * 2) + (textureExtendX * 2)
	local hlH = h - hlTop - hlBottom
	local hlOffsetX = hlX - textureExtendX
	local hlY = (hlBottom - hlTop) / 2
	row._flyoutDisabled = disabled
	row._flyoutSelected = isSel
	layoutFlyoutHover(row, hlW, hlH, hlOffsetX, hlY)
	layoutFlyoutSelected(row, hlW, hlH, hlOffsetX, hlY)
	setFlyoutSelectedShown(row, isSel)
	if row.sel then
		row.sel:Hide()
	end
	setFlyoutHoverShown(row, false)
	row.hit:SetFrameLevel(row:GetFrameLevel() + 2)
	layoutFavoriteOrderButtons(row, n, label)
	row._flyoutDisabledTooltip = disabledTooltipText(n, navTree) ~= nil
	-- Disabled rows still need pointer entry events so they can close a stale
	-- sibling submenu. Business actions remain blocked by the row handlers.
	row.hit:EnableMouse(true)
	if n.favoriteResult then
		-- Unavailable favorites remain manageable through Remove/Reorder even
		-- though their normal left-click action is disabled.
		row.hit:EnableMouse(true)
	end
	row._gfNavLevel = n.level or 1
end

function NF:RefreshVisibleRows()
	if not self.panels then
		return
	end
	for _, panel in ipairs(self.panels) do
		for _, row in ipairs(panel.rows or {}) do
			if row and row:IsShown() and row.nodeData then
				layoutFlyoutRow(row, row.nodeData, self.navTree, row:GetWidth(), row:GetHeight())
			end
		end
	end
end

local function rowForPanel(flyout, panel, index)
	local row = panel.rows[index]
	if row and row._gfFlyoutPanel and row._gfFlyoutPanel ~= panel then
		panel.rows[index] = nil
		row = nil
	end
	if row == nil then
		row = flyout:AcquireRow(panel.content, panel)
		panel.rows[index] = row
	elseif row:GetParent() ~= panel.content then
		row:ClearAllPoints()
		row:SetParent(panel.content)
	end
	return row
end

local function estimatedChildrenHeight(children)
	local total = 0
	for _, node in ipairs(children) do
		total = total + flyoutRowHeight(node.level or 1)
	end
	return math.max(total, 1)
end

local function applyQuickSearchDivider(texture, alpha)
	if not texture then
		return
	end
	local color = GF.BROWSE_HEADER_TEXT_COLOR or { 1, 0.82, 0, 1 }
	local hasAtlas = GF.UI and GF.UI.TrySetAtlas
		and GF.UI.TrySetAtlas(texture, "Options_HorizontalDivider", true)
	if not hasAtlas then
		texture:SetColorTexture(
			color[1] or 1,
			color[2] or 0.82,
			color[3] or 0,
			1)
		texture:SetHeight(1)
	else
		texture:SetVertexColor(
			color[1] or 1,
			color[2] or 0.82,
			color[3] or 0,
			1)
	end
	texture:SetAlpha(alpha or 1)
end

local function refreshQuickSearchHeaderLocale(panel)
	if not panel then
		return
	end
	local L = GF.L or {}
	if panel.quickSearchTitle then
		panel.quickSearchTitle:SetText(L.QUICK_SEARCH_TITLE or "Quick Instance Search")
	end
	if panel.quickSearchBox and panel.quickSearchBox.Instructions then
		panel.quickSearchBox.Instructions:SetText(
			L.QUICK_SEARCH_PLACEHOLDER or "Enter an instance name")
	end
end

local function ensureQuickSearchHeader(panel)
	if not panel or panel.quickSearchHeader then
		return panel and panel.quickSearchHeader
	end
	local insetL, insetR, insetT = flyoutContentInsets()
	local header = CreateFrame("Frame", nil, panel)
	header:SetPoint("TOPLEFT", panel, "TOPLEFT", insetL, -insetT)
	header:SetPoint("TOPRIGHT", panel, "TOPRIGHT", -insetR, -insetT)
	header:SetHeight(QUICK_SEARCH_HEADER_HEIGHT)
	header:SetFrameLevel(panel:GetFrameLevel() + 3)

	local title = GF.UI.CreateFontString(header, "OVERLAY", "GameFontNormal")
	title:SetPoint("TOPLEFT", header, "TOPLEFT", 0, 0)
	title:SetPoint("TOPRIGHT", header, "TOPRIGHT", 0, 0)
	title:SetHeight(QUICK_SEARCH_TITLE_HEIGHT)
	title:SetJustifyH("CENTER")
	title:SetJustifyV("MIDDLE")
	title:SetTextColor(1, 0.82, 0, 1)
	title._gfFontSizeOverride = 14
	if GF.Font and GF.Font.Track then
		GF.Font.Track(title, "GameFontNormal")
	end

	local dividerTop = header:CreateTexture(nil, "ARTWORK")
	dividerTop:SetPoint("TOPLEFT", title, "BOTTOMLEFT", 0, -1)
	dividerTop:SetPoint("TOPRIGHT", title, "BOTTOMRIGHT", 0, -1)
	applyQuickSearchDivider(dividerTop, 1)

	local searchBox = CreateFrame("EditBox", nil, header, "SearchBoxTemplate")
	searchBox:SetPoint("TOPLEFT", dividerTop, "BOTTOMLEFT", 0, -6)
	searchBox:SetPoint("TOPRIGHT", dividerTop, "BOTTOMRIGHT", 0, -6)
	searchBox:SetHeight(QUICK_SEARCH_BOX_HEIGHT)
	if GF.UI and GF.UI.StyleBrowseSearchBox then
		GF.UI.StyleBrowseSearchBox(
			searchBox,
			(GF.L or {}).QUICK_SEARCH_PLACEHOLDER or "Enter an instance name")
	end

	local dividerBottom = header:CreateTexture(nil, "ARTWORK")
	dividerBottom:SetPoint("TOPLEFT", searchBox, "BOTTOMLEFT", 0, -6)
	dividerBottom:SetPoint("TOPRIGHT", searchBox, "BOTTOMRIGHT", 0, -6)
	applyQuickSearchDivider(dividerBottom, 0.7)

	panel.quickSearchHeader = header
	panel.quickSearchTitle = title
	panel.quickSearchBox = searchBox
	panel.quickSearchDividerTop = dividerTop
	panel.quickSearchDividerBottom = dividerBottom
	searchBox._gfQuickSearchPanel = panel
	searchBox:SetScript("OnTextChanged", function(box)
		if SearchBoxTemplate_OnTextChanged then
			SearchBoxTemplate_OnTextChanged(box)
		end
		local owner = box._gfQuickSearchPanel
		if owner and not owner._suppressQuickSearchTextChanged then
			NF:RefreshQuickSearchPanel(owner, box:GetText() or "")
		end
	end)
	searchBox:SetScript("OnEscapePressed", function(box)
		if box:GetText() ~= "" then
			box:SetText("")
		else
			box:ClearFocus()
			NF:HideAll()
			invoke(NF.navTree, "Refresh")
		end
	end)
	searchBox:SetScript("OnEnterPressed", function(box)
		box:ClearFocus()
	end)
	refreshQuickSearchHeaderLocale(panel)
	return header
end

local function refreshFavoriteReorderFooterLocale(panel)
	local footer = panel and panel.favoriteReorderFooter
	if not footer then
		return
	end
	local L = GF.L or {}
	footer.hint:SetText(
		L.FAVORITE_INSTANCE_REORDER_HINT
			or "Use arrows to adjust order")
	footer.cancelButton:SetText(L.CANCEL or CANCEL or "Cancel")
	footer.doneButton:SetText(
		L.FAVORITE_INSTANCE_REORDER_DONE or "Confirm")
end

local function ensureFavoriteReorderFooter(panel)
	if not panel or panel.favoriteReorderFooter then
		return panel and panel.favoriteReorderFooter
	end
	local insetL, insetR, _, insetB = flyoutContentInsets()
	local footer = CreateFrame("Frame", nil, panel)
	footer:SetPoint("BOTTOMLEFT", panel, "BOTTOMLEFT", insetL, insetB)
	footer:SetPoint("BOTTOMRIGHT", panel, "BOTTOMRIGHT", -insetR, insetB)
	footer:SetHeight(FAVORITE_REORDER_FOOTER_HEIGHT)
	footer:SetFrameLevel(panel:GetFrameLevel() + 4)

	local divider = footer:CreateTexture(nil, "ARTWORK")
	divider:SetPoint("TOPLEFT", footer, "TOPLEFT", 0, 0)
	divider:SetPoint("TOPRIGHT", footer, "TOPRIGHT", 0, 0)
	applyQuickSearchDivider(divider, 0.55)

	local hint = GF.UI.CreateFontString(footer, "OVERLAY", "GameFontNormalSmall")
	hint:SetPoint("TOPLEFT", footer, "TOPLEFT", 0, -5)
	hint:SetPoint("TOPRIGHT", footer, "TOPRIGHT", 0, -5)
	hint:SetHeight(16)
	hint:SetJustifyH("CENTER")
	hint:SetJustifyV("MIDDLE")
	hint:SetTextColor(0.72, 0.68, 0.55, 1)
	hint:SetWordWrap(false)
	hint._gfFontSizeOverride = 12
	if GF.Font and GF.Font.Track then
		GF.Font.Track(hint, "GameFontNormalSmall")
	end

	local cancelButton = GF.UI.CreatePanelButton(
		footer, "", FAVORITE_REORDER_ACTION_BUTTON_WIDTH)
	cancelButton:SetPoint(
		"BOTTOMRIGHT", footer, "BOTTOM",
		-(FAVORITE_REORDER_ACTION_BUTTON_GAP / 2), 2)
	local doneButton = GF.UI.CreatePanelButton(
		footer, "", FAVORITE_REORDER_ACTION_BUTTON_WIDTH)
	doneButton:SetPoint(
		"BOTTOMLEFT", footer, "BOTTOM",
		FAVORITE_REORDER_ACTION_BUTTON_GAP / 2, 2)
	doneButton:SetScript("OnClick", function()
		NF:ExitFavoriteReorderMode(true)
	end)
	cancelButton:SetScript("OnClick", function()
		NF:ExitFavoriteReorderMode(false)
	end)

	footer.hint = hint
	footer.doneButton = doneButton
	footer.cancelButton = cancelButton
	panel.favoriteReorderFooter = footer
	refreshFavoriteReorderFooterLocale(panel)
	footer:Hide()
	return footer
end

local function updatePanelModeAnchors(panel)
	if not panel or not panel.scroll then
		return
	end
	local insetL, insetR, insetT, insetB = flyoutContentInsets()
	panel.scroll:ClearAllPoints()
	if panel._quickSearchMode then
		local header = ensureQuickSearchHeader(panel)
		header:Show()
		panel.scroll:SetPoint(
			"TOPLEFT",
			panel,
			"TOPLEFT",
			insetL,
			-(insetT + QUICK_SEARCH_HEADER_HEIGHT))
	else
		if panel.quickSearchHeader then
			panel.quickSearchHeader:Hide()
		end
		panel.scroll:SetPoint("TOPLEFT", panel, "TOPLEFT", insetL, -insetT)
	end
	local footer = panel.favoriteReorderFooter
	if panel._favoriteReorderMode then
		footer = footer or ensureFavoriteReorderFooter(panel)
		footer:Show()
	else
		if footer then
			footer:Hide()
		end
	end
	panel.scroll:SetPoint(
		"BOTTOMRIGHT",
		panel,
		"BOTTOMRIGHT",
		-insetR,
		insetB + (panel._favoriteReorderMode
			and FAVORITE_REORDER_FOOTER_HEIGHT or 0))
end

local function setQuickSearchPanelMode(panel, enabled)
	if not panel then
		return
	end
	panel._quickSearchMode = enabled == true
	updatePanelModeAnchors(panel)
end

local function setFavoriteReorderPanelMode(panel, enabled)
	if not panel then
		return
	end
	panel._favoriteReorderMode = enabled == true
	panel._layoutSig = nil
	updatePanelModeAnchors(panel)
end

function NF:LayoutPanelRows(panel, children, navTree, options)
	options = options or {}
	children = children or {}
	local insetL, insetR, insetT, insetB = flyoutContentInsets()
	local width = panel._quickSearchMode
		and QUICK_SEARCH_PANEL_WIDTH or resolvePanelWidth(panel, children)
	local contentWidth = math.max(80, width - insetL - insetR)
	local signature = panelLayoutSig(navTree, children, contentWidth)
	panel:SetWidth(width)
	panel.panelW = width
	if signature == panel._layoutSig then
		return
	end
	panel._layoutSig = signature
	if not options.preservePanel then
		self:BeginPanelLayout(panel)
	end
	panel.content:SetSize(contentWidth, estimatedChildrenHeight(children))

	local offset = 0
	for index, node in ipairs(children) do
		local row = rowForPanel(self, panel, index)
		local rowHeight = flyoutRowHeight(node.level or 1)
		row:SetSize(contentWidth, rowHeight)
		row:ClearAllPoints()
		row:SetPoint("TOPLEFT", panel.content, "TOPLEFT", 0, -offset)
		row.nodeData = node
		row:Show()
		layoutFlyoutRow(row, node, navTree, contentWidth, rowHeight)
		offset = offset + rowHeight
	end
	self:ReleasePanelRows(panel, #children + 1)
	panel.content:SetSize(contentWidth, math.max(offset, 1))
	panel._fullHeight = offset + insetT + insetB
		+ (panel._quickSearchMode and QUICK_SEARCH_HEADER_HEIGHT or 0)
		+ (panel._favoriteReorderMode
			and FAVORITE_REORDER_FOOTER_HEIGHT or 0)
	local visibleHeight = panel._fullHeight
	if panel._quickSearchMode then
		visibleHeight = resolveQuickSearchVisibleHeight(
			visibleHeight,
			resolveSecondLevelMaxVisibleHeight(navTree))
	end
	setFlyoutPanelVisibleHeight(panel, visibleHeight, true)
end

function NF:PositionPanel(panel, anchorRow, panelIdx)
	local positionIsCurrent = panel._positioned and panel._anchorRow == anchorRow
	if positionIsCurrent then
		return
	end
	local gap = flyoutPanelAnchorGap(panelIdx)
	local parentPanel = panelIdx and panelIdx > 1 and self.panels
		and self.panels[panelIdx - 1] or nil
	panel:ClearAllPoints()
	if parentPanel and shouldAlignDeepPanelToParentRow(panelIdx) then
		local maxVisibleH = resolveSecondLevelMaxVisibleHeight(self.navTree)
		local visibleH, offsetY = resolveDeepPanelHeightAndOffset(panel, anchorRow, parentPanel, maxVisibleH)
		if visibleH and offsetY then
			setFlyoutPanelVisibleHeight(panel, visibleH, false)
			panel:SetPoint("TOPLEFT", parentPanel, "TOPRIGHT", gap, offsetY)
			panel._positioned = true
			return
		end
	end
	local desiredTop
	local rootAnchor
	if panelIdx == 1 then
		rootAnchor = resolveRootFlyoutAnchor(self.navTree)
		if rootAnchor then
			desiredTop = rootAnchor.baseTop
		end
	end
	if not desiredTop and panelIdx and panelIdx > 1 and self.panels and self.panels[1] then
		local rootPanel = self.panels[1]
		desiredTop = rootPanel.GetTop and rootPanel:GetTop()
		if desiredTop then
			desiredTop = desiredTop - (flyoutStairStepY() * (panelIdx - 1))
		end
	end
	if not desiredTop then
		desiredTop = anchorRow and anchorRow.GetTop and anchorRow:GetTop()
	end
	local visibleH, resolvedTop = resolvePanelHeightAndTop(panel, desiredTop)
	if panel._quickSearchMode then
		visibleH = resolveQuickSearchVisibleHeight(
			visibleH,
			resolveSecondLevelMaxVisibleHeight(self.navTree))
	end
	setFlyoutPanelVisibleHeight(panel, visibleH, false)
	if panelIdx and panelIdx > 1 then
		if parentPanel then
			local parentTop = parentPanel.GetTop and parentPanel:GetTop()
			if parentTop and resolvedTop then
				panel:SetPoint("TOPLEFT", parentPanel, "TOPRIGHT", gap, resolvedTop - parentTop)
			else
				panel:SetPoint("TOP", anchorRow, "TOP", 0, 0)
				panel:SetPoint("LEFT", parentPanel, "RIGHT", gap, 0)
			end
			panel._positioned = true
			return
		end
	end
	if rootAnchor then
		panel:SetPoint("TOPLEFT", rootAnchor.frame, "BOTTOMLEFT", rootAnchor.offsetX, rootAnchor.offsetY + (resolvedTop - rootAnchor.baseTop))
		panel._positioned = true
		return
	end
	local anchorRight = anchorRow and anchorRow.GetRight and anchorRow:GetRight()
	if anchorRight and resolvedTop and UIParent then
		panel:SetPoint("TOPLEFT", UIParent, "BOTTOMLEFT", anchorRight + gap, resolvedTop)
	else
		panel:SetPoint("TOPLEFT", anchorRow, "TOPRIGHT", gap, 0)
	end
	panel._positioned = true
end

local function currentTabID()
	return invoke(GF.MainFrame, "GetCurrentTabID") or GF.TAB_BROWSE
end

function NF:PrepareNode(node, navTree)
	if node and navTree then
		invoke(navTree, "PrepareBranch", node)
	end
	if node and node.favoriteInstancesRoot
		and GF.FavoriteInstances and GF.FavoriteInstances.RefreshRoot
	then
		GF.FavoriteInstances:RefreshRoot(
			currentTabID(),
			self.favoriteReorderMode and self._favoriteReorderOrder or nil)
	end
end

function NF:RefreshQuickSearchPanel(panel, query)
	local root = panel and panel._quickSearchRoot
	local anchorRow = panel and panel._quickSearchAnchorRow
	local search = GF.QuickSearch
	if not (root and anchorRow and search and search.GetResultNodes) then
		return false
	end
	self:CancelQuickSearchHover()
	local children = search:GetResultNodes(query or "", currentTabID())
	root.children = children
	root.childrenLoaded = true
	self:HideFromLevel(2)
	refreshQuickSearchHeaderLocale(panel)
	panel._layoutSig = nil
	self:LayoutPanelRows(panel, children, self.navTree, { preservePanel = true })
	self:RefreshVisibleRows()
	return true
end

function NF:OpenQuickSearch(anchorRow, node)
	local panel = self.panels and self.panels[1]
	local search = GF.QuickSearch
	if not (panel and search and search.GetResultNodes) then
		return false
	end
	self:HideFromLevel(2)
	self.openKeys = self.openKeys or {}
	self.openKeys[0] = node.key
	panel._openKey, panel._anchorRow = node.key, anchorRow
	panel._quickSearchRoot = node
	panel._quickSearchAnchorRow = anchorRow
	setQuickSearchPanelMode(panel, true)
	local query = search:GetQuery(currentTabID())
	local searchBox = panel.quickSearchBox
	if searchBox and searchBox:GetText() ~= query then
		panel._suppressQuickSearchTextChanged = true
		searchBox:SetText(query)
		panel._suppressQuickSearchTextChanged = nil
	end
	local children = search:GetResultNodes(query, currentTabID())
	node.children = children
	node.childrenLoaded = true
	self:BeginPanelLayout(panel)
	self:LayoutPanelRows(panel, children, self.navTree)
	self:PositionPanel(panel, anchorRow, 1)
	panel:Show()
	self:RefreshVisibleRows()
	if searchBox and searchBox.SetFocus then
		searchBox:SetFocus()
	end
	return true
end

function NF:RefreshLocale()
	local panel = self.panels and self.panels[1]
	if not panel then
		return
	end
	refreshQuickSearchHeaderLocale(panel)
	refreshFavoriteReorderFooterLocale(panel)
	if panel._quickSearchMode and panel.IsShown and panel:IsShown() then
		local query = panel.quickSearchBox and panel.quickSearchBox:GetText() or ""
		self:RefreshQuickSearchPanel(panel, query)
	elseif GF.FavoriteInstances and GF.FavoriteInstances.RefreshRoot then
		local root = GF.FavoriteInstances:RefreshRoot(
			currentTabID(),
			self.favoriteReorderMode and self._favoriteReorderOrder or nil)
		if root and self:IsNodeOpen(root) then
			self:ReanchorOpenPanels()
		end
	end
end

function NF:OpenFrom(anchorRow, node)
	if anchorRow == nil or nodeInteractionBlocked(node, self.navTree) or not nodeCanExpand(node) then
		return
	end
	if self.favoriteReorderMode and not node.favoriteInstancesRoot then
		self:ExitFavoriteReorderMode(false)
	end
	if node.quickSearchRoot then
		return self:OpenQuickSearch(anchorRow, node)
	end
	local panelIndex = panelIndexForNode(node)
	local panel = panelIndex and self.panels and self.panels[panelIndex]
	if panel == nil or rowIsDescendantOf(anchorRow, panel) then
		return
	end

	local navTree = self.navTree
	self:PrepareNode(node, navTree)
	local children = filterChildrenForCurrentTab(node.children)
	if #children == 0 then
		return
	end

	self:HideFromLevel(panelIndex + 1)
	self.openKeys = self.openKeys or {}
	self.openKeys[node.level or 0] = node.key
	panel._openKey, panel._anchorRow = node.key, anchorRow
	panel._quickSearchRoot, panel._quickSearchAnchorRow = nil, nil
	setQuickSearchPanelMode(panel, false)
	self:BeginPanelLayout(panel)
	self:LayoutPanelRows(panel, children, navTree)
	self:PositionPanel(panel, anchorRow, panelIndex)
	panel:Show()
	self:RefreshVisibleRows()
end

function NF:OpenFromHover(row)
	local n = row and row.nodeData
	if nodeInteractionBlocked(n, self.navTree) then
		-- The node may become blocked while a delayed quick-search hover is
		-- pending. Retire the old sibling branch instead of leaving its open
		-- visual and child panel behind.
		self:CloseChildPanelsForRow(row)
		return false
	end
	if self.navTree and self.navTree.IsInteractionEnabled and not self.navTree:IsInteractionEnabled() then
		return false
	end
	if not nodeCanExpand(n) then
		self:CloseChildPanelsForRow(row)
		return false
	end
	if self:IsNodeOpen(n) then
		return true
	end
	self:OpenFrom(row, n)
	return true
end

local function shouldAutoSearchNode(node)
	if not node then
		return false
	end
	if (node.level or 0) == 0 then
		return false
	end
	local mainFrame = GF.MainFrame
	if not (mainFrame and mainFrame.GetCurrentTabID and mainFrame:GetCurrentTabID() == GF.TAB_BROWSE) then
		return false
	end
	if GF.FindGroupTab and GF.FindGroupTab.IsSearchableSelection then
		return GF.FindGroupTab:IsSearchableSelection(node) == true
	end
	return GF.NavData and GF.NavData.IsSearchable and GF.NavData.IsSearchable(node) == true
end

local function autoSearchSelectedNode(node)
	if not shouldAutoSearchNode(node) then
		return
	end
	if GF.FindGroupTab and GF.FindGroupTab.DoSearch then
		GF.FindGroupTab:DoSearch({ source = "navFlyoutClick" })
	end
end

function NF:OnRowClick(row)
	local node = row and row.nodeData
	if nodeInteractionBlocked(node, self.navTree) then
		return false
	end
	local navTree = self.navTree
	if navTree and navTree.IsInteractionEnabled and navTree:IsInteractionEnabled() == false then
		return false
	end
	local tabID = currentTabID()
	if node.favoriteResult
		and (node.favoriteActivity or tabID == GF.TAB_BROWSE)
	then
		local favorites = GF.FavoriteInstances
		local target = favorites and favorites.ResolveTarget
			and favorites:ResolveTarget(node, tabID) or nil
		if target then
			if tabID == GF.TAB_BROWSE then
				invoke(GF.SubtitleBar, "ClearSearchText")
				invoke(GF.FindGroupTab, "ClearCommittedSearchQuery")
			end
			invoke(navTree, "SetSelected", target)
			autoSearchSelectedNode(target)
			self:HideAll()
			return true
		end
		self:HideAll()
		invoke(navTree, "Refresh")
		return false
	end
	if node.quickSearchDifficulty then
		local search = GF.QuickSearch
		local target = search and search.ResolveTarget
			and search:ResolveTarget(node, currentTabID()) or nil
		if not target then
			self:HideAll()
			invoke(navTree, "Refresh")
			return false
		end
		if currentTabID() == GF.TAB_BROWSE then
			invoke(GF.SubtitleBar, "ClearSearchText")
			invoke(GF.FindGroupTab, "ClearCommittedSearchQuery")
		end
		invoke(navTree, "SetSelected", target)
		autoSearchSelectedNode(target)
		self:HideAll()
		return true
	end

	if nodeCanExpand(node) then
		if GF.NavData.AcceptsBrowseSelection(node) then
			invoke(navTree, "SetSelected", node, true, { keepFlyouts = true })
		end
		self:OpenFrom(row, node)
		self:RefreshVisibleRows()
		autoSearchSelectedNode(node)
		return true
	end

	if GF.NavData.AcceptsBrowseSelection(node) then
		invoke(navTree, "SetSelected", node)
		autoSearchSelectedNode(node)
		self:HideAll()
		return true
	end
	return false
end

function NF:CancelQuickSearchHover()
	self._quickSearchHoverToken = (self._quickSearchHoverToken or 0) + 1
	if self._quickSearchHoverTimer and self._quickSearchHoverTimer.Cancel then
		self._quickSearchHoverTimer:Cancel()
	end
	self._quickSearchHoverTimer = nil
end

function NF:QueueQuickSearchHover(row)
	local node = row and row.nodeData
	if not (node and node.quickSearchResult) then
		return self:OpenFromHover(row)
	end
	self:CancelQuickSearchHover()
	local token = self._quickSearchHoverToken
	local searchGeneration = GF.QuickSearch and GF.QuickSearch.GetGeneration
		and GF.QuickSearch:GetGeneration() or 0
	local function openIfCurrent()
		self._quickSearchHoverTimer = nil
		if token ~= self._quickSearchHoverToken
			or row.nodeData ~= node
			or node._gfQuickSearchGeneration ~= searchGeneration
		then
			return
		end
		local hit = row.hit
		if hit and hit.IsMouseMotionFocus and not hit:IsMouseMotionFocus() then
			return
		end
		self:OpenFromHover(row)
	end
	if C_Timer and C_Timer.NewTimer then
		self._quickSearchHoverTimer = C_Timer.NewTimer(
			GF.QUICK_SEARCH_HOVER_DELAY or QUICK_SEARCH_HOVER_DELAY,
			openIfCurrent)
	else
		openIfCurrent()
	end
	return true
end

function NF:HandleRowPointerEnter(row)
	local node = row and row.nodeData
	local hitTarget = row and row.hit
	if not (node and hitTarget) then
		return false
	end

	-- Every visible row owns the pointer transition. Clear any delayed hover
	-- before deciding whether the new target can replace the open branch.
	self:CancelQuickSearchHover()
	if self.favoriteReorderMode then
		setFlyoutHoverShown(row, false)
		return false
	end
	if nodeInteractionBlocked(node, self.navTree) then
		-- OnLeave cannot close immediately because the pointer may be crossing
		-- into this row's child panel. A blocked sibling is an explicit new
		-- target, so it must retire the previous sibling's child branch.
		self:CloseChildPanelsForRow(row)
		setFlyoutHoverShown(row, false)
		showDisabledTooltip(hitTarget, node, self.navTree)
		return false
	end
	if node.quickSearchResult then
		self:QueueQuickSearchHover(row)
	else
		self:OpenFromHover(row)
	end
	setFlyoutHoverShown(row, true)
	return true
end

function NF:RefreshFavoritePanel(root)
	local panel = self.panels and self.panels[1]
	if not (root and panel and self:IsNodeOpen(root)
		and panel.IsShown and panel:IsShown())
	then
		return false
	end
	self:HideFromLevel(2)
	panel._layoutSig = nil
	self:LayoutPanelRows(
		panel,
		filterChildrenForCurrentTab(root.children),
		self.navTree,
		{ preservePanel = true })
	self:RefreshVisibleRows()
	return true
end

function NF:EnterFavoriteReorderMode()
	local favorites = GF.FavoriteInstances
	if not (favorites and favorites.GetCount and favorites:GetCount() > 1) then
		return false
	end
	self.favoriteReorderMode = true
	self._favoriteReorderOrder = favorites:GetIdentityOrder()
	self._favoriteReorderDirty = nil
	self:CancelQuickSearchHover()
	self:HideFromLevel(2)
	local panel = self.panels and self.panels[1]
	if panel then
		setFavoriteReorderPanelMode(panel, true)
		panel:EnableKeyboard(true)
		if panel.SetPropagateKeyboardInput then
			panel:SetPropagateKeyboardInput(true)
		end
		panel:SetScript("OnKeyDown", function(owner, key)
			if key == "ESCAPE" then
				if owner.SetPropagateKeyboardInput then
					owner:SetPropagateKeyboardInput(false)
				end
				NF:ExitFavoriteReorderMode(false)
			elseif owner.SetPropagateKeyboardInput then
				owner:SetPropagateKeyboardInput(true)
			end
		end)
	end
	local root = favorites:RefreshRoot(currentTabID(), self._favoriteReorderOrder)
	self:RefreshFavoritePanel(root)
	return true
end

function NF:MoveFavoriteDraft(identity, direction)
	if not self.favoriteReorderMode or type(identity) ~= "string" then
		return false
	end
	direction = direction < 0 and -1 or 1
	local order = self._favoriteReorderOrder or {}
	local sourceIndex
	for index, currentIdentity in ipairs(order) do
		if currentIdentity == identity then
			sourceIndex = index
			break
		end
	end
	local targetIndex = sourceIndex and (sourceIndex + direction) or nil
	if not (targetIndex and targetIndex >= 1 and targetIndex <= #order) then
		return false
	end
	order[sourceIndex], order[targetIndex] = order[targetIndex], order[sourceIndex]
	self._favoriteReorderDirty = true
	local favorites = GF.FavoriteInstances
	local root = favorites and favorites.RefreshRoot
		and favorites:RefreshRoot(currentTabID(), order) or nil
	self:RefreshFavoritePanel(root)
	return true
end

function NF:ExitFavoriteReorderMode(commit)
	if not self.favoriteReorderMode then
		return false
	end
	local favorites = GF.FavoriteInstances
	local order = self._favoriteReorderOrder
	local dirty = self._favoriteReorderDirty == true
	self.favoriteReorderMode = nil
	self._favoriteReorderOrder = nil
	self._favoriteReorderDirty = nil
	local panel = self.panels and self.panels[1]
	if panel then
		panel:SetScript("OnKeyDown", nil)
		panel:EnableKeyboard(false)
		setFavoriteReorderPanelMode(panel, false)
	end
	local saved = commit == true and favorites and favorites.CommitOrder
		and favorites:CommitOrder(order, true) or false
	local root = favorites and favorites.RefreshRoot
		and favorites:RefreshRoot(currentTabID()) or nil
	if not self:RefreshFavoritePanel(root) then
		self:RefreshVisibleRows()
	end
	if saved and GF.ShowTopNotice then
		GF.ShowTopNotice(
			(GF.L or {}).FAVORITE_INSTANCE_REORDER_SAVED
				or "Favorite activity order saved",
			{ source = "favorite_reorder", playSound = false })
	end
	return commit == true and (saved or not dirty)
end

function NF:OpenRowContextMenu(row)
	if self.favoriteReorderMode or not MenuUtil or not MenuUtil.CreateContextMenu then
		return false
	end
	local node = row and row.nodeData
	local owner = row and row.hit
	local favorites = GF.FavoriteInstances
	if not (node and owner and favorites) then
		return false
	end
	local candidate = not node.favoriteResult
		and favorites:GetCandidateForNode(node) or nil
	if not node.favoriteResult and not candidate then
		return false
	end
	self:CancelQuickSearchHover()
	self:CloseChildPanelsForRow(row)
	local L = GF.L or {}
	if node.favoriteResult then
		local identity = node.favoriteIdentity
		MenuUtil.CreateContextMenu(owner, function(_, rootDescription)
			rootDescription:SetTag("MENU_GROUPFINDER_FAVORITE_INSTANCE")
			rootDescription:CreateButton(
				L.FAVORITE_INSTANCE_REMOVE or "Remove",
				function()
					if GF.FavoriteInstances then
						GF.FavoriteInstances:Remove(identity)
					end
				end)
			local reorder = rootDescription:CreateButton(
				L.FAVORITE_INSTANCE_REORDER or "Adjust Order",
				function()
					NF:EnterFavoriteReorderMode()
				end)
			reorder:SetEnabled(favorites:GetCount() > 1)
		end)
		return true
	end

	local alreadyFavorite = favorites:IsFavorite(candidate.identity)
	MenuUtil.CreateContextMenu(owner, function(_, rootDescription)
		rootDescription:SetTag("MENU_GROUPFINDER_ADD_FAVORITE_INSTANCE")
		local label = L.FAVORITE_INSTANCE_ADD or "Add to Favorite Activities"
		if alreadyFavorite then
			label = L.FAVORITE_INSTANCE_ADDED or "Already in Favorite Activities"
		end
		local add = rootDescription:CreateButton(label, function()
			if GF.FavoriteInstances then
				GF.FavoriteInstances:AddCandidate(candidate)
			end
		end)
		add:SetEnabled(not alreadyFavorite)
	end)
	return true
end

local function wireRow(row, flyout)
	local hitTarget = row.hit
	hitTarget:RegisterForClicks("LeftButtonUp", "RightButtonUp")
	hitTarget:SetScript("OnEnter", function()
		flyout:HandleRowPointerEnter(row)
	end)
	hitTarget:SetScript("OnLeave", function()
		if row.nodeData and row.nodeData.quickSearchResult then
			flyout:CancelQuickSearchHover()
		end
		setFlyoutHoverShown(row, false)
		if GameTooltip_Hide then
			GameTooltip_Hide()
		end
	end)
	hitTarget:SetScript("OnClick", function(_, button)
		if button == "RightButton" then
			local node = row.nodeData
			if nodeInteractionBlocked(node, flyout.navTree)
				and not (node and node.favoriteResult)
			then
				return
			end
			flyout:OpenRowContextMenu(row)
			return
		end
		if flyout.favoriteReorderMode then
			return
		end
		flyout:OnRowClick(row)
	end)
end

local function createFlyoutRow(parent, index, flyout)
	local createRow = GF.NavTree.CreateNavRowFrame
	local rowName = "GroupFinderAddonNavFlyoutRow" .. index
	local row = createRow(parent, rowName)
	wireRow(row, flyout)
	row:Hide()
	return row
end

local rowNameSeq = 0

local function invalidateFlyoutRow(row)
	local arrow, hover, selectedTexture = row.arrow, row.hover, row.sel
	if arrow then
		arrow:Hide()
	end
	if hover then
		hover:Hide()
	end
	row.nodeData, row._flyoutSelected, row._flyoutDisabled = nil, nil, nil
	row._gfHlW, row._gfHlH, row._gfHlX = nil, nil, nil
	setFlyoutHoverShown(row, false)
	setFlyoutSelectedShown(row, false)
	if selectedTexture then
		selectedTexture:Hide()
	end
	if row.favoriteIcon then
		row.favoriteIcon:Hide()
	end
	if row.favoriteMoveUp then
		row.favoriteMoveUp:Hide()
		row.favoriteMoveDown:Hide()
	end
	row:Hide()
end

function NF:ReleaseRow(row)
	if row == nil then
		return
	end
	invalidateFlyoutRow(row)
	row._gfFlyoutPanel = nil
	if self:RowIsAnchor(row) then
		return
	end
	row:ClearAllPoints()
	local poolHost = self.rowPoolHost
	if poolHost then
		row:SetParent(poolHost)
	end
	table.insert(self.freeRows, row)
end

function NF:ReleasePanelRows(panel, fromIdx)
	local rows = panel and panel.rows
	if rows == nil then
		return
	end
	for index = #rows, fromIdx or 1, -1 do
		local row = rows[index]
		rows[index] = nil
		self:ReleaseRow(row)
	end
end

function NF:AcquireRow(parent, panel)
	local pool, row = self.freeRows
	local index = #pool
	while index > 0 and row == nil do
		local candidate = pool[index]
		if not self:RowIsAnchor(candidate) then
			row = candidate
			table.remove(pool, index)
		end
		index = index - 1
	end
	if row == nil then
		rowNameSeq = rowNameSeq + 1
		row = createFlyoutRow(parent, rowNameSeq, self)
	else
		row:SetParent(parent)
		row:ClearAllPoints()
	end
	row["_gfFlyoutPanel"] = panel
	return row
end

local function createPanel(parent, index)
	local name = "GroupFinderAddonNavFlyout" .. index
	local panel = CreateFrame("Frame", name, parent)
	panel:SetFrameStrata("DIALOG")
	panel:EnableMouse(true)
	panel:Hide()
	applyFlyoutPanelChrome(panel)

	local insetL, insetR, insetT, insetB = flyoutContentInsets()
	local scroll = GF.UI.CreateScrollFrame(panel, { rowHeight = GF.NAV_WHEEL_ROW_H or 28 })
	scroll:SetFrameLevel(panel:GetFrameLevel() + 2)
	scroll:SetPoint("TOPLEFT", panel, "TOPLEFT", insetL, -insetT)
	scroll:SetPoint("BOTTOMRIGHT", panel, "BOTTOMRIGHT", -insetR, insetB)
	local content = CreateFrame("Frame", nil, scroll)
	scroll:SetScrollChild(content)
	panel.scroll, panel.content = scroll, content
	panel.rows = {}
	return panel
end

function NF:RefreshPanelChrome()
	for _, panel in ipairs(self.panels or {}) do
		panel._layoutSig = nil
		applyFlyoutPanelChrome(panel)
	end
end

function NF:Init(anchorParent, navTree)
	if self.panels then
		self.navTree = navTree
		return
	end

	local poolHost = CreateFrame("Frame", nil, anchorParent)
	poolHost:Hide()
	self.navTree, self.rowPoolHost = navTree, poolHost
	self.freeRows, self.openKeys, self.panels = {}, {}, {}

	local initialWidth = flyoutMinPanelWidth()
	local baseLevel = (anchorParent:GetFrameLevel() or 1) + 30
	for index = 1, PANEL_COUNT do
		local panel = createPanel(anchorParent, index)
		panel:SetWidth(initialWidth)
		panel.panelW = initialWidth
		panel:SetFrameLevel(baseLevel + index)
		self.panels[index] = panel
	end
end

function NF:SyncPanelWidth()
	for _, panel in ipairs(self.panels or {}) do
		panel._layoutSig = nil
	end
end

function NF:ReanchorOpenPanelPositions()
	if not self.panels then
		return
	end
	for index, panel in ipairs(self.panels) do
		if panel and panel:IsShown() and panel._anchorRow then
			panel._positioned = nil
			self:PositionPanel(panel, panel._anchorRow, index)
		end
	end
end

function NF:ReanchorOpenPanels()
	if not self.panels then
		return
	end
	for index, panel in ipairs(self.panels) do
		if panel and panel:IsShown() and panel._anchorRow then
			local node = panel._openKey and GF.NavData.FindNodeByKey and GF.NavData.FindNodeByKey(panel._openKey)
			if node and nodeInteractionBlocked(node, self.navTree) then
				self:HideFromLevel(index)
				return
			elseif node then
				self:PrepareNode(node, self.navTree)
				local children = filterChildrenForCurrentTab(node.children)
				if #children == 0 then
					self:HideFromLevel(index)
					return
				end
				self:LayoutPanelRows(panel, children, self.navTree)
			end
			panel._positioned = nil
			self:PositionPanel(panel, panel._anchorRow, index)
			panel:Show()
		end
	end
end
