package main

import (
	"bytes"
	"context"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"testing"
	"time"

	"bookhoard/internal/database"
	wsync "bookhoard/internal/sync"

	"github.com/google/uuid"
	"github.com/jackc/pgx/v5/pgtype"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
)

func pFloat64(v float64) *float64 { return &v }
func pStr(v string) *string       { return &v }
func pInt(v int) *int             { return &v }
func pInt64(v int64) *int64       { return &v }

func doReq(t *testing.T, method, url string, body interface{}, token string) *http.Response {
	t.Helper()
	var bodyReader io.Reader
	if body != nil {
		b, err := json.Marshal(body)
		require.NoError(t, err)
		bodyReader = bytes.NewBuffer(b)
	}
	req, err := http.NewRequest(method, url, bodyReader)
	require.NoError(t, err)
	req.Header.Set("Content-Type", "application/json")
	if token != "" {
		req.Header.Set("Authorization", "Bearer "+token)
	}
	resp, err := (&http.Client{}).Do(req)
	require.NoError(t, err)
	return resp
}

func decodeJSON(t *testing.T, resp *http.Response) map[string]interface{} {
	t.Helper()
	var result map[string]interface{}
	err := json.NewDecoder(resp.Body).Decode(&result)
	require.NoError(t, err)
	return result
}

func progressURL(serverURL, mediaItemID string) string {
	return serverURL + "/api/media-items/" + mediaItemID + "/progress"
}

func getFloatField(t *testing.T, data map[string]interface{}, field string) float64 {
	t.Helper()
	val, ok := data[field]
	require.True(t, ok, "%s should be present in response", field)
	require.NotNil(t, val, "%s should not be null", field)
	f, ok := val.(float64)
	require.True(t, ok, "%s should be a number, got %T: %v", field, val, val)
	return f
}

func getStringField(t *testing.T, data map[string]interface{}, field string) string {
	t.Helper()
	val, ok := data[field]
	require.True(t, ok, "%s should be present in response", field)
	require.NotNil(t, val, "%s should not be null", field)
	s, ok := val.(string)
	require.True(t, ok, "%s should be a string, got %T: %v", field, val, val)
	return s
}

func TestProgressWeb_AuthContexts(t *testing.T) {
	setup := setupTestServer(t)
	mediaItemID := createTestMediaItemID(t, setup)
	url := progressURL(setup.Server.URL, mediaItemID)

	t.Run("unauthenticated PUT returns 401", func(t *testing.T) {
		resp := doReq(t, "PUT", url, map[string]interface{}{
			"percentage": 0.5,
		}, "")
		defer resp.Body.Close()
		assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
	})

	t.Run("unauthenticated GET returns 401", func(t *testing.T) {
		resp := doReq(t, "GET", url, nil, "")
		defer resp.Body.Close()
		assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
	})

	t.Run("regular user PUT succeeds", func(t *testing.T) {
		resp := doReq(t, "PUT", url, map[string]interface{}{
			"percentage": 0.3,
		}, setup.RegularToken)
		defer resp.Body.Close()
		assert.Equal(t, http.StatusOK, resp.StatusCode)
	})

	t.Run("regular user GET succeeds", func(t *testing.T) {
		resp := doReq(t, "GET", url, nil, setup.RegularToken)
		defer resp.Body.Close()
		assert.Equal(t, http.StatusOK, resp.StatusCode)
	})

	t.Run("admin PUT succeeds", func(t *testing.T) {
		resp := doReq(t, "PUT", url, map[string]interface{}{
			"percentage": 0.7,
		}, setup.Token)
		defer resp.Body.Close()
		assert.Equal(t, http.StatusOK, resp.StatusCode)
	})

	t.Run("admin GET succeeds", func(t *testing.T) {
		resp := doReq(t, "GET", url, nil, setup.Token)
		defer resp.Body.Close()
		assert.Equal(t, http.StatusOK, resp.StatusCode)
	})

	t.Run("invalid media item ID returns 400", func(t *testing.T) {
		badURL := setup.Server.URL + "/api/media-items/not-a-uuid/progress"
		resp := doReq(t, "GET", badURL, nil, setup.Token)
		defer resp.Body.Close()
		assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
	})

	t.Run("nonexistent media item GET returns 200 with empty", func(t *testing.T) {
		fakeID := uuid.New().String()
		fakeURL := progressURL(setup.Server.URL, fakeID)
		resp := doReq(t, "GET", fakeURL, nil, setup.Token)
		defer resp.Body.Close()
		assert.Equal(t, http.StatusOK, resp.StatusCode)
	})
}

