--[[--
ScrollableContainer allows scrolling its content (1 widget) within its own dimensions

This scrollable container needs to be known as widget.cropping_widget in
the widget using it that is passed to UIManager:show() for UIManager to
ensure proper interception of inner widget self-repainting/invert (mostly
used when flashing for UI feedback that we want to limit to the cropped
area).
If we notice some inner element flashing leaking outside the scrollable
area, it's probably some 'show_parent' forwarding missing from the main
widget to some of the inner widgets: chase the missing ones and add them.
--]]

local BD = require("ui/bidi")
local Blitbuffer = require("ffi/blitbuffer")
local Device = require("device")
local Geom = require("ui/geometry")
local GestureRange = require("ui/gesturerange")
local HorizontalScrollBar = require("ui/widget/horizontalscrollbar")
local InputContainer = require("ui/widget/container/inputcontainer")
local Math = require("optmath")
local UIManager = require("ui/uimanager")
local VerticalScrollBar = require("ui/widget/verticalscrollbar")
local Screen = Device.screen
local logger = require("logger")

local ScrollableContainer = InputContainer:extend{
    -- Events to ignore (ie: ignore_events={"hold", "hold_release"})
    ignore_events = nil,
    scroll_bar_width = Screen:scaleBySize(6),

    -- Set to true if child widget is larger, false otherwise
    _is_scrollable = nil,
    -- Current scroll offset (use getScrolledOffset()/setScrolledOffset() to access them)
    _scroll_offset_x = 0,
    _scroll_offset_y = 0,
    _max_scroll_offset_x = 0,
    _max_scroll_offset_y = 0,
    -- Internal state between events
    _touch_pre_pan_was_inside = false,
    _scrolling = false,
    _scroll_relative_x = nil,
    _scroll_relative_y = nil,
    -- Scrollbar widgets, created as needed
    _v_scroll_bar = nil,
    _h_scroll_bar = nil,
    -- Scratch buffer
    _bb = nil,
    _crop_w = nil,
    _crop_h = nil,
    _crop_dx = 0,
}

function ScrollableContainer:getScrollbarWidth(scroll_bar_width)
    -- Return the width taken by the (default) scroll bar and its paddings
    if not scroll_bar_width then
        scroll_bar_width = self.scroll_bar_width
    end
    return 3 * scroll_bar_width
end

function ScrollableContainer:init()
    if Device:isTouchDevice() then
        local range = Geom:new{
            x = 0, y = 0,
            w = Screen:getWidth(),
            h = Screen:getHeight(),
        }
        -- Unflatten self.ignore_events to table keys for cleaner code below
        local ignore = {}
        if self.ignore_events then
            for _, evname in pairs(self.ignore_events) do
                ignore[evname] = true
            end
        end
        -- The following gestures need to be supported, depending on the
        -- ways a user can move/scroll things:
        --   Hold happens if he holds at start
        --   Pan happens if he doesn't hold at start, but holds at end
        --   Swipe happens if he doesn't hold at any moment
        -- (Touch is needed for accurate pan)
        self.ges_events = {}
        self.ges_events.ScrollableTouch = not ignore.touch and { GestureRange:new{ ges = "touch", range = range } } or nil
        self.ges_events.ScrollableSwipe = not ignore.swipe and { GestureRange:new{ ges = "swipe", range = range } } or nil
        self.ges_events.ScrollableHold = not ignore.hold and { GestureRange:new{ ges = "hold", range = range } } or nil
        self.ges_events.ScrollableHoldPan = not ignore.hold_pan and { GestureRange:new{ ges = "hold_pan", range = range } } or nil
        self.ges_events.ScrollableHoldRelease = not ignore.hold_release and { GestureRange:new{ ges = "hold_release", range = range } } or nil
        self.ges_events.ScrollablePan = not ignore.pan and { GestureRange:new{ ges = "pan", range = range } } or nil
        self.ges_events.ScrollablePanRelease = not ignore.pan_release and { GestureRange:new{ ges = "pan_release", range = range } } or nil
    end
end

