// cancel_test.go — covers POST /api/jobs/{id}/cancel.
package http

import (
	"context"
	"encoding/json"
	stdhttp "net/http"
	"strings"
	"testing"
	"time"

	"github.com/coder/websocket"
	"github.com/oklog/ulid/v2"

	"gitea.dcglab.co.uk/steve/restic-manager/internal/api"
	"gitea.dcglab.co.uk/steve/restic-manager/internal/store"
)

// TestCancelJobRunningHappyPath: a running job's cancel endpoint sends
// a command.cancel envelope with the right job id, returns 202, and
// writes a job.cancel audit row.
func TestCancelJobRunningHappyPath(t *testing.T) {
	t.Parallel()
	srv, ts, st := rawTestServer(t)
	hostID, token := enrolHostForWS(t, srv, st, "cancel-host")
	c := agentDial(t, srv, ts, hostID, token)
	sendHello(t, c, "cancel-host")
	_ = drainUntil(t, c, api.MsgScheduleSet)

	// Seed a running job we can target.
	jobID := ulid.Make().String()
	now := time.Now().UTC()
	if err := st.CreateJob(context.Background(), store.Job{
		ID: jobID, HostID: hostID, Kind: "backup",
		ActorKind: "user", CreatedAt: now,
	}); err != nil {
		t.Fatalf("create job: %v", err)
	}
	if err := st.MarkJobStarted(context.Background(), jobID, now); err != nil {
		t.Fatalf("mark started: %v", err)
	}

	cookie := loginAsAdmin(t, st)
	req, _ := stdhttp.NewRequest("POST",
		ts.URL+"/api/jobs/"+jobID+"/cancel", nil)
	req.AddCookie(cookie)
	res, err := stdhttp.DefaultClient.Do(req)
	if err != nil {
		t.Fatalf("do: %v", err)
	}
	defer res.Body.Close()
	if res.StatusCode != stdhttp.StatusAccepted {
		t.Fatalf("status: got %d, want 202", res.StatusCode)
	}

	// Read the dispatched command.cancel envelope.
	deadline := time.Now().Add(2 * time.Second)
	var got api.Envelope
	for time.Now().Before(deadline) {
		ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
		mt, raw, rerr := c.Read(ctx)
		cancel()
		if rerr != nil {
			break
		}
		if mt != websocket.MessageText {
			continue
		}
		if !strings.Contains(string(raw), `"command.cancel"`) {
			continue
		}
		if err := json.Unmarshal(raw, &got); err != nil {
			t.Fatalf("unmarshal: %v", err)
		}
		break
	}
	if got.Type != api.MsgCommandCancel {
		t.Fatalf("never received command.cancel envelope")
	}
	var cp api.CommandCancelPayload
	if err := got.UnmarshalPayload(&cp); err != nil {
		t.Fatalf("unmarshal payload: %v", err)
	}
	if cp.JobID != jobID {
		t.Fatalf("payload job_id: got %q want %q", cp.JobID, jobID)
	}

	// Audit row exists.
	var n int
	if err := st.DB().QueryRow(
		`SELECT COUNT(*) FROM audit_log WHERE action = 'job.cancel' AND target_id = ?`,
		jobID).Scan(&n); err != nil {
		t.Fatalf("audit count: %v", err)
	}
	if n != 1 {
		t.Fatalf("audit rows: got %d, want 1", n)
	}
}

// TestCancelJobAlreadyTerminal: a job in succeeded/failed/canceled
// state returns 409 and does NOT send a WS envelope.
func TestCancelJobAlreadyTerminal(t *testing.T) {
	t.Parallel()
	srv, ts, st := rawTestServer(t)
	hostID, token := enrolHostForWS(t, srv, st, "term-host")
	c := agentDial(t, srv, ts, hostID, token)
	sendHello(t, c, "term-host")
	_ = drainUntil(t, c, api.MsgScheduleSet)

	jobID := ulid.Make().String()
	now := time.Now().UTC()
	if err := st.CreateJob(context.Background(), store.Job{
		ID: jobID, HostID: hostID, Kind: "backup",
		ActorKind: "user", CreatedAt: now,
	}); err != nil {
		t.Fatalf("create job: %v", err)
	}
	if err := st.MarkJobFinished(context.Background(), jobID, "succeeded", 0, nil, "", now); err != nil {
		t.Fatalf("mark finished: %v", err)
	}

	cookie := loginAsAdmin(t, st)
	req, _ := stdhttp.NewRequest("POST",
		ts.URL+"/api/jobs/"+jobID+"/cancel", nil)
	req.AddCookie(cookie)
	res, err := stdhttp.DefaultClient.Do(req)
	if err != nil {
		t.Fatalf("do: %v", err)
	}
	defer res.Body.Close()
	if res.StatusCode != stdhttp.StatusConflict {
		t.Fatalf("status: got %d, want 409", res.StatusCode)
	}

	// Drain — no command.cancel should arrive.
	ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond)
	defer cancel()
	for {
		mt, raw, rerr := c.Read(ctx)
		if rerr != nil {
			break
		}
		if mt == websocket.MessageText && strings.Contains(string(raw), `"command.cancel"`) {
			t.Fatalf("unexpected command.cancel envelope for terminal job")
		}
	}
}

// TestCancelJobNotFound: 404 for a job id that doesn't exist.
func TestCancelJobNotFound(t *testing.T) {
	t.Parallel()
	_, ts, st := rawTestServer(t)
	cookie := loginAsAdmin(t, st)
	req, _ := stdhttp.NewRequest("POST",
		ts.URL+"/api/jobs/"+ulid.Make().String()+"/cancel", nil)
	req.AddCookie(cookie)
	res, err := stdhttp.DefaultClient.Do(req)
	if err != nil {
		t.Fatalf("do: %v", err)
	}
	defer res.Body.Close()
	if res.StatusCode != stdhttp.StatusNotFound {
		t.Fatalf("status: got %d, want 404", res.StatusCode)
	}
}

// TestCancelJobHostOffline: a queued/running job whose host has no
// active WS connection returns 503.
func TestCancelJobHostOffline(t *testing.T) {
	t.Parallel()
	_, ts, st := rawTestServer(t)
	// Create a host but don't connect a WS for it.
	hostID := ulid.Make().String()
	if err := st.CreateHost(context.Background(), store.Host{
		ID: hostID, Name: "offline-host", OS: "linux", Arch: "amd64",
		EnrolledAt: time.Now().UTC(),
	}, "deadbeef", ""); err != nil {
		t.Fatalf("create host: %v", err)
	}
	jobID := ulid.Make().String()
	now := time.Now().UTC()
	if err := st.CreateJob(context.Background(), store.Job{
		ID: jobID, HostID: hostID, Kind: "backup",
		ActorKind: "user", CreatedAt: now,
	}); err != nil {
		t.Fatalf("create job: %v", err)
	}
	if err := st.MarkJobStarted(context.Background(), jobID, now); err != nil {
		t.Fatalf("mark started: %v", err)
	}

	cookie := loginAsAdmin(t, st)
	req, _ := stdhttp.NewRequest("POST",
		ts.URL+"/api/jobs/"+jobID+"/cancel", nil)
	req.AddCookie(cookie)
	res, err := stdhttp.DefaultClient.Do(req)
	if err != nil {
		t.Fatalf("do: %v", err)
	}
	defer res.Body.Close()
	if res.StatusCode != stdhttp.StatusServiceUnavailable {
		t.Fatalf("status: got %d, want 503", res.StatusCode)
	}
}