func TestProgressWeb_MergePreservesFields(t *testing.T) {
	setup := setupTestServer(t)
	mediaItemID := createTestMediaItemID(t, setup)
	url := progressURL(setup.Server.URL, mediaItemID)

	t.Run("second PUT with only percentage preserves epubcfi and chapter from first", func(t *testing.T) {
		resp1 := doReq(t, "PUT", url, map[string]interface{}{
			"percentage": 0.3,
			"epubcfi":    "epubcfi(/6/4/2:first)",
			"chapter":    2,
		}, setup.Token)
		defer resp1.Body.Close()
		require.Equal(t, http.StatusOK, resp1.StatusCode)

		resp2 := doReq(t, "PUT", url, map[string]interface{}{
			"percentage": 0.5,
		}, setup.Token)
		defer resp2.Body.Close()
		require.Equal(t, http.StatusOK, resp2.StatusCode)

		getResp := doReq(t, "GET", url, nil, setup.Token)
		defer getResp.Body.Close()
		require.Equal(t, http.StatusOK, getResp.StatusCode)

		result := decodeJSON(t, getResp)
		pct := getFloatField(t, result, "percentage")
		assert.InDelta(t, 0.5, pct, 0.01)

		epubcfi := getStringField(t, result, "epubcfi")
		assert.Equal(t, "epubcfi(/6/4/2:first)", epubcfi, "epubcfi should be preserved from first save")

		chapter := getFloatField(t, result, "chapter")
		assert.Equal(t, float64(2), chapter, "chapter should be preserved from first save")
	})

	t.Run("web save preserves koreader character_offset", func(t *testing.T) {
		mediaItemID2 := createTestMediaItemID(t, setup)
		url2 := progressURL(setup.Server.URL, mediaItemID2)
		mediaUUID, _ := uuid.Parse(mediaItemID2)
		userID := getTestUserID(t, setup.DB)
		ctx := context.Background()

		charOffset := int64(15000)
		_, err := setup.ProgressService.SaveProgress(ctx, wsync.SaveProgressRequest{
			MediaItemID:     pgtype.UUID{Bytes: mediaUUID, Valid: true},
			UserID:          pgtype.UUID{Bytes: userID, Valid: true},
			Source:          "koreader",
			DeviceID:        pgtype.UUID{Bytes: userID, Valid: true},
			Percentage:      pFloat64(0.45),
			CharacterOffset: &charOffset,
			Chapter:         pInt(5),
			DeviceType:      "koreader",
			DeviceName:      "KOReader Test",
			Broadcast:       false,
		})
		require.NoError(t, err)

		resp := doReq(t, "PUT", url2, map[string]interface{}{
			"percentage": 0.5,
			"epubcfi":    "epubcfi(/6/4/2:10)",
		}, setup.Token)
		defer resp.Body.Close()
		require.Equal(t, http.StatusOK, resp.StatusCode)

		progress, err := setup.DB.GetReadingProgress(ctx, database.GetReadingProgressParams{
			MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
			UserID:      pgtype.UUID{Bytes: userID, Valid: true},
		})
		require.NoError(t, err)
		assert.True(t, progress.CharacterOffset.Valid, "character_offset should be preserved")
		assert.Equal(t, int64(15000), progress.CharacterOffset.Int64)
		assert.True(t, progress.Chapter.Valid, "chapter should be preserved")
		assert.Equal(t, int32(5), progress.Chapter.Int32)
		assert.InDelta(t, 0.5, progress.Percentage.Float64, 0.001)
		assert.Equal(t, "web", progress.LastSyncSource.String)
	})
}

