package cicdstate

import (
	"fmt"
	"testing"
	"time"
)

func TestStoreCreatesJobAndExecution(t *testing.T) {
	var mockTime time.Time
	store := NewStore()
	store.now = func() time.Time { return mockTime }

	// 1. Create a job
	mockTime = time.Date(2026, 6, 5, 12, 0, 0, 0, time.UTC)
	job, err := store.CreateJob("job-1", "build", nil)
	if err != nil {
		t.Fatalf("CreateJob failed: %v", err)
	}
	if job.ID != "job-1" {
		t.Fatalf("job ID = %q, want job-1", job.ID)
	}
	if job.Name != "build" {
		t.Fatalf("job Name = %q, want build", job.Name)
	}
	if job.State != StateQueued {
		t.Fatalf("job State = %q, want %q", job.State, StateQueued)
	}

	// 2. Try duplicate job creation
	_, err = store.CreateJob("job-1", "duplicate", nil)
	if err == nil {
		t.Fatal("expected error for duplicate job")
	}

	// 3. Verify job is stored and returns a copy
	job2, err := store.GetJob("job-1")
	if err != nil {
		t.Fatalf("GetJob failed: %v", err)
	}
	if job2.ID != job.ID || job2.Name != job.Name || job2.State != job.State {
		t.Fatalf("GetJob returned different data: got %+v", job2)
	}
	// Modifying returned job should not affect store
	job2.Name = "modified"
	jobOrig, err := store.GetJob("job-1")
	if err != nil {
		t.Fatalf("GetJob after modification failed: %v", err)
	}
	if jobOrig.Name == "modified" {
		t.Fatal("GetJob did not return a copy; modifications leaked back")
	}

	// 4. Create execution for job
	exec, err := store.CreateExecution("job-1", "exec-1")
	if err != nil {
		t.Fatalf("CreateExecution failed: %v", err)
	}
	if exec.ID != "exec-1" {
		t.Fatalf("execution ID = %q, want exec-1", exec.ID)
	}
	if exec.JobID != "job-1" {
		t.Fatalf("execution JobID = %q, want job-1", exec.JobID)
	}
	if exec.State != StateQueued {
		t.Fatalf("execution State = %q, want %q", exec.State, StateQueued)
	}
	if len(exec.Logs) != 0 || len(exec.Artifacts) != 0 {
		t.Fatalf("execution Logs/Artifacts should be empty, got %d/%d", len(exec.Logs), len(exec.Artifacts))
	}

	// 5. Job should now have ExecutionID
	jobAfterExec, err := store.GetJob("job-1")
	if err != nil {
		t.Fatalf("GetJob after execution creation failed: %v", err)
	}
	if jobAfterExec.ExecutionID != "exec-1" {
		t.Fatalf("job ExecutionID = %q, want exec-1", jobAfterExec.ExecutionID)
	}

	// 6. Try creating second execution for same job
	_, err = store.CreateExecution("job-1", "exec-2")
	if err == nil {
		t.Fatal("expected error for second execution on same job")
	}

	store.CreateJob("job-2", "test", nil)
	_, err = store.CreateExecution("job-2", "exec-1")
	if err == nil {
		t.Fatal("expected error for duplicate execution id")
	}
}

func TestStoreNextExecutionID(t *testing.T) {
	store := NewStore()
	fixed := time.Date(2026, 6, 5, 12, 0, 0, 0, time.UTC)
	store.now = func() time.Time { return fixed }

	execID := store.NextExecutionID()
	expected := fmt.Sprintf("exec-%d", fixed.UnixNano())
	if execID != expected {
		t.Fatalf("execution id = %q, want %q", execID, expected)
	}
}

