package workitempipeline

import (
	"context"
	"encoding/json"
	"errors"
	"sync"
	"testing"

	"github.com/jackc/pgx/v5"
	"github.com/nomadcode/nomadcode-core/internal/projectsync"
	"github.com/nomadcode/nomadcode-core/internal/storage"
	"github.com/nomadcode/nomadcode-core/internal/workflow"
	"github.com/nomadcode/nomadcode-core/internal/workitem"
)

type fakeReader struct {
	item        workitem.WorkItem
	err         error
	fetchCalled bool
}

func (f *fakeReader) FetchWorkItem(_ context.Context, _ workitem.Ref) (workitem.WorkItem, error) {
	f.fetchCalled = true
	return f.item, f.err
}

type fakeTaskCreator struct {
	task        storage.Task
	err         error
	calledWith  *workflow.CreateTaskInput
	getTaskTask storage.Task
	getTaskErr  error
	getCalled   bool
	getProvider string
	getID       string
}

func (f *fakeTaskCreator) CreateTask(_ context.Context, input workflow.CreateTaskInput) (storage.Task, error) {
	f.calledWith = &input
	return f.task, f.err
}

func (f *fakeTaskCreator) GetTaskByExternalRef(_ context.Context, provider, id string) (storage.Task, error) {
	f.getCalled = true
	f.getProvider = provider
	f.getID = id
	if f.getTaskErr == nil && f.getTaskTask.ID == "" {
		return storage.Task{}, pgx.ErrNoRows
	}
	return f.getTaskTask, f.getTaskErr
}

func (f *fakeTaskCreator) WithExternalRefLock(_ context.Context, _, _ string, fn func(context.Context) (storage.Task, error)) (storage.Task, error) {
	return fn(context.Background())
}

// fakeBinder resolves a single fixed binding and reserves a single fixed slot,
// recording what it was called with so tests can assert ordering and inputs.
type fakeBinder struct {
	binding       ProjectBinding
	resolveErr    error
	slot          projectsync.WorkspaceSlot
	reserveErr    error
	resolvedRef   *workitem.Ref
	reservedFor   *int64
	reserveCalled bool
	ensureErr     error
	ensureCalled  bool
	ensuredWith   *ProjectBinding
}

func (f *fakeBinder) ResolveProjectBinding(_ context.Context, ref workitem.Ref) (ProjectBinding, error) {
	f.resolvedRef = &ref
	return f.binding, f.resolveErr
}

func (f *fakeBinder) EnsureProjectWorkspace(_ context.Context, binding ProjectBinding) error {
	f.ensureCalled = true
	f.ensuredWith = &binding
	return f.ensureErr
}

func (f *fakeBinder) ReserveWorkspaceSlot(_ context.Context, id int64) (projectsync.WorkspaceSlot, error) {
	f.reserveCalled = true
	f.reservedFor = &id
	return f.slot, f.reserveErr
}

// mapBinder keys bindings on the provider/tenant/project target and slots on the
// setting ID so the binding tests can prove same-project sharing and
// cross-project isolation through the recorded task metadata.
type mapBinder struct {
	bindings map[projectsync.ProviderProjectTarget]ProjectBinding
	slots    map[int64]projectsync.WorkspaceSlot
}

func (m *mapBinder) ResolveProjectBinding(_ context.Context, ref workitem.Ref) (ProjectBinding, error) {
	target := projectsync.ProviderProjectTarget{Provider: ref.Provider, Tenant: ref.Tenant, Project: ref.Project}
	binding, ok := m.bindings[target]
	if !ok {
		return ProjectBinding{}, storage.ErrProjectSyncNotFound
	}
	return binding, nil
}

func (m *mapBinder) EnsureProjectWorkspace(_ context.Context, _ ProjectBinding) error {
	return nil
}

func (m *mapBinder) ReserveWorkspaceSlot(_ context.Context, id int64) (projectsync.WorkspaceSlot, error) {
	slot, ok := m.slots[id]
	if !ok {
		return projectsync.WorkspaceSlot{}, storage.ErrNoAvailableWorkspaceSlot
	}
	return slot, nil
}

func validProjectConfig() projectsync.Config {
	return projectsync.Config{
		Target: projectsync.ProviderProjectTarget{
			Provider: "plane",
			Tenant:   "acme",
			Project:  "proj-1",
		},
		GitRemoteURL:      "git@example.com:acme/nomadcode.git",
		SourceBranch:      "develop",
		WorkspaceID:       "ws-main",
		WorkspaceBasePath: "/home/user/workspace",
		RepoDirName:       "nomadcode",
	}
}

