package http

import (
	"context"
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"net/http"
	"net/http/httptest"
	"os"
	"strings"
	"sync"
	"testing"

	"github.com/nomadcode/nomadcode-core/internal/storage"
	"github.com/nomadcode/nomadcode-core/internal/workitempipeline"
)

func TestReceivePlaneWebhookAcceptsSignedPayload(t *testing.T) {
	h := newHandlerForTest()
	h.SetPlaneWebhookSecret("secret")

	body := `{"event": "issue", "action": "update", "webhook_id": "wh-1", "workspace_id": "ws-1", "data": {"id": "item-1"}, "activity": {"field": "assignees"}}`
	req := httptest.NewRequest(http.MethodPost, "/api/integrations/plane/webhook", strings.NewReader(body))
	req.Header.Set("X-Plane-Signature", testPlaneSignature("secret", body))
	req.Header.Set("X-Plane-Delivery", "delivery-1")
	req.Header.Set("X-Plane-Event", "issue")
	rec := httptest.NewRecorder()

	h.ReceivePlaneWebhook(rec, req)

	if rec.Code != http.StatusAccepted {
		t.Fatalf("expected %d, got %d: %s", http.StatusAccepted, rec.Code, rec.Body.String())
	}
}

func TestReceivePlaneWebhookAcceptsRedactedPlaneFixture(t *testing.T) {
	fixtures := []string{
		"testdata/plane_webhook_issue_assignees_updated.json",
		"testdata/plane_webhook_issue_created.json",
		"testdata/plane_webhook_issue_state_updated.json",
	}

	for _, fixture := range fixtures {
		t.Run(fixture, func(t *testing.T) {
			h := newHandlerForTest()
			h.SetPlaneWebhookSecret("secret")

			body, err := os.ReadFile(fixture)
			if err != nil {
				t.Fatalf("read fixture: %v", err)
			}
			req := httptest.NewRequest(http.MethodPost, "/api/integrations/plane/webhook", strings.NewReader(string(body)))
			req.Header.Set("X-Plane-Signature", testPlaneSignature("secret", string(body)))
			req.Header.Set("X-Plane-Delivery", "fixture-delivery")
			req.Header.Set("X-Plane-Event", "issue")
			rec := httptest.NewRecorder()

			h.ReceivePlaneWebhook(rec, req)

			if rec.Code != http.StatusAccepted {
				t.Fatalf("expected %d, got %d: %s", http.StatusAccepted, rec.Code, rec.Body.String())
			}
		})
	}
}

func TestReceivePlaneWebhookRejectsBadSignature(t *testing.T) {
	h := newHandlerForTest()
	h.SetPlaneWebhookSecret("secret")

	req := httptest.NewRequest(http.MethodPost, "/api/integrations/plane/webhook", strings.NewReader(`{"event":"issue"}`))
	req.Header.Set("X-Plane-Signature", testPlaneSignature("wrong-secret", `{"event":"issue"}`))
	rec := httptest.NewRecorder()

	h.ReceivePlaneWebhook(rec, req)

	if rec.Code != http.StatusUnauthorized {
		t.Fatalf("expected %d, got %d: %s", http.StatusUnauthorized, rec.Code, rec.Body.String())
	}
}

func TestPlaneWebhookRouteBypassesBasicAuth(t *testing.T) {
	h := newHandlerForTest()
	h.SetPlaneWebhookSecret("secret")

	body := `{"event": "issue", "action": "update"}`
	req := httptest.NewRequest(http.MethodPost, "/api/integrations/plane/webhook", strings.NewReader(body))
	req.Header.Set("X-Plane-Signature", testPlaneSignature("secret", body))
	rec := httptest.NewRecorder()

	router := NewRouter(
		h,
		nil,
		AuthConfig{Username: "nomadcode", Password: "required"},
		nil,
		"",
	)
	router.ServeHTTP(rec, req)

	if rec.Code != http.StatusAccepted {
		t.Fatalf("expected webhook route to bypass basic auth, got %d: %s", rec.Code, rec.Body.String())
	}
}

