--[[
    Automatic Doors - RuneScape: Dragonwilds (UE4SS Lua mod)

    - Automatically OPENS a door when a player is within a configurable
      radius of it.
    - Automatically CLOSES a door a configurable number of real-world
      seconds after no player remains within that radius.

    ----------------------------------------------------------------------
    DESIGN HISTORY (so the reasoning isn't lost):

    v1 (polling every 2s, scanning the whole world every tick): confirmed
    correct by live testing, but caused noticeable game lag.

    v2 (event-driven, hooking InteractionComponent:K2_OnShowInteractionPrompt
    / K2_OnHideInteractionPrompt / K2_IsInteractable): all three hooks
    registered successfully but NEVER FIRED during real play, confirmed by
    adding unconditional diagnostic logging inside each one and testing -
    zero log lines appeared even while interacting with objects. Whatever
    native system actually drives the interaction prompt does not call
    these particular Blueprint events, so this approach was abandoned
    rather than guessing at further hook candidates.

    v3 (this version): back to polling (the proven-correct approach), but
    the expensive part - FindAllOf(), which scans the entire live UObject
    array - is now decoupled from the frequent check. A slow "rescan" pass
    (default every 30s) rebuilds a small cached list of door components and
    players. A fast "check" pass (default every 1.5s) does cheap math
    (distance checks, elapsed-time checks) only against that small cached
    list - no full-world scan in the hot path. This should give the same
    correct behavior as v1 without its lag.

    ----------------------------------------------------------------------
    FACTS USED BELOW, AND WHERE THEY CAME FROM (nothing here is guessed):

    - Door blueprint classes (confirmed from BP_T1_Door.json, BP_T2_Door.json,
      BP_T3_Door.json, BP_T3_CastleDoor.json):
          BP_T1_Door_C, BP_T2_Door_C, BP_T3_Door_C, BP_T3_CastleDoor_C
      All four derive from BP_BaseBuilding_BaseActor_C and share an
      identical component layout.

    - Each door actor owns a component named "Hinge" (native class
      /Script/Dominion.HingeComponent). Confirmed via UE4SS_ObjectDump.txt
      and live "Dump as JSON" snapshots.

    - HingeComponent.HingeState is an EnumProperty backed by
      /Script/Dominion.EHingeState. Confirmed values (grep "EHingeState" in
      UE4SS_ObjectDump.txt):
          OpenForward  = 0
          OpenBackward = 1
          Closed       = 2

    - HingeComponent:TryToggling(Player) opens/closes the door - confirmed
      working in both directions by live testing (manual Ctrl+F2/Ctrl+F3
      key-binds successfully closed/opened all 34 doors in a real session).

    - ActorComponent:GetOwner() is a confirmed real UFunction (seen in the
      object dump under /Script/Engine.ActorComponent:GetOwner). This
      script tries `.Owner` property-style access first (confirmed working
      in live testing) and falls back to `:GetOwner()` if that's ever nil.

    - K2_GetActorLocation() is the standard Actor-level location getter,
      confirmed via UE4SS's own official tutorial.
--]]

local ModName = "Automatic Doors"

-- ---------------------------------------------------------------------------
-- Confirmed enum values for /Script/Dominion.EHingeState
-- ---------------------------------------------------------------------------
local HINGE_STATE_OPEN_FORWARD  = 0
local HINGE_STATE_OPEN_BACKWARD = 1
local HINGE_STATE_CLOSED        = 2

-- ---------------------------------------------------------------------------
-- Confirmed door blueprint classes. HingeComponent also exists on windows
-- (BP_T2_Window_C, etc.) - those are deliberately excluded.
-- ---------------------------------------------------------------------------
local DoorClassNames = {
    BP_T1_Door_C       = true,
    BP_T2_Door_C       = true,
    BP_T3_Door_C       = true,
    BP_T3_CastleDoor_C = true,
}

-- ---------------------------------------------------------------------------
-- Config loading
--
-- config.txt lives next to this script (Mods/Automatic Doors/scripts/config.txt)
-- and uses simple "Key=Value" lines, matching the plain-text config style
-- already used by other RSDragonwilds UE4SS mods (e.g. Architect's config.txt).
-- ---------------------------------------------------------------------------
local Config = {
    CloseAfterSeconds   = 30,    -- real-world seconds with no player in range before auto-closing
    AutoOpenEnabled     = 1,     -- 1 = open doors automatically on approach, 0 = disabled
    AutoOpenRadiusUnits = 250,   -- Unreal units (cm) - how close a player must be to auto-open a door
    CheckIntervalMs     = 1500,  -- cheap pass: cached-list distance/state checks only, no world scan
    RescanIntervalMs    = 30000, -- expensive pass: rebuilds the cached door/player lists via FindAllOf
}

local function GetScriptDir()
    local source = debug.getinfo(1, "S").source
    local path = source:match("^@(.*)$") or source
    return path:match("(.*)[/\\]") or "."
end

local ConfigPath = GetScriptDir() .. "/config.txt"

local function LoadConfig()
    local file = io.open(ConfigPath, "r")
    if not file then
        print(string.format(
            "[%s] No config.txt found at '%s', using defaults.\n",
            ModName, ConfigPath))
        return
    end

    for line in file:lines() do
        local key, value = line:match("^%s*([%a_][%w_]*)%s*=%s*([%-%d%.]+)%s*$")
        if key and Config[key] ~= nil then
            Config[key] = tonumber(value)
        end
    end
    file:close()

    print(string.format(
        "[%s] Loaded config: CloseAfterSeconds=%s, AutoOpenEnabled=%s, AutoOpenRadiusUnits=%s, CheckIntervalMs=%s, RescanIntervalMs=%s\n",
        ModName,
        tostring(Config.CloseAfterSeconds),
        tostring(Config.AutoOpenEnabled),
        tostring(Config.AutoOpenRadiusUnits),
        tostring(Config.CheckIntervalMs),
        tostring(Config.RescanIntervalMs)))
end

LoadConfig()

-- ---------------------------------------------------------------------------
-- Helpers
-- ---------------------------------------------------------------------------

local function IsHingeOpen(State)
    return State == HINGE_STATE_OPEN_FORWARD or State == HINGE_STATE_OPEN_BACKWARD
end

-- Returns the owning actor of a component, trying property-style access
-- first and falling back to the confirmed real GetOwner() UFunction.
local function GetComponentOwner(Component)
    local Owner = Component.Owner
    if Owner and Owner:IsValid() then
        return Owner
    end

    local ok, Result = pcall(function() return Component:GetOwner() end)
    if ok and Result and Result:IsValid() then
        return Result
    end

    return nil
end

local function IsKnownDoorActor(Actor)
    if not Actor or not Actor:IsValid() then
        return false
    end
    local ClassName = Actor:GetClass():GetFName():ToString()
    return DoorClassNames[ClassName] == true
end

local function Distance3D(a, b)
    local dx, dy, dz = a.X - b.X, a.Y - b.Y, a.Z - b.Z
    return math.sqrt(dx * dx + dy * dy + dz * dz)
end

-- Calls Hinge:TryToggling(Player) and logs the outcome. ActionLabel is just
-- for readable log output ("Opened"/"Closed").
local function TryToggleHinge(Hinge, Owner, Player, ActionLabel)
    local ok, err = pcall(function()
        Hinge:TryToggling(Player)
    end)

    if not ok then
        print(string.format(
            "[%s] TryToggling errored on %s: %s\n",
            ModName, Owner:GetFullName(), tostring(err)))
        return false
    end

    print(string.format("[%s] %s door: %s\n", ModName, ActionLabel, Owner:GetFullName()))
    return true
end

-- ---------------------------------------------------------------------------
-- Cached lists. Rebuilt only by RescanWorld() (slow, expensive), read by
-- CheckDoors() (fast, cheap) every tick.
-- ---------------------------------------------------------------------------
local CachedDoorHinges = {} -- array of { Hinge = <HingeComponent>, Owner = <AActor> }
local CachedPlayers    = {} -- array of <DominionPlayerCharacter>
local OpenSince        = {} -- [HingeAddress] = os.clock() timestamp when last seen with no player in range

local function RescanWorld()
    local NewDoorHinges = {}
    local Hinges = FindAllOf("HingeComponent")
    if Hinges then
        for _, Hinge in ipairs(Hinges) do
            if Hinge and Hinge:IsValid() then
                local Owner = GetComponentOwner(Hinge)
                if IsKnownDoorActor(Owner) then
                    table.insert(NewDoorHinges, { Hinge = Hinge, Owner = Owner })
                end
            end
        end
    end
    CachedDoorHinges = NewDoorHinges

    local NewPlayers = {}
    local Players = FindAllOf("DominionPlayerCharacter")
    if Players then
        for _, Player in ipairs(Players) do
            if Player and Player:IsValid() then
                table.insert(NewPlayers, Player)
            end
        end
    end
    CachedPlayers = NewPlayers

    print(string.format(
        "[%s] Rescanned world: %d known door(s), %d player(s) cached.\n",
        ModName, #CachedDoorHinges, #CachedPlayers))
end

-- The cheap, frequent pass: no FindAllOf here at all, only cached-list reads
-- plus simple math and the occasional TryToggling call.
local function CheckDoors()
    -- Resolve live locations for cached players (cheap: direct calls on
    -- already-known objects, no world scan).
    local PlayerLocations = {}
    if Config.AutoOpenEnabled ~= 0 then
        for _, Player in ipairs(CachedPlayers) do
            if Player and Player:IsValid() then
                local ok, Location = pcall(function() return Player:K2_GetActorLocation() end)
                if ok and Location then
                    table.insert(PlayerLocations, Location)
                end
            end
        end
    end

    for _, Entry in ipairs(CachedDoorHinges) do
        local Hinge, Owner = Entry.Hinge, Entry.Owner
        if Hinge and Hinge:IsValid() and Owner and Owner:IsValid() then
            local Address = Hinge:GetAddress()
            local State = Hinge.HingeState

            if IsHingeOpen(State) then
                -- Open: check whether any cached player is still within
                -- radius. If none are, start/continue the close-timer.
                local SomeoneNearby = false
                if #PlayerLocations > 0 then
                    local OwnerLocOk, OwnerLocation = pcall(function()
                        return Owner:K2_GetActorLocation()
                    end)
                    if OwnerLocOk and OwnerLocation then
                        for _, PlayerLocation in ipairs(PlayerLocations) do
                            if Distance3D(OwnerLocation, PlayerLocation) <= Config.AutoOpenRadiusUnits then
                                SomeoneNearby = true
                                break
                            end
                        end
                    end
                end

                if SomeoneNearby then
                    OpenSince[Address] = nil -- someone's still here, reset the idle clock
                else
                    local now = os.clock()
                    if not OpenSince[Address] then
                        OpenSince[Address] = now
                    elseif (now - OpenSince[Address]) >= Config.CloseAfterSeconds then
                        local Player = CachedPlayers[1]
                        if Player and Player:IsValid() then
                            if TryToggleHinge(Hinge, Owner, Player, "Closed") then
                                OpenSince[Address] = nil
                            end
                        end
                    end
                end
            else
                -- Closed: clear any stale close-timer, and consider opening
                -- if a cached player is within radius.
                OpenSince[Address] = nil

                if Config.AutoOpenEnabled ~= 0 and #PlayerLocations > 0 then
                    local OwnerLocOk, OwnerLocation = pcall(function()
                        return Owner:K2_GetActorLocation()
                    end)
                    if OwnerLocOk and OwnerLocation then
                        for i, PlayerLocation in ipairs(PlayerLocations) do
                            if Distance3D(OwnerLocation, PlayerLocation) <= Config.AutoOpenRadiusUnits then
                                TryToggleHinge(Hinge, Owner, CachedPlayers[i], "Opened")
                                break
                            end
                        end
                    end
                end
            end
        end
    end
end

-- Initial population so the mod works immediately instead of waiting up to
-- RescanIntervalMs for the first cache build.
RescanWorld()

-- ---------------------------------------------------------------------------
-- Scheduling
--
-- LoopAsync's own callback is not guaranteed to run on the game thread, so
-- the actual UObject-touching work is wrapped in ExecuteInGameThread, same
-- as UE4SS's own official tutorial does for engine-touching code triggered
-- from outside a direct hook callback.
-- ---------------------------------------------------------------------------
LoopAsync(Config.RescanIntervalMs, function()
    ExecuteInGameThread(function()
        local ok, err = pcall(RescanWorld)
        if not ok then
            print(string.format("[%s] RescanWorld error: %s\n", ModName, tostring(err)))
        end
    end)
    return false -- keep looping indefinitely
end)

LoopAsync(Config.CheckIntervalMs, function()
    ExecuteInGameThread(function()
        local ok, err = pcall(CheckDoors)
        if not ok then
            print(string.format("[%s] CheckDoors error: %s\n", ModName, tostring(err)))
        end
    end)
    return false -- keep looping indefinitely
end)

-- ---------------------------------------------------------------------------
-- Manual test key-binds
--   Ctrl+F2 - immediately closes every currently-open known door.
--   Ctrl+F3 - immediately opens every currently-closed known door.
--   Ctrl+F4 - forces an immediate world rescan (refreshes the cache now
--             instead of waiting for RescanIntervalMs).
-- These do their own direct FindAllOf calls rather than using the cache,
-- since they're explicit one-off user actions, not the hot path.
-- ---------------------------------------------------------------------------
RegisterKeyBind(Key.F2, { ModifierKey.CONTROL }, function()
    ExecuteInGameThread(function()
        print(string.format("[%s] Manual close-all triggered (Ctrl+F2).\n", ModName))

        local Hinges = FindAllOf("HingeComponent")
        if not Hinges then
            print(string.format("[%s] No HingeComponent instances found.\n", ModName))
            return
        end

        local Player = FindFirstOf("DominionPlayerCharacter")
        if not Player or not Player:IsValid() then
            print(string.format("[%s] No valid DominionPlayerCharacter found.\n", ModName))
            return
        end

        local ClosedCount = 0
        for _, Hinge in ipairs(Hinges) do
            if Hinge and Hinge:IsValid() then
                local Owner = GetComponentOwner(Hinge)
                if IsKnownDoorActor(Owner) and IsHingeOpen(Hinge.HingeState) then
                    if TryToggleHinge(Hinge, Owner, Player, "Closed") then
                        ClosedCount = ClosedCount + 1
                        OpenSince[Hinge:GetAddress()] = nil
                    end
                end
            end
        end

        print(string.format("[%s] Manual close-all finished. Closed %d door(s).\n", ModName, ClosedCount))
    end)
end)

RegisterKeyBind(Key.F3, { ModifierKey.CONTROL }, function()
    ExecuteInGameThread(function()
        print(string.format("[%s] Manual open-all triggered (Ctrl+F3).\n", ModName))

        local Hinges = FindAllOf("HingeComponent")
        if not Hinges then
            print(string.format("[%s] No HingeComponent instances found.\n", ModName))
            return
        end

        local Player = FindFirstOf("DominionPlayerCharacter")
        if not Player or not Player:IsValid() then
            print(string.format("[%s] No valid DominionPlayerCharacter found.\n", ModName))
            return
        end

        local OpenedCount = 0
        for _, Hinge in ipairs(Hinges) do
            if Hinge and Hinge:IsValid() then
                local Owner = GetComponentOwner(Hinge)
                if IsKnownDoorActor(Owner) and not IsHingeOpen(Hinge.HingeState) then
                    if TryToggleHinge(Hinge, Owner, Player, "Opened") then
                        OpenedCount = OpenedCount + 1
                    end
                end
            end
        end

        print(string.format("[%s] Manual open-all finished. Opened %d door(s).\n", ModName, OpenedCount))
    end)
end)

RegisterKeyBind(Key.F4, { ModifierKey.CONTROL }, function()
    ExecuteInGameThread(function()
        print(string.format("[%s] Manual rescan triggered (Ctrl+F4).\n", ModName))
        local ok, err = pcall(RescanWorld)
        if not ok then
            print(string.format("[%s] RescanWorld error: %s\n", ModName, tostring(err)))
        end
    end)
end)

print(string.format(
    "[%s] Initialized (cached polling). AutoOpen=%s (radius=%s units). Doors auto-close %ds after everyone leaves range. Check every %sms, rescan every %sms.\n",
    ModName,
    (Config.AutoOpenEnabled ~= 0) and "on" or "off",
    tostring(Config.AutoOpenRadiusUnits),
    Config.CloseAfterSeconds,
    tostring(Config.CheckIntervalMs),
    tostring(Config.RescanIntervalMs)))
