local addonName, ns = ...;
local SinsToolkit = ns;

--- @type table<string, { checkFit: boolean, checkFitExtraWidth: number, checkFitExtraHeight: number }>
ns.hookedFrames = {};
--- @type table<string, boolean> # true if hooked, false if pending
ns.ignoreControlLostFrames = {};
ns.ignore = {
    BarberShopFrame = true, -- barbershop frame, better to allow it to hide the UI
    CinematicFrame = true, -- cinematic frame, better to allow it to hide the UI
    CommunitiesFrame = true,
    DelvesCompanionConfigurationFrame = true,
    DelvesCompanionFrame = true,
    DelvesDashboardFrame = true,
    DelvesDifficultyPickerFrame = true,
    DelvesScenarioOverviewFrame = true,
    HouseEditorFrame = true, -- player housing editor frame, better to allow it to hide the UI
    MacroFrame = true,
    PerksProgramFrame = true, -- trading post frame, better to allow it to hide the UI
    TokenFrame = true, -- breaks baganator's currency transfer, ignoring it doesn't impact things otherwise
    WarboardQuestChoiceFrame = true,
};
local uiSpecialFrameBlacklist = {
    PlayerSpellsFrame = true, -- cannot be safely closed with UISpecialFrames
};
SinsToolkit.NoAutoClose = ns;

local UpdateScaleForFit = UpdateScaleForFit or UIPanelUpdateScaleForFit or FrameUtil.UpdateScaleForFit;

local CHECK_FIT_DEFAULT_EXTRA_WIDTH = 20;
local CHECK_FIT_DEFAULT_EXTRA_HEIGHT = 20;

local FRAME_POSITION_KEYS = {
    left = 'left',
    center = 'center',
    right = 'right',
    doublewide = 'doublewide',
    fullscreen = 'fullscreen',
};

local function table_invert(t)
    local s = {};
    for k, v in pairs(t) do
        s[v] = k;
    end

    return s;
end

local function setTrue(table, key)
    TextureLoadingGroupMixin.AddTexture({ textures = table }, key);
end

local function setNil(table, key)
    TextureLoadingGroupMixin.RemoveTexture({ textures = table }, key);
end

EventUtil.ContinueOnAddOnLoaded(addonName, function()
    function SinsToolkit.ApplyNoAutoClosePanels()
        if ns:IsEnabled() then
            ns:Init();
        elseif ns.initialized then
            print("|cff9b59b6[Sin's Toolkit: QOL]|r Keep Panels Open changes require /reload after disabling.");
        end
    end
    SinsToolkit.ApplyNoAutoClosePanels();
end);

ns.lastPrint = 0;
ns.escHandlerMap = {};
ns.handlerFrameIndex = 0;
ns.sharedAttributesFrame = CreateFrame('Frame', nil, nil, 'SecureHandlerBaseTemplate');

function ns:ShouldNotManuallyShowHide(frame, interfaceActionWasBlocked)
    local name = frame.GetName and frame:GetName();
    if uiSpecialFrameBlacklist[name] then
        return true;
    end

    return not interfaceActionWasBlocked;
end

function ns:OnDisplayInterfaceActionBlockedMessage()
    if InCombatLockdown() and (debugstack(3):find('in function .ShowUIPanel.') or debugstack(3):find('in function .HideUIPanel.')) then
        self.interfaceActionWasBlocked = true;
    end
end

function ns:SetDefaultPosition(frame)
    if
        (frame.IsForbidden and frame:IsForbidden())
        or (frame.IsProtected and frame:IsProtected() and InCombatLockdown())
    then
        return;
    end
    frame:ClearAllPoints();
    (frame.SetPointBase or frame.SetPoint)(
        frame,
        self.db.defaultPosition.anchor,
        UIParent,
        self.db.defaultPosition.anchor,
        self.db.defaultPosition.x,
        self.db.defaultPosition.y
    );
end