func TestNormalizePlaneWebhookPayloadExtractsRawIdentity(t *testing.T) {
	payload := planeWebhookPayload{
		Event:       "issue",
		Action:      "updated",
		WorkspaceID: "ws-1",
		Data:        json.RawMessage(`{"id":"item-1","project":"proj-1","workspace":"ws-data"}`),
	}
	payload.Activity.Field = "assignees"
	payload.Activity.Actor.ID = "actor-1"

	event, ok, err := normalizePlaneWebhookPayload(payload)
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if !ok {
		t.Fatalf("expected relevant work item event")
	}
	if event.WorkItemID != "item-1" {
		t.Fatalf("expected work item id item-1, got %q", event.WorkItemID)
	}
	if event.ProjectID != "proj-1" {
		t.Fatalf("expected project proj-1, got %q", event.ProjectID)
	}
	// data.workspace (raw UUID/id) takes precedence over the top-level
	// workspace_id and is preserved raw, not as a dispatch-ready tenant.
	if event.WorkspaceID != "ws-data" {
		t.Fatalf("expected workspace id ws-data, got %q", event.WorkspaceID)
	}
	if event.ActorID != "actor-1" {
		t.Fatalf("expected actor actor-1, got %q", event.ActorID)
	}
	if event.ChangeField != "assignees" {
		t.Fatalf("expected change field assignees, got %q", event.ChangeField)
	}
	if event.Action != "updated" {
		t.Fatalf("expected action updated, got %q", event.Action)
	}
}

func TestNormalizePlaneWebhookPayloadFallsBackToWorkspaceID(t *testing.T) {
	payload := planeWebhookPayload{
		Event:       "issue",
		WorkspaceID: "ws-top",
		Data:        json.RawMessage(`{"id":"item-1","project":"proj-1"}`),
	}

	event, ok, err := normalizePlaneWebhookPayload(payload)
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if !ok {
		t.Fatalf("expected relevant work item event")
	}
	if event.WorkspaceID != "ws-top" {
		t.Fatalf("expected workspace id ws-top from workspace_id fallback, got %q", event.WorkspaceID)
	}
}

func TestPlaneWebhookWorkItemEventBuildsRefWithWorkspaceSlug(t *testing.T) {
	event := planeWebhookWorkItemEvent{
		WorkspaceID: "dadf050e-cd1e-4590-bc33-672511630841",
		ProjectID:   "a6beb42f-7a8a-410c-b50f-ea3ca94828f3",
		WorkItemID:  "954c1887-48d6-4626-9ac6-34e323392ed1",
	}

	ref, err := event.WorkItemRef("general")
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if ref.Provider != planeProvider {
		t.Fatalf("expected provider %q, got %q", planeProvider, ref.Provider)
	}
	// The dispatch-ready tenant is the resolved workspace slug, not the raw
	// workspace UUID from the webhook payload.
	if ref.Tenant != "general" {
		t.Fatalf("expected tenant general (slug), got %q", ref.Tenant)
	}
	if ref.Project != event.ProjectID {
		t.Fatalf("expected project %q, got %q", event.ProjectID, ref.Project)
	}
	if ref.ID != event.WorkItemID {
		t.Fatalf("expected id %q, got %q", event.WorkItemID, ref.ID)
	}
}

func TestPlaneWebhookWorkItemEventRefRejectsBlankSlugOrIdentity(t *testing.T) {
	full := planeWebhookWorkItemEvent{
		WorkspaceID: "ws-id",
		ProjectID:   "proj-1",
		WorkItemID:  "item-1",
	}
	cases := map[string]struct {
		event planeWebhookWorkItemEvent
		slug  string
	}{
		"blank slug":      {full, "   "},
		"missing project": {planeWebhookWorkItemEvent{WorkspaceID: "ws-id", WorkItemID: "item-1"}, "general"},
		"missing id":      {planeWebhookWorkItemEvent{WorkspaceID: "ws-id", ProjectID: "proj-1"}, "general"},
	}

	for name, tc := range cases {
		t.Run(name, func(t *testing.T) {
			if _, err := tc.event.WorkItemRef(tc.slug); err == nil {
				t.Fatalf("expected error for %s", name)
			}
		})
	}
}

