package store

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

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

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

// openFullTestDB returns the underlying *sql.DB plus repos for cross-repo
// tests (e.g. TaskRepo needs a JobRepo parent row when foreign keys are on).
func openFullTestDB(t *testing.T) (*sql.DB, *JobRepo, *TaskRepo, func()) {
	t.Helper()
	path := filepath.Join(t.TempDir(), "test.db")
	db, err := Open(path)
	if err != nil {
		t.Fatalf("open db: %v", err)
	}
	return db, NewJobRepo(db), NewTaskRepo(db), func() { _ = db.Close() }
}

func TestJobRepo_Get(t *testing.T) {
	repo, cleanup := openJobTestDB(t)
	defer cleanup()
	ctx := context.Background()

	insertJob(t, repo, ctx, "job-get", "alpha")

	got, err := repo.Get(ctx, "job-get")
	if err != nil {
		t.Fatalf("Get: %v", err)
	}
	if got.ID != "job-get" || got.Name != "alpha" {
		t.Errorf("Get: got %+v", got)
	}
	if got.Status != model.JobStatusPending {
		t.Errorf("Get: status = %q, want pending", got.Status)
	}
	if got.Spec != "test" {
		t.Errorf("Get: spec = %q, want test", got.Spec)
	}

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

func TestJobRepo_List(t *testing.T) {
	repo, cleanup := openJobTestDB(t)
	defer cleanup()
	ctx := context.Background()

	insertJob(t, repo, ctx, "j1", "first")
	insertJob(t, repo, ctx, "j2", "second")
	insertJob(t, repo, ctx, "j3", "third")

	jobs, err := repo.List(ctx)
	if err != nil {
		t.Fatalf("List: %v", err)
	}
	if len(jobs) != 3 {
		t.Fatalf("List: got %d jobs, want 3", len(jobs))
	}
	// ORDER BY created_at DESC — but timestamps may collide at second
	// precision. Just verify all 3 IDs are present.
	ids := map[string]bool{}
	for _, j := range jobs {
		ids[j.ID] = true
	}
	for _, want := range []string{"j1", "j2", "j3"} {
		if !ids[want] {
			t.Errorf("List: missing job %q", want)
		}
	}
}

func TestJobRepo_UpdateStatus(t *testing.T) {
	repo, cleanup := openJobTestDB(t)
	defer cleanup()
	ctx := context.Background()

	insertJob(t, repo, ctx, "job-status", "alpha")

	cases := []struct {
		name     string
		status   model.JobStatus
		exitCode int
	}{
		{"running", model.JobStatusRunning, 0},
		{"complete", model.JobStatusComplete, 0},
		{"failed", model.JobStatusFailed, 1},
		{"stopped", model.JobStatusStopped, 130},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			if err := repo.UpdateStatus(ctx, "job-status", tc.status, tc.exitCode); err != nil {
				t.Fatalf("UpdateStatus(%s): %v", tc.name, err)
			}
			got, err := repo.Get(ctx, "job-status")
			if err != nil {
				t.Fatalf("Get: %v", err)
			}
			if got.Status != tc.status {
				t.Errorf("status = %q, want %q", got.Status, tc.status)
			}
			if got.ExitCode != tc.exitCode {
				t.Errorf("exit_code = %d, want %d", got.ExitCode, tc.exitCode)
			}
			switch tc.status {
			case model.JobStatusRunning:
				if got.StartedAt == nil {
					t.Errorf("started_at should be set for %s", tc.name)
				}
			case model.JobStatusComplete, model.JobStatusFailed, model.JobStatusStopped:
				if got.EndedAt == nil {
					t.Errorf("ended_at should be set for %s", tc.name)
				}
			}
		})
	}
}

func TestJobRepo_InsertDefaults(t *testing.T) {
	repo, cleanup := openJobTestDB(t)
	defer cleanup()
	ctx := context.Background()

	// Insert with zero CreatedAt and empty Status — defaults should kick in.
	j := &model.Job{ID: "defaults-1", Name: "d", Spec: "s"}
	if err := repo.Insert(ctx, j); err != nil {
		t.Fatalf("Insert: %v", err)
	}
	if j.CreatedAt.IsZero() {
		t.Errorf("Insert did not fill CreatedAt")
	}
	if j.Status != model.JobStatusPending {
		t.Errorf("Insert default status = %q, want pending", j.Status)
	}
	got, _ := repo.Get(ctx, "defaults-1")
	if got.Status != model.JobStatusPending {
		t.Errorf("Get: status = %q, want pending", got.Status)
	}
}

func sampleTask(id, jobID string) *model.Task {
	return &model.Task{
		ID:      id,
		JobID:   jobID,
		Command: "/bin/echo",
		Args:    []string{"hello", "world"},
		Env:     []string{"FOO=bar", "BAZ=qux"},
	}
}

func TestTaskRepo_InsertAndGet(t *testing.T) {
	_, jobRepo, taskRepo, cleanup := openFullTestDB(t)
	defer cleanup()
	ctx := context.Background()

	insertJob(t, jobRepo, ctx, "job-1", "alpha")
	tk := sampleTask("task-1", "job-1")
	if err := taskRepo.Insert(ctx, tk); err != nil {
		t.Fatalf("Insert: %v", err)
	}
	if tk.CreatedAt.IsZero() {
		t.Errorf("Insert did not fill CreatedAt")
	}
	if tk.Status != model.TaskStatusPending {
		t.Errorf("Insert default status = %q, want pending", tk.Status)
	}

	got, err := taskRepo.Get(ctx, "task-1")
	if err != nil {
		t.Fatalf("Get: %v", err)
	}
	if got.Command != "/bin/echo" {
		t.Errorf("command = %q", got.Command)
	}
	if len(got.Args) != 2 || got.Args[0] != "hello" {
		t.Errorf("args = %v", got.Args)
	}
	if len(got.Env) != 2 || got.Env[0] != "FOO=bar" {
		t.Errorf("env = %v", got.Env)
	}
	if got.Status != model.TaskStatusPending {
		t.Errorf("status = %q, want pending", got.Status)
	}

	if _, err := taskRepo.Get(ctx, "missing"); err != ErrNotFound {
		t.Errorf("Get(missing) = %v, want ErrNotFound", err)
	}
}

func TestTaskRepo_ListByJob(t *testing.T) {
	_, jobRepo, taskRepo, cleanup := openFullTestDB(t)
	defer cleanup()
	ctx := context.Background()

	insertJob(t, jobRepo, ctx, "job-lbj", "alpha")
	for _, id := range []string{"t1", "t2", "t3"} {
		if err := taskRepo.Insert(ctx, sampleTask(id, "job-lbj")); err != nil {
			t.Fatalf("Insert %s: %v", id, err)
		}
	}
	// Insert a task for a different job to ensure filtering works.
	insertJob(t, jobRepo, ctx, "job-other", "beta")
	if err := taskRepo.Insert(ctx, sampleTask("t-other", "job-other")); err != nil {
		t.Fatalf("Insert t-other: %v", err)
	}

	tasks, err := taskRepo.ListByJob(ctx, "job-lbj")
	if err != nil {
		t.Fatalf("ListByJob: %v", err)
	}
	if len(tasks) != 3 {
		t.Fatalf("ListByJob: got %d tasks, want 3", len(tasks))
	}
	for _, tk := range tasks {
		if tk.JobID != "job-lbj" {
			t.Errorf("ListByJob returned task with job_id=%q", tk.JobID)
		}
	}
}

func TestTaskRepo_UpdateRunning(t *testing.T) {
	_, jobRepo, taskRepo, cleanup := openFullTestDB(t)
	defer cleanup()
	ctx := context.Background()

	insertJob(t, jobRepo, ctx, "job-run", "alpha")
	if err := taskRepo.Insert(ctx, sampleTask("task-run", "job-run")); err != nil {
		t.Fatalf("Insert: %v", err)
	}
	if err := taskRepo.UpdateRunning(ctx, "task-run", 4242); err != nil {
		t.Fatalf("UpdateRunning: %v", err)
	}
	got, _ := taskRepo.Get(ctx, "task-run")
	if got.PID != 4242 {
		t.Errorf("pid = %d, want 4242", got.PID)
	}
	if got.Status != model.TaskStatusRunning {
		t.Errorf("status = %q, want running", got.Status)
	}
	if got.StartedAt == nil {
		t.Errorf("started_at should be set after UpdateRunning")
	}
}