func TestProgressWeb_EnrichmentComputesFields(t *testing.T) {
	setup := setupTestServer(t)
	mediaItemID := createTestMediaItemID(t, setup)
	url := progressURL(setup.Server.URL, mediaItemID)
	mediaUUID, _ := uuid.Parse(mediaItemID)
	userID := getTestUserID(t, setup.DB)
	ctx := context.Background()

	t.Run("character_offset computed from percentage when total_characters set", func(t *testing.T) {
		_, err := setup.DBPool.Exec(ctx, "UPDATE media_items SET total_characters = $1 WHERE id = $2", int64(200000), mediaUUID)
		require.NoError(t, err)

		resp := doReq(t, "PUT", url, map[string]interface{}{
			"percentage": 0.5,
		}, setup.Token)
		defer resp.Body.Close()
		require.Equal(t, http.StatusOK, resp.StatusCode)

		progress, err := setup.DB.GetReadingProgress(ctx, database.GetReadingProgressParams{
			MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
			UserID:      pgtype.UUID{Bytes: userID, Valid: true},
		})
		require.NoError(t, err)
		assert.True(t, progress.CharacterOffset.Valid, "character_offset should be computed from percentage")
		assert.Equal(t, int64(100000), progress.CharacterOffset.Int64)
	})

	t.Run("GET returns enriched format_group and total_characters", func(t *testing.T) {
		getResp := doReq(t, "GET", url, nil, setup.Token)
		defer getResp.Body.Close()
		require.Equal(t, http.StatusOK, getResp.StatusCode)

		result := decodeJSON(t, getResp)
		_, hasFormatGroup := result["format_group"]
		assert.True(t, hasFormatGroup, "format_group should be present in GET response")
		_, hasTotalChars := result["total_characters"]
		assert.True(t, hasTotalChars, "total_characters should be present in GET response")
	})
}