func validBinding() ProjectBinding {
	return ProjectBinding{SettingID: 5, Config: validProjectConfig()}
}

func reservedSlot() projectsync.WorkspaceSlot {
	return projectsync.WorkspaceSlot{
		ID:                   11,
		ProjectSyncSettingID: 5,
		Index:                projectsync.DefaultSlotIndex,
		State:                projectsync.SlotStateInUse,
		Path:                 "/home/user/workspace/nomadcode/slots/000",
	}
}

func decodeCheckout(t *testing.T, raw json.RawMessage) CheckoutMetadata {
	t.Helper()
	var wrap struct {
		Checkout CheckoutMetadata `json:"checkout"`
	}
	if err := json.Unmarshal(raw, &wrap); err != nil {
		t.Fatalf("checkout metadata unmarshal: %v (raw=%s)", err, string(raw))
	}
	return wrap.Checkout
}

func TestCreateTaskFromWorkItemFetchesMapsAndRecordsCheckout(t *testing.T) {
	ref := workitem.Ref{Provider: "plane", Tenant: "acme", Project: "proj-1", ID: "work-1", URL: "https://example.com/work-1"}
	reader := &fakeReader{item: workitem.WorkItem{
		Ref:   ref,
		Title: "Fix issue",
	}}
	creator := &fakeTaskCreator{task: storage.Task{ID: "task-abc", Status: "pending"}}
	binder := &fakeBinder{binding: validBinding(), slot: reservedSlot()}
	svc := New(reader, binder, creator)

	result, err := svc.CreateTaskFromWorkItem(context.Background(), CreateTaskInput{
		Ref:     ref,
		StateID: "state-1",
		Comment: "my comment",
	})
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if result.ID != "task-abc" {
		t.Errorf("id: got %q", result.ID)
	}
	if creator.calledWith == nil {
		t.Fatal("CreateTask was not called")
	}
	if creator.calledWith.Source != "plane" {
		t.Errorf("source: got %q", creator.calledWith.Source)
	}

	// The slot is reserved against the resolved binding's setting ID.
	if binder.reservedFor == nil || *binder.reservedFor != 5 {
		t.Errorf("reserve called with: %v, want 5", binder.reservedFor)
	}

	var payload map[string]any
	if err := json.Unmarshal(creator.calledWith.Payload, &payload); err != nil {
		t.Fatalf("payload unmarshal: %v", err)
	}
	if payload["message"] != "my comment" {
		t.Errorf("message: got %v", payload["message"])
	}
	wi, ok := payload["work_item"].(map[string]any)
	if !ok {
		t.Fatalf("work_item missing: %v", payload)
	}
	checks := map[string]string{
		"provider": "plane",
		"tenant":   "acme",
		"project":  "proj-1",
		"id":       "work-1",
	}
	for k, want := range checks {
		if wi[k] != want {
			t.Errorf("work_item[%q]: got %v, want %q", k, wi[k], want)
		}
	}

	if creator.calledWith.External == nil ||
		creator.calledWith.External.Provider != "plane" ||
		creator.calledWith.External.ID != "work-1" {
		t.Errorf("external ref: %v", creator.calledWith.External)
	}

	// The checkout binding is recorded on the task metadata: remote/branch from
	// config, slot id/index/path from the reserved slot and checkout plan,
	// branch paths from the provision plan.
	checkout := decodeCheckout(t, creator.calledWith.Metadata)
	want := CheckoutMetadata{
		ProjectSyncSettingID: 5,
		WorkspaceID:          "ws-main",
		GitRemoteURL:         "git@example.com:acme/nomadcode.git",
		SourceBranch:         "develop",
		ProjectWorkspaceRoot: "/home/user/workspace/nomadcode",
		MainBranchPath:       "/home/user/workspace/nomadcode/branches/main",
		DevelopBranchPath:    "/home/user/workspace/nomadcode/branches/develop",
		SlotID:               11,
		SlotIndex:            0,
		SlotPath:             "/home/user/workspace/nomadcode/slots/000",
	}
	if checkout != want {
		t.Errorf("checkout metadata:\n got %+v\nwant %+v", checkout, want)
	}
}

func TestCreateTaskFromWorkItemPropagatesReaderError(t *testing.T) {
	readerErr := errors.New("not found")
	reader := &fakeReader{err: readerErr}
	creator := &fakeTaskCreator{}
	binder := &fakeBinder{binding: validBinding(), slot: reservedSlot()}
	svc := New(reader, binder, creator)

	_, err := svc.CreateTaskFromWorkItem(context.Background(), CreateTaskInput{
		Ref: workitem.Ref{Provider: "plane", ID: "work-1"},
	})
	if !errors.Is(err, readerErr) {
		t.Errorf("expected reader error, got %v", err)
	}
	if creator.calledWith != nil {
		t.Error("CreateTask should not have been called")
	}
}

