--[[
GameUI.lua
Reactive Game UI State Observer

Copyright (c) 2021 psiberx

How to use:
```
local GameUI = require('GameUI')

registerForEvent('onInit', function()
	GameUI.Listen(function(state)
		GameUI.PrintState(state)
	end)
end)
```
]]

local GameUI = {
    version = '1.2.3',
    framework = '1.29.0'
}

GameUI.Event = {
    Braindance = 'Braindance',
    BraindancePlay = 'BraindancePlay',
    BraindanceEdit = 'BraindanceEdit',
    BraindanceExit = 'BraindanceExit',
    Camera = 'Camera',
    Context = 'Context',
    Cyberspace = 'Cyberspace',
    CyberspaceEnter = 'CyberspaceEnter',
    CyberspaceExit = 'CyberspaceExit',
    Device = 'Device',
    DeviceEnter = 'DeviceEnter',
    DeviceExit = 'DeviceExit',
    FastTravel = 'FastTravel',
    FastTravelFinish = 'FastTravelFinish',
    FastTravelStart = 'FastTravelStart',
    Flashback = 'Flashback',
    FlashbackEnd = 'FlashbackEnd',
    FlashbackStart = 'FlashbackStart',
    Johnny = 'Johnny',
    Loading = 'Loading',
    LoadingFinish = 'LoadingFinish',
    LoadingStart = 'LoadingStart',
    Menu = 'Menu',
    MenuClose = 'MenuClose',
    MenuNav = 'MenuNav',
    MenuOpen = 'MenuOpen',
    PhotoMode = 'PhotoMode',
    PhotoModeClose = 'PhotoModeClose',
    PhotoModeOpen = 'PhotoModeOpen',
    Popup = 'Popup',
    PopupClose = 'PopupClose',
    PopupOpen = 'PopupOpen',
    Possession = 'Possession',
    PossessionEnd = 'PossessionEnd',
    PossessionStart = 'PossessionStart',
    QuickHack = 'QuickHack',
    QuickHackClose = 'QuickHackClose',
    QuickHackOpen = 'QuickHackOpen',
    Scanner = 'Scanner',
    ScannerClose = 'ScannerClose',
    ScannerOpen = 'ScannerOpen',
    Scene = 'Scene',
    SceneEnter = 'SceneEnter',
    SceneExit = 'SceneExit',
    Session = 'Session',
    SessionEnd = 'SessionEnd',
    SessionStart = 'SessionStart',
    Shard = 'Shard',
    ShardClose = 'ShardClose',
    ShardOpen = 'ShardOpen',
    Tutorial = 'Tutorial',
    TutorialClose = 'TutorialClose',
    TutorialOpen = 'TutorialOpen',
    Update = 'Update',
    Vehicle = 'Vehicle',
    VehicleEnter = 'VehicleEnter',
    VehicleExit = 'VehicleExit',
    Wheel = 'Wheel',
    WheelClose = 'WheelClose',
    WheelOpen = 'WheelOpen',
}

GameUI.StateEvent = {
    [GameUI.Event.Braindance] = GameUI.Event.Braindance,
    [GameUI.Event.Context] = GameUI.Event.Context,
    [GameUI.Event.Cyberspace] = GameUI.Event.Cyberspace,
    [GameUI.Event.Device] = GameUI.Event.Device,
    [GameUI.Event.FastTravel] = GameUI.Event.FastTravel,
    [GameUI.Event.Flashback] = GameUI.Event.Flashback,
    [GameUI.Event.Johnny] = GameUI.Event.Johnny,
    [GameUI.Event.Loading] = GameUI.Event.Loading,
    [GameUI.Event.Menu] = GameUI.Event.Menu,
    [GameUI.Event.PhotoMode] = GameUI.Event.PhotoMode,
    [GameUI.Event.Popup] = GameUI.Event.Popup,
    [GameUI.Event.Possession] = GameUI.Event.Possession,
    [GameUI.Event.QuickHack] = GameUI.Event.QuickHack,
    [GameUI.Event.Scanner] = GameUI.Event.Scanner,
    [GameUI.Event.Scene] = GameUI.Event.Scene,
    [GameUI.Event.Session] = GameUI.Event.Session,
    [GameUI.Event.Shard] = GameUI.Event.Shard,
    [GameUI.Event.Tutorial] = GameUI.Event.Tutorial,
    [GameUI.Event.Update] = GameUI.Event.Update,
    [GameUI.Event.Vehicle] = GameUI.Event.Vehicle,
    [GameUI.Event.Wheel] = GameUI.Event.Wheel,
}

GameUI.Camera = {
    FirstPerson = 'FirstPerson',
    ThirdPerson = 'ThirdPerson',
}

local initialized = {}
local listeners = {}
local updateQueue = {}
local previousState = {
    isDetached = true,
    isMenu = false,
    menu = false,
}

local isDetached = true
local isLoaded = false
local isLoading = false
local isMenu = true
local isVehicle = false
local isBraindance = false
local isFastTravel = false
local isPhotoMode = false
local isShard = false
local isTutorial = false
local sceneTier = 4
local isPossessed = false
local isFlashback = false
local isCyberspace = false
local currentMenu = false
local currentSubmenu = false
local currentCamera = GameUI.Camera.FirstPerson
local contextStack = {}

