package webui

import (
	"context"
	"encoding/json"
	"net/http"
	"net/http/httptest"
	"os"
	"os/exec"
	"path/filepath"
	"strings"
	"testing"
	"time"

	"bee/audit/internal/app"
	"bee/audit/internal/platform"
)

func TestTaskQueuePersistsAndRecoversPendingTasks(t *testing.T) {
	dir := t.TempDir()
	q := &taskQueue{
		statePath: filepath.Join(dir, "tasks-state.json"),
		logsDir:   filepath.Join(dir, "tasks"),
		trigger:   make(chan struct{}, 1),
	}
	if err := os.MkdirAll(q.logsDir, 0755); err != nil {
		t.Fatal(err)
	}

	started := time.Now().Add(-time.Minute)

	// A task that was pending (not yet started) must be re-queued on restart.
	pendingTask := &Task{
		ID:        "task-pending",
		Name:      "Memory Burn-in",
		Target:    "memory-stress",
		Priority:  2,
		Status:    TaskPending,
		CreatedAt: time.Now().Add(-2 * time.Minute),
		params:    taskParams{Duration: 300, BurnProfile: "smoke"},
	}
	// A task that was running when bee-web crashed must NOT be re-queued —
	// its child processes (e.g. gpu-burn-worker) survive the restart in
	// their own process groups and can't be cancelled retroactively.
	runningTask := &Task{
		ID:        "task-running",
		Name:      "NVIDIA GPU Stress",
		Target:    "nvidia-stress",
		Priority:  1,
		Status:    TaskRunning,
		CreatedAt: time.Now().Add(-3 * time.Minute),
		StartedAt: &started,
		params:    taskParams{Duration: 86400},
	}
	for _, task := range []*Task{pendingTask, runningTask} {
		q.tasks = append(q.tasks, task)
		q.assignTaskLogPathLocked(task)
	}
	q.persistLocked()

	recovered := &taskQueue{
		statePath: q.statePath,
		logsDir:   q.logsDir,
		trigger:   make(chan struct{}, 1),
	}
	recovered.loadLocked()

	if len(recovered.tasks) != 2 {
		t.Fatalf("tasks=%d want 2", len(recovered.tasks))
	}

	byID := map[string]*Task{}
	for i := range recovered.tasks {
		byID[recovered.tasks[i].ID] = recovered.tasks[i]
	}

	// Pending task must be re-queued as pending with params intact.
	p := byID["task-pending"]
	if p == nil {
		t.Fatal("task-pending not found")
	}
	if p.Status != TaskPending {
		t.Fatalf("pending task: status=%q want %q", p.Status, TaskPending)
	}
	if p.StartedAt != nil {
		t.Fatalf("pending task: started_at=%v want nil", p.StartedAt)
	}
	if p.params.Duration != 300 || p.params.BurnProfile != "smoke" {
		t.Fatalf("pending task: params=%+v", p.params)
	}
	if p.LogPath == "" {
		t.Fatal("pending task: expected log path")
	}

	// Running task must be marked failed, not re-queued, to prevent
	// launching duplicate workers (e.g. a second set of gpu-burn-workers).
	r := byID["task-running"]
	if r == nil {
		t.Fatal("task-running not found")
	}
	if r.Status != TaskFailed {
		t.Fatalf("running task: status=%q want %q", r.Status, TaskFailed)
	}
	if r.ErrMsg == "" {
		t.Fatal("running task: expected non-empty error message")
	}
	if r.DoneAt == nil {
		t.Fatal("running task: expected done_at to be set")
	}
}

func TestNewTaskJobStateLoadsExistingLog(t *testing.T) {
	dir := t.TempDir()
	path := filepath.Join(dir, "task.log")
	if err := os.WriteFile(path, []byte("line1\nline2\n"), 0644); err != nil {
		t.Fatal(err)
	}

	j := newTaskJobState(path)
	existing, ch := j.subscribe()
	if ch == nil {
		t.Fatal("expected live subscription channel")
	}
	if len(existing) != 2 || existing[0] != "line1" || existing[1] != "line2" {
		t.Fatalf("existing=%v", existing)
	}
}