function ns:OnShowUIPanel(frame)
    local interfaceActionWasBlocked = self.interfaceActionWasBlocked;
    self.interfaceActionWasBlocked = false;
    if not frame or (frame.IsForbidden and frame:IsForbidden()) then return; end
    local name = frame.GetName and frame:GetName();
    local isHooked = self.hookedFrames[name];
    if not isHooked and frame.IsProtected and frame:IsProtected() and InCombatLockdown() then return; end

    if isHooked and ((frame.IsProtected and frame:IsProtected()) or uiSpecialFrameBlacklist[name]) then
        -- ensure that we have a secure esc handler configured for this frame
        if InCombatLockdown() then
            self:AddToCombatLockdownQueue(self.ConfigureSecureEscHandler, ns, frame);
            return; -- don't do anything else while we're in combat
        end
        self:ConfigureSecureEscHandler(frame, uiSpecialFrameBlacklist[name]);
    end

    if (frame.IsShown and not frame:IsShown()) then
        if self:ShouldNotManuallyShowHide(frame, interfaceActionWasBlocked) then return; end
        -- if possible, force show the frame, ignoring the INTERFACE_ACTION_BLOCKED message
        frame:Show();
    end
    if isHooked then
        if (frame.GetPoint and not frame:GetPoint()) then
            -- disabling the UIPanelLayout system removes the default location, so let's set one
            self:SetDefaultPosition(frame);
        end
        if (frame.IsToplevel and frame:IsToplevel() and frame.IsShown and frame:IsShown()) then
            -- if the frame is a toplevel frame, raise it to the top of the stack
            frame:Raise();
        end
        if isHooked.checkFit then
            UpdateScaleForFit(frame, isHooked.checkFitExtraWidth, isHooked.checkFitExtraHeight);
        end
    end
end

function ns:OnHideUIPanel(frame)
    local interfaceActionWasBlocked = self.interfaceActionWasBlocked;
    self.interfaceActionWasBlocked = false;
    if (not frame or (InCombatLockdown() and frame:IsProtected())) then
        return; -- can't touch this frame in combat :(
    end
    if (frame.IsShown and frame:IsShown()) then
        if self:ShouldNotManuallyShowHide(frame, interfaceActionWasBlocked) then return; end
        -- if possible, force hide the frame, ignoring the INTERFACE_ACTION_BLOCKED message
        frame:Hide();
    end
end

function ns:DetectStaleUIPanels()
    local inCombat = InCombatLockdown();
    local shownFrames = {};
    for position, _ in pairs(FRAME_POSITION_KEYS) do
        -- Blizzard could fix this in 5 minutes, but well...
        local frame = GetUIPanel(position);
        if frame and not frame:IsShown() then
            tinsert(shownFrames, frame);
        end
    end
    if not next(shownFrames) then return; end

    if inCombat then
        if (GetTime() - self.lastPrint) > 5 then
            self.lastPrint = GetTime();
            print("|cff9b59b6[Sin's Toolkit: QOL]|r Keep Panels Open detected an ESC handling issue. It will be fixed after combat.");
        end

        return;
    end
    for _, frame in pairs(shownFrames) do
        frame:Show();
        HideUIPanel(frame);
    end
end

function ns:ReworkSettingsOpenAndClose()
    if not SettingsPanel then return; end

    -- this prevents closing the settings panel from cancelling spell casting (and therefore giving a taint error when any addon is registered)
    if SettingsPanel.TransitionBackOpeningPanel then
        function SettingsPanel:TransitionBackOpeningPanel()
            HideUIPanel(SettingsPanel);
        end
    end
    -- this closes the game menu when opening the settings ui, which makes it less buggy when pressing escape to close the settings UI
    local settingsButton = GameMenuButtonSettings or GameMenuButtonOptions;
    if settingsButton then
        settingsButton:HookScript('OnClick', function()
            if GameMenuFrame and GameMenuFrame:IsShown() then
                HideUIPanel(GameMenuFrame);
            end
        end);
    end
end

function ns:HandleUIPanel(name, info, flippedUiSpecialFrames)
    local frame = _G[name];
    if not frame then return; end
    if self.db and self.db.respectExcludedFrames ~= false and self.ignore[name] then return; end
    if ((frame.IsProtected and frame:IsProtected()) or uiSpecialFrameBlacklist[name]) then
        if InCombatLockdown() then
            self:AddToCombatLockdownQueue(ns.HandleUIPanel, ns, name, info, flippedUiSpecialFrames);
            setTrue(frame, 'editModeManuallyShown');
            return;
        end
        setNil(frame, 'editModeManuallyShown');

        self:ConfigureSecureEscHandler(frame, uiSpecialFrameBlacklist[name]);
    end
    if (not flippedUiSpecialFrames[name] and not uiSpecialFrameBlacklist[name]) then
        flippedUiSpecialFrames[name] = true;
        tinsert(UISpecialFrames, name);
    end
    local panelInfo = UIPanelWindows[name];
    self.hookedFrames[name] = {
        checkFit = panelInfo and panelInfo.checkFit,
        checkFitExtraWidth = panelInfo and panelInfo.checkFitExtraWidth or CHECK_FIT_DEFAULT_EXTRA_WIDTH,
        checkFitExtraHeight = panelInfo and panelInfo.checkFitExtraHeight or CHECK_FIT_DEFAULT_EXTRA_HEIGHT,
    };
    if panelInfo and panelInfo.ignoreControlLost then
        self.ignoreControlLostFrames[name] = false;
    end
    setNil(UIPanelWindows, name);
    if frame.SetAttribute then
        frame:SetAttribute('UIPanelLayout-defined', nil);
        frame:SetAttribute('UIPanelLayout-enabled', nil);
        frame:SetAttribute('UIPanelLayout-area', nil);
        frame:SetAttribute('UIPanelLayout-pushable', nil);
        frame:SetAttribute('UIPanelLayout-whileDead', nil);
    end
    if (frame.GetPoint and not frame:GetPoint()) then
        -- disabling the UIPanelLayout system removes the default location, so let's set one
        self:SetDefaultPosition(frame);
    end
