package api

import (
	"context"
	"encoding/json"
	"net/http"
	"net/http/httptest"
	"strconv"
	"testing"

	"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
	"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
	"git.fabledsword.com/bvandeusen/minstrel/internal/sync"
)

func callLibrarySync(h *handlers, user dbq.User, since string) *httptest.ResponseRecorder {
	url := "/api/library/sync"
	if since != "" {
		url += "?since=" + since
	}
	req := httptest.NewRequest(http.MethodGet, url, nil)
	req = req.WithContext(context.WithValue(req.Context(), auth.UserCtxKeyForTest(), user))
	w := httptest.NewRecorder()
	h.handleLibrarySync(w, req)
	return w
}

func TestLibrarySync_NoChanges_Returns204(t *testing.T) {
	h, pool := testHandlers(t)
	truncateLibrary(t, pool)
	u := seedUser(t, pool, "alice", "x", false)

	w := callLibrarySync(h, u, "0")
	if w.Code != http.StatusNoContent {
		t.Fatalf("status = %d body = %s", w.Code, w.Body.String())
	}
}

func TestLibrarySync_AfterArtistUpsert_ReturnsHydratedPayload(t *testing.T) {
	h, pool := testHandlers(t)
	truncateLibrary(t, pool)
	u := seedUser(t, pool, "alice", "x", false)

	// Seed an artist + write a change row in the same tx — that's the
	// invariant the scanner wiring (Task 5) will enforce, and the
	// handler relies on.
	ctx := context.Background()
	tx, err := pool.Begin(ctx)
	if err != nil {
		t.Fatalf("begin: %v", err)
	}
	defer func() { _ = tx.Rollback(ctx) }()
	var artistID string
	if err := tx.QueryRow(ctx,
		`INSERT INTO artists (name, sort_name) VALUES ($1, $2) RETURNING id::text`,
		"Boards of Canada", "Boards of Canada",
	).Scan(&artistID); err != nil {
		t.Fatalf("insert artist: %v", err)
	}
	if err := sync.LogChange(ctx, tx, sync.EntityArtist, artistID, sync.OpUpsert); err != nil {
		t.Fatalf("LogChange: %v", err)
	}
	if err := tx.Commit(ctx); err != nil {
		t.Fatalf("commit: %v", err)
	}

	w := callLibrarySync(h, u, "0")
	if w.Code != http.StatusOK {
		t.Fatalf("status = %d body = %s", w.Code, w.Body.String())
	}
	var body struct {
		Cursor  int64                        `json:"cursor"`
		Upserts map[string][]json.RawMessage `json:"upserts"`
		Deletes map[string][]string          `json:"deletes"`
	}
	if err := json.NewDecoder(w.Body).Decode(&body); err != nil {
		t.Fatalf("decode: %v", err)
	}
	if body.Cursor <= 0 {
		t.Fatalf("expected positive cursor, got %d", body.Cursor)
	}
	if len(body.Upserts["artist"]) != 1 {
		t.Fatalf("expected 1 artist upsert, got %d", len(body.Upserts["artist"]))
	}
}

func TestLibrarySync_CursorMonotonic(t *testing.T) {
	h, pool := testHandlers(t)
	truncateLibrary(t, pool)
	u := seedUser(t, pool, "alice", "x", false)

	ctx := context.Background()
	tx, _ := pool.Begin(ctx)
	var artistID string
	_ = tx.QueryRow(ctx,
		`INSERT INTO artists (name, sort_name) VALUES ($1, $2) RETURNING id::text`,
		"A", "A",
	).Scan(&artistID)
	_ = sync.LogChange(ctx, tx, sync.EntityArtist, artistID, sync.OpUpsert)
	_ = tx.Commit(ctx)

	w1 := callLibrarySync(h, u, "0")
	if w1.Code != http.StatusOK {
		t.Fatalf("first call status = %d", w1.Code)
	}
	var body1 struct {
		Cursor int64 `json:"cursor"`
	}
	if err := json.NewDecoder(w1.Body).Decode(&body1); err != nil {
		t.Fatalf("decode: %v", err)
	}

	w2 := callLibrarySync(h, u, strconv.FormatInt(body1.Cursor, 10))
	if w2.Code != http.StatusNoContent {
		t.Fatalf("second call status = %d body = %s", w2.Code, w2.Body.String())
	}
}

