local Event = require("ui/event")
local InfoMessage = require("ui/widget/infomessage")
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 SyncAnnotations = {}

-- KOReader color name → Readest color value
local KO_TO_READEST_COLOR = {
    yellow = "yellow",
    red = "red",
    green = "green",
    blue = "blue",
    purple = "violet",
    orange = "#ff8800",
    cyan = "#00bcd4",
    olive = "#808000",
    gray = "#9e9e9e",
}

-- Readest color value → KOReader color name
local READEST_TO_KO_COLOR = {
    yellow = "yellow",
    red = "red",
    green = "green",
    blue = "blue",
    violet = "purple",
    ["#ff8800"] = "orange",
    ["#00bcd4"] = "cyan",
    ["#808000"] = "olive",
    ["#9e9e9e"] = "gray",
}

function SyncAnnotations:parseDatetimeToMs(dt)
    if not dt then return os.time() * 1000 end
    local y, m, d, h, min, s = dt:match("(%d+)-(%d+)-(%d+) (%d+):(%d+):(%d+)")
    if y then
        return os.time({
            year = tonumber(y), month = tonumber(m), day = tonumber(d),
            hour = tonumber(h), min = tonumber(min), sec = tonumber(s),
        }) * 1000
    end
    return os.time() * 1000
end

function SyncAnnotations:parseISODatetime(dt)
    if not dt then return os.time() end
    local y, m, d, h, min, s = dt:match("(%d+)-(%d+)-(%d+)T(%d+):(%d+):(%d+)")
    if y then
        return os.time({
            year = tonumber(y), month = tonumber(m), day = tonumber(d),
            hour = tonumber(h), min = tonumber(min), sec = tonumber(s),
        })
    end
    return os.time()
end

function SyncAnnotations:generateNoteId(book_hash, note_type, pos0, pos1)
    local raw = "ko:" .. book_hash .. ":" .. note_type .. ":" .. (pos0 or "") .. ":" .. (pos1 or "")
    return sha2.md5(raw):sub(1, 7)
end

-- Build the Readest note payload for a single KOReader annotation item, or nil
-- if the item isn't a syncable highlight/bookmark. Shared by the push walk
-- (getAnnotations) and the deletion path (recordDeletion) so a note's id is
-- derived identically however it was created.
function SyncAnnotations:buildNoteDescriptor(item, book_hash, meta_hash)
    local note_text = item.note
    if note_text == "" then note_text = nil end

    local pos0 = item.pos0
    local pos1 = item.pos1
    if type(pos0) == "table" then pos0 = nil end
    if type(pos1) == "table" then pos1 = nil end

    if item.drawer and pos0 then
        -- Annotation (highlight/underline/strikeout): has drawer and pos0/pos1
        local style = "highlight"
        if item.drawer == "underscore" then
            style = "underline"
        elseif item.drawer == "strikeout" then
            style = "squiggly"
        end

        local id = item.id or self:generateNoteId(book_hash, "annotation", tostring(pos0), pos1 and tostring(pos1))
        return {
            bookHash = book_hash,
            metaHash = meta_hash,
            id = id,
            type = "annotation",
            xpointer0 = tostring(pos0),
            xpointer1 = pos1 and tostring(pos1) or nil,
            text = item.text or "",
            note = note_text,
            style = style,
            color = KO_TO_READEST_COLOR[item.color or "yellow"],
            page = item.pageno,
            createdAt = self:parseDatetimeToMs(item.datetime),
            updatedAt = self:parseDatetimeToMs(item.datetime_updated or item.datetime),
        }
    elseif not item.drawer and type(item.page) == "string" then
        -- Bookmark: no drawer, position in page field (xpointer string)
        local page_xp = item.page
        local id = item.id or self:generateNoteId(book_hash, "bookmark", page_xp)
        return {
            bookHash = book_hash,
            metaHash = meta_hash,
            id = id,
            type = "bookmark",
            xpointer0 = page_xp,
            text = item.text or "",
            note = note_text,
            page = item.pageno,
            createdAt = self:parseDatetimeToMs(item.datetime),
            updatedAt = self:parseDatetimeToMs(item.datetime_updated or item.datetime),
        }
    end
    return nil
end

function SyncAnnotations:getAnnotations(ui, settings, book_hash, meta_hash, full_sync)
    local annotations = ui.annotation and ui.annotation.annotations
    if not annotations then return {} end

    local last_sync = full_sync and 0 or (settings.last_notes_sync_at or 0)

    local notes = {}
    for _, item in ipairs(annotations) do
        local updated_at = self:parseDatetimeToMs(item.datetime_updated or item.datetime)
        if updated_at > last_sync then
            local note = self:buildNoteDescriptor(item, book_hash, meta_hash)
            if note then
                notes[#notes + 1] = note
            end
        end
    end
    return notes
end

-- Drop local annotations the server has tombstoned (deleted_at set), so a
-- highlight deleted on Readest also disappears in KOReader. Without this,
-- pull only skips deleted notes — the local copy lingers and any later
-- push resurrects it on the server, making it reappear (issue #4119).
function SyncAnnotations:removeDeletedAnnotations(annotation_mgr, notes, book_hash)
    local annotations = annotation_mgr and annotation_mgr.annotations
    if not annotations or #annotations == 0 then return 0 end

    -- Map every local annotation to its index by id and by position so a
    -- tombstone can be matched however it was originally created: pulled
    -- notes carry a stored id, native KOReader highlights don't (their id
    -- is derived from book hash + positions, matching what push uploads).
    local index_by_id = {}
    local index_by_anno = {}
    local index_by_bookmark = {}
    for i, item in ipairs(annotations) do
        if item.id then
            index_by_id[item.id] = i
        end
        if item.drawer then
            local pos0 = item.pos0
            local pos1 = item.pos1
            if type(pos0) == "table" then pos0 = nil end
            if type(pos1) == "table" then pos1 = nil end
            if pos0 then
                index_by_anno[tostring(pos0) .. "|" .. tostring(pos1 or "")] = i
                if not item.id then
                    local id = self:generateNoteId(book_hash, "annotation", tostring(pos0), pos1 and tostring(pos1))
                    index_by_id[id] = i
                end
            end
        elseif type(item.page) == "string" then
            index_by_bookmark[item.page] = i
            if not item.id then
                local id = self:generateNoteId(book_hash, "bookmark", item.page)
                index_by_id[id] = i
            end
        end
    end

    local to_remove = {}
    for _, note in ipairs(notes) do
        if note.deleted_at then
            local idx
            if note.id and index_by_id[note.id] then
                idx = index_by_id[note.id]
            elseif note.type == "bookmark" and note.xpointer0 then
                idx = index_by_bookmark[note.xpointer0]
            elseif note.xpointer0 then
                idx = index_by_anno[note.xpointer0 .. "|" .. (note.xpointer1 or "")]
            end
            if idx then
                to_remove[idx] = true
            end
        end
    end

    -- Remove highest index first so earlier indexes stay valid.
    local indexes = {}
    for idx in pairs(to_remove) do
        indexes[#indexes + 1] = idx
    end
    table.sort(indexes, function(a, b) return a > b end)
    for _, idx in ipairs(indexes) do
        table.remove(annotations, idx)
    end
    return #indexes
end

-- Stash a tombstone for a note deleted locally. By the time auto-sync runs the
-- deleted item is already gone from ui.annotation.annotations, so the push walk
-- can't see it; without this the deletion never reaches the server and the
-- highlight resurrects on the next pull (issue #4119, push direction). The
-- tombstone is a normal note payload with deletedAt set, persisted in the
-- per-book sidecar and folded into the next push (see push()), then cleared
-- once the server accepts it.
function SyncAnnotations:recordDeletion(doc_settings, item)
    if not doc_settings or not item then return end
    local book_hash = doc_settings:readSetting("partial_md5_checksum")
    if not book_hash then return end

    local doc_readest_sync = doc_settings:readSetting("readest_sync") or {}
    local note = self:buildNoteDescriptor(item, book_hash, doc_readest_sync.meta_hash_v1)
    if not note then return end
    note.deletedAt = os.time() * 1000

    local deleted = doc_readest_sync.deleted_notes or {}
    for _, t in ipairs(deleted) do
        if t.id == note.id then return end -- already recorded
    end
    deleted[#deleted + 1] = note
    doc_readest_sync.deleted_notes = deleted
    doc_settings:saveSetting("readest_sync", doc_readest_sync)
end

function SyncAnnotations:push(ui, settings, client, interactive, full_sync)
    local book_hash = ui.doc_settings:readSetting("partial_md5_checksum")
    local doc_readest_sync = ui.doc_settings:readSetting("readest_sync") or {}
    local meta_hash = doc_readest_sync.meta_hash_v1
    if not book_hash or not meta_hash then return end

    local annotations = self:getAnnotations(ui, settings, book_hash, meta_hash, full_sync)

    -- Fold in tombstones for notes deleted locally since the last push. These
    -- are gone from ui.annotation.annotations, so getAnnotations can't see them;
    -- re-stamp the current book/meta hash in case they were recorded before the
    -- book was registered for sync.
    local deleted_notes = doc_readest_sync.deleted_notes or {}
    for _, t in ipairs(deleted_notes) do
        t.bookHash = book_hash
        t.metaHash = meta_hash
        annotations[#annotations + 1] = t
    end

    if #annotations == 0 then
        if interactive then
            UIManager:show(InfoMessage:new{
                text = _("No annotations to push"),
                timeout = 2,
            })
        end
        return
    end

    if interactive then
        UIManager:show(InfoMessage:new{
            text = _("Pushing annotations..."),
            timeout = 1,
        })
    end

    local payload = {
        books = {},
        notes = annotations,
        configs = {},
    }
    logger.dbg("ReadestSync: Pushing annotations, payload:", payload)

    client:pushChanges(
        payload,
        function(success, _response)
            if interactive then
                if success then
                    UIManager:show(InfoMessage:new{
                        text = T(_("%1 annotations pushed successfully"), #annotations),
                        timeout = 2,
                    })
                else
                    UIManager:show(InfoMessage:new{
                        text = _("Failed to push annotations"),
                        timeout = 2,
                    })
                end
            end
            if success then
                settings.last_notes_sync_at = os.time() * 1000
                G_reader_settings:saveSetting("readest_sync", settings)
                if ui.doc_settings then
                    local synced = ui.doc_settings:readSetting("readest_sync") or {}
                    synced.last_synced_at_notes = os.time()
                    -- The server has the tombstones now; drop them so they don't
                    -- ride along on every future push.
                    synced.deleted_notes = nil
                    ui.doc_settings:saveSetting("readest_sync", synced)
                end
            end
        end
    )
end

function SyncAnnotations:pull(ui, settings, client, book_hash, meta_hash, dialog, interactive, full_sync)
    if ui.document.info.has_pages then
        if interactive then
            UIManager:show(InfoMessage:new{
                text = _("Annotation sync is not supported for PDF documents"),
                timeout = 3,
            })
        end
        return
    end

    if interactive then
        UIManager:show(InfoMessage:new{
            text = full_sync and _("Full sync: pulling all annotations...") or _("Pulling annotations..."),
            timeout = 1,
        })
    end

    client:pullChanges(
        {
            since = full_sync and 0 or (settings.last_notes_sync_at or 0),
            type = "notes",
            book = book_hash,
            meta_hash = meta_hash,
        },
        function(success, response, status)
            if not success then
                -- Treat HTTP 401/403 as auth failure regardless of body shape
                -- so a future server tweak to the error string doesn't
                -- silently turn relogin into "Failed to pull annotations"
                -- noise (codex round 1 finding 15).
                local is_auth_fail = status == 401 or status == 403
                    or (response and response.error == "Not authenticated")
                if interactive then
                    UIManager:show(InfoMessage:new{
                        text = is_auth_fail
                            and _("Authentication failed, please login again")
                            or _("Failed to pull annotations"),
                        timeout = 2,
                    })
                end
                return
            end

            local data = response.notes
            if not data or #data == 0 then
                if interactive then
                    UIManager:show(InfoMessage:new{
                        text = _("No new annotations found"),
                        timeout = 2,
                    })
                end
                return
            end

            logger.dbg("ReadestSync: Pulled annotations from sync:", #data)
            local annotation_mgr = ui.annotation
            if not annotation_mgr then return end

            -- Honor remote deletions before adding: drop local annotations
            -- the server has tombstoned so they don't reappear (issue #4119).
            local removed = self:removeDeletedAnnotations(annotation_mgr, data, book_hash)

            -- Build dedup sets: by ID, by pos0|pos1 for annotations, by page xpointer for bookmarks
            local existing_ids = {}
            local existing_annotations = {}
            local existing_bookmarks = {}
            for _, item in ipairs(annotation_mgr.annotations) do
                -- Use stored id if available
                if item.id then
                    existing_ids[item.id] = true
                end
                if item.drawer then
                    local pos0 = item.pos0
                    local pos1 = item.pos1
                    if type(pos0) == "table" then pos0 = nil end
                    if type(pos1) == "table" then pos1 = nil end
                    local key = tostring(pos0) .. "|" .. tostring(pos1 or "")
                    existing_annotations[key] = true
                    -- Also generate ID for annotations without id
                    if not item.id and pos0 then
                        local id = self:generateNoteId(book_hash, "annotation", tostring(pos0), pos1 and tostring(pos1))
                        existing_ids[id] = true
                    end
                elseif type(item.page) == "string" then
                    existing_bookmarks[item.page] = true
                    if not item.id then
                        local id = self:generateNoteId(book_hash, "bookmark", item.page)
                        existing_ids[id] = true
                    end
                end
            end

            local added = 0
            for _, note in ipairs(data) do
                if note.deleted_at then
                    goto continue
                end

                local xp0 = note.xpointer0
                if not xp0 then goto continue end

                -- Deduplicate by server-provided ID
                if note.id and existing_ids[note.id] then goto continue end

                local note_type = note.type
                local item

                local created = self:parseISODatetime(note.created_at)
                local updated = self:parseISODatetime(note.updated_at) or created
                local datetime_str = os.date("%Y-%m-%d %H:%M:%S", created)
                local datetime_updated_str = os.date("%Y-%m-%d %H:%M:%S", updated)

                -- Resolve KOReader page number from xpointer
                local pageno = ui.document:getPageFromXPointer(xp0) or note.page
                -- Resolve chapter title so downstream tools can group by chapter,
                -- matching native KOReader highlight creation behavior.
                local chapter = ui.toc and ui.toc:getTocTitleByPage(xp0) or nil
                if chapter == "" then chapter = nil end

                local note_text = note.note
                if note_text == "" then note_text = nil end

                if note_type == "bookmark" then
                    if existing_bookmarks[xp0] then goto continue end

                    item = {
                        id = note.id,
                        page = xp0,
                        text = note.text or "",
                        note = note_text,
                        chapter = chapter,
                        pageno = pageno,
                        datetime = datetime_str,
                        datetime_updated = datetime_updated_str,
                    }
                    existing_bookmarks[xp0] = true
                else
                    local xp1 = note.xpointer1
                    local key = xp0 .. "|" .. (xp1 or "")
                    if existing_annotations[key] then goto continue end

                    local drawer = "lighten"
                    if note.style == "underline" then
                        drawer = "underscore"
                    elseif note.style == "squiggly" then
                        drawer = "strikeout"
                    end

                    item = {
                        id = note.id,
                        pos0 = xp0,
                        pos1 = xp1 or xp0,
                        page = xp0,
                        text = note.text or "",
                        note = note_text,
                        drawer = drawer,
                        color = READEST_TO_KO_COLOR[note.color] or "yellow",
                        chapter = chapter,
                        pageno = pageno,
                        datetime = datetime_str,
                        datetime_updated = datetime_updated_str,
                    }
                    existing_annotations[key] = true
                end

                local index = annotation_mgr:addItem(item)
                ui:handleEvent(Event:new("AnnotationsModified", { item, index_modified = index }))
                logger.dbg("ReadestSync: Added annotation from sync:", item)
                added = added + 1

                ::continue::
            end

            settings.last_notes_sync_at = os.time() * 1000
            G_reader_settings:saveSetting("readest_sync", settings)
            if ui.doc_settings then
                local doc_readest_sync = ui.doc_settings:readSetting("readest_sync") or {}
                doc_readest_sync.last_synced_at_notes = os.time()
                ui.doc_settings:saveSetting("readest_sync", doc_readest_sync)
            end

            if interactive then
                UIManager:show(InfoMessage:new{
                    text = T(_("%1 annotations pulled"), added),
                    timeout = 2,
                })
            end

            if added > 0 or removed > 0 then
                UIManager:setDirty(dialog, "ui")
            end
        end
    )
end

return SyncAnnotations