func TestNormalizePlaneWebhookPayloadIgnoresIrrelevantEvent(t *testing.T) {
	for _, event := range []string{"project", "cycle", "module", ""} {
		t.Run(event, func(t *testing.T) {
			payload := planeWebhookPayload{
				Event: event,
				Data:  json.RawMessage(`{"id":"item-1","project":"proj-1","workspace":"ws-1"}`),
			}

			got, ok, err := normalizePlaneWebhookPayload(payload)
			if err != nil {
				t.Fatalf("unexpected error: %v", err)
			}
			if ok {
				t.Fatalf("expected irrelevant event to be ignored")
			}
			if got.WorkItemID != "" || got.WorkspaceID != "" || got.ActorID != "" || got.ChangeField != "" {
				t.Fatalf("expected zero event for irrelevant event, got %+v", got)
			}
		})
	}
}

func TestNormalizePlaneWebhookPayloadRejectsMissingWorkItemID(t *testing.T) {
	cases := map[string]json.RawMessage{
		"missing id":     json.RawMessage(`{"project":"proj-1","workspace":"ws-1"}`),
		"empty data":     json.RawMessage(`{}`),
		"null data":      json.RawMessage(`null`),
		"blank id":       json.RawMessage(`{"id":"  ","project":"proj-1"}`),
		"missing entire": nil,
	}

	for name, data := range cases {
		t.Run(name, func(t *testing.T) {
			payload := planeWebhookPayload{Event: "issue", Data: data}

			_, ok, err := normalizePlaneWebhookPayload(payload)
			if !ok {
				t.Fatalf("expected event to be relevant even when ref is missing")
			}
			if err == nil {
				t.Fatalf("expected error for missing work item id")
			}
		})
	}
}

func TestNormalizePlaneWebhookPayloadRejectsMissingProject(t *testing.T) {
	cases := map[string]planeWebhookPayload{
		"no project key": {
			Event: "issue",
			Data:  json.RawMessage(`{"id":"item-1","workspace":"ws-1"}`),
		},
		"blank project": {
			Event: "issue",
			Data:  json.RawMessage(`{"id":"item-1","project":"  ","workspace":"ws-1"}`),
		},
	}

	for name, payload := range cases {
		t.Run(name, func(t *testing.T) {
			_, ok, err := normalizePlaneWebhookPayload(payload)
			if !ok {
				t.Fatalf("expected event to be relevant even when project is missing")
			}
			if err == nil {
				t.Fatalf("expected error for missing project so dispatch never gets an empty project key")
			}
		})
	}
}

func TestNormalizePlaneWebhookPayloadRejectsMissingWorkspace(t *testing.T) {
	cases := map[string]planeWebhookPayload{
		"no workspace anywhere": {
			Event: "issue",
			Data:  json.RawMessage(`{"id":"item-1","project":"proj-1"}`),
		},
		"blank workspace data and id": {
			Event:       "issue",
			WorkspaceID: "  ",
			Data:        json.RawMessage(`{"id":"item-1","project":"proj-1","workspace":"  "}`),
		},
	}

	for name, payload := range cases {
		t.Run(name, func(t *testing.T) {
			_, ok, err := normalizePlaneWebhookPayload(payload)
			if !ok {
				t.Fatalf("expected event to be relevant even when workspace/tenant is missing")
			}
			if err == nil {
				t.Fatalf("expected error for missing workspace/tenant so dispatch never gets an empty tenant key")
			}
		})
	}
}

