package api

import (
	"bytes"
	"context"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"net/http/httptest"
	"strings"
	"sync/atomic"
	"testing"

	"github.com/alexei/tinyforge/internal/auth"
	"github.com/alexei/tinyforge/internal/crypto"
	"github.com/alexei/tinyforge/internal/store"
	"github.com/alexei/tinyforge/internal/webhook"
	"github.com/alexei/tinyforge/internal/workload/plugin"

	// Blank-imports register the source/trigger plugins the tests assert
	// against. Mirrors cmd/server/main.go's set.
	_ "github.com/alexei/tinyforge/internal/workload/plugin/source/image"
	_ "github.com/alexei/tinyforge/internal/workload/plugin/trigger/git"
	_ "github.com/alexei/tinyforge/internal/workload/plugin/trigger/manual"
	_ "github.com/alexei/tinyforge/internal/workload/plugin/trigger/registry"
)

// =============================================================================
// Test helpers
// =============================================================================

// fakeAPIDispatcher is the minimum PluginDispatcher the API needs. It
// counts Deploy / Teardown calls so handlers can be observed end-to-end.
// Returning nil errors keeps the tests focused on HTTP/store behaviour;
// per-test errFn override flips that on demand.
type fakeAPIDispatcher struct {
	deployCount   atomic.Int32
	teardownCount atomic.Int32

	lastIntent atomic.Pointer[plugin.DeploymentIntent]
	lastWorkID atomic.Value // string

	deployErrFn   func() error
	teardownErrFn func() error
}

func (f *fakeAPIDispatcher) DispatchPlugin(_ context.Context, w plugin.Workload, intent plugin.DeploymentIntent) error {
	f.deployCount.Add(1)
	f.lastIntent.Store(&intent)
	f.lastWorkID.Store(w.ID)
	if f.deployErrFn != nil {
		return f.deployErrFn()
	}
	return nil
}

func (f *fakeAPIDispatcher) DispatchTeardown(_ context.Context, w plugin.Workload) error {
	f.teardownCount.Add(1)
	f.lastWorkID.Store(w.ID)
	if f.teardownErrFn != nil {
		return f.teardownErrFn()
	}
	return nil
}

func (f *fakeAPIDispatcher) PluginDeps() plugin.Deps { return plugin.Deps{} }

// apiTestEnv bundles everything a test needs: a live test server, the
// underlying store for asserting persistence, the fake dispatcher for
// observing dispatch calls, and an admin token for hitting protected routes.
type apiTestEnv struct {
	srv        *httptest.Server
	store      *store.Store
	dispatcher *fakeAPIDispatcher
	adminToken string
	encKey     [32]byte
}

func (e *apiTestEnv) close() { e.srv.Close() }

// newAPITestEnv spins up an in-memory store, a fake dispatcher, a webhook
// handler bound to the dispatcher, and the API server. An admin user is
// created and a valid JWT minted so authenticated routes can be exercised.
func newAPITestEnv(t *testing.T) *apiTestEnv {
	t.Helper()

	st, err := store.New(":memory:")
	if err != nil {
		t.Fatalf("create store: %v", err)
	}
	t.Cleanup(func() { st.Close() })

	encKey := [32]byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
	dispatcher := &fakeAPIDispatcher{}
	wh := webhook.NewHandler(st)
	wh.SetPluginDispatcher(dispatcher)

	srv := NewServer(
		st,
		nil, // dockerClient — unused on the routes under test
		nil, // npmClient
		nil, // proxyProvider
		dispatcher,
		nil, // notifier
		wh,
		nil, // eventBus
		encKey,
	)

	httpsrv := httptest.NewServer(srv.Router())
	t.Cleanup(httpsrv.Close)

	// Mint an admin token via the same auth.LocalAuth instance the server uses.
	// The router constructs LocalAuth from encKey internally; rebuilding one
	// here with the same key produces a token the server's middleware
	// accepts.
	la := auth.NewLocalAuth(encKey)
	tok, err := la.GenerateToken(auth.Claims{
		UserID: "u-admin", Username: "admin", Role: "admin",
	})
	if err != nil {
		t.Fatalf("mint token: %v", err)
	}

	return &apiTestEnv{
		srv:        httpsrv,
		store:      st,
		dispatcher: dispatcher,
		adminToken: tok.Token,
		encKey:     encKey,
	}
}

// do issues an authenticated request and returns the response. Failures
// to construct the request are fatal because they are bugs in the test
// itself, not the system under test.
func (e *apiTestEnv) do(t *testing.T, method, path string, body any) *http.Response {
	t.Helper()
	var rdr io.Reader
	if body != nil {
		b, err := json.Marshal(body)
		if err != nil {
			t.Fatalf("marshal body: %v", err)
		}
		rdr = bytes.NewReader(b)
	}
	req, err := http.NewRequest(method, e.srv.URL+path, rdr)
	if err != nil {
		t.Fatalf("new request: %v", err)
	}
	req.Header.Set("Authorization", "Bearer "+e.adminToken)
	if body != nil {
		req.Header.Set("Content-Type", "application/json")
	}
	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		t.Fatalf("do request: %v", err)
	}
	return resp
}

// decodeEnvelope reads the response body into the standard {success,data,error}
// envelope and decodes data into out. Fatals on any error — tests should
// already have asserted the status code separately.
func decodeEnvelope(t *testing.T, resp *http.Response, out any) string {
	t.Helper()
	defer resp.Body.Close()
	body, err := io.ReadAll(resp.Body)
	if err != nil {
		t.Fatalf("read body: %v", err)
	}
	var env struct {
		Success bool            `json:"success"`
		Data    json.RawMessage `json:"data"`
		Error   string          `json:"error"`
	}
	if err := json.Unmarshal(body, &env); err != nil {
		t.Fatalf("unmarshal envelope: %v\nbody=%s", err, string(body))
	}
	if out != nil && len(env.Data) > 0 {
		if err := json.Unmarshal(env.Data, out); err != nil {
			t.Fatalf("unmarshal data: %v\ndata=%s", err, string(env.Data))
		}
	}
	return env.Error
}

// validImageSourceConfig returns the JSON body for a valid image source
// config — kept consistent across tests so the create-success cases all
// look the same.
func validImageSourceConfig() json.RawMessage {
	return json.RawMessage(`{"image":"registry.example.com/owner/app","port":8080,"default_tag":"latest"}`)
}

// =============================================================================
// POST /api/workloads — create
// =============================================================================

func TestCreateWorkload_HappyPath_ReturnsCreatedRow(t *testing.T) {
	e := newAPITestEnv(t)

	body := pluginWorkloadRequest{
		Name:         "my-app",
		SourceKind:   "image",
		SourceConfig: validImageSourceConfig(),
	}
	resp := e.do(t, http.MethodPost, "/api/workloads", body)
	if resp.StatusCode != http.StatusCreated {
		_ = decodeEnvelope(t, resp, nil)
		t.Fatalf("status = %d, want 201", resp.StatusCode)
	}
	var got plugin.Workload
	if errMsg := decodeEnvelope(t, resp, &got); errMsg != "" {
		t.Fatalf("envelope error: %q", errMsg)
	}
	if got.ID == "" {
		t.Fatal("expected ID to be assigned")
	}
	if got.Name != "my-app" {
		t.Fatalf("Name = %q, want my-app", got.Name)
	}
	// Sanity: the row is persisted in the store with the same ID and the
	// kind="plugin" sentinel so legacy filters continue to skip it.
	row, err := e.store.GetWorkloadByID(got.ID)
	if err != nil {
		t.Fatalf("GetWorkloadByID: %v", err)
	}
	if row.Kind != "plugin" {
		t.Fatalf("row.Kind = %q, want plugin", row.Kind)
	}
	// CreateWorkload self-references RefID to ID for plugin-native rows
	// so the UNIQUE(kind, ref_id) constraint can hold many siblings.
	if row.RefID != row.ID {
		t.Fatalf("RefID = %q, want self-reference to ID %q", row.RefID, row.ID)
	}
}

func TestCreateWorkload_ValidationErrors(t *testing.T) {
	cases := []struct {
		name     string
		req      pluginWorkloadRequest
		wantCode int
		wantSub  string
	}{
		{
			name:     "empty name",
			req:      pluginWorkloadRequest{Name: "   ", SourceKind: "image", SourceConfig: validImageSourceConfig()},
			wantCode: http.StatusBadRequest,
			wantSub:  "name is required",
		},
		{
			name:     "unknown source kind",
			req:      pluginWorkloadRequest{Name: "x", SourceKind: "no-such-kind", SourceConfig: json.RawMessage(`{}`)},
			wantCode: http.StatusBadRequest,
			wantSub:  "no source registered",
		},
		{
			name:     "unknown trigger kind via inline binding (validateTrigger)",
			req:      pluginWorkloadRequest{Name: "x", SourceKind: "image", SourceConfig: validImageSourceConfig(), TriggerKind: "no-such-trigger", TriggerConfig: json.RawMessage(`{}`)},
			wantCode: http.StatusBadRequest,
			wantSub:  "no trigger registered",
		},
		{
			name: "oversized source config",
			req: pluginWorkloadRequest{
				Name:         "x",
				SourceKind:   "image",
				SourceConfig: json.RawMessage(`{"image":"x","junk":"` + strings.Repeat("A", maxSourceConfigBytes+10) + `"}`),
			},
			wantCode: http.StatusBadRequest,
			wantSub:  "source_config exceeds",
		},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			e := newAPITestEnv(t)
			resp := e.do(t, http.MethodPost, "/api/workloads", tc.req)
			defer resp.Body.Close()
			if resp.StatusCode != tc.wantCode {
				body, _ := io.ReadAll(resp.Body)
				t.Fatalf("status = %d, want %d (body=%s)", resp.StatusCode, tc.wantCode, string(body))
			}
			body, _ := io.ReadAll(resp.Body)
			if !strings.Contains(string(body), tc.wantSub) {
				t.Fatalf("body %q missing substring %q", string(body), tc.wantSub)
			}
		})
	}
}

// =============================================================================
// GET /api/workloads — list
// =============================================================================

func TestListWorkloads_Empty(t *testing.T) {
	e := newAPITestEnv(t)
	resp := e.do(t, http.MethodGet, "/api/workloads", nil)
	if resp.StatusCode != http.StatusOK {
		t.Fatalf("status = %d, want 200", resp.StatusCode)
	}
	var got []plugin.Workload
	if errMsg := decodeEnvelope(t, resp, &got); errMsg != "" {
		t.Fatalf("envelope error: %q", errMsg)
	}
	if len(got) != 0 {
		t.Fatalf("expected empty list, got %d rows", len(got))
	}
}

func TestListWorkloads_Populated(t *testing.T) {
	e := newAPITestEnv(t)
	// Seed via the test helper to avoid the production UNIQUE(kind, ref_id)
	// quirk on the create handler (see seedWorkload comment).
	alphaID := seedWorkload(t, e, "alpha")
	betaID := seedWorkload(t, e, "beta")

	resp := e.do(t, http.MethodGet, "/api/workloads", nil)
	var got []plugin.Workload
	if errMsg := decodeEnvelope(t, resp, &got); errMsg != "" {
		t.Fatalf("envelope error: %q", errMsg)
	}
	// Assert membership, not just count — a regression that dropped a row
	// while inserting a duplicate would pass a bare len() check.
	seen := map[string]bool{}
	for _, w := range got {
		seen[w.ID] = true
	}
	if !seen[alphaID] || !seen[betaID] {
		t.Fatalf("list missing seeded ids; got=%v want both %s and %s", got, alphaID, betaID)
	}
	if len(got) != 2 {
		t.Fatalf("expected 2 rows, got %d", len(got))
	}
}

// =============================================================================
// GET /api/workloads/{id}
// =============================================================================

func TestGetWorkload_NotFound(t *testing.T) {
	e := newAPITestEnv(t)
	resp := e.do(t, http.MethodGet, "/api/workloads/no-such-id", nil)
	if resp.StatusCode != http.StatusNotFound {
		t.Fatalf("status = %d, want 404", resp.StatusCode)
	}
}

func TestGetWorkload_Found(t *testing.T) {
	e := newAPITestEnv(t)
	id := seedWorkload(t, e, "fetch-me")
	resp := e.do(t, http.MethodGet, "/api/workloads/"+id, nil)
	if resp.StatusCode != http.StatusOK {
		t.Fatalf("status = %d, want 200", resp.StatusCode)
	}
}

// =============================================================================
// PUT /api/workloads/{id}/plugin
// =============================================================================

func TestUpdatePluginWorkload_PreservesKindAndUpdatesName(t *testing.T) {
	e := newAPITestEnv(t)
	id := seedWorkload(t, e, "before")

	body := pluginWorkloadRequest{
		Name:         "after",
		SourceKind:   "image",
		SourceConfig: validImageSourceConfig(),
	}
	resp := e.do(t, http.MethodPut, "/api/workloads/"+id+"/plugin", body)
	if resp.StatusCode != http.StatusOK {
		body, _ := io.ReadAll(resp.Body)
		t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, string(body))
	}
	resp.Body.Close()

	row, err := e.store.GetWorkloadByID(id)
	if err != nil {
		t.Fatalf("GetWorkloadByID: %v", err)
	}
	if row.Name != "after" {
		t.Fatalf("Name = %q, want after", row.Name)
	}
	if row.Kind != "plugin" {
		t.Fatalf("Kind mutated unexpectedly: %q", row.Kind)
	}
}

// =============================================================================
// POST /api/workloads/{id}/deploy
// =============================================================================

func TestDeployPluginWorkload_DispatchesManualIntent(t *testing.T) {
	e := newAPITestEnv(t)
	id := seedWorkload(t, e, "deploy-me")

	resp := e.do(t, http.MethodPost, "/api/workloads/"+id+"/deploy", map[string]string{
		"reference": "v1.2.3",
		"note":      "test deploy",
	})
	if resp.StatusCode != http.StatusAccepted {
		body, _ := io.ReadAll(resp.Body)
		t.Fatalf("status = %d, want 202 (body=%s)", resp.StatusCode, string(body))
	}
	resp.Body.Close()

	if got := e.dispatcher.deployCount.Load(); got != 1 {
		t.Fatalf("Deploy called %d times, want 1", got)
	}
	intent := e.dispatcher.lastIntent.Load()
	if intent == nil {
		t.Fatal("dispatcher did not capture intent")
	}
	if intent.Reason != "manual" {
		t.Fatalf("intent.Reason = %q, want manual", intent.Reason)
	}
	if intent.Reference != "v1.2.3" {
		t.Fatalf("intent.Reference = %q, want v1.2.3", intent.Reference)
	}
	if intent.TriggeredBy != "admin" {
		t.Fatalf("intent.TriggeredBy = %q, want admin", intent.TriggeredBy)
	}
}

func TestDeployPluginWorkload_RejectsWorkloadWithoutSourceKind(t *testing.T) {
	e := newAPITestEnv(t)
	// Build a row directly (bypass the API) with empty SourceKind.
	row, err := e.store.CreateWorkload(store.Workload{
		Kind: "plugin", Name: "no-kind",
	})
	if err != nil {
		t.Fatalf("seed: %v", err)
	}
	resp := e.do(t, http.MethodPost, "/api/workloads/"+row.ID+"/deploy", nil)
	if resp.StatusCode != http.StatusBadRequest {
		t.Fatalf("status = %d, want 400", resp.StatusCode)
	}
	resp.Body.Close()
}

// =============================================================================
// DELETE /api/workloads/{id}
// =============================================================================

func TestDeleteWorkload_CallsTeardownAndDeletes(t *testing.T) {
	e := newAPITestEnv(t)
	id := seedWorkload(t, e, "delete-me")

	resp := e.do(t, http.MethodDelete, "/api/workloads/"+id, nil)
	if resp.StatusCode != http.StatusOK {
		t.Fatalf("status = %d, want 200", resp.StatusCode)
	}
	resp.Body.Close()

	if got := e.dispatcher.teardownCount.Load(); got != 1 {
		t.Fatalf("Teardown called %d times, want 1", got)
	}
	if _, err := e.store.GetWorkloadByID(id); err == nil {
		t.Fatal("expected workload to be deleted from store")
	}
}

func TestDeleteWorkload_TeardownErrorDoesNotBlockDelete(t *testing.T) {
	e := newAPITestEnv(t)
	e.dispatcher.teardownErrFn = func() error { return fmt.Errorf("teardown blew up") }
	id := seedWorkload(t, e, "stubborn")

	resp := e.do(t, http.MethodDelete, "/api/workloads/"+id, nil)
	if resp.StatusCode != http.StatusOK {
		body, _ := io.ReadAll(resp.Body)
		t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, string(body))
	}
	resp.Body.Close()
	if _, err := e.store.GetWorkloadByID(id); err == nil {
		t.Fatal("workload row must be deleted even when teardown fails")
	}
}

// =============================================================================
// PATCH /api/workloads/{id}/app
// =============================================================================

func TestUpdateWorkloadAppID_SetsAppID(t *testing.T) {
	e := newAPITestEnv(t)
	id := seedWorkload(t, e, "with-app")

	resp := e.do(t, http.MethodPatch, "/api/workloads/"+id+"/app", map[string]string{
		"app_id": "app-123",
	})
	if resp.StatusCode != http.StatusOK {
		body, _ := io.ReadAll(resp.Body)
		t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, string(body))
	}
	resp.Body.Close()
	row, _ := e.store.GetWorkloadByID(id)
	if row.AppID != "app-123" {
		t.Fatalf("AppID = %q, want app-123", row.AppID)
	}
}

// =============================================================================
// /api/workloads/{id}/env  CRUD
// =============================================================================

func TestWorkloadEnv_PutListDelete(t *testing.T) {
	e := newAPITestEnv(t)
	id := seedWorkload(t, e, "with-env")

	// PUT (plaintext)
	put := e.do(t, http.MethodPut, "/api/workloads/"+id+"/env", map[string]any{
		"key":       "DATABASE_URL",
		"value":     "postgres://plain",
		"encrypted": false,
	})
	if put.StatusCode != http.StatusOK {
		body, _ := io.ReadAll(put.Body)
		t.Fatalf("PUT status = %d (body=%s)", put.StatusCode, string(body))
	}
	put.Body.Close()

	// LIST
	listResp := e.do(t, http.MethodGet, "/api/workloads/"+id+"/env", nil)
	var rows []workloadEnvRow
	_ = decodeEnvelope(t, listResp, &rows)
	if len(rows) != 1 {
		t.Fatalf("expected 1 row, got %d", len(rows))
	}
	if rows[0].Value != "postgres://plain" {
		t.Fatalf("plaintext value missing in list: got %q", rows[0].Value)
	}

	// DELETE
	delResp := e.do(t, http.MethodDelete, "/api/workloads/"+id+"/env/"+rows[0].ID, nil)
	if delResp.StatusCode != http.StatusOK {
		t.Fatalf("DELETE status = %d", delResp.StatusCode)
	}
	delResp.Body.Close()

	listResp2 := e.do(t, http.MethodGet, "/api/workloads/"+id+"/env", nil)
	var rows2 []workloadEnvRow
	_ = decodeEnvelope(t, listResp2, &rows2)
	if len(rows2) != 0 {
		t.Fatalf("expected 0 rows after delete, got %d", len(rows2))
	}
}

func TestWorkloadEnv_EncryptedValueNotEchoed(t *testing.T) {
	e := newAPITestEnv(t)
	id := seedWorkload(t, e, "secret-env")

	plain := "super-secret-value-12345"
	put := e.do(t, http.MethodPut, "/api/workloads/"+id+"/env", map[string]any{
		"key":       "API_KEY",
		"value":     plain,
		"encrypted": true,
	})
	put.Body.Close()
	if put.StatusCode != http.StatusOK {
		t.Fatalf("PUT status = %d", put.StatusCode)
	}

	listResp := e.do(t, http.MethodGet, "/api/workloads/"+id+"/env", nil)
	defer listResp.Body.Close()
	body, _ := io.ReadAll(listResp.Body)

	if strings.Contains(string(body), plain) {
		t.Fatalf("encrypted plaintext leaked in response body: %s", string(body))
	}

	// Cross-check: the stored ciphertext must decrypt back to the plain value.
	rows, _ := e.store.ListWorkloadEnv(id)
	if len(rows) != 1 || !rows[0].Encrypted {
		t.Fatalf("expected 1 encrypted row, got %+v", rows)
	}
	dec, err := crypto.Decrypt(e.encKey, rows[0].Value)
	if err != nil {
		t.Fatalf("decrypt at-rest value: %v", err)
	}
	if dec != plain {
		t.Fatalf("decrypted = %q, want %q", dec, plain)
	}
}

func TestWorkloadEnv_RejectsInvalidKey(t *testing.T) {
	e := newAPITestEnv(t)
	id := seedWorkload(t, e, "bad-env-key")

	resp := e.do(t, http.MethodPut, "/api/workloads/"+id+"/env", map[string]any{
		"key":   "1BAD-KEY",
		"value": "x",
	})
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusBadRequest {
		t.Fatalf("status = %d, want 400", resp.StatusCode)
	}
}

// =============================================================================
// /api/workloads/{id}/volumes  CRUD
// =============================================================================

func TestWorkloadVolumes_PutListDelete(t *testing.T) {
	e := newAPITestEnv(t)
	id := seedWorkload(t, e, "with-vols")

	put := e.do(t, http.MethodPut, "/api/workloads/"+id+"/volumes", map[string]any{
		"source": "/srv/data",
		"target": "/data",
		"scope":  "absolute",
	})
	if put.StatusCode != http.StatusOK {
		body, _ := io.ReadAll(put.Body)
		t.Fatalf("PUT status = %d (body=%s)", put.StatusCode, string(body))
	}
	put.Body.Close()

	listResp := e.do(t, http.MethodGet, "/api/workloads/"+id+"/volumes", nil)
	var rows []store.WorkloadVolume
	_ = decodeEnvelope(t, listResp, &rows)
	if len(rows) != 1 {
		t.Fatalf("expected 1 volume, got %d", len(rows))
	}

	delResp := e.do(t, http.MethodDelete, "/api/workloads/"+id+"/volumes/"+rows[0].ID, nil)
	if delResp.StatusCode != http.StatusOK {
		t.Fatalf("DELETE status = %d", delResp.StatusCode)
	}
	delResp.Body.Close()

	rowsAfter, _ := e.store.ListWorkloadVolumes(id)
	if len(rowsAfter) != 0 {
		t.Fatalf("expected 0 volumes after delete, got %d", len(rowsAfter))
	}
}

