package sync

import (
	"bookhoard/internal/database"
	"context"
	"crypto/sha1"
	"encoding/hex"
	"encoding/json"
	"errors"
	"fmt"
	"log"
	"math"
	"strings"
	"time"

	"github.com/google/uuid"
	"github.com/jackc/pgx/v5"
	"github.com/jackc/pgx/v5/pgtype"
)

// TombstoneTTL is the fallback retention for soft-deleted annotations when no
// settings registry is wired (e.g. in tests). It matches the historical value.
const TombstoneTTL = 30 * 24 * time.Hour

type AnnotationService struct {
	db      *database.Queries
	connMgr *ConnectionManager
	settings *database.SettingsRegistry
}

func NewAnnotationService(db *database.Queries, connMgr *ConnectionManager) *AnnotationService {
	return &AnnotationService{db: db, connMgr: connMgr}
}

// SetSettings wires the tunable settings registry. When wired, the tombstone
// TTL is read live from the DB; otherwise the package const TombstoneTTL is
// used.
func (s *AnnotationService) SetSettings(reg *database.SettingsRegistry) { s.settings = reg }

// tombstoneTTL returns the active tombstone retention window.
func (s *AnnotationService) tombstoneTTL() time.Duration {
	if s.settings != nil {
		return s.settings.TombstoneTTL()
	}
	return TombstoneTTL
}

// ActiveTombstoneTTL exposes the configured tombstone retention window for
// callers outside the sync package (e.g. kobo/koreader handlers) that need to
// compute cutoffs consistently with the service.
func (s *AnnotationService) ActiveTombstoneTTL() time.Duration { return s.tombstoneTTL() }

type SaveOutcome string

const (
	SaveOutcomeCreated SaveOutcome = "created"
	SaveOutcomeUpdated SaveOutcome = "updated"
	SaveOutcomeSkipped SaveOutcome = "skipped"
	SaveOutcomeDeleted SaveOutcome = "deleted"
)

type SaveHighlightRequest struct {
	MediaItemID      pgtype.UUID
	UserID           pgtype.UUID
	SelectionText    string
	StartPosition    string
	EndPosition      string
	Color            string
	NoteText         string
	PercentageStart  float64
	PercentageEnd    float64
	EpubcfiStart     string
	EpubcfiEnd       string
	ChapterReference int32
	Source           string
	ModifiedAt       time.Time
	DeviceSyncData   json.RawMessage
}

type SaveHighlightResult struct {
	Highlight database.MediaHighlights
	Outcome   SaveOutcome
	Conflict  bool
}

func (s *AnnotationService) SaveHighlight(ctx context.Context, req SaveHighlightRequest) (*SaveHighlightResult, error) {
	dedupKey := ComputeDedupKey(req.SelectionText, req.EpubcfiStart, req.StartPosition)

	existing, err := s.db.GetMediaHighlightByDedupKey(ctx, database.GetMediaHighlightByDedupKeyParams{
		UserID:      req.UserID,
		MediaItemID: req.MediaItemID,
		DedupKey:    pgtype.Text{String: dedupKey, Valid: true},
	})
	if err != nil && !errors.Is(err, pgx.ErrNoRows) {
		return nil, fmt.Errorf("query existing highlight: %w", err)
	}

	if errors.Is(err, pgx.ErrNoRows) {
		return s.createHighlight(ctx, req, dedupKey)
	}

	if existing.Deleted.Bool {
		if !incomingNewerThanTombstone(req.ModifiedAt, existing.DeletedAt, existing.LastModifiedAt) {
			return &SaveHighlightResult{Highlight: existing, Outcome: SaveOutcomeDeleted}, nil
		}
		// Newer than the tombstone: a deliberate re-create. Resurrect via the
		// LWW update (which clears deleted/deleted_at).
		return s.applyLWW(ctx, req, existing, dedupKey)
	}

	return s.applyLWW(ctx, req, existing, dedupKey)
}

func (s *AnnotationService) createHighlight(
	ctx context.Context,
	req SaveHighlightRequest,
	dedupKey string,
) (*SaveHighlightResult, error) {
	modifiedAt := req.ModifiedAt
	if modifiedAt.IsZero() {
		modifiedAt = time.Now()
	}

	deviceData := mergeDeviceSyncData(nil, req.Source, req.DeviceSyncData)

	highlight, err := s.db.CreateMediaHighlightFull(ctx, database.CreateMediaHighlightFullParams{
		MediaItemID:      req.MediaItemID,
		UserID:           req.UserID,
		SelectionText:    req.SelectionText,
		StartPosition:    pgText(req.StartPosition),
		EndPosition:      pgText(req.EndPosition),
		Color:            pgText(req.Color),
		NoteText:         pgText(req.NoteText),
		PercentageStart:  pgFloat8(req.PercentageStart),
		PercentageEnd:    pgFloat8(req.PercentageEnd),
		EpubcfiStart:     pgText(req.EpubcfiStart),
		EpubcfiEnd:       pgText(req.EpubcfiEnd),
		ChapterReference: pgInt4(req.ChapterReference),
		DedupKey:         pgtype.Text{String: dedupKey, Valid: true},
		LastModifiedAt:   pgtype.Timestamptz{Time: modifiedAt, Valid: true},
		LastModifiedSource: pgtype.Text{String: req.Source, Valid: req.Source != ""},
		DeviceSyncData:   deviceData,
	})
	if err != nil {
		return nil, fmt.Errorf("create highlight: %w", err)
	}

	s.broadcast(highlight.ID, req.UserID, req.MediaItemID, "highlight", req.Source)
	return &SaveHighlightResult{Highlight: highlight, Outcome: SaveOutcomeCreated}, nil
}

func (s *AnnotationService) applyLWW(
	ctx context.Context,
	req SaveHighlightRequest,
	existing database.MediaHighlights,
	dedupKey string,
) (*SaveHighlightResult, error) {
	incomingNewer, contentChanged := s.compareIncoming(req, existing)

	if !incomingNewer && !contentChanged {
		conflict := isCrossSource(req.Source, existing.LastModifiedSource)
		if conflict {
			s.recordConflict(ctx, req.UserID, req.MediaItemID, "annotation_highlight",
				existing.DedupKey.String, req.Source, existing.LastModifiedSource.String,
				req, existing, "existing")
		}
		return &SaveHighlightResult{
			Highlight: existing,
			Outcome:   SaveOutcomeSkipped,
			Conflict:  conflict,
		}, nil
	}

	modifiedAt := req.ModifiedAt
	if modifiedAt.IsZero() {
		modifiedAt = time.Now()
	}

	deviceData := mergeDeviceSyncData(existing.DeviceSyncData, req.Source, req.DeviceSyncData)

	highlight, err := s.db.UpdateMediaHighlightForSync(ctx, database.UpdateMediaHighlightForSyncParams{
		ID:               existing.ID,
		SelectionText:    req.SelectionText,
		StartPosition:    pgText(req.StartPosition),
		EndPosition:      pgText(req.EndPosition),
		Color:            pgText(req.Color),
		NoteText:         pgText(req.NoteText),
		PercentageStart:  pgFloat8(req.PercentageStart),
		PercentageEnd:    pgFloat8(req.PercentageEnd),
		EpubcfiStart:     pgText(req.EpubcfiStart),
		EpubcfiEnd:       pgText(req.EpubcfiEnd),
		ChapterReference: pgInt4(req.ChapterReference),
		LastModifiedAt:   pgtype.Timestamptz{Time: modifiedAt, Valid: true},
		LastModifiedSource: pgtype.Text{String: req.Source, Valid: req.Source != ""},
		DeviceSyncData:   deviceData,
	})
	if err != nil {
		return nil, fmt.Errorf("update highlight: %w", err)
	}

	conflict := isCrossSource(req.Source, existing.LastModifiedSource)
	if conflict {
		s.recordConflict(ctx, req.UserID, req.MediaItemID, "annotation_highlight",
			existing.DedupKey.String, req.Source, existing.LastModifiedSource.String,
			req, existing, "incoming")
	}
	s.broadcast(highlight.ID, req.UserID, req.MediaItemID, "highlight", req.Source)
	return &SaveHighlightResult{Highlight: highlight, Outcome: SaveOutcomeUpdated, Conflict: conflict}, nil
}

func (s *AnnotationService) compareIncoming(req SaveHighlightRequest, existing database.MediaHighlights) (incomingNewer bool, contentChanged bool) {
	if req.ModifiedAt.IsZero() {
		contentSame := strings.EqualFold(req.SelectionText, existing.SelectionText) &&
			textEq(req.Color, existing.Color) &&
			textEq(req.NoteText, existing.NoteText) &&
			floatEq(req.PercentageStart, existing.PercentageStart) &&
			floatEq(req.PercentageEnd, existing.PercentageEnd)
		return !contentSame, !contentSame
	}

	existingMod := existing.LastModifiedAt
	if !existingMod.Valid {
		existingMod = existing.UpdatedAt
	}
	return req.ModifiedAt.After(existingMod.Time), true
}

func (s *AnnotationService) TombstoneHighlight(
	ctx context.Context,
	userID, mediaItemID pgtype.UUID,
	dedupKey string,
	source string,
) error {
	err := s.db.TombstoneMediaHighlightByDedupKey(ctx, database.TombstoneMediaHighlightByDedupKeyParams{
		UserID:      userID,
		MediaItemID: mediaItemID,
		DedupKey:    pgtype.Text{String: dedupKey, Valid: true},
	})
	if err != nil {
		return fmt.Errorf("tombstone highlight: %w", err)
	}
	s.broadcast(pgtype.UUID{}, userID, mediaItemID, "highlight_delete", source)
	return nil
}

func (s *AnnotationService) TombstoneHighlightByID(
	ctx context.Context,
	highlightID pgtype.UUID,
	source string,
) error {
	h, err := s.db.GetMediaHighlight(ctx, highlightID)
	if err != nil {
		return fmt.Errorf("get highlight for tombstone: %w", err)
	}
	err = s.db.TombstoneMediaHighlightByID(ctx, highlightID)
	if err != nil {
		return fmt.Errorf("tombstone highlight by ID: %w", err)
	}
	s.broadcast(pgtype.UUID{}, h.UserID, h.MediaItemID, "highlight_delete", source)
	return nil
}

func (s *AnnotationService) PurgeExpiredTombstones(ctx context.Context) error {
	cutoff := pgtype.Timestamptz{Time: time.Now().Add(-s.tombstoneTTL()), Valid: true}
	if err := s.db.PurgeExpiredHighlightTombstones(ctx, cutoff); err != nil {
		return fmt.Errorf("purge highlight tombstones: %w", err)
	}
	if err := s.db.PurgeExpiredNoteTombstones(ctx, cutoff); err != nil {
		return fmt.Errorf("purge note tombstones: %w", err)
	}
	if err := s.db.PurgeExpiredBookmarkTombstones(ctx, cutoff); err != nil {
		return fmt.Errorf("purge bookmark tombstones: %w", err)
	}
	return nil
}

// StartDailyMaintenance launches a single background goroutine that runs all
// periodic cleanup tasks once every 24 hours: expired annotation tombstones,
// expired/revoked refresh tokens (retention follows the configured session
// duration), and expired OPDS tokens. Each task is independent; a failure in
// one is logged and does not skip the others. The returned CancelFunc stops the
// goroutine and the underlying ticker; it must be invoked on shutdown.
func (s *AnnotationService) StartDailyMaintenance() context.CancelFunc {
	ticker := time.NewTicker(24 * time.Hour)
	ctx, cancel := context.WithCancel(context.Background())

	go func() {
		for {
			select {
			case <-ctx.Done():
				ticker.Stop()
				return
			case <-ticker.C:
				s.runDailyMaintenance(ctx)
			}
		}
	}()

	return cancel
}

// runDailyMaintenance executes every periodic cleanup task. Tasks run
// sequentially under the single daily-tick goroutine so there is no added
// concurrency. All three queries only delete rows that are already unusable
// (expired or revoked), so this never logs out active sessions.
func (s *AnnotationService) runDailyMaintenance(ctx context.Context) {
	if err := s.PurgeExpiredTombstones(ctx); err != nil {
		log.Printf("maintenance: tombstone purge failed: %v", err)
	}
	if err := s.db.CleanupExpiredOpdsTokens(ctx); err != nil {
		log.Printf("maintenance: OPDS token purge failed: %v", err)
	}
	// Refresh-token retention follows the configured session duration; re-read
	// on every tick so live settings changes are honored. Guarded so unwired
	// test paths simply skip cleanup (production always wires the registry).
	if s.settings != nil {
		retention := s.settings.SessionDuration().Seconds()
		if err := s.db.CleanupExpiredRefreshTokens(ctx, retention); err != nil {
			log.Printf("maintenance: refresh token purge failed: %v", err)
		}
	}
}

type SaveNoteRequest struct {
	MediaItemID        pgtype.UUID
	UserID             pgtype.UUID
	Content            string
	Position           string
	PercentageLocation float64
	CharacterStart     int32
	CharacterEnd       int32
	EpubcfiLocation    string
	ChapterReference   int32
	ParagraphReference int32
	Source             string
	ModifiedAt         time.Time
	DeviceSyncData     []byte
}

type SaveNoteResult struct {
	Note     database.MediaNotes
	Outcome  SaveOutcome
	Conflict bool
}

func (s *AnnotationService) SaveNote(ctx context.Context, req SaveNoteRequest) (*SaveNoteResult, error) {
	if !req.UserID.Valid || !req.MediaItemID.Valid {
		return nil, errors.New("invalid user_id or media_item_id")
	}

	dedupKey := ComputeDedupKey(req.Content, req.EpubcfiLocation, req.Position)

	existing, err := s.db.GetMediaNoteByDedupKey(ctx, database.GetMediaNoteByDedupKeyParams{
		UserID:      req.UserID,
		MediaItemID: req.MediaItemID,
		DedupKey:    pgtype.Text{String: dedupKey, Valid: true},
	})
	if err != nil {
		if !errors.Is(err, pgx.ErrNoRows) {
			return nil, fmt.Errorf("get note by dedup key: %w", err)
		}
		return s.createNote(ctx, req, dedupKey)
	}

	if existing.Deleted.Valid && existing.Deleted.Bool {
		if !incomingNewerThanTombstone(req.ModifiedAt, existing.DeletedAt, existing.LastModifiedAt) {
			return &SaveNoteResult{Note: existing, Outcome: SaveOutcomeDeleted}, nil
		}
		// Newer than the tombstone: a deliberate re-create. Resurrect.
		return s.applyNoteLWW(ctx, req, existing, dedupKey)
	}

	return s.applyNoteLWW(ctx, req, existing, dedupKey)
}

func (s *AnnotationService) createNote(ctx context.Context, req SaveNoteRequest, dedupKey string) (*SaveNoteResult, error) {
	modifiedAt := req.ModifiedAt
	if modifiedAt.IsZero() {
		modifiedAt = time.Now()
	}

	note, err := s.db.CreateMediaNoteFull(ctx, database.CreateMediaNoteFullParams{
		MediaItemID:        req.MediaItemID,
		UserID:             req.UserID,
		Content:            req.Content,
		Position:           pgText(req.Position),
		PercentageLocation: pgFloat8(req.PercentageLocation),
		CharacterStart:     pgInt4(req.CharacterStart),
		CharacterEnd:       pgInt4(req.CharacterEnd),
		EpubcfiLocation:    pgText(req.EpubcfiLocation),
		ChapterReference:   pgInt4(req.ChapterReference),
		ParagraphReference: pgInt4(req.ParagraphReference),
		DedupKey:           pgtype.Text{String: dedupKey, Valid: true},
		LastModifiedAt:     pgtype.Timestamptz{Time: modifiedAt, Valid: true},
		LastModifiedSource: pgtype.Text{String: req.Source, Valid: req.Source != ""},
		DeviceSyncData:     req.DeviceSyncData,
	})
	if err != nil {
		return nil, fmt.Errorf("create note: %w", err)
	}
	s.broadcast(note.ID, req.UserID, req.MediaItemID, "note", req.Source)
	return &SaveNoteResult{Note: note, Outcome: SaveOutcomeCreated}, nil
}

func (s *AnnotationService) applyNoteLWW(ctx context.Context, req SaveNoteRequest, existing database.MediaNotes, dedupKey string) (*SaveNoteResult, error) {
	incomingNewer, contentChanged := s.compareIncomingNote(req, existing)

	if !incomingNewer && !contentChanged {
		conflict := isCrossSource(req.Source, existing.LastModifiedSource)
		if conflict {
			s.recordConflict(ctx, req.UserID, req.MediaItemID, "annotation_note",
				existing.DedupKey.String, req.Source, existing.LastModifiedSource.String,
				req, existing, "existing")
		}
		return &SaveNoteResult{Note: existing, Outcome: SaveOutcomeSkipped, Conflict: conflict}, nil
	}

	modifiedAt := req.ModifiedAt
	if modifiedAt.IsZero() {
		modifiedAt = time.Now()
	}

	deviceData := mergeDeviceSyncData(existing.DeviceSyncData, req.Source, req.DeviceSyncData)

	note, err := s.db.UpdateMediaNoteForSync(ctx, database.UpdateMediaNoteForSyncParams{
		ID:                 existing.ID,
		Content:            req.Content,
		Position:           pgText(req.Position),
		PercentageLocation: pgFloat8(req.PercentageLocation),
		CharacterStart:     pgInt4(req.CharacterStart),
		CharacterEnd:       pgInt4(req.CharacterEnd),
		EpubcfiLocation:    pgText(req.EpubcfiLocation),
		ChapterReference:   pgInt4(req.ChapterReference),
		ParagraphReference: pgInt4(req.ParagraphReference),
		LastModifiedAt:     pgtype.Timestamptz{Time: modifiedAt, Valid: true},
		LastModifiedSource: pgtype.Text{String: req.Source, Valid: req.Source != ""},
		DeviceSyncData:     deviceData,
	})
	if err != nil {
		return nil, fmt.Errorf("update note: %w", err)
	}

	conflict := isCrossSource(req.Source, existing.LastModifiedSource)
	if conflict {
		s.recordConflict(ctx, req.UserID, req.MediaItemID, "annotation_note",
			existing.DedupKey.String, req.Source, existing.LastModifiedSource.String,
			req, existing, "incoming")
	}
	s.broadcast(note.ID, req.UserID, req.MediaItemID, "note", req.Source)
	return &SaveNoteResult{Note: note, Outcome: SaveOutcomeUpdated, Conflict: conflict}, nil
}

func (s *AnnotationService) compareIncomingNote(req SaveNoteRequest, existing database.MediaNotes) (incomingNewer bool, contentChanged bool) {
	if req.ModifiedAt.IsZero() {
		contentSame := strings.EqualFold(req.Content, existing.Content) &&
			textEq(req.Position, existing.Position)
		return !contentSame, !contentSame
	}
	existingMod := existing.LastModifiedAt
	if !existingMod.Valid {
		existingMod = existing.UpdatedAt
	}
	if !existingMod.Valid {
		return true, true
	}
	return req.ModifiedAt.After(existingMod.Time), true
}

func (s *AnnotationService) TombstoneNoteByID(ctx context.Context, id pgtype.UUID) error {
	return s.db.TombstoneMediaNoteByID(ctx, id)
}

type SaveBookmarkRequest struct {
	MediaItemID      pgtype.UUID
	UserID           pgtype.UUID
	Title            string
	Position         string
	Notes            string
	PageNumber       int32
	ChapterNumber    int32
	CFIPosition      string
	PercentageLoc    float64
	EpubcfiLocation  string
	ChapterReference int32
	Source           string
	ModifiedAt       time.Time
	DeviceSyncData   json.RawMessage
}

type SaveBookmarkResult struct {
	Bookmark database.MediaBookmarks
	Outcome  SaveOutcome
	Conflict bool
}

func (s *AnnotationService) SaveBookmark(ctx context.Context, req SaveBookmarkRequest) (*SaveBookmarkResult, error) {
	dedupKey := ComputeDedupKey(req.Title, req.EpubcfiLocation, req.Position)

	existing, err := s.db.GetMediaBookmarkByDedupKey(ctx, database.GetMediaBookmarkByDedupKeyParams{
		UserID:      req.UserID,
		MediaItemID: req.MediaItemID,
		DedupKey:    pgtype.Text{String: dedupKey, Valid: true},
	})
	if err != nil && !errors.Is(err, pgx.ErrNoRows) {
		return nil, fmt.Errorf("query existing bookmark: %w", err)
	}

	if errors.Is(err, pgx.ErrNoRows) {
		return s.createBookmark(ctx, req, dedupKey)
	}

	if existing.Deleted.Bool {
		if !incomingNewerThanTombstone(req.ModifiedAt, existing.DeletedAt, existing.LastModifiedAt) {
			return &SaveBookmarkResult{Bookmark: existing, Outcome: SaveOutcomeDeleted}, nil
		}
		// Newer than the tombstone: a deliberate re-create. Resurrect via the
		// LWW update instead of INSERT (the tombstoned row still holds the
		// UNIQUE(media_item_id, user_id, title) slot).
		return s.applyBookmarkLWW(ctx, req, existing, dedupKey)
	}

	return s.applyBookmarkLWW(ctx, req, existing, dedupKey)
}

func (s *AnnotationService) createBookmark(ctx context.Context, req SaveBookmarkRequest, dedupKey string) (*SaveBookmarkResult, error) {
	modifiedAt := req.ModifiedAt
	if modifiedAt.IsZero() {
		modifiedAt = time.Now()
	}
	deviceData := mergeDeviceSyncData(nil, req.Source, req.DeviceSyncData)

	bm, err := s.db.CreateMediaBookmarkFull(ctx, database.CreateMediaBookmarkFullParams{
		MediaItemID:      req.MediaItemID,
		UserID:           req.UserID,
		PageNumber:       pgInt4(req.PageNumber),
		ChapterNumber:    pgInt4(req.ChapterNumber),
		CfiPosition:      pgText(req.CFIPosition),
		Title:            req.Title,
		Position:         pgText(req.Position),
		Notes:            pgText(req.Notes),
		PercentageLocation: pgFloat8(req.PercentageLoc),
		EpubcfiLocation:  pgText(req.EpubcfiLocation),
		ChapterReference: pgInt4(req.ChapterReference),
		DedupKey:         pgtype.Text{String: dedupKey, Valid: true},
		LastModifiedAt:   pgtype.Timestamptz{Time: modifiedAt, Valid: true},
		LastModifiedSource: pgtype.Text{String: req.Source, Valid: req.Source != ""},
		DeviceSyncData:   deviceData,
	})
	if err != nil {
		return nil, fmt.Errorf("create bookmark: %w", err)
	}
	s.broadcast(bm.ID, req.UserID, req.MediaItemID, "bookmark", req.Source)
	return &SaveBookmarkResult{Bookmark: bm, Outcome: SaveOutcomeCreated}, nil
}

func (s *AnnotationService) applyBookmarkLWW(ctx context.Context, req SaveBookmarkRequest, existing database.MediaBookmarks, dedupKey string) (*SaveBookmarkResult, error) {
	incomingNewer, contentChanged := s.compareIncomingBookmark(req, existing)

	if !incomingNewer && !contentChanged {
		conflict := isCrossSource(req.Source, existing.LastModifiedSource)
		if conflict {
			s.recordConflict(ctx, req.UserID, req.MediaItemID, "annotation_bookmark",
				existing.DedupKey.String, req.Source, existing.LastModifiedSource.String,
				req, existing, "existing")
		}
		return &SaveBookmarkResult{Bookmark: existing, Outcome: SaveOutcomeSkipped, Conflict: conflict}, nil
	}

	modifiedAt := req.ModifiedAt
	if modifiedAt.IsZero() {
		modifiedAt = time.Now()
	}
	deviceData := mergeDeviceSyncData(existing.DeviceSyncData, req.Source, req.DeviceSyncData)

	bm, err := s.db.UpdateMediaBookmarkForSync(ctx, database.UpdateMediaBookmarkForSyncParams{
		ID:               existing.ID,
		PageNumber:       pgInt4(req.PageNumber),
		ChapterNumber:    pgInt4(req.ChapterNumber),
		CfiPosition:      pgText(req.CFIPosition),
		Title:            req.Title,
		Position:         pgText(req.Position),
		Notes:            pgText(req.Notes),
		PercentageLocation: pgFloat8(req.PercentageLoc),
		EpubcfiLocation:  pgText(req.EpubcfiLocation),
		ChapterReference: pgInt4(req.ChapterReference),
		LastModifiedAt:   pgtype.Timestamptz{Time: modifiedAt, Valid: true},
		LastModifiedSource: pgtype.Text{String: req.Source, Valid: req.Source != ""},
		DeviceSyncData:   deviceData,
	})
	if err != nil {
		return nil, fmt.Errorf("update bookmark: %w", err)
	}
	conflict := isCrossSource(req.Source, existing.LastModifiedSource)
	if conflict {
		s.recordConflict(ctx, req.UserID, req.MediaItemID, "annotation_bookmark",
			existing.DedupKey.String, req.Source, existing.LastModifiedSource.String,
			req, existing, "incoming")
	}
	s.broadcast(bm.ID, req.UserID, req.MediaItemID, "bookmark", req.Source)
	return &SaveBookmarkResult{Bookmark: bm, Outcome: SaveOutcomeUpdated, Conflict: conflict}, nil
}