func TestNormalizePlaneWebhookPayloadAcceptsAssigneeFixture(t *testing.T) {
	body, err := os.ReadFile("testdata/plane_webhook_issue_assignees_updated.json")
	if err != nil {
		t.Fatalf("read fixture: %v", err)
	}
	var payload planeWebhookPayload
	if err := json.Unmarshal(body, &payload); err != nil {
		t.Fatalf("unmarshal fixture: %v", err)
	}

	event, ok, err := normalizePlaneWebhookPayload(payload)
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if !ok {
		t.Fatalf("expected relevant work item event")
	}
	if event.WorkItemID != "954c1887-48d6-4626-9ac6-34e323392ed1" {
		t.Fatalf("unexpected work item id: %q", event.WorkItemID)
	}
	if event.ProjectID != "a6beb42f-7a8a-410c-b50f-ea3ca94828f3" {
		t.Fatalf("unexpected project: %q", event.ProjectID)
	}
	// The fixture workspace is a raw Plane workspace UUID, preserved raw and not
	// treated as a dispatch-ready tenant.
	if event.WorkspaceID != "dadf050e-cd1e-4590-bc33-672511630841" {
		t.Fatalf("unexpected workspace id: %q", event.WorkspaceID)
	}
	if event.ChangeField != "assignees" {
		t.Fatalf("expected change field assignees, got %q", event.ChangeField)
	}

	// A dispatch-ready ref exists only once a workspace slug is resolved; the
	// fixture workspace UUID alone is not the tenant the Plane adapter consumes.
	ref, err := event.WorkItemRef("general")
	if err != nil {
		t.Fatalf("build ref with slug: %v", err)
	}
	if ref.Tenant != "general" || ref.Project != event.ProjectID || ref.ID != event.WorkItemID {
		t.Fatalf("unexpected dispatch ref tuple: %+v", ref)
	}
}

func planeDispatchTestConfig() PlaneWebhookDispatchConfig {
	return PlaneWebhookDispatchConfig{
		WorkspaceID:     "ws-uuid",
		WorkspaceSlug:   "general",
		BacklogStateID:  "backlog-state",
		AgentAssigneeID: "agent-assignee",
		SelfActorID:     "self-actor",
	}
}

func signedPlaneWebhookRequest(body string) (*http.Request, *httptest.ResponseRecorder) {
	req := httptest.NewRequest(http.MethodPost, "/api/integrations/plane/webhook", strings.NewReader(body))
	req.Header.Set("X-Plane-Signature", testPlaneSignature("secret", body))
	req.Header.Set("X-Plane-Delivery", "delivery-1")
	req.Header.Set("X-Plane-Event", "issue")
	return req, httptest.NewRecorder()
}

func decodeWebhookResponse(t *testing.T, rec *httptest.ResponseRecorder) map[string]string {
	t.Helper()
	var resp map[string]string
	if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
		t.Fatalf("decode response: %v: %s", err, rec.Body.String())
	}
	return resp
}

func TestReceivePlaneWebhookDispatchesMatchingIssueEvent(t *testing.T) {
	creator := &fakeWorkItemTaskCreator{task: storage.Task{ID: "task-1", Status: "created"}}
	h := newHandlerForTest(withPlaneCreator(creator))
	h.SetPlaneWebhookSecret("secret")
	h.SetPlaneWebhookDispatchConfig(planeDispatchTestConfig())

	body := `{"event":"issue","action":"updated","webhook_id":"wh-1","workspace_id":"ws-uuid","data":{"id":"item-1","project":"proj-1","workspace":"ws-uuid"},"activity":{"field":"assignees","actor":{"id":"actor-1"}}}`
	req, rec := signedPlaneWebhookRequest(body)

	h.ReceivePlaneWebhook(rec, req)

	if rec.Code != http.StatusAccepted {
		t.Fatalf("expected %d, got %d: %s", http.StatusAccepted, rec.Code, rec.Body.String())
	}
	if creator.calledWith == nil {
		t.Fatalf("expected creator to be called for a matching event")
	}
	got := creator.calledWith
	if got.Ref.Provider != planeProvider {
		t.Fatalf("ref provider: got %q, want %q", got.Ref.Provider, planeProvider)
	}
	// The webhook carries a workspace UUID; the dispatch-ready tenant is the
	// configured workspace slug, not the raw UUID.
	if got.Ref.Tenant != "general" {
		t.Fatalf("ref tenant: got %q, want general (slug)", got.Ref.Tenant)
	}
	if got.Ref.Project != "proj-1" || got.Ref.ID != "item-1" {
		t.Fatalf("unexpected dispatch ref: %+v", got.Ref)
	}
	if got.Trigger.RequiredStateID != "backlog-state" || got.Trigger.RequiredAssigneeID != "agent-assignee" {
		t.Fatalf("unexpected trigger gate: %+v", got.Trigger)
	}
	if got.Trigger.Actor != "actor-1" || got.Trigger.SelfActor != "self-actor" {
		t.Fatalf("unexpected actor/self actor: %+v", got.Trigger)
	}

	resp := decodeWebhookResponse(t, rec)
	if resp["status"] != "accepted" || resp["task_id"] != "task-1" {
		t.Fatalf("unexpected response body: %v", resp)
	}
}