func TestWorkloadVolumes_RejectsTraversal(t *testing.T) {
	e := newAPITestEnv(t)
	id := seedWorkload(t, e, "no-traversal")

	cases := []struct {
		name string
		body map[string]any
	}{
		{"target with ..", map[string]any{"source": "/srv/data", "target": "/data/../etc", "scope": "absolute"}},
		{"source with ..", map[string]any{"source": "/srv/../etc/shadow", "target": "/d", "scope": "absolute"}},
		{"target not absolute", map[string]any{"source": "/srv/data", "target": "data", "scope": "absolute"}},
		{"target empty", map[string]any{"source": "/srv/data", "target": "", "scope": "absolute"}},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			resp := e.do(t, http.MethodPut, "/api/workloads/"+id+"/volumes", tc.body)
			defer resp.Body.Close()
			if resp.StatusCode != http.StatusBadRequest {
				body, _ := io.ReadAll(resp.Body)
				t.Fatalf("status = %d, want 400 (body=%s)", resp.StatusCode, string(body))
			}
		})
	}
}

// =============================================================================
// GET /api/workloads/{id}/chain
// =============================================================================

func TestGetWorkloadChain_ParentSelfChildren(t *testing.T) {
	e := newAPITestEnv(t)
	parentID := seedWorkload(t, e, "parent")
	childID := seedWorkloadWithParent(t, e, "child", parentID)

	resp := e.do(t, http.MethodGet, "/api/workloads/"+parentID+"/chain", nil)
	var got struct {
		Parent   *map[string]any   `json:"parent"`
		Self     map[string]any    `json:"self"`
		Children []map[string]any  `json:"children"`
	}
	if errMsg := decodeEnvelope(t, resp, &got); errMsg != "" {
		t.Fatalf("envelope error: %q", errMsg)
	}
	if got.Parent != nil {
		t.Fatalf("parent should be nil for root, got %+v", *got.Parent)
	}
	if got.Self["id"] != parentID {
		t.Fatalf("self.id = %v, want %s", got.Self["id"], parentID)
	}
	if len(got.Children) != 1 || got.Children[0]["id"] != childID {
		t.Fatalf("children = %+v", got.Children)
	}
}

// =============================================================================
// POST /api/workloads/{id}/promote-from/{sourceID}
// =============================================================================

func TestPromoteFrom_CopiesRunningTagToTarget(t *testing.T) {
	e := newAPITestEnv(t)
	sourceID := seedWorkload(t, e, "stage-prod")
	targetID := seedWorkload(t, e, "stage-staging")

	// Seed a "running" container with an image_tag on the source workload
	// so the promote endpoint has something to copy.
	if err := e.store.UpsertContainer(store.Container{
		ID:         sourceID + ":web",
		WorkloadID: sourceID,
		Role:       "web",
		ImageTag:   "v9.9.9",
		State:      "running",
		LastSeenAt: store.Now(),
	}); err != nil {
		t.Fatalf("seed container: %v", err)
	}

	resp := e.do(t, http.MethodPost,
		fmt.Sprintf("/api/workloads/%s/promote-from/%s", targetID, sourceID),
		map[string]any{},
	)
	if resp.StatusCode != http.StatusOK {
		body, _ := io.ReadAll(resp.Body)
		t.Fatalf("status = %d (body=%s)", resp.StatusCode, string(body))
	}
	resp.Body.Close()

	// Target workload's source_config.default_tag must now equal v9.9.9.
	row, _ := e.store.GetWorkloadByID(targetID)
	var cfg map[string]any
	if err := json.Unmarshal([]byte(row.SourceConfig), &cfg); err != nil {
		t.Fatalf("decode target source_config: %v", err)
	}
	if cfg["default_tag"] != "v9.9.9" {
		t.Fatalf("default_tag = %v, want v9.9.9", cfg["default_tag"])
	}
}

// =============================================================================
// /api/workloads/{id}/triggers — list + inline create+bind
// =============================================================================

func TestListBindingsForWorkload_EmptyByDefault(t *testing.T) {
	e := newAPITestEnv(t)
	id := seedWorkload(t, e, "no-bindings")

	resp := e.do(t, http.MethodGet, "/api/workloads/"+id+"/triggers", nil)
	var rows []map[string]any
	_ = decodeEnvelope(t, resp, &rows)
	if len(rows) != 0 {
		t.Fatalf("expected 0 bindings, got %d", len(rows))
	}
}

