package store

import (
	"context"
	"path/filepath"
	"testing"
	"time"

	"git.cloudinit.dev/coreci/orca/internal/model"
)

func openTestDB(t *testing.T) (*NodeRepo, func()) {
	t.Helper()
	path := filepath.Join(t.TempDir(), "test.db")
	db, err := Open(path)
	if err != nil {
		t.Fatalf("open db: %v", err)
	}
	return NewNodeRepo(db), func() { _ = db.Close() }
}

func TestNodeRepo_InsertAndGet(t *testing.T) {
	repo, cleanup := openTestDB(t)
	defer cleanup()

	ctx := context.Background()
	n := &model.Node{
		ID:       "test-id-1",
		Name:     "alpha",
		Address:  "localhost:8443",
		State:    model.NodeStateReady,
		JoinedAt: time.Now().UTC(),
		LastSeen: time.Now().UTC(),
	}
	if err := repo.Insert(ctx, n); err != nil {
		t.Fatalf("insert: %v", err)
	}
	got, err := repo.Get(ctx, "test-id-1")
	if err != nil {
		t.Fatalf("get: %v", err)
	}
	if got.Name != "alpha" || got.Address != "localhost:8443" {
		t.Errorf("unexpected node: %+v", got)
	}
	if got.State != model.NodeStateReady {
		t.Errorf("expected state ready, got %s", got.State)
	}
}

func TestNodeRepo_List(t *testing.T) {
	repo, cleanup := openTestDB(t)
	defer cleanup()

	ctx := context.Background()
	for _, name := range []string{"a", "b", "c"} {
		_ = repo.Insert(ctx, &model.Node{
			ID: name, Name: name, Address: "addr",
			JoinedAt: time.Now().UTC(), LastSeen: time.Now().UTC(),
		})
	}
	nodes, err := repo.List(ctx)
	if err != nil {
		t.Fatalf("list: %v", err)
	}
	if len(nodes) != 3 {
		t.Errorf("expected 3 nodes, got %d", len(nodes))
	}
}

func TestNodeRepo_UpdateState(t *testing.T) {
	repo, cleanup := openTestDB(t)
	defer cleanup()

	ctx := context.Background()
	_ = repo.Insert(ctx, &model.Node{
		ID: "x", Name: "x", Address: "a", JoinedAt: time.Now().UTC(), LastSeen: time.Now().UTC(),
	})
	if err := repo.UpdateState(ctx, "x", model.NodeStateLeft); err != nil {
		t.Fatalf("update: %v", err)
	}
	got, _ := repo.Get(ctx, "x")
	if got.State != model.NodeStateLeft {
		t.Errorf("expected left, got %s", got.State)
	}
}

func TestNodeRepo_Delete(t *testing.T) {
	repo, cleanup := openTestDB(t)
	defer cleanup()

	ctx := context.Background()
	_ = repo.Insert(ctx, &model.Node{
		ID: "y", Name: "y", Address: "a", JoinedAt: time.Now().UTC(), LastSeen: time.Now().UTC(),
	})
	if err := repo.Delete(ctx, "y"); err != nil {
		t.Fatalf("delete: %v", err)
	}
	_, err := repo.Get(ctx, "y")
	if err != ErrNotFound {
		t.Errorf("expected ErrNotFound, got %v", err)
	}
}

func TestNodeRepo_DeleteMissing(t *testing.T) {
	repo, cleanup := openTestDB(t)
	defer cleanup()

	ctx := context.Background()
	if err := repo.Delete(ctx, "ghost"); err != ErrNotFound {
		t.Errorf("Delete(ghost) = %v, want ErrNotFound", err)
	}
}

func TestNodeRepo_UpdateStateMissing(t *testing.T) {
	repo, cleanup := openTestDB(t)
	defer cleanup()

	ctx := context.Background()
	if err := repo.UpdateState(ctx, "ghost", model.NodeStateLeft); err != ErrNotFound {
		t.Errorf("UpdateState(ghost) = %v, want ErrNotFound", err)
	}
}

func TestNodeRepo_SetNodeState(t *testing.T) {
	repo, cleanup := openTestDB(t)
	defer cleanup()

	ctx := context.Background()
	n := &model.Node{
		ID:       "setstate-1",
		Name:     "setstate",
		Address:  "setstate:8443",
		State:    model.NodeStateReady,
		JoinedAt: time.Now().UTC(),
		LastSeen: time.Now().UTC(),
	}
	if err := repo.Insert(ctx, n); err != nil {
		t.Fatalf("insert: %v", err)
	}
	if err := repo.SetNodeState(ctx, n.ID, "draining"); err != nil {
		t.Fatalf("SetNodeState: %v", err)
	}
	got, err := repo.Get(ctx, n.ID)
	if err != nil {
		t.Fatalf("get: %v", err)
	}
	if got.State != model.NodeStateDraining {
		t.Errorf("state = %q, want draining", got.State)
	}

	// Missing node → ErrNotFound.
	if err := repo.SetNodeState(ctx, "ghost", "drained"); err != ErrNotFound {
		t.Errorf("SetNodeState(ghost) = %v, want ErrNotFound", err)
	}
}

func TestNodeRepo_UpdateLastSeenAndOSMissing(t *testing.T) {
	repo, cleanup := openTestDB(t)
	defer cleanup()

	ctx := context.Background()
	if err := repo.UpdateLastSeenAndOS(ctx, "ghost", "ubuntu"); err != ErrNotFound {
		t.Errorf("UpdateLastSeenAndOS(ghost) = %v, want ErrNotFound", err)
	}
}

func TestNodeRepo_GetMissing(t *testing.T) {
	repo, cleanup := openTestDB(t)
	defer cleanup()

	ctx := context.Background()
	if _, err := repo.Get(ctx, "ghost"); err != ErrNotFound {
		t.Errorf("Get(ghost) = %v, want ErrNotFound", err)
	}
}

func TestNodeRepo_InsertDefaults(t *testing.T) {
	repo, cleanup := openTestDB(t)
	defer cleanup()

	ctx := context.Background()
	// Insert with zero JoinedAt/LastSeen and empty State — defaults apply.
	n := &model.Node{ID: "defaults-1", Name: "d", Address: "addr"}
	if err := repo.Insert(ctx, n); err != nil {
		t.Fatalf("Insert: %v", err)
	}
	if n.JoinedAt.IsZero() {
		t.Errorf("Insert did not fill JoinedAt")
	}
	if n.LastSeen.IsZero() {
		t.Errorf("Insert did not fill LastSeen")
	}
	if n.State != model.NodeStateReady {
		t.Errorf("Insert default state = %q, want ready", n.State)
	}
}

func TestNodeRepo_MetadataRoundTrip(t *testing.T) {
	repo, cleanup := openTestDB(t)
	defer cleanup()

	ctx := context.Background()
	n := &model.Node{
		ID:       "meta-1",
		Name:     "meta",
		Address:  "addr",
		JoinedAt: time.Now().UTC(),
		LastSeen: time.Now().UTC(),
		Metadata: map[string]string{"arch": "amd64", "kernel": "6.1"},
	}
	if err := repo.Insert(ctx, n); err != nil {
		t.Fatalf("Insert: %v", err)
	}
	got, err := repo.Get(ctx, "meta-1")
	if err != nil {
		t.Fatalf("Get: %v", err)
	}
	if got.Metadata["arch"] != "amd64" {
		t.Errorf("metadata[arch] = %q, want amd64", got.Metadata["arch"])
	}
	if got.Metadata["kernel"] != "6.1" {
		t.Errorf("metadata[kernel] = %q, want 6.1", got.Metadata["kernel"])
	}
}

func TestNodeRepo_ListEmpty(t *testing.T) {
	repo, cleanup := openTestDB(t)
	defer cleanup()

	ctx := context.Background()
	nodes, err := repo.List(ctx)
	if err != nil {
		t.Fatalf("List: %v", err)
	}
	if len(nodes) != 0 {
		t.Errorf("List(empty): got %d, want 0", len(nodes))
	}
}

func TestNodeRepo_GetByNameMultiplePicksOldest(t *testing.T) {
	repo, cleanup := openTestDB(t)
	defer cleanup()

	ctx := context.Background()
	older := time.Now().UTC().Add(-1 * time.Hour)
	newer := time.Now().UTC()
	_ = repo.Insert(ctx, &model.Node{
		ID: "n-old", Name: "dup", Address: "a",
		JoinedAt: older, LastSeen: older,
	})
	_ = repo.Insert(ctx, &model.Node{
		ID: "n-new", Name: "dup", Address: "a",
		JoinedAt: newer, LastSeen: newer,
	})
	got, err := repo.GetByName(ctx, "dup")
	if err != nil {
		t.Fatalf("GetByName: %v", err)
	}
	if got.ID != "n-old" {
		t.Errorf("GetByName = %q, want oldest n-old (ORDER BY joined_at ASC)", got.ID)
	}
}