func TestStoreCreatesJobWithRunInputCopy(t *testing.T) {
	store := NewStore()

	variables := map[string]string{"FLAVOR": "release"}
	commandTypes := []string{"Shell", "Git"}
	input := &RunInput{
		PipelineYAML: "commands:\n  - type: Shell",
		Variables:    variables,
		CommandTypes: commandTypes,
	}

	job, err := store.CreateJob("job-1", "build", input)
	if err != nil {
		t.Fatalf("CreateJob failed: %v", err)
	}
	if job.RunInput == nil || job.RunInput.PipelineYAML != "commands:\n  - type: Shell" {
		t.Fatalf("job RunInput = %+v, want stored pipeline yaml", job.RunInput)
	}

	// Mutating the caller-owned input must not leak into the store.
	variables["FLAVOR"] = "debug"
	commandTypes[0] = "Mutated"
	input.PipelineYAML = "mutated"

	stored, err := store.GetJob("job-1")
	if err != nil {
		t.Fatalf("GetJob failed: %v", err)
	}
	if stored.RunInput == nil {
		t.Fatal("stored RunInput is nil")
	}
	if stored.RunInput.PipelineYAML != "commands:\n  - type: Shell" {
		t.Fatalf("stored PipelineYAML leaked mutation: %q", stored.RunInput.PipelineYAML)
	}
	if stored.RunInput.Variables["FLAVOR"] != "release" {
		t.Fatalf("stored Variables leaked mutation: %q", stored.RunInput.Variables["FLAVOR"])
	}
	if stored.RunInput.CommandTypes[0] != "Shell" {
		t.Fatalf("stored CommandTypes leaked mutation: %q", stored.RunInput.CommandTypes[0])
	}

	// Mutating the returned copy must not leak back into the store either.
	stored.RunInput.Variables["FLAVOR"] = "leak"
	stored.RunInput.CommandTypes[0] = "Leak"
	reread, err := store.GetJob("job-1")
	if err != nil {
		t.Fatalf("GetJob after mutation failed: %v", err)
	}
	if reread.RunInput.Variables["FLAVOR"] != "release" || reread.RunInput.CommandTypes[0] != "Shell" {
		t.Fatalf("GetJob did not return a deep copy of RunInput: %+v", reread.RunInput)
	}
}

func TestStoreAppendsLogsAndArtifacts(t *testing.T) {
	var mockTime time.Time
	store := NewStore()
	store.now = func() time.Time { return mockTime }

	mockTime = time.Date(2026, 6, 5, 12, 0, 0, 0, time.UTC)
	store.CreateJob("job-1", "build", nil)

	// Append log to non-existent execution
	err := store.AppendLog("exec-unknown", "line 1")
	if err == nil {
		t.Fatal("expected error appending log to unknown execution")
	}

	// Create execution and append log
	_, err = store.CreateExecution("job-1", "exec-1")
	if err != nil {
		t.Fatalf("CreateExecution failed: %v", err)
	}

	mockTime = mockTime.Add(1 * time.Second)
	err = store.AppendLog("exec-1", "starting build")
	if err != nil {
		t.Fatalf("AppendLog failed: %v", err)
	}

	mockTime = mockTime.Add(1 * time.Second)
	err = store.AppendLog("exec-1", "compile complete")
	if err != nil {
		t.Fatalf("AppendLog failed: %v", err)
	}

	logs, err := store.GetLogs("exec-1")
	if err != nil {
		t.Fatalf("GetLogs failed: %v", err)
	}
	if len(logs) != 2 {
		t.Fatalf("len(logs) = %d, want 2", len(logs))
	}
	if logs[0].Line != "starting build" {
		t.Fatalf("logs[0].Line = %q, want starting build", logs[0].Line)
	}
	if !logs[0].Timestamp.Equal(time.Date(2026, 6, 5, 12, 0, 1, 0, time.UTC)) {
		t.Fatalf("logs[0].Timestamp = %s, want injected clock time", logs[0].Timestamp)
	}
	if logs[1].Line != "compile complete" {
		t.Fatalf("logs[1].Line = %q, want compile complete", logs[1].Line)
	}
	if !logs[1].Timestamp.Equal(time.Date(2026, 6, 5, 12, 0, 2, 0, time.UTC)) {
		t.Fatalf("logs[1].Timestamp = %s, want injected clock time", logs[1].Timestamp)
	}

	// GetLogs should return a copy
	logs[1].Line = "modified"
	logs2, err := store.GetLogs("exec-1")
	if err != nil {
		t.Fatalf("GetLogs after modification failed: %v", err)
	}
	if logs2[1].Line == "modified" {
		t.Fatal("GetLogs did not return a copy")
	}

	// Append artifacts
	err = store.AppendArtifact("exec-1", "binary", "/dist/app")
	if err != nil {
		t.Fatalf("AppendArtifact failed: %v", err)
	}

	err = store.AppendArtifact("exec-1", "report", "/dist/report.xml")
	if err != nil {
		t.Fatalf("AppendArtifact failed: %v", err)
	}

	artifacts, err := store.GetArtifacts("exec-1")
	if err != nil {
		t.Fatalf("GetArtifacts failed: %v", err)
	}
	if len(artifacts) != 2 {
		t.Fatalf("len(artifacts) = %d, want 2", len(artifacts))
	}
	if artifacts[0].Name != "binary" || artifacts[0].Path != "/dist/app" {
		t.Fatalf("artifacts[0] = %#v", artifacts[0])
	}
	if artifacts[1].Name != "report" || artifacts[1].Path != "/dist/report.xml" {
		t.Fatalf("artifacts[1] = %#v", artifacts[1])
	}

	// GetArtifacts should return a copy
	artifacts[0].Path = "modified"
	artifacts2, err := store.GetArtifacts("exec-1")
	if err != nil {
		t.Fatalf("GetArtifacts after modification failed: %v", err)
	}
	if artifacts2[0].Path == "modified" {
		t.Fatal("GetArtifacts did not return a copy")
	}
}