func TestCreateTaskFromWorkItemPropagatesCreateTaskError(t *testing.T) {
	ref := workitem.Ref{Provider: "plane", Tenant: "acme", Project: "proj-1", ID: "work-1"}
	reader := &fakeReader{item: workitem.WorkItem{Ref: ref, Title: "T"}}
	createErr := errors.New("db error")
	creator := &fakeTaskCreator{err: createErr}
	binder := &fakeBinder{binding: validBinding(), slot: reservedSlot()}
	svc := New(reader, binder, creator)

	_, err := svc.CreateTaskFromWorkItem(context.Background(), CreateTaskInput{Ref: ref})
	if !errors.Is(err, createErr) {
		t.Errorf("expected create error, got %v", err)
	}
}

func TestCreateTaskFromWorkItemRequiresBinder(t *testing.T) {
	ref := workitem.Ref{Provider: "plane", Tenant: "acme", Project: "proj-1", ID: "work-1"}
	reader := &fakeReader{item: workitem.WorkItem{Ref: ref, Title: "T"}}
	creator := &fakeTaskCreator{task: storage.Task{ID: "x"}}
	svc := New(reader, nil, creator)

	_, err := svc.CreateTaskFromWorkItem(context.Background(), CreateTaskInput{Ref: ref})
	if !errors.Is(err, ErrProjectSyncNotConfigured) {
		t.Errorf("expected ErrProjectSyncNotConfigured, got %v", err)
	}
	if creator.calledWith != nil {
		t.Error("CreateTask should not have been called without a binder")
	}
}

func TestCreateTaskFromWorkItemBlocksWhenProjectSyncMissing(t *testing.T) {
	ref := workitem.Ref{Provider: "plane", Tenant: "acme", Project: "proj-1", ID: "work-1"}
	reader := &fakeReader{item: workitem.WorkItem{Ref: ref, Title: "T"}}
	creator := &fakeTaskCreator{task: storage.Task{ID: "x"}}
	binder := &fakeBinder{resolveErr: storage.ErrProjectSyncNotFound}
	svc := New(reader, binder, creator)

	_, err := svc.CreateTaskFromWorkItem(context.Background(), CreateTaskInput{Ref: ref})
	if !errors.Is(err, storage.ErrProjectSyncNotFound) {
		t.Errorf("expected ErrProjectSyncNotFound, got %v", err)
	}
	if binder.reserveCalled {
		t.Error("slot reservation should not run when config is missing")
	}
	if reader.fetchCalled {
		t.Error("provider fetch should not run when config is missing")
	}
	if creator.calledWith != nil {
		t.Error("CreateTask should not run when project sync config is missing")
	}
}

func TestCreateTaskFromWorkItemRejectsInvalidProjectSyncConfig(t *testing.T) {
	bad := validBinding()
	bad.Config.GitRemoteURL = ""
	ref := workitem.Ref{Provider: "plane", Tenant: "acme", Project: "proj-1", ID: "work-1"}
	reader := &fakeReader{item: workitem.WorkItem{Ref: ref, Title: "T"}}
	creator := &fakeTaskCreator{task: storage.Task{ID: "x"}}
	binder := &fakeBinder{binding: bad, slot: reservedSlot()}
	svc := New(reader, binder, creator)

	_, err := svc.CreateTaskFromWorkItem(context.Background(), CreateTaskInput{Ref: ref})
	if !errors.Is(err, projectsync.ErrInvalidConfig) {
		t.Errorf("expected ErrInvalidConfig, got %v", err)
	}
	if binder.reserveCalled {
		t.Error("slot reservation should not run with an invalid binding")
	}
	if creator.calledWith != nil {
		t.Error("CreateTask should not run with an invalid binding")
	}
}

func TestCreateTaskFromWorkItemBlocksWhenNoSlotAvailable(t *testing.T) {
	ref := workitem.Ref{Provider: "plane", Tenant: "acme", Project: "proj-1", ID: "work-1"}
	reader := &fakeReader{item: workitem.WorkItem{Ref: ref, Title: "T"}}
	creator := &fakeTaskCreator{task: storage.Task{ID: "x"}}
	binder := &fakeBinder{binding: validBinding(), reserveErr: storage.ErrNoAvailableWorkspaceSlot}
	svc := New(reader, binder, creator)

	_, err := svc.CreateTaskFromWorkItem(context.Background(), CreateTaskInput{Ref: ref})
	if !errors.Is(err, storage.ErrNoAvailableWorkspaceSlot) {
		t.Errorf("expected ErrNoAvailableWorkspaceSlot, got %v", err)
	}
	if !reader.fetchCalled {
		t.Error("provider fetch should run before slot reservation")
	}
	if creator.calledWith != nil {
		t.Error("CreateTask should not run when no slot is available")
	}
}

