local Dispatcher = require("dispatcher")
local InfoMessage = require("ui/widget/infomessage")
local KeyValuePage = require("ui/widget/keyvaluepage")
local WidgetContainer = require("ui/widget/container/widgetcontainer")
local NetworkMgr = require("ui/network/manager")
local UIManager = require("ui/uimanager")
local logger = require("logger")
local sha2 = require("ffi/sha2")
local T = require("ffi/util").template
local _ = require("readest_i18n")

local SyncAuth = require("readest_syncauth")
local SyncConfig = require("readest_syncconfig")
local SyncAnnotations = require("readest_syncannotations")
local SyncStats = require("readest_syncstats")
local SelfUpdate = require("readest_selfupdate")

local ReadestSync = WidgetContainer:new{
    name = "readest",
    title = _("Readest"),
    settings = nil,
}

local API_CALL_DEBOUNCE_DELAY = 30
-- Delay before a background pull runs (book open, device wake, network back).
-- Lets the reader paint and become interactive first, gives Wi-Fi a moment to
-- settle after wake (kosync uses the same 1s), and coalesces rapid triggers
-- into a single pull for the book you settle on instead of stacking blocking
-- round-trips (#5006).
local BACKGROUND_PULL_DELAY = 1

-- KOReader's "Action when Wi-Fi is off" (Network settings). nil is "prompt",
-- the default; "turn on" brings Wi-Fi up without asking; "ignore" (Android)
-- waits for the system to reconnect. Only "prompt" puts a dialog in front of
-- the reader.
local function wifiEnableAction()
    return G_reader_settings:readSetting("wifi_enable_action") or "prompt"
end
local SUPABAE_ANON_KEY_BASE64 = "ZXlKaGJHY2lPaUpJVXpJMU5pSXNJblI1Y0NJNklrcFhWQ0o5LmV5SnBjM01pT2lKemRYQmhZbUZ6WlNJc0luSmxaaUk2SW5aaWMzbDRablZ6YW1weFpIaHJhbkZzZVhOaklpd2ljbTlzWlNJNkltRnViMjRpTENKcFlYUWlPakUzTXpReE1qTTJOekVzSW1WNGNDSTZNakEwT1RZNU9UWTNNWDAuM1U1VXFhb3VfMVNnclZlMWVvOXJBcGMwdUtqcWhwUWRVWGh2d1VIbVVmZw=="

ReadestSync.default_settings = {
    supabase_url = "https://readest.supabase.co",
    supabase_anon_key = sha2.base64_to_bin(SUPABAE_ANON_KEY_BASE64),
    auto_sync = false,
    user_email = nil,
    user_name = nil,
    user_id = nil,
    access_token = nil,
    refresh_token = nil,
    expires_at = nil,
    expires_in = nil,
    last_sync_at = nil,
    localsend_enabled = false,
    localsend_alias = nil,
}

-- ── Lifecycle ──────────────────────────────────────────────────────

function ReadestSync:init()
    self.last_sync_timestamp = 0
    self.settings = G_reader_settings:readSetting("readest_sync", self.default_settings)

    local meta = dofile(self.path .. "/_meta.lua")
    self.installed_version = meta and meta.version and tostring(meta.version)

    self.ui.menu:registerToMainMenu(self)
    -- Dispatcher actions (gestures / quick menu entries). Registering
    -- here in init() rather than onReaderReady so the FileManager-only
    -- actions (Readest Library, Push books, Pull books — flagged with
    -- general=true) show up in FileManager → Settings → Taps and gestures
    -- when no document is open. Reader-only actions (auto sync etc.)
    -- still get registered on first plugin load too — they just don't
    -- fire until a document opens.
    self:onDispatcherRegisterActions()
    -- Long-press file menu in FileManager: add an "Add to Readest"
    -- entry that hashes the file, registers it in the LibraryStore, and
    -- uploads it to the user's Readest cloud. Skipped in reader context
    -- (FileManager.instance is nil there).
    self:registerFileDialogButton()
    -- LocalSend receive (module singleton; re-attaches on context switch).
    self.localsend = require("readest_localsend")
    self.localsend:init(self)
end

-- Register Library actions (Open / Push / Pull) — available in both
-- reader and FileManager via the `general=true` flag.
function ReadestSync:onDispatcherRegisterActions()
    Dispatcher:registerAction("readest_open_library", { category="none", event="ReadestOpenLibrary", title=_("Open Readest Library"), general=true,})
    Dispatcher:registerAction("readest_push_books", { category="none", event="ReadestPushBooks", title=_("Push Readest book library"), general=true,})
    Dispatcher:registerAction("readest_pull_books", { category="none", event="ReadestPullBooks", title=_("Pull Readest book library"), general=true,})
end

-- Reader-only actions (progress + annotations sync) — registered only
-- after a document opens, so they don't appear in the gesture picker
-- when FileManager is the foreground context. They'd never fire from
-- FileManager anyway (the handlers operate on the active ReaderUI), so
-- showing them there would just be noise.
function ReadestSync:onDispatcherRegisterReaderActions()
    Dispatcher:registerAction("readest_sync_set_autosync",
        { category="string", event="ReadestSyncToggleAutoSync", title=_("Set auto progress sync"), reader=true,
        args={true, false}, toggle={_("on"), _("off")},})
    Dispatcher:registerAction("readest_sync_toggle_autosync", { category="none", event="ReadestSyncToggleAutoSync", title=_("Toggle auto readest sync"), reader=true,})
    Dispatcher:registerAction("readest_sync_push_progress", { category="none", event="ReadestSyncPushProgress", title=_("Push readest progress from this device"), reader=true,})
    Dispatcher:registerAction("readest_sync_pull_progress", { category="none", event="ReadestSyncPullProgress", title=_("Pull readest progress from other devices"), reader=true, separator=true,})
    Dispatcher:registerAction("readest_sync_push_annotations", { category="none", event="ReadestSyncPushAnnotations", title=_("Push readest annotations from this device"), reader=true,})
    Dispatcher:registerAction("readest_sync_pull_annotations", { category="none", event="ReadestSyncPullAnnotations", title=_("Pull readest annotations from other devices"), reader=true,})
    -- Issue #5094: reaching a full annotation sync through the menu takes
    -- several taps. The title keeps the "readest" token because the gesture
    -- picker pools every plugin's actions into one flat list.
    Dispatcher:registerAction("readest_sync_full_annotations", { category="none", event="ReadestSyncFullSyncAnnotations", title=_("Full sync all readest annotations"), reader=true, separator=true,})
end

function ReadestSync:onReaderReady()
    if self.settings.auto_sync and self.settings.access_token then
        -- Defer the per-book pull so the reader is interactive first (issue #5006).
        self:scheduleBackgroundPull(BACKGROUND_PULL_DELAY)
    end
    self:onDispatcherRegisterReaderActions()
end

-- Schedule the background pull of the open book's config, notes and stats.
-- One handle for every trigger (book open, device wake, network back), so
-- rapid events coalesce into a single pull instead of stacking blocking
-- round-trips on the UI thread, and onCloseWidget cancels whatever is pending.
function ReadestSync:scheduleBackgroundPull(delay)
    if self.background_pull_task then
        UIManager:unschedule(self.background_pull_task)
    end
    self.background_pull_task = function()
        self.background_pull_task = nil
        self:pullBookConfig(false)
        self:pullBookNotes(false)
        self:pullBookStats(false)
    end
    UIManager:scheduleIn(delay, self.background_pull_task)
end

-- Reverse-lookup table: file extension (lowercase) → Readest format
-- token (uppercase). Computed once at module load from EXTS so we don't
-- pay the iteration cost per long-press.
local _readest_format_for_ext = nil
local function readest_format_for_ext(ext)
    if not _readest_format_for_ext then
        _readest_format_for_ext = {}
        local EXTS = require("library.exts")
        for fmt, e in pairs(EXTS) do _readest_format_for_ext[e] = fmt end
    end
    return ext and _readest_format_for_ext[ext:lower()]
end

-- Register an "Add to Readest" button in FileManager's long-press file
-- dialog (the one with Paste / Cut / Delete / Copy / Reading / On hold
-- / etc.). The button only renders for supported book formats and
-- when the user is signed in to Readest.
--
-- Why scheduleIn(0): plugin init() runs while KOReader is still
-- constructing the FileManager — FileManager.instance is not yet
-- assigned. Deferring to the next event-loop tick (same trick simpleui
-- uses at plugins/simpleui.koplugin/main.lua:261) lets the FileManager
-- finish its own init first so .instance is populated when we look it
-- up. Safe in reader context too: .instance stays nil so we no-op.
function ReadestSync:registerFileDialogButton()
    local plugin = self
    UIManager:scheduleIn(0, function()
        local ok_FM, FileManager = pcall(require, "apps/filemanager/filemanager")
        if not ok_FM or not FileManager.instance then return end
        FileManager.instance:addFileDialogButtons("readest_add_to_library",
            function(file, is_file, _book_props)
                if not is_file then return nil end
                local ext = file:match("%.([^./\\]+)$")
                if not readest_format_for_ext(ext) then return nil end
                return {
                    {
                        text = _("Add to Readest library"),
                        enabled = plugin.settings.access_token ~= nil,
                        callback = function()
                            local fc = FileManager.instance and FileManager.instance.file_chooser
                            local dlg = fc and fc.file_dialog
                            if dlg then UIManager:close(dlg) end
                            plugin:addToReadest(file)
                        end,
                    },
                }
            end)
        -- Second row (own registration id): shown only for supported book
        -- formats, and only once a LocalSend helper binary exists for this
        -- device (plugin.localsend:init() sets that during plugin init()).
        FileManager.instance:addFileDialogButtons("readest_send_localsend",
            function(file, is_file, _book_props)
                if not is_file then return nil end
                local ext = file:match("%.([^./\\]+)$")
                if not readest_format_for_ext(ext) then return nil end
                if not (plugin.localsend and plugin.localsend:isAvailable()) then return nil end
                return {
                    {
                        text = _("Send to nearby Readest devices"),
                        callback = function()
                            local fc = FileManager.instance and FileManager.instance.file_chooser
                            local dlg = fc and fc.file_dialog
                            if dlg then UIManager:close(dlg) end
                            plugin.localsend:sendFile(file)
                        end,
                    },
                }
            end)
    end)
end

-- Hash the file, upsert a Library row, then upload to cloud. Mirrors
-- the upload flow we already use in librarywidget's "Upload to Cloud"
-- action — the only new thing here is computing the partial_md5 from
-- the file directly, since long-pressing in FileManager doesn't go
-- through the Library row path.
function ReadestSync:addToReadest(file, opts)
    opts = opts or {}
    local lfs    = require("libs/libkoreader-lfs")
    local util   = require("util")

    if not self.settings.access_token then
        if not opts.silent then
            UIManager:show(InfoMessage:new{
                text = _("Sign in to Readest first."), timeout = 3,
            })
        end
        return
    end
    local attr = lfs.attributes(file)
    if not attr or attr.mode ~= "file" then
        if not opts.silent then
            UIManager:show(InfoMessage:new{
                text = _("File not found."), timeout = 3,
            })
        end
        return
    end
    local ext = file:match("%.([^./\\]+)$")
    local format = readest_format_for_ext(ext)
    if not format then
        if not opts.silent then
            UIManager:show(InfoMessage:new{
                text = _("Unsupported book format."), timeout = 3,
            })
        end
        return
    end

    -- Hash via util.partialMD5 — same algorithm Readest uses, fast
    -- (reads small chunks at fixed offsets, no full-file scan). Runs
    -- unconditionally regardless of opts.silent — only the InfoMessages
    -- announcing it are gated.
    local progress
    if not opts.silent then
        progress = InfoMessage:new{
            text = _("Hashing book…"),
        }
        UIManager:show(progress)
    end
    UIManager:nextTick(function()
        local hash = util.partialMD5(file)
        if progress then UIManager:close(progress) end
        if not hash then
            if not opts.silent then
                UIManager:show(InfoMessage:new{
                    text = _("Could not read file."), timeout = 3,
                })
            end
            return
        end
        self:_addLocalRow(file, hash, format, attr.size, opts)
    end)
end

function ReadestSync:_addLocalRow(file, hash, format, _size, opts)
    local store = self:getLibraryStore()
    if not store then
        if not (opts and opts.silent) then
            UIManager:show(InfoMessage:new{
                text = _("Sign in to Readest first."), timeout = 3,
            })
        end
        return
    end

    -- Title: filename minus extension. BIM might have richer metadata
    -- if the user has opened the book before, but a filename-derived
    -- title is good enough for the row to round-trip — the user can
    -- later trigger an Upload to Cloud which carries it to Readest.
    local basename = file:match("([^/]+)$") or file
    local title = basename:gsub("%.[^.]+$", "")
    local now = math.floor(os.time() * 1000)
    logger.info("ReadestSync addToReadest: hash=" .. hash:sub(1, 8)
        .. " title=" .. tostring(title)
        .. " format=" .. tostring(format)
        .. " path=" .. tostring(file)
        .. " now=" .. tostring(now))

    -- Dedupe by partial_md5: if a non-tombstoned row with this hash is
    -- already in the user's library, just bump its updated_at and skip.
    -- Tombstoned rows (deleted_at set) are treated as "not in library"
    -- — re-adding writes a fresh local-only row with cloud_present=0.
    local existing = store:_getRowRaw(hash)
    if existing then
        logger.info("ReadestSync addToReadest: existing row found"
            .. " cloud_present=" .. tostring(existing.cloud_present)
            .. " local_present=" .. tostring(existing.local_present)
            .. " deleted_at=" .. tostring(existing.deleted_at)
            .. " updated_at=" .. tostring(existing.updated_at))
    else
        logger.info("ReadestSync addToReadest: no existing row — inserting")
    end
    if existing and existing.deleted_at == nil then
        store:upsertBook({
            hash          = hash,
            title         = existing.title or title,
            file_path     = file,
            local_present = 1,
            updated_at    = now,
        })
        logger.info("ReadestSync addToReadest dedupe: bumped updated_at for "
            .. hash:sub(1, 8) .. " to " .. tostring(now))
        local LibraryWidget = require("library.librarywidget")
        if LibraryWidget._menu then LibraryWidget.refresh() end
        if not (opts and opts.silent) then
            UIManager:show(InfoMessage:new{
                text = _("Already in your Readest library:") .. " "
                    .. (existing.title or title),
                timeout = 2,
            })
        end
        return
    end

    -- A sidecar from a previous read is the only metadata available without
    -- opening the book. When one exists, stamp the fingerprint Readest's
    -- importBook would (PDF-salted, issue #5411) so peers preserve it; with
    -- no sidecar, leave meta_hash unset and Readest stamps it on first open.
    local meta_hash
    local ok, DocSettings = pcall(require, "docsettings")
    if ok and DocSettings and DocSettings:hasSidecarFile(file) then
        local doc_props = DocSettings:open(file):readSetting("doc_props")
        if doc_props then
            meta_hash = SyncConfig:computeMetadataHashInfo(doc_props, file).meta_hash
        end
    end

    -- Add as a local-only row (cloud_present defaults to 0). Stamp
    -- created_at + updated_at explicitly so the row sorts under "Date
    -- Added" / "Last Updated" right away, and so the next pushChangedBooks
    -- pass picks it up (its query is `updated_at > since`).
    -- _clear_fields un-tombstones the row when re-adding a previously-
    -- deleted book — passing deleted_at = nil alone wouldn't clear it
    -- because Lua tables drop nil keys and upsertBook's preserve pass
    -- would copy the existing tombstone forward.
    store:upsertBook({
        hash          = hash,
        title         = title,
        format        = format,
        meta_hash     = meta_hash,
        file_path     = file,
        local_present = 1,
        created_at    = now,
        updated_at    = now,
        _clear_fields = { "deleted_at" },
    })
    local row = store:_getRowRaw(hash)
    logger.info("ReadestSync addToReadest insert: stored row"
        .. " updated_at=" .. tostring(row and row.updated_at)
        .. " created_at=" .. tostring(row and row.created_at)
        .. " cloud_present=" .. tostring(row and row.cloud_present)
        .. " local_present=" .. tostring(row and row.local_present))
    local LibraryWidget = require("library.librarywidget")
    if LibraryWidget._menu then LibraryWidget.refresh() end
    if not (opts and opts.silent) then
        UIManager:show(InfoMessage:new{
            text = _("Added to Readest:") .. " " .. title,
            timeout = 2,
        })
    end
end

function ReadestSync:onAddToReadest(file)
    self:addToReadest(file)
end

local function refreshLibraryWidget()
    local LibraryWidget = require("library.librarywidget")
    if LibraryWidget._menu then LibraryWidget.refresh() end
end

-- Upload the book currently open in the reader, mirroring the Library
-- widget's long-press "Upload to Cloud" (library/librarywidget.lua). Both
-- routes end in syncbooks.uploadAndRecord, so the cloud + store bookkeeping
-- stays identical between them.
--
-- The wrinkle the Library route doesn't have: a book you sideloaded and
-- opened may have no LibraryStore row at all — rows only appear via "Add to
-- Readest" or a cloud pull — and uploadBook needs one for the hash, format
-- and file path. So we create the row first, exactly as addToReadest does.
function ReadestSync:uploadCurrentBook()
    if not self.settings.access_token then
        UIManager:show(InfoMessage:new{
            text = _("Sign in to Readest first."), timeout = 3,
        })
        return
    end
    if not self.ui.document then
        UIManager:show(InfoMessage:new{
            text = _("No book is open"), timeout = 2,
        })
        return
    end

    local file = self.ui.document.file
    local ext = file and file:match("%.([^./\\]+)$")
    local format = readest_format_for_ext(ext)
    if not format then
        UIManager:show(InfoMessage:new{
            text = _("Unsupported book format."), timeout = 3,
        })
        return
    end

    local store = self:getLibraryStore()
    if not store then
        UIManager:show(InfoMessage:new{
            text = _("Sign in to Readest first."), timeout = 3,
        })
        return
    end

    -- KOReader stamps partial_md5_checksum into the .sdr sidecar for most
    -- books, but localscanner treats it as optional, so fall back to computing
    -- it — same util.partialMD5 addToReadest uses.
    local hash = self.ui.doc_settings:readSetting("partial_md5_checksum")
    if hash and hash ~= "" then
        self:_uploadBookRow(store, file, hash, format)
        return
    end

    local util = require("util")
    local progress = InfoMessage:new{
        text = _("Hashing book…"),
    }
    UIManager:show(progress)
    UIManager:nextTick(function()
        local computed = util.partialMD5(file)
        UIManager:close(progress)
        if not computed then
            UIManager:show(InfoMessage:new{
                text = _("Could not read file."), timeout = 3,
            })
            return
        end
        self:_uploadBookRow(store, file, computed, format)
    end)
end

function ReadestSync:_uploadBookRow(store, file, hash, format)
    local now = math.floor(os.time() * 1000)
    local existing = store:_getRowRaw(hash)

    -- Prefer the title the library already holds: the user may have renamed
    -- the book, or a cloud pull may carry richer metadata than the file does.
    local title = existing and existing.title
    if not title or title == "" then
        local doc_props = self.ui.doc_settings:readSetting("doc_props") or {}
        if doc_props.title and doc_props.title ~= "" then
            title = doc_props.title
        else
            local basename = file:match("([^/]+)$") or file
            title = basename:gsub("%.[^.]+$", "")
        end
    end

    -- Uploading introduces the book to the fleet, so stamp the same
    -- fingerprint Readest's importBook would; peers preserve whatever is
    -- stamped here. getMetaHash keeps an existing fleet value if the row
    -- already carries one.
    local meta_hash = SyncConfig:getMetaHash(self.ui, store)

    -- Same row shape addToReadest writes, so both entry points produce the
    -- same row for the same book. _clear_fields un-tombstones a previously
    -- deleted book: a bare deleted_at = nil would be dropped by Lua's table
    -- semantics and then copied forward by upsertBook's preserve pass.
    store:upsertBook({
        hash          = hash,
        title         = title,
        format        = format,
        meta_hash     = meta_hash,
        file_path     = file,
        local_present = 1,
        created_at    = (existing and existing.created_at) or now,
        updated_at    = now,
        _clear_fields = { "deleted_at" },
    })

    local row = store:_getRowRaw(hash)
    local progress = InfoMessage:new{
        text = _("Uploading…") .. " " .. (row.title or ""),
    }
    UIManager:show(progress)

    local DataStorage = require("datastorage")
    local syncbooks = require("library.syncbooks")
    syncbooks.uploadAndRecord(row, {
        sync_auth  = SyncAuth,
        sync_path  = self.path,
        settings   = self.settings,
        store      = store,
        covers_dir = DataStorage:getSettingsDir() .. "/readest_covers",
        on_pushed  = refreshLibraryWidget,
    }, function(success, msg, status)
        UIManager:close(progress)
        if not success then
            local text
            if status == 403 and msg and msg:find("quota", 1, true) then
                text = _("Storage quota exceeded.")
            else
                text = _("Upload failed.")
                    .. " (" .. tostring(msg or status) .. ")"
            end
            UIManager:show(InfoMessage:new{ text = text, timeout = 4 })
            return
        end
        UIManager:show(InfoMessage:new{
            text = _("Uploaded to Readest:") .. " " .. (row.title or ""),
            timeout = 2,
        })
        refreshLibraryWidget()
    end)
end

-- ── Menu ───────────────────────────────────────────────────────────

function ReadestSync:addToMainMenu(menu_items)
    menu_items.readest_sync = {
        sorting_hint = "tools",
        text = _("Readest"),
        sub_item_table = {
            {
                text_func = function()
                    return SyncAuth:needsLogin(self.settings) and _("Log in Readest Account")
                        or T(_("Log out as %1"), self.settings.user_name or "")
                end,
                callback_func = function()
                    if SyncAuth:needsLogin(self.settings) then
                        return function(menu)
                            SyncAuth:login(self.settings, self.path, self.title, menu)
                        end
                    else
                        return function(menu)
                            SyncAuth:logout(self.settings, self.path, menu)
                        end
                    end
                end,
            },
            {
                text = _("Auto sync"),
                checked_func = function() return self.settings.auto_sync end,
                callback = function()
                    self:onReadestSyncToggleAutoSync()
                end,
                separator = true,
            },
            {
                text = _("Readest library"),
                enabled_func = function()
                    return self.settings.access_token ~= nil and self.settings.user_id ~= nil
                end,
                callback = function()
                    self:openLibrary()
                end,
            },
            {
                text = _("Push stats now"),
                enabled_func = function()
                    return self.settings.access_token ~= nil and self.settings.user_id ~= nil
                end,
                callback = function()
                    self:pushBookStats(true)
                end,
            },
            {
                text = _("Pull stats now"),
                enabled_func = function()
                    return self.settings.access_token ~= nil and self.settings.user_id ~= nil
                end,
                callback = function()
                    self:pullBookStats(true)
                end,
            },
            {
                text = _("Push books now"),
                enabled_func = function()
                    return self.settings.access_token ~= nil and self.settings.user_id ~= nil
                end,
                callback = function()
                    self:syncBooksLibrary("push", true)
                end,
            },
            {
                text = _("Pull books now"),
                enabled_func = function()
                    return self.settings.access_token ~= nil and self.settings.user_id ~= nil
                end,
                callback = function()
                    self:syncBooksLibrary("pull", true)
                end,
            },
            {
                text = _("Receive via Nearby BookDrop"),
                enabled_func = function()
                    return self.localsend:isAvailable()
                end,
                checked_func = function()
                    return self.settings.localsend_enabled == true
                end,
                callback = function()
                    self.localsend:toggle()
                end,
            },
            {
                text_func = function()
                    return self.localsend:statusText()
                end,
                enabled_func = function() return false end,
                separator = true,
            },
            {
                text = _("Upload current book to Readest"),
                enabled_func = function()
                    return self.settings.access_token ~= nil and self.ui.document ~= nil
                end,
                callback = function()
                    self:uploadCurrentBook()
                end,
            },
            {
                text = _("Push reading progress now"),
                enabled_func = function()
                    return self.settings.access_token ~= nil and self.ui.document ~= nil
                end,
                callback = function()
                    self:pushBookConfig(true)
                end,
            },
            {
                text = _("Pull reading progress now"),
                enabled_func = function()
                    return self.settings.access_token ~= nil and self.ui.document ~= nil
                end,
                callback = function()
                    self:pullBookConfig(true)
                end,
                separator = true,
            },
            {
                text = _("Push annotations now"),
                enabled_func = function()
                    return self.settings.access_token ~= nil and self.ui.document ~= nil
                end,
                callback = function()
                    self:pushBookNotes(true)
                end,
            },
            {
                text = _("Pull annotations now"),
                enabled_func = function()
                    return self.settings.access_token ~= nil and self.ui.document ~= nil
                end,
                callback = function()
                    self:pullBookNotes(true)
                end,
            },
            {
                text = _("Full sync all annotations"),
                enabled_func = function()
                    return self.settings.access_token ~= nil and self.ui.document ~= nil
                end,
                callback = function()
                    self:fullSyncBookNotes()
                end,
                separator = true,
            },
            {
                text = _("Sync info"),
                enabled_func = function()
                    return self.ui.document ~= nil
                end,
                callback = function()
                    self:showSyncInfo()
                end,
                separator = true,
            },
            {
                text_func = function()
                    if self.installed_version then
                        return T(_("Check for update (v%1)"), self.installed_version)
                    end
                    return _("Check for update")
                end,
                callback = function()
                    SelfUpdate:checkForUpdate(self.path, self.installed_version)
                end,
            },
        }
    }
end

-- ── Sync helpers (thin wrappers around modules) ────────────────────

function ReadestSync:ensureClient(interactive)
    if not self.settings.access_token or not self.settings.user_id then
        if interactive then
            UIManager:show(InfoMessage:new{
                text = _("Please login first"),
                timeout = 2,
            })
        end
        return nil
    end

    SyncAuth:tryRefreshToken(self.settings, self.path)

    local client = SyncAuth:getReadestSyncClient(self.settings, self.path)
    if not client then
        if interactive then
            UIManager:show(InfoMessage:new{
                text = _("Please configure Readest settings first"),
                timeout = 3,
            })
        end
        return nil
    end
    return client
end

function ReadestSync:getBookIdentifiers()
    local book_hash = SyncConfig:getDocumentIdentifier(self.ui)
    -- The library store may hold the fleet-stamped meta_hash for this book
    -- (pulled from the cloud); getMetaHash prefers it over local computation.
    local meta_hash = SyncConfig:getMetaHash(self.ui, self:getLibraryStore())
    return book_hash, meta_hash
end

function ReadestSync:showSyncInfo()
    if not self.ui.document then
        UIManager:show(InfoMessage:new{
            text = _("No book is open"),
            timeout = 2,
        })
        return
    end

    local info = SyncConfig:getMetadataHashInfo(self.ui)
    local doc_readest_sync = self.ui.doc_settings:readSetting("readest_sync") or {}
    local stored_meta_hash = doc_readest_sync.meta_hash_v1
    local placeholder = _("(none)")

    local last_synced_at = math.max(
        doc_readest_sync.last_synced_at_config or 0,
        doc_readest_sync.last_synced_at_notes or 0
    )
    local last_synced_label = last_synced_at > 0
        and os.date("%Y-%m-%d %H:%M", last_synced_at)
        or _("Never synced")

    local kv_pairs = {
        { _("Book Fingerprint"), stored_meta_hash or info.meta_hash },
    }
    table.insert(kv_pairs, { _("Title"), info.title ~= "" and info.title or placeholder })
    table.insert(kv_pairs, {
        _("Author"),
        #info.authors > 0 and table.concat(info.authors, ", ") or placeholder,
    })
    table.insert(kv_pairs, {
        _("Identifiers"),
        #info.identifiers > 0 and table.concat(info.identifiers, ", ") or placeholder,
    })
    table.insert(kv_pairs, { _("Last Synced"), last_synced_label })

    UIManager:show(KeyValuePage:new{
        title = _("Sync Info"),
        kv_pairs = kv_pairs,
    })
end

-- Gate a pull on connectivity. Returns true when the caller must stop
-- (offline), false to proceed.
--
-- Interactive pulls (menu tap / gesture) go through
-- NetworkMgr:willRerunWhenOnline, which brings Wi-Fi up per KOReader's
-- "Action when Wi-Fi is off" setting and reruns the call once connected,
-- like every other interactive network action in KOReader.
--
-- Background pulls (auto sync on book open / device wake) take that path
-- only when the configured action is silent ("turn on", "ignore"): the user
-- told KOReader to handle Wi-Fi without asking, so the pull behaves as it
-- always did. With "prompt" (the default) the same path puts a "Do you want
-- to turn on Wi-Fi?" box in front of the reader on every open and wake, one
-- per pull (#4113, #2137, #5838), and a background task must never do that.
-- So a background pull then skips silently when offline, remembers that it
-- skipped, and onNetworkConnected reruns it once the device is back online
-- (KOReader broadcasts NetworkConnected after its own silent "Restore Wi-Fi
-- connection on resume" completes, and after a manual toggle).
function ReadestSync:willRerunPullWhenOnline(interactive, callback)
    if interactive or wifiEnableAction() ~= "prompt" then
        return NetworkMgr:willRerunWhenOnline(callback)
    end
    if NetworkMgr:isOnline() then
        return false
    end
    logger.dbg("ReadestSync: offline; skipping background pull until NetworkConnected")
    self.pull_pending_offline = true
    return true
end

-- ── Config sync ────────────────────────────────────────────────────

function ReadestSync:pushBookConfig(interactive)
    local now = os.time()
    if not interactive and now - self.last_sync_timestamp <= API_CALL_DEBOUNCE_DELAY then
        return
    end

    if interactive and NetworkMgr:willRerunWhenOnline(function() self:pushBookConfig(interactive) end) then
        return
    end

    local client = self:ensureClient(interactive)
    if not client then return end

    self.last_sync_timestamp = SyncConfig:push(
        self.ui, self.settings, client, interactive, self.last_sync_timestamp
    )
end

function ReadestSync:pullBookConfig(interactive)
    local book_hash, meta_hash = self:getBookIdentifiers()
    if not book_hash or not meta_hash then return end

    if self:willRerunPullWhenOnline(interactive, function() self:pullBookConfig(interactive) end) then
        return
    end

    local client = self:ensureClient(interactive)
    if not client then return end

    SyncConfig:pull(
        self.ui, self.settings, client, book_hash, meta_hash, interactive,
        function() SyncAuth:logout(self.settings, self.path) end
    )
end

-- ── Reading statistics sync ────────────────────────────────────────

function ReadestSync:pushBookStats(interactive)
    logger.dbg("ReadestStats pushBookStats: triggered, interactive=" .. tostring(interactive))
    if interactive and NetworkMgr:willRerunWhenOnline(function() self:pushBookStats(interactive) end) then
        return
    end
    local client = self:ensureClient(interactive)
    if not client then
        logger.dbg("ReadestStats pushBookStats: no client (not signed in / offline); skipping")
        return
    end
    SyncStats:push(self.settings, client, interactive)
end

function ReadestSync:pullBookStats(interactive)
    logger.dbg("ReadestStats pullBookStats: triggered, interactive=" .. tostring(interactive))
    if self:willRerunPullWhenOnline(interactive, function() self:pullBookStats(interactive) end) then
        return
    end
    local client = self:ensureClient(interactive)
    if not client then
        logger.dbg("ReadestStats pullBookStats: no client (not signed in / offline); skipping")
        return
    end
    SyncStats:pull(self.settings, client, interactive,
        function() SyncAuth:logout(self.settings, self.path) end, self.ui)
end

-- ── Annotation sync ────────────────────────────────────────────────

function ReadestSync:pushBookNotes(interactive, full_sync)
    if interactive and NetworkMgr:willRerunWhenOnline(function() self:pushBookNotes(interactive, full_sync) end) then
        return
    end

    local client = self:ensureClient(interactive)
    if not client then return end

    SyncAnnotations:push(self.ui, self.settings, client, interactive, full_sync)
end

function ReadestSync:pullBookNotes(interactive, full_sync)
    local book_hash, meta_hash = self:getBookIdentifiers()
    if not book_hash or not meta_hash then return end

    if self:willRerunPullWhenOnline(interactive, function() self:pullBookNotes(interactive, full_sync) end) then
        return
    end

    local client = self:ensureClient(interactive)
    if not client then return end

    SyncAnnotations:pull(
        self.ui, self.settings, client, book_hash, meta_hash, self.dialog, interactive, full_sync
    )
end

function ReadestSync:fullSyncBookNotes()
    -- Push all annotations first, then pull all
    self:pushBookNotes(true, true)
    self:pullBookNotes(true, true)
end

-- ── Event handlers ─────────────────────────────────────────────────

function ReadestSync:onReadestSyncToggleAutoSync(toggle)
    if toggle == self.settings.auto_sync then
        return true
    end
    self.settings.auto_sync = not self.settings.auto_sync
    G_reader_settings:saveSetting("readest_sync", self.settings)
    if self.settings.auto_sync and self.ui.document then
        self:pullBookConfig(false)
    end
end

function ReadestSync:onReadestSyncPushProgress()
    self:pushBookConfig(true)
end

function ReadestSync:onReadestSyncPullProgress()
    self:pullBookConfig(true)
end

function ReadestSync:onReadestSyncPushAnnotations()
    self:pushBookNotes(true)
end

function ReadestSync:onReadestSyncPullAnnotations()
    self:pullBookNotes(true)
end

function ReadestSync:onReadestSyncFullSyncAnnotations()
    self:fullSyncBookNotes()
end

function ReadestSync:openLibrary()
    if not self.settings.access_token or not self.settings.user_id then
        UIManager:show(InfoMessage:new{ text = _("Please login first"), timeout = 2 })
        return
    end
    local LibraryWidget = require("library.librarywidget")
    LibraryWidget.open({
        settings  = self.settings,
        sync_path = self.path,
        sync_auth = SyncAuth,
    })
end

function ReadestSync:onReadestOpenLibrary()
    self:openLibrary()
end

-- Lazy-open a LibraryStore for the current user. The Library widget may
-- already have one open via librarywidget._store; we share it when present
-- to avoid two SQLite handles to the same file. Returns nil if user_id
-- isn't set (shouldn't happen — caller should gate on access_token).
function ReadestSync:getLibraryStore()
    if not self.settings.user_id or self.settings.user_id == "" then return nil end
    local LibraryWidget = require("library.librarywidget")
    if LibraryWidget._store and LibraryWidget._current_user == self.settings.user_id then
        return LibraryWidget._store
    end
    if self.library_store and self.library_store.user_id == self.settings.user_id then
        return self.library_store
    end
    if self.library_store then self.library_store:close() end
    local LibraryStore = require("library.librarystore")
    local DataStorage  = require("datastorage")
    self.library_store = LibraryStore.new({
        user_id = self.settings.user_id,
        db_path = DataStorage:getSettingsDir() .. "/readest_library.sqlite3",
    })
    return self.library_store
end

-- Bump updated_at + last_read_at on the local row for the currently-open
-- book. Called before a sync push so the row's timestamp is fresh. Returns
-- the touched row, or nil if there's nothing to touch (no book open, no
-- partial_md5, or hash not in the LibraryStore index).
function ReadestSync:touchOpenBook()
    if not self.ui or not self.ui.doc_settings then return nil end
    local hash = self.ui.doc_settings:readSetting("partial_md5_checksum")
    if not hash or hash == "" then return nil end

    local store = self:getLibraryStore()
    if not store then return nil end

    local progress_lib
    if self.ui.document and self.ui.document.getPageCount and self.ui.getCurrentPage then
        local cur = self.ui:getCurrentPage()
        local total = self.ui.document:getPageCount()
        if cur and total then
            progress_lib = require("json").encode({ cur, total })
        end
    end

    local touched = store:touchBook(hash, { progress_lib = progress_lib })
    if not touched then
        logger.dbg("ReadestSync touchOpenBook: no row for " .. hash
            .. " (book not in LibraryStore index)")
    end
    return touched
end

-- syncBooksLibrary(mode, interactive) — bidirectional book-row sync,
-- mirroring useBooksSync.handleAutoSync at apps/readest-app/src/app/
-- library/hooks/useBooksSync.ts:66-78. mode: "push"|"pull"|"both".
-- The touched-row bump happens via the before_push callback so it lands
-- AFTER pull has refreshed the local row with the cloud's uploaded_at /
-- metadata / group_id — see syncbooks.syncBooks docstring + issue #4138.
-- Interactive=true shows toast feedback.
function ReadestSync:syncBooksLibrary(mode, interactive)
    if not self.settings.access_token or not self.settings.user_id then
        if interactive then
            UIManager:show(InfoMessage:new{ text = _("Please login first"), timeout = 2 })
        end
        return
    end
    local store = self:getLibraryStore()
    if not store then
        if interactive then
            UIManager:show(InfoMessage:new{ text = _("Library not initialized"), timeout = 2 })
        end
        return
    end

    local syncbooks = require("library.syncbooks")
    syncbooks.syncBooks({
        sync_auth = SyncAuth,
        sync_path = self.path,
        settings  = self.settings,
        store     = store,
    }, mode, function(success, msg, status)
        logger.info("ReadestSync syncBooksLibrary[" .. mode .. "] done: success="
            .. tostring(success) .. " msg=" .. tostring(msg))
        if interactive then
            UIManager:show(InfoMessage:new{
                text = success
                    and _("Books synced")
                    or _("Books sync failed"),
                timeout = 2,
            })
        end
        -- If the Library widget is open, refresh it so newly-pulled rows show
        local LibraryWidget = require("library.librarywidget")
        if LibraryWidget._menu then LibraryWidget.refresh() end
    end, function()
        -- before_push: bump updated_at on the open book so its row is in
        -- the push delta. Runs after pull so the cloud's uploaded_at /
        -- metadata / group_id have already merged into the local row;
        -- touchBook then preserves those fields.
        self:touchOpenBook()
    end)
end

-- pushOpenBook(interactive) — push ONLY the currently-open book's library
-- row. Closing a book has to persist this book's reading progress and
-- timestamps, but it does NOT need other devices' rows, so we skip the full
-- library pull whose ~3s blocking round-trip froze the UI on every close
-- (issue #5006). The cross-device library pull still runs when the Library
-- widget opens. touchOpenBook returns the row with the cloud-side uploaded_at
-- / metadata / group_id still in place (from the last pull), so this
-- single-book push preserves them — no explicit-null wipe (issue #4138), same
-- as the existing interactive "push" path.
function ReadestSync:pushOpenBook(interactive)
    if not (self.settings.access_token and self.settings.user_id) then return end
    local touched = self:touchOpenBook()
    if not touched then return end
    local syncbooks = require("library.syncbooks")
    syncbooks.pushBook(touched, {
        sync_auth = SyncAuth,
        sync_path = self.path,
        settings  = self.settings,
    }, function(success, _msg, status)
        logger.info("ReadestSync pushOpenBook done: success=" .. tostring(success)
            .. " status=" .. tostring(status))
        if interactive then
            UIManager:show(InfoMessage:new{
                text = success and _("Books synced") or _("Books sync failed"),
                timeout = 2,
            })
        end
    end)
end

function ReadestSync:onCloseDocument()
    if not (self.settings.auto_sync and self.settings.access_token) then
        return
    end
    local function push()
        self:pushBookConfig(false)
        self:pushBookNotes(false)
        self:pushBookStats(false)
        self:pushOpenBook(false)
    end
    -- The push needs the document, so it cannot be deferred: with "turn on"
    -- let KOReader bring Wi-Fi up (blocking) as it always did. With anything
    -- else goOnlineToRun refuses to run offline, so skip without a dialog and
    -- let the next online push catch up.
    if wifiEnableAction() == "turn_on" then
        NetworkMgr:goOnlineToRun(push)
        return
    end
    if not NetworkMgr:isOnline() then
        logger.dbg("ReadestSync: offline; skipping close-time push")
        return
    end
    push()
end

function ReadestSync:onReadestPushBooks()
    self:syncBooksLibrary("push", true)
end

function ReadestSync:onReadestPullBooks()
    self:syncBooksLibrary("pull", true)
end

function ReadestSync:onPageUpdate(page)
    if self.settings.auto_sync and self.settings.access_token and page then
        if self.delayed_push_task then
            UIManager:unschedule(self.delayed_push_task)
        end
        self.delayed_push_task = function()
            self:pushBookConfig(false)
        end
        UIManager:scheduleIn(5, self.delayed_push_task)
    end
end

-- Waking the device with a book already open should pull like reopening
-- the book does (issue #4924). Delayed because Wi-Fi is usually still
-- coming back up right after wake; with "Action when Wi-Fi is off: prompt"
-- the pull skips if it isn't, and onNetworkConnected reruns it (KOReader
-- turns Wi-Fi off on suspend, so on Kobo/Kindle that rerun, once "Restore
-- Wi-Fi connection on resume" has finished, is the path that actually
-- pulls). On devices where Suspend/Resume also fire on focus changes
-- (Android), the debounce keeps this from hammering the API.
function ReadestSync:onResume()
    -- Guarded because some tests construct a plugin table without calling
    -- init() (see onCloseWidget). The service kept running while suspended
    -- only if the platform doesn't tear down networking on suspend; restart
    -- unconditionally so a real suspend/resume cycle always ends up in sync
    -- with the localsend_enabled setting.
    if self.localsend and self.settings.localsend_enabled and NetworkMgr:isConnected() then
        self.localsend:startService()
    end
    if not (self.settings.auto_sync and self.settings.access_token and self.ui.document) then
        return
    end
    local now = os.time()
    if now - (self.last_resume_sync_timestamp or 0) <= API_CALL_DEBOUNCE_DELAY then
        return
    end
    self.last_resume_sync_timestamp = now
    self:scheduleBackgroundPull(BACKGROUND_PULL_DELAY)
end

function ReadestSync:onAnnotationsModified(items)
    -- A removal fires AnnotationsModified with a negative index_modified and the
    -- deleted item at items[1]. Capture a tombstone now, before the item is gone
    -- for good — the push walk only sees live annotations, so without this the
    -- deletion never reaches the server (issue #4119, push direction).
    if self.settings.access_token and items and items.index_modified
            and items.index_modified < 0 and items[1] then
        SyncAnnotations:recordDeletion(self.ui.doc_settings, items[1])
    end
    if self.settings.auto_sync and self.settings.access_token then
        UIManager:nextTick(function()
            self:pushBookNotes(false)
        end)
    end
end

function ReadestSync:onNetworkConnected()
    if self.settings.localsend_enabled then
        self.localsend:startService()
    end
    -- A background pull (open / wake) was skipped while offline, see
    -- willRerunPullWhenOnline. Now that the device is online, run it.
    if not self.pull_pending_offline then
        return
    end
    if not (self.settings.auto_sync and self.settings.access_token and self.ui.document) then
        return
    end
    self.pull_pending_offline = nil
    self:scheduleBackgroundPull(BACKGROUND_PULL_DELAY)
end

function ReadestSync:onNetworkDisconnected()
    self.localsend:stopService()
end

function ReadestSync:onSuspend()
    self.localsend:stopService()
end

function ReadestSync:onCloseWidget()
    if self.delayed_push_task then
        UIManager:unschedule(self.delayed_push_task)
        self.delayed_push_task = nil
    end
    if self.background_pull_task then
        UIManager:unschedule(self.background_pull_task)
        self.background_pull_task = nil
    end
    -- A skipped pull belongs to the document that was open at the time; the
    -- next open pulls on its own, so don't carry the flag across books.
    self.pull_pending_offline = nil
    -- The LocalSend poll belongs to the singleton service, not to this
    -- plugin instance, and its closure captures the singleton (which
    -- survives context switches). Tearing it down here raced the next
    -- init()'s re-attach and could strand the service with no live poll
    -- (incoming transfers silently ignored), so leave it running; it stops
    -- only when the service does (stopService).
end

function ReadestSync:deletePluginSettings()
    G_reader_settings:delSetting("readest_sync")
    self.settings = self.default_settings
    return true
end

return ReadestSync