func TestStoreRejectsUnknownExecution(t *testing.T) {
	store := NewStore()
	_, err := store.GetExecution("unknown-exec")
	if err == nil {
		t.Fatal("expected error for unknown execution")
	}

	err = store.AppendLog("unknown-exec", "line")
	if err == nil {
		t.Fatal("expected error appending log to unknown execution")
	}

	err = store.AppendArtifact("unknown-exec", "name", "path")
	if err == nil {
		t.Fatal("expected error appending artifact to unknown execution")
	}

	_, err = store.GetLogs("unknown-exec")
	if err == nil {
		t.Fatal("expected error getting logs from unknown execution")
	}

	_, err = store.GetArtifacts("unknown-exec")
	if err == nil {
		t.Fatal("expected error getting artifacts from unknown execution")
	}
}

func TestStoreJobExecutionNotFound(t *testing.T) {
	store := NewStore()

	_, err := store.GetJob("nonexistent")
	if err == nil {
		t.Fatal("expected error for unknown job")
	}

	exec, err := store.CreateExecution("nonexistent", "exec-1")
	if err == nil {
		t.Fatal("expected error creating execution for unknown job")
	}
	if exec != nil {
		t.Fatal("CreateExecution for unknown job returned non-nil execution")
	}
}

func TestStoreStateTransitions(t *testing.T) {
	store := NewStore()
	store.CreateJob("job-1", "build", nil)
	transitionAt := time.Date(2026, 6, 5, 13, 0, 0, 0, time.UTC)

	job, _ := store.GetJob("job-1")
	if job.State != StateQueued {
		t.Fatalf("initial job state = %q, want %q", job.State, StateQueued)
	}

	// queued -> running
	err := job.transitionTo(StateRunning, transitionAt)
	if err != nil {
		t.Fatalf("queued->running transition failed: %v", err)
	}
	if job.State != StateRunning {
		t.Fatalf("job state = %q, want %q", job.State, StateRunning)
	}
	if !job.UpdatedAt.Equal(transitionAt) {
		t.Fatalf("job UpdatedAt = %s, want %s", job.UpdatedAt, transitionAt)
	}

	// running -> succeeded
	err = job.transitionTo(StateSucceeded, transitionAt.Add(time.Second))
	if err != nil {
		t.Fatalf("running->succeeded transition failed: %v", err)
	}
	if job.State != StateSucceeded {
		t.Fatalf("job state = %q, want %q", job.State, StateSucceeded)
	}

	// succeeded -> terminal, no further transitions allowed
	err = job.transitionTo(StateRunning, transitionAt.Add(2*time.Second))
	if err == nil {
		t.Fatal("expected error transitioning from terminal state succeeded")
	}

	// Reset: queued -> running -> failed
	store.CreateJob("job-2", "test", nil)
	job2, _ := store.GetJob("job-2")
	err = job2.transitionTo(StateRunning, transitionAt)
	if err != nil {
		t.Fatalf("transition failed: %v", err)
	}
	err = job2.transitionTo(StateFailed, transitionAt.Add(time.Second))
	if err != nil {
		t.Fatalf("transition failed: %v", err)
	}
	if job2.State != StateFailed {
		t.Fatalf("job state = %q, want %q", job2.State, StateFailed)
	}

	// queued -> canceled
	store.CreateJob("job-3", "lint", nil)
	job3, _ := store.GetJob("job-3")
	err = job3.transitionTo(StateCanceled, transitionAt)
	if err != nil {
		t.Fatalf("queued->canceled transition failed: %v", err)
	}
	if job3.State != StateCanceled {
		t.Fatalf("job state = %q, want %q", job3.State, StateCanceled)
	}

	// queued -> failed (direct from queued)
	store.CreateJob("job-4", "deploy", nil)
	job4, _ := store.GetJob("job-4")
	err = job4.transitionTo(StateFailed, transitionAt)
	if err != nil {
		t.Fatalf("queued->failed transition failed: %v", err)
	}
	if job4.State != StateFailed {
		t.Fatalf("job state = %q, want %q", job4.State, StateFailed)
	}
}

func TestStoreExecutionStateTransitions(t *testing.T) {
	store := NewStore()
	store.CreateJob("job-1", "build", nil)
	store.CreateExecution("job-1", "exec-1")
	transitionAt := time.Date(2026, 6, 5, 13, 0, 0, 0, time.UTC)

	exec, _ := store.GetExecution("exec-1")
	if exec.State != StateQueued {
		t.Fatalf("initial exec state = %q, want %q", exec.State, StateQueued)
	}

	err := exec.transitionTo(StateRunning, transitionAt)
	if err != nil {
		t.Fatalf("queued->running failed: %v", err)
	}
	if exec.State != StateRunning {
		t.Fatalf("exec state = %q, want %q", exec.State, StateRunning)
	}
	if !exec.UpdatedAt.Equal(transitionAt) {
		t.Fatalf("exec UpdatedAt = %s, want %s", exec.UpdatedAt, transitionAt)
	}

	exec.transitionTo(StateSucceeded, transitionAt.Add(time.Second))
	if exec.State != StateSucceeded {
		t.Fatalf("exec state = %q, want %q", exec.State, StateSucceeded)
	}

	// Terminal state: no further transitions
	err = exec.transitionTo(StateRunning, transitionAt.Add(2*time.Second))
	if err == nil {
		t.Fatal("expected error from terminal exec state")
	}
}

func TestStorePersistedJobTransition(t *testing.T) {
	store := NewStore()
	mockTime := time.Date(2026, 6, 5, 12, 0, 0, 0, time.UTC)
	store.now = func() time.Time { return mockTime }

	store.CreateJob("job-1", "build", nil)

	// queued -> running -> succeeded
	mockTime = time.Date(2026, 6, 5, 12, 1, 0, 0, time.UTC)
	err := store.TransitionJob("job-1", StateRunning)
	if err != nil {
		t.Fatalf("TransitionJob queued->running failed: %v", err)
	}

	job, err := store.GetJob("job-1")
	if err != nil {
		t.Fatalf("GetJob after transition failed: %v", err)
	}
	if job.State != StateRunning {
		t.Fatalf("job state = %q, want %q", job.State, StateRunning)
	}
	if !job.UpdatedAt.Equal(mockTime) {
		t.Fatalf("job UpdatedAt = %s, want %s", job.UpdatedAt, mockTime)
	}

	jobFromSnapshot := store.SnapshotJobs()
	if len(jobFromSnapshot) != 1 || jobFromSnapshot[0].State != StateRunning {
		t.Fatalf("SnapshotJobs state = %q, want %q", jobFromSnapshot[0].State, StateRunning)
	}

	mockTime = time.Date(2026, 6, 5, 12, 2, 0, 0, time.UTC)
	err = store.TransitionJob("job-1", StateSucceeded)
	if err != nil {
		t.Fatalf("TransitionJob running->succeeded failed: %v", err)
	}

	job, err = store.GetJob("job-1")
	if err != nil {
		t.Fatalf("GetJob after succeeded failed: %v", err)
	}
	if job.State != StateSucceeded {
		t.Fatalf("job state = %q, want %q", job.State, StateSucceeded)
	}
	if !job.UpdatedAt.Equal(mockTime) {
		t.Fatalf("job UpdatedAt = %s, want %s", job.UpdatedAt, mockTime)
	}

	// Terminal state: no further transitions
	err = store.TransitionJob("job-1", StateRunning)
	if err == nil {
		t.Fatal("expected error transitioning from terminal state succeeded")
	}

	job, err = store.GetJob("job-1")
	if err != nil {
		t.Fatalf("GetJob after failed terminal transition: %v", err)
	}
	if job.State != StateSucceeded {
		t.Fatalf("state should still be %q after failed transition, got %q", StateSucceeded, job.State)
	}

	// Unknown job ID
	err = store.TransitionJob("unknown-job", StateRunning)
	if err == nil {
		t.Fatal("expected error for unknown job transition")
	}
}

func TestStorePersistedExecutionTransition(t *testing.T) {
	store := NewStore()
	mockTime := time.Date(2026, 6, 5, 12, 0, 0, 0, time.UTC)
	store.now = func() time.Time { return mockTime }

	store.CreateJob("job-1", "build", nil)
	store.CreateExecution("job-1", "exec-1")

	// queued -> running -> failed
	mockTime = time.Date(2026, 6, 5, 12, 1, 0, 0, time.UTC)
	err := store.TransitionExecution("exec-1", StateRunning)
	if err != nil {
		t.Fatalf("TransitionExecution queued->running failed: %v", err)
	}

	exec, err := store.GetExecution("exec-1")
	if err != nil {
		t.Fatalf("GetExecution after transition failed: %v", err)
	}
	if exec.State != StateRunning {
		t.Fatalf("exec state = %q, want %q", exec.State, StateRunning)
	}
	if !exec.UpdatedAt.Equal(mockTime) {
		t.Fatalf("exec UpdatedAt = %s, want %s", exec.UpdatedAt, mockTime)
	}

	execsFromSnapshot := store.SnapshotExecutions()
	if len(execsFromSnapshot) != 1 || execsFromSnapshot[0].State != StateRunning {
		t.Fatalf("SnapshotExecutions state = %q, want %q", execsFromSnapshot[0].State, StateRunning)
	}

	mockTime = time.Date(2026, 6, 5, 12, 2, 0, 0, time.UTC)
	err = store.TransitionExecution("exec-1", StateFailed)
	if err != nil {
		t.Fatalf("TransitionExecution running->failed failed: %v", err)
	}

	exec, err = store.GetExecution("exec-1")
	if err != nil {
		t.Fatalf("GetExecution after failed: %v", err)
	}
	if exec.State != StateFailed {
		t.Fatalf("exec state = %q, want %q", exec.State, StateFailed)
	}
	if !exec.UpdatedAt.Equal(mockTime) {
		t.Fatalf("exec UpdatedAt = %s, want %s", exec.UpdatedAt, mockTime)
	}

	// Terminal state: no further transitions
	err = store.TransitionExecution("exec-1", StateRunning)
	if err == nil {
		t.Fatal("expected error from terminal exec state")
	}

	exec, err = store.GetExecution("exec-1")
	if err != nil {
		t.Fatalf("GetExecution after failed terminal transition: %v", err)
	}
	if exec.State != StateFailed {
		t.Fatalf("state should still be %q after failed transition, got %q", StateFailed, exec.State)
	}

	// Unknown execution ID
	err = store.TransitionExecution("unknown-exec", StateRunning)
	if err == nil {
		t.Fatal("expected error for unknown execution transition")
	}

	// queued -> running -> succeeded
	mockTime = time.Date(2026, 6, 5, 12, 3, 0, 0, time.UTC)
	store.CreateJob("job-2", "deploy", nil)
	store.CreateExecution("job-2", "exec-2")
	mockTime = time.Date(2026, 6, 5, 12, 4, 0, 0, time.UTC)
	err = store.TransitionExecution("exec-2", StateRunning)
	if err != nil {
		t.Fatalf("TransitionExecution queued->running failed: %v", err)
	}
	mockTime = time.Date(2026, 6, 5, 12, 5, 0, 0, time.UTC)
	err = store.TransitionExecution("exec-2", StateSucceeded)
	if err != nil {
		t.Fatalf("TransitionExecution running->succeeded failed: %v", err)
	}
	exec, err = store.GetExecution("exec-2")
	if err != nil {
		t.Fatalf("GetExecution after succeeded: %v", err)
	}
	if exec.State != StateSucceeded {
		t.Fatalf("exec state = %q, want %q", exec.State, StateSucceeded)
	}
}