func TestCreateTaskFromWorkItemResolvesUsingNormalizedTarget(t *testing.T) {
	binder := &fakeBinder{binding: validBinding(), slot: reservedSlot()}
	reader := &fakeReader{item: workitem.WorkItem{Title: "T"}}
	creator := &fakeTaskCreator{task: storage.Task{ID: "x"}}
	svc := New(reader, binder, creator)

	ref := workitem.Ref{Provider: "plane", Tenant: "acme", Project: "proj-1", ID: "work-1"}
	if _, err := svc.CreateTaskFromWorkItem(context.Background(), CreateTaskInput{Ref: ref}); err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if binder.resolvedRef == nil {
		t.Fatal("binder was not asked to resolve")
	}
	if binder.resolvedRef.Provider != "plane" || binder.resolvedRef.Tenant != "acme" || binder.resolvedRef.Project != "proj-1" {
		t.Errorf("binder received wrong target: %+v", *binder.resolvedRef)
	}
}

func TestCreateTaskFromWorkItemBindsConfigAndSlotPerProject(t *testing.T) {
	projectA := projectsync.ProviderProjectTarget{Provider: "plane", Tenant: "acme", Project: "proj-1"}
	projectB := projectsync.ProviderProjectTarget{Provider: "plane", Tenant: "acme", Project: "proj-2"}

	configA := validProjectConfig()
	configB := validProjectConfig()
	configB.Target.Project = "proj-2"
	configB.GitRemoteURL = "git@example.com:acme/other.git"

	binder := &mapBinder{
		bindings: map[projectsync.ProviderProjectTarget]ProjectBinding{
			projectA: {SettingID: 5, Config: configA},
			projectB: {SettingID: 7, Config: configB},
			// proj-3 intentionally has no binding and must not borrow another's.
		},
		slots: map[int64]projectsync.WorkspaceSlot{
			5: {ID: 11, ProjectSyncSettingID: 5, Index: projectsync.DefaultSlotIndex, State: projectsync.SlotStateInUse},
			7: {ID: 22, ProjectSyncSettingID: 7, Index: projectsync.SlotIndex(1), State: projectsync.SlotStateInUse},
		},
	}
	reader := &fakeReader{item: workitem.WorkItem{Title: "T"}}
	creator := &fakeTaskCreator{task: storage.Task{ID: "x"}}
	svc := New(reader, binder, creator)

	// Two tickets in the same project both resolve to the same config/remote.
	for _, id := range []string{"work-1", "work-2"} {
		creator.calledWith = nil
		ref := workitem.Ref{Provider: "plane", Tenant: "acme", Project: "proj-1", ID: id}
		if _, err := svc.CreateTaskFromWorkItem(context.Background(), CreateTaskInput{Ref: ref}); err != nil {
			t.Fatalf("same-project ticket %q failed: %v", id, err)
		}
		if creator.calledWith == nil {
			t.Fatalf("same-project ticket %q did not create a task", id)
		}
		checkout := decodeCheckout(t, creator.calledWith.Metadata)
		if checkout.ProjectSyncSettingID != 5 || checkout.GitRemoteURL != "git@example.com:acme/nomadcode.git" {
			t.Errorf("same-project ticket %q checkout: %+v", id, checkout)
		}
		if checkout.SlotID != 11 || checkout.SlotIndex != 0 || checkout.SlotPath != "/home/user/workspace/nomadcode/slots/000" {
			t.Errorf("same-project ticket %q slot: %+v", id, checkout)
		}
		// Branch paths must be present and must not overlap with the slot path.
		if checkout.MainBranchPath != "/home/user/workspace/nomadcode/branches/main" {
			t.Errorf("same-project ticket %q main branch path: got %q", id, checkout.MainBranchPath)
		}
		if checkout.DevelopBranchPath != "/home/user/workspace/nomadcode/branches/develop" {
			t.Errorf("same-project ticket %q develop branch path: got %q", id, checkout.DevelopBranchPath)
		}
		if checkout.SlotPath == checkout.MainBranchPath || checkout.SlotPath == checkout.DevelopBranchPath {
			t.Errorf("same-project ticket %q: slot path collides with branch path: %+v", id, checkout)
		}
	}

	// A different project resolves to its own config and an independent slot.
	creator.calledWith = nil
	refB := workitem.Ref{Provider: "plane", Tenant: "acme", Project: "proj-2", ID: "work-9"}
	if _, err := svc.CreateTaskFromWorkItem(context.Background(), CreateTaskInput{Ref: refB}); err != nil {
		t.Fatalf("different-project ticket failed: %v", err)
	}
	if creator.calledWith == nil {
		t.Fatal("different-project ticket did not create a task")
	}
	checkoutB := decodeCheckout(t, creator.calledWith.Metadata)
	if checkoutB.ProjectSyncSettingID != 7 || checkoutB.GitRemoteURL != "git@example.com:acme/other.git" {
		t.Errorf("different-project checkout not isolated: %+v", checkoutB)
	}
	if checkoutB.SlotID != 22 || checkoutB.SlotIndex != 1 || checkoutB.SlotPath != "/home/user/workspace/nomadcode/slots/001" {
		t.Errorf("different-project slot not independent: %+v", checkoutB)
	}
	// Branch paths must not overlap with the slot path for the different project.
	if checkoutB.SlotPath == checkoutB.MainBranchPath || checkoutB.SlotPath == checkoutB.DevelopBranchPath {
		t.Errorf("different-project: slot path collides with branch path: %+v", checkoutB)
	}

	// An unconfigured project is isolated and blocked.
	creator.calledWith = nil
	refC := workitem.Ref{Provider: "plane", Tenant: "acme", Project: "proj-3", ID: "work-x"}
	if _, err := svc.CreateTaskFromWorkItem(context.Background(), CreateTaskInput{Ref: refC}); !errors.Is(err, storage.ErrProjectSyncNotFound) {
		t.Fatalf("unconfigured project: expected ErrProjectSyncNotFound, got %v", err)
	}
	if creator.calledWith != nil {
		t.Error("unconfigured project should not create a task")
	}
}