func TestTaskQueueSnapshotSortsNewestFirst(t *testing.T) {
	now := time.Date(2026, 4, 2, 12, 0, 0, 0, time.UTC)
	q := &taskQueue{
		tasks: []*Task{
			{
				ID:        "old-running",
				Name:      "Old Running",
				Status:    TaskRunning,
				Priority:  10,
				CreatedAt: now.Add(-3 * time.Minute),
			},
			{
				ID:        "new-done",
				Name:      "New Done",
				Status:    TaskDone,
				Priority:  0,
				CreatedAt: now.Add(-1 * time.Minute),
			},
			{
				ID:        "mid-pending",
				Name:      "Mid Pending",
				Status:    TaskPending,
				Priority:  1,
				CreatedAt: now.Add(-2 * time.Minute),
			},
		},
	}

	got := q.snapshot()
	if len(got) != 3 {
		t.Fatalf("snapshot len=%d want 3", len(got))
	}
	if got[0].ID != "new-done" || got[1].ID != "mid-pending" || got[2].ID != "old-running" {
		t.Fatalf("snapshot order=%q,%q,%q", got[0].ID, got[1].ID, got[2].ID)
	}
}

func TestNewJobIDUsesTASKPrefixAndZeroPadding(t *testing.T) {
	globalQueue.mu.Lock()
	origTasks := globalQueue.tasks
	globalQueue.tasks = nil
	globalQueue.mu.Unlock()
	origCounter := jobCounter.Load()
	jobCounter.Store(0)
	t.Cleanup(func() {
		globalQueue.mu.Lock()
		globalQueue.tasks = origTasks
		globalQueue.mu.Unlock()
		jobCounter.Store(origCounter)
	})

	if got := newJobID("ignored"); got != "TASK-000" {
		t.Fatalf("id=%q want TASK-000", got)
	}
	if got := newJobID("ignored"); got != "TASK-001" {
		t.Fatalf("id=%q want TASK-001", got)
	}
}

func TestTaskArtifactsDirStartsWithTaskNumber(t *testing.T) {
	root := t.TempDir()
	task := &Task{
		ID:   "TASK-007",
		Name: "NVIDIA Benchmark",
	}
	got := filepath.Base(taskArtifactsDir(root, task, TaskDone))
	if !strings.HasPrefix(got, "007_") {
		t.Fatalf("artifacts dir=%q want prefix 007_", got)
	}
}

func TestHandleAPITasksStreamReplaysPersistedLogWithoutLiveJob(t *testing.T) {
	dir := t.TempDir()
	logPath := filepath.Join(dir, "task.log")
	if err := os.WriteFile(logPath, []byte("line1\nline2\n"), 0644); err != nil {
		t.Fatal(err)
	}

	globalQueue.mu.Lock()
	origTasks := globalQueue.tasks
	globalQueue.tasks = []*Task{{
		ID:        "done-1",
		Name:      "Done Task",
		Status:    TaskDone,
		CreatedAt: time.Now(),
		LogPath:   logPath,
	}}
	globalQueue.mu.Unlock()
	t.Cleanup(func() {
		globalQueue.mu.Lock()
		globalQueue.tasks = origTasks
		globalQueue.mu.Unlock()
	})

	req := httptest.NewRequest(http.MethodGet, "/api/tasks/done-1/stream", nil)
	req.SetPathValue("id", "done-1")
	rec := httptest.NewRecorder()

	h := &handler{}
	h.handleAPITasksStream(rec, req)

	if rec.Code != http.StatusOK {
		t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
	}
	body := rec.Body.String()
	if !strings.Contains(body, "data: line1\n\n") || !strings.Contains(body, "data: line2\n\n") {
		t.Fatalf("body=%q", body)
	}
	if !strings.Contains(body, "event: done\n") {
		t.Fatalf("missing done event: %q", body)
	}
}

