local utils = require 'mp.utils'
local msg = require 'mp.msg'
local assdraw = require 'mp.assdraw'

local gallery_mt = {}
gallery_mt.__index = gallery_mt

function gallery_new()
    local gallery = setmetatable({
        -- public, can be modified by user
        items = {},
        item_to_overlay_path = function(index, item) return "" end,
        item_to_thumbnail_params = function(index, item) return "", 0 end,
        item_to_text = function(index, item) return "", true end,
        item_to_border = function(index, item) return 0, "" end,
        ass_show = function(ass) end,
        config = {
            background_color = '333333',
            background_opacity = '33',
            background_roundness = 5,
            scrollbar = true,
            scrollbar_left_side = false,
            scrollbar_min_size = 10,
            overlay_range = 0,
            max_thumbnails = 64,
            show_placeholders = true,
            always_show_placeholders = false,
            placeholder_color = '222222',
            text_size = 28,
            align_text = true,
            accurate = false,
            generate_thumbnails_with_mpv = false,
        },

        -- private, can be read but should not be modified
        active = false,
        geometry = {
            ok = false,
            position = { 0, 0 },
            size = { 0, 0 },
            min_spacing = { 0, 0 },
            thumbnail_size = { 0, 0 },
            rows = 0,
            columns = 0,
            effective_spacing = { 0, 0 },
        },
        view = { -- 1-based indices into the "playlist" array
            first = 0, -- must be equal to N*columns
            last = 0, -- must be > first and <= first + rows*columns
        },
        overlays = {
            active = {}, -- array of <=64 strings indicating the file associated to the current overlay (false if nothing)
            missing = {}, -- associative array of thumbnail path to view index it should be shown at
        },
        selection = nil,
        ass = {
            background = "",
            selection = "",
            scrollbar = "",
            placeholders = "",
        },
        generators = {}, -- list of generator scripts


    }, gallery_mt)

    for i = 1, gallery.config.max_thumbnails do
        gallery.overlays.active[i] = false
    end
    return gallery
end

function gallery_mt.show_overlay(gallery, index_1, thumb_path)
    local g = gallery.geometry
    gallery.overlays.active[index_1] = thumb_path
    local index_0 = index_1 - 1
    local x, y = gallery:view_index_position(index_0)
    mp.commandv("overlay-add",
        tostring(index_0 + gallery.config.overlay_range),
        tostring(math.floor(x + 0.5)),
        tostring(math.floor(y + 0.5)),
        thumb_path,
        "0",
        "bgra",
        tostring(g.thumbnail_size[1]),
        tostring(g.thumbnail_size[2]),
        tostring(4*g.thumbnail_size[1]))
    mp.osd_message(" ", 0.01)
end

function gallery_mt.remove_overlays(gallery)
    for view_index, _ in pairs(gallery.overlays.active) do
        mp.commandv("overlay-remove", gallery.config.overlay_range + view_index - 1)
        gallery.overlays.active[view_index] = false
    end
    gallery.overlays.missing = {}
end

local function file_exists(path)
    local info = utils.file_info(path)
    return info ~= nil and info.is_file
end

