--[[
    UI/ModelViewer.lua

    Shows a creature model, with drag to rotate, wheel to zoom and a reset.

    Built on `CreateFrame("PlayerModel")` and `SetDisplayInfo`, both confirmed
    in use on this client. `SetDisplayInfo` takes a BLIZZARD CREATURE DISPLAY
    ID, never a creature id and never our own appearance id -- see
    docs/database-schema.md for why those three are kept apart.

    An appearance with no verified display id shows a plain message instead of
    an empty 3D frame. The model frame is hidden rather than cleared: the
    `ClearModel` method is not in use by any addon on this client, so it stays
    unverified and unused.

    On the OnUpdate rule
    --------------------
    The project forbids OnUpdate except where it is unavoidable. Drag rotation
    is that case: it has to sample the cursor between mouse events. The script
    is installed on mouse down and removed on mouse up, so it runs only while
    the button is actually held and never in the background.
]]

local _, ns = ...

local ModelViewer = ns.NewModule("ModelViewer")
ns.UI = ns.UI or {}
ns.UI.ModelViewer = ModelViewer

local DEFAULT_ROTATION = 0.6          -- radians, a three-quarter view
local DEFAULT_ZOOM = 1.0

-- SetCamDistanceScale pushes the camera away as the value grows, so zooming in
-- means a smaller number.
local ZOOM_MIN, ZOOM_MAX = 0.4, 4.0
local ZOOM_STEP = 0.15

local ROTATION_PER_PIXEL = 0.012

local RESET_BUTTON_WIDTH, RESET_BUTTON_HEIGHT = 90, 20
local Theme = ns.UI.Theme

-- Used to end a drag whose mouse-up landed outside the frame. Captured rather
-- than called blind: if it is ever absent, the OnMouseUp and OnHide handlers
-- still stop the drag.
local IsMouseButtonDown = IsMouseButtonDown

--=============================================================================
-- Construction
--=============================================================================