function ScrollableContainer:initState()
    local content_size = self[1]:getSize()
    self._max_scroll_offset_x = math.max(0, content_size.w - self.dimen.w)
    self._max_scroll_offset_y = math.max(0, content_size.h - self.dimen.h)
    if self._max_scroll_offset_x == 0 and self._max_scroll_offset_y == 0 then
        -- Inner widget fits entirely: no need for anything scrollable
        self._is_scrollable = false
    else
        self._is_scrollable = true
        self._crop_w = self.dimen.w
        self._crop_h = self.dimen.h
        if self._max_scroll_offset_y > 0 then
            -- Adding a vertical scrollbar reduces the available width: recompute
            self._max_scroll_offset_x = math.max(0, content_size.w - (self.dimen.w - 3*self.scroll_bar_width))
        end
        if self._max_scroll_offset_x > 0 then
            -- Adding a horizontal scrollbar reduces the available height: recompute
            self._max_scroll_offset_y = math.max(0, content_size.h - (self.dimen.h - 3*self.scroll_bar_width))
            if self._max_scroll_offset_y > 0 then
                -- And re-compute again if we have to now add a vertical scrollbar
                self._max_scroll_offset_x = math.max(0, content_size.w - (self.dimen.w - 3*self.scroll_bar_width))
            end
        end
        -- Scrollbars won't be classic sub-widgets, we'll handle their painting ourselves
        if self._max_scroll_offset_y > 0 then
            self._v_scroll_bar = VerticalScrollBar:new{
                width = self.scroll_bar_width,
                height = self.dimen.h,
                scroll_callback = function(ratio)
                    self:scrollToRatio(nil, ratio)
                end
            }
            self._crop_w = self.dimen.w - 3*self.scroll_bar_width
        end
        if self._max_scroll_offset_x > 0 then
            self._h_scroll_bar_shift = 0
            if self._v_scroll_bar then
                -- Reduce its width so to not overlap with the vertical scroll bar
                self._h_scroll_bar_shift = 3*self.scroll_bar_width
            end
            self._h_scroll_bar = HorizontalScrollBar:new{
                height = self.scroll_bar_width,
                width = self.dimen.w - self._h_scroll_bar_shift,
                scroll_callback = function(ratio)
                    self:scrollToRatio(ratio, nil)
                end
            }
            self._crop_h = self.dimen.h - 3*self.scroll_bar_width
        end
        if BD.mirroredUILayout() then
            if self._v_scroll_bar then
                self._crop_dx = self.dimen.w - self._crop_w
            end
        end
        self:_updateScrollBars()
    end
end

function ScrollableContainer:getCropRegion()
    return Geom:new{
        x = self.dimen.x + self._crop_dx,
        y = self.dimen.y,
        w = self._crop_w,
        h = self._crop_h,
    }
end

function ScrollableContainer:_updateScrollBars()
    if self._v_scroll_bar then
        local dheight = self._crop_h / (self._max_scroll_offset_y + self._crop_h)
        local low = self._scroll_offset_y / (self._max_scroll_offset_y + self._crop_h)
        local high = low + dheight
        self._v_scroll_bar:set(low, high)
    end
    if self._h_scroll_bar then
        local dwidth = self._crop_w / (self._max_scroll_offset_x + self._crop_w)
        local low = self._scroll_offset_x / (self._max_scroll_offset_x + self._crop_w)
        local high = low + dwidth
        self._h_scroll_bar:set(low, high)
    end
end

function ScrollableContainer:scrollToRatio(ratio_x, ratio_y)
    if ratio_y then
        local dy = ratio_y * (self._max_scroll_offset_y + self._crop_h)
        self._scroll_offset_y = dy - Math.round(self._crop_h/2)
        if self._scroll_offset_y < 0 then
            self._scroll_offset_y = 0
        end
        if self._scroll_offset_y > self._max_scroll_offset_y then
            self._scroll_offset_y = self._max_scroll_offset_y
        end
    end
    if ratio_x then
        local dx = ratio_x * (self._max_scroll_offset_x + self._crop_w)
        self._scroll_offset_x = dx - Math.round(self._crop_w/2)
        if self._scroll_offset_x < 0 then
            self._scroll_offset_x = 0
        end
        if self._scroll_offset_x > self._max_scroll_offset_x then
            self._scroll_offset_x = self._max_scroll_offset_x
        end
    end
    self:_scrollBy(0, 0) -- get the additional work done
end

function ScrollableContainer:_scrollBy(dx, dy)
    if BD.mirroredUILayout() then
        dx = -dx
    end
    self._scroll_offset_x = self._scroll_offset_x + Math.round(dx)
    self._scroll_offset_y = self._scroll_offset_y + Math.round(dy)
    if self._scroll_offset_x < 0 then
        self._scroll_offset_x = 0
    end
    if self._scroll_offset_y < 0 then
        self._scroll_offset_y = 0
    end
    if self._scroll_offset_x > self._max_scroll_offset_x then
        self._scroll_offset_x = self._max_scroll_offset_x
    end
    if self._scroll_offset_y > self._max_scroll_offset_y then
        self._scroll_offset_y = self._max_scroll_offset_y
    end
    self:_updateScrollBars()
    UIManager:setDirty(self.show_parent, function()
        return "ui", self.dimen
    end)
end

function ScrollableContainer:getScrolledOffset()
    return Geom:new{
        x = self._scroll_offset_x,
        y = self._scroll_offset_y,
    }
end

function ScrollableContainer:setScrolledOffset(offset_point)
    if offset_point and offset_point.x and offset_point.y then
        self._scroll_offset_x = offset_point.x
        self._scroll_offset_y = offset_point.y
    end
end

function ScrollableContainer:onCloseWidget()
    if self._bb then
        self._bb:free()
        self._bb = nil
    end
end

function ScrollableContainer:reset()
    if self._bb then
        self._bb:free()
        self._bb = nil
    end
    self._is_scrollable = nil
    self._scroll_offset_x = 0
    self._scroll_offset_y = 0
end

function ScrollableContainer:paintTo(bb, x, y)
    if self[1] == nil then
        return
    end
    self.dimen.x = x
    self.dimen.y = y

    if self._is_scrollable == nil then -- not checked yet
        self:initState()
    end

    local _mirroredUI = BD.mirroredUILayout()

    if not self._is_scrollable then
        -- nothing to scroll: pass-through
        if _mirroredUI then -- behave as LeftContainer
            x = x + (self.dimen.w - self[1]:getSize().w)
        end
        self[1]:paintTo(bb, x, y)
        return
    end

    local screen_size = Screen:getSize()
    -- Create/Recreate the compose cache if we changed screen geometry
    if not self._bb or self._bb:getWidth() ~= screen_size.w or self._bb:getHeight() ~= screen_size.h then
        if self._bb then
            self._bb:free()
        end
        -- create a canvas for our child widget to paint to
        self._bb = Blitbuffer.new(screen_size.w, screen_size.h, bb:getType())
    end

    -- We need to fill it with our usual background color on each drawing,
    -- to erase bits that may not be overwritten after a scroll
    self._bb:fill(Blitbuffer.COLOR_WHITE)
    local dx
    if _mirroredUI then
        dx = self._max_scroll_offset_x - self._scroll_offset_x - self._crop_dx
    else
        dx = self._scroll_offset_x
    end
    self[1]:paintTo(self._bb, x - dx, y - self._scroll_offset_y)
    bb:blitFrom(self._bb, x + self._crop_dx, y, x + self._crop_dx, y, self._crop_w, self._crop_h)

    -- Draw our scrollbars over
    if self._h_scroll_bar then
        if _mirroredUI then
            self._h_scroll_bar:paintTo(bb, x + self._h_scroll_bar_shift, y + self.dimen.h - 2*self.scroll_bar_width)
        else
            self._h_scroll_bar:paintTo(bb, x, y + self.dimen.h - 2*self.scroll_bar_width)
        end
    end
    if self._v_scroll_bar then
        if _mirroredUI then
            self._v_scroll_bar:paintTo(bb, x + self.scroll_bar_width, y)
        else
            self._v_scroll_bar:paintTo(bb, x + self.dimen.w - 2*self.scroll_bar_width, y)
        end
    end
end

function ScrollableContainer:propagateEvent(event)
    -- Override WidgetContainer:propagateEvent() (which propagates an event
    -- to children before having it handled by the widget itself)
    if not self._is_scrollable then
        -- pass-through
        return InputContainer.propagateEvent(self, event)
    end
    if event.handler == "onGesture" and event.argc == 1 then
        local ges = event.args[1]
        -- Don't propagate events that happen out of view (in the hidden
        -- scrolled-out area) to child
        if ges.pos and not ges.pos:intersectWith(self.dimen) then
            return false -- we may handle it here
        end
    end
    -- Give any event first to our scrollbars
    if self._v_scroll_bar and self._v_scroll_bar:handleEvent(event) then
        return true
    end
    if self._h_scroll_bar and self._h_scroll_bar:handleEvent(event) then
        return true
    end
    -- Pass non-gestures events, and gestures event in the view, to our child
    return InputContainer.propagateEvent(self, event)
end

function ScrollableContainer:onScrollableSwipe(_, ges)
    if not self._is_scrollable then
        return false
    end
    logger.dbg("ScrollableContainer:onScrollableSwipe", ges)
    if not ges.pos:intersectWith(self.dimen) then
        -- with swipe, ges.pos is swipe's start position, which should
        -- be on us to consider it
        return false
    end
    self._scrolling = false -- could have been set by "pan" event received before "swipe"
    local direction = ges.direction
    local distance = ges.distance
    local sq_distance = math.floor(math.sqrt(distance*distance/2))
    if direction == "north" then self:_scrollBy(0, distance)
    elseif direction == "south" then self:_scrollBy(0, -distance)
    elseif direction == "east" then self:_scrollBy(-distance, 0)
    elseif direction == "west" then self:_scrollBy(distance, 0)
    elseif direction == "northeast" then self:_scrollBy(-sq_distance, sq_distance)
    elseif direction == "northwest" then self:_scrollBy(sq_distance, sq_distance)
    elseif direction == "southeast" then self:_scrollBy(-sq_distance, -sq_distance)
    elseif direction == "southwest" then self:_scrollBy(sq_distance, -sq_distance)
    end
    return true
end

function ScrollableContainer:onScrollableTouch(_, ges)
    if not self._is_scrollable then
        return false
    end
    -- First "pan" event may already be outside of us, we need to
    -- remember any "touch" event on us prior to "pan"
    logger.dbg("ScrollableContainer:onScrollableTouch", ges)
    if ges.pos:intersectWith(self.dimen) then
        self._touch_pre_pan_was_inside = true
        self._scroll_relative_x = ges.pos.x
        self._scroll_relative_y = ges.pos.y
    else
        self._touch_pre_pan_was_inside = false
    end
    return false
end

function ScrollableContainer:onScrollableHold(_, ges)
    if not self._is_scrollable then
        return false
    end
    logger.dbg("ScrollableContainer:onScrollableHold", ges)
    if ges.pos:intersectWith(self.dimen) then
        self._scrolling = true -- start of pan
        self._scroll_relative_x = ges.pos.x
        self._scroll_relative_y = ges.pos.y
        return true
    end
    return false
end

function ScrollableContainer:onScrollableHoldPan(_, ges)
    if not self._is_scrollable then
        return false
    end
    logger.dbg("ScrollableContainer:onScrollableHoldPan", ges)
    -- we may sometimes not see the "hold" event
    if ges.pos:intersectWith(self.dimen) or self._scrolling or self._touch_pre_pan_was_inside then
        self._touch_pre_pan_was_inside = false -- reset it
        self._scrolling = true
        return true
    end
    return false
end

function ScrollableContainer:onScrollableHoldRelease(_, ges)
    if not self._is_scrollable then
        return false
    end
    logger.dbg("ScrollableContainer:onScrollableHoldRelease", ges)
    if self._scrolling or self._touch_pre_pan_was_inside then
        self._scrolling = false
        if not self._scroll_relative_x or not self._scroll_relative_y then
            -- no previous event gave us accurate scroll info, ignore it
            return false
        end
        self._scroll_relative_x = ges.pos.x - self._scroll_relative_x
        self._scroll_relative_y = ges.pos.y - self._scroll_relative_y
        self:_scrollBy(-self._scroll_relative_x, -self._scroll_relative_y)
        self._scroll_relative_x = nil
        self._scroll_relative_y = nil
        return true
    end
    return false
end

function ScrollableContainer:onScrollablePan(_, ges)
    if not self._is_scrollable then
        return false
    end
    logger.dbg("ScrollableContainer:onScrollablePan", ges)
    if ges.pos:intersectWith(self.dimen) or self._scrolling or self._touch_pre_pan_was_inside then
        self._touch_pre_pan_was_inside = false -- reset it
        self._scrolling = true
        self._scroll_relative_x = ges.relative.x
        self._scroll_relative_y = ges.relative.y
        return true
    end
    return false
end

function ScrollableContainer:onScrollablePanRelease(_, ges)
    if not self._is_scrollable then
        return false
    end
    logger.dbg("ScrollableContainer:onScrollablePanRelease", ges)
    if self._scrolling then
        self:_scrollBy(-self._scroll_relative_x, -self._scroll_relative_y)
        self._scrolling = false
        self._scroll_relative_x = nil
        self._scroll_relative_y = nil
        return true
    end
    return false
end

return ScrollableContainer