func TestProgressWeb_ConflictDetection(t *testing.T) {
	setup := setupTestServer(t)
	mediaItemID := createTestMediaItemID(t, setup)
	mediaUUID, _ := uuid.Parse(mediaItemID)
	userID := getTestUserID(t, setup.DB)
	ctx := context.Background()

	t.Run("different sources with >1% diff within 5min creates conflict record", func(t *testing.T) {
		pct1 := 0.3
		_, err := setup.ProgressService.SaveProgress(ctx, wsync.SaveProgressRequest{
			MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
			UserID:      pgtype.UUID{Bytes: userID, Valid: true},
			Source:      "koreader",
			DeviceID:    pgtype.UUID{Bytes: userID, Valid: true},
			Percentage:  &pct1,
			DeviceType:  "koreader",
			DeviceName:  "KOReader",
			Broadcast:   false,
		})
		require.NoError(t, err)

		pct2 := 0.6
		_, err = setup.ProgressService.SaveProgress(ctx, wsync.SaveProgressRequest{
			MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
			UserID:      pgtype.UUID{Bytes: userID, Valid: true},
			Source:      "kobo",
			DeviceID:    pgtype.UUID{Bytes: userID, Valid: true},
			Percentage:  &pct2,
			DeviceType:  "kobo",
			DeviceName:  "Kobo",
			Broadcast:   false,
		})
		require.NoError(t, err)

		progress, err := setup.DB.GetReadingProgress(ctx, database.GetReadingProgressParams{
			MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
			UserID:      pgtype.UUID{Bytes: userID, Valid: true},
		})
		require.NoError(t, err)
		assert.InDelta(t, 0.6, progress.Percentage.Float64, 0.001)
		assert.Equal(t, "kobo", progress.LastSyncSource.String)

		conflicts, err := setup.DB.ListSyncConflictsByMediaItem(ctx, database.ListSyncConflictsByMediaItemParams{
			MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
			UserID:      pgtype.UUID{Bytes: userID, Valid: true},
		})
		require.NoError(t, err)
		assert.NotEmpty(t, conflicts, "conflict should be recorded in sync_conflicts table")
		assert.Equal(t, "progress", conflicts[0].ConflictType)
		assert.True(t, conflicts[0].ResolutionStatus.Valid)
		assert.Equal(t, "unresolved", conflicts[0].ResolutionStatus.String)
	})

	mediaItemID2 := createTestMediaItemID(t, setup)
	mediaUUID2, _ := uuid.Parse(mediaItemID2)

	t.Run("same source rapid saves create no conflict", func(t *testing.T) {
		for _, pct := range []float64{0.1, 0.3, 0.5, 0.7} {
			_, err := setup.ProgressService.SaveProgress(ctx, wsync.SaveProgressRequest{
				MediaItemID: pgtype.UUID{Bytes: mediaUUID2, Valid: true},
				UserID:      pgtype.UUID{Bytes: userID, Valid: true},
				Source:      "web",
				DeviceID:    pgtype.UUID{Bytes: userID, Valid: true},
				Percentage:  &pct,
				DeviceType:  "web",
				DeviceName:  "Web",
				Broadcast:   false,
			})
			require.NoError(t, err)
		}

		conflicts, err := setup.DB.ListSyncConflictsByMediaItem(ctx, database.ListSyncConflictsByMediaItemParams{
			MediaItemID: pgtype.UUID{Bytes: mediaUUID2, Valid: true},
			UserID:      pgtype.UUID{Bytes: userID, Valid: true},
		})
		require.NoError(t, err)
		assert.Empty(t, conflicts, "same-source saves should not create conflicts")
	})

	mediaItemID3 := createTestMediaItemID(t, setup)
	mediaUUID3, _ := uuid.Parse(mediaItemID3)

	t.Run("different sources with <1% diff creates no conflict", func(t *testing.T) {
		pct1 := 0.5
		_, err := setup.ProgressService.SaveProgress(ctx, wsync.SaveProgressRequest{
			MediaItemID: pgtype.UUID{Bytes: mediaUUID3, Valid: true},
			UserID:      pgtype.UUID{Bytes: userID, Valid: true},
			Source:      "koreader",
			DeviceID:    pgtype.UUID{Bytes: userID, Valid: true},
			Percentage:  &pct1,
			DeviceType:  "koreader",
			DeviceName:  "KOReader",
			Broadcast:   false,
		})
		require.NoError(t, err)

		pct2 := 0.505
		_, err = setup.ProgressService.SaveProgress(ctx, wsync.SaveProgressRequest{
			MediaItemID: pgtype.UUID{Bytes: mediaUUID3, Valid: true},
			UserID:      pgtype.UUID{Bytes: userID, Valid: true},
			Source:      "kobo",
			DeviceID:    pgtype.UUID{Bytes: userID, Valid: true},
			Percentage:  &pct2,
			DeviceType:  "kobo",
			DeviceName:  "Kobo",
			Broadcast:   false,
		})
		require.NoError(t, err)

		conflicts, err := setup.DB.ListSyncConflictsByMediaItem(ctx, database.ListSyncConflictsByMediaItemParams{
			MediaItemID: pgtype.UUID{Bytes: mediaUUID3, Valid: true},
			UserID:      pgtype.UUID{Bytes: userID, Valid: true},
		})
		require.NoError(t, err)
		assert.Empty(t, conflicts, "small percentage diff should not create conflict")
	})
}