end

--- @param func function
--- @param ... any # arguments
function ns:AddToCombatLockdownQueue(func, ...)
    tinsert(self.combatLockdownQueue, { func = func, args = { ... } });
end

function ns:PLAYER_REGEN_ENABLED()
    self:DetectStaleUIPanels();

    if #self.combatLockdownQueue == 0 then return; end

    for _, item in pairs(self.combatLockdownQueue) do
        item.func(unpack(item.args));
    end
    wipe(self.combatLockdownQueue);
end

function ns:PLAYER_REGEN_DISABLED()
    self:DetectStaleUIPanels();

    -- if any frame has become protected since it was last shown, we need to configure the secure esc handler
    for frameName, _ in pairs(self.hookedFrames) do
        local frame = _G[frameName];
        if frame and frame.IsProtected and frame:IsProtected() then
            if not self.escHandlerMap[frame] then
                self:ConfigureSecureEscHandler(frame, false, true);
            end
        end
    end
end

local function escHandlerOnEvent(handlerFrame, event)
    if event == 'PLAYER_REGEN_ENABLED' and not handlerFrame:GetAttribute('alwaysSetBindingOnShow') then
        ClearOverrideBindings(handlerFrame);
    elseif event == 'PLAYER_REGEN_DISABLED' and handlerFrame.panel:IsVisible() then
        SetOverrideBindingClick(handlerFrame, true, 'ESCAPE', handlerFrame.name);
    end
end

--- @param frame Frame
--- @param alwaysSetBindingOnShow boolean # if true, the ESC binding will always be set when the frame is shown, regardless of combat state
--- @param callRegenDisabledCode boolean? # if true, the PLAYER_REGEN_DISABLED event will be called immediately
function ns:ConfigureSecureEscHandler(frame, alwaysSetBindingOnShow, callRegenDisabledCode)
    --[[
        Since the UIPanel system no longer taintlessly hides protected panels, we need to create a secure handler, which will
        configure a temporary keybinding for ESC, to hide the panel. Once we leave combat, we unbind it again, to go back to normal.
        The downside of this approach, is that protected frames will close 1 by 1, and that hiding the frames takes priority over e.g. canceling spell casting.
    --]]
    if self.escHandlerMap[frame] then return; end

    self.handlerFrameIndex = self.handlerFrameIndex + 1;
    local name = 'SinsToolkit_NoAutoClose_SecureEscapeHandlerFrame' .. self.handlerFrameIndex;
    --- @class NAP_EscHandler: Button, SecureHandlerShowHideTemplate, SecureHandlerClickTemplate
    local escHandler = CreateFrame('Button', name, frame, 'SecureHandlerShowHideTemplate,SecureHandlerClickTemplate');
    self.escHandlerMap[frame] = escHandler;

    escHandler.name = name;
    escHandler.panel = frame;
    escHandler:SetFrameRef('panel', frame);
    escHandler:SetFrameRef('UIParent', UIParent);
    escHandler:SetFrameRef('sharedAttributesFrame', self.sharedAttributesFrame);
    escHandler:RegisterEvent('PLAYER_REGEN_ENABLED');
    escHandler:RegisterEvent('PLAYER_REGEN_DISABLED');
    escHandler:HookScript('OnEvent', escHandlerOnEvent);
    escHandler:SetAttribute('alwaysSetBindingOnShow', alwaysSetBindingOnShow);
    escHandler:SetAttribute('_onclick', [[
        self:ClearBindings(); -- clear the bindings, just in case something is preventing the _onhide from firing
        local panel = self:GetFrameRef('panel');
        if panel:IsShown() then
            panel:Hide();
        end
    ]]);
    escHandler:SetAttribute('_onhide', [[
        self:ClearBindings();
    ]]);
    escHandler:SetAttribute('_onshow', [[
        local alwaysSetBindingOnShow = self:GetAttribute('alwaysSetBindingOnShow');
        if alwaysSetBindingOnShow or PlayerInCombat() then
            self:SetBindingClick(true, 'ESCAPE', self);
            local panel = self:GetFrameRef('panel');
            panel:Raise();
            if (not panel:GetPoint()) then
                -- disabling the UIPanelLayout system removes the default location, so let's set one
                local UIParent = self:GetFrameRef('UIParent');
                local sharedAttributesFrame = self:GetFrameRef('sharedAttributesFrame');
                local anchor, ofsx, ofsy = sharedAttributesFrame:GetAttribute('anchor'), sharedAttributesFrame:GetAttribute('x'), sharedAttributesFrame:GetAttribute('y');
                panel:SetPoint(anchor, UIParent, anchor, ofsx, ofsy);
            end
        end
    ]]);
    if frame:IsShown() and alwaysSetBindingOnShow then
        escHandler:Execute(escHandler:GetAttribute('_onshow'));
    end
    if callRegenDisabledCode then
        escHandlerOnEvent(escHandler, 'PLAYER_REGEN_DISABLED');
    end
end

function ns:ADDON_LOADED(loadedAddon)
    local flippedUiSpecialFrames = table_invert(UISpecialFrames);

    for name, info in pairs(UIPanelWindows) do
        self:HandleUIPanel(name, info, flippedUiSpecialFrames);
    end
    for name, isHooked in pairs(self.ignoreControlLostFrames) do
        if not isHooked and _G[name] and type(_G[name]) == 'table' then
            self.ignoreControlLostFrames[name] = true;
            _G[name]:HookScript('OnHide', function(f)
                if debugstack(3):find("in function .CloseAllWindows_WithExceptions.") then
                    ShowUIPanel(f);
                end
            end);
        end
    end
    if InCombatLockdown() and WorldMapFrame:IsProtected() then
        self:AddToCombatLockdownQueue(function()
            WorldMapFrame:SetAttribute('UIPanelLayout-defined', '1');
            WorldMapFrame:SetAttribute('UIPanelLayout-maximizePoint', 'TOP');
        end);
    else
        WorldMapFrame:SetAttribute('UIPanelLayout-defined', '1');
        WorldMapFrame:SetAttribute('UIPanelLayout-maximizePoint', 'TOP');
    end
    if loadedAddon == 'Sorted' then
        -- that addon does some silly stuff, easier to just hardcode a workaround than to find a convoluted fix
        tDeleteItem(UISpecialFrames, 'BankFrame');
    end
end

ns.playerInteractionHideMap = {
    [Enum.PlayerInteractionType.Gossip] = 'GossipFrame',
    [Enum.PlayerInteractionType.QuestGiver] = 'QuestFrame',
};

function ns:PLAYER_INTERACTION_MANAGER_FRAME_SHOW(type)
    for mapType, frameName in pairs(self.playerInteractionHideMap) do
        local frame = _G[frameName];
        if type ~= mapType and frame.IsShown and frame:IsShown() then
            HideUIPanel(_G[frameName]);
        end
    end
end

function ns:PLAYER_INTERACTION_MANAGER_FRAME_HIDE(type)
    if self.playerInteractionHideMap[type] then
        local frame = _G[self.playerInteractionHideMap[type]];
        if frame and frame.IsShown and frame:IsShown() then
            HideUIPanel(frame);
        end
    end
end

function ns:IsEnabled()
    return SinsToolkit and SinsToolkit.IsFeatureActive and SinsToolkit.IsFeatureActive("keepPanelsOpen");
end