func TestCreateTaskFromWorkItemBlocksWhenWorkspaceProvisionFails(t *testing.T) {
	ref := workitem.Ref{Provider: "plane", Tenant: "acme", Project: "proj-1", ID: "work-1"}
	reader := &fakeReader{item: workitem.WorkItem{Ref: ref, Title: "T"}}
	creator := &fakeTaskCreator{task: storage.Task{ID: "x"}}
	provisionErr := errors.New("provision error")
	binder := &fakeBinder{binding: validBinding(), slot: reservedSlot(), ensureErr: provisionErr}
	svc := New(reader, binder, creator)

	_, err := svc.CreateTaskFromWorkItem(context.Background(), CreateTaskInput{Ref: ref})
	if !errors.Is(err, provisionErr) {
		t.Errorf("expected provision error, got %v", err)
	}
	if binder.reserveCalled {
		t.Error("slot reservation should not run when workspace provision fails")
	}
	if !reader.fetchCalled {
		t.Error("provider fetch should run before workspace provision")
	}
	if creator.calledWith != nil {
		t.Error("CreateTask should not run when workspace provision fails")
	}
}

func TestCreateTaskFromWorkItemIgnoresNonMatchingTrigger(t *testing.T) {
	ref := workitem.Ref{Provider: "plane", Tenant: "acme", Project: "proj-1", ID: "work-1"}

	// Test case 1: Wrong state ID
	reader1 := &fakeReader{item: workitem.WorkItem{
		Ref:         ref,
		Title:       "Wrong State",
		State:       "state-other",
		AssigneeIDs: []string{"agent-1"},
	}}
	creator1 := &fakeTaskCreator{task: storage.Task{ID: "task-1"}}
	binder1 := &fakeBinder{binding: validBinding(), slot: reservedSlot()}
	svc1 := New(reader1, binder1, creator1)

	_, err1 := svc1.CreateTaskFromWorkItem(context.Background(), CreateTaskInput{
		Ref: ref,
		Trigger: CreationTrigger{
			RequiredStateID:    "state-backlog",
			RequiredAssigneeID: "agent-1",
		},
	})
	if !errors.Is(err1, ErrCreationTriggerIgnored) {
		t.Errorf("expected ErrCreationTriggerIgnored due to wrong state, got %v", err1)
	}
	if binder1.ensureCalled {
		t.Error("EnsureProjectWorkspace should not be called on ignored trigger")
	}
	if binder1.reserveCalled {
		t.Error("ReserveWorkspaceSlot should not be called on ignored trigger")
	}
	if creator1.calledWith != nil {
		t.Error("CreateTask should not be called on ignored trigger")
	}

	// Test case 2: Missing AGENT assignee
	reader2 := &fakeReader{item: workitem.WorkItem{
		Ref:         ref,
		Title:       "Missing Assignee",
		State:       "state-backlog",
		AssigneeIDs: []string{"user-1"},
	}}
	creator2 := &fakeTaskCreator{task: storage.Task{ID: "task-2"}}
	binder2 := &fakeBinder{binding: validBinding(), slot: reservedSlot()}
	svc2 := New(reader2, binder2, creator2)

	_, err2 := svc2.CreateTaskFromWorkItem(context.Background(), CreateTaskInput{
		Ref: ref,
		Trigger: CreationTrigger{
			RequiredStateID:    "state-backlog",
			RequiredAssigneeID: "agent-1",
		},
	})
	if !errors.Is(err2, ErrCreationTriggerIgnored) {
		t.Errorf("expected ErrCreationTriggerIgnored due to missing assignee, got %v", err2)
	}
	if binder2.ensureCalled {
		t.Error("EnsureProjectWorkspace should not be called on ignored trigger")
	}
	if binder2.reserveCalled {
		t.Error("ReserveWorkspaceSlot should not be called on ignored trigger")
	}
	if creator2.calledWith != nil {
		t.Error("CreateTask should not be called on ignored trigger")
	}
}