func TestProgressWeb_KoboIntegration(t *testing.T) {
	setup := setupTestServer(t)
	ctx := context.Background()
	userID := getTestUserID(t, setup.DB)

	koboDeviceToken := fmt.Sprintf("dev_%s", uuid.New().String())
	pgUserID := pgtype.UUID{Bytes: userID, Valid: true}
	device, err := setup.DB.CreateDevice(ctx, database.CreateDeviceParams{
		UserID:               pgUserID,
		DeviceName:           "Test Kobo Progress",
		DeviceType:           "kobo",
		DeviceIdentifier:     "kobo-progress-test",
		AuthToken:            koboDeviceToken,
		SyncEnabled:          pgtype.Bool{Bool: true, Valid: true},
		AutoSync:             pgtype.Bool{Bool: true, Valid: true},
		SyncFrequencyMinutes: pgtype.Int4{Int32: 5, Valid: true},
		DeviceMetadata:       []byte("{}"),
	})
	require.NoError(t, err, "Should create kobo device")
	_ = device

	t.Run("unauthenticated Kobo markup returns 401", func(t *testing.T) {
		resp := doReq(t, "POST", setup.Server.URL+"/api/sync/kobo/invalid-token/markup", map[string]interface{}{
			"ReadingSync": []map[string]interface{}{
				{"ContentId": uuid.New().String(), "PercentRead": 50.0},
			},
		}, "")
		defer resp.Body.Close()
		assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
	})

	mediaItemID := createTestMediaItemID(t, setup)

	t.Run("ReadingSync then last-read-place preserves percentage", func(t *testing.T) {
		readingSyncBody := map[string]interface{}{
			"ReadingSync": []map[string]interface{}{
				{
					"ContentId":   mediaItemID,
					"PercentRead": 55.0,
				},
			},
		}

		resp := doReq(t, "POST", fmt.Sprintf("%s/api/sync/kobo/%s/markup", setup.Server.URL, koboDeviceToken), readingSyncBody, "")
		defer resp.Body.Close()
		io.ReadAll(resp.Body)
		assert.Equal(t, http.StatusOK, resp.StatusCode)

		time.Sleep(100 * time.Millisecond)

		bookmarkBody := map[string]interface{}{
			"BookmarkSync": []map[string]interface{}{
				{
					"ContentId":    mediaItemID,
					"BookmarkId":   "epubcfi(/6/4!/4/2/1:0)",
					"BookmarkType": "last-read-place",
					"Chapter":      5,
				},
			},
		}

		resp2 := doReq(t, "POST", fmt.Sprintf("%s/api/sync/kobo/%s/markup", setup.Server.URL, koboDeviceToken), bookmarkBody, "")
		defer resp2.Body.Close()
		io.ReadAll(resp2.Body)
		assert.Equal(t, http.StatusOK, resp2.StatusCode)

		mediaUUID, _ := uuid.Parse(mediaItemID)
		progress, err := setup.DB.GetReadingProgress(ctx, database.GetReadingProgressParams{
			MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
			UserID:      pgUserID,
		})
		require.NoError(t, err)
		assert.True(t, progress.Percentage.Valid)
		assert.InDelta(t, 0.55, progress.Percentage.Float64, 0.01, "percentage should still be 55% from ReadingSync")
	})

	t.Run("Kobo last-read-place without prior ReadingSync sets epubcfi and chapter", func(t *testing.T) {
		newMediaID := createTestMediaItemID(t, setup)
		newMediaUUID, _ := uuid.Parse(newMediaID)

		bookmarkBody := map[string]interface{}{
			"BookmarkSync": []map[string]interface{}{
				{
					"ContentId":    newMediaID,
					"BookmarkId":   "epubcfi(/6/14!/4/2/1:0)",
					"BookmarkType": "last-read-place",
					"Chapter":      3,
				},
			},
		}

		resp := doReq(t, "POST", fmt.Sprintf("%s/api/sync/kobo/%s/markup", setup.Server.URL, koboDeviceToken), bookmarkBody, "")
		defer resp.Body.Close()
		io.ReadAll(resp.Body)
		assert.Equal(t, http.StatusOK, resp.StatusCode)

		progress, err := setup.DB.GetReadingProgress(ctx, database.GetReadingProgressParams{
			MediaItemID: pgtype.UUID{Bytes: newMediaUUID, Valid: true},
			UserID:      pgUserID,
		})
		require.NoError(t, err)
		assert.True(t, progress.Epubcfi.Valid, "epubcfi should be set from last-read-place")
		assert.True(t, progress.Chapter.Valid, "chapter should be set from last-read-place")
		assert.Equal(t, int32(3), progress.Chapter.Int32)
	})
}

func TestProgressWeb_KOReaderIntegration(t *testing.T) {
	setup := setupTestServer(t)
	mediaItemID := createTestMediaItemID(t, setup)
	userID := getTestUserID(t, setup.DB)
	ctx := context.Background()

	koreaderDeviceToken := fmt.Sprintf("dev_%s", uuid.New().String())
	pgUserID := pgtype.UUID{Bytes: userID, Valid: true}
	_, err := setup.DB.CreateDevice(ctx, database.CreateDeviceParams{
		UserID:               pgUserID,
		DeviceName:           "Test KOReader Progress",
		DeviceType:           "koreader",
		DeviceIdentifier:     "koreader-progress-test",
		AuthToken:            koreaderDeviceToken,
		SyncEnabled:          pgtype.Bool{Bool: true, Valid: true},
		AutoSync:             pgtype.Bool{Bool: true, Valid: true},
		SyncFrequencyMinutes: pgtype.Int4{Int32: 5, Valid: true},
		DeviceMetadata:       []byte("{}"),
	})
	require.NoError(t, err, "Should create koreader device")

	t.Run("KOReader progress sync via HTTP", func(t *testing.T) {
		progressBody := map[string]interface{}{
			"books": []map[string]interface{}{
				{
					"file_path":  "/tmp/test.epub",
					"percentage": 0.42,
					"chapter":    3,
					"device_info": map[string]interface{}{
						"device_model":     "Test Device",
						"koreader_version": "1.0",
					},
				},
			},
		}

		bodyBytes, _ := json.Marshal(progressBody)
		req, err := http.NewRequest("POST", setup.Server.URL+"/api/sync/koreader/progress", bytes.NewBuffer(bodyBytes))
		require.NoError(t, err)
		req.Header.Set("Content-Type", "application/json")
		req.Header.Set("Authorization", "Bearer "+koreaderDeviceToken)

		resp, err := (&http.Client{}).Do(req)
		require.NoError(t, err)
		defer resp.Body.Close()
		io.ReadAll(resp.Body)
		assert.Equal(t, http.StatusAccepted, resp.StatusCode)

		mediaUUID, _ := uuid.Parse(mediaItemID)
		progress, err := setup.DB.GetReadingProgress(ctx, database.GetReadingProgressParams{
			MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
			UserID:      pgUserID,
		})
		if err == nil {
			assert.InDelta(t, 0.42, progress.Percentage.Float64, 0.01)
		}
	})

	t.Run("unauthenticated KOReader sync returns 401", func(t *testing.T) {
		resp := doReq(t, "POST", setup.Server.URL+"/api/sync/koreader/progress", map[string]interface{}{
			"books": []map[string]interface{}{
				{"file_path": "/tmp/test.epub", "percentage": 0.5},
			},
		}, "")
		defer resp.Body.Close()
		assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
	})
}

func TestProgressWeb_DeleteProgress(t *testing.T) {
	setup := setupTestServer(t)
	mediaItemID := createTestMediaItemID(t, setup)
	url := progressURL(setup.Server.URL, mediaItemID)

	t.Run("DELETE removes progress", func(t *testing.T) {
		resp := doReq(t, "PUT", url, map[string]interface{}{
			"percentage": 0.75,
			"epubcfi":    "epubcfi(/6/4/2:20)",
			"chapter":    7,
		}, setup.Token)
		defer resp.Body.Close()
		require.Equal(t, http.StatusOK, resp.StatusCode)

		delResp := doReq(t, "DELETE", url, nil, setup.Token)
		defer delResp.Body.Close()
		assert.Equal(t, http.StatusOK, delResp.StatusCode)

		getResp := doReq(t, "GET", url, nil, setup.Token)
		defer getResp.Body.Close()
		result := decodeJSON(t, getResp)
		assert.Equal(t, float64(0), result["current_page"], "progress should be cleared after delete")
	})

	t.Run("DELETE without auth returns 401", func(t *testing.T) {
		resp := doReq(t, "DELETE", url, nil, "")
		defer resp.Body.Close()
		assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
	})
}