func TestBindTriggerToWorkload_InlineManualTrigger(t *testing.T) {
	e := newAPITestEnv(t)
	id := seedWorkload(t, e, "inline-bind")

	body := map[string]any{
		"binding_config": json.RawMessage(`{}`),
		"inline": map[string]any{
			"kind":   "manual",
			"name":   "manual-trigger-for-inline",
			"config": json.RawMessage(`{}`),
		},
	}
	resp := e.do(t, http.MethodPost, "/api/workloads/"+id+"/triggers", body)
	if resp.StatusCode != http.StatusCreated {
		body, _ := io.ReadAll(resp.Body)
		t.Fatalf("status = %d (body=%s)", resp.StatusCode, string(body))
	}
	resp.Body.Close()

	// Verify a binding row exists for this workload.
	bindings, err := e.store.ListBindingsForWorkloadWithNames(id)
	if err != nil {
		t.Fatalf("list bindings: %v", err)
	}
	if len(bindings) != 1 {
		t.Fatalf("expected 1 binding, got %d", len(bindings))
	}
	if bindings[0].TriggerKind != "manual" {
		t.Fatalf("expected manual trigger, got %q", bindings[0].TriggerKind)
	}
}

func TestBindTriggerToWorkload_RequiresEitherTriggerIDOrInline(t *testing.T) {
	e := newAPITestEnv(t)
	id := seedWorkload(t, e, "bind-validation")

	resp := e.do(t, http.MethodPost, "/api/workloads/"+id+"/triggers", map[string]any{
		"binding_config": json.RawMessage(`{}`),
	})
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusBadRequest {
		t.Fatalf("status = %d, want 400", resp.StatusCode)
	}
}

// =============================================================================
// Standalone /api/triggers CRUD
// =============================================================================

func TestCreateTrigger_HappyPath(t *testing.T) {
	e := newAPITestEnv(t)
	body := map[string]any{
		"kind":            "manual",
		"name":            "standalone-manual",
		"config":          json.RawMessage(`{}`),
		"webhook_enabled": false,
	}
	resp := e.do(t, http.MethodPost, "/api/triggers", body)
	if resp.StatusCode != http.StatusCreated {
		raw, _ := io.ReadAll(resp.Body)
		t.Fatalf("status = %d (body=%s)", resp.StatusCode, string(raw))
	}
	var got map[string]any
	if errMsg := decodeEnvelope(t, resp, &got); errMsg != "" {
		t.Fatalf("envelope error %q", errMsg)
	}
	if got["kind"] != "manual" || got["name"] != "standalone-manual" {
		t.Fatalf("wrong shape: %v", got)
	}
	// Sanity: the row landed in the store.
	if _, err := e.store.GetTriggerByName("standalone-manual"); err != nil {
		t.Fatalf("trigger missing from store: %v", err)
	}
}

func TestCreateTrigger_DuplicateNameReturns409(t *testing.T) {
	e := newAPITestEnv(t)
	body := map[string]any{
		"kind":   "manual",
		"name":   "dup-trigger",
		"config": json.RawMessage(`{}`),
	}
	if r := e.do(t, http.MethodPost, "/api/triggers", body); r.StatusCode != http.StatusCreated {
		r.Body.Close()
		t.Fatalf("first create status = %d", r.StatusCode)
	}
	r2 := e.do(t, http.MethodPost, "/api/triggers", body)
	defer r2.Body.Close()
	if r2.StatusCode != http.StatusConflict {
		t.Fatalf("dup status = %d, want 409", r2.StatusCode)
	}
}

func TestListTriggers_PopulatedKindFilter(t *testing.T) {
	e := newAPITestEnv(t)
	mkBody := func(kind, name string) map[string]any {
		cfg := json.RawMessage(`{}`)
		switch kind {
		case "registry":
			cfg = json.RawMessage(`{"image":"registry.example.com/o/a","tag_pattern":"*"}`)
		case "git":
			cfg = json.RawMessage(`{"repo":"o/r","mode":"push","branch":"main"}`)
		}
		return map[string]any{"kind": kind, "name": name, "config": cfg}
	}
	for _, kn := range []struct{ kind, name string }{
		{"manual", "m1"}, {"manual", "m2"}, {"registry", "r1"}, {"git", "g1"},
	} {
		if r := e.do(t, http.MethodPost, "/api/triggers", mkBody(kn.kind, kn.name)); r.StatusCode != http.StatusCreated {
			raw, _ := io.ReadAll(r.Body)
			t.Fatalf("seed %s/%s: status %d (%s)", kn.kind, kn.name, r.StatusCode, raw)
		} else {
			r.Body.Close()
		}
	}
	resp := e.do(t, http.MethodGet, "/api/triggers", nil)
	var all []map[string]any
	_ = decodeEnvelope(t, resp, &all)
	if len(all) != 4 {
		t.Fatalf("all triggers = %d, want 4", len(all))
	}
	r2 := e.do(t, http.MethodGet, "/api/triggers?kind=manual", nil)
	var manuals []map[string]any
	_ = decodeEnvelope(t, r2, &manuals)
	if len(manuals) != 2 {
		t.Fatalf("manual triggers = %d, want 2", len(manuals))
	}
	for _, row := range manuals {
		if row["kind"] != "manual" {
			t.Fatalf("kind filter leaked: %v", row)
		}
	}
}

