package api

import (
	"encoding/base64"
	"encoding/json"
	"net/http"
	"os"
	"testing"

	"gitea.dooplex.hu/admin/felhom-hub/internal/store"
)

// The desired-state wire is a contract DUPLICATED with felhom-agent. testdata/desired-state.golden.json
// MUST stay byte-identical with the agent's internal/hub/testdata copy. The hub serves desired_state
// OPAQUELY (stores + emits the bytes), so this test proves the golden's desired_state round-trips
// through admin-set → GET unchanged (the pass-through contract).
func TestDesiredStateGolden_RoundTripsThroughHub(t *testing.T) {
	h, st, _ := newTestHandler(t)
	seedHost(t, st, "h1", "c1", "HKEY1")

	raw, err := os.ReadFile("testdata/desired-state.golden.json")
	if err != nil {
		t.Fatal(err)
	}
	var golden struct {
		DesiredState json.RawMessage `json:"desired_state"`
	}
	if err := json.Unmarshal(raw, &golden); err != nil {
		t.Fatal(err)
	}

	// Operator sets the golden's desired_state.
	if rr := do(h, http.MethodPut, "/admin/hosts/h1/desired-state", globalKey, string(golden.DesiredState)); rr.Code != http.StatusOK {
		t.Fatalf("admin-set golden desired_state: %d body=%s", rr.Code, rr.Body.String())
	}
	// The host fetches it back — byte-for-byte the same object (the hub never reshapes it).
	rr := do(h, http.MethodGet, "/hosts/h1/desired-state", "HKEY1", "")
	if rr.Code != 200 {
		t.Fatalf("GET desired-state: %d", rr.Code)
	}
	var got struct {
		Generation   int64           `json:"generation"`
		DesiredState json.RawMessage `json:"desired_state"`
	}
	json.Unmarshal(rr.Body.Bytes(), &got)
	if got.Generation != 1 {
		t.Errorf("generation = %d, want 1", got.Generation)
	}
	// Compare semantically (re-marshal both through a generic map to ignore whitespace).
	var want, have any
	json.Unmarshal(golden.DesiredState, &want)
	json.Unmarshal(got.DesiredState, &have)
	wb, _ := json.Marshal(want)
	hb, _ := json.Marshal(have)
	if string(wb) != string(hb) {
		t.Errorf("served desired_state diverged from the set golden:\n set:    %s\n served: %s", wb, hb)
	}
}

// seedHost mints a host directly in the store for desired-state tests.
func seedHost(t *testing.T, st *store.Store, hostID, custID, apiKey string) {
	t.Helper()
	if err := st.UpsertHost(&store.Host{HostID: hostID, CustomerID: custID, APIKey: apiKey}); err != nil {
		t.Fatalf("UpsertHost(%s): %v", hostID, err)
	}
}

// Admin-set bumps the generation each write and the served desired-state reflects the latest body.
func TestAdminSetDesiredState_BumpsGenerationAndServes(t *testing.T) {
	h, st, _ := newTestHandler(t)
	seedHost(t, st, "h1", "c1", "HKEY1")

	// Initial generation is 0 (nothing set yet).
	if rr := do(h, http.MethodGet, "/hosts/h1/desired-state", "HKEY1", ""); rr.Code != 200 {
		t.Fatalf("initial GET desired-state: %d", rr.Code)
	}

	// First admin-set → generation 1.
	rr := do(h, http.MethodPut, "/admin/hosts/h1/desired-state", globalKey, `{"guests":[{"vmid":100,"run":"running"}]}`)
	if rr.Code != http.StatusOK {
		t.Fatalf("admin-set #1: %d body=%s", rr.Code, rr.Body.String())
	}
	var set struct {
		Generation int64 `json:"generation"`
	}
	json.Unmarshal(rr.Body.Bytes(), &set)
	if set.Generation != 1 {
		t.Fatalf("generation after set #1 = %d, want 1", set.Generation)
	}

	// Second admin-set → generation 2 (monotonic).
	rr = do(h, http.MethodPut, "/admin/hosts/h1/desired-state", globalKey, `{"guests":[{"vmid":100,"run":"stopped"}]}`)
	json.Unmarshal(rr.Body.Bytes(), &set)
	if set.Generation != 2 {
		t.Fatalf("generation after set #2 = %d, want 2", set.Generation)
	}

	// GET serves the latest body + its generation.
	rr = do(h, http.MethodGet, "/hosts/h1/desired-state", "HKEY1", "")
	if rr.Code != 200 {
		t.Fatalf("GET desired-state: %d", rr.Code)
	}
	var got struct {
		Generation   int64           `json:"generation"`
		DesiredState json.RawMessage `json:"desired_state"`
	}
	json.Unmarshal(rr.Body.Bytes(), &got)
	if got.Generation != 2 {
		t.Errorf("served generation = %d, want 2", got.Generation)
	}
	var ds struct {
		Guests []struct {
			VMID int    `json:"vmid"`
			Run  string `json:"run"`
		} `json:"guests"`
	}
	if err := json.Unmarshal(got.DesiredState, &ds); err != nil {
		t.Fatalf("desired_state not valid JSON: %v", err)
	}
	if len(ds.Guests) != 1 || ds.Guests[0].VMID != 100 || ds.Guests[0].Run != "stopped" {
		t.Errorf("served desired_state = %+v, want the latest (vmid 100 stopped)", ds.Guests)
	}
}