func TestReceivePlaneWebhookAutoEnqueuesPendingTask(t *testing.T) {
	creator := &fakeWorkItemTaskCreator{task: storage.Task{ID: "task-1", Status: "pending"}}
	enqueuer := &fakeWebhookEnqueuer{task: storage.Task{ID: "task-1", Status: "queued"}}
	h := newHandlerForTest(withPlaneCreator(creator), withWebhookEnqueuer(enqueuer))
	h.SetPlaneWebhookSecret("secret")
	h.SetPlaneWebhookDispatchConfig(planeDispatchTestConfig())

	body := `{"event":"issue","action":"updated","webhook_id":"wh-1","workspace_id":"ws-uuid","data":{"id":"item-1","project":"proj-1","workspace":"ws-uuid"},"activity":{"field":"assignees","actor":{"id":"actor-1"}}}`
	req, rec := signedPlaneWebhookRequest(body)

	h.ReceivePlaneWebhook(rec, req)

	if rec.Code != http.StatusAccepted {
		t.Fatalf("expected %d, got %d: %s", http.StatusAccepted, rec.Code, rec.Body.String())
	}
	if len(enqueuer.enqueued) != 1 || enqueuer.enqueued[0] != "task-1" {
		t.Fatalf("expected task-1 to be enqueued once, got %v", enqueuer.enqueued)
	}
	resp := decodeWebhookResponse(t, rec)
	if resp["status"] != "accepted" || resp["task_id"] != "task-1" {
		t.Fatalf("unexpected response body: %v", resp)
	}
}

func TestReceivePlaneWebhookDoesNotAutoEnqueueAlreadyQueuedTask(t *testing.T) {
	creator := &fakeWorkItemTaskCreator{task: storage.Task{ID: "task-1", Status: "queued"}}
	enqueuer := &fakeWebhookEnqueuer{task: storage.Task{ID: "task-1", Status: "queued"}}
	h := newHandlerForTest(withPlaneCreator(creator), withWebhookEnqueuer(enqueuer))
	h.SetPlaneWebhookSecret("secret")
	h.SetPlaneWebhookDispatchConfig(planeDispatchTestConfig())

	body := `{"event":"issue","action":"updated","webhook_id":"wh-1","workspace_id":"ws-uuid","data":{"id":"item-1","project":"proj-1","workspace":"ws-uuid"},"activity":{"field":"assignees","actor":{"id":"actor-1"}}}`
	req, rec := signedPlaneWebhookRequest(body)

	h.ReceivePlaneWebhook(rec, req)

	if rec.Code != http.StatusAccepted {
		t.Fatalf("expected %d, got %d: %s", http.StatusAccepted, rec.Code, rec.Body.String())
	}
	if len(enqueuer.enqueued) != 0 {
		t.Fatalf("expected no enqueue for queued duplicate, got %v", enqueuer.enqueued)
	}
}

func TestReceivePlaneWebhookIgnoresIrrelevantEventWithoutCreatorCall(t *testing.T) {
	creator := &fakeWorkItemTaskCreator{task: storage.Task{ID: "task-x"}}
	h := newHandlerForTest(withPlaneCreator(creator))
	h.SetPlaneWebhookSecret("secret")
	h.SetPlaneWebhookDispatchConfig(planeDispatchTestConfig())

	body := `{"event":"project","action":"updated","data":{"id":"proj-1"}}`
	req, rec := signedPlaneWebhookRequest(body)

	h.ReceivePlaneWebhook(rec, req)

	if rec.Code != http.StatusAccepted {
		t.Fatalf("expected %d, got %d: %s", http.StatusAccepted, rec.Code, rec.Body.String())
	}
	if creator.calledWith != nil {
		t.Fatalf("expected creator NOT to be called for an irrelevant event, got %+v", creator.calledWith)
	}
	if resp := decodeWebhookResponse(t, rec); resp["status"] != "ignored" {
		t.Fatalf("expected ignored status, got %v", resp)
	}
}