func TestCreateTaskFromWorkItemIgnoresSelfActor(t *testing.T) {
	ref := workitem.Ref{Provider: "plane", Tenant: "acme", Project: "proj-1", ID: "work-1"}

	// Test case 1: normal self actor matching
	reader1 := &fakeReader{item: workitem.WorkItem{
		Ref:         ref,
		Title:       "Self Mutation",
		State:       "state-backlog",
		AssigneeIDs: []string{"agent-1"},
	}}
	creator1 := &fakeTaskCreator{task: storage.Task{ID: "task-1"}}
	binder1 := &fakeBinder{binding: validBinding(), slot: reservedSlot()}
	svc1 := New(reader1, binder1, creator1)

	_, err1 := svc1.CreateTaskFromWorkItem(context.Background(), CreateTaskInput{
		Ref: ref,
		Trigger: CreationTrigger{
			RequiredStateID:    "state-backlog",
			RequiredAssigneeID: "agent-1",
			Actor:              "nomadcode-bot",
			SelfActor:          "nomadcode-bot",
		},
	})
	if !errors.Is(err1, ErrCreationTriggerIgnored) {
		t.Errorf("expected ErrCreationTriggerIgnored due to self actor, got %v", err1)
	}
	if binder1.ensureCalled {
		t.Error("EnsureProjectWorkspace should not be called on ignored trigger")
	}
	if binder1.reserveCalled {
		t.Error("ReserveWorkspaceSlot should not be called on ignored trigger")
	}
	if creator1.calledWith != nil {
		t.Error("CreateTask should not be called on ignored trigger")
	}

	// Test case 2: whitespace self actor matching (normalization check)
	reader2 := &fakeReader{item: workitem.WorkItem{
		Ref:         ref,
		Title:       "Self Mutation Whitespace",
		State:       "state-backlog",
		AssigneeIDs: []string{"agent-1"},
	}}
	creator2 := &fakeTaskCreator{task: storage.Task{ID: "task-2"}}
	binder2 := &fakeBinder{binding: validBinding(), slot: reservedSlot()}
	svc2 := New(reader2, binder2, creator2)

	_, err2 := svc2.CreateTaskFromWorkItem(context.Background(), CreateTaskInput{
		Ref: ref,
		Trigger: CreationTrigger{
			RequiredStateID:    "state-backlog",
			RequiredAssigneeID: "agent-1",
			Actor:              "  nomadcode-bot  ",
			SelfActor:          "nomadcode-bot\n",
		},
	})
	if !errors.Is(err2, ErrCreationTriggerIgnored) {
		t.Errorf("expected ErrCreationTriggerIgnored due to whitespace self actor, got %v", err2)
	}
	if binder2.ensureCalled {
		t.Error("EnsureProjectWorkspace should not be called on ignored trigger")
	}
	if binder2.reserveCalled {
		t.Error("ReserveWorkspaceSlot should not be called on ignored trigger")
	}
	if creator2.calledWith != nil {
		t.Error("CreateTask should not be called on ignored trigger")
	}
}