local stateProps = {
    { current = 'isLoaded', previous = nil, event = { change = GameUI.Event.Session, on = GameUI.Event.SessionStart } },
    { current = 'isDetached', previous = nil, event = { change = GameUI.Event.Session, on = GameUI.Event.SessionEnd } },
    { current = 'isLoading', previous = 'wasLoading', event = { change = GameUI.Event.Loading, on = GameUI.Event.LoadingStart, off = GameUI.Event.LoadingFinish } },
    { current = 'isMenu', previous = 'wasMenu', event = { change = GameUI.Event.Menu, on = GameUI.Event.MenuOpen, off = GameUI.Event.MenuClose } },
    { current = 'isScene', previous = 'wasScene', event = { change = GameUI.Event.Scene, on = GameUI.Event.SceneEnter, off = GameUI.Event.SceneExit, reqs = { isMenu = false } } },
    { current = 'isVehicle', previous = 'wasVehicle', event = { change = GameUI.Event.Vehicle, on = GameUI.Event.VehicleEnter, off = GameUI.Event.VehicleExit } },
    { current = 'isBraindance', previous = 'wasBraindance', event = { change = GameUI.Event.Braindance, on = GameUI.Event.BraindancePlay, off = GameUI.Event.BraindanceExit } },
    { current = 'isEditor', previous = 'wasEditor', event = { change = GameUI.Event.Braindance, on = GameUI.Event.BraindanceEdit, off = GameUI.Event.BraindancePlay } },
    { current = 'isFastTravel', previous = 'wasFastTravel', event = { change = GameUI.Event.FastTravel, on = GameUI.Event.FastTravelStart, off = GameUI.Event.FastTravelFinish } },
    { current = 'isJohnny', previous = 'wasJohnny', event = { change = GameUI.Event.Johnny } },
    { current = 'isPossessed', previous = 'wasPossessed', event = { change = GameUI.Event.Possession, on = GameUI.Event.PossessionStart, off = GameUI.Event.PossessionEnd, scope = GameUI.Event.Johnny } },
    { current = 'isFlashback', previous = 'wasFlashback', event = { change = GameUI.Event.Flashback, on = GameUI.Event.FlashbackStart, off = GameUI.Event.FlashbackEnd, scope = GameUI.Event.Johnny } },
    { current = 'isCyberspace', previous = 'wasCyberspace', event = { change = GameUI.Event.Cyberspace, on = GameUI.Event.CyberspaceEnter, off = GameUI.Event.CyberspaceExit } },
    { current = 'isDefault', previous = 'wasDefault' },
    { current = 'isScanner', previous = 'wasScanner', event = { change = GameUI.Event.Scanner, on = GameUI.Event.ScannerOpen, off = GameUI.Event.ScannerClose, scope = GameUI.Event.Context } },
    { current = 'isQuickHack', previous = 'wasQuickHack', event = { change = GameUI.Event.QuickHack, on = GameUI.Event.QuickHackOpen, off = GameUI.Event.QuickHackClose, scope = GameUI.Event.Context } },
    { current = 'isPopup', previous = 'wasPopup', event = { change = GameUI.Event.Popup, on = GameUI.Event.PopupOpen, off = GameUI.Event.PopupClose, scope = GameUI.Event.Context } },
    { current = 'isWheel', previous = 'wasWheel', event = { change = GameUI.Event.Wheel, on = GameUI.Event.WheelOpen, off = GameUI.Event.WheelClose, scope = GameUI.Event.Context } },
    { current = 'isDevice', previous = 'wasDevice', event = { change = GameUI.Event.Device, on = GameUI.Event.DeviceEnter, off = GameUI.Event.DeviceExit, scope = GameUI.Event.Context } },
    { current = 'isPhoto', previous = 'wasPhoto', event = { change = GameUI.Event.PhotoMode, on = GameUI.Event.PhotoModeOpen, off = GameUI.Event.PhotoModeClose } },
    { current = 'isShard', previous = 'wasShard', event = { change = GameUI.Event.Shard, on = GameUI.Event.ShardOpen, off = GameUI.Event.ShardClose } },
    { current = 'isTutorial', previous = 'wasTutorial', event = { change = GameUI.Event.Tutorial, on = GameUI.Event.TutorialOpen, off = GameUI.Event.TutorialClose } },
    { current = 'menu', previous = 'lastMenu', event = { change = GameUI.Event.MenuNav, reqs = { isMenu = true, wasMenu = true }, scope = GameUI.Event.Menu } },
    { current = 'submenu', previous = 'lastSubmenu', event = { change = GameUI.Event.MenuNav, reqs = { isMenu = true, wasMenu = true }, scope = GameUI.Event.Menu } },
    { current = 'camera', previous = 'lastCamera', event = { change = GameUI.Event.Camera, scope = GameUI.Event.Vehicle }, parent = 'isVehicle' },
    { current = 'context', previous = 'lastContext', event = { change = GameUI.Event.Context } },
}