func TestHandleAPITasksStreamPendingTaskStartsSSEImmediately(t *testing.T) {
	globalQueue.mu.Lock()
	origTasks := globalQueue.tasks
	globalQueue.tasks = []*Task{{
		ID:        "pending-1",
		Name:      "Pending Task",
		Status:    TaskPending,
		CreatedAt: time.Now(),
	}}
	globalQueue.mu.Unlock()
	t.Cleanup(func() {
		globalQueue.mu.Lock()
		globalQueue.tasks = origTasks
		globalQueue.mu.Unlock()
	})

	ctx, cancel := context.WithCancel(context.Background())
	req := httptest.NewRequest(http.MethodGet, "/api/tasks/pending-1/stream", nil).WithContext(ctx)
	req.SetPathValue("id", "pending-1")
	rec := httptest.NewRecorder()

	done := make(chan struct{})
	go func() {
		h := &handler{}
		h.handleAPITasksStream(rec, req)
		close(done)
	}()

	deadline := time.Now().Add(2 * time.Second)
	for time.Now().Before(deadline) {
		if strings.Contains(rec.Body.String(), "Task is queued. Waiting for worker...") {
			cancel()
			<-done
			if rec.Code != http.StatusOK {
				t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
			}
			return
		}
		time.Sleep(20 * time.Millisecond)
	}
	cancel()
	<-done
	t.Fatalf("stream did not emit queued status promptly, body=%q", rec.Body.String())
}

func TestFinalizeTaskRunCreatesReportFolderAndArtifacts(t *testing.T) {
	dir := t.TempDir()
	metricsPath := filepath.Join(dir, "metrics.db")
	prevMetricsPath := taskReportMetricsDBPath
	taskReportMetricsDBPath = metricsPath
	t.Cleanup(func() { taskReportMetricsDBPath = prevMetricsPath })

	db, err := openMetricsDB(metricsPath)
	if err != nil {
		t.Fatalf("openMetricsDB: %v", err)
	}
	base := time.Now().UTC().Add(-45 * time.Second)
	if err := db.Write(platform.LiveMetricSample{
		Timestamp:  base,
		CPULoadPct: 42,
		MemLoadPct: 35,
		PowerW:     510,
	}); err != nil {
		t.Fatalf("Write: %v", err)
	}
	_ = db.Close()

	q := &taskQueue{
		statePath: filepath.Join(dir, "tasks-state.json"),
		logsDir:   filepath.Join(dir, "tasks"),
		trigger:   make(chan struct{}, 1),
	}
	if err := os.MkdirAll(q.logsDir, 0755); err != nil {
		t.Fatal(err)
	}

	started := time.Now().UTC().Add(-90 * time.Second)
	task := &Task{
		ID:        "task-1",
		Name:      "CPU SAT",
		Target:    "cpu",
		Status:    TaskRunning,
		CreatedAt: started.Add(-10 * time.Second),
		StartedAt: &started,
	}
	q.assignTaskLogPathLocked(task)
	appendJobLog(task.LogPath, "line-1")

	job := newTaskJobState(task.LogPath)
	job.finish("")
	q.finalizeTaskRun(task, job)

	if task.Status != TaskDone {
		t.Fatalf("status=%q want %q", task.Status, TaskDone)
	}
	if !strings.Contains(filepath.Base(task.ArtifactsDir), "_done") {
		t.Fatalf("artifacts dir=%q", task.ArtifactsDir)
	}
	if _, err := os.Stat(task.ReportJSONPath); err != nil {
		t.Fatalf("report json: %v", err)
	}
	if _, err := os.Stat(task.ReportHTMLPath); err != nil {
		t.Fatalf("report html: %v", err)
	}
	var report taskReport
	data, err := os.ReadFile(task.ReportJSONPath)
	if err != nil {
		t.Fatalf("ReadFile(report.json): %v", err)
	}
	if err := json.Unmarshal(data, &report); err != nil {
		t.Fatalf("Unmarshal(report.json): %v", err)
	}
	if report.ID != task.ID || report.Status != TaskDone {
		t.Fatalf("report=%+v", report)
	}
	if len(report.Charts) == 0 {
		t.Fatalf("expected charts in report, got none")
	}
}

func TestWriteTaskReportArtifactsIncludesBenchmarkResultsForTask(t *testing.T) {
	dir := t.TempDir()
	metricsPath := filepath.Join(dir, "metrics.db")
	prevMetricsPath := taskReportMetricsDBPath
	taskReportMetricsDBPath = metricsPath
	t.Cleanup(func() { taskReportMetricsDBPath = prevMetricsPath })

	benchmarkDir := filepath.Join(dir, "bee-bench", "perf", "perf-20260406-120000")
	if err := os.MkdirAll(benchmarkDir, 0755); err != nil {
		t.Fatal(err)
	}
	result := platform.NvidiaBenchmarkResult{
		GeneratedAt:      time.Date(2026, time.April, 6, 12, 0, 0, 0, time.UTC),
		BenchmarkProfile: "standard",
		OverallStatus:    "OK",
		GPUs: []platform.BenchmarkGPUResult{
			{
				Index: 0,
				Name:  "NVIDIA H100 PCIe",
				Scores: platform.BenchmarkScorecard{
					CompositeScore: 1176.25,
				},
			},
		},
	}
	raw, err := json.Marshal(result)
	if err != nil {
		t.Fatal(err)
	}
	if err := os.WriteFile(filepath.Join(benchmarkDir, "result.json"), raw, 0644); err != nil {
		t.Fatal(err)
	}

	artifactsDir := filepath.Join(dir, "tasks", "task-bench_done")
	if err := os.MkdirAll(artifactsDir, 0755); err != nil {
		t.Fatal(err)
	}
	task := &Task{
		ID:           "task-bench",
		Name:         "NVIDIA Bee Bench Perf",
		Target:       "nvidia-bench-perf",
		Status:       TaskDone,
		CreatedAt:    time.Now().UTC().Add(-time.Minute),
		ArtifactsDir: artifactsDir,
	}
	ensureTaskReportPaths(task)
	logText := "line-1\nArchive: " + filepath.Join(dir, "bee-bench", "perf", "perf-20260406-120000.tar.gz") + "\n"
	if err := os.WriteFile(task.LogPath, []byte(logText), 0644); err != nil {
		t.Fatal(err)
	}

	if err := writeTaskReportArtifacts(task); err != nil {
		t.Fatalf("writeTaskReportArtifacts: %v", err)
	}

	body, err := os.ReadFile(task.ReportHTMLPath)
	if err != nil {
		t.Fatalf("ReadFile(report.html): %v", err)
	}
	html := string(body)
	for _, needle := range []string{
		`Perf Results`,
		`Composite score for this benchmark task.`,
		`GPU 0`,
		`1176.25`,
	} {
		if !strings.Contains(html, needle) {
			t.Fatalf("report missing %q: %s", needle, html)
		}
	}
}

func TestTaskLifecycleMirrorsToSerialConsole(t *testing.T) {
	var lines []string
	prev := taskSerialWriteLine
	taskSerialWriteLine = func(line string) { lines = append(lines, line) }
	t.Cleanup(func() { taskSerialWriteLine = prev })

	dir := t.TempDir()
	q := &taskQueue{
		statePath: filepath.Join(dir, "tasks-state.json"),
		logsDir:   filepath.Join(dir, "tasks"),
		trigger:   make(chan struct{}, 1),
	}
	task := &Task{
		ID:        "task-serial-1",
		Name:      "CPU SAT",
		Target:    "cpu",
		Status:    TaskPending,
		CreatedAt: time.Now().UTC(),
	}

	q.enqueue(task)
	started := time.Now().UTC()
	task.Status = TaskRunning
	task.StartedAt = &started
	job := newTaskJobState(task.LogPath, taskSerialPrefix(task))
	job.append("Starting CPU SAT...")
	job.append("CPU stress duration: 60s")
	job.finish("")
	q.finalizeTaskRun(task, job)

	joined := strings.Join(lines, "\n")
	for _, needle := range []string{
		"queued",
		"Starting CPU SAT...",
		"CPU stress duration: 60s",
		"finished with status=done",
	} {
		if !strings.Contains(joined, needle) {
			t.Fatalf("serial mirror missing %q in %q", needle, joined)
		}
	}
}