func TestCreateTaskFromWorkItemEmptyTriggerKeepsCompatibility(t *testing.T) {
	ref := workitem.Ref{Provider: "plane", Tenant: "acme", Project: "proj-1", ID: "work-1"}
	reader := &fakeReader{item: workitem.WorkItem{
		Ref:   ref,
		Title: "Empty Trigger",
	}}
	creator := &fakeTaskCreator{task: storage.Task{ID: "task-1"}}
	binder := &fakeBinder{binding: validBinding(), slot: reservedSlot()}
	svc := New(reader, binder, creator)

	_, err := svc.CreateTaskFromWorkItem(context.Background(), CreateTaskInput{
		Ref:     ref,
		Trigger: CreationTrigger{}, // empty trigger
	})
	if err != nil {
		t.Fatalf("expected compatibility call to succeed, got error: %v", err)
	}
	if !binder.ensureCalled {
		t.Error("EnsureProjectWorkspace should be called on compatible request")
	}
	if !binder.reserveCalled {
		t.Error("ReserveWorkspaceSlot should be called on compatible request")
	}
	if creator.calledWith == nil {
		t.Fatal("expected task creation call")
	}
}

func TestCreateTaskFromWorkItemReturnsExistingExternalTaskWithoutProvision(t *testing.T) {
	ref := workitem.Ref{Provider: "plane", Tenant: "acme", Project: "proj-1", ID: "work-1"}
	reader := &fakeReader{item: workitem.WorkItem{
		Ref:   ref,
		Title: "Existing Task",
	}}

	existingTask := storage.Task{
		ID:               "task-existing-123",
		Title:            "Existing Task",
		Source:           "plane",
		ExternalProvider: testOptionalString("plane"),
		ExternalID:       testOptionalString("work-1"),
	}

	creator := &fakeTaskCreator{
		getTaskTask: existingTask,
		getTaskErr:  nil,
	}
	binder := &fakeBinder{binding: validBinding(), slot: reservedSlot()}
	svc := New(reader, binder, creator)

	task, err := svc.CreateTaskFromWorkItem(context.Background(), CreateTaskInput{
		Ref:     ref,
		Trigger: CreationTrigger{},
	})
	if err != nil {
		t.Fatalf("expected call to succeed, got error: %v", err)
	}

	if task.ID != "task-existing-123" {
		t.Errorf("expected existing task to be returned, got ID: %s", task.ID)
	}

	if !creator.getCalled {
		t.Error("expected GetTaskByExternalRef to be called")
	}

	if binder.ensureCalled {
		t.Error("EnsureProjectWorkspace should not be called when task already exists")
	}

	if binder.reserveCalled {
		t.Error("ReserveWorkspaceSlot should not be called when task already exists")
	}

	if creator.calledWith != nil {
		t.Error("CreateTask should not be called when task already exists")
	}
}

func TestCreateTaskFromWorkItemDuplicateExternalRefDoesNotReserveSlot(t *testing.T) {
	ref := workitem.Ref{Provider: "plane", Tenant: "acme", Project: "proj-1", ID: "work-1"}
	reader := &fakeReader{item: workitem.WorkItem{
		Ref:   ref,
		Title: "Existing Task",
	}}

	existingTask := storage.Task{
		ID:               "task-existing-456",
		Title:            "Existing Task",
		Source:           "plane",
		ExternalProvider: testOptionalString("plane"),
		ExternalID:       testOptionalString("work-1"),
	}

	creator := &fakeTaskCreator{
		getTaskTask: existingTask,
		getTaskErr:  nil,
	}
	binder := &fakeBinder{binding: validBinding(), slot: reservedSlot()}
	svc := New(reader, binder, creator)

	_, err := svc.CreateTaskFromWorkItem(context.Background(), CreateTaskInput{
		Ref:     ref,
		Trigger: CreationTrigger{},
	})
	if err != nil {
		t.Fatalf("expected call to succeed, got error: %v", err)
	}

	if binder.ensureCalled {
		t.Error("EnsureProjectWorkspace should not be called when duplicate is detected")
	}

	if binder.reserveCalled {
		t.Error("ReserveWorkspaceSlot should not be called when duplicate is detected")
	}
}

func testOptionalString(s string) *string {
	return &s
}

// ---- concurrent duplicate test helpers ----

// concurrentFakeTaskCreator implements TaskCreator with real per-key locking and
// stateful GetTaskByExternalRef so concurrent duplicate requests see the task
// created by whichever goroutine won the lock first.
type concurrentFakeTaskCreator struct {
	stateMu     sync.Mutex
	tasks       map[string]storage.Task
	createCount int

	lockMu   sync.Mutex
	lockKeys map[string]*sync.Mutex
	lockRefs map[string]int
}

