-- group_covers_spec.lua
-- Pure-function tests for library/group_covers.lua. The Blitbuffer-driven
-- compose() pipeline calls into KOReader's FFI graphics stack and isn't
-- unit-testable here, so it's exercised by the manual matrix. These specs
-- lock down:
--   * URI builder/parser round-trip (incl. nested group paths with "/")
--   * child_cover_bb's cover-missing branch — must queue a cloud-cover
--     download for cloud-present children so a subsequent paint can
--     compose the mosaic. Without this hook, a freshly-pulled library
--     renders every group as FakeCover until the user drills into each
--     group individually (see https://github.com/readest/readest/… for
--     the bug report).

require("spec_helper")
local lfs = require("lfs")

-- Minimal real-lfs shim for production code that imports
-- "libs/libkoreader-lfs". The KOReader binding extends LuaFileSystem with
-- a few extras we don't need here.
package.preload["libs/libkoreader-lfs"] = function() return lfs end

-- Stub renderimage so cloud_covers.load_cover_bb can be exercised without
-- pulling in the full KOReader graphics stack. We always return nil
-- (= "couldn't decode") so the trigger-download branch is reachable.
package.preload["ui/renderimage"] = function()
    return {
        renderImageFile = function() return nil end,
    }
end

describe("library.group_covers", function()
    local group_covers, cloud_covers

    before_each(function()
        require("spec_helper").reset()
        package.loaded["library.cloud_covers"] = nil
        package.loaded["library.group_covers"] = nil
        cloud_covers = require("library.cloud_covers")
        group_covers = require("library.group_covers")
    end)

    -- =====================================================================
    -- build_uri / parse_uri round-trip
    -- =====================================================================
    describe("build_uri / parse_uri", function()
        it("round-trips a simple group name", function()
            local uri = group_covers.build_uri("group_name", "Fantasy", "grid")
            local g, v, s = group_covers.parse_uri(uri)
            assert.are.equal("group_name", g)
            assert.are.equal("Fantasy", v)
            assert.are.equal("grid", s)
        end)

        it("preserves slashes inside nested group paths", function()
            local uri = group_covers.build_uri("group_name", "Selected/Very", "grid")
            local g, v, s = group_covers.parse_uri(uri)
            assert.are.equal("group_name", g)
            assert.are.equal("Selected/Very", v)
            assert.are.equal("grid", s)
        end)

        it("defaults to grid shape when none is given", function()
            local uri = group_covers.build_uri("author", "Asimov")
            local _, _, s = group_covers.parse_uri(uri)
            assert.are.equal("grid", s)
        end)

        it("returns nil for non-group URIs", function()
            assert.is_nil(group_covers.parse_uri("/local/path/file.epub"))
            assert.is_nil(group_covers.parse_uri("readest-cloud://abc.epub"))
        end)
    end)

    -- =====================================================================
    -- cells_for: layout cell count
    -- =====================================================================
    describe("cells_for", function()
        it("returns 4 for both grid and list shapes", function()
            assert.are.equal(4, group_covers.cells_for("grid"))
            assert.are.equal(4, group_covers.cells_for("list"))
        end)

        it("falls back to grid (4) for unknown shapes", function()
            assert.are.equal(4, group_covers.cells_for("nonsense"))
        end)
    end)

    -- =====================================================================
    -- child_cover_bb: cloud-cover trigger when cover .png is missing
    -- =====================================================================
    describe("child_cover_bb missing-cover behaviour", function()
        local trigger_calls

        before_each(function()
            -- Auth must be present for trigger_download to even consider
            -- the call; otherwise it bails early (intentional safety net
            -- in production).
            cloud_covers.set_opts({ sync_auth = "fake-token" })

            -- Record trigger calls and short-circuit so we never actually
            -- hit the network or try to dequeue.
            trigger_calls = {}
            cloud_covers.trigger_download = function(hash, opts)
                trigger_calls[#trigger_calls + 1] = { hash = hash, opts = opts }
            end
        end)

        it("queues a cloud-cover download for a cloud-only child whose .png isn't on disk", function()
            local cover = group_covers.child_cover_bb(
                { hash = "abc12345", cloud_present = 1, local_present = 0 },
                nil, nil)
            assert.is_nil(cover)
            assert.are.equal(1, #trigger_calls)
            assert.are.equal("abc12345", trigger_calls[1].hash)
        end)

        it("queues a download for a hybrid (cloud + local) book when its cover isn't extracted yet", function()
            -- BIM hasn't seen this file yet — orig_getBookInfo returns nil.
            local orig_getBookInfo = function() return nil end
            local cover = group_covers.child_cover_bb(
                {
                    hash = "hybrid01",
                    cloud_present = 1,
                    local_present = 1,
                    file_path = "/no/such/file.epub",
                },
                orig_getBookInfo, {})
            assert.is_nil(cover)
            assert.are.equal(1, #trigger_calls)
            assert.are.equal("hybrid01", trigger_calls[1].hash)
        end)

        it("does not queue a download for a local-only child (nothing to fetch)", function()
            local orig_getBookInfo = function() return nil end
            local cover = group_covers.child_cover_bb(
                {
                    hash = "local001",
                    cloud_present = 0,
                    local_present = 1,
                    file_path = "/no/such/file.epub",
                },
                orig_getBookInfo, {})
            assert.is_nil(cover)
            assert.are.equal(0, #trigger_calls)
        end)

        it("does not queue a download when the child has no hash (defensive)", function()
            local cover = group_covers.child_cover_bb(
                { cloud_present = 1, local_present = 0 }, nil, nil)
            assert.is_nil(cover)
            assert.are.equal(0, #trigger_calls)
        end)
    end)

    -- =====================================================================
    -- cover availability + mosaic cache key (issue #4954)
    -- =====================================================================
    -- Group mosaics are cached and served as copies instead of recomposed
    -- on every paint. The cache key must change exactly when a mosaic's
    -- inputs change: the child set OR a previously-missing child cover
    -- becoming available. The availability bit is what fixes the historical
    -- "partial composite served forever" bug the old on-disk cache had.
    describe("cover availability + mosaic cache key", function()
        local real_lfs = require("lfs")

        local function write_file(path)
            local f = assert(io.open(path, "w"))
            f:write("x")
            f:close()
        end

        local function write_cover(hash)
            local dir = cloud_covers.covers_dir()
            real_lfs.mkdir(dir)
            write_file(dir .. "/" .. hash .. ".png")
        end

        describe("cloud_covers.cover_exists", function()
            it("is false when no <hash>.png is on disk", function()
                assert.is_false(cloud_covers.cover_exists("missing1"))
            end)

            it("is true once the <hash>.png file exists", function()
                write_cover("present1")
                assert.is_true(cloud_covers.cover_exists("present1"))
            end)
        end)

        describe("group_covers.child_cover_available", function()
            it("is true for a cloud child whose cover .png is cached", function()
                write_cover("cloudcov")
                assert.is_true(group_covers.child_cover_available(
                    { hash = "cloudcov", cloud_present = 1, local_present = 0 }))
            end)

            it("is false for a cloud child whose cover isn't downloaded yet", function()
                assert.is_false(group_covers.child_cover_available(
                    { hash = "nocover1", cloud_present = 1, local_present = 0 }))
            end)

            it("is true for a local child whose file is on disk", function()
                local path = require("datastorage"):getSettingsDir() .. "/localbook.epub"
                write_file(path)
                assert.is_true(group_covers.child_cover_available(
                    { hash = "localbk1", local_present = 1, file_path = path }))
            end)

            it("is false when neither a local file nor a cached cover exists", function()
                assert.is_false(group_covers.child_cover_available(
                    { hash = "ghost001", local_present = 1, file_path = "/no/such/file.epub" }))
            end)
        end)

        describe("group_covers.mosaic_cache_key", function()
            local books = {
                { hash = "aaa" }, { hash = "bbb" }, { hash = "ccc" }, { hash = "ddd" },
            }

            it("is stable across calls with identical inputs (enables cache hits)", function()
                assert.are.equal(
                    group_covers.mosaic_cache_key("group_name", "Fantasy", "grid", books),
                    group_covers.mosaic_cache_key("group_name", "Fantasy", "grid", books))
            end)

            it("changes when the child set changes", function()
                local other = {
                    { hash = "aaa" }, { hash = "zzz" }, { hash = "ccc" }, { hash = "ddd" },
                }
                assert.are_not.equal(
                    group_covers.mosaic_cache_key("group_name", "Fantasy", "grid", books),
                    group_covers.mosaic_cache_key("group_name", "Fantasy", "grid", other))
            end)

            it("changes when a missing child cover becomes available (anti-regression)", function()
                local one = { { hash = "latecov" } }
                local before = group_covers.mosaic_cache_key("author", "Asimov", "grid", one)
                write_cover("latecov")
                local after = group_covers.mosaic_cache_key("author", "Asimov", "grid", one)
                assert.are_not.equal(before, after)
            end)

            it("distinguishes different groups and shapes", function()
                assert.are_not.equal(
                    group_covers.mosaic_cache_key("group_name", "A", "grid", books),
                    group_covers.mosaic_cache_key("group_name", "A", "list", books))
                assert.are_not.equal(
                    group_covers.mosaic_cache_key("group_name", "A", "grid", books),
                    group_covers.mosaic_cache_key("group_name", "B", "grid", books))
            end)
        end)
    end)
end)

-- =====================================================================
-- libraryitem.set_visible_hashes — group children expansion
-- =====================================================================
-- The patched BIM only triggers child cover downloads when the child's
-- hash is in cloud_covers' visibility set. Without expanding visible
-- groups → first-N children, trigger_download in child_cover_bb is a
-- no-op and the mosaic never composes. These tests pin that contract.
describe("library.libraryitem.set_visible_hashes", function()
    local libraryitem, cloud_covers
    local fake_widget

    before_each(function()
        require("spec_helper").reset()
        package.loaded["library.cloud_covers"] = nil
        package.loaded["library.group_covers"] = nil
        package.loaded["library.bim_patch"]    = nil
        package.loaded["library.cloud_icons"]  = nil
        package.loaded["library.list_strip"]   = nil
        package.loaded["library.libraryitem"]  = nil
        package.loaded["library.librarywidget"] = nil

        -- Fake LibraryWidget so libraryitem can pick up the store.
        -- libraryitem reads package.loaded directly (not require), so a
        -- preload won't suffice — populate the loaded slot itself.
        fake_widget = { _store = nil }
        package.loaded["library.librarywidget"] = fake_widget

        cloud_covers = require("library.cloud_covers")
        libraryitem  = require("library.libraryitem")
    end)

    after_each(function()
        package.loaded["library.librarywidget"] = nil
    end)

    -- Capture whatever set libraryitem hands cloud_covers, regardless of
    -- whether trigger_download itself is exercised.
    local function capture_set()
        local captured
        cloud_covers.set_visible_hashes = function(set) captured = set end
        return function() return captured end
    end

    -- Minimal store stub: returns the books we tell it to, keyed by
    -- (group_by, name). listBooksInGroup is the only method the wrapper
    -- calls.
    local function fake_store(by_group)
        return {
            listBooksInGroup = function(_self, group_by, name, _n, _opts)
                local key = group_by .. ":" .. name
                return by_group[key] or {}
            end,
        }
    end

    it("clears the visibility set when called with nil menu", function()
        local get = capture_set()
        libraryitem.set_visible_hashes(nil)
        assert.is_nil(get())
    end)

    it("includes hashes for cloud-only book entries on the visible page", function()
        local get = capture_set()
        local menu = {
            page = 1, perpage = 3,
            item_table = {
                { [libraryitem.CLOUD_ONLY_FLAG] = true, file = "readest-cloud://aaa.epub" },
                { [libraryitem.CLOUD_ONLY_FLAG] = true, file = "readest-cloud://bbb.epub" },
                -- a local book — should NOT contribute
                { is_file = true, file = "/local/book.epub" },
            },
        }
        libraryitem.set_visible_hashes(menu)
        local set = get()
        assert.is_truthy(set["aaa"])
        assert.is_truthy(set["bbb"])
        local n = 0
        for _ in pairs(set) do n = n + 1 end
        assert.are.equal(2, n)
    end)

    it("expands visible group entries to include their first-N children's hashes", function()
        local get = capture_set()
        fake_widget._store = fake_store({
            ["group_name:Selected/Very"] = {
                { hash = "child01" }, { hash = "child02" },
                { hash = "child03" }, { hash = "child04" },
            },
            ["group_name:Selected/Philosophy"] = {
                { hash = "phil01" }, { hash = "phil02" },
            },
        })
        local menu = {
            page = 1, perpage = 3,
            item_table = {
                {
                    _readest_group = {
                        name = "Selected/Very",
                        _group_by = "group_name",
                    },
                },
                {
                    _readest_group = {
                        name = "Selected/Philosophy",
                        _group_by = "group_name",
                    },
                },
                -- A book entry on the same page
                { [libraryitem.CLOUD_ONLY_FLAG] = true, file = "readest-cloud://book01.epub" },
            },
        }
        libraryitem.set_visible_hashes(menu)
        local set = get()
        -- Group children
        assert.is_truthy(set["child01"])
        assert.is_truthy(set["child04"])
        assert.is_truthy(set["phil01"])
        assert.is_truthy(set["phil02"])
        -- Cloud-only book on the same page
        assert.is_truthy(set["book01"])
    end)

    it("only walks the current page's window, not the full item_table", function()
        local get = capture_set()
        fake_widget._store = fake_store({
            ["group_name:OnPage1"] = { { hash = "child01" } },
            ["group_name:OnPage2"] = { { hash = "child99" } },
        })
        local menu = {
            page = 2, perpage = 1,
            item_table = {
                { _readest_group = { name = "OnPage1", _group_by = "group_name" } },
                { _readest_group = { name = "OnPage2", _group_by = "group_name" } },
            },
        }
        libraryitem.set_visible_hashes(menu)
        local set = get()
        assert.is_nil(set["child01"])
        assert.is_truthy(set["child99"])
    end)
end)