// Admin-set requires the global key; a per-host key is refused.
func TestAdminSetDesiredState_GlobalKeyOnly(t *testing.T) {
	h, st, _ := newTestHandler(t)
	seedHost(t, st, "h1", "c1", "HKEY1")

	if rr := do(h, http.MethodPut, "/admin/hosts/h1/desired-state", "HKEY1", `{"x":1}`); rr.Code != http.StatusForbidden {
		t.Errorf("per-host key admin-set = %d, want 403", rr.Code)
	}
	if rr := do(h, http.MethodPut, "/admin/hosts/h1/desired-state", globalKey, `{"x":1}`); rr.Code != http.StatusOK {
		t.Errorf("global key admin-set = %d, want 200", rr.Code)
	}
	// Malformed JSON is rejected at the door.
	if rr := do(h, http.MethodPut, "/admin/hosts/h1/desired-state", globalKey, `not json`); rr.Code != http.StatusBadRequest {
		t.Errorf("malformed admin-set = %d, want 400", rr.Code)
	}
	// Unknown host → 404.
	if rr := do(h, http.MethodPut, "/admin/hosts/nope/desired-state", globalKey, `{}`); rr.Code != http.StatusNotFound {
		t.Errorf("unknown host admin-set = %d, want 404", rr.Code)
	}
}

// GET /desired-state is SELF-SCOPED: host A's key cannot read host B; the global key can read any.
func TestGetDesiredState_SelfScoped(t *testing.T) {
	h, st, _ := newTestHandler(t)
	seedHost(t, st, "h1", "c1", "HKEY1")
	seedHost(t, st, "h2", "c2", "HKEY2")
	if _, err := st.SetHostDesired("h1", []byte(`{"guests":[]}`)); err != nil {
		t.Fatal(err)
	}

	// h1's key reading h1 → 200.
	if rr := do(h, http.MethodGet, "/hosts/h1/desired-state", "HKEY1", ""); rr.Code != 200 {
		t.Errorf("h1 reading h1 = %d, want 200", rr.Code)
	}
	// h2's key reading h1 → 403 (the headline self-scoping check).
	if rr := do(h, http.MethodGet, "/hosts/h1/desired-state", "HKEY2", ""); rr.Code != http.StatusForbidden {
		t.Errorf("h2 reading h1 = %d, want 403", rr.Code)
	}
	// Global key reading any → 200.
	if rr := do(h, http.MethodGet, "/hosts/h1/desired-state", globalKey, ""); rr.Code != 200 {
		t.Errorf("global reading h1 = %d, want 200", rr.Code)
	}
	// No token → 401.
	if rr := do(h, http.MethodGet, "/hosts/h1/desired-state", "", ""); rr.Code != http.StatusUnauthorized {
		t.Errorf("no token = %d, want 401", rr.Code)
	}
}

// The control envelope on a host-report carries the current generation + has_signed_ops flag.
func TestHostReportEnvelope_GenerationAndSignedOps(t *testing.T) {
	h, st, _ := newTestHandler(t)
	st.SaveCustomerConfig(&store.CustomerConfig{CustomerID: "c1", APIKey: "ckey", RetrievalPassword: "p"})
	seedHost(t, st, "h1", "c1", "HKEY")

	readEnvelope := func() (int64, bool) {
		rr := do(h, http.MethodPost, "/host-report", "HKEY", validReportBody("h1"))
		if rr.Code != 200 {
			t.Fatalf("host-report: %d body=%s", rr.Code, rr.Body.String())
		}
		var env struct {
			DesiredGeneration int64 `json:"desired_generation"`
			HasSignedOps      bool  `json:"has_signed_ops"`
		}
		json.Unmarshal(rr.Body.Bytes(), &env)
		return env.DesiredGeneration, env.HasSignedOps
	}

	// Fresh host: generation 0, no signed ops.
	if gen, signed := readEnvelope(); gen != 0 || signed {
		t.Fatalf("fresh envelope = gen %d signed %v, want 0/false", gen, signed)
	}

	// After an admin-set: generation advances on the next heartbeat.
	if _, err := st.SetHostDesired("h1", []byte(`{"guests":[]}`)); err != nil {
		t.Fatal(err)
	}
	if gen, _ := readEnvelope(); gen != 1 {
		t.Errorf("envelope generation after set = %d, want 1", gen)
	}

	// After enqueueing a signed job: has_signed_ops flips true.
	if err := st.EnqueueSignedJob("h1", "job1", []byte("opaque-signed-blob")); err != nil {
		t.Fatal(err)
	}
	if _, signed := readEnvelope(); !signed {
		t.Errorf("has_signed_ops after enqueue = false, want true")
	}
}