func TestReceivePlaneWebhookIgnoresForeignWorkspaceWithoutCreatorCall(t *testing.T) {
	creator := &fakeWorkItemTaskCreator{task: storage.Task{ID: "task-x"}}
	h := newHandlerForTest(withPlaneCreator(creator))
	h.SetPlaneWebhookSecret("secret")
	h.SetPlaneWebhookDispatchConfig(planeDispatchTestConfig())

	// workspace id does not match the configured guard: dispatch is skipped so a
	// foreign workspace never builds a ref against the wrong slug.
	body := `{"event":"issue","action":"updated","workspace_id":"other-ws","data":{"id":"item-1","project":"proj-1","workspace":"other-ws"},"activity":{"actor":{"id":"actor-1"}}}`
	req, rec := signedPlaneWebhookRequest(body)

	h.ReceivePlaneWebhook(rec, req)

	if rec.Code != http.StatusAccepted {
		t.Fatalf("expected %d, got %d: %s", http.StatusAccepted, rec.Code, rec.Body.String())
	}
	if creator.calledWith != nil {
		t.Fatalf("expected creator NOT to be called for a foreign workspace, got %+v", creator.calledWith)
	}
	if resp := decodeWebhookResponse(t, rec); resp["status"] != "ignored" {
		t.Fatalf("expected ignored status, got %v", resp)
	}
}

func TestReceivePlaneWebhookIgnoresWhenDispatchConfigAbsentWithoutCreatorCall(t *testing.T) {
	creator := &fakeWorkItemTaskCreator{task: storage.Task{ID: "task-x"}}
	h := newHandlerForTest(withPlaneCreator(creator))
	h.SetPlaneWebhookSecret("secret")
	// No SetPlaneWebhookDispatchConfig: receiver reachable but dispatch unconfigured.

	body := `{"event":"issue","action":"updated","workspace_id":"ws-uuid","data":{"id":"item-1","project":"proj-1","workspace":"ws-uuid"},"activity":{"actor":{"id":"actor-1"}}}`
	req, rec := signedPlaneWebhookRequest(body)

	h.ReceivePlaneWebhook(rec, req)

	if rec.Code != http.StatusAccepted {
		t.Fatalf("expected %d, got %d: %s", http.StatusAccepted, rec.Code, rec.Body.String())
	}
	if creator.calledWith != nil {
		t.Fatalf("expected creator NOT to be called when dispatch config is absent, got %+v", creator.calledWith)
	}
	if resp := decodeWebhookResponse(t, rec); resp["status"] != "ignored" {
		t.Fatalf("expected ignored status, got %v", resp)
	}
}

func TestReceivePlaneWebhookReturnsAcceptedWhenCreationTriggerIgnored(t *testing.T) {
	creator := &fakeWorkItemTaskCreator{err: workitempipeline.ErrCreationTriggerIgnored}
	h := newHandlerForTest(withPlaneCreator(creator))
	h.SetPlaneWebhookSecret("secret")
	h.SetPlaneWebhookDispatchConfig(planeDispatchTestConfig())

	body := `{"event":"issue","action":"updated","workspace_id":"ws-uuid","data":{"id":"item-1","project":"proj-1","workspace":"ws-uuid"},"activity":{"actor":{"id":"actor-1"}}}`
	req, rec := signedPlaneWebhookRequest(body)

	h.ReceivePlaneWebhook(rec, req)

	if rec.Code != http.StatusAccepted {
		t.Fatalf("expected %d, got %d: %s", http.StatusAccepted, rec.Code, rec.Body.String())
	}
	if creator.calledWith == nil {
		t.Fatalf("expected creator to be called so the pipeline can evaluate the trigger gate")
	}
	if resp := decodeWebhookResponse(t, rec); resp["status"] != "ignored" {
		t.Fatalf("expected ignored status when trigger gate rejects, got %v", resp)
	}
}

func testPlaneSignature(secret, body string) string {
	mac := hmac.New(sha256.New, []byte(secret))
	_, _ = mac.Write([]byte(body))
	return hex.EncodeToString(mac.Sum(nil))
}