local menuScenarios = {
    ['MenuScenario_BodyTypeSelection'] = { menu = 'NewGame', submenu = 'BodyType' },
    ['MenuScenario_BoothMode'] = { menu = 'BoothMode', submenu = false },
    ['MenuScenario_CharacterCustomization'] = { menu = 'NewGame', submenu = 'Customization' },
    ['MenuScenario_ClippedMenu'] = { menu = 'ClippedMenu', submenu = false },
    ['MenuScenario_Credits'] = { menu = 'MainMenu', submenu = 'Credits' },
    ['MenuScenario_CreditsE1'] = { menu = 'MainMenu', submenu = 'Credits' },
    ['MenuScenario_CreditsPicker'] = { menu = 'MainMenu', submenu = 'Credits' },
    ['MenuScenario_CreditsPickerPause'] = { menu = 'PauseMenu', submenu = 'Credits' },
    ['MenuScenario_DeathMenu'] = { menu = 'DeathMenu', submenu = false },
    ['MenuScenario_Difficulty'] = { menu = 'NewGame', submenu = 'Difficulty' },
    ['MenuScenario_E3EndMenu'] = { menu = 'E3EndMenu', submenu = false },
    ['MenuScenario_FastTravel'] = { menu = 'FastTravel', submenu = 'Map' },
    ['MenuScenario_FinalBoards'] = { menu = 'FinalBoards', submenu = false },
    ['MenuScenario_FindServers'] = { menu = 'FindServers', submenu = false },
    ['MenuScenario_HubMenu'] = { menu = 'Hub', submenu = false },
    ['MenuScenario_Idle'] = { menu = false, submenu = false },
    ['MenuScenario_LifePathSelection'] = { menu = 'NewGame', submenu = 'LifePath' },
    ['MenuScenario_LoadGame'] = { menu = 'MainMenu', submenu = 'LoadGame' },
    ['MenuScenario_MultiplayerMenu'] = { menu = 'Multiplayer', submenu = false },
    ['MenuScenario_NetworkBreach'] = { menu = 'NetworkBreach', submenu = false },
    ['MenuScenario_NewGame'] = { menu = 'NewGame', submenu = false },
    ['MenuScenario_PauseMenu'] = { menu = 'PauseMenu', submenu = false },
    ['MenuScenario_PlayRecordedSession'] = { menu = 'PlayRecordedSession', submenu = false },
    ['MenuScenario_Settings'] = { menu = 'MainMenu', submenu = 'Settings' },
    ['MenuScenario_SingleplayerMenu'] = { menu = 'MainMenu', submenu = false },
    ['MenuScenario_StatsAdjustment'] = { menu = 'NewGame', submenu = 'Attributes' },
    ['MenuScenario_Storage'] = { menu = 'Stash', submenu = false },
    ['MenuScenario_Summary'] = { menu = 'NewGame', submenu = 'Summary' },
    ['MenuScenario_Vendor'] = { menu = 'Vendor', submenu = false },
}

local eventScopes = {
    [GameUI.Event.Update] = {},
    [GameUI.Event.Menu] = { [GameUI.Event.Loading] = true },
}

local function toStudlyCase(s)
    return (s:lower():gsub('_*(%l)(%w*)', function(first, rest)
        return string.upper(first) .. rest
    end))
end

local function updateDetached(detached)
    isDetached = detached
    isLoaded = false
end

local function updateLoaded(loaded)
    isDetached = not loaded
    isLoaded = loaded
end

local function updateLoading(loading)
    isLoading = loading
end

local function updateMenu(menuActive)
    isMenu = menuActive or GameUI.IsMainMenu()
end

local function updateMenuScenario(scenarioName)
    local scenario = menuScenarios[scenarioName] or menuScenarios['MenuScenario_Idle']

    isMenu = scenario.menu ~= false
    currentMenu = scenario.menu
    currentSubmenu = scenario.submenu
end

local function updateMenuItem(itemName)
    currentSubmenu = itemName or false
end

local function updateVehicle(vehicleActive, cameraMode)
    isVehicle = vehicleActive
    currentCamera = cameraMode and GameUI.Camera.ThirdPerson or GameUI.Camera.FirstPerson
end

local function updateBraindance(braindanceActive)
    isBraindance = braindanceActive
end

local function updateFastTravel(fastTravelActive)
    isFastTravel = fastTravelActive
end

local function updatePhotoMode(photoModeActive)
    isPhotoMode = photoModeActive
end

local function updateShard(shardActive)
    isShard = shardActive
end

local function updateTutorial(tutorialActive)
    isTutorial = tutorialActive
end

local function updateSceneTier(sceneTierValue)
    sceneTier = sceneTierValue
end

local function updatePossessed(possessionActive)
    isPossessed = possessionActive
end

local function updateFlashback(flashbacklActive)
    isFlashback = flashbacklActive
end

local function updateCyberspace(isCyberspacePresence)
    isCyberspace = isCyberspacePresence
end