func (s *AnnotationService) compareIncomingBookmark(req SaveBookmarkRequest, existing database.MediaBookmarks) (incomingNewer bool, contentChanged bool) {
	if req.ModifiedAt.IsZero() {
		contentSame := strings.EqualFold(req.Title, existing.Title) &&
			textEq(req.Notes, existing.Notes)
		return !contentSame, !contentSame
	}
	existingMod := existing.LastModifiedAt
	if !existingMod.Valid {
		existingMod = existing.CreatedAt
	}
	return req.ModifiedAt.After(existingMod.Time), true
}

func (s *AnnotationService) TombstoneBookmarkByID(ctx context.Context, bookmarkID pgtype.UUID, source string) error {
	bm, err := s.db.GetMediaBookmark(ctx, bookmarkID)
	if err != nil {
		return fmt.Errorf("get bookmark for tombstone: %w", err)
	}
	err = s.db.TombstoneMediaBookmarkByID(ctx, bookmarkID)
	if err != nil {
		return fmt.Errorf("tombstone bookmark by ID: %w", err)
	}
	s.broadcast(pgtype.UUID{}, bm.UserID, bm.MediaItemID, "bookmark_delete", source)
	return nil
}

func (s *AnnotationService) recordConflict(
	ctx context.Context,
	userID, mediaItemID pgtype.UUID,
	conflictType, dedupKey string,
	incomingSource, existingSource string,
	incoming any,
	existing any,
	winner string,
) {
	if s.connMgr == nil || !userID.Valid || !mediaItemID.Valid {
		return
	}

	incomingJSON, _ := json.Marshal(incoming)
	existingJSON, _ := json.Marshal(existing)

	var incomingMap, existingMap map[string]interface{}
	json.Unmarshal(incomingJSON, &incomingMap)
	json.Unmarshal(existingJSON, &existingMap)
	if incomingMap == nil {
		incomingMap = map[string]interface{}{}
	}
	if existingMap == nil {
		existingMap = map[string]interface{}{}
	}
	incomingMap["dedup_key"] = dedupKey
	existingMap["dedup_key"] = dedupKey

	conflictData, _ := json.Marshal(map[string]interface{}{
		"incoming": map[string]interface{}{
			"source": incomingSource,
			"data":   incomingMap,
		},
		"existing": map[string]interface{}{
			"source": existingSource,
			"data":   existingMap,
		},
	})

	resolutionData, _ := json.Marshal(map[string]interface{}{
		"winner": winner,
		"reason": "last_modified_at_wins",
	})

	conflict, err := s.db.CreateAutoResolvedSyncConflict(ctx, database.CreateAutoResolvedSyncConflictParams{
		MediaItemID:    mediaItemID,
		UserID:         userID,
		ConflictType:   conflictType,
		ConflictData:   conflictData,
		ResolutionData: resolutionData,
	})
	if err != nil {
		log.Printf("AnnotationService: failed to record conflict: %v", err)
		return
	}

	var conflictIDStr string
	if conflict.ID.Valid {
		conflictIDStr = uuid.UUID(conflict.ID.Bytes).String()
	}
	s.connMgr.BroadcastConflictNotification(
		uuid.UUID(mediaItemID.Bytes),
		"annotation_conflict",
		conflictIDStr,
	)
}

