--[[--
Widget class.

@classmod Widget
--]]--

local STRICT = false
local ROOT = (...):gsub('[^.]*$', '')

local Backend = require(ROOT .. 'backend')
local Event = require(ROOT .. 'event')
local Attribute = require(ROOT .. 'attribute')
local Painter = require(ROOT .. 'painter')
local Font = Backend.Font

local Widget = {}

Event.injectBinders(Widget)

--[[--
API Properties

These properties may be useful when creating user interfaces,
and are a formal part of the API.

@section api
--]]--

--[[--
Whether this widget has keyboard focus.

Can be used by styles and themes. This value is automatically set by
the `Input` class, and should generally be treated as read-only.
--]]--
Widget.focused = false

--[[--
Whether the pointer is within this widget.

Can be used by styles and themes. This value is automatically set by
the `Input` class, and should generally be treated as read-only.
--]]--
Widget.hovered = false

--[[--
Table of mouse buttons pressed on this widget and not yet released,
keyed by mouse button name with booleans as values.

Can be used by styles and themes. Values are automatically set by
the `Input` class, and should generally be treated as read-only.
--]]--
Widget.pressed = nil

--[[--
Internal Properties

These properties are used internally, but are not likely to be useful
when creating user interfaces; they are not a formal part of the API
and may change at any time.

@section internal
--]]--

--[[--
Identifies this object as a widget.

Can be used to determine whether an unknown object is a widget.
--]]--
Widget.isWidget = true

--[[--
Whether the widget is currently being reshaped.

Used internally by `reshape` to prevent stack overflows when handling
`Reshape` events.
--]]--
Widget.isReshaping = false

--[[--
Whether this widget has a type.

Used by the @{attribute.type|type} attribute to determine whether to
run the type initializer when the widget's type is set. After a type
initializer has run, `hasType` becomes `true` and no other type
initializers should run on the widget.
--]]--
Widget.hasType = false

--[[--
The `Font` object associated with the widget.
--]]--
Widget.fontData = nil

--[[--
The `Text` object associated with the widget.
--]]--
Widget.textData = nil


--[[--
@section end
--]]--

Widget.typeDecorators = {
    button = require(ROOT .. 'widget.button'),
    check = require(ROOT .. 'widget.check'),
    menu = require(ROOT .. 'widget.menu'),
    ['menu.item'] = require(ROOT .. 'widget.menu.item'),
    progress = require(ROOT .. 'widget.progress'),
    radio = require(ROOT .. 'widget.radio'),
    sash = require(ROOT .. 'widget.sash'),
    slider = require(ROOT .. 'widget.slider'),
    status = require(ROOT .. 'widget.status'),
    stepper = require(ROOT .. 'widget.stepper'),
    text = require(ROOT .. 'widget.text'),
    window = require(ROOT .. 'widget.window'),
}

--[[--
Static Functions

@section static
--]]--

--[[--
Register a custom widget type.

@static

@tparam string name
A unique name for this type of widget.

@tparam function(Widget) decorator
An initialization function for this type of widget.
--]]--
function Widget.register (name, decorator)
    Widget.typeDecorators[name] = decorator
end

--[[--
@section end
--]]--

-- look for properties in attributes, Widget, style, and theme
local function metaIndex (self, property)
    -- look in widget's own attributes
    local A = self.attributeDescriptors[property] or Attribute[property]
    if A then
        local value = A.get and A.get(self, property)
            or self.attributes[property]
        if type(value) == 'function' then value = value(self) end
        if value ~= nil then return value end
    end

    -- look in Widget class properties
    local value = Widget[property]
    if value ~= nil then return value end

    -- look in style
    local layout = self.layout
    value = layout:getStyle():getProperty(self, property)
    if value ~= nil then return value end

    -- look in theme
    return layout:getTheme():getProperty(self, property)
end

-- setting attributes triggers special behavior
local function metaNewIndex (self, property, value)
    local A = self.attributeDescriptors[property] or Attribute[property]
    if A then
        if A.set then
            A.set(self, value, property)
        else
            self.attributes[property] = value
        end
    else
        if STRICT and Widget[property] == nil then
            error(property .. ' is not a valid widget property.')
        else
            rawset(self, property, value)
        end
    end
end

local attributeNames = {}

for name in pairs(Attribute) do
    if name ~= 'type' then -- type must be handled last
        attributeNames[#attributeNames + 1] = name
    end
end

attributeNames[#attributeNames + 1] = 'type'

--[[--
Widget pseudo-constructor.

@function Luigi.Widget

@within Constructor

@tparam Layout layout
The layout this widget belongs to.

@tparam[opt] table data
The data definition table for this widget.
This table is identical to the constructed widget.

@treturn Widget
A Widget instance.
--]]--
local function metaCall (Widget, layout, self)
    self = self or {}
    self.layout = layout
    self.position = { x = nil, y = nil }
    self.dimensions = { width = nil, height = nil }
    self.attributes = {}
    self.attributeDescriptors = {}
    self.pressed = {}
    self.painter = Painter(self)

    setmetatable(self, { __index = metaIndex, __newindex = metaNewIndex })

    for _, property in ipairs(attributeNames) do
        local value = rawget(self, property)
        rawset(self, property, nil)
        self[property] = value
    end

    for k, v in ipairs(self) do
        self[k] = v.isWidget and v or metaCall(Widget, self.layout, v)
        self[k].parent = self
    end

    return self
end

function Widget:getMasterLayout ()
    return self.layout.master or self.layout
end

--[[--
Define a custom attribute for this widget.

When an attribute is defined, the current value is stored locally and
removed from the widget's own properties and its attributes collection.
Then, the newly-defined setter is called with the stored value.

@tparam string name
The name of the attribute.

@tparam table descriptor
A table, optionally containing `get` and `set` functions (see `Attribute`).

@treturn Widget
Return this widget for chaining.
--]]--
function Widget:defineAttribute (name, descriptor)
    local value = rawget(self, name)
    if value == nil then value = self.attributes[name] end
    self.attributeDescriptors[name] = descriptor or {}
    rawset(self, name, nil)
    self.attributes[name] = nil
    self[name] = value
    return self
end

--[[--
Fire an event on this widget and each ancestor.

If any event handler returns non-nil, stop the event from propagating.

@tparam string eventName
The name of the Event.

@tparam[opt] table data
Information about the event to send to handlers.

@treturn mixed
The first value returned by an event handler.
--]]--
function Widget:bubbleEvent (eventName, data)
    local event = Event[eventName]
    data = data or {}
    data.target = self
    for ancestor in self:eachAncestor(true) do
        local result = event:emit(ancestor, data)
        if result ~= nil then return result end
    end
    return event:emit(self.layout, data)
end

--[[--
Get widget's previous sibling.

@treturn Widget|nil
The widget's previous sibling, if any.
--]]--
function Widget:getPreviousSibling ()
    local parent = self.parent
    if not parent then return end
    for i, widget in ipairs(parent) do
        if widget == self then return parent[i - 1] end
    end
end

--[[--
Get widget's next sibling.

@treturn Widget|nil
The widget's next sibling, if any.
--]]--
function Widget:getNextSibling ()
    local parent = self.parent
    if not parent then return end
    for i, widget in ipairs(parent) do
        if widget == self then return parent[i + 1] end
    end
end

--[[--
Attempt to focus the widget.

Unfocus currently focused widget, and focus this widget if it's focusable.

@treturn boolean
true if this widget was focused, else false.
--]]--
function Widget:focus ()
    local layout = self.layout

    if layout.focusedWidget == self then
        return true
    end

    if layout.focusedWidget then
        layout.focusedWidget.focused = nil
        Event.Blur:emit(self.layout, layout.focusedWidget)
        layout.focusedWidget = nil
    end

    if self.focusable then
        self.focused = true
        layout.focusedWidget = self
        Event.Focus:emit(self.layout, self)
        return true
    end

    return false
end

--[[--
Get the next widget, depth-first.

If the widget has children, returns the first child.
Otherwise, returns the next sibling of the nearest possible ancestor.
Cycles back around to the layout root from the last widget in the tree.

@treturn Widget
The next widget in the tree.
--]]--
function Widget:getNextNeighbor ()
    if #self > 0 then
        return self[1]
    end
    for ancestor in self:eachAncestor(true) do
        local nextWidget = ancestor:getNextSibling()
        if nextWidget then return nextWidget end
    end
    return self.layout.root
end

-- get the last child of the last child of the last child of the...
local function getGreatestDescendant (widget)
    while #widget > 0 do
        widget = widget[#widget]
    end
    return widget
end

--[[--
Get the previous widget, depth-first.

Uses the reverse of the traversal order used by `getNextNeighbor`.
Cycles back around to the last widget in the tree from the layout root.

@treturn Widget
The previous widget in the tree.
--]]--
function Widget:getPreviousNeighbor ()
    local layout = self.layout

    if self == layout.root then
        return getGreatestDescendant(self)
    end

    for ancestor in self:eachAncestor(true) do
        local previousWidget = ancestor:getPreviousSibling()
        if previousWidget then
            return getGreatestDescendant(previousWidget)
        end
        if ancestor ~= self then return ancestor end
    end

    return layout.root
end

--[[--
Add a child to this widget.

@tparam Widget|table data
A widget or definition table representing a widget.

@treturn Widget
The newly added child widget.
--]]--
function Widget:addChild (data)
    local layout = self.layout
    local child = data and data.isWidget and data or Widget(layout, data or {})

    self[#self + 1] = child
    child.parent = self
    child.layout = self.layout

    return child
end

function Widget:calculateDimension (name)
    -- If dimensions are already calculated, return them.
    if self.dimensions[name] then
        return self.dimensions[name]
    end

    -- Get minimum width/height from attributes.
    local min = (name == 'width') and (self.minwidth or 0)
        or (self.minheight or 0)

    -- If width/height attribute is found (in widget, style or theme)
    if self[name] then
        -- and if width/height is "auto" then shrink to fit content
        if self[name] == 'auto' then
            self.dimensions[name] = self:calculateDimensionMinimum(name)
            return self.dimensions[name]
        end
        -- else width/height should be a number; use that value,
        -- clamped to minimum.
        self.dimensions[name] = math.max(self[name], min)
        return self.dimensions[name]
    end

    -- If the widget is a layout root (and has no width/height),
    -- it's the same size as the window.
    local parent = self.parent
    if not parent then
        local windowWidth, windowHeight = Backend.getWindowSize()
        local size = name == 'width' and windowWidth or windowHeight
        self.dimensions[name] = size
        return self.dimensions[name]
    end

    -- Widgets expand to fit their parents when no width/height is specified.
    local parentDimension = parent:calculateDimension(name)
    parentDimension = parentDimension - (parent.margin or 0) * 2
    parentDimension = parentDimension - (parent.padding or 0) * 2

    -- If the dimension is in the opposite direction of the parent flow
    -- (for example if parent.flow is 'x' and the dimension is 'height'),
    -- then return the parent dimension.
    local parentFlow = parent.flow or 'y'
    if (parentFlow ~= 'x' and name == 'width')
    or (parentFlow == 'x' and name == 'height') then
        self.dimensions[name] = math.max(parentDimension, min)
        return self.dimensions[name]
    end

    -- If the dimension is in the same direction as the parent flow
    -- (for example if parent.flow is 'x' and the dimension is 'width'),
    -- then return an equal portion of the unclaimed space in the parent.
    local claimed = 0
    local unsized = 1
    for i, widget in ipairs(self.parent) do
        if widget ~= self then
            local value = widget[name]
            if value == 'auto' then
                if not widget.dimensions[name] then
                    widget.dimensions[name] = widget:calculateDimensionMinimum(name)
                end
                claimed = claimed + widget.dimensions[name]
            elseif value then
                local min = (name == 'width') and (widget.minwidth or 0)
                    or (widget.minheight or 0)
                claimed = claimed + math.max(value, min)
            else
                unsized = unsized + 1
            end
        end
    end
    local size = (parentDimension - claimed) / unsized

    size = math.max(size, min)
    self.dimensions[name] = size
    return size
end

function Widget:calculateRootPosition (axis)
    local value = (axis == 'x' and self.left) or (axis ~= 'x' and self.top)

    if value then
        self.position[axis] = value
        return value
    end

    local ww, wh = Backend.getWindowSize()

    if axis == 'x' and type(self.width) == 'number' then
        value = (ww - self.width) / 2
    elseif axis ~= 'x' and type(self.height) == 'number' then
        value = (wh - self.height) / 2
    else
        value = 0
    end

    self.position[axis] = value
    return value
end

function Widget:calculatePosition (axis)
    if self.position[axis] then
        return self.position[axis]
    end
    local parent = self.parent
    local scroll = 0
    if not parent then
        return self:calculateRootPosition(axis)
    else
        scroll = axis == 'x' and (parent.scrollX or 0)
            or axis ~= 'x' and (parent.scrollY or 0)
    end
    local parentPos = parent:calculatePosition(axis)
    local p = parentPos - scroll + (parent.margin or 0) + (parent.padding or 0)
    local parentFlow = parent.flow or 'y'
    for i, widget in ipairs(parent) do
        if widget == self then
            self.position[axis] = p
            return p
        end
        if parentFlow == axis then
            local dimension = (axis == 'x') and 'width' or 'height'
            p = p + widget:calculateDimension(dimension)
        end
    end
    self.position[axis] = 0
    return 0
end

function Widget:calculateDimensionMinimum (name)
    local dim = self[name]
    local min = (name == 'width') and (self.minwidth or 0)
        or (self.minheight or 0)

    if type(dim) == 'number' then
        return math.max(dim, min)
    end

    local value = 0

    for _, child in ipairs(self) do
        if (name == 'width' and self.flow == 'x')
        or (name == 'height' and self.flow ~= 'x') then
            value = value + child:calculateDimensionMinimum(name)
        else
            value = math.max(value, child:calculateDimensionMinimum(name))
        end
    end

    if value > 0 then
        local space = (self.margin or 0) * 2 + (self.padding or 0) * 2
        value = value + space
    end

    return math.max(value, min)
end

--[[--
Get the widget's X coordinate.

@treturn number
The widget's X coordinate.
--]]--
function Widget:getX ()
    return self:calculatePosition('x')
end

--[[--
Get the widget's Y coordinate.

@treturn number
The widget's Y coordinate.
--]]--
function Widget:getY ()
    return self:calculatePosition('y')
end

--[[--
Get the widget's calculated width.

@treturn number
The widget's calculated width.
--]]--
function Widget:getWidth ()
    return self:calculateDimension('width')
end

--[[--
Get the widget's calculated height.

@treturn number
The widget's calculated height.
--]]--
function Widget:getHeight ()
    return self:calculateDimension('height')
end

--[[--
Get the content width.

Gets the combined width of the widget's children.

@treturn number
The content width.
--]]--
function Widget:getContentWidth ()
    if not self.layout.isReady then return 0 end
    local width = 0
    if self.flow == 'x' then
        for _, child in ipairs(self) do
            width = width + child:getWidth()
        end
    else
        for _, child in ipairs(self) do
            width = math.max(width, child:getWidth())
        end
    end
    return width
end

--[[--
Get the content height.

Gets the combined height of the widget's children.

@treturn number
The content height.
--]]--
function Widget:getContentHeight ()
    if not self.layout.isReady then return 0 end
    local height = 0
    if self.flow ~= 'x' then
        for _, child in ipairs(self) do
            height = height + child:getHeight()
        end
    else
        for _, child in ipairs(self) do
            height = math.max(height, child:getHeight())
        end
    end
    return height
end

function Widget:getFont ()
    if not self.fontData then
        self.fontData = Font(self.font, self.size)
    end
    return self.fontData
end

--[[--
Get x/y/width/height values describing a rectangle within the widget.

@tparam boolean useMargin
Whether to adjust the rectangle based on the widget's margin.

@tparam boolean usePadding
Whether to adjust the rectangle based on the widget's padding.

@treturn number
The upper left corner's X position.

@treturn number
The upper left corner's Y position.

@treturn number
The rectangle's width

@treturn number
The rectangle's height
--]]--
function Widget:getRectangle (useMargin, usePadding)
    local x, y = self:getX(), self:getY()
    local w, h = self:getWidth(), self:getHeight()
    local function shrink(amount)
        x = x + amount
        y = y + amount
        w = w - amount * 2
        h = h - amount * 2
    end
    if useMargin then
        shrink(self.margin or 0)
    end
    if usePadding then
        shrink(self.padding or 0)
    end
    return math.floor(x), math.floor(y), math.floor(w), math.floor(h)
end

--[[--
Determine whether a point is within a widget.

@tparam number x
The point's X coordinate.

@tparam number y
The point's Y coordinate.

@treturn boolean
true if the point is within the widget, else false.
--]]--
function Widget:isAt (x, y)
    local x1, y1, w, h = self:getRectangle()
    local x2, y2 = x1 + w, y1 + h
    return (x1 <= x) and (x2 >= x) and (y1 <= y) and (y2 >= y)
end

--[[--
Iterate widget's ancestors.

@tparam boolean includeSelf
Whether to include this widget as the first result.

@treturn function
Returns an iterator function that returns widgets.

@usage
for ancestor in myWidget:eachAncestor(true) do
    print(widget.type or 'generic')
end
--]]--
function Widget:eachAncestor (includeSelf)
    local instance = includeSelf and self or self.parent
    return function()
        local widget = instance
        if not widget then return end
        instance = widget.parent
        return widget
    end
end

function Widget:paint ()
    return self.painter:paint()
end

--[[--
Reshape the widget.

Clears calculated widget dimensions, allowing them to be recalculated, and
fires a Reshape event (does not bubble). Called recursively for each child.

When setting a widget's width or height, this function is automatically called
on the parent widget.
--]]--
function Widget:reshape ()
    if self.isReshaping then return end
    self.isReshaping = true

    self:scrollBy(0, 0)

    self.position = {}
    self.dimensions = {}

    self.textData = nil

    Event.Reshape:emit(self, { target = self })
    for _, child in ipairs(self) do
        if child.reshape then
            child:reshape()
        end
    end
    local items = self.items
    if items then
        for _, child in ipairs(items) do
            if child.reshape then
                child:reshape()
            end
        end
    end
    self.isReshaping = nil
end

function Widget:scrollBy (amount)
    if not self.scroll then return end
    --TODO: eliminate redundancy
    if self.flow == 'x' then
        if not self.scrollX then self.scrollX = 0 end
        local scrollX = self.scrollX - amount * 10
        local inner = math.max(self:getContentWidth(), self.innerWidth or 0)
        local maxX = inner - self:getWidth()
            + (self.padding or 0) * 2 + (self.margin or 0) * 2
        scrollX = math.max(math.min(scrollX, maxX), 0)
        if scrollX ~= self.scrollX then
            self.scrollX = scrollX
            self:reshape()
            return true
        end
    else
        if not self.scrollY then self.scrollY = 0 end
        local scrollY = self.scrollY - amount * 10
        local inner = math.max(self:getContentHeight(), self.innerHeight or 0)
        local maxY = inner - self:getHeight()
            + (self.padding or 0) * 2 + (self.margin or 0) * 2
        scrollY = math.max(math.min(scrollY, maxY), 0)
        if scrollY ~= self.scrollY then
            self.scrollY = scrollY
            self:reshape()
            return true
        end
    end
end

return setmetatable(Widget, { __call = metaCall })
