-- HousingDecorGuide/Modules/HDG_TopologyCapture.lua
-- Pin-frame snapshot capture for the player's house topology.
--
-- Pipeline: HOUSING_LAYOUT_PIN_FRAME_ADDED → _ingestPin → snapshot.rooms.
-- Finalize: compute fingerprint + worldCardinals from raw pin-frame facings,
-- match to existing rooms, upsert into ProjectsStore.
--
-- World cardinals come straight from pin-frame doorFacing (compass-bearing
-- radians, 0=N CW). No DB2 rotation deduction or synthetic-door injection —
-- those layers caused stale data and cycle bugs and bought us nothing the
-- raw facings don't already give.
--
-- Entry exception: Blizzard pin frames only emit Entry's external (south)
-- door. Entry's interior connection is fixed at world-N per Vamoose's housing
-- rule, so we hardcode worldCardinals = {"N"} for entry-shaped rooms instead
-- of trying to deduce it.

local _, privateDB = ...

local IDs      = privateDB.HDG_ProjectsIDs
local FP       = privateDB.HDG_FingerprintMatcher
local Store    = privateDB.HDG_ProjectsStore

local function _EventBus()
    local g = _G.HousingDecorGuide
    return g and g.EventBus
end
local Debug    = privateDB.HDG_Debug

local function _currentHouseID()
    local faction = UnitFactionGroup("player")
    if faction == "Alliance" then return IDs.makeHouseID("alliance") end
    if faction == "Horde"    then return IDs.makeHouseID("horde") end
    return nil
end

local function _layoutMode()
    return (Enum and Enum.HouseEditorMode and Enum.HouseEditorMode.Layout) or 3
end

local function _decorateMode()
    return (Enum and Enum.HouseEditorMode and Enum.HouseEditorMode.Decorate) or 1
end

local M = {}

-- ---------------------------------------------------------------------------
-- Capture state (transient across one Layout-mode session)
-- ---------------------------------------------------------------------------
local capture = nil
local activeSweep = nil

local function _newCapture()
    return {
        active = false,
        rooms  = {},
        floor  = nil,
    }
end

-- ---------------------------------------------------------------------------
-- Pin ingestion
-- ---------------------------------------------------------------------------
local function _ingestPin(pinFrame)
    if not capture then return end
    if not pinFrame or not pinFrame.GetPinType then return end
    local pinType = pinFrame:GetPinType()
    local roomGUID = pinFrame:GetRoomGUID()
    if not roomGUID then return end

    capture.rooms[roomGUID] = capture.rooms[roomGUID] or {
        shape        = nil,
        name         = nil,
        blizzardGUID = roomGUID,
        doors        = {},
        rawFacings   = {},
        capturedID   = roomGUID,
        roomCx       = nil,
        roomCy       = nil,
        roomW        = nil,
        roomH        = nil,
        _roomPin     = nil,   -- temp ref for late position re-read
    }
    local room = capture.rooms[roomGUID]

    if pinType == 1 then
        room.name  = pinFrame:GetRoomName()
        room.shape = M.NameToShape(room.name) or room.name
        room._roomPin = pinFrame
        -- IsBaseRoom is a deterministic API flag — always identifies the
        -- entry/anchor room, independent of localized shape name.
        if C_HousingLayout and C_HousingLayout.IsBaseRoom then
            local okB, isBase = pcall(function() return C_HousingLayout.IsBaseRoom(roomGUID) end)
            if okB and isBase then room.isBase = true end
        end
        local okC, cx, cy = pcall(function() return pinFrame:GetCenter() end)
        if okC then room.roomCx, room.roomCy = cx, cy end
        local okS, w, h = pcall(function() return pinFrame:GetSize() end)
        if okS then room.roomW, room.roomH = w, h end
    elseif pinType == 0 then
        local d = pinFrame:GetDoorConnectionInfo()
        if d then
            local cx, cy
            local okC, x, y = pcall(function() return pinFrame:GetCenter() end)
            if okC then cx, cy = x, y end
            table.insert(room.doors, {
                doorID         = d.doorID,
                connectionType = d.connectionType,   -- HousingRoomComponentType enum
                facing         = d.doorFacing,
                occupied       = pinFrame:IsOccupiedDoor() and true or false,
                cx             = cx,
                cy             = cy,
                _pinFrame      = pinFrame,           -- temp ref for late position re-read
            })
            table.insert(room.rawFacings, d.doorFacing)
        end
    end
end

local NAME_TO_SHAPE = {
    ["Closet"]                  = "closet_xs",
    ["Square Room (Small)"]     = "square_s",
    ["Square Room (Medium)"]    = "square_m",
    ["Square Room (Large)"]     = "square_l",
    ["Octagon Room (Small)"]    = "octagon_s",
    ["Octagon Room (Medium)"]   = "octagon_m",
    ["Octagon Room (Large)"]    = "octagon_l",
    ["L-Shaped Room"]           = "l_shape",
    ["T-Shaped Room"]           = "t_shape",
    ["Cross-Shaped Room"]       = "cross_shape",
    ["Hallway"]                 = "hallway",
    ["Entry"]                   = "entry",
    ["Evening Circle Room"]     = "circle_evening",
    ["Daylight Circle Room"]    = "circle_daylight",
    ["Stairwell (Left)"]        = "stairwell_left",
    ["Stairwell (Right)"]       = "stairwell_right",
    ["Stairwell Room (Empty)"]  = "tall_room",
}

function M.NameToShape(localizedName)
    return NAME_TO_SHAPE[localizedName]
end

-- ---------------------------------------------------------------------------
-- World-cardinal derivation (the simple replacement for RotationDeducer)
-- ---------------------------------------------------------------------------
-- Pin frames only ever emit INTERNAL connection doors (the external front
-- door of Entry is never pinned). Blizzard's IsOccupiedDoor() flags the
-- doors that are currently connected to a neighbour; the unflagged ones are
-- the open slots where a new room can be attached (rendered as orbs).
--
-- We keep both:
--   doorCardinals     = every captured door (player-cardinals after the
--                       N↔S flip baked into FP.facingToCardinal)
--   occupiedCardinals = subset that Blizzard reports as occupied (real
--                       connections — what BFS uses to wire the graph)
local function _doorCardinals(cap)
    local out = {}
    for _, d in ipairs(cap.doors or {}) do
        local c = FP.facingToCardinal(d.facing)
        if c then out[#out + 1] = c end
    end
    table.sort(out)
    return out
end

local function _occupiedCardinals(cap)
    local out = {}
    for _, d in ipairs(cap.doors or {}) do
        if d.occupied then
            local c = FP.facingToCardinal(d.facing)
            if c then out[#out + 1] = c end
        end
    end
    table.sort(out)
    return out
end

-- Per-door records persisted to the Store. doorID is the master key for
-- adjacency: when a door connects two rooms, both rooms' pin frames carry
-- the SAME doorID, so room-to-room edges are derivable by matching IDs
-- across rooms — no guessing from complementary cardinals.
local function _doorRecords(cap)
    local out = {}
    for _, d in ipairs(cap.doors or {}) do
        out[#out + 1] = {
            doorID   = d.doorID,
            cardinal = FP.facingToCardinal(d.facing),
            occupied = d.occupied,
        }
    end
    return out
end

-- ---------------------------------------------------------------------------
-- Capture lifecycle
-- ---------------------------------------------------------------------------
function M.IsCapturing() return capture and capture.active or false end

function M.BeginCapture(floor)
    capture = _newCapture()
    capture.active = true
    capture.floor = floor or 1
    if Debug and Debug.Log then Debug.Log("HDG.TopologyCapture", "begin floor=" .. tostring(floor)) end
end

function M.EndCapture()
    if not capture or not capture.active then return nil end
    capture.active = false
    local snapshot = capture
    capture = nil
    return snapshot
end

function M.OnPinFrameAdded(pinFrame)
    if capture and capture.active then _ingestPin(pinFrame) end
end

local function _toCapturedList(rooms)
    local list = {}
    for _, r in pairs(rooms) do
        list[#list + 1] = {
            capturedID = r.capturedID,
            shape      = r.shape,
            doors      = r.doors,
            rawFacings = r.rawFacings,
        }
    end
    return list
end

local function _toExistingList(houseID, floor)
    local floorID = IDs.makeFloorID(houseID, floor)
    if not floorID then return {} end
    local entries = Store.GetRoomsForFloor(floorID)
    local list = {}
    for _, e in ipairs(entries) do
        if e.room.fingerprint then
            list[#list + 1] = {
                existingID  = e.id,
                fingerprint = e.room.fingerprint,
            }
        end
    end
    return list
end

-- ---------------------------------------------------------------------------
-- Finalize: match captured rooms to existing, upsert, demote orphans
-- ---------------------------------------------------------------------------
function M.Finalize(snapshot, houseID)
    if not snapshot or not houseID then return nil end
    local floor   = snapshot.floor or 1
    local floorID = IDs.makeFloorID(houseID, floor)
    if not floorID then return nil end

    -- Read pin-frame restriction values for each room while pin frames are
    -- still alive. CanMove/CanRemove/CanRotate return HousingLayoutRestriction
    -- enum values that encode topology role beyond what cardinals alone tell
    -- us — most usefully:
    --   value 8 (UnreachableWithoutRoom) marks structural rooms (hubs,
    --     corridors, AND single-door rooms with stairs to other floors)
    --   value 4 (IsBaseRoom semantics) marks ordinary removable leaves
    --   value 2 (NotInsideHouse semantics) uniquely identifies the entry
    -- We store these on the room record; AutoLayout / multi-floor backdrop
    -- consumers can read them to distinguish a stair-leaf from a wall-leaf.
    for _, room in pairs(snapshot.rooms) do
        if room._roomPin then
            local okM, mv = pcall(function() return room._roomPin:CanMove() end)
            local okR, rm = pcall(function() return room._roomPin:CanRemove() end)
            local okT, rt = pcall(function() return room._roomPin:CanRotate() end)
            if okM then room.canMove   = mv end
            if okR then room.canRemove = rm end
            if okT then room.canRotate = rt end
        end
    end

    local capturedList = _toCapturedList(snapshot.rooms)
    local existingList = _toExistingList(houseID, floor)
    local match        = FP.matchRooms(capturedList, existingList)

    local now = time and time() or 0
    local stats = { matched = 0, new = 0, orphaned = 0, ambiguous = 0 }

    local function _upsert(roomID, cap)
        Store.UpsertRoom(roomID, {
            shape             = cap.shape,
            name              = cap.name,        -- localized "Cross-Shaped Room" etc
            fingerprint       = FP.computeFingerprint(cap),
            blizzardGUID      = cap.blizzardGUID,
            capturedAt        = now,
            doorCardinals     = _doorCardinals(cap),
            occupiedCardinals = _occupiedCardinals(cap),
            doors             = _doorRecords(cap),
            isBase            = cap.isBase,
            -- HousingLayoutRestriction enum values from pin-frame methods.
            -- Decoded: 2=entry-marker, 4=plain-leaf, 6=stairwell,
            -- 8=structural (hub/corridor/stair-leaf). See multi-floor
            -- backdrop logic — stair-leaves should hint "this room
            -- continues upstairs" rather than dead-end.
            canMove           = cap.canMove,
            canRemove         = cap.canRemove,
            canRotate         = cap.canRotate,
        })
    end

    for _, m in ipairs(match.matched) do
        local cap = snapshot.rooms[m.capturedID]
        if cap then _upsert(m.existingID, cap); stats.matched = stats.matched + 1 end
    end
    for _, n in ipairs(match.newRooms) do
        local cap = snapshot.rooms[n.capturedID]
        if cap then _upsert(Store.MintRoomID(floorID), cap); stats.new = stats.new + 1 end
    end
    for _, o in ipairs(match.orphans) do
        Store.DeleteRoom(o.existingID)
        stats.orphaned = stats.orphaned + 1
    end
    stats.ambiguous = #match.ambiguous

    Store.UpsertHouse(houseID, { lastCapturedAt = now })

    local bus = _EventBus()
    if bus and bus.Trigger then
        bus:Trigger("HDG_PROJECTS_TOPOLOGY_UPDATED", { houseID = houseID, floor = floor, stats = stats })
    end
    return stats
end

function M.GetCurrentHouseID()
    return _currentHouseID()
end

-- ---------------------------------------------------------------------------
-- Event wiring (passive capture for manual editor use)
-- ---------------------------------------------------------------------------
local eventFrame = nil

local function _onModeChanged()
    -- During an explicit CaptureAllFloors sweep, _stepSweep owns the capture
    -- lifecycle — ignore passive mode-change finalize/begin so the forced
    -- Decorate→Layout toggle doesn't race with pin emission.
    if activeSweep then return end

    local active = C_HouseEditor and C_HouseEditor.IsHouseEditorActive() or false
    local mode   = (C_HouseEditor and C_HouseEditor.GetActiveHouseEditorMode()) or 0

    if active and mode == _layoutMode() then
        if not M.IsCapturing() then
            local floor = (C_HousingLayout and C_HousingLayout.GetViewedFloor()) or 1
            M.BeginCapture(floor)
        end
    else
        if M.IsCapturing() then
            local snap = M.EndCapture()
            local houseID = _currentHouseID()
            if snap and houseID then M.Finalize(snap, houseID) end
        end
    end
end

local function _onFloorChanged()
    if activeSweep then return end
    if not M.IsCapturing() then return end

    local snap = M.EndCapture()
    local houseID = _currentHouseID()
    if snap and houseID then M.Finalize(snap, houseID) end

    local floor = (C_HousingLayout and C_HousingLayout.GetViewedFloor()) or 1
    M.BeginCapture(floor)
end

function M.RegisterEvents()
    if eventFrame then return end
    eventFrame = CreateFrame("Frame")
    eventFrame:RegisterEvent("HOUSE_EDITOR_MODE_CHANGED")
    eventFrame:RegisterEvent("HOUSING_LAYOUT_PIN_FRAME_ADDED")
    eventFrame:RegisterEvent("HOUSING_LAYOUT_VIEWED_FLOOR_CHANGED")
    eventFrame:SetScript("OnEvent", function(_, event, arg1)
        if event == "HOUSE_EDITOR_MODE_CHANGED" then
            _onModeChanged()
        elseif event == "HOUSING_LAYOUT_PIN_FRAME_ADDED" then
            M.OnPinFrameAdded(arg1)
        elseif event == "HOUSING_LAYOUT_VIEWED_FLOOR_CHANGED" then
            _onFloorChanged()
        end
    end)
end

-- ---------------------------------------------------------------------------
-- Active "Capture all floors" sweep
-- ---------------------------------------------------------------------------
local function _stepSweep()
    if not activeSweep or activeSweep.cancelled then return end
    local nextFloor = activeSweep.floor + 1

    if nextFloor > activeSweep.maxFloor then
        if M.IsCapturing() then
            local snap = M.EndCapture()
            local houseID = _currentHouseID()
            if snap and houseID then M.Finalize(snap, houseID) end
            if snap then
                local n = 0; for _ in pairs(snap.rooms) do n = n + 1 end
                activeSweep.totalRooms = (activeSweep.totalRooms or 0) + n
            end
        end

        if C_HouseEditor and C_HouseEditor.LeaveHouseEditor then
            C_HouseEditor.LeaveHouseEditor()
        end
        local toastMsg = string.format("Captured %d floor%s, %d rooms",
            activeSweep.maxFloor,
            activeSweep.maxFloor == 1 and "" or "s",
            activeSweep.totalRooms or 0)
        if UIErrorsFrame and UIErrorsFrame.AddMessage then
            UIErrorsFrame:AddMessage(toastMsg, 0.0, 1.0, 0.0)
        end
        if DEFAULT_CHAT_FRAME and DEFAULT_CHAT_FRAME.AddMessage then
            DEFAULT_CHAT_FRAME:AddMessage("|cff66ff66[HDG]|r " .. toastMsg)
        end
        local bus = _EventBus()
        if bus and bus.Trigger then
            bus:Trigger("HDG_PROJECTS_CAPTURE_COMPLETE", {
                houseID = activeSweep.houseID,
                floors  = activeSweep.maxFloor,
                rooms   = activeSweep.totalRooms,
            })
        end
        activeSweep = nil
        return
    end

    activeSweep.floor = nextFloor
    if M.IsCapturing() then
        local snap = M.EndCapture()
        local houseID = _currentHouseID()
        if snap and houseID then M.Finalize(snap, houseID) end
        if snap then
            local n = 0; for _ in pairs(snap.rooms) do n = n + 1 end
            activeSweep.totalRooms = (activeSweep.totalRooms or 0) + n
        end
    end
    -- Capture state ready before SetViewedFloor (Blizzard may fire pin
    -- events sync). SetViewedFloor takes a 0-INDEXED floor — Blizzard's
    -- HouseEditorLayoutFloorLineMixin passes self.floorIndex with the
    -- comment "Floor indices are 0-based". Our internal floor is
    -- 1-indexed for display, so subtract 1 when crossing to the API.
    -- Passing 1-indexed silently no-ops (asks for non-existent floor and
    -- editor renders blank).
    M.BeginCapture(nextFloor)
    if C_HousingLayout and C_HousingLayout.SetViewedFloor then
        C_HousingLayout.SetViewedFloor(nextFloor - 1)
    end
    if C_Timer and C_Timer.After then
        C_Timer.After(activeSweep.settleSeconds or 0.6, _stepSweep)
    end
end

-- Public entry: full sweep with forced mode-toggle so pins always re-emit.
-- Sequence:
--   1. End any in-progress passive capture.
--   2. Enter editor + switch to Decorate mode (forces a mode-change event).
--   3. After 0.1s: switch to Layout mode (re-emits pin frames), BeginCapture.
--   4. After settleSeconds: _stepSweep finalizes (or advances to next floor).
function M.CaptureAllFloors()
    if activeSweep then return false, "already in progress" end
    if not C_HouseEditor or not C_HouseEditor.IsHouseEditorStatusAvailable() then
        return false, "house editor not available — visit your house first"
    end
    local houseID = _currentHouseID()
    if not houseID then return false, "could not determine faction" end

    if M.IsCapturing() then M.EndCapture() end

    if C_HouseEditor.EnterHouseEditor then C_HouseEditor.EnterHouseEditor() end
    if C_HouseEditor.ActivateHouseEditorMode then
        C_HouseEditor.ActivateHouseEditorMode(_decorateMode())
    end

    local maxFloor = (C_HousingLayout and C_HousingLayout.GetNumFloors()) or 1
    activeSweep = {
        houseID       = houseID,
        floor         = 1,
        maxFloor      = maxFloor,
        settleSeconds = 1.5,   -- enough for ~30 pin frames per floor to emit
        totalRooms    = 0,
        cancelled     = false,
    }

    if C_Timer and C_Timer.After then
        C_Timer.After(0.1, function()
            if not activeSweep or activeSweep.cancelled then return end
            if C_HouseEditor.ActivateHouseEditorMode then
                C_HouseEditor.ActivateHouseEditorMode(_layoutMode())
            end
            if C_HousingLayout and C_HousingLayout.SetViewedFloor then
                C_HousingLayout.SetViewedFloor(0)   -- 0-indexed F1
            end
            -- _onModeChanged may BeginCapture from the Layout-mode event; if
            -- not, do it explicitly so we never miss the first floor's pins.
            if not M.IsCapturing() then M.BeginCapture(1) end
            C_Timer.After(activeSweep.settleSeconds or 0.6, _stepSweep)
        end)
    end
    return true
end

function M.IsSweeping() return activeSweep ~= nil end
function M.CancelSweep() if activeSweep then activeSweep.cancelled = true end end

privateDB.HDG_TopologyCapture = M