func TestStoreSnapshotReturnsCopies(t *testing.T) {
	store := NewStore()

	job1 := &Job{
		ID:    "job-1",
		Name:  "build",
		State: StateQueued,
	}
	job2 := &Job{
		ID:    "job-2",
		Name:  "test",
		State: StateRunning,
	}

	// Directly populate store maps for snapshot testing
	store.jobs = map[string]*Job{
		"job-1": job1,
		"job-2": job2,
	}
	store.executions = map[string]*Execution{
		"exec-1": {
			ID:        "exec-1",
			JobID:     "job-1",
			State:     StateRunning,
			Logs:      []LogEntry{{Line: "hello"}},
			Artifacts: []ArtifactEntry{{Name: "a.out"}},
		},
	}

	jobs := store.SnapshotJobs()
	if len(jobs) != 2 {
		t.Fatalf("SnapshotJobs returned %d jobs, want 2", len(jobs))
	}

	jobs[0].State = StateFailed
	jobsRaw := store.SnapshotJobs()
	if jobsRaw[0].State == StateFailed {
		t.Fatal("SnapshotJobs returned mutable reference")
	}

	execs := store.SnapshotExecutions()
	if len(execs) != 1 {
		t.Fatalf("SnapshotExecutions returned %d executions, want 1", len(execs))
	}

	execs[0].Logs[0].Line = "modified"
	execsRaw := store.SnapshotExecutions()
	if execsRaw[0].Logs[0].Line == "modified" {
		t.Fatal("SnapshotExecutions returned mutable reference")
	}
}

func TestStoreCancelJobExecution(t *testing.T) {
	store := NewStore()
	mockTime := time.Date(2026, 6, 5, 12, 0, 0, 0, time.UTC)
	store.now = func() time.Time { return mockTime }

	// 1. Setup queued job and try to cancel without execution (should fail mismatch because exec doesn't exist yet)
	store.CreateJob("job-queued", "build", nil)
	err := store.CancelJobExecution("job-queued", "exec-nonexistent")
	if err == nil {
		t.Fatal("expected error cancelling nonexistent execution")
	}

	// 2. Setup running job/exec and cancel it
	store.CreateJob("job-running", "test", nil)
	store.CreateExecution("job-running", "exec-running")
	store.TransitionJob("job-running", StateRunning)
	store.TransitionExecution("exec-running", StateRunning)

	mockTime = time.Date(2026, 6, 5, 12, 1, 0, 0, time.UTC)
	err = store.CancelJobExecution("job-running", "exec-running")
	if err != nil {
		t.Fatalf("CancelJobExecution failed: %v", err)
	}

	j, _ := store.GetJob("job-running")
	if j.State != StateCanceled {
		t.Fatalf("job state = %q, want %q", j.State, StateCanceled)
	}
	if !j.UpdatedAt.Equal(mockTime) {
		t.Fatalf("job UpdatedAt = %s, want %s", j.UpdatedAt, mockTime)
	}

	e, _ := store.GetExecution("exec-running")
	if e.State != StateCanceled {
		t.Fatalf("execution state = %q, want %q", e.State, StateCanceled)
	}
	if !e.UpdatedAt.Equal(mockTime) {
		t.Fatalf("execution UpdatedAt = %s, want %s", e.UpdatedAt, mockTime)
	}

	// 3. Setup terminal job/exec and cancel (should fail)
	store.CreateJob("job-succeeded", "deploy", nil)
	store.CreateExecution("job-succeeded", "exec-succeeded")
	store.TransitionJob("job-succeeded", StateRunning)
	store.TransitionExecution("exec-succeeded", StateRunning)
	store.TransitionJob("job-succeeded", StateSucceeded)
	store.TransitionExecution("exec-succeeded", StateSucceeded)

	err = store.CancelJobExecution("job-succeeded", "exec-succeeded")
	if err == nil {
		t.Fatal("expected error cancelling succeeded (terminal) job/execution")
	}
}