function gallery_mt.refresh_overlays(gallery, force)
    local todo = {}
    local o = gallery.overlays
    local g = gallery.geometry
    o.missing = {}
    for view_index = 1, g.rows * g.columns do
        local index = gallery.view.first + view_index - 1
        local active = o.active[view_index]
        if index > 0 and index <= #gallery.items then
            local thumb_path = gallery.item_to_overlay_path(index, gallery.items[index])
            if not force and active == thumb_path then
                -- nothing to do
            elseif file_exists(thumb_path) then
                gallery:show_overlay(view_index, thumb_path)
            else
                -- need to generate that thumbnail
                o.active[view_index] = false
                mp.commandv("overlay-remove", gallery.config.overlay_range + view_index - 1)
                o.missing[thumb_path] = view_index
                todo[#todo + 1] = { index = index, output = thumb_path }
            end
        else
            -- might happen if we're close to the end of gallery.items
            if active ~= false then
                o.active[view_index] = false
                mp.commandv("overlay-remove", gallery.config.overlay_range + view_index - 1)
            end
        end
    end
    if #gallery.generators >= 1 then
        -- reverse iterate so that the first thumbnail is at the top of the stack
        for i = #todo, 1, -1 do
            local generator = gallery.generators[i % #gallery.generators + 1]
            local t = todo[i]
            local input_path, time = gallery.item_to_thumbnail_params(t.index, gallery.items[t.index])
            mp.commandv("script-message-to", generator, "push-thumbnail-front",
                mp.get_script_name(),
                input_path,
                tostring(g.thumbnail_size[1]),
                tostring(g.thumbnail_size[2]),
                time,
                t.output,
                gallery.config.accurate and "true" or "false",
                gallery.config.generate_thumbnails_with_mpv and "true" or "false"
            )
        end
    end
end

function gallery_mt.index_at(gallery, mx, my)
    local g = gallery.geometry
    if mx < g.position[1] or my < g.position[2] then return nil end
    mx = mx - g.position[1]
    my = my - g.position[2]
    if mx > g.size[1] or my > g.size[2] then return nil end
    mx = mx - g.effective_spacing[1]
    my = my - g.effective_spacing[2]
    local on_column = (mx % (g.thumbnail_size[1] + g.effective_spacing[1])) < g.thumbnail_size[1]
    local on_row = (my % (g.thumbnail_size[2] + g.effective_spacing[2])) < g.thumbnail_size[2]
    if on_column and on_row then
        local column = math.floor(mx / (g.thumbnail_size[1] + g.effective_spacing[1]))
        local row = math.floor(my / (g.thumbnail_size[2] + g.effective_spacing[2]))
        local index = gallery.view.first + row * g.columns + column
        if index > 0 and index <= gallery.view.last then
            return index
        end
    end
    return nil
end

function gallery_mt.compute_internal_geometry(gallery)
    local g = gallery.geometry
    g.rows = math.floor((g.size[2] - g.min_spacing[2]) / (g.thumbnail_size[2] + g.min_spacing[2]))
    g.columns = math.floor((g.size[1] - g.min_spacing[1]) / (g.thumbnail_size[1] + g.min_spacing[1]))
    if g.rows <= 0 or g.columns <= 0 then
        g.rows = 0
        g.columns = 0
        g.effective_spacing[1] = g.size[1]
        g.effective_spacing[2] = g.size[2]
        return
    end
    if (g.rows * g.columns > gallery.config.max_thumbnails) then
        local r = math.sqrt(g.rows * g.columns / gallery.config.max_thumbnails)
        g.rows = math.floor(g.rows / r)
        g.columns = math.floor(g.columns / r)
    end
    g.effective_spacing[1] = (g.size[1] - g.columns * g.thumbnail_size[1]) / (g.columns + 1)
    g.effective_spacing[2] = (g.size[2] - g.rows * g.thumbnail_size[2]) / (g.rows + 1)
end

-- makes sure that view.first and view.last are valid with regards to the playlist
-- and that selection is within the view
-- to be called after the playlist, view or selection was modified somehow
function gallery_mt.ensure_view_valid(gallery)
    local g = gallery.geometry
    if #gallery.items == 0 or g.rows == 0 or g.columns == 0 then
        gallery.view.first = 0
        gallery.view.last = 0
        return
    end
    local v = gallery.view
    local selection_row = math.floor((gallery.selection - 1) / g.columns)
    local max_thumbs = g.rows * g.columns
    local changed = false

    if v.last >= #gallery.items then
        v.last = #gallery.items
        if g.rows == 1 then
            v.first = math.max(1, v.last - g.columns + 1)
        else
            local last_row = math.floor((v.last - 1) / g.columns)
            local first_row = math.max(0, last_row - g.rows + 1)
            v.first = 1 + first_row * g.columns
        end
        changed = true
    elseif v.first == 0 or v.last == 0 or v.last - v.first + 1 ~= max_thumbs then
        -- special case: the number of possible thumbnails was changed
        -- just recreate the view such that the selection is in the middle row
        local max_row = (#gallery.items - 1) / g.columns + 1
        local row_first = selection_row - math.floor((g.rows - 1) / 2)
        local row_last = selection_row + math.floor((g.rows - 1) / 2) + g.rows % 2
        if row_first < 0 then
            row_first = 0
        elseif row_last > max_row then
            row_first = max_row - g.rows + 1
        end
        v.first = 1 + row_first * g.columns
        v.last = math.min(#gallery.items, v.first - 1 + max_thumbs)
        return true
    end

    if gallery.selection < v.first then
        -- the selection is now on the first line
        v.first = (g.rows == 1) and gallery.selection or selection_row * g.columns + 1
        v.last = math.min(#gallery.items, v.first + max_thumbs - 1)
        changed = true
    elseif gallery.selection > v.last then
        v.last = (g.rows == 1) and gallery.selection or (selection_row + 1) * g.columns
        v.first = math.max(1, v.last - max_thumbs + 1)
        v.last = math.min(#gallery.items, v.last)
        changed = true
    end
    return changed
end

-- ass related stuff
function gallery_mt.refresh_background(gallery)
    local g = gallery.geometry
    local a = assdraw.ass_new()
    a:new_event()
    a:append('{\\an7}')
    a:append('{\\bord0}')
    a:append('{\\shad0}')
    a:append('{\\1c&' .. gallery.config.background_color .. '}')
    a:append('{\\1a&' .. gallery.config.background_opacity .. '}')
    a:pos(0, 0)
    a:draw_start()
    a:round_rect_cw(g.position[1], g.position[2], g.position[1] + g.size[1], g.position[2] + g.size[2], gallery.config.background_roundness)
    a:draw_stop()
    gallery.ass.background = a.text
end

function gallery_mt.refresh_placeholders(gallery)
    if not gallery.config.show_placeholders then return end
    if gallery.view.first == 0 then
        gallery.ass.placeholders = ""
        return
    end
    local g = gallery.geometry
    local a = assdraw.ass_new()
    a:new_event()
    a:append('{\\an7}')
    a:append('{\\bord0}')
    a:append('{\\shad0}')
    a:append('{\\1c&' .. gallery.config.placeholder_color .. '}')
    a:pos(0, 0)
    a:draw_start()
    for i = 0, gallery.view.last - gallery.view.first do
        if gallery.config.always_show_placeholders or not gallery.overlays.active[i + 1] then
            local x, y = gallery:view_index_position(i)
            a:rect_cw(x, y, x + g.thumbnail_size[1], y + g.thumbnail_size[2])
        end
    end
    a:draw_stop()
    gallery.ass.placeholders = a.text
end

function gallery_mt.refresh_scrollbar(gallery)
    if not gallery.config.scrollbar then return end
    gallery.ass.scrollbar = ""
    if gallery.view.first == 0 then return end
    local g = gallery.geometry
    local before = (gallery.view.first - 1) / #gallery.items
    local after = (#gallery.items - gallery.view.last) / #gallery.items
    -- don't show the scrollbar if everything is visible
    if before + after == 0 then return end
    local p = gallery.config.scrollbar_min_size / 100
    if before + after > 1 - p then
        if before == 0 then
            after = (1 - p)
        elseif after == 0 then
            before = (1 - p)
        else
            before, after =
                before / after * (1 - p) / (1 + before / after),
                after / before * (1 - p) / (1 + after / before)
        end
    end
    local dist_from_edge = g.size[2] * 0.015
    local y1 = g.position[2] + dist_from_edge + before * (g.size[2] - 2 * dist_from_edge)
    local y2 = g.position[2] + g.size[2] - (dist_from_edge + after * (g.size[2] - 2 * dist_from_edge))
    local x1, x2
    if gallery.config.scrollbar_left_side then
        x1 = g.position[1] + g.effective_spacing[1] / 2 - 2
    else
        x1 = g.position[1] + g.size[1] - g.effective_spacing[1] / 2 - 2
    end
    x2 = x1 + 4
    local scrollbar = assdraw.ass_new()
    scrollbar:new_event()
    scrollbar:append('{\\an7}')
    scrollbar:append('{\\bord0}')
    scrollbar:append('{\\shad0}')
    scrollbar:append('{\\1c&AAAAAA&}')
    scrollbar:pos(0, 0)
    scrollbar:draw_start()
    scrollbar:rect_cw(x1, y1, x2, y2)
    scrollbar:draw_stop()
    gallery.ass.scrollbar = scrollbar.text
end

function gallery_mt.refresh_selection(gallery)
    local v = gallery.view
    if v.first == 0 then
        gallery.ass.selection = ""
        return
    end
    local selection_ass = assdraw.ass_new()
    local g = gallery.geometry
    local draw_frame = function(index, size, color)
        local x, y = gallery:view_index_position(index - v.first)
        selection_ass:new_event()
        selection_ass:append('{\\an7}')
        selection_ass:append('{\\bord' .. size ..'}')
        selection_ass:append('{\\3c&'.. color ..'&}')
        selection_ass:append('{\\1a&FF&}')
        selection_ass:pos(0, 0)
        selection_ass:draw_start()
        selection_ass:rect_cw(x, y, x + g.thumbnail_size[1], y + g.thumbnail_size[2])
        selection_ass:draw_stop()
    end
    for i = v.first, v.last do
        local size, color = gallery.item_to_border(i, gallery.items[i])
        if size > 0 then
            draw_frame(i, size, color)
        end
    end

    for index = v.first, v.last do
        local text  = gallery.item_to_text(index, gallery.items[index])
        if text ~= "" then
            selection_ass:new_event()
            local an = 5
            local x, y = gallery:view_index_position(index - v.first)
            x = x + g.thumbnail_size[1] / 2
            y = y + g.thumbnail_size[2] + gallery.config.text_size * 0.75
            if gallery.config.align_text then
                local col = (index - v.first) % g.columns
                if g.columns > 1 then
                    if col == 0 then
                        x = x - g.thumbnail_size[1] / 2
                        an = 4
                    elseif col == g.columns - 1 then
                        x = x + g.thumbnail_size[1] / 2
                        an = 6
                    end
                end
            end
            selection_ass:an(an)
            selection_ass:pos(x, y)
            selection_ass:append(string.format("{\\fs%d}", gallery.config.text_size))
            selection_ass:append("{\\bord0}")
            selection_ass:append(text)
        end
    end
    gallery.ass.selection = selection_ass.text
end

function gallery_mt.ass_refresh(gallery, selection, scrollbar, placeholders, background)
    if not gallery.active then return end
    if selection then gallery:refresh_selection() end
    if scrollbar then gallery:refresh_scrollbar() end
    if placeholders then gallery:refresh_placeholders() end
    if background then gallery:refresh_background() end
    gallery.ass_show(table.concat({
        gallery.ass.background,
        gallery.ass.placeholders,
        gallery.ass.selection,
        gallery.ass.scrollbar
    }, "\n"))
end

function gallery_mt.set_selection(gallery, selection)
    if not selection or selection ~= selection then return end
    local new_selection = math.max(1, math.min(selection, #gallery.items))
    if gallery.selection == new_selection then return end
    gallery.selection = new_selection
    if gallery.active then
        if gallery:ensure_view_valid() then
            gallery:refresh_overlays(false)
            gallery:ass_refresh(true, true, true, false)
        else
            gallery:ass_refresh(true, false, false, false)
        end
    end
end

function gallery_mt.set_geometry(gallery, x, y, w, h, sw, sh, tw, th)
    if w <= 0 or h <= 0 or tw <= 0 or th <= 0 then
        msg.warn("Invalid coordinates")
        return
    end
    gallery.geometry.position = {x, y}
    gallery.geometry.size = {w, h}
    gallery.geometry.min_spacing = {sw, sh}
    gallery.geometry.thumbnail_size = {tw, th}
    gallery.geometry.ok = true
    if not gallery.active then return end
    if not gallery:enough_space() then
        msg.warn("Not enough space to display something")
    end
    local old_total = gallery.geometry.rows * gallery.geometry.columns
    gallery:compute_internal_geometry()
    gallery:ensure_view_valid()
    local new_total = gallery.geometry.rows * gallery.geometry.columns
    for view_index = new_total + 1, old_total do
        if gallery.overlays.active[view_index] then
            mp.commandv("overlay-remove", gallery.config.overlay_range + view_index - 1)
            gallery.overlays.active[view_index] = false
        end
    end
    gallery:refresh_overlays(true)
    gallery:ass_refresh(true, true, true, true)
end

function gallery_mt.items_changed(gallery, new_sel)
    gallery.selection = math.max(1, math.min(new_sel, #gallery.items))
    if not gallery.active then return end
    gallery:ensure_view_valid()
    gallery:refresh_overlays(false)
    gallery:ass_refresh(true, true, true, false)
end

function gallery_mt.thumbnail_generated(gallery, thumb_path)
    if not gallery.active then return end
    local view_index = gallery.overlays.missing[thumb_path]
    if view_index == nil then return end
    gallery:show_overlay(view_index, thumb_path)
    if not gallery.config.always_show_placeholders then
        gallery:ass_refresh(false, false, true, false)
    end
    gallery.overlays.missing[thumb_path] = nil
end

function gallery_mt.add_generator(gallery, generator_name)
    for _, g in ipairs(gallery.generators) do
        if generator_name == g then return end
    end
    gallery.generators[#gallery.generators + 1] = generator_name
end

function gallery_mt.view_index_position(gallery, index_0)
    local g = gallery.geometry
    return math.floor(g.position[1] + g.effective_spacing[1] + (g.effective_spacing[1] + g.thumbnail_size[1]) * (index_0 % g.columns)),
        math.floor(g.position[2] + g.effective_spacing[2] + (g.effective_spacing[2] + g.thumbnail_size[2]) * math.floor(index_0 / g.columns))
end

function gallery_mt.enough_space(gallery)
    if gallery.geometry.size[1] < gallery.geometry.thumbnail_size[1] + 2 * gallery.geometry.min_spacing[1] then return false end
    if gallery.geometry.size[2] < gallery.geometry.thumbnail_size[2] + 2 * gallery.geometry.min_spacing[2] then return false end
    return true
end

function gallery_mt.activate(gallery)
    if gallery.active then return false end
    if not gallery:enough_space() then
        msg.warn("Not enough space, refusing to start")
        return false
    end
    if not gallery.geometry.ok then
        msg.warn("Gallery geometry unitialized, refusing to start")
        return false
    end
    gallery.active = true
    if not gallery.selection then
        gallery:set_selection(1)
    end
    gallery:compute_internal_geometry()
    gallery:ensure_view_valid()
    gallery:refresh_overlays(false)
    gallery:ass_refresh(true, true, true, true)
    return true
end

function gallery_mt.deactivate(gallery)
    if not gallery.active then return end
    gallery.active = false
    gallery:remove_overlays()
    gallery.ass_show("")
end

return {gallery_new = gallery_new}