func TestDeleteTrigger_RemovesRow(t *testing.T) {
	e := newAPITestEnv(t)
	createResp := e.do(t, http.MethodPost, "/api/triggers", map[string]any{
		"kind": "manual", "name": "del-me", "config": json.RawMessage(`{}`),
	})
	var created map[string]any
	_ = decodeEnvelope(t, createResp, &created)
	id, _ := created["id"].(string)
	if id == "" {
		t.Fatal("no id in create response")
	}
	r2 := e.do(t, http.MethodDelete, "/api/triggers/"+id, nil)
	r2.Body.Close()
	if r2.StatusCode != http.StatusOK {
		t.Fatalf("delete status = %d", r2.StatusCode)
	}
	if _, err := e.store.GetTriggerByID(id); err == nil {
		t.Fatal("trigger still in store after delete")
	}
}

// =============================================================================
// Auth gating
// =============================================================================

func TestAdminOnlyRoutes_RejectViewerToken(t *testing.T) {
	e := newAPITestEnv(t)

	// Mint a viewer token using a fresh LocalAuth bound to the same key.
	la := auth.NewLocalAuth(e.encKey)
	tok, err := la.GenerateToken(auth.Claims{
		UserID: "u-viewer", Username: "viewer", Role: "viewer",
	})
	if err != nil {
		t.Fatalf("mint viewer token: %v", err)
	}
	body := pluginWorkloadRequest{
		Name: "x", SourceKind: "image", SourceConfig: validImageSourceConfig(),
	}
	b, _ := json.Marshal(body)
	req, _ := http.NewRequest(http.MethodPost, e.srv.URL+"/api/workloads", bytes.NewReader(b))
	req.Header.Set("Authorization", "Bearer "+tok.Token)
	req.Header.Set("Content-Type", "application/json")
	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		t.Fatalf("do: %v", err)
	}
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusForbidden {
		t.Fatalf("viewer status = %d, want 403", resp.StatusCode)
	}
}

func TestUnauthenticatedRoutes_RejectMissingToken(t *testing.T) {
	e := newAPITestEnv(t)
	req, _ := http.NewRequest(http.MethodGet, e.srv.URL+"/api/workloads", nil)
	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		t.Fatalf("do: %v", err)
	}
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusUnauthorized {
		t.Fatalf("status = %d, want 401", resp.StatusCode)
	}
}

// =============================================================================
// Test-only fixtures
// =============================================================================

// seedWorkload creates a minimal valid image-source workload via the
// real POST /api/workloads handler and returns its ID. Going through
// the handler exercises the same validation + ref_id self-reference
// path that production callers hit.
func seedWorkload(t *testing.T, e *apiTestEnv, name string) string {
	t.Helper()
	body := pluginWorkloadRequest{
		Name:         name,
		SourceKind:   "image",
		SourceConfig: validImageSourceConfig(),
	}
	resp := e.do(t, http.MethodPost, "/api/workloads", body)
	if resp.StatusCode != http.StatusCreated {
		_ = decodeEnvelope(t, resp, nil)
		t.Fatalf("seedWorkload(%s): status = %d", name, resp.StatusCode)
	}
	var got plugin.Workload
	if errMsg := decodeEnvelope(t, resp, &got); errMsg != "" {
		t.Fatalf("seedWorkload(%s): envelope error %q", name, errMsg)
	}
	return got.ID
}

func seedWorkloadWithParent(t *testing.T, e *apiTestEnv, name, parentID string) string {
	t.Helper()
	body := pluginWorkloadRequest{
		Name:             name,
		ParentWorkloadID: parentID,
		SourceKind:       "image",
		SourceConfig:     validImageSourceConfig(),
	}
	resp := e.do(t, http.MethodPost, "/api/workloads", body)
	if resp.StatusCode != http.StatusCreated {
		_ = decodeEnvelope(t, resp, nil)
		t.Fatalf("seedWorkloadWithParent(%s): status = %d", name, resp.StatusCode)
	}
	var got plugin.Workload
	if errMsg := decodeEnvelope(t, resp, &got); errMsg != "" {
		t.Fatalf("seedWorkloadWithParent(%s): envelope error %q", name, errMsg)
	}
	return got.ID
}