// GET /jobs is self-scoped and serves the enqueued opaque blobs (oldest first).
func TestGetJobs_SelfScopedAndServesBlobs(t *testing.T) {
	h, st, _ := newTestHandler(t)
	seedHost(t, st, "h1", "c1", "HKEY1")
	seedHost(t, st, "h2", "c2", "HKEY2")
	st.EnqueueSignedJob("h1", "jobA", []byte("blobA"))
	st.EnqueueSignedJob("h1", "jobB", []byte("blobB"))

	// h2 reading h1's jobs → 403.
	if rr := do(h, http.MethodGet, "/hosts/h1/jobs", "HKEY2", ""); rr.Code != http.StatusForbidden {
		t.Errorf("h2 reading h1 jobs = %d, want 403", rr.Code)
	}
	// h1 reading its own → 200 with both blobs, oldest first.
	rr := do(h, http.MethodGet, "/hosts/h1/jobs", "HKEY1", "")
	if rr.Code != 200 {
		t.Fatalf("h1 reading jobs = %d", rr.Code)
	}
	var resp struct {
		Jobs []struct {
			JobID   string `json:"job_id"`
			BlobB64 string `json:"blob_b64"`
		} `json:"jobs"`
	}
	json.Unmarshal(rr.Body.Bytes(), &resp)
	if len(resp.Jobs) != 2 || resp.Jobs[0].JobID != "jobA" || resp.Jobs[1].JobID != "jobB" {
		t.Fatalf("jobs = %+v, want jobA then jobB", resp.Jobs)
	}
	blobA, _ := base64.StdEncoding.DecodeString(resp.Jobs[0].BlobB64)
	if string(blobA) != "blobA" {
		t.Errorf("jobA blob = %q, want blobA", blobA)
	}
}

// DELETE /hosts/{id}/jobs/{job_id} clears a processed job (slice 10B), self-scoped + idempotent.
func TestDeleteJob_SelfScopedAndIdempotent(t *testing.T) {
	h, st, _ := newTestHandler(t)
	seedHost(t, st, "h1", "c1", "HKEY1")
	seedHost(t, st, "h2", "c2", "HKEY2")
	st.EnqueueSignedJob("h1", "jobA", []byte("blob"))

	// h2 cannot clear h1's job (self-scope).
	if rr := do(h, http.MethodDelete, "/hosts/h1/jobs/jobA", "HKEY2", ""); rr.Code != http.StatusForbidden {
		t.Errorf("h2 clearing h1's job = %d, want 403", rr.Code)
	}
	if n, _ := st.CountSignedJobs("h1"); n != 1 {
		t.Errorf("job was removed by an unauthorized delete (depth=%d)", n)
	}
	// h1 clears its own job → 200, queue empties.
	if rr := do(h, http.MethodDelete, "/hosts/h1/jobs/jobA", "HKEY1", ""); rr.Code != http.StatusOK {
		t.Fatalf("h1 clearing own job = %d, want 200", rr.Code)
	}
	if n, _ := st.CountSignedJobs("h1"); n != 0 {
		t.Errorf("queue depth after delete = %d, want 0", n)
	}
	// Idempotent: deleting an absent job is a clean 200.
	if rr := do(h, http.MethodDelete, "/hosts/h1/jobs/jobA", "HKEY1", ""); rr.Code != http.StatusOK {
		t.Errorf("idempotent delete = %d, want 200", rr.Code)
	}
}

// The admin enqueue-job endpoint (global key only) seeds the queue, reflected in has_signed_ops.
func TestAdminEnqueueJob_GlobalKeyOnly(t *testing.T) {
	h, st, _ := newTestHandler(t)
	seedHost(t, st, "h1", "c1", "HKEY1")
	blob := base64.StdEncoding.EncodeToString([]byte("signed-op-envelope"))

	// per-host key → 403.
	if rr := do(h, http.MethodPost, "/admin/hosts/h1/jobs", "HKEY1", `{"blob_b64":"`+blob+`"}`); rr.Code != http.StatusForbidden {
		t.Errorf("per-host enqueue = %d, want 403", rr.Code)
	}
	// global key → 201.
	if rr := do(h, http.MethodPost, "/admin/hosts/h1/jobs", globalKey, `{"job_id":"j1","blob_b64":"`+blob+`"}`); rr.Code != http.StatusCreated {
		t.Fatalf("global enqueue = %d, want 201", rr.Code)
	}
	if n, _ := st.CountSignedJobs("h1"); n != 1 {
		t.Errorf("queue depth = %d, want 1", n)
	}
}