func TestNodeRepo_KindOS_RoundTrip(t *testing.T) {
	repo, cleanup := openTestDB(t)
	defer cleanup()

	ctx := context.Background()
	n := &model.Node{
		ID: "kind-os-1", Name: "localhost", Address: "localhost:8443",
		State: model.NodeStateReady, JoinedAt: time.Now().UTC(), LastSeen: time.Now().UTC(),
		Kind: string(model.NodeKindLocalhost), OS: "ubuntu",
	}
	if err := repo.Insert(ctx, n); err != nil {
		t.Fatalf("insert: %v", err)
	}
	got, err := repo.Get(ctx, "kind-os-1")
	if err != nil {
		t.Fatalf("get: %v", err)
	}
	if got.Kind != "localhost" {
		t.Errorf("kind = %q, want localhost", got.Kind)
	}
	if got.OS != "ubuntu" {
		t.Errorf("os = %q, want ubuntu", got.OS)
	}
}

func TestNodeRepo_NullKindOS_EmptyString(t *testing.T) {
	repo, cleanup := openTestDB(t)
	defer cleanup()

	ctx := context.Background()
	// Insert with empty Kind/OS — simulates a pre-0006 row or a node
	// that doesn't set kind/os.
	n := &model.Node{
		ID: "null-kind-os", Name: "legacy", Address: "addr",
		JoinedAt: time.Now().UTC(), LastSeen: time.Now().UTC(),
	}
	if err := repo.Insert(ctx, n); err != nil {
		t.Fatalf("insert: %v", err)
	}
	got, err := repo.Get(ctx, "null-kind-os")
	if err != nil {
		t.Fatalf("get: %v", err)
	}
	if got.Kind != "" {
		t.Errorf("kind = %q, want empty string for NULL", got.Kind)
	}
	if got.OS != "" {
		t.Errorf("os = %q, want empty string for NULL", got.OS)
	}
}

func TestNodeRepo_GetByName(t *testing.T) {
	repo, cleanup := openTestDB(t)
	defer cleanup()

	ctx := context.Background()
	_ = repo.Insert(ctx, &model.Node{
		ID: "by-name-1", Name: "localhost", Address: "addr",
		JoinedAt: time.Now().UTC(), LastSeen: time.Now().UTC(),
		Kind: "localhost", OS: "ubuntu",
	})

	got, err := repo.GetByName(ctx, "localhost")
	if err != nil {
		t.Fatalf("get by name: %v", err)
	}
	if got.ID != "by-name-1" {
		t.Errorf("id = %q, want by-name-1", got.ID)
	}

	_, err = repo.GetByName(ctx, "nonexistent")
	if err != ErrNotFound {
		t.Errorf("expected ErrNotFound, got %v", err)
	}
}

func TestNodeRepo_UpdateLastSeenAndOS(t *testing.T) {
	repo, cleanup := openTestDB(t)
	defer cleanup()

	ctx := context.Background()
	original := time.Now().UTC().Add(-1 * time.Hour)
	n := &model.Node{
		ID: "update-os-1", Name: "localhost", Address: "addr",
		JoinedAt: original, LastSeen: original,
		Kind: "localhost", OS: "ubuntu",
	}
	if err := repo.Insert(ctx, n); err != nil {
		t.Fatalf("insert: %v", err)
	}

	if err := repo.UpdateLastSeenAndOS(ctx, "update-os-1", "debian"); err != nil {
		t.Fatalf("update last_seen+os: %v", err)
	}

	got, err := repo.Get(ctx, "update-os-1")
	if err != nil {
		t.Fatalf("get: %v", err)
	}
	if got.OS != "debian" {
		t.Errorf("os = %q, want debian", got.OS)
	}
	if !got.LastSeen.After(original) {
		t.Errorf("last_seen not refreshed: %v", got.LastSeen)
	}
	if !got.JoinedAt.Equal(original) {
		t.Errorf("joined_at changed: was %v, now %v (D-036 violation)", original, got.JoinedAt)
	}
	if got.ID != "update-os-1" {
		t.Errorf("id changed: %q (D-036 violation)", got.ID)
	}
}

