-- librarystore.lua
-- SQLite-backed book index for the Library view. Merges Readest cloud books
-- (from /sync) with KOReader local books (from sidecar walks + ReadHistory)
-- via the partial-md5 hash that both sides already use.
--
-- All queries are scoped by user_id (composite PK with hash) so signing into
-- a different Readest account doesn't surface the previous user's books.
--
-- See apps/readest.koplugin/docs/library-design.md for the full schema and
-- contract notes; spec/library/librarystore_spec.lua is the canonical
-- behavioral spec.

local SQ3 = require("lua-ljsqlite3/init")
local json = require("json")

local SCHEMA_VERSION = 3

local SCHEMA_SQL = [[
CREATE TABLE IF NOT EXISTS books (
    user_id          TEXT NOT NULL,
    hash             TEXT NOT NULL,
    meta_hash        TEXT,
    title            TEXT NOT NULL,
    source_title     TEXT,
    author           TEXT,
    format           TEXT,
    metadata_json    TEXT,
    series           TEXT,
    series_index     REAL,
    group_id         TEXT,
    group_name       TEXT,
    cover_path       TEXT,
    file_path        TEXT,
    cloud_present    INTEGER NOT NULL DEFAULT 0,
    local_present    INTEGER NOT NULL DEFAULT 0,
    uploaded_at      INTEGER,
    progress_lib     TEXT,
    reading_status   TEXT,
    reading_status_updated_at INTEGER,
    last_read_at     INTEGER,
    created_at       INTEGER,
    updated_at       INTEGER,
    deleted_at       INTEGER,
    PRIMARY KEY (user_id, hash)
);
CREATE INDEX IF NOT EXISTS books_user_updated  ON books(user_id, updated_at DESC);
CREATE INDEX IF NOT EXISTS books_user_lastread ON books(user_id, last_read_at DESC);
CREATE INDEX IF NOT EXISTS books_user_meta     ON books(user_id, meta_hash);
CREATE INDEX IF NOT EXISTS books_user_group    ON books(user_id, group_name);
CREATE INDEX IF NOT EXISTS books_user_author   ON books(user_id, author);

CREATE TABLE IF NOT EXISTS sync_state (
    user_id TEXT NOT NULL,
    key     TEXT NOT NULL,
    value   TEXT,
    PRIMARY KEY (user_id, key)
);
]]

-- All columns we round-trip in the books row, in insert order.
local BOOK_COLS = {
    "user_id", "hash", "meta_hash", "title", "source_title", "author",
    "format", "metadata_json", "series", "series_index", "group_id",
    "group_name", "cover_path", "file_path", "cloud_present",
    "local_present", "uploaded_at", "progress_lib", "reading_status",
    "reading_status_updated_at", "last_read_at", "created_at", "updated_at", "deleted_at",
}
local BOOK_COL_INDEX = {}
for i, c in ipairs(BOOK_COLS) do BOOK_COL_INDEX[c] = i end

-- Integer/real columns that lua-ljsqlite3 returns as int64_t / double cdata.
-- We tonumber() these on row read so consumers can do arithmetic and
-- string concat without worrying about cdata. Unix-ms timestamps fit
-- well within Lua's 53-bit double mantissa.
local NUMERIC_COLS = {
    series_index = true, cloud_present = true, local_present = true,
    uploaded_at = true, reading_status_updated_at = true, last_read_at = true,
    created_at = true, updated_at = true, deleted_at = true,
}

local function row_to_table(raw)
    local out = {}
    for i, col in ipairs(BOOK_COLS) do
        local v = raw[i]
        if v ~= nil and NUMERIC_COLS[col] then
            v = tonumber(v)
        end
        out[col] = v
    end
    return out
end

-- Allowed sort columns. listBooks accepts only these to keep SQL safe from
-- injection via filters.sort_by.
local SORT_WHITELIST = {
    updated_at    = true,
    last_read_at  = true,
    title         = true,
    author        = true,
    created_at    = true,
    series        = true,
    format        = true,
}

-- Allowed group_by columns. Must match a real column name.
local GROUP_WHITELIST = {
    author       = true,
    series       = true,
    group_name   = true,
}