func TestTaskRepo_UpdateDone(t *testing.T) {
	_, jobRepo, taskRepo, cleanup := openFullTestDB(t)
	defer cleanup()
	ctx := context.Background()

	insertJob(t, jobRepo, ctx, "job-done", "alpha")
	if err := taskRepo.Insert(ctx, sampleTask("task-done", "job-done")); err != nil {
		t.Fatalf("Insert: %v", err)
	}

	cases := []struct {
		name     string
		exitCode int
		want     model.TaskStatus
	}{
		{"complete", 0, model.TaskStatusComplete},
		{"failed", 1, model.TaskStatusFailed},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			id := "task-done-" + tc.name
			if err := taskRepo.Insert(ctx, sampleTask(id, "job-done")); err != nil {
				t.Fatalf("Insert: %v", err)
			}
			if err := taskRepo.UpdateDone(ctx, id, tc.exitCode, "stdout-data", "stderr-data"); err != nil {
				t.Fatalf("UpdateDone: %v", err)
			}
			got, _ := taskRepo.Get(ctx, id)
			if got.Status != tc.want {
				t.Errorf("status = %q, want %q", got.Status, tc.want)
			}
			if got.ExitCode != tc.exitCode {
				t.Errorf("exit_code = %d, want %d", got.ExitCode, tc.exitCode)
			}
			if got.Stdout != "stdout-data" {
				t.Errorf("stdout = %q", got.Stdout)
			}
			if got.Stderr != "stderr-data" {
				t.Errorf("stderr = %q", got.Stderr)
			}
			if got.EndedAt == nil {
				t.Errorf("ended_at should be set after UpdateDone")
			}
		})
	}
}

func TestTaskRepo_UpdateKilled(t *testing.T) {
	_, jobRepo, taskRepo, cleanup := openFullTestDB(t)
	defer cleanup()
	ctx := context.Background()

	insertJob(t, jobRepo, ctx, "job-kill", "alpha")
	if err := taskRepo.Insert(ctx, sampleTask("task-kill", "job-kill")); err != nil {
		t.Fatalf("Insert: %v", err)
	}
	if err := taskRepo.UpdateKilled(ctx, "task-kill"); err != nil {
		t.Fatalf("UpdateKilled: %v", err)
	}
	got, _ := taskRepo.Get(ctx, "task-kill")
	if got.Status != model.TaskStatusKilled {
		t.Errorf("status = %q, want killed", got.Status)
	}
	if got.EndedAt == nil {
		t.Errorf("ended_at should be set after UpdateKilled")
	}
}

func TestTaskRepo_ListRecent(t *testing.T) {
	_, jobRepo, taskRepo, cleanup := openFullTestDB(t)
	defer cleanup()
	ctx := context.Background()

	insertJob(t, jobRepo, ctx, "job-recent", "alpha")
	for i := 0; i < 5; i++ {
		id := "task-recent-" + string(rune('a'+i))
		if err := taskRepo.Insert(ctx, sampleTask(id, "job-recent")); err != nil {
			t.Fatalf("Insert %s: %v", id, err)
		}
	}

	// limit=3
	tasks, err := taskRepo.ListRecent(ctx, 3)
	if err != nil {
		t.Fatalf("ListRecent(3): %v", err)
	}
	if len(tasks) != 3 {
		t.Errorf("ListRecent(3): got %d, want 3", len(tasks))
	}

	// limit<=0 → defaults to 100
	all, err := taskRepo.ListRecent(ctx, 0)
	if err != nil {
		t.Fatalf("ListRecent(0): %v", err)
	}
	if len(all) != 5 {
		t.Errorf("ListRecent(0): got %d, want 5 (default limit 100)", len(all))
	}

	// limit negative
	neg, err := taskRepo.ListRecent(ctx, -1)
	if err != nil {
		t.Fatalf("ListRecent(-1): %v", err)
	}
	if len(neg) != 5 {
		t.Errorf("ListRecent(-1): got %d, want 5", len(neg))
	}
}