func (s *AnnotationService) broadcast(
	highlightID, userID, mediaItemID pgtype.UUID,
	annotationType string,
	source string,
) {
	if s.connMgr == nil || !userID.Valid || !mediaItemID.Valid {
		return
	}
	src := SourceDevice{Type: source}
	s.connMgr.BroadcastAnnotationUpdate(
		uuid.UUID(mediaItemID.Bytes),
		annotationType,
		map[string]interface{}{
			"highlight_id": uuid.UUID(highlightID.Bytes),
		},
		src,
	)
}

func ComputeDedupKey(selectionText, epubcfiStart, startPosition string) string {
	normalized := normalizeText(selectionText)
	posBucket := bucketPosition(epubcfiStart)
	if posBucket == "" {
		posBucket = bucketPosition(startPosition)
	}

	h := sha1.New()
	h.Write([]byte(normalized))
	h.Write([]byte{0})
	h.Write([]byte(posBucket))
	return hex.EncodeToString(h.Sum(nil))
}

// incomingNewerThanTombstone reports whether an incoming save should
// resurrect a tombstoned annotation. A save carrying a modification time
// newer than the tombstone (e.g. the user deliberately re-adding on the web,
// or a device that genuinely re-created it) wins; a save with a missing or
// older timestamp is treated as a stale replay from a client that still has
// the deleted annotation, and the tombstone stands.
func incomingNewerThanTombstone(incoming time.Time, deletedAt, lastModifiedAt pgtype.Timestamptz) bool {
	if incoming.IsZero() {
		return false
	}
	tombstone := deletedAt.Time
	if lastModifiedAt.Valid && lastModifiedAt.Time.After(tombstone) {
		tombstone = lastModifiedAt.Time
	}
	return incoming.After(tombstone)
}

func normalizeText(s string) string {
	fields := strings.Fields(strings.ToLower(s))
	return strings.Join(fields, " ")
}

func bucketPosition(pos string) string {
	if pos == "" {
		return ""
	}
	if strings.HasPrefix(pos, "epubcfi(") {
		if idx := strings.LastIndex(pos, ":"); idx > 0 {
			return pos[:idx]
		}
	}
	if len(pos) > 50 {
		return pos[:50]
	}
	return pos
}

func mergeDeviceSyncData(existing []byte, source string, data json.RawMessage) []byte {
	if source == "" && len(data) == 0 {
		return existing
	}
	m := make(map[string]interface{})
	if len(existing) > 0 {
		_ = json.Unmarshal(existing, &m)
	}
	if source != "" {
		if len(data) > 0 {
			var val interface{}
			_ = json.Unmarshal(data, &val)
			m[source] = val
		} else {
			m[source] = map[string]interface{}{"synced_at": time.Now().UTC().Format(time.RFC3339)}
		}
	}
	result, _ := json.Marshal(m)
	return result
}

func isCrossSource(incoming string, existing pgtype.Text) bool {
	if incoming == "" || !existing.Valid {
		return false
	}
	return incoming != existing.String
}

func pgText(s string) pgtype.Text {
	if s == "" {
		return pgtype.Text{Valid: false}
	}
	return pgtype.Text{String: s, Valid: true}
}

func pgFloat8(f float64) pgtype.Float8 {
	if f == 0 {
		return pgtype.Float8{Valid: false}
	}
	return pgtype.Float8{Float64: f, Valid: true}
}

func pgInt4(i int32) pgtype.Int4 {
	if i == 0 {
		return pgtype.Int4{Valid: false}
	}
	return pgtype.Int4{Int32: i, Valid: true}
}

func textEq(a string, b pgtype.Text) bool {
	if !b.Valid {
		return a == ""
	}
	return a == b.String
}

func floatEq(a float64, b pgtype.Float8) bool {
	if !b.Valid {
		return a == 0
	}
	return math.Abs(a-b.Float64) < 0.001
}