-- A book is shown in the Library only when its file is actually reachable:
-- either uploaded to Readest cloud (uploaded_at set, so the file + its cover
-- can be downloaded) or present on this device (local_present = 1).
--
-- A bare cloud *record* with no uploaded file (cloud_present = 1 but
-- uploaded_at NULL) has no cover and cannot be opened, so showing it is
-- meaningless. This mirrors Readest, which only adds a synced book to the
-- library when uploadedAt is set, and keeps locally-imported books that
-- carry a downloadedAt — see useBooksSync.updateLibrary at
-- apps/readest-app/src/app/library/hooks/useBooksSync.ts:136-139.
local VISIBLE_BOOK_SQL = "(uploaded_at IS NOT NULL OR local_present = 1)"

local M = {}
M.__index = M

-- ---------------------------------------------------------------------------
-- Construction
-- ---------------------------------------------------------------------------
-- opts:
--   user_id  (required, string) — currently-authenticated Readest user.id
--   db_path  (optional, string) — defaults to ":memory:" for tests
function M.new(opts)
    assert(opts and type(opts.user_id) == "string" and opts.user_id ~= "",
        "LibraryStore.new requires opts.user_id")
    local self = setmetatable({}, M)
    self.user_id = opts.user_id
    self.db_path = opts.db_path or ":memory:"
    self.db = SQ3.open(self.db_path)
    -- Read version before creating tables; getUserVersion uses rowexec which
    -- may leave an open iterator in some SQLite bindings, so use prepare/step.
    local prev_stmt = self.db:prepare("PRAGMA user_version;")
    local prev_row = prev_stmt:reset():step()
    prev_stmt:close()
    local prev = prev_row and tonumber(prev_row[1]) or 0
    self.db:exec(SCHEMA_SQL)
    -- v1 -> v2: add reading_status_updated_at to existing DBs. CREATE TABLE
    -- IF NOT EXISTS won't add a column, so ALTER it in (pcall guards a DB that
    -- somehow already has the column).
    if prev >= 1 and prev < 2 then
        pcall(function()
            self.db:exec("ALTER TABLE books ADD COLUMN reading_status_updated_at INTEGER;")
        end)
    end
    -- v2 -> v3: split the shared books cursor into a pull cursor (server
    -- synced_at) and a push watermark (local updated_at). The old shared
    -- `last_books_pulled_at` was advanced from client updated_at, which a
    -- device with a fast clock could push into the future, starving every
    -- later synced_at-keyed pull so the library went stale and never
    -- recovered (issue #4934). Seed the new push watermark from the old value
    -- (local-change detection is unbroken), then zero the pull cursor so the
    -- next sync re-establishes it on the server's synced_at clock.
    if prev >= 1 and prev < 3 then
        self.db:exec([[
            INSERT INTO sync_state (user_id, key, value)
                SELECT user_id, 'last_books_pushed_at', value
                  FROM sync_state WHERE key = 'last_books_pulled_at'
            ON CONFLICT(user_id, key) DO UPDATE SET value = excluded.value;
            UPDATE sync_state SET value = '0' WHERE key = 'last_books_pulled_at';
        ]])
    end
    if prev < SCHEMA_VERSION then
        self.db:exec(string.format("PRAGMA user_version = %d;", SCHEMA_VERSION))
    end
    self._groups_cache = {}
    return self
end

function M:close()
    if self.db then self.db:close(); self.db = nil end
end

function M:getUserVersion()
    local v = self.db:rowexec("PRAGMA user_version;")
    return tonumber(v)
end

-- ---------------------------------------------------------------------------
-- Sync state per (user_id, key)
-- ---------------------------------------------------------------------------
function M:getLastPulledAt()
    local stmt = self.db:prepare(
        "SELECT value FROM sync_state WHERE user_id = ? AND key = ?")
    local row = stmt:reset():bind(self.user_id, "last_books_pulled_at"):step()
    stmt:close()
    if not row then return nil end
    return tonumber(row[1])
end

function M:setLastPulledAt(ts)
    local stmt = self.db:prepare([[
        INSERT INTO sync_state (user_id, key, value) VALUES (?, ?, ?)
        ON CONFLICT(user_id, key) DO UPDATE SET value = excluded.value
    ]])
    stmt:reset():bind(self.user_id, "last_books_pulled_at", tostring(ts)):step()
    stmt:close()
end

-- The push watermark: max local updated_at | deleted_at we've already
-- accounted for (pushed to the server or pulled from it). getChangedBooks
-- keys on this so a book already on the server isn't re-pushed. It is kept
-- SEPARATE from the pull cursor (last_books_pulled_at, server synced_at)
-- because the two live on different clocks — mixing them starved pulls in
-- issue #4934.
function M:getLastPushedAt()
    local stmt = self.db:prepare(
        "SELECT value FROM sync_state WHERE user_id = ? AND key = ?")
    local row = stmt:reset():bind(self.user_id, "last_books_pushed_at"):step()
    stmt:close()
    if not row then return nil end
    return tonumber(row[1])
end

function M:setLastPushedAt(ts)
    local stmt = self.db:prepare([[
        INSERT INTO sync_state (user_id, key, value) VALUES (?, ?, ?)
        ON CONFLICT(user_id, key) DO UPDATE SET value = excluded.value
    ]])
    stmt:reset():bind(self.user_id, "last_books_pushed_at", tostring(ts)):step()
    stmt:close()
end

-- ---------------------------------------------------------------------------
-- upsertBook
-- ---------------------------------------------------------------------------
-- Merges a row by (user_id, hash). Flags `cloud_present` and `local_present`
-- are OR-merged with the existing row UNLESS the caller passes the
-- `_force_cloud_present` sentinel, in which case the supplied value is
-- written verbatim (used for cloud-tombstone updates that must clear the
-- flag).
--
-- Sentinels:
--   _force_cloud_present = true → caller's cloud_present overrides OR-merge.
--   _clear_fields = { "deleted_at", ... } → after the preserve-existing pass,
--     these columns are explicitly nulled. Lets a caller un-tombstone a row
--     by passing nil (which would otherwise be indistinguishable from "not
--     provided" since Lua tables drop nil values).
function M:upsertBook(row)
    assert(row and type(row.hash) == "string" and row.hash ~= "",
        "upsertBook requires row.hash")
    assert(row.title, "upsertBook requires row.title")

    local existing = self:_getRowRaw(row.hash)

    local merged = {}
    for k in pairs(BOOK_COL_INDEX) do
        merged[k] = row[k]
    end
    merged.user_id = self.user_id
    merged.hash = row.hash

    if existing then
        -- OR-merge cloud_present unless explicit override
        if not row._force_cloud_present then
            merged.cloud_present = math.max(
                tonumber(existing.cloud_present) or 0,
                tonumber(merged.cloud_present) or 0)
        else
            merged.cloud_present = tonumber(row.cloud_present) or 0
        end
        -- OR-merge local_present always (no use case for force-clearing yet)
        merged.local_present = math.max(
            tonumber(existing.local_present) or 0,
            tonumber(merged.local_present) or 0)
        -- Preserve fields the caller didn't provide
        for k in pairs(BOOK_COL_INDEX) do
            if merged[k] == nil and existing[k] ~= nil then
                merged[k] = existing[k]
            end
        end
        -- Explicit clears: applied after preserve so they win.
        if row._clear_fields then
            for _, col in ipairs(row._clear_fields) do
                merged[col] = nil
            end
        end
    else
        merged.cloud_present = tonumber(merged.cloud_present) or 0
        merged.local_present = tonumber(merged.local_present) or 0
    end

    -- Build INSERT … ON CONFLICT … DO UPDATE … with positional params.
    local placeholders = {}
    local update_setters = {}
    for i, col in ipairs(BOOK_COLS) do
        placeholders[i] = "?"
        if col ~= "user_id" and col ~= "hash" then
            update_setters[#update_setters + 1] = col .. " = excluded." .. col
        end
    end
    local sql = string.format([[
        INSERT INTO books (%s) VALUES (%s)
        ON CONFLICT(user_id, hash) DO UPDATE SET %s
    ]], table.concat(BOOK_COLS, ", "),
        table.concat(placeholders, ", "),
        table.concat(update_setters, ", "))

    local stmt = self.db:prepare(sql)
    stmt:reset()
    for i, col in ipairs(BOOK_COLS) do
        stmt:bind1(i, merged[col])
    end
    stmt:step()
    stmt:close()

    -- Cached groupings stale after any insert/update.
    self._groups_cache = {}
end

-- ---------------------------------------------------------------------------
-- getChangedBooks(since) — returns every row whose updated_at OR deleted_at
-- exceeds the watermark. Mirrors useBooksSync.getNewBooks at
-- apps/readest-app/src/app/library/hooks/useBooksSync.ts:22-35.
-- Used by the auto-sync push path on book close to send deltas.
-- ---------------------------------------------------------------------------
function M:getChangedBooks(since)
    since = tonumber(since) or 0
    local stmt = self.db:prepare(string.format([[
        SELECT %s FROM books
        WHERE user_id = ?
          AND (updated_at > ? OR (deleted_at IS NOT NULL AND deleted_at > ?))
        ORDER BY updated_at ASC
    ]], table.concat(BOOK_COLS, ", ")))
    stmt:reset():bind(self.user_id, since, since)
    local rows = {}
    while true do
        local r = stmt:step()
        if not r then break end
        rows[#rows + 1] = row_to_table(r)
    end
    stmt:close()
    return rows
end

-- ---------------------------------------------------------------------------
-- touchBook(hash, fields) — update updated_at + last_read_at to "now",
-- merge in any other fields the caller passes (commonly progress_lib),
-- return the resulting row (or nil if the book isn't in the index).
--
-- This is the local-write half of "after open/close, sync to server"; the
-- caller composes touchBook + syncbooks.pushBook to mirror what
-- Readest web does in updateBookProgress + the books-table sync push
-- (apps/readest-app/src/store/libraryStore.ts:105-122).
-- ---------------------------------------------------------------------------
function M:touchBook(hash, fields)
    if not hash or hash == "" then return nil end
    local existing = self:_getRowRaw(hash)
    if not existing then return nil end
    local now = os.time() * 1000
    local merge = {
        hash         = hash,
        title        = existing.title,
        updated_at   = now,
        last_read_at = now,
    }
    if fields then for k, v in pairs(fields) do merge[k] = v end end
    self:upsertBook(merge)
    return self:_getRowRaw(hash)
end

-- Internal: fetch a row by hash for the current user, returned as a table
-- keyed by column name. Exposed (with leading underscore) for spec checks.
function M:_getRowRaw(hash)
    local stmt = self.db:prepare(string.format(
        "SELECT %s FROM books WHERE user_id = ? AND hash = ?",
        table.concat(BOOK_COLS, ", ")))
    local row = stmt:reset():bind(self.user_id, hash):step()
    stmt:close()
    if not row then return nil end
    return row_to_table(row)
end

-- ---------------------------------------------------------------------------
-- listBooks
-- ---------------------------------------------------------------------------
-- filters: { search, sort_by, sort_asc, group_by, group_filter }
function M:listBooks(filters)
    filters = filters or {}
    local where = {
        "user_id = ?",
        "deleted_at IS NULL",
        VISIBLE_BOOK_SQL,
    }
    local args = { self.user_id }

    if filters.search and filters.search ~= "" then
        where[#where + 1] = "(LOWER(COALESCE(title, '')) LIKE ? OR LOWER(COALESCE(author, '')) LIKE ?)"
        local needle = "%" .. string.lower(filters.search) .. "%"
        args[#args + 1] = needle
        args[#args + 1] = needle
    end

    if filters.group_by and filters.group_filter then
        local col = GROUP_WHITELIST[filters.group_by] and filters.group_by
        if col then
            where[#where + 1] = col .. " = ?"
            args[#args + 1] = filters.group_filter
        end
    end

    -- "Books at this shelf level with no group value" — used by the
    -- bookshelf composer for the root view of group_by=author/series/group_name.
    if filters.ungrouped_col and GROUP_WHITELIST[filters.ungrouped_col] then
        local col = filters.ungrouped_col
        where[#where + 1] = "(" .. col .. " IS NULL OR " .. col .. " = '')"
    end

    local sort_by = SORT_WHITELIST[filters.sort_by] and filters.sort_by or "last_read_at"
    local sort_dir = filters.sort_asc and "ASC" or "DESC"
    -- "Date Read" semantics in this plugin = "any recent activity" (the
    -- web's Updated + Date Read concepts merged earlier). Prefer
    -- updated_at when present so a metadata bump (e.g. "Add to Readest"
    -- dedupe re-stamping updated_at) floats the row to the top even
    -- when last_read_at is older. Falls back to last_read_at for the
    -- rare row that has only the read timestamp (no updated_at).
    local sort_expr = (sort_by == "last_read_at")
        and "COALESCE(updated_at, last_read_at)"
        or sort_by

    local sql = string.format(
        "SELECT %s FROM books WHERE %s ORDER BY %s %s, hash ASC",
        table.concat(BOOK_COLS, ", "),
        table.concat(where, " AND "),
        sort_expr, sort_dir)

    local stmt = self.db:prepare(sql)
    stmt:reset()
    for i, v in ipairs(args) do stmt:bind1(i, v) end

    local rows = {}
    while true do
        local r = stmt:step()
        if not r then break end
        rows[#rows + 1] = row_to_table(r)
    end
    stmt:close()
    return rows
end

-- ---------------------------------------------------------------------------
-- listCloudOnlyBooks — the bulk-download candidate set (#4751)
-- ---------------------------------------------------------------------------
-- Books that are in the cloud with a downloadable file but not yet on this
-- device: cloud_present = 1, local_present = 0, not deleted, and with an
-- uploaded_at (a phantom record without an uploaded file is unreachable, so
-- it's excluded just like listBooks excludes it). Returns full rows so the
-- caller can hand each straight to syncbooks.downloadBook. Ordered newest
-- first for a sensible progress sequence; hash ASC tiebreak for determinism.
function M:listCloudOnlyBooks()
    local sql = string.format([[
        SELECT %s FROM books
        WHERE user_id = ?
          AND deleted_at IS NULL
          AND cloud_present = 1
          AND local_present = 0
          AND uploaded_at IS NOT NULL
        ORDER BY COALESCE(updated_at, created_at) DESC, hash ASC
    ]], table.concat(BOOK_COLS, ", "))
    local stmt = self.db:prepare(sql)
    stmt:reset()
    stmt:bind1(1, self.user_id)
    local rows = {}
    while true do
        local r = stmt:step()
        if not r then break end
        rows[#rows + 1] = row_to_table(r)
    end
    stmt:close()
    return rows
end

-- ---------------------------------------------------------------------------
-- getGroups
-- ---------------------------------------------------------------------------
-- Returns array of { name, count, latest_updated_at, latest_last_read_at,
-- latest_created_at } sorted by name. The per-sort aggregates let the
-- caller interleave groups and books in the merged shelf list using each
-- group's "most recent child" value (parity with Readest's
-- getGroupSortValue at apps/readest-app/src/app/library/utils/libraryUtils.ts:381-387).
-- Memoized per (user_id, group_by); invalidated by upsertBook.
function M:getGroups(group_by)
    if not GROUP_WHITELIST[group_by] then return {} end

    local cached = self._groups_cache[group_by]
    if cached then return cached end

    local sql = string.format([[
        SELECT %s AS name,
               COUNT(*) AS cnt,
               MAX(updated_at) AS latest_updated,
               MAX(COALESCE(updated_at, last_read_at)) AS latest_last_read,
               MAX(created_at) AS latest_created
        FROM books
        WHERE user_id = ? AND deleted_at IS NULL
          AND %s
          AND %s IS NOT NULL AND %s != ''
        GROUP BY %s
        ORDER BY name ASC
    ]], group_by, VISIBLE_BOOK_SQL, group_by, group_by, group_by)

    local stmt = self.db:prepare(sql)
    stmt:reset():bind1(1, self.user_id)
    local out = {}
    while true do
        local r = stmt:step()
        if not r then break end
        out[#out + 1] = {
            name = r[1],
            count = tonumber(r[2]),
            latest_updated_at   = tonumber(r[3]),
            latest_last_read_at = tonumber(r[4]),
            latest_created_at   = tonumber(r[5]),
        }
    end
    stmt:close()

    self._groups_cache[group_by] = out
    return out
end

-- ---------------------------------------------------------------------------
-- listBookshelfGroups(group_by, parent_path)
-- ---------------------------------------------------------------------------
-- Returns the group entries shown at the current shelf level, mirroring
-- Readest's library nav model:
--   author/series — flat groups; parent_path is ignored (only meaningful
--   at root). Each entry is the existing getGroups output with display_name
--   set to the group name.
--
--   group_name — nested folders. parent_path is the folder we're inside
--   (nil = root). We emit one entry per immediate-child segment, with the
--   `name` field carrying the full slash-delimited path so the caller can
--   pass it back as parent_path for drill-in.
--
-- Each returned entry is { _group=true, name, display_name, count,
-- latest_updated_at }, sorted by display_name ASC.
function M:listBookshelfGroups(group_by, parent_path)
    if not GROUP_WHITELIST[group_by] then return {} end

    if group_by ~= "group_name" then
        if parent_path then return {} end
        local out = {}
        for _i, g in ipairs(self:getGroups(group_by)) do
            out[#out + 1] = {
                _group              = true,
                name                = g.name,
                display_name        = g.name,
                count               = g.count,
                latest_updated_at   = g.latest_updated_at,
                latest_last_read_at = g.latest_last_read_at,
                latest_created_at   = g.latest_created_at,
            }
        end
        return out
    end

    -- group_name: walk distinct group_name values and bucket by immediate
    -- child segment relative to parent_path. SQLite doesn't have great
    -- string-slicing primitives, but the distinct-group_name set is small
    -- (one row per unique path), so a Lua-side bucket is cheap.
    -- Per-sort aggregates mirror getGroups so the merged-shelf sort can
    -- use a folder's "most recent child" timestamp under any sort_by.
    local stmt = self.db:prepare(string.format([[
        SELECT group_name,
               COUNT(*) AS cnt,
               MAX(updated_at) AS latest_updated,
               MAX(COALESCE(updated_at, last_read_at)) AS latest_last_read,
               MAX(created_at) AS latest_created
        FROM books
        WHERE user_id = ? AND deleted_at IS NULL
          AND %s
          AND group_name IS NOT NULL AND group_name != ''
        GROUP BY group_name
    ]], VISIBLE_BOOK_SQL))
    stmt:reset():bind1(1, self.user_id)

    local prefix = parent_path and (parent_path .. "/") or nil
    local prefix_len = prefix and #prefix or 0
    local children = {}  -- segment → aggregate accumulator

    while true do
        local r = stmt:step()
        if not r then break end
        local group_name      = r[1]
        local cnt             = tonumber(r[2]) or 0
        local latest_updated  = tonumber(r[3]) or 0
        local latest_lastread = tonumber(r[4]) or 0
        local latest_created  = tonumber(r[5]) or 0
        local rest
        if parent_path then
            -- "Fantasy" with parent="Fantasy" is a direct-child book, not
            -- a folder; skip from the folder list (caller picks it up via
            -- listBookshelfBooks).
            if group_name ~= parent_path
               and group_name:sub(1, prefix_len) == prefix then
                rest = group_name:sub(prefix_len + 1)
            end
        else
            rest = group_name
        end
        if rest and rest ~= "" then
            -- Match Readest's slashIndex > 0 semantics
            -- (apps/readest-app/src/app/library/components/BookshelfItem.tsx:43-44):
            -- a leading slash keeps the whole rest as the immediate-child name
            -- instead of producing an empty segment.
            local slash_pos = rest:find("/", 1, true)
            local segment
            if slash_pos and slash_pos > 1 then
                segment = rest:sub(1, slash_pos - 1)
            else
                segment = rest
            end
            local entry = children[segment]
            if entry then
                entry.count          = entry.count + cnt
                entry.latest_updated = math.max(entry.latest_updated, latest_updated)
                entry.latest_lastread = math.max(entry.latest_lastread, latest_lastread)
                entry.latest_created = math.max(entry.latest_created, latest_created)
            else
                children[segment] = {
                    count           = cnt,
                    latest_updated  = latest_updated,
                    latest_lastread = latest_lastread,
                    latest_created  = latest_created,
                }
            end
        end
    end
    stmt:close()

    local out = {}
    for segment, data in pairs(children) do
        out[#out + 1] = {
            _group              = true,
            name                = parent_path and (parent_path .. "/" .. segment) or segment,
            display_name        = segment,
            count               = data.count,
            latest_updated_at   = data.latest_updated,
            latest_last_read_at = data.latest_lastread,
            latest_created_at   = data.latest_created,
        }
    end
    table.sort(out, function(a, b) return a.display_name < b.display_name end)
    return out
end

-- ---------------------------------------------------------------------------
-- listBooksInGroup(group_by, group_value, limit, opts)
-- ---------------------------------------------------------------------------
-- Returns up to `limit` books in the group. opts.sort_by + opts.sort_asc
-- mirror M:listBooks, so the cover composer picks the same first-N
-- books the user would see when drilling in. Default sort:
-- COALESCE(updated_at, last_read_at) DESC.
--
-- For group_name, matches the path itself AND any descendant path
-- (so a top-level "Fantasy" preview pulls in books from Fantasy/Tolkien
-- /LOTR even when nothing lives at the root level).
function M:listBooksInGroup(group_by, group_value, limit, opts)
    if not GROUP_WHITELIST[group_by] then return {} end
    opts = opts or {}
    local where_extra, args
    if group_by == "group_name" then
        where_extra = "(group_name = ? OR group_name LIKE ?)"
        args = { self.user_id, group_value, group_value .. "/%", limit }
    else
        where_extra = group_by .. " = ?"
        args = { self.user_id, group_value, limit }
    end
    -- Honor the caller's current sort so the cover-preview composite
    -- picks the same first-N books the user would see when drilling in.
    -- Mirrors the sort_expr logic in M:listBooks above.
    local sort_by = SORT_WHITELIST[opts.sort_by] and opts.sort_by or "last_read_at"
    local sort_dir = opts.sort_asc and "ASC" or "DESC"
    local sort_expr = (sort_by == "last_read_at")
        and "COALESCE(updated_at, last_read_at)"
        or sort_by
    local sql = string.format([[
        SELECT %s FROM books
        WHERE user_id = ? AND deleted_at IS NULL
          AND %s
          AND %s
        ORDER BY %s %s, hash ASC
        LIMIT ?
    ]], table.concat(BOOK_COLS, ", "), VISIBLE_BOOK_SQL, where_extra, sort_expr, sort_dir)
    local stmt = self.db:prepare(sql)
    stmt:reset()
    for i, v in ipairs(args) do stmt:bind1(i, v) end
    local rows = {}
    while true do
        local r = stmt:step()
        if not r then break end
        rows[#rows + 1] = row_to_table(r)
    end
    stmt:close()
    return rows
end

-- ---------------------------------------------------------------------------
-- listBookshelfBooks(filters, group_by, parent_path)
-- ---------------------------------------------------------------------------
-- Returns the book rows that appear directly at the current shelf level
-- (siblings of the listBookshelfGroups output, NOT recursively).
--   group_by=nil/"none"            — all books matching filters
--   group_by=author/series, root   — books whose author/series is null/empty
--   group_by=author/series, drill  — books with col = parent_path
--   group_by=group_name, root      — books with null/empty group_name
--   group_by=group_name, drill     — books with group_name = parent_path
function M:listBookshelfBooks(filters, group_by, parent_path)
    local sub = {}
    for k, v in pairs(filters or {}) do sub[k] = v end
    if not GROUP_WHITELIST[group_by] then
        sub.group_by = nil
        sub.group_filter = nil
        return self:listBooks(sub)
    end
    if parent_path then
        sub.group_by = group_by
        sub.group_filter = parent_path
        return self:listBooks(sub)
    end
    sub.group_by = nil
    sub.group_filter = nil
    sub.ungrouped_col = group_by
    return self:listBooks(sub)
end

-- ---------------------------------------------------------------------------
-- parseSyncRow (pure helper, no DB access)
-- ---------------------------------------------------------------------------
-- Maps a raw /sync DB row (snake_case, ISO timestamps, JSON-string metadata)
-- to our internal row shape (fields ready for upsertBook). Returns nil for
-- the dummy initial-sync hash.
local DUMMY_HASH = "00000000000000000000000000000000"

-- ISO-8601 → unix ms. Accepts:
--   2026-02-01T00:00:00Z
--   2026-02-01T00:00:00+00:00          (Supabase / Postgres native; the common case)
--   2026-02-01T00:00:00+0000
--   2026-02-01T00:00:00.123456+00:00   (with fractional seconds)
--   2026-02-01 00:00:00+00:00          (Postgres without the T separator)
local function iso_to_ms(s)
    if not s then return nil end
    if type(s) == "number" then return s end
    if type(s) ~= "string" then return nil end

    local y, mo, d, h, mi, sec, frac, tz = s:match(
        "^(%d%d%d%d)%-(%d%d)%-(%d%d)[T ](%d%d):(%d%d):(%d%d)([%.%d]*)(.*)$")
    if not y then return nil end

    local t = os.time({
        year = tonumber(y), month = tonumber(mo), day = tonumber(d),
        hour = tonumber(h), min  = tonumber(mi), sec  = tonumber(sec),
        isdst = false,
    })
    -- os.time interprets the struct as LOCAL time; convert to UTC by
    -- subtracting the local TZ offset.
    local utc_offset = os.difftime(t, os.time(os.date("!*t", t)))
    t = t + utc_offset

    -- Apply the input's own offset (Z = +00:00; "+05:30" subtracts 5.5h to
    -- get UTC). Default to UTC if no offset present (server contract).
    if tz and tz ~= "" and tz ~= "Z" then
        local sign, oh, om = tz:match("^([%+%-])(%d%d):?(%d%d)$")
        if sign then
            local off = (tonumber(oh) * 3600) + (tonumber(om or 0) * 60)
            if sign == "+" then t = t - off else t = t + off end
        end
    end

    local ms = t * 1000
    if frac and frac:sub(1, 1) == "." then
        -- Fractional seconds: take only the first 3 digits (ms precision)
        local f = frac:sub(2, 4)
        if #f > 0 then
            ms = ms + tonumber(f .. string.rep("0", 3 - #f))
        end
    end
    return ms
end

function M.parseSyncRow(dbRow)
    if not dbRow then return nil end
    local hash = dbRow.book_hash or dbRow.hash
    if not hash or hash == DUMMY_HASH then return nil end

    local out = {
        hash         = hash,
        meta_hash    = dbRow.meta_hash,
        title        = dbRow.title or "Untitled",
        source_title = dbRow.source_title,
        author       = dbRow.author,
        format       = dbRow.format,
        group_id     = dbRow.group_id,
        group_name   = dbRow.group_name,
        uploaded_at  = iso_to_ms(dbRow.uploaded_at),
        updated_at   = iso_to_ms(dbRow.updated_at),
        created_at   = iso_to_ms(dbRow.created_at),
        deleted_at   = iso_to_ms(dbRow.deleted_at),
        -- Server-stamped pull cursor (issue #4678); transient — used only to
        -- advance last_books_pulled_at, not persisted as a books column.
        synced_at    = iso_to_ms(dbRow.synced_at),
    }

    -- Metadata: parse JSON string OR accept an already-parsed table; extract
    -- series/series_index into denormalized columns; round-trip the raw JSON
    -- so callers can read other fields lazily later.
    if dbRow.metadata ~= nil then
        local meta
        if type(dbRow.metadata) == "string" then
            local ok, parsed = pcall(json.decode, dbRow.metadata)
            if ok and type(parsed) == "table" then meta = parsed end
        elseif type(dbRow.metadata) == "table" then
            meta = dbRow.metadata
        end
        if meta then
            out.series = meta.series
            out.series_index = meta.seriesIndex
            local ok, encoded = pcall(json.encode, meta)
            if ok then out.metadata_json = encoded end
        end
    end

    -- Progress: snake-case web shape is `progress = [cur, total]`.
    if dbRow.progress and type(dbRow.progress) == "table" then
        local ok, encoded = pcall(json.encode, dbRow.progress)
        if ok then out.progress_lib = encoded end
    end

    -- Reading status passthrough (web side has 'unread'/'reading'/'finished')
    out.reading_status = dbRow.readingStatus or dbRow.reading_status
    -- ms; server sends it as a timestamptz ISO string (iso_to_ms also passes
    -- through a raw number when a caller already supplied ms).
    out.reading_status_updated_at = iso_to_ms(dbRow.reading_status_updated_at)
        or iso_to_ms(dbRow.readingStatusUpdatedAt)

    -- Cloud-presence flag: tombstones from the cloud arrive with deleted_at
    -- set; the row is still useful for tracking that the cloud copy is gone,
    -- but it doesn't count as cloud-present anymore. Force the flag through
    -- upsertBook's OR-merge with the sentinel.
    if out.deleted_at then
        out.cloud_present = 0
        out._force_cloud_present = true
    else
        out.cloud_present = 1
    end

    return out
end

return M