func TestTaskRepo_ListByJob_Empty(t *testing.T) {
	_, _, taskRepo, cleanup := openFullTestDB(t)
	defer cleanup()
	ctx := context.Background()

	tasks, err := taskRepo.ListByJob(ctx, "nope")
	if err != nil {
		t.Fatalf("ListByJob: %v", err)
	}
	if len(tasks) != 0 {
		t.Errorf("ListByJob(empty): got %d, want 0", len(tasks))
	}
}

func insertJob(t *testing.T, repo *JobRepo, ctx context.Context, id, name string) {
	t.Helper()
	if err := repo.Insert(ctx, &model.Job{
		ID:     id,
		Name:   name,
		Spec:   "test",
		Status: model.JobStatusPending,
	}); err != nil {
		t.Fatalf("insert job %s: %v", id, err)
	}
}

// withFastWatch sets watchInterval to a small value for deterministic tests and
// restores the default (1s) on cleanup.
func withFastWatch(t *testing.T, d time.Duration) {
	t.Helper()
	prev := watchInterval
	watchInterval = d
	t.Cleanup(func() { watchInterval = prev })
}

// TestJobRepoWatch_YieldsSnapshots verifies each yield is a complete tick
// snapshot (G-001): the first snapshot contains only the first job, and a
// later snapshot contains both jobs after a second insert.
func TestJobRepoWatch_YieldsSnapshots(t *testing.T) {
	withFastWatch(t, 10*time.Millisecond)
	repo, cleanup := openJobTestDB(t)
	defer cleanup()

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

	insertJob(t, repo, ctx, "job-1", "alpha")

	var snapshots [][]*model.Job
	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
			}
		}
	}()

	// Insert a second job after a short delay so a later tick observes it.
	// Use a background context — the watch ctx may be cancelled by the
	// goroutine above once it collects enough snapshots.
	time.Sleep(100 * time.Millisecond)
	insertJob(t, repo, context.Background(), "job-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")
	}
	// First snapshot must contain only the first job (G-001).
	if len(snapshots[0]) != 1 || snapshots[0][0].ID != "job-1" {
		t.Errorf("first snapshot = %+v, want only job-1", snapshots[0])
	}
	// At least one later snapshot must contain both jobs.
	foundBoth := false
	for _, snap := range snapshots[1:] {
		ids := make(map[string]bool, len(snap))
		for _, j := range snap {
			ids[j.ID] = true
		}
		if ids["job-1"] && ids["job-2"] {
			foundBoth = true
			break
		}
	}
	if !foundBoth {
		t.Errorf("no snapshot contained both jobs; snapshots=%v", snapshots)
	}
}

// TestJobRepoWatch_ImmediateFirstYield verifies G-002: the first snapshot
// arrives before the first ticker wait, i.e. well under the watchInterval.
func TestJobRepoWatch_ImmediateFirstYield(t *testing.T) {
	withFastWatch(t, 200*time.Millisecond)
	repo, cleanup := openJobTestDB(t)
	defer cleanup()

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

	insertJob(t, repo, ctx, "job-immediate", "first")

	start := time.Now()
	var firstSnap []*model.Job
	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 != "job-immediate" {
		t.Errorf("first snapshot = %+v, want job-immediate", firstSnap)
	}
}

// TestJobRepoWatch_StopsOnConsumerBreak verifies the yield==false path: the
// range loop returns promptly when the consumer breaks after the first yield.
func TestJobRepoWatch_StopsOnConsumerBreak(t *testing.T) {
	withFastWatch(t, 10*time.Millisecond)
	repo, cleanup := openJobTestDB(t)
	defer cleanup()

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

	insertJob(t, repo, ctx, "job-break", "break")

	done := make(chan struct{})
	go func() {
		defer close(done)
		for range repo.Watch(ctx) {
			break // stop pulling immediately after the first snapshot
		}
	}()

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

// TestJobRepoWatch_StopsOnCtxCancel verifies the loop exits promptly after
// ctx is cancelled.
func TestJobRepoWatch_StopsOnCtxCancel(t *testing.T) {
	withFastWatch(t, 10*time.Millisecond)
	repo, cleanup := openJobTestDB(t)
	defer cleanup()

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

	insertJob(t, repo, ctx, "job-cancel", "cancel")

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

	// Let at least one tick land, then cancel.
	time.Sleep(20 * time.Millisecond)
	cancel()

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