// repo_ops_test.go — integration tests for the repo run-now endpoints:
// prune, check, unlock.
package http

import (
	"context"
	"encoding/json"
	stdhttp "net/http"
	"strconv"
	"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"
)

// ----- helpers -------------------------------------------------------

// seedInitJob marks a fake init job done for the host so the auto-init
// path doesn't fire and pollute the envelope sequence we're measuring.
func seedInitJob(t *testing.T, st *store.Store, hostID string) {
	t.Helper()
	if err := st.CreateJob(context.Background(), store.Job{
		ID: ulid.Make().String(), HostID: hostID, Kind: "init",
		ActorKind: "system", CreatedAt: time.Now().UTC(),
	}); err != nil {
		t.Fatalf("seed init job: %v", err)
	}
}

// setAdminCreds writes admin credentials for a host via the store directly.
func setAdminCreds(t *testing.T, srv *Server, st *store.Store, hostID string) {
	t.Helper()
	enc, err := srv.encryptRepoCreds(repoCredsBlob{
		RepoURL:      "rest:http://admin.example/h",
		RepoUsername: "admin",
		RepoPassword: "prune-pass",
	}, []byte("host:"+hostID+":admin"))
	if err != nil {
		t.Fatalf("encrypt admin creds: %v", err)
	}
	if err := st.SetHostCredentials(context.Background(), hostID, store.CredKindAdmin, enc); err != nil {
		t.Fatalf("set admin creds: %v", err)
	}
}

// setMaintenanceSubset sets check_subset_pct for the host via the store.
func setMaintenanceSubset(t *testing.T, st *store.Store, hostID string, pct int) {
	t.Helper()
	// Ensure the row exists first.
	if err := st.CreateDefaultRepoMaintenance(context.Background(), hostID); err != nil {
		t.Fatalf("seed maintenance: %v", err)
	}
	m, err := st.GetRepoMaintenance(context.Background(), hostID)
	if err != nil {
		t.Fatalf("get maintenance: %v", err)
	}
	m.CheckSubsetPct = pct
	if err := st.UpdateRepoMaintenance(context.Background(), m); err != nil {
		t.Fatalf("update maintenance: %v", err)
	}
}

// drainCommandRun reads envelopes until a command.run arrives, then
// unmarshals and returns the payload.
func drainCommandRun(t *testing.T, c *websocket.Conn) api.CommandRunPayload {
	t.Helper()
	env := drainUntil(t, c, api.MsgCommandRun)
	var p api.CommandRunPayload
	if err := env.UnmarshalPayload(&p); err != nil {
		t.Fatalf("unmarshal command.run: %v", err)
	}
	return p
}

// ----- prune tests ---------------------------------------------------

// TestRunPruneRefusesWithoutAdminCreds: POST prune with no admin creds
// set → 400, code admin_creds_required, no job row created.
func TestRunPruneRefusesWithoutAdminCreds(t *testing.T) {
	t.Parallel()
	srv, ts, st := rawTestServer(t)
	hostID, token := enrolHostForWS(t, srv, st, "prune-no-admin")
	cookie := loginAsAdmin(t, st)
	seedInitJob(t, st, hostID)

	c := agentDial(t, srv, ts, hostID, token)
	sendHello(t, c, "prune-no-admin")
	_ = drainUntil(t, c, api.MsgScheduleSet)

	status, body := doJSON(t, ts.URL, "POST", "/api/hosts/"+hostID+"/repo/prune", nil, cookie)
	if status != stdhttp.StatusBadRequest {
		t.Fatalf("want 400, got %d body=%+v", status, body)
	}
	if code, _ := body["code"].(string); code != "admin_creds_required" {
		t.Errorf("want code=admin_creds_required, got %+v", body)
	}

	// No prune job row should have been persisted.
	var n int
	if err := st.DB().QueryRow(
		`SELECT COUNT(*) FROM jobs WHERE host_id = ? AND kind = 'prune'`, hostID).Scan(&n); err != nil {
		t.Fatalf("count: %v", err)
	}
	if n != 0 {
		t.Errorf("unexpected prune job rows: %d", n)
	}
}

// TestRunPruneShipsConfigUpdateThenCommandRun: set admin creds, connect
// host, POST prune. Assert envelope sequence: config.update(slot=admin)
// → command.run(prune, RequiresAdminCreds=true). Assert job row persisted.
func TestRunPruneShipsConfigUpdateThenCommandRun(t *testing.T) {
	t.Parallel()
	srv, ts, st := rawTestServer(t)
	hostID, token := enrolHostForWS(t, srv, st, "prune-happy")
	cookie := loginAsAdmin(t, st)
	setAdminCreds(t, srv, st, hostID)
	seedInitJob(t, st, hostID)

	c := agentDial(t, srv, ts, hostID, token)
	sendHello(t, c, "prune-happy")
	// Drain on-hello burst (repo config.update + schedule.set).
	_ = drainUntil(t, c, api.MsgScheduleSet)

	status, body := doJSON(t, ts.URL, "POST", "/api/hosts/"+hostID+"/repo/prune", nil, cookie)
	if status != stdhttp.StatusAccepted {
		t.Fatalf("want 202, got %d body=%+v", status, body)
	}
	jobID, _ := body["job_id"].(string)
	if jobID == "" {
		t.Fatalf("no job_id in response: %+v", body)
	}

	// Read the next two envelopes — must be config.update(slot=admin)
	// followed by command.run(prune).
	deadline := time.Now().Add(3 * time.Second)
	var sawAdminPush bool
	var prunePayload *api.CommandRunPayload
	for (prunePayload == nil) && time.Now().Before(deadline) {
		ctx, cancel := context.WithTimeout(context.Background(), 800*time.Millisecond)
		mt, raw, err := c.Read(ctx)
		cancel()
		if err != nil {
			break
		}
		if mt != websocket.MessageText {
			continue
		}
		var env api.Envelope
		if err := json.Unmarshal(raw, &env); err != nil {
			continue
		}
		switch env.Type {
		case api.MsgConfigUpdate:
			var p api.ConfigUpdatePayload
			if err := env.UnmarshalPayload(&p); err == nil && p.Slot == "admin" {
				sawAdminPush = true
			}
		case api.MsgCommandRun:
			var p api.CommandRunPayload
			if err := env.UnmarshalPayload(&p); err == nil && p.Kind == api.JobPrune {
				copy := p
				prunePayload = &copy
			}
		}
	}

	if !sawAdminPush {
		t.Error("expected config.update(slot=admin) before prune dispatch")
	}
	if prunePayload == nil {
		t.Fatal("timed out waiting for command.run(prune)")
	}
	if !prunePayload.RequiresAdminCreds {
		t.Error("prune command.run must have RequiresAdminCreds=true")
	}
	if prunePayload.JobID != jobID {
		t.Errorf("job_id mismatch: dispatch=%s run=%s", jobID, prunePayload.JobID)
	}

	// Job row must be persisted.
	var n int
	if err := st.DB().QueryRow(
		`SELECT COUNT(*) FROM jobs WHERE id = ? AND host_id = ? AND kind = 'prune'`,
		jobID, hostID).Scan(&n); err != nil {
		t.Fatalf("count: %v", err)
	}
	if n != 1 {
		t.Errorf("prune job row count: want 1, got %d", n)
	}
}

// ----- check tests ---------------------------------------------------

// TestRunCheckUsesMaintenanceSubset: check_subset_pct=25 → Args==["25"].
func TestRunCheckUsesMaintenanceSubset(t *testing.T) {
	t.Parallel()
	srv, ts, st := rawTestServer(t)
	hostID, token := enrolHostForWS(t, srv, st, "check-subset")
	cookie := loginAsAdmin(t, st)
	setMaintenanceSubset(t, st, hostID, 25)
	seedInitJob(t, st, hostID)

	c := agentDial(t, srv, ts, hostID, token)
	sendHello(t, c, "check-subset")
	_ = drainUntil(t, c, api.MsgScheduleSet)

	status, body := doJSON(t, ts.URL, "POST", "/api/hosts/"+hostID+"/repo/check", nil, cookie)
	if status != stdhttp.StatusAccepted {
		t.Fatalf("want 202, got %d body=%+v", status, body)
	}

	p := drainCommandRun(t, c)
	if p.Kind != api.JobCheck {
		t.Fatalf("kind: want check, got %s", p.Kind)
	}
	if len(p.Args) != 1 || p.Args[0] != "25" {
		t.Errorf("args: want [25], got %v", p.Args)
	}
}

// TestRunCheckHonorsSubsetOverride: ?subset=10 overrides DB value of 25.
func TestRunCheckHonorsSubsetOverride(t *testing.T) {
	t.Parallel()
	srv, ts, st := rawTestServer(t)
	hostID, token := enrolHostForWS(t, srv, st, "check-override")
	cookie := loginAsAdmin(t, st)
	setMaintenanceSubset(t, st, hostID, 25)
	seedInitJob(t, st, hostID)

	c := agentDial(t, srv, ts, hostID, token)
	sendHello(t, c, "check-override")
	_ = drainUntil(t, c, api.MsgScheduleSet)

	status, body := doJSON(t, ts.URL, "POST", "/api/hosts/"+hostID+"/repo/check?subset=10", nil, cookie)
	if status != stdhttp.StatusAccepted {
		t.Fatalf("want 202, got %d body=%+v", status, body)
	}

	p := drainCommandRun(t, c)
	if len(p.Args) != 1 || p.Args[0] != "10" {
		t.Errorf("args: want [10], got %v", p.Args)
	}
}

// TestRunCheckRejectsBadSubsetGracefully: ?subset=abc falls back to DB
// value (not an error). strconv.Atoi failure silently ignored.
func TestRunCheckRejectsBadSubsetGracefully(t *testing.T) {
	t.Parallel()
	srv, ts, st := rawTestServer(t)
	hostID, token := enrolHostForWS(t, srv, st, "check-badsubset")
	cookie := loginAsAdmin(t, st)
	setMaintenanceSubset(t, st, hostID, 30)
	seedInitJob(t, st, hostID)

	c := agentDial(t, srv, ts, hostID, token)
	sendHello(t, c, "check-badsubset")
	_ = drainUntil(t, c, api.MsgScheduleSet)

	status, body := doJSON(t, ts.URL, "POST", "/api/hosts/"+hostID+"/repo/check?subset=abc", nil, cookie)
	if status != stdhttp.StatusAccepted {
		t.Fatalf("want 202 (bad subset falls back), got %d body=%+v", status, body)
	}

	p := drainCommandRun(t, c)
	if len(p.Args) != 1 || p.Args[0] != strconv.Itoa(30) {
		t.Errorf("args: want [30], got %v", p.Args)
	}
}

// ----- unlock tests --------------------------------------------------

// TestRunUnlockNeedsNoAdminCreds: no admin creds, POST unlock → 202.
func TestRunUnlockNeedsNoAdminCreds(t *testing.T) {
	t.Parallel()
	srv, ts, st := rawTestServer(t)
	hostID, token := enrolHostForWS(t, srv, st, "unlock-no-admin")
	cookie := loginAsAdmin(t, st)
	seedInitJob(t, st, hostID)

	c := agentDial(t, srv, ts, hostID, token)
	sendHello(t, c, "unlock-no-admin")
	_ = drainUntil(t, c, api.MsgScheduleSet)

	status, body := doJSON(t, ts.URL, "POST", "/api/hosts/"+hostID+"/repo/unlock", nil, cookie)
	if status != stdhttp.StatusAccepted {
		t.Fatalf("want 202, got %d body=%+v", status, body)
	}

	p := drainCommandRun(t, c)
	if p.Kind != api.JobUnlock {
		t.Fatalf("kind: want unlock, got %s", p.Kind)
	}
	// RequiresAdminCreds must be false for unlock.
	if p.RequiresAdminCreds {
		t.Error("unlock must not set RequiresAdminCreds")
	}
}

// ----- auth tests ----------------------------------------------------

// TestRunOpsRequireAuth: unauthenticated POST to each endpoint → 401.
func TestRunOpsRequireAuth(t *testing.T) {
	t.Parallel()
	_, url, st := newTestServerWithHub(t)
	hostID := makeHost(t, st, "auth-host")

	for _, path := range []string{
		"/api/hosts/" + hostID + "/repo/prune",
		"/api/hosts/" + hostID + "/repo/check",
		"/api/hosts/" + hostID + "/repo/unlock",
	} {
		path := path
		t.Run(path, func(t *testing.T) {
			t.Parallel()
			req, _ := stdhttp.NewRequest("POST", url+path, nil)
			res, err := stdhttp.DefaultClient.Do(req)
			if err != nil {
				t.Fatalf("do: %v", err)
			}
			defer res.Body.Close()
			if res.StatusCode != stdhttp.StatusUnauthorized {
				t.Errorf("want 401, got %d", res.StatusCode)
			}
		})
	}

	// HTMX path: unauthenticated POST with HX-Request: true → 303 to /login.
	// Auth check fires before host lookup so the host ID doesn't need to exist.
	for _, path := range []string{
		"/hosts/" + hostID + "/repo/prune",
		"/hosts/" + hostID + "/repo/check",
		"/hosts/" + hostID + "/repo/unlock",
	} {
		path := path
		t.Run("htmx"+path, func(t *testing.T) {
			t.Parallel()
			client := &stdhttp.Client{
				CheckRedirect: func(_ *stdhttp.Request, _ []*stdhttp.Request) error {
					return stdhttp.ErrUseLastResponse
				},
			}
			req, _ := stdhttp.NewRequest("POST", url+path, nil)
			req.Header.Set("HX-Request", "true")
			res, err := client.Do(req)
			if err != nil {
				t.Fatalf("do: %v", err)
			}
			defer res.Body.Close()
			if res.StatusCode != stdhttp.StatusSeeOther {
				t.Errorf("want 303, got %d", res.StatusCode)
			}
			if loc := res.Header.Get("Location"); loc != "/login" {
				t.Errorf("want Location=/login, got %q", loc)
			}
		})
	}
}