func TestResolveBurnPreset(t *testing.T) {
	tests := []struct {
		profile string
		want    burnPreset
	}{
		{profile: "smoke", want: burnPreset{DurationSec: 5 * 60}},
		{profile: "acceptance", want: burnPreset{DurationSec: 60 * 60}},
		{profile: "overnight", want: burnPreset{DurationSec: 8 * 60 * 60}},
		{profile: "", want: burnPreset{DurationSec: 5 * 60}},
	}
	for _, tc := range tests {
		if got := resolveBurnPreset(tc.profile); got != tc.want {
			t.Fatalf("resolveBurnPreset(%q)=%+v want %+v", tc.profile, got, tc.want)
		}
	}
}

func TestResolveNvidiaRampPlan(t *testing.T) {
	tests := []struct {
		name     string
		profile  string
		enabled  bool
		selected []int
		want     nvidiaRampSpec
		wantErr  string
	}{
		{
			name:     "disabled uses base preset",
			profile:  "acceptance",
			selected: []int{0, 1},
			want:     nvidiaRampSpec{DurationSec: 60 * 60, TotalDurationSec: 60 * 60},
		},
		{
			name:     "smoke ramp uses two minute steps",
			profile:  "smoke",
			enabled:  true,
			selected: []int{0, 1, 2},
			want:     nvidiaRampSpec{DurationSec: 5 * 60, StaggerSeconds: 2 * 60, TotalDurationSec: 9 * 60},
		},
		{
			name:     "acceptance ramp uses ten minute steps",
			profile:  "acceptance",
			enabled:  true,
			selected: []int{0, 1, 2},
			want:     nvidiaRampSpec{DurationSec: 60 * 60, StaggerSeconds: 10 * 60, TotalDurationSec: 80 * 60},
		},
		{
			name:     "overnight stays at eight hours when possible",
			profile:  "overnight",
			enabled:  true,
			selected: []int{0, 1, 2},
			want:     nvidiaRampSpec{DurationSec: 6 * 60 * 60, StaggerSeconds: 60 * 60, TotalDurationSec: 8 * 60 * 60},
		},
		{
			name:     "overnight extends to keep one hour after final gpu",
			profile:  "overnight",
			enabled:  true,
			selected: []int{0, 1, 2, 3, 4, 5, 6, 7, 8},
			want:     nvidiaRampSpec{DurationSec: 60 * 60, StaggerSeconds: 60 * 60, TotalDurationSec: 9 * 60 * 60},
		},
		{
			name:     "overnight rejects impossible gpu count",
			profile:  "overnight",
			enabled:  true,
			selected: []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10},
			wantErr:  "at most 10 GPUs",
		},
		{
			name:    "enabled requires explicit selection",
			profile: "smoke",
			enabled: true,
			wantErr: "requires explicit GPU selection",
		},
	}

	for _, tc := range tests {
		t.Run(tc.name, func(t *testing.T) {
			got, err := resolveNvidiaRampPlan(tc.profile, tc.enabled, tc.selected)
			if tc.wantErr != "" {
				if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
					t.Fatalf("err=%v want substring %q", err, tc.wantErr)
				}
				return
			}
			if err != nil {
				t.Fatalf("resolveNvidiaRampPlan error: %v", err)
			}
			if got != tc.want {
				t.Fatalf("resolveNvidiaRampPlan(%q, %t, %v)=%+v want %+v", tc.profile, tc.enabled, tc.selected, got, tc.want)
			}
		})
	}
}

func TestTaskDisplayNameUsesNvidiaStressLoader(t *testing.T) {
	tests := []struct {
		loader string
		want   string
	}{
		{loader: "", want: "NVIDIA GPU Stress (bee-gpu-burn)"},
		{loader: "builtin", want: "NVIDIA GPU Stress (bee-gpu-burn)"},
		{loader: "john", want: "NVIDIA GPU Stress (John/OpenCL)"},
		{loader: "nccl", want: "NVIDIA GPU Stress (NCCL)"},
	}
	for _, tc := range tests {
		if got := taskDisplayName("nvidia-stress", "acceptance", tc.loader); got != tc.want {
			t.Fatalf("taskDisplayName(loader=%q)=%q want %q", tc.loader, got, tc.want)
		}
	}
}

func TestRunTaskHonorsCancel(t *testing.T) {
	blocked := make(chan struct{})
	released := make(chan struct{})
	aRun := func(_ any, ctx context.Context, _ string, _ int, _ func(string)) (string, error) {
		close(blocked)
		select {
		case <-ctx.Done():
			close(released)
			return "", ctx.Err()
		case <-time.After(5 * time.Second):
			close(released)
			return "unexpected", nil
		}
	}

	q := &taskQueue{
		opts: &HandlerOptions{App: &app.App{}},
	}
	tk := &Task{
		ID:        "cpu-1",
		Name:      "CPU SAT",
		Target:    "cpu",
		Status:    TaskRunning,
		CreatedAt: time.Now(),
		params:    taskParams{Duration: 60},
	}
	j := &jobState{}
	ctx, cancel := context.WithCancel(context.Background())
	j.cancel = cancel
	tk.job = j

	orig := runCPUAcceptancePackCtx
	runCPUAcceptancePackCtx = func(_ *app.App, ctx context.Context, baseDir string, durationSec int, logFunc func(string)) (string, error) {
		return aRun(nil, ctx, baseDir, durationSec, logFunc)
	}
	defer func() { runCPUAcceptancePackCtx = orig }()

	done := make(chan struct{})
	go func() {
		q.runTask(tk, j, ctx)
		close(done)
	}()

	<-blocked
	j.abort()

	select {
	case <-released:
	case <-time.After(2 * time.Second):
		t.Fatal("task did not observe cancel")
	}
	select {
	case <-done:
	case <-time.After(2 * time.Second):
		t.Fatal("runTask did not return after cancel")
	}
}

func TestRunTaskUsesBurnProfileDurationForCPU(t *testing.T) {
	var gotDuration int
	q := &taskQueue{
		opts: &HandlerOptions{App: &app.App{}},
	}
	tk := &Task{
		ID:        "cpu-burn-1",
		Name:      "CPU Burn-in",
		Target:    "cpu",
		Status:    TaskRunning,
		CreatedAt: time.Now(),
		params:    taskParams{BurnProfile: "smoke"},
	}
	j := &jobState{}

	orig := runCPUAcceptancePackCtx
	runCPUAcceptancePackCtx = func(_ *app.App, _ context.Context, _ string, durationSec int, _ func(string)) (string, error) {
		gotDuration = durationSec
		return "/tmp/cpu-burn.tar.gz", nil
	}
	defer func() { runCPUAcceptancePackCtx = orig }()

	q.runTask(tk, j, context.Background())

	if gotDuration != 5*60 {
		t.Fatalf("duration=%d want %d", gotDuration, 5*60)
	}
}

func TestRunTaskBuildsSupportBundleWithoutApp(t *testing.T) {
	dir := t.TempDir()
	q := &taskQueue{
		opts: &HandlerOptions{ExportDir: dir},
	}
	tk := &Task{
		ID:        "support-bundle-1",
		Name:      "Support Bundle",
		Target:    "support-bundle",
		Status:    TaskRunning,
		CreatedAt: time.Now(),
	}
	j := &jobState{}

	var gotExportDir string
	orig := buildSupportBundle
	buildSupportBundle = func(exportDir string) (string, error) {
		gotExportDir = exportDir
		return filepath.Join(exportDir, "bundle.tar.gz"), nil
	}
	defer func() { buildSupportBundle = orig }()

	q.runTask(tk, j, context.Background())

	if gotExportDir != dir {
		t.Fatalf("exportDir=%q want %q", gotExportDir, dir)
	}
	if j.err != "" {
		t.Fatalf("unexpected error: %q", j.err)
	}
	if !strings.Contains(strings.Join(j.lines, "\n"), "Archive: "+filepath.Join(dir, "bundle.tar.gz")) {
		t.Fatalf("lines=%v", j.lines)
	}
}