func insertNode(t *testing.T, repo *NodeRepo, ctx context.Context, id, name string) {
	t.Helper()
	if err := repo.Insert(ctx, &model.Node{
		ID:       id,
		Name:     name,
		Address:  "addr",
		State:    model.NodeStateReady,
		JoinedAt: time.Now().UTC(),
		LastSeen: time.Now().UTC(),
	}); err != nil {
		t.Fatalf("insert node %s: %v", id, err)
	}
}

func TestNodeRepoWatch_YieldsSnapshots(t *testing.T) {
	withFastWatch(t, 10*time.Millisecond)
	repo, cleanup := openTestDB(t)
	defer cleanup()

	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()

	insertNode(t, repo, ctx, "node-1", "alpha")

	var snapshots [][]*model.Node
	done := make(chan struct{})
	go func() {
		defer close(done)
		for snap := range repo.Watch(ctx) {
			snapshots = append(snapshots, snap)
			if len(snapshots) >= 40 {
				cancel()
				return
			}
		}
	}()

	time.Sleep(100 * time.Millisecond)
	insertNode(t, repo, context.Background(), "node-2", "beta")

	select {
	case <-done:
	case <-time.After(2 * time.Second):
		t.Fatal("watch did not complete within 2s")
	}

	if len(snapshots) == 0 {
		t.Fatal("expected at least one snapshot, got none")
	}
	if len(snapshots[0]) != 1 || snapshots[0][0].ID != "node-1" {
		t.Errorf("first snapshot = %+v, want only node-1", snapshots[0])
	}
	foundBoth := false
	for _, snap := range snapshots[1:] {
		ids := make(map[string]bool, len(snap))
		for _, n := range snap {
			ids[n.ID] = true
		}
		if ids["node-1"] && ids["node-2"] {
			foundBoth = true
			break
		}
	}
	if !foundBoth {
		t.Errorf("no snapshot contained both nodes; snapshots=%v", snapshots)
	}
}

func TestNodeRepoWatch_ImmediateFirstYield(t *testing.T) {
	withFastWatch(t, 200*time.Millisecond)
	repo, cleanup := openTestDB(t)
	defer cleanup()

	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()

	insertNode(t, repo, ctx, "node-immediate", "first")

	start := time.Now()
	var firstSnap []*model.Node
	got := make(chan struct{})
	go func() {
		for snap := range repo.Watch(ctx) {
			firstSnap = snap
			close(got)
			cancel()
			return
		}
	}()

	select {
	case <-got:
	case <-time.After(100 * time.Millisecond):
		t.Fatal("first yield took >100ms; expected immediate (G-002)")
	}

	elapsed := time.Since(start)
	if elapsed > 100*time.Millisecond {
		t.Errorf("first yield took %v; expected immediate (G-002)", elapsed)
	}
	if len(firstSnap) != 1 || firstSnap[0].ID != "node-immediate" {
		t.Errorf("first snapshot = %+v, want node-immediate", firstSnap)
	}
}

func TestNodeRepoWatch_StopsOnConsumerBreak(t *testing.T) {
	withFastWatch(t, 10*time.Millisecond)
	repo, cleanup := openTestDB(t)
	defer cleanup()

	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()

	insertNode(t, repo, ctx, "node-break", "break")

	done := make(chan struct{})
	go func() {
		defer close(done)
		for range repo.Watch(ctx) {
			break
		}
	}()

	select {
	case <-done:
	case <-time.After(500 * time.Millisecond):
		t.Fatal("watch did not stop on consumer break within 500ms")
	}
}

func TestNodeRepoWatch_StopsOnCtxCancel(t *testing.T) {
	withFastWatch(t, 10*time.Millisecond)
	repo, cleanup := openTestDB(t)
	defer cleanup()

	ctx, cancel := context.WithCancel(context.Background())

	insertNode(t, repo, ctx, "node-cancel", "cancel")

	done := make(chan struct{})
	go func() {
		defer close(done)
		for range repo.Watch(ctx) {
		}
	}()

	time.Sleep(20 * time.Millisecond)
	cancel()

	select {
	case <-done:
	case <-time.After(500 * time.Millisecond):
		t.Fatal("watch did not stop on ctx cancel within 500ms")
	}
}