function ns:Init()
    if self.initialized then return; end
    if not self:IsEnabled() then return; end
    self.initialized = true;
    hooksecurefunc('ShowUIPanel', function(frame) self:OnShowUIPanel(frame); end);
    hooksecurefunc('HideUIPanel', function(frame) self:OnHideUIPanel(frame); end);
    hooksecurefunc('RegisterUIPanel', function() self:ADDON_LOADED(); end);
    hooksecurefunc('DisplayInterfaceActionBlockedMessage', function() self:OnDisplayInterfaceActionBlockedMessage(); end);
    hooksecurefunc('RestoreUIPanelArea', function(frame) self:SetDefaultPosition(frame); end);
    hooksecurefunc('CloseWindows', function() self:DetectStaleUIPanels(); end);
    hooksecurefunc('RegisterUIPanel', function(name, info)
        self:HandleUIPanel(name, info, table_invert(UISpecialFrames));
    end);
    self:ReworkSettingsOpenAndClose();

    self.eventFrame = CreateFrame('Frame');
    self.eventFrame:HookScript('OnEvent', function(_, event, ...) self[event](self, ...); end);
    self.eventFrame:RegisterEvent('ADDON_LOADED');
    self.eventFrame:RegisterEvent('PLAYER_INTERACTION_MANAGER_FRAME_SHOW');
    self.eventFrame:RegisterEvent('PLAYER_INTERACTION_MANAGER_FRAME_HIDE');
    self.eventFrame:RegisterEvent('PLAYER_REGEN_ENABLED');
    self.eventFrame:RegisterEvent('PLAYER_REGEN_DISABLED');

    self.combatLockdownQueue = {};

    self:initOptions()
end

function ns:initOptions()
    if SinsToolkit.EnsureDB then SinsToolkit.EnsureDB(); end
    self.db = SinsToolkitDB.noAutoClose;
    self.sharedAttributesFrame:SetAttribute('anchor', self.db.defaultPosition.anchor);
    self.sharedAttributesFrame:SetAttribute('x', self.db.defaultPosition.x);
    self.sharedAttributesFrame:SetAttribute('y', self.db.defaultPosition.y);
end

function ns:GetMoverFrame(onMoveCallback)
    local NineSliceLayout =
    {
        ["TopRightCorner"] = { atlas = "%s-NineSlice-Corner", mirrorLayout = true, x = 8, y = 8 },
        ["TopLeftCorner"] = { atlas = "%s-NineSlice-Corner", mirrorLayout = true, x = -8, y = 8 },
        ["BottomLeftCorner"] = { atlas = "%s-NineSlice-Corner", mirrorLayout = true, x = -8, y = -8 },
        ["BottomRightCorner"] = { atlas = "%s-NineSlice-Corner", mirrorLayout = true, x = 8, y = -8 },
        ["TopEdge"] = { atlas = "_%s-NineSlice-EdgeTop" },
        ["BottomEdge"] = { atlas = "_%s-NineSlice-EdgeBottom" },
        ["LeftEdge"] = { atlas = "!%s-NineSlice-EdgeLeft" },
        ["RightEdge"] = { atlas = "!%s-NineSlice-EdgeRight" },
        ["Center"] = { atlas = "%s-NineSlice-Center", x = -8, y = 8, x1 = 8, y1 = -8, },
    };

    local frame = CreateFrame('Frame', 'SinsToolkitNoAutoCloseMoverFrame', UIParent);
    frame:SetSize(150, 100);
    frame:SetPoint(self.db.defaultPosition.anchor, self.db.defaultPosition.x, self.db.defaultPosition.y);
    frame:SetFrameStrata('DIALOG');
    frame:SetFrameLevel(9990);
    frame.layoutType = 'UniqueCornersLayout';
    frame.layoutTextureKit = 'OptionsFrame';
    NineSliceUtil.ApplyLayout(frame, NineSliceLayout, 'editmode-actionbar-highlight');

    local closeButton = CreateFrame('Button', nil, frame, 'UIPanelCloseButton');
    closeButton:SetFrameStrata('DIALOG')
    closeButton:SetFrameLevel(9999);
    closeButton:SetPoint('TOPRIGHT', frame, 'TOPRIGHT', 0, 0);
    closeButton:SetScript('OnClick', function()
        frame:Hide();
    end);

    local label = frame:CreateFontString(nil, 'OVERLAY', 'GameFontHighlightSmall');
    label:SetAllPoints();
    label:SetIgnoreParentScale(true);
    label:SetJustifyH('CENTER');
    label:SetJustifyV('MIDDLE');
    label:SetText("Sin's Toolkit: QOL");

    frame.onMoveCallback = onMoveCallback;
    frame:SetMovable(true);
    frame:SetScript('OnMouseDown', function()
        frame:StartMoving();
    end);
    frame:SetScript('OnMouseUp', function()
        frame:StopMovingOrSizing();
        frame:SetUserPlaced(false);
        local anchor, _, _, x, y = frame:GetPoint();
        frame.onMoveCallback(anchor, x, y);
    end);
    frame:Hide();

    return frame;
end