func (f *concurrentFakeTaskCreator) acquireKey(key string) *sync.Mutex {
	f.lockMu.Lock()
	if f.lockKeys == nil {
		f.lockKeys = make(map[string]*sync.Mutex)
		f.lockRefs = make(map[string]int)
	}
	mu, ok := f.lockKeys[key]
	if !ok {
		mu = &sync.Mutex{}
		f.lockKeys[key] = mu
	}
	f.lockRefs[key]++
	f.lockMu.Unlock()
	mu.Lock()
	return mu
}

func (f *concurrentFakeTaskCreator) releaseKey(key string, mu *sync.Mutex) {
	mu.Unlock()
	f.lockMu.Lock()
	f.lockRefs[key]--
	if f.lockRefs[key] == 0 {
		delete(f.lockKeys, key)
		delete(f.lockRefs, key)
	}
	f.lockMu.Unlock()
}

func (f *concurrentFakeTaskCreator) WithExternalRefLock(_ context.Context, provider, id string, fn func(context.Context) (storage.Task, error)) (storage.Task, error) {
	key := provider + "\x00" + id
	mu := f.acquireKey(key)
	defer f.releaseKey(key, mu)
	return fn(context.Background())
}

func (f *concurrentFakeTaskCreator) GetTaskByExternalRef(_ context.Context, provider, id string) (storage.Task, error) {
	f.stateMu.Lock()
	defer f.stateMu.Unlock()
	if f.tasks == nil {
		return storage.Task{}, pgx.ErrNoRows
	}
	key := provider + "\x00" + id
	if t, ok := f.tasks[key]; ok {
		return t, nil
	}
	return storage.Task{}, pgx.ErrNoRows
}

func (f *concurrentFakeTaskCreator) CreateTask(_ context.Context, input workflow.CreateTaskInput) (storage.Task, error) {
	f.stateMu.Lock()
	defer f.stateMu.Unlock()
	f.createCount++
	task := storage.Task{ID: "task-concurrent-1", Status: "pending"}
	if input.External != nil && input.External.Provider != "" && input.External.ID != "" {
		if f.tasks == nil {
			f.tasks = make(map[string]storage.Task)
		}
		f.tasks[input.External.Provider+"\x00"+input.External.ID] = task
	}
	return task, nil
}

// countingFakeBinder is a thread-safe binder that counts slot reservations.
type countingFakeBinder struct {
	binding      ProjectBinding
	slot         projectsync.WorkspaceSlot
	mu           sync.Mutex
	reserveCount int
}

func (f *countingFakeBinder) ResolveProjectBinding(_ context.Context, _ workitem.Ref) (ProjectBinding, error) {
	return f.binding, nil
}

func (f *countingFakeBinder) EnsureProjectWorkspace(_ context.Context, _ ProjectBinding) error {
	return nil
}

func (f *countingFakeBinder) ReserveWorkspaceSlot(_ context.Context, _ int64) (projectsync.WorkspaceSlot, error) {
	f.mu.Lock()
	f.reserveCount++
	f.mu.Unlock()
	return f.slot, nil
}

func TestCreateTaskFromWorkItemConcurrentDuplicateExternalRefReservesOneSlot(t *testing.T) {
	ref := workitem.Ref{Provider: "plane", Tenant: "acme", Project: "proj-1", ID: "work-1"}
	reader := &fakeReader{item: workitem.WorkItem{Ref: ref, Title: "Concurrent Task"}}
	creator := &concurrentFakeTaskCreator{}
	binder := &countingFakeBinder{binding: validBinding(), slot: reservedSlot()}
	svc := New(reader, binder, creator)

	var wg sync.WaitGroup
	results := make([]storage.Task, 2)
	errs := make([]error, 2)

	wg.Add(2)
	for i := range 2 {
		go func(i int) {
			defer wg.Done()
			results[i], errs[i] = svc.CreateTaskFromWorkItem(context.Background(), CreateTaskInput{Ref: ref})
		}(i)
	}
	wg.Wait()

	for i, err := range errs {
		if err != nil {
			t.Errorf("goroutine %d: unexpected error: %v", i, err)
		}
	}
	if results[0].ID != results[1].ID {
		t.Errorf("expected same task ID from both goroutines, got %q and %q", results[0].ID, results[1].ID)
	}
	if creator.createCount != 1 {
		t.Errorf("CreateTask call count: got %d, want 1", creator.createCount)
	}
	if binder.reserveCount != 1 {
		t.Errorf("ReserveWorkspaceSlot call count: got %d, want 1", binder.reserveCount)
	}
}