// statefulWorkItemCreator simulates pipeline-level idempotency: the first call
// for a given provider/id creates a task and records it; subsequent calls for
// the same ref return the existing task without incrementing createCount.
type statefulWorkItemCreator struct {
	mu          sync.Mutex
	created     map[string]storage.Task
	createCount int
}

func (f *statefulWorkItemCreator) CreateTaskFromWorkItem(_ context.Context, input workitempipeline.CreateTaskInput) (storage.Task, error) {
	f.mu.Lock()
	defer f.mu.Unlock()
	key := string(input.Ref.Provider) + "\x00" + input.Ref.ID
	if existing, ok := f.created[key]; ok {
		return existing, nil
	}
	f.createCount++
	task := storage.Task{ID: "task-1", Status: "created"}
	if f.created == nil {
		f.created = make(map[string]storage.Task)
	}
	f.created[key] = task
	return task, nil
}

func TestReceivePlaneWebhookDuplicateSignedEvent(t *testing.T) {
	creator := &statefulWorkItemCreator{}
	h := newHandlerForTest(withPlaneCreator(creator))
	h.SetPlaneWebhookSecret("secret")
	h.SetPlaneWebhookDispatchConfig(planeDispatchTestConfig())

	body := `{"event":"issue","action":"updated","workspace_id":"ws-uuid","data":{"id":"item-1","project":"proj-1","workspace":"ws-uuid"},"activity":{"actor":{"id":"actor-1"}}}`

	// First call
	req1, rec1 := signedPlaneWebhookRequest(body)
	h.ReceivePlaneWebhook(rec1, req1)
	if rec1.Code != http.StatusAccepted {
		t.Fatalf("expected 202, got %d", rec1.Code)
	}
	resp1 := decodeWebhookResponse(t, rec1)
	if resp1["status"] != "accepted" || resp1["task_id"] != "task-1" {
		t.Fatalf("expected accepted status with task-1, got %v", resp1)
	}

	// Second call (duplicate webhook retry) — same task returned, no new creation.
	req2, rec2 := signedPlaneWebhookRequest(body)
	h.ReceivePlaneWebhook(rec2, req2)
	if rec2.Code != http.StatusAccepted {
		t.Fatalf("expected 202, got %d", rec2.Code)
	}
	resp2 := decodeWebhookResponse(t, rec2)
	if resp2["status"] != "accepted" || resp2["task_id"] != "task-1" {
		t.Fatalf("expected accepted status with task-1, got %v", resp2)
	}

	if creator.createCount != 1 {
		t.Errorf("expected exactly one task creation, got %d (duplicate side effect leaked)", creator.createCount)
	}
}

func TestReceivePlaneWebhookSelfActorIgnored(t *testing.T) {
	creator := &fakeWorkItemTaskCreator{
		task: storage.Task{ID: "task-1", Status: "pending"},
	}
	enqueuer := &fakeWebhookEnqueuer{task: storage.Task{ID: "task-1", Status: "queued"}}
	h := newHandlerForTest(withPlaneCreator(creator), withWebhookEnqueuer(enqueuer))
	h.SetPlaneWebhookSecret("secret")

	cfg := planeDispatchTestConfig()
	cfg.SelfActorID = "actor-self"
	h.SetPlaneWebhookDispatchConfig(cfg)

	body := `{"event":"issue","action":"updated","workspace_id":"ws-uuid","data":{"id":"item-1","project":"proj-1","workspace":"ws-uuid"},"activity":{"actor":{"id":"actor-self"}}}`
	req, rec := signedPlaneWebhookRequest(body)

	h.ReceivePlaneWebhook(rec, req)

	if rec.Code != http.StatusAccepted {
		t.Fatalf("expected %d, got %d: %s", http.StatusAccepted, rec.Code, rec.Body.String())
	}
	resp := decodeWebhookResponse(t, rec)
	if resp["status"] != "ignored" {
		t.Fatalf("expected ignored status, got %v", resp)
	}
	if creator.calledWith != nil {
		t.Fatalf("expected self actor webhook to stop before creator, got %+v", creator.calledWith)
	}
	if len(enqueuer.enqueued) != 0 {
		t.Fatalf("expected self actor webhook not to enqueue existing pending task, got %v", enqueuer.enqueued)
	}
}