func TestTaskElapsedSecClampsInvalidStartedAt(t *testing.T) {
	now := time.Date(2026, 4, 1, 19, 10, 0, 0, time.UTC)
	created := time.Date(2026, 4, 1, 19, 4, 5, 0, time.UTC)
	started := time.Time{}
	task := &Task{
		Status:    TaskRunning,
		CreatedAt: created,
		StartedAt: &started,
	}
	if got := taskElapsedSec(task, now); got != 0 {
		t.Fatalf("taskElapsedSec(zero start)=%d want 0", got)
	}

	stale := created.Add(-24 * time.Hour)
	task.StartedAt = &stale
	if got := taskElapsedSec(task, now); got != int(now.Sub(created).Seconds()) {
		t.Fatalf("taskElapsedSec(stale start)=%d want %d", got, int(now.Sub(created).Seconds()))
	}
}

func TestRunTaskInstallUsesSharedCommandStreaming(t *testing.T) {
	q := &taskQueue{
		opts: &HandlerOptions{},
	}
	tk := &Task{
		ID:        "install-1",
		Name:      "Install to Disk",
		Target:    "install",
		Status:    TaskRunning,
		CreatedAt: time.Now(),
		params:    taskParams{Device: "/dev/sda"},
	}
	j := &jobState{}

	var gotDevice string
	var gotLogPath string
	orig := installCommand
	installCommand = func(ctx context.Context, device string, logPath string) *exec.Cmd {
		gotDevice = device
		gotLogPath = logPath
		return exec.CommandContext(ctx, "sh", "-c", "printf 'line1\nline2\n'")
	}
	defer func() { installCommand = orig }()

	q.runTask(tk, j, context.Background())

	if gotDevice != "/dev/sda" {
		t.Fatalf("device=%q want /dev/sda", gotDevice)
	}
	if gotLogPath == "" {
		t.Fatal("expected install log path")
	}
	logs := strings.Join(j.lines, "\n")
	if !strings.Contains(logs, "Install log: ") {
		t.Fatalf("missing install log line: %v", j.lines)
	}
	if !strings.Contains(logs, "line1") || !strings.Contains(logs, "line2") {
		t.Fatalf("missing streamed output: %v", j.lines)
	}
	if j.err != "" {
		t.Fatalf("unexpected error: %q", j.err)
	}
}

func TestExecuteTaskMarksPanicsAsFailedAndClosesKmsgWindow(t *testing.T) {
	dir := t.TempDir()
	q := &taskQueue{
		opts:        &HandlerOptions{App: &app.App{}},
		statePath:   filepath.Join(dir, "tasks-state.json"),
		logsDir:     filepath.Join(dir, "tasks"),
		kmsgWatcher: newKmsgWatcher(nil),
	}
	tk := &Task{
		ID:        "cpu-panic-1",
		Name:      "CPU SAT",
		Target:    "cpu",
		Status:    TaskRunning,
		CreatedAt: time.Now(),
	}
	j := &jobState{}

	orig := runCPUAcceptancePackCtx
	runCPUAcceptancePackCtx = func(_ *app.App, _ context.Context, _ string, _ int, _ func(string)) (string, error) {
		panic("boom")
	}
	defer func() { runCPUAcceptancePackCtx = orig }()

	q.executeTask(tk, j, context.Background())

	if tk.Status != TaskFailed {
		t.Fatalf("status=%q want %q", tk.Status, TaskFailed)
	}
	if tk.DoneAt == nil {
		t.Fatal("expected done_at to be set")
	}
	if !strings.Contains(tk.ErrMsg, "task panic: boom") {
		t.Fatalf("task error=%q", tk.ErrMsg)
	}
	if !strings.Contains(j.err, "task panic: boom") {
		t.Fatalf("job error=%q", j.err)
	}
	q.kmsgWatcher.mu.Lock()
	activeCount := q.kmsgWatcher.activeCount
	window := q.kmsgWatcher.window
	q.kmsgWatcher.mu.Unlock()
	if activeCount != 0 {
		t.Fatalf("activeCount=%d want 0", activeCount)
	}
	if window != nil {
		t.Fatalf("expected kmsg window to be cleared, got %+v", window)
	}
}