--- Builds a model viewer filling its parent.
-- @param parent Frame
-- @return table A viewer handle with SetDisplayID / ResetView.
function ModelViewer:Create(parent)
    local viewer = {
        -- Not nil: the initial SetDisplayID(nil) below must not be swallowed
        -- by the "same display, nothing to do" guard.
        displayID = false,
        rotation = DEFAULT_ROTATION,
        zoom = DEFAULT_ZOOM,
    }

    local container = CreateFrame("Frame", nil, parent)
    container:SetAllPoints(parent)
    viewer.container = container

    local model = CreateFrame("PlayerModel", nil, container)
    model:SetAllPoints(container)
    model:Hide()
    viewer.model = model

    local undiscoveredOverlay = CreateFrame("Frame", nil, container)
    undiscoveredOverlay:SetAllPoints(container)
    undiscoveredOverlay:SetFrameLevel(model:GetFrameLevel() + 1)
    undiscoveredOverlay:Hide()

    local undiscoveredTint = undiscoveredOverlay:CreateTexture(nil, "BACKGROUND")
    undiscoveredTint:SetAllPoints(undiscoveredOverlay)
    undiscoveredTint:SetColorTexture(0.12, 0.12, 0.12, 0.48)

    local undiscoveredLabel = undiscoveredOverlay:CreateFontString(
        nil, "ARTWORK", "GameFontNormalLarge")
    undiscoveredLabel:SetPoint("TOP", undiscoveredOverlay, "TOP", 0, -14)
    undiscoveredLabel:SetText(ns.L["DETAIL_UNDISCOVERED"])
    undiscoveredLabel:SetTextColor(0.72, 0.72, 0.72)

    local placeholder = container:CreateFontString(nil, "ARTWORK", "GameFontHighlightSmall")
    placeholder:SetPoint("CENTER", container, "CENTER", 0, 0)
    placeholder:SetWidth(260)
    placeholder:SetJustifyH("CENTER")
    placeholder:SetTextColor(0.5, 0.5, 0.5)
    viewer.placeholder = placeholder

    local hint = container:CreateFontString(nil, "ARTWORK", "GameFontHighlightSmall")
    hint:SetPoint("BOTTOMLEFT", container, "BOTTOMLEFT", 2, 2)
    hint:SetTextColor(0.45, 0.45, 0.45)
    hint:SetText(ns.L["MODEL_HINT"])
    viewer.hint = hint

    local resetButton = CreateFrame("Button", nil, container, "BackdropTemplate")
    resetButton:SetSize(RESET_BUTTON_WIDTH, RESET_BUTTON_HEIGHT)
    resetButton:SetFrameLevel(undiscoveredOverlay:GetFrameLevel() + 1)
    resetButton:SetNormalFontObject(GameFontNormalSmall)
    resetButton:SetPoint("BOTTOMRIGHT", container, "BOTTOMRIGHT", -2, 2)
    resetButton:SetText(ns.L["BUTTON_RESET_VIEW"])
    Theme:ApplyButton(resetButton, false)
    resetButton:SetScript("OnClick", function()
        viewer:ResetView()
    end)
    viewer.resetButton = resetButton

    --=========================================================================
    -- View
    --=========================================================================

    local function ApplyView()
        model:SetRotation(viewer.rotation)
        model:SetCamDistanceScale(viewer.zoom)
        model:SetPosition(0, 0, 0)
    end

    --- Returns the model to its default angle and distance.
    function viewer:ResetView()
        self.rotation = DEFAULT_ROTATION
        self.zoom = DEFAULT_ZOOM

        if self.displayID then
            ApplyView()
        end
    end

    --- Shows a creature model, or the placeholder when there is none.
    -- @param displayID number|nil A Blizzard creature display id.
    function viewer:SetDisplayID(displayID)
        if displayID == self.displayID then
            return
        end

        self.displayID = displayID

        if not displayID then
            model:Hide()
            undiscoveredOverlay:Hide()
            placeholder:SetText(ns.L["DETAIL_NO_MODEL"])
            placeholder:Show()
            hint:Hide()
            resetButton:Hide()
            return
        end

        placeholder:Hide()
        hint:Show()
        resetButton:Show()

        model:Show()
        model:SetDisplayInfo(displayID)
        model:SetAnimation(0)

        self.rotation = DEFAULT_ROTATION
        self.zoom = DEFAULT_ZOOM
        ApplyView()

        ns.Debug:Log("UI", "model set to display id %d", displayID)
    end

    --- Collected models are shown at full colour; missing entries read like a
    --- Bestiary silhouette without hiding the useful shape of the creature.
    function viewer:SetDiscovered(discovered)
        local desaturated = false
        if model.SetDesaturation then
            desaturated = pcall(model.SetDesaturation, model, discovered and 0 or 1)
        end
        if desaturated then
            model:SetAlpha(1)
        else
            model:SetAlpha(discovered and 1 or 0.52)
        end
        undiscoveredOverlay:SetShown(not discovered and self.displayID and true or false)
    end

    --=========================================================================
    -- Interaction
    --=========================================================================

    local dragStartX, dragStartRotation

    local function StopDragging(self)
        self:SetScript("OnUpdate", nil)
    end

    local function OnDragUpdate(self)
        -- A mouse-up outside the frame never reaches OnMouseUp, which would
        -- leave this running every frame with the button already released.
        if IsMouseButtonDown and not IsMouseButtonDown("LeftButton") then
            StopDragging(self)
            return
        end

        local cursorX = GetCursorPosition() / container:GetEffectiveScale()
        viewer.rotation = dragStartRotation + (cursorX - dragStartX) * ROTATION_PER_PIXEL
        model:SetRotation(viewer.rotation)
    end

    model:EnableMouse(true)
    model:EnableMouseWheel(true)

    model:SetScript("OnMouseDown", function(self, button)
        if button ~= "LeftButton" or not viewer.displayID then
            return
        end

        dragStartX = GetCursorPosition() / container:GetEffectiveScale()
        dragStartRotation = viewer.rotation
        self:SetScript("OnUpdate", OnDragUpdate)
    end)

    model:SetScript("OnMouseUp", StopDragging)

    -- Releasing the button outside the frame must also stop the drag, or the
    -- OnUpdate would keep running with the button already up.
    model:SetScript("OnHide", StopDragging)

    model:SetScript("OnMouseWheel", function(_, delta)
        if not viewer.displayID then
            return
        end

        viewer.zoom = math.max(ZOOM_MIN, math.min(ZOOM_MAX, viewer.zoom - delta * ZOOM_STEP))
        model:SetCamDistanceScale(viewer.zoom)
    end)

    viewer:SetDisplayID(nil)

    return viewer
end
