package sync

import (
	"bookhoard/internal/database"
	"encoding/json"
	"testing"
	"time"

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

func TestComputeDedupKey_Deterministic(t *testing.T) {
	k1 := ComputeDedupKey("Hello World", "epubcfi(/6/4!/4/10/3:100)", "")
	k2 := ComputeDedupKey("Hello World", "epubcfi(/6/4!/4/10/3:100)", "")
	if k1 != k2 {
		t.Errorf("same input should produce same key: %q vs %q", k1, k2)
	}
}

func TestComputeDedupKey_Normalization(t *testing.T) {
	cases := [][]string{
		{"Hello World", "  Hello   World  "},
		{"HELLO WORLD", "hello world"},
		{"Hello  World", "Hello World"},
		{"Hello\t\nWorld", "Hello World"},
	}
	cfi := "epubcfi(/6/4!/4/10/3:100)"
	for _, c := range cases {
		k1 := ComputeDedupKey(c[0], cfi, "")
		k2 := ComputeDedupKey(c[1], cfi, "")
		if k1 != k2 {
			t.Errorf("normalized texts should match: %q vs %q → %q vs %q", c[0], c[1], k1, k2)
		}
	}
}

func TestComputeDedupKey_PositionSensitivity(t *testing.T) {
	text := "same text"
	k1 := ComputeDedupKey(text, "epubcfi(/6/4!/4/10/3:100)", "")
	k2 := ComputeDedupKey(text, "epubcfi(/6/4!/4/20/3:100)", "")
	if k1 == k2 {
		t.Error("different element paths should produce different keys")
	}
}

func TestComputeDedupKey_OffsetInsensitive(t *testing.T) {
	text := "same text"
	base := "epubcfi(/6/4!/4/10/3:100)"
	offsetShift := "epubcfi(/6/4!/4/10/3:200)"
	k1 := ComputeDedupKey(text, base, "")
	k2 := ComputeDedupKey(text, offsetShift, "")
	if k1 != k2 {
		t.Error("same element path with different char offsets should produce same key (bucket)")
	}
}

func TestComputeDedupKey_FallbackToRawPosition(t *testing.T) {
	text := "same text"
	k1 := ComputeDedupKey(text, "", "page:42")
	k2 := ComputeDedupKey(text, "", "page:42")
	if k1 != k2 {
		t.Error("same raw position should produce same key")
	}
	k3 := ComputeDedupKey(text, "", "page:99")
	if k1 == k3 {
		t.Error("different raw positions should produce different keys")
	}
}

func TestComputeDedupKey_DifferentTextSamePosition(t *testing.T) {
	cfi := "epubcfi(/6/4!/4/10/3:100)"
	k1 := ComputeDedupKey("first highlight", cfi, "")
	k2 := ComputeDedupKey("second highlight", cfi, "")
	if k1 == k2 {
		t.Error("different selection text should produce different keys")
	}
}

func TestNormalizeText(t *testing.T) {
	cases := []struct{ in, want string }{
		{"Hello World", "hello world"},
		{"  Hello   World  ", "hello world"},
		{"Hello\t\nWorld", "hello world"},
		{"", ""},
		{"   ", ""},
	}
	for _, c := range cases {
		got := normalizeText(c.in)
		if got != c.want {
			t.Errorf("normalizeText(%q) = %q, want %q", c.in, got, c.want)
		}
	}
}

func TestBucketPosition(t *testing.T) {
	cases := []struct{ in, want string }{
		{"epubcfi(/6/4!/4/10/3:100)", "epubcfi(/6/4!/4/10/3"},
		{"epubcfi(/6/4!/4/10/3:0)", "epubcfi(/6/4!/4/10/3"},
		{"page:42", "page:42"},
		{"short", "short"},
		{"", ""},
	}
	for _, c := range cases {
		got := bucketPosition(c.in)
		if got != c.want {
			t.Errorf("bucketPosition(%q) = %q, want %q", c.in, got, c.want)
		}
	}
}

func TestBucketPosition_LongString(t *testing.T) {
	long := "this_is_a_very_long_position_string_that_exceeds_fifty_characters_total"
	got := bucketPosition(long)
	if len(got) > 50 {
		t.Errorf("bucketPosition should truncate to <=50 chars, got %d", len(got))
	}
	if got != long[:50] {
		t.Errorf("bucketPosition truncated wrong: got %q", got)
	}
}

func TestMergeDeviceSyncData_NewEntry(t *testing.T) {
	result := mergeDeviceSyncData(nil, "koreader", json.RawMessage(`{"datetime":"2024-01-01"}`))
	var m map[string]interface{}
	if err := json.Unmarshal(result, &m); err != nil {
		t.Fatalf("unmarshal failed: %v", err)
	}
	entry, ok := m["koreader"]
	if !ok {
		t.Fatal("expected koreader entry")
	}
	entryMap := entry.(map[string]interface{})
	if entryMap["datetime"] != "2024-01-01" {
		t.Errorf("unexpected datetime: %v", entryMap["datetime"])
	}
}

func TestMergeDeviceSyncData_PreservesExisting(t *testing.T) {
	existing := []byte(`{"koreader":{"datetime":"2024-01-01"}}`)
	result := mergeDeviceSyncData(existing, "kobo", json.RawMessage(`{"bookmark_id":"abc"}`))
	var m map[string]interface{}
	if err := json.Unmarshal(result, &m); err != nil {
		t.Fatalf("unmarshal failed: %v", err)
	}
	if _, ok := m["koreader"]; !ok {
		t.Error("koreader entry should be preserved")
	}
	if _, ok := m["kobo"]; !ok {
		t.Error("kobo entry should be added")
	}
}

func TestMergeDeviceSyncData_OverwritesSameSource(t *testing.T) {
	existing := []byte(`{"koreader":{"datetime":"old"}}`)
	result := mergeDeviceSyncData(existing, "koreader", json.RawMessage(`{"datetime":"new"}`))
	var m map[string]interface{}
	json.Unmarshal(result, &m)
	entry := m["koreader"].(map[string]interface{})
	if entry["datetime"] != "new" {
		t.Errorf("expected overwritten datetime 'new', got %v", entry["datetime"])
	}
}

func TestIsCrossSource(t *testing.T) {
	if isCrossSource("koreader", pgtype.Text{String: "kobo", Valid: true}) != true {
		t.Error("different sources should be cross-source")
	}
	if isCrossSource("koreader", pgtype.Text{String: "koreader", Valid: true}) != false {
		t.Error("same sources should not be cross-source")
	}
	if isCrossSource("", pgtype.Text{String: "koreader", Valid: true}) != false {
		t.Error("empty incoming source should not be cross-source")
	}
	if isCrossSource("koreader", pgtype.Text{Valid: false}) != false {
		t.Error("invalid existing source should not be cross-source")
	}
}

func TestCompareIncoming_FieldDiff_Identical(t *testing.T) {
	svc := &AnnotationService{}
	req := SaveHighlightRequest{
		SelectionText:   "hello",
		Color:           "#ffff00",
		NoteText:        "a note",
		PercentageStart: 10.5,
		PercentageEnd:   11.0,
	}
	existing := pgHighlights("hello", "#ffff00", "a note", 10.5, 11.0)
	newer, changed := svc.compareIncoming(req, existing)
	if newer {
		t.Error("identical content should not be newer")
	}
	if changed {
		t.Error("identical content should not be changed")
	}
}

func TestCompareIncoming_FieldDiff_DifferentText(t *testing.T) {
	svc := &AnnotationService{}
	req := SaveHighlightRequest{
		SelectionText: "edited text",
	}
	existing := pgHighlights("original text", "#ffff00", "", 0, 0)
	newer, changed := svc.compareIncoming(req, existing)
	if !newer {
		t.Error("different content should be newer")
	}
	if !changed {
		t.Error("different content should be changed")
	}
}

func TestCompareIncoming_FieldDiff_DifferentColor(t *testing.T) {
	svc := &AnnotationService{}
	req := SaveHighlightRequest{
		SelectionText: "same",
		Color:         "#ff0000",
	}
	existing := pgHighlights("same", "#ffff00", "", 0, 0)
	_, changed := svc.compareIncoming(req, existing)
	if !changed {
		t.Error("different color should be detected as changed")
	}
}

func TestCompareIncoming_LWW_NewerWins(t *testing.T) {
	svc := &AnnotationService{}
	now := time.Now()
	req := SaveHighlightRequest{
		SelectionText: "same",
		ModifiedAt:    now.Add(1 * time.Hour),
	}
	existing := pgHighlights("same", "", "", 0, 0)
	existing.LastModifiedAt = pgtype.Timestamptz{Time: now, Valid: true}
	newer, changed := svc.compareIncoming(req, existing)
	if !newer {
		t.Error("future timestamp should be newer")
	}
	if !changed {
		t.Error("LWW mode should always report changed=true")
	}
}

func TestCompareIncoming_LWW_OlderSkipped(t *testing.T) {
	svc := &AnnotationService{}
	now := time.Now()
	req := SaveHighlightRequest{
		SelectionText: "same",
		ModifiedAt:    now.Add(-1 * time.Hour),
	}
	existing := pgHighlights("same", "", "", 0, 0)
	existing.LastModifiedAt = pgtype.Timestamptz{Time: now, Valid: true}
	newer, _ := svc.compareIncoming(req, existing)
	if newer {
		t.Error("past timestamp should not be newer")
	}
}

func TestCompareIncoming_LWW_FallsBackToUpdatedAt(t *testing.T) {
	svc := &AnnotationService{}
	now := time.Now()
	req := SaveHighlightRequest{
		SelectionText: "same",
		ModifiedAt:    now.Add(1 * time.Hour),
	}
	existing := pgHighlights("same", "", "", 0, 0)
	existing.LastModifiedAt = pgtype.Timestamptz{Valid: false}
	existing.UpdatedAt = pgtype.Timestamptz{Time: now, Valid: true}
	newer, _ := svc.compareIncoming(req, existing)
	if !newer {
		t.Error("should fall back to updated_at when last_modified_at is invalid")
	}
}

func TestPgText(t *testing.T) {
	if pgText("").Valid {
		t.Error("empty string should produce invalid pgtype.Text")
	}
	v := pgText("hello")
	if !v.Valid || v.String != "hello" {
		t.Errorf("expected valid 'hello', got %+v", v)
	}
}

func TestPgFloat8(t *testing.T) {
	if pgFloat8(0).Valid {
		t.Error("zero should produce invalid pgtype.Float8")
	}
	v := pgFloat8(1.5)
	if !v.Valid || v.Float64 != 1.5 {
		t.Errorf("expected valid 1.5, got %+v", v)
	}
}

func TestPgInt4(t *testing.T) {
	if pgInt4(0).Valid {
		t.Error("zero should produce invalid pgtype.Int4")
	}
	v := pgInt4(3)
	if !v.Valid || v.Int32 != 3 {
		t.Errorf("expected valid 3, got %+v", v)
	}
}

func TestFloatEq(t *testing.T) {
	if !floatEq(0, pgtype.Float8{Valid: false}) {
		t.Error("0 vs invalid should be equal")
	}
	if !floatEq(10.5, pgtype.Float8{Float64: 10.5, Valid: true}) {
		t.Error("10.5 vs 10.5 should be equal")
	}
	if floatEq(10.6, pgtype.Float8{Float64: 10.5, Valid: true}) {
		t.Error("10.6 vs 10.5 should not be equal")
	}
}

func TestTextEq(t *testing.T) {
	if !textEq("", pgtype.Text{Valid: false}) {
		t.Error("empty vs invalid should be equal")
	}
	if !textEq("hi", pgtype.Text{String: "hi", Valid: true}) {
		t.Error("same strings should be equal")
	}
	if textEq("hi", pgtype.Text{String: "bye", Valid: true}) {
		t.Error("different strings should not be equal")
	}
}

func TestTombstoneTTL(t *testing.T) {
	if TombstoneTTL != 30*24*time.Hour {
		t.Errorf("expected 30 days, got %v", TombstoneTTL)
	}
}

func pgHighlights(text, color, note string, pctStart, pctEnd float64) database.MediaHighlights {
	return database.MediaHighlights{
		SelectionText:   text,
		Color:           pgtype.Text{String: color, Valid: color != ""},
		NoteText:        pgtype.Text{String: note, Valid: note != ""},
		PercentageStart: pgtype.Float8{Float64: pctStart, Valid: pctStart != 0},
		PercentageEnd:   pgtype.Float8{Float64: pctEnd, Valid: pctEnd != 0},
	}
}

func TestIncomingNewerThanTombstone(t *testing.T) {
	base := time.Date(2026, 8, 14, 12, 0, 0, 0, time.UTC)
	delAt := pgtype.Timestamptz{Time: base, Valid: true}
	lastMod := pgtype.Timestamptz{Time: base.Add(-time.Minute), Valid: true}

	tests := []struct {
		name     string
		incoming time.Time
		deleted  pgtype.Timestamptz
		lastMod  pgtype.Timestamptz
		want     bool
	}{
		{"newer than tombstone resurrects", base.Add(time.Hour), delAt, lastMod, true},
		{"older than tombstone is a stale replay", base.Add(-time.Hour), delAt, lastMod, false},
		{"missing timestamp never resurrects", time.Time{}, delAt, lastMod, false},
		{"exactly equal does not resurrect", base, delAt, lastMod, false},
		{"last_modified newer than deleted_at wins", base.Add(30 * time.Minute), delAt, pgtype.Timestamptz{Time: base.Add(90 * time.Minute), Valid: true}, false},
		{"invalid timestamps compare against deleted_at", base.Add(time.Hour), delAt, pgtype.Timestamptz{}, true},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			if got := incomingNewerThanTombstone(tt.incoming, tt.deleted, tt.lastMod); got != tt.want {
				t.Errorf("incomingNewerThanTombstone() = %v, want %v", got, tt.want)
			}
		})
	}
}