func TestLibrarySync_DeleteIsReflected(t *testing.T) {
	h, pool := testHandlers(t)
	truncateLibrary(t, pool)
	u := seedUser(t, pool, "alice", "x", false)

	// Just write a delete change row directly (no underlying mutation).
	// Scanner wiring (Task 5) is what couples real deletes to LogChange;
	// here we test the handler reflects what's in the log.
	ctx := context.Background()
	tx, _ := pool.Begin(ctx)
	_ = sync.LogChange(ctx, tx, sync.EntityArtist, "deadbeef-dead-beef-dead-beefdeadbeef", sync.OpDelete)
	_ = tx.Commit(ctx)

	w := callLibrarySync(h, u, "0")
	if w.Code != http.StatusOK {
		t.Fatalf("status = %d body = %s", w.Code, w.Body.String())
	}
	var body struct {
		Cursor  int64                        `json:"cursor"`
		Upserts map[string][]json.RawMessage `json:"upserts"`
		Deletes map[string][]string          `json:"deletes"`
	}
	if err := json.NewDecoder(w.Body).Decode(&body); err != nil {
		t.Fatalf("decode: %v", err)
	}
	if got := body.Deletes["artist"]; len(got) != 1 || got[0] != "deadbeef-dead-beef-dead-beefdeadbeef" {
		t.Fatalf("expected one artist delete; got %#v", body.Deletes)
	}
}

func TestLibrarySync_LikeRowScopedToUser(t *testing.T) {
	h, pool := testHandlers(t)
	truncateLibrary(t, pool)
	u := seedUser(t, pool, "alice", "x", false)
	other := seedUser(t, pool, "bob", "x", false)

	// Two like_track rows: one for u, one for other. u's call should
	// see only their own row.
	ctx := context.Background()
	tx, _ := pool.Begin(ctx)
	_ = sync.LogChange(ctx, tx, sync.EntityLikeTrack,
		sync.EncodeLikeID(sync.FormatUUID(u.ID), "11111111-1111-1111-1111-111111111111"),
		sync.OpUpsert)
	_ = sync.LogChange(ctx, tx, sync.EntityLikeTrack,
		sync.EncodeLikeID(sync.FormatUUID(other.ID), "22222222-2222-2222-2222-222222222222"),
		sync.OpUpsert)
	_ = tx.Commit(ctx)

	w := callLibrarySync(h, u, "0")
	if w.Code != http.StatusOK {
		t.Fatalf("status = %d body = %s", w.Code, w.Body.String())
	}
	var body struct {
		Upserts map[string][]json.RawMessage `json:"upserts"`
	}
	if err := json.NewDecoder(w.Body).Decode(&body); err != nil {
		t.Fatalf("decode: %v", err)
	}
	got := body.Upserts["like_track"]
	if len(got) != 1 {
		t.Fatalf("expected 1 like row scoped to alice, got %d", len(got))
	}
}

// Pure unit tests below — run even with -short.

func TestSplitOnce(t *testing.T) {
	cases := []struct {
		in, sep string
		want    []string
	}{
		{"a:b", ":", []string{"a", "b"}},
		{"a:b:c", ":", []string{"a", "b:c"}},
		{"abc", ":", []string{"abc"}},
		{":x", ":", []string{"", "x"}},
	}
	for _, c := range cases {
		got := splitOnce(c.in, c.sep)
		if len(got) != len(c.want) {
			t.Errorf("splitOnce(%q,%q): len got=%d want=%d", c.in, c.sep, len(got), len(c.want))
			continue
		}
		for i := range got {
			if got[i] != c.want[i] {
				t.Errorf("splitOnce(%q,%q)[%d] = %q want %q", c.in, c.sep, i, got[i], c.want[i])
			}
		}
	}
}