func TestProgressWeb_EdgeCases(t *testing.T) {
	setup := setupTestServer(t)
	mediaItemID := createTestMediaItemID(t, setup)
	url := progressURL(setup.Server.URL, mediaItemID)

	t.Run("PUT with empty body still succeeds", func(t *testing.T) {
		resp := doReq(t, "PUT", url, map[string]interface{}{}, setup.Token)
		defer resp.Body.Close()
		assert.Equal(t, http.StatusOK, resp.StatusCode)
	})

	t.Run("PUT percentage 0.0", func(t *testing.T) {
		resp := doReq(t, "PUT", url, map[string]interface{}{
			"percentage": 0.0,
		}, setup.Token)
		defer resp.Body.Close()
		assert.Equal(t, http.StatusOK, resp.StatusCode)

		getResp := doReq(t, "GET", url, nil, setup.Token)
		defer getResp.Body.Close()
		require.Equal(t, http.StatusOK, getResp.StatusCode)
	})

	t.Run("PUT percentage 1.0", func(t *testing.T) {
		resp := doReq(t, "PUT", url, map[string]interface{}{
			"percentage": 1.0,
		}, setup.Token)
		defer resp.Body.Close()
		assert.Equal(t, http.StatusOK, resp.StatusCode)
	})

	t.Run("PUT with all fields then GET verifies each", func(t *testing.T) {
		mediaItemID2 := createTestMediaItemID(t, setup)
		url2 := progressURL(setup.Server.URL, mediaItemID2)
		mediaUUID2, _ := uuid.Parse(mediaItemID2)
		userID := getTestUserID(t, setup.DB)
		ctx := context.Background()

		resp := doReq(t, "PUT", url2, map[string]interface{}{
			"percentage":        0.42,
			"current_page":      84,
			"total_pages":       200,
			"epubcfi":           "epubcfi(/6/4!/4/2/1:0)",
			"chapter":           3,
			"chapter_progress":  0.5,
			"character_offset":  15000,
			"reading_mode":      "page",
			"zoom_level":        1.5,
			"scroll_position_x": 0.0,
			"scroll_position_y": 100.0,
		}, setup.Token)
		defer resp.Body.Close()
		require.Equal(t, http.StatusOK, resp.StatusCode)

		progress, err := setup.DB.GetReadingProgress(ctx, database.GetReadingProgressParams{
			MediaItemID: pgtype.UUID{Bytes: mediaUUID2, Valid: true},
			UserID:      pgtype.UUID{Bytes: userID, Valid: true},
		})
		require.NoError(t, err)
		assert.True(t, progress.Percentage.Valid)
		assert.InDelta(t, 0.42, progress.Percentage.Float64, 0.01)
		assert.True(t, progress.Chapter.Valid)
		assert.Equal(t, int32(3), progress.Chapter.Int32)
		assert.True(t, progress.Epubcfi.Valid)
		assert.Equal(t, "epubcfi(/6/4!/4/2/1:0)", progress.Epubcfi.String)
		assert.True(t, progress.CurrentPage.Valid)
		assert.Equal(t, int32(84), progress.CurrentPage.Int32)
		assert.True(t, progress.TotalPages.Valid)
		assert.Equal(t, int32(200), progress.TotalPages.Int32)
		assert.True(t, progress.ReadingMode.Valid)
		assert.Equal(t, "page", progress.ReadingMode.String)
		assert.True(t, progress.ZoomLevel.Valid)
		assert.InDelta(t, 1.5, progress.ZoomLevel.Float64, 0.01)
	})
}