local function updateContext(oldContext, newContext)
    if oldContext == nil and newContext == nil then
        contextStack = {}
    elseif oldContext ~= nil then
        for i = #contextStack, 1, -1 do
            if contextStack[i].value == oldContext.value then
                table.remove(contextStack, i)
                break
            end
        end
    elseif newContext ~= nil then
        table.insert(contextStack, newContext)
    else
        if #contextStack > 0 and contextStack[#contextStack].value == oldContext.value then
            contextStack[#contextStack] = newContext
        end
    end
end

local function refreshCurrentState()
    local player = Game.GetPlayer()
    local blackboardDefs = Game.GetAllBlackboardDefs()
    local blackboardUI = Game.GetBlackboardSystem():Get(blackboardDefs.UI_System)
    local blackboardVH = Game.GetBlackboardSystem():Get(blackboardDefs.UI_ActiveVehicleData)
    local blackboardBD = Game.GetBlackboardSystem():Get(blackboardDefs.Braindance)
    local blackboardPM = Game.GetBlackboardSystem():Get(blackboardDefs.PhotoMode)
    local blackboardPSM = Game.GetBlackboardSystem():GetLocalInstanced(player:GetEntityID(), blackboardDefs.PlayerStateMachine)

    if not isLoaded then
        updateDetached(not player:IsAttached() or GetSingleton('inkMenuScenario'):GetSystemRequestsHandler():IsPreGame())

        if isDetached then
            currentMenu = 'MainMenu'
        end
    end

    updateMenu(blackboardUI:GetBool(blackboardDefs.UI_System.IsInMenu))
    updateTutorial(Game.GetTimeSystem():IsTimeDilationActive('UI_TutorialPopup'))

    updateSceneTier(blackboardPSM:GetInt(blackboardDefs.PlayerStateMachine.SceneTier))
    updateVehicle(
        blackboardVH:GetBool(blackboardDefs.UI_ActiveVehicleData.IsPlayerMounted),
        blackboardVH:GetBool(blackboardDefs.UI_ActiveVehicleData.IsTPPCameraOn)
    )

    updateBraindance(blackboardBD:GetBool(blackboardDefs.Braindance.IsActive))

    updatePossessed(Game.GetQuestsSystem():GetFactStr(Game.GetPlayerSystem():GetPossessedByJohnnyFactName()) == 1)
    updateFlashback(player:IsJohnnyReplacer())

    updatePhotoMode(blackboardPM:GetBool(blackboardDefs.PhotoMode.IsActive))

    if #contextStack == 0 then
        if isBraindance then
            updateContext(nil, GameUI.Context.BraindancePlayback)
        elseif Game.GetTimeSystem():IsTimeDilationActive('radial') then
            updateContext(nil, GameUI.Context.ModalPopup)
        end
    end
end

local function pushCurrentState()
    previousState = GameUI.GetState()
end

local function applyQueuedChanges()
    if #updateQueue > 0 then
        for _, updateCallback in ipairs(updateQueue) do
            updateCallback()
        end

        updateQueue = {}
    end
end

local function determineEvents(currentState)
    local events = { GameUI.Event.Update }
    local firing = {}

    for _, stateProp in ipairs(stateProps) do
        local currentValue = currentState[stateProp.current]
        local previousValue = previousState[stateProp.current]

        if stateProp.event and (not stateProp.parent or currentState[stateProp.parent]) then
            local reqSatisfied = true

            if stateProp.event.reqs then
                for reqProp, reqValue in pairs(stateProp.event.reqs) do
                    if tostring(currentState[reqProp]) ~= tostring(reqValue) then
                        reqSatisfied = false
                        break
                    end
                end
            end

            if reqSatisfied then
                if stateProp.event.change and previousValue ~= nil then
                    if tostring(currentValue) ~= tostring(previousValue) then
                        if not firing[stateProp.event.change] then
                            table.insert(events, stateProp.event.change)
                            firing[stateProp.event.change] = true
                        end
                    end
                end

                if stateProp.event.on and currentValue and not previousValue then
                    if not firing[stateProp.event.on] then
                        table.insert(events, stateProp.event.on)
                        firing[stateProp.event.on] = true
                    end
                elseif stateProp.event.off and not currentValue and previousValue then
                    if not firing[stateProp.event.off] then
                        table.insert(events, 1, stateProp.event.off)
                        firing[stateProp.event.off] = true
                    end
                end
            end
        end
    end

    return events
end

local function notifyObservers()
    if not isDetached then
        applyQueuedChanges()
    end

    local currentState = GameUI.GetState()
    local stateChanged = false

    for _, stateProp in ipairs(stateProps) do
        local currentValue = currentState[stateProp.current]
        local previousValue = previousState[stateProp.current]

        if tostring(currentValue) ~= tostring(previousValue) then
            stateChanged = true
            break
        end
    end

    if stateChanged then
        local events = determineEvents(currentState)

        for _, event in ipairs(events) do
            if listeners[event] then
                if event ~= GameUI.Event.Update then
                    currentState.event = event
                end

                for _, callback in ipairs(listeners[event]) do
                    callback(currentState)
                end

                currentState.event = nil
            end
        end

        if isLoaded then
            isLoaded = false
        end

        previousState = currentState
    end
end

local function notifyAfterStart(updateCallback)
    if not isDetached then
        updateCallback()
        notifyObservers()
    else
        table.insert(updateQueue, updateCallback)
    end
end

local function initialize(event)
    if not initialized.data then
        GameUI.Context = {
            Default = Enum.new('UIGameContext', 0),
            QuickHack = Enum.new('UIGameContext', 1),
            Scanning = Enum.new('UIGameContext', 2),
            DeviceZoom = Enum.new('UIGameContext', 3),
            BraindanceEditor = Enum.new('UIGameContext', 4),
            BraindancePlayback = Enum.new('UIGameContext', 5),
            VehicleMounted = Enum.new('UIGameContext', 6),
            ModalPopup = Enum.new('UIGameContext', 7),
            RadialWheel = Enum.new('UIGameContext', 8),
            VehicleRace = Enum.new('UIGameContext', 9),
        }

        for _, stateProp in ipairs(stateProps) do
            if stateProp.event then
                local eventScope = stateProp.event.scope or stateProp.event.change

                if eventScope then
                    for _, eventKey in ipairs({ 'change', 'on', 'off' }) do
                        local eventName = stateProp.event[eventKey]

                        if eventName then
                            if not eventScopes[eventName] then
                                eventScopes[eventName] = {}
                                eventScopes[eventName][GameUI.Event.Session] = true
                            end

                            eventScopes[eventName][eventScope] = true
                        end
                    end

                    eventScopes[GameUI.Event.Update][eventScope] = true
                end
            end
        end

        initialized.data = true
    end

    local required = eventScopes[event] or eventScopes[GameUI.Event.Update]

    -- Game Session Listeners

    if required[GameUI.Event.Session] and not initialized[GameUI.Event.Session] then
        Observe('QuestTrackerGameController', 'OnInitialize', function()
            --spdlog.error(('QuestTrackerGameController::OnInitialize()'))

            if isDetached then
                updateLoading(false)
                updateLoaded(true)
                updateMenuScenario()
                applyQueuedChanges()
                refreshCurrentState()
                notifyObservers()
            end
        end)

        Observe('QuestTrackerGameController', 'OnUninitialize', function()
            --spdlog.error(('QuestTrackerGameController::OnUninitialize()'))

            if Game.GetPlayer() == nil then
                updateDetached(true)
                updateSceneTier(1)
                updateContext()
                updateVehicle(false, false)
                updateBraindance(false)
                updateCyberspace(false)
                updatePossessed(false)
                updateFlashback(false)
                updatePhotoMode(false)

                if currentMenu ~= 'MainMenu' then
                    notifyObservers()
                else
                    pushCurrentState()
                end
            end
        end)

        initialized[GameUI.Event.Session] = true
    end

    -- Loading State Listeners

    if required[GameUI.Event.Loading] and not initialized[GameUI.Event.Loading] then
        Observe('LoadingScreenProgressBarController', 'SetProgress', function(_, progress)
            --spdlog.info(('LoadingScreenProgressBarController::SetProgress(%.3f)'):format(progress))

            if not isLoading then
                updateMenuScenario()
                updateLoading(true)
                notifyObservers()

            elseif progress == 1.0 then
                if currentMenu ~= 'MainMenu' then
                    updateMenuScenario()
                end

                updateLoading(false)
                notifyObservers()
            end
        end)

        initialized[GameUI.Event.Loading] = true
    end

    -- Menu State Listeners

    if required[GameUI.Event.Menu] and not initialized[GameUI.Event.Menu] then
        local menuOpenListeners = {
            'MenuScenario_Idle',
            'MenuScenario_BaseMenu',
            'MenuScenario_PreGameSubMenu',
            'MenuScenario_SingleplayerMenu',
        }

        for _, menuScenario in pairs(menuOpenListeners) do
            Observe(menuScenario, 'OnLeaveScenario', function(_, menuName)
                if type(menuName) ~= 'userdata' then
                    menuName = _
                end

                --spdlog.error(('%s::OnLeaveScenario()'):format(menuScenario))

                updateMenuScenario(Game.NameToString(menuName))

                if not isLoading then
                    notifyObservers()
                end
            end)
        end

        Observe('MenuScenario_HubMenu', 'OnSelectMenuItem', function(_, menuItemData)
            if type(menuItemData) ~= 'userdata' then
                menuItemData = _
            end

            --spdlog.error(('MenuScenario_HubMenu::OnSelectMenuItem(%q)'):format(menuItemData.menuData.label))

            updateMenuItem(EnumValueToName('HubMenuItems', menuItemData.menuData.identifier).value)
            --updateMenuItem(toStudlyCase(menuItemData.menuData.label))
            notifyObservers()
        end)

        Observe('MenuScenario_HubMenu', 'OnCloseHubMenu', function()
            --spdlog.error(('MenuScenario_HubMenu::OnCloseHubMenu()'))

            updateMenuItem(false)
            notifyObservers()
        end)

        local menuItemListeners = {
            ['MenuScenario_SingleplayerMenu'] = {
                ['OnLoadGame'] = 'LoadGame',
                ['OnMainMenuBack'] = false,
            },
            ['MenuScenario_Settings'] = {
                ['OnSwitchToControllerPanel'] = 'Controller',
                ['OnSwitchToBrightnessSettings'] = 'Brightness',
                ['OnSwitchToHDRSettings'] = 'HDR',
                ['OnSettingsBack'] = 'Settings',
            },
            ['MenuScenario_PauseMenu'] = {
                ['OnSwitchToBrightnessSettings'] = 'Brightness',
                ['OnSwitchToControllerPanel'] = 'Controller',
                ['OnCreditsPicker'] = 'Credits',
                ['OnSwitchToHDRSettings'] = 'HDR',
                ['OnSwitchToLoadGame'] = 'LoadGame',
                ['OnSwitchToSaveGame'] = 'SaveGame',
                ['OnSwitchToSettings'] = 'Settings',
            },
            ['MenuScenario_DeathMenu'] = {
                ['OnSwitchToBrightnessSettings'] = 'Brightness',
                ['OnSwitchToControllerPanel'] = 'Controller',
                ['OnSwitchToHDRSettings'] = 'HDR',
                ['OnSwitchToLoadGame'] = 'LoadGame',
                ['OnSwitchToSettings'] = 'Settings',
            },
            ['MenuScenario_Vendor'] = {
                ['OnSwitchToVendor'] = 'Trade',
                ['OnSwitchToRipperDoc'] = 'RipperDoc',
                ['OnSwitchToCrafting'] = 'Crafting',
            },
        }

        for menuScenario, menuItemEvents in pairs(menuItemListeners) do
            for menuEvent, menuItem in pairs(menuItemEvents) do
                Observe(menuScenario, menuEvent, function()
                    --spdlog.error(('%s::%s()'):format(menuScenario, menuEvent))

                    updateMenuScenario(menuScenario)
                    updateMenuItem(menuItem)
                    notifyObservers()
                end)
            end
        end

        local menuBackListeners = {
            ['MenuScenario_PauseMenu'] = 'GoBack',
            ['MenuScenario_DeathMenu'] = 'GoBack',
        }

        for menuScenario, menuBackEvent in pairs(menuBackListeners) do
            Observe(menuScenario, menuBackEvent, function(self)
                --spdlog.error(('%s::%s()'):format(menuScenario, menuBackEvent))

                if Game.NameToString(self.prevMenuName) == 'settings_main' then
                    updateMenuItem('Settings')
                else
                    updateMenuItem(false)
                end

                notifyObservers()
            end)
        end

        Observe('SingleplayerMenuGameController', 'OnSavesForLoadReady', function()
            --spdlog.error(('SingleplayerMenuGameController::OnSavesForLoadReady()'))

            updateMenuScenario('MenuScenario_SingleplayerMenu')

            if not isLoading then
                notifyObservers()
            end
        end)

        initialized[GameUI.Event.Menu] = true
    end

    -- Vehicle State Listeners

    if required[GameUI.Event.Vehicle] and not initialized[GameUI.Event.Vehicle] then
        Observe('hudCarController', 'OnCameraModeChanged', function(_, mode)
            if type(mode) ~= 'boolean' then
                mode = _
            end

            --spdlog.error(('hudCarController::OnCameraModeChanged(%s)'):format(tostring(mode)))

            updateVehicle(true, mode)
            notifyObservers()
        end)

        Observe('hudCarController', 'OnUnmountingEvent', function()
            --spdlog.error(('hudCarController::OnUnmountingEvent()'))

            updateVehicle(false, false)
            notifyObservers()
        end)

        Observe('gameuiPanzerHUDGameController', 'OnUninitialize', function()
            --spdlog.error(('gameuiPanzerHUDGameController::OnUninitialize()'))

            updateVehicle(false, false)
            notifyObservers()
        end)

        Observe('PlayerVisionModeController', 'OnRestrictedSceneChanged', function(_, sceneTierValue)
            if type(sceneTierValue) ~= 'number' then
                sceneTierValue = _
            end

            --spdlog.error(('PlayerVisionModeController::OnRestrictedSceneChanged(%d)'):format(sceneTierValue))

            if isVehicle then
                updateVehicle(true, sceneTierValue < 3)
                notifyObservers()
            end
        end)

        initialized[GameUI.Event.Vehicle] = true
    end

    -- Braindance State Listeners

    if required[GameUI.Event.Braindance] and not initialized[GameUI.Event.Braindance] then
        Observe('BraindanceGameController', 'OnIsActiveUpdated', function(_, braindanceActive)
            if type(braindanceActive) ~= 'boolean' then
                braindanceActive = _
            end

            --spdlog.error(('BraindanceGameController::OnIsActiveUpdated(%s)'):format(tostring(braindanceActive)))

            updateBraindance(braindanceActive)
            notifyObservers()
        end)

        initialized[GameUI.Event.Braindance] = true
    end

    -- Scene State Listeners

    if required[GameUI.Event.Scene] and not initialized[GameUI.Event.Scene] then
        Observe('PlayerVisionModeController', 'OnRestrictedSceneChanged', function(_, sceneTierValue)
            if type(sceneTierValue) ~= 'number' then
                sceneTierValue = _
            end

            --spdlog.error(('PlayerVisionModeController::OnRestrictedSceneChanged(%d)'):format(sceneTierValue))

            notifyAfterStart(function()
                updateSceneTier(sceneTierValue)
            end)
        end)

        initialized[GameUI.Event.Scene] = true
    end

    -- Photo Mode Listeners

    if required[GameUI.Event.PhotoMode] and not initialized[GameUI.Event.PhotoMode] then
        Observe('gameuiPhotoModeMenuController', 'OnShow', function()
            --spdlog.error(('PhotoModeMenuController::OnShow()'))

            updatePhotoMode(true)
            notifyObservers()
        end)

        Observe('gameuiPhotoModeMenuController', 'OnHide', function()
            --spdlog.error(('PhotoModeMenuController::OnHide()'))

            updatePhotoMode(false)
            notifyObservers()
        end)

        initialized[GameUI.Event.PhotoMode] = true
    end

    -- Fast Travel Listeners

    if required[GameUI.Event.FastTravel] and not initialized[GameUI.Event.FastTravel] then
        local fastTravelStart

        Observe('FastTravelSystem', 'OnUpdateFastTravelPointRecordRequest', function(_, request)
            --spdlog.error(('FastTravelSystem::OnUpdateFastTravelPointRecordRequest()'))

            fastTravelStart = request.pointRecord
        end)

        Observe('FastTravelSystem', 'OnPerformFastTravelRequest', function(self, request)
            --spdlog.error(('FastTravelSystem::OnPerformFastTravelRequest()'))

            if self.isFastTravelEnabledOnMap then
                local fastTravelDestination = request.pointData and request.pointData.pointRecord or nil

                if tostring(fastTravelStart) ~= tostring(fastTravelDestination) then
                    updateLoading(true)
                    updateFastTravel(true)
                    notifyObservers()
                end
            end
        end)

        Observe('FastTravelSystem', 'OnLoadingScreenFinished', function(_, finished)
            --spdlog.error(('FastTravelSystem::OnLoadingScreenFinished(%s)'):format(tostring(finished)))

            if isFastTravel and finished then
                updateLoading(false)
                updateFastTravel(false)
                refreshCurrentState()
                notifyObservers()
            end
        end)

        initialized[GameUI.Event.FastTravel] = true
    end

    -- Shard Listeners

    if required[GameUI.Event.Shard] and not initialized[GameUI.Event.Shard] then
        Observe('ShardNotificationController', 'SetButtonHints', function()
            --spdlog.error(('ShardNotificationController::SetButtonHints()'))
            updateShard(true)
            notifyObservers()
        end)

        Observe('ShardNotificationController', 'Close', function()
            --spdlog.error(('ShardNotificationController::Close()'))
            updateShard(false)
            notifyObservers()
        end)

        initialized[GameUI.Event.Shard] = true
    end

    -- Tutorial Listeners

    if required[GameUI.Event.Tutorial] and not initialized[GameUI.Event.Tutorial] then
        Observe('gameuiTutorialPopupGameController', 'PauseGame', function(_, tutorialActive)
            --spdlog.error(('gameuiTutorialPopupGameController::PauseGame(%s)'):format(tostring(tutorialActive)))

            updateTutorial(tutorialActive)
            notifyObservers()
        end)

        initialized[GameUI.Event.Tutorial] = true
    end

    -- UI Context Listeners

    if required[GameUI.Event.Context] and not initialized[GameUI.Event.Context] then
        Observe('gameuiGameSystemUI', 'PushGameContext', function(_, newContext)
            --spdlog.error(('GameSystemUI::PushGameContext(%s)'):format(tostring(newContext)))

            if isBraindance and newContext.value == GameUI.Context.Scanning.value then
                return
            end

            updateContext(nil, newContext)
            notifyObservers()
        end)

        Observe('gameuiGameSystemUI', 'PopGameContext', function(_, oldContext)
            --spdlog.error(('GameSystemUI::PopGameContext(%s)'):format(tostring(oldContext)))

            if isBraindance and oldContext.value == GameUI.Context.Scanning.value then
                return
            end

            if oldContext.value == GameUI.Context.QuickHack.value then
                oldContext = GameUI.Context.Scanning
            end

            updateContext(oldContext, nil)
            notifyObservers()
        end)

        Observe('HUDManager', 'OnQuickHackUIVisibleChanged', function(_, quickhacking)
            if type(quickhacking) ~= 'boolean' then
                quickhacking = _
            end

            --spdlog.error(('HUDManager::OnQuickHackUIVisibleChanged(%s)'):format(tostring(quickhacking)))

            if quickhacking then
                updateContext(GameUI.Context.Scanning, GameUI.Context.QuickHack)
            else
                updateContext(GameUI.Context.QuickHack, GameUI.Context.Scanning)
            end

            notifyObservers()
        end)

        Observe('gameuiGameSystemUI', 'ResetGameContext', function()
            --spdlog.error(('GameSystemUI::ResetGameContext()'))

            updateContext()
            notifyObservers()
        end)

        initialized[GameUI.Event.Context] = true
    end

    -- Johnny

    if required[GameUI.Event.Johnny] and not initialized[GameUI.Event.Johnny] then
        Observe('cpPlayerSystem', 'OnLocalPlayerPossesionChanged', function(_, possession)
            if type(possession) ~= 'userdata' then
                possession = _
            end

            --spdlog.error(('cpPlayerSystem::OnLocalPlayerPossesionChanged(%s)'):format(tostring(possession)))

            notifyAfterStart(function()
                updatePossessed(possession.value == 'Johnny')
            end)
        end)

        Observe('cpPlayerSystem', 'OnLocalPlayerChanged', function(_, player)
            if type(player) ~= 'userdata' then
                player = _
            end

            --spdlog.error(('cpPlayerSystem::OnLocalPlayerChanged(%s)'):format(tostring(player:IsJohnnyReplacer())))

            notifyAfterStart(function()
                updateFlashback(player:IsJohnnyReplacer())
            end)
        end)

        initialized[GameUI.Event.Johnny] = true
    end

    -- Cyberspace

    if required[GameUI.Event.Cyberspace] and not initialized[GameUI.Event.Cyberspace] then
        Observe('PlayerPuppet', 'OnStatusEffectApplied', function(_, evt)
            --spdlog.error(('PlayerPuppet::OnStatusEffectApplied()'))

            if evt.staticData then
                local applyCyberspacePresence = evt.staticData:GameplayTagsContains('CyberspacePresence')

                if applyCyberspacePresence then
                    notifyAfterStart(function()
                        updateCyberspace(true)
                    end)
                end
            end
        end)

        Observe('PlayerPuppet', 'OnStatusEffectRemoved', function(_, evt)
            --spdlog.error(('PlayerPuppet::OnStatusEffectRemoved()'))

            if evt.staticData then
                local removeCyberspacePresence = evt.staticData:GameplayTagsContains('CyberspacePresence')

                if removeCyberspacePresence then
                    notifyAfterStart(function()
                        updateCyberspace(false)
                    end)
                end
            end
        end)

        initialized[GameUI.Event.Cyberspace] = true
    end

    -- Initial state

    if not initialized.state then
        refreshCurrentState()
        pushCurrentState()

        initialized.state = true
    end
end

function GameUI.Observe(event, callback)
    if type(event) == 'string' then
        initialize(event)
    elseif type(event) == 'function' then
        callback, event = event, GameUI.Event.Update
        initialize(event)
    else
        if not event then
            initialize(GameUI.Event.Update)
        elseif type(event) == 'table' then
            for _, evt in ipairs(event) do
                GameUI.Observe(evt, callback)
            end
        end
        return
    end

    if type(callback) == 'function' then
        if not listeners[event] then
            listeners[event] = {}
        end

        table.insert(listeners[event], callback)
    end
end

function GameUI.Listen(event, callback)
    if type(event) == 'function' then
        callback = event
        for _, evt in pairs(GameUI.Event) do
            if not GameUI.StateEvent[evt] then
                GameUI.Observe(evt, callback)
            end
        end
    else
        GameUI.Observe(event, callback)
    end
end

function GameUI.IsDetached()
    return isDetached
end

function GameUI.IsLoading()
    return isLoading
end

function GameUI.IsMenu()
    return isMenu
end

function GameUI.IsMainMenu()
    return isMenu and currentMenu == 'MainMenu'
end

function GameUI.IsShard()
    return isShard
end

function GameUI.IsTutorial()
    return isTutorial
end

function GameUI.IsScene()
    return sceneTier >= 3 and not GameUI.IsMainMenu()
end

function GameUI.IsScanner()
    local context = GameUI.GetContext()

    return not isMenu and not isLoading and not isFastTravel and (context.value == GameUI.Context.Scanning.value)
end

function GameUI.IsQuickHack()
    local context = GameUI.GetContext()

    return not isMenu and not isLoading and not isFastTravel and (context.value == GameUI.Context.QuickHack.value)
end

function GameUI.IsPopup()
    local context = GameUI.GetContext()

    return not isMenu and (context.value == GameUI.Context.ModalPopup.value)
end

function GameUI.IsWheel()
    local context = GameUI.GetContext()

    return not isMenu and (context.value == GameUI.Context.RadialWheel.value)
end

function GameUI.IsDevice()
    local context = GameUI.GetContext()

    return not isMenu and (context.value == GameUI.Context.DeviceZoom.value)
end

function GameUI.IsVehicle()
    return isVehicle
end

function GameUI.IsFastTravel()
    return isFastTravel
end

function GameUI.IsBraindance()
    return isBraindance
end

function GameUI.IsCyberspace()
    return isCyberspace
end

function GameUI.IsJohnny()
    return isPossessed or isFlashback
end

function GameUI.IsPossessed()
    return isPossessed
end

function GameUI.IsFlashback()
    return isFlashback
end

function GameUI.IsPhoto()
    return isPhotoMode
end

function GameUI.IsDefault()
    return not isDetached
        and not isLoading
        and not isMenu
        and not GameUI.IsScene()
        and not isFastTravel
        and not isBraindance
        and not isCyberspace
        and not isPhotoMode
        and GameUI.IsContext(GameUI.Context.Default)
end

function GameUI.GetMenu()
    return currentMenu
end

function GameUI.GetSubmenu()
    return currentSubmenu
end

function GameUI.GetCamera()
    return currentCamera
end

function GameUI.GetContext()
    return #contextStack > 0 and contextStack[#contextStack] or GameUI.Context.Default
end

function GameUI.IsContext(context)
    return GameUI.GetContext().value == (type(context) == 'userdata' and context.value or context)
end

function GameUI.GetState()
    local currentState = {}

    currentState.isDetached = GameUI.IsDetached()
    currentState.isLoading = GameUI.IsLoading()
    currentState.isLoaded = isLoaded

    currentState.isMenu = GameUI.IsMenu()
    currentState.isShard = GameUI.IsShard()
    currentState.isTutorial = GameUI.IsTutorial()

    currentState.isScene = GameUI.IsScene()
    currentState.isScanner = GameUI.IsScanner()
    currentState.isQuickHack = GameUI.IsQuickHack()
    currentState.isPopup = GameUI.IsPopup()
    currentState.isWheel = GameUI.IsWheel()
    currentState.isDevice = GameUI.IsDevice()
    currentState.isVehicle = GameUI.IsVehicle()

    currentState.isFastTravel = GameUI.IsFastTravel()

    currentState.isBraindance = GameUI.IsBraindance()
    currentState.isCyberspace = GameUI.IsCyberspace()

    currentState.isJohnny = GameUI.IsJohnny()
    currentState.isPossessed = GameUI.IsPossessed()
    currentState.isFlashback = GameUI.IsFlashback()

    currentState.isPhoto = GameUI.IsPhoto()

    currentState.isEditor = GameUI.IsContext(GameUI.Context.BraindanceEditor)

    currentState.isDefault = not currentState.isDetached
        and not currentState.isLoading
        and not currentState.isMenu
        and not currentState.isScene
        and not currentState.isScanner
        and not currentState.isQuickHack
        and not currentState.isPopup
        and not currentState.isWheel
        and not currentState.isDevice
        and not currentState.isFastTravel
        and not currentState.isBraindance
        and not currentState.isCyberspace
        and not currentState.isPhoto

    currentState.menu = GameUI.GetMenu()
    currentState.submenu = GameUI.GetSubmenu()
    currentState.camera = GameUI.GetCamera()
    currentState.context = GameUI.GetContext()

    for _, stateProp in ipairs(stateProps) do
        if stateProp.previous then
            currentState[stateProp.previous] = previousState[stateProp.current]
        end
    end

    return currentState
end

function GameUI.ExportState(state)
    local export = {}

    if state.event then
        table.insert(export, 'event = ' .. string.format('%q', state.event))
    end

    for _, stateProp in ipairs(stateProps) do
        local value = state[stateProp.current]

        if value and (not stateProp.parent or state[stateProp.parent]) then
            if type(value) == 'userdata' then
                value = string.format('%q', value.value) -- 'GameUI.Context.'
            elseif type(value) == 'string' then
                value = string.format('%q', value)
            else
                value = tostring(value)
            end

            table.insert(export, stateProp.current .. ' = ' .. value)
        end
    end

    for _, stateProp in ipairs(stateProps) do
        if stateProp.previous then
            local currentValue = state[stateProp.current]
            local previousValue = state[stateProp.previous]

            if previousValue and previousValue ~= currentValue then
                if type(previousValue) == 'userdata' then
                    previousValue = string.format('%q', previousValue.value) -- 'GameUI.Context.'
                elseif type(previousValue) == 'string' then
                    previousValue = string.format('%q', previousValue)
                else
                    previousValue = tostring(previousValue)
                end

                table.insert(export, stateProp.previous .. ' = ' .. previousValue)
            end
        end
    end

    return '{ ' .. table.concat(export, ', ') .. ' }'
end

function GameUI.PrintState(state)
    print('[GameUI] ' .. GameUI.ExportState(state))
end

GameUI.On = GameUI.Listen

setmetatable(GameUI, {
    __index = function(_, key)
        local event = string.match(key, '^On(%w+)$')

        if event and GameUI.Event[event] then
            rawset(GameUI, key, function(callback)
                GameUI.Observe(event, callback)
            end)

            return rawget(GameUI, key)
        end
    end
})

return GameUI