package task_test

import (
	"net/http"
	"net/http/httptest"
	"testing"
	"time"

	"github.com/gin-gonic/gin"
	"github.com/stretchr/testify/require"

	"github.com/tidbcloud/AgentTeam/internal/api/dto"
	. "github.com/tidbcloud/AgentTeam/internal/api/testkit"
	"github.com/tidbcloud/AgentTeam/internal/store/agent"
	"github.com/tidbcloud/AgentTeam/internal/store/message"
	"github.com/tidbcloud/AgentTeam/internal/store/taskboard"
)

func TestTaskWatcherRosterIsExplicitBoundedPaginatedAndCompleteForEveryReader(t *testing.T) {
	t.Parallel()

	runtime := StartRuntime(t)
	router := gin.New()
	runtime.RegisterRoutes(router)
	server := httptest.NewServer(router)
	defer server.Close()
	client := server.Client()

	owner, workspaceID, channelID := SetupTaskHTTPActor(
		t,
		client,
		server.URL,
		"task-watcher-roster-owner@example.com",
		"zulu-owner",
	)
	peer := AcceptTaskHTTPInvite(
		t,
		runtime,
		client,
		server.URL,
		owner.User.ID,
		workspaceID,
		"task-watcher-roster-peer@example.com",
		"omega-peer",
	)
	privateHuman := AcceptTaskHTTPInvite(
		t,
		runtime,
		client,
		server.URL,
		owner.User.ID,
		workspaceID,
		"task-watcher-roster-private-human@example.com",
		"epsilon-private-human",
	)
	ownerMemberID := TaskWorkspaceMemberIDForUser(t, runtime, workspaceID, owner.User.ID)
	peerMemberID := TaskWorkspaceMemberIDForUser(t, runtime, workspaceID, peer.User.ID)
	privateHumanMemberID := TaskWorkspaceMemberIDForUser(t, runtime, workspaceID, privateHuman.User.ID)

	ownerMachineID := createTaskWatcherMachine(t, client, server.URL, owner.AccessToken, workspaceID, "watch-owner-machine")
	peerMachineID := createTaskWatcherMachine(t, client, server.URL, peer.AccessToken, workspaceID, "watch-peer-machine")
	alphaPrivate, alphaMemberID := createTaskWatcherAgent(t, runtime, client, server.URL, owner.AccessToken, workspaceID, ownerMachineID, "alpha-private", "https://example.com/alpha.png")
	deltaAssignee, deltaMemberID := createTaskWatcherAgent(t, runtime, client, server.URL, owner.AccessToken, workspaceID, ownerMachineID, "delta-assignee", "https://example.com/delta.png")
	betaPrivate, betaMemberID := createTaskWatcherAgent(t, runtime, client, server.URL, peer.AccessToken, workspaceID, peerMachineID, "beta-private", "https://example.com/beta.png")
	gammaPublic, gammaMemberID := createTaskWatcherAgent(t, runtime, client, server.URL, peer.AccessToken, workspaceID, peerMachineID, "gamma-public", "https://example.com/gamma.png")

	_, err := runtime.Pool.Exec(runtime.Ctx, `
UPDATE users
SET avatar_url = CASE id
  WHEN $1 THEN 'https://example.com/owner.png'
  WHEN $2 THEN 'https://example.com/peer.png'
  ELSE avatar_url
END
WHERE id IN ($1, $2)
`, owner.User.ID, peer.User.ID)
	require.NoError(t, err)

	var created taskboard.TaskPage
	response := DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/workspaces/"+workspaceID+"/channels/"+channelID+"/tasks", map[string]any{
		"tasks": []map[string]any{{
			"title": "Explicit watcher roster",
		}},
	}, WorkspaceHeaders(owner.AccessToken, workspaceID), &created)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Len(t, created.Tasks, 1)
	task := created.Tasks[0]
	response = DoJSON(t, client, http.MethodPatch, server.URL+"/api/v1/workspaces/"+workspaceID+"/tasks/"+task.ID, map[string]any{
		"assigneeRef": deltaMemberID,
	}, WorkspaceHeaders(owner.AccessToken, workspaceID), nil)
	require.Equal(t, http.StatusOK, response.StatusCode)

	now := time.Now().UTC()
	require.NoError(t, message.EnsureScopeMembership(runtime.Ctx, runtime.Pool, task.TaskScopeID, ownerMemberID, "public", "", "", 0, 0, now))
	require.NoError(t, message.UpsertTaskWatchNotificationState(runtime.Ctx, runtime.Pool, ownerMemberID, task.TaskScopeID, "active", now))
	require.NoError(t, message.EnsureScopeMembership(runtime.Ctx, runtime.Pool, task.TaskScopeID, peerMemberID, "public", "", "", 0, 0, now))
	require.NoError(t, message.UpsertTaskWatchNotificationState(runtime.Ctx, runtime.Pool, peerMemberID, task.TaskScopeID, "active", now))
	require.NoError(t, message.EnsureScopeMembership(runtime.Ctx, runtime.Pool, task.TaskScopeID, privateHumanMemberID, "private", peerMemberID, "", 0, 0, now))
	require.NoError(t, message.UpsertTaskWatchNotificationState(runtime.Ctx, runtime.Pool, privateHumanMemberID, task.TaskScopeID, "active", now))

	require.NoError(t, message.EnsureScopeMembership(runtime.Ctx, runtime.Pool, task.TaskScopeID, alphaMemberID, "private", ownerMemberID, "", 0, 0, now))
	require.NoError(t, message.UpsertTaskWatchNotificationState(runtime.Ctx, runtime.Pool, alphaMemberID, task.TaskScopeID, "auto-wake", now))
	require.NoError(t, message.EnsureScopeMembership(runtime.Ctx, runtime.Pool, task.TaskScopeID, betaMemberID, "private", peerMemberID, "", 0, 0, now))
	require.NoError(t, message.UpsertTaskWatchNotificationState(runtime.Ctx, runtime.Pool, betaMemberID, task.TaskScopeID, "auto-wake", now))
	require.NoError(t, message.EnsureScopeMembership(runtime.Ctx, runtime.Pool, task.TaskScopeID, gammaMemberID, "public", "", "", 0, 0, now))
	require.NoError(t, message.UpsertTaskWatchNotificationState(runtime.Ctx, runtime.Pool, gammaMemberID, task.TaskScopeID, "auto-wake", now))

	legacyOnlyMemberID := seedTaskWatcherHumanMember(t, runtime, workspaceID, "legacy-only", "legacy-only", "active", now)
	require.NoError(t, message.EnsureScopeMembership(runtime.Ctx, runtime.Pool, task.TaskScopeID, legacyOnlyMemberID, "public", "", "", 0, 0, now))
	require.NoError(t, message.UpsertTaskWatchNotificationState(runtime.Ctx, runtime.Pool, legacyOnlyMemberID, task.TaskScopeID, "mute", now))
	inactiveMemberID := seedTaskWatcherHumanMember(t, runtime, workspaceID, "inactive", "inactive-explicit-watch", "inactive", now)
	require.NoError(t, message.EnsureScopeMembership(runtime.Ctx, runtime.Pool, task.TaskScopeID, inactiveMemberID, "public", "", "", 0, 0, now))
	require.NoError(t, message.UpsertTaskWatchNotificationState(runtime.Ctx, runtime.Pool, inactiveMemberID, task.TaskScopeID, "active", now))
	staleMemberID := seedTaskWatcherHumanMember(t, runtime, workspaceID, "stale", "stale-explicit-watch", "active", now)
	require.NoError(t, message.EnsureScopeMembership(runtime.Ctx, runtime.Pool, task.TaskScopeID, staleMemberID, "public", "", "", 0, 0, now))
	require.NoError(t, message.UpsertTaskWatchNotificationState(runtime.Ctx, runtime.Pool, staleMemberID, task.TaskScopeID, "active", now))
	_, err = runtime.Pool.Exec(runtime.Ctx, `
UPDATE scope_memberships
SET effective_to_seq = 1,
    updated_at = NOW()
WHERE scope_id = $1 AND member_id = $2 AND effective_to_seq IS NULL
`, task.TaskScopeID, staleMemberID)
	require.NoError(t, err)

	// Conflict matrix: only task_watch_state is authoritative in either
	// direction. The legacy state column cannot add or remove roster entries.
	_, err = runtime.Pool.Exec(runtime.Ctx, `
UPDATE scope_notification_settings
SET state = CASE member_id
      WHEN $2 THEN 'mute'
      WHEN $3 THEN 'no-wake'
      WHEN $4 THEN 'active'
      ELSE state
    END,
    updated_at = NOW()
WHERE scope_id = $1
  AND member_id IN ($2, $3, $4)
`, task.TaskScopeID, ownerMemberID, alphaMemberID, legacyOnlyMemberID)
	require.NoError(t, err)

	// The assignee is a current Task participant but remains explicitly
	// no-wake. Assignment must not infer watcher membership.
	var assigneeTaskWatchState string
	require.NoError(t, runtime.Pool.QueryRow(runtime.Ctx, `
SELECT COALESCE(task_watch_state, '')
FROM scope_notification_settings
WHERE scope_id = $1 AND member_id = $2
`, task.TaskScopeID, deltaMemberID).Scan(&assigneeTaskWatchState))
	require.Equal(t, "no-wake", assigneeTaskWatchState)
	_, err = runtime.Pool.Exec(runtime.Ctx, `
UPDATE scope_notification_settings
SET state = 'auto-wake',
    task_watch_state = 'no-wake',
    updated_at = NOW()
WHERE scope_id = $1 AND member_id = $2
`, task.TaskScopeID, deltaMemberID)
	require.NoError(t, err, "legacy state must not make the assignee a watcher")

	var policy dto.TaskNotificationPolicyResponse
	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/workspaces/"+workspaceID+"/tasks/"+task.ID+"/notification-policy", nil, WorkspaceHeaders(owner.AccessToken, workspaceID), &policy)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Equal(t, 6, policy.WatcherCount)
	require.Equal(t, 8, policy.MemberCount)
	require.Len(t, policy.WatcherPreview, 3)
	require.Equal(t, []string{"alpha-private", "beta-private", "epsilon-private-human"}, taskWatcherDisplayNames(policy.WatcherPreview))
	require.Equal(t, alphaPrivate.ID, policy.WatcherPreview[0].PrincipalID)
	require.Equal(t, alphaMemberID, policy.WatcherPreview[0].MemberID)
	require.Equal(t, "alpha-private", policy.WatcherPreview[0].TargetID)
	require.Equal(t, "https://example.com/alpha.png", policy.WatcherPreview[0].AvatarURL)

	var first dto.TaskWatcherPageResponse
	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/workspaces/"+workspaceID+"/tasks/"+task.ID+"/watchers?limit=2", nil, WorkspaceHeaders(owner.AccessToken, workspaceID), &first)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Equal(t, 6, first.WatcherCount)
	require.True(t, first.HasMore)
	require.NotEmpty(t, first.NextCursor)
	require.Equal(t, []string{"alpha-private", "beta-private"}, taskWatcherDisplayNames(first.Watchers))

	var second dto.TaskWatcherPageResponse
	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/workspaces/"+workspaceID+"/tasks/"+task.ID+"/watchers?limit=2&cursor="+first.NextCursor, nil, WorkspaceHeaders(owner.AccessToken, workspaceID), &second)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Equal(t, 6, second.WatcherCount)
	require.True(t, second.HasMore)
	require.NotEmpty(t, second.NextCursor)
	require.Equal(t, []string{"epsilon-private-human", "gamma-public"}, taskWatcherDisplayNames(second.Watchers))

	var third dto.TaskWatcherPageResponse
	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/workspaces/"+workspaceID+"/tasks/"+task.ID+"/watchers?limit=2&cursor="+second.NextCursor, nil, WorkspaceHeaders(owner.AccessToken, workspaceID), &third)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Equal(t, 6, third.WatcherCount)
	require.False(t, third.HasMore)
	require.Empty(t, third.NextCursor)
	require.Equal(t, []string{"omega-peer", "zulu-owner"}, taskWatcherDisplayNames(third.Watchers))
	require.Equal(t, peer.User.ID, third.Watchers[0].PrincipalID)
	require.Equal(t, peerMemberID, third.Watchers[0].MemberID)
	require.Equal(t, peerMemberID, third.Watchers[0].TargetID)
	require.Equal(t, "https://example.com/peer.png", third.Watchers[0].AvatarURL)

	malformedCursorResponse := DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/workspaces/"+workspaceID+"/tasks/"+task.ID+"/watchers?cursor=not-base64", nil, WorkspaceHeaders(owner.AccessToken, workspaceID), nil)
	require.Equal(t, http.StatusBadRequest, malformedCursorResponse.StatusCode)

	var peerPage dto.TaskWatcherPageResponse
	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/workspaces/"+workspaceID+"/tasks/"+task.ID+"/watchers?limit=100", nil, WorkspaceHeaders(peer.AccessToken, workspaceID), &peerPage)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Equal(t, 6, peerPage.WatcherCount)
	require.False(t, peerPage.HasMore)
	require.Equal(t, []string{"alpha-private", "beta-private", "epsilon-private-human", "gamma-public", "omega-peer", "zulu-owner"}, taskWatcherDisplayNames(peerPage.Watchers))
	ownerRoster := append(append(append([]dto.AuthorProfile{}, first.Watchers...), second.Watchers...), third.Watchers...)
	require.Equal(t, ownerRoster, peerPage.Watchers, "every authorized Task viewer sees the same safe explicit roster")
	require.Contains(t, taskWatcherPrincipalIDs(peerPage.Watchers), alphaPrivate.ID)
	require.Contains(t, taskWatcherPrincipalIDs(peerPage.Watchers), betaPrivate.ID)
	require.Contains(t, taskWatcherPrincipalIDs(peerPage.Watchers), gammaPublic.ID)
	require.NotContains(t, taskWatcherPrincipalIDs(peerPage.Watchers), deltaAssignee.ID)
	require.NotContains(t, taskWatcherMemberIDs(peerPage.Watchers), legacyOnlyMemberID)
	require.NotContains(t, taskWatcherMemberIDs(peerPage.Watchers), inactiveMemberID)
	require.NotContains(t, taskWatcherMemberIDs(peerPage.Watchers), staleMemberID)

	var peerPolicy dto.TaskNotificationPolicyResponse
	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/workspaces/"+workspaceID+"/tasks/"+task.ID+"/notification-policy", nil, WorkspaceHeaders(peer.AccessToken, workspaceID), &peerPolicy)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Equal(t, policy.WatcherCount, peerPolicy.WatcherCount)
	require.Equal(t, policy.WatcherPreview, peerPolicy.WatcherPreview, "the bounded policy preview follows the same all-readers roster contract")

	// Reading the roster is deliberately broader than mutating it: every
	// authorized Task reader can see alpha-private, but only its owner can
	// change that Agent's explicit Task watch state.
	response = DoJSON(t, client, http.MethodPut, server.URL+"/api/v1/workspaces/"+workspaceID+"/tasks/"+task.ID+"/notification-policy/agents/"+alphaPrivate.ID, map[string]any{
		"wakeState": "no-wake",
	}, WorkspaceHeaders(peer.AccessToken, workspaceID), nil)
	require.Equal(t, http.StatusForbidden, response.StatusCode)
	requireTaskWatcherState(t, runtime, task.TaskScopeID, alphaMemberID, "auto-wake")

	var ownerMutedAlpha dto.TaskNotificationPolicyResponse
	response = DoJSON(t, client, http.MethodPut, server.URL+"/api/v1/workspaces/"+workspaceID+"/tasks/"+task.ID+"/notification-policy/agents/"+alphaPrivate.ID, map[string]any{
		"wakeState": "no-wake",
	}, WorkspaceHeaders(owner.AccessToken, workspaceID), &ownerMutedAlpha)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Equal(t, 5, ownerMutedAlpha.WatcherCount)
	require.Equal(t, policy.MemberCount, ownerMutedAlpha.MemberCount)
	requireTaskWatcherState(t, runtime, task.TaskScopeID, alphaMemberID, "no-wake")

	var ownerReactivatedAlpha dto.TaskNotificationPolicyResponse
	response = DoJSON(t, client, http.MethodPut, server.URL+"/api/v1/workspaces/"+workspaceID+"/tasks/"+task.ID+"/notification-policy/agents/"+alphaPrivate.ID, map[string]any{
		"wakeState": "auto-wake",
	}, WorkspaceHeaders(owner.AccessToken, workspaceID), &ownerReactivatedAlpha)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Equal(t, 6, ownerReactivatedAlpha.WatcherCount)
	requireTaskWatcherState(t, runtime, task.TaskScopeID, alphaMemberID, "auto-wake")

	var ownerMutedAgents dto.TaskNotificationPolicyResponse
	response = DoJSON(t, client, http.MethodPut, server.URL+"/api/v1/workspaces/"+workspaceID+"/tasks/"+task.ID+"/notification-policy/agents/all", map[string]any{
		"wakeState": "no-wake",
	}, WorkspaceHeaders(owner.AccessToken, workspaceID), &ownerMutedAgents)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Equal(t, 5, ownerMutedAgents.WatcherCount)
	requireTaskWatcherState(t, runtime, task.TaskScopeID, alphaMemberID, "no-wake")
	requireTaskWatcherState(t, runtime, task.TaskScopeID, deltaMemberID, "no-wake")
	requireTaskWatcherState(t, runtime, task.TaskScopeID, betaMemberID, "auto-wake")

	var ownerReactivatedAgents dto.TaskNotificationPolicyResponse
	response = DoJSON(t, client, http.MethodPut, server.URL+"/api/v1/workspaces/"+workspaceID+"/tasks/"+task.ID+"/notification-policy/agents/all", map[string]any{
		"wakeState": "auto-wake",
	}, WorkspaceHeaders(owner.AccessToken, workspaceID), &ownerReactivatedAgents)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Equal(t, 7, ownerReactivatedAgents.WatcherCount)
	requireTaskWatcherState(t, runtime, task.TaskScopeID, alphaMemberID, "auto-wake")
	requireTaskWatcherState(t, runtime, task.TaskScopeID, deltaMemberID, "auto-wake")
	requireTaskWatcherState(t, runtime, task.TaskScopeID, betaMemberID, "auto-wake")

	var ownerResetAssignee dto.TaskNotificationPolicyResponse
	response = DoJSON(t, client, http.MethodPut, server.URL+"/api/v1/workspaces/"+workspaceID+"/tasks/"+task.ID+"/notification-policy/agents/"+deltaAssignee.ID, map[string]any{
		"wakeState": "no-wake",
	}, WorkspaceHeaders(owner.AccessToken, workspaceID), &ownerResetAssignee)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Equal(t, 6, ownerResetAssignee.WatcherCount)
	requireTaskWatcherState(t, runtime, task.TaskScopeID, deltaMemberID, "no-wake")

	// The Human mutation endpoint has no target selector and must change only
	// the authenticated Human. Muting the peer leaves every other Human's
	// explicit Task watch state intact.
	var peerMuted dto.TaskNotificationPolicyResponse
	response = DoJSON(t, client, http.MethodPut, server.URL+"/api/v1/workspaces/"+workspaceID+"/tasks/"+task.ID+"/notification-policy/self", map[string]any{
		"state": "mute",
	}, WorkspaceHeaders(peer.AccessToken, workspaceID), &peerMuted)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Equal(t, 5, peerMuted.WatcherCount)
	requireTaskWatcherState(t, runtime, task.TaskScopeID, peerMemberID, "mute")
	requireTaskWatcherState(t, runtime, task.TaskScopeID, ownerMemberID, "active")
	requireTaskWatcherState(t, runtime, task.TaskScopeID, privateHumanMemberID, "active")

	var peerReactivated dto.TaskNotificationPolicyResponse
	response = DoJSON(t, client, http.MethodPut, server.URL+"/api/v1/workspaces/"+workspaceID+"/tasks/"+task.ID+"/notification-policy/self", map[string]any{
		"state": "active",
	}, WorkspaceHeaders(peer.AccessToken, workspaceID), &peerReactivated)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Equal(t, 6, peerReactivated.WatcherCount)
	requireTaskWatcherState(t, runtime, task.TaskScopeID, peerMemberID, "active")

	var muted dto.TaskNotificationPolicyResponse
	response = DoJSON(t, client, http.MethodPut, server.URL+"/api/v1/workspaces/"+workspaceID+"/tasks/"+task.ID+"/notification-policy/self", map[string]any{
		"state": "mute",
	}, WorkspaceHeaders(owner.AccessToken, workspaceID), &muted)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Equal(t, 5, muted.WatcherCount)
	require.Len(t, muted.WatcherPreview, 3)

	var active dto.TaskNotificationPolicyResponse
	response = DoJSON(t, client, http.MethodPut, server.URL+"/api/v1/workspaces/"+workspaceID+"/tasks/"+task.ID+"/notification-policy/self", map[string]any{
		"state": "active",
	}, WorkspaceHeaders(owner.AccessToken, workspaceID), &active)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Equal(t, 6, active.WatcherCount)
	require.Len(t, active.WatcherPreview, 3)

	_, err = runtime.Pool.Exec(runtime.Ctx, `
UPDATE scopes
SET status = 'archived',
    updated_at = NOW()
WHERE id = $1
`, task.TaskScopeID)
	require.NoError(t, err)
	response = DoJSON(t, client, http.MethodPut, server.URL+"/api/v1/workspaces/"+workspaceID+"/tasks/"+task.ID+"/notification-policy/agents/all", map[string]any{
		"wakeState": "no-wake",
	}, WorkspaceHeaders(owner.AccessToken, workspaceID), nil)
	require.Equal(t, http.StatusNotFound, response.StatusCode)
	requireTaskWatcherState(t, runtime, task.TaskScopeID, alphaMemberID, "auto-wake")
	requireTaskWatcherState(t, runtime, task.TaskScopeID, deltaMemberID, "no-wake")
}

func TestTaskWatcherRosterReadGateMatchesTaskDetailNonEnumeration(t *testing.T) {
	t.Parallel()

	runtime := StartRuntime(t)
	router := gin.New()
	runtime.RegisterRoutes(router)
	server := httptest.NewServer(router)
	defer server.Close()
	client := server.Client()

	owner, workspaceID, _ := SetupTaskHTTPActor(
		t,
		client,
		server.URL,
		"task-watcher-gate-owner@example.com",
		"task-watcher-gate-owner",
	)
	member := AcceptTaskHTTPInvite(
		t,
		runtime,
		client,
		server.URL,
		owner.User.ID,
		workspaceID,
		"task-watcher-gate-member@example.com",
		"task-watcher-gate-member",
	)
	var privateChannel ChannelResponse
	response := DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/workspaces/"+workspaceID+"/channels", map[string]any{
		"name":        "watcher-gate-private",
		"description": "private watcher gate",
	}, WorkspaceHeaders(owner.AccessToken, workspaceID), &privateChannel)
	require.Equal(t, http.StatusOK, response.StatusCode)

	var created taskboard.TaskPage
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/workspaces/"+workspaceID+"/channels/"+privateChannel.ID+"/tasks", map[string]any{
		"tasks": []map[string]any{{"title": "Unreadable watcher roster"}},
	}, WorkspaceHeaders(owner.AccessToken, workspaceID), &created)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Len(t, created.Tasks, 1)

	detailResponse := DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/workspaces/"+workspaceID+"/tasks/"+created.Tasks[0].ID, nil, WorkspaceHeaders(member.AccessToken, workspaceID), nil)
	watchersResponse := DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/workspaces/"+workspaceID+"/tasks/"+created.Tasks[0].ID+"/watchers", nil, WorkspaceHeaders(member.AccessToken, workspaceID), nil)
	require.NotEqual(t, http.StatusOK, detailResponse.StatusCode)
	require.Equal(t, detailResponse.StatusCode, watchersResponse.StatusCode)

	missingTaskID := "tsk_missing_watcher_gate"
	missingDetailResponse := DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/workspaces/"+workspaceID+"/tasks/"+missingTaskID, nil, WorkspaceHeaders(member.AccessToken, workspaceID), nil)
	missingWatchersResponse := DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/workspaces/"+workspaceID+"/tasks/"+missingTaskID+"/watchers", nil, WorkspaceHeaders(member.AccessToken, workspaceID), nil)
	require.NotEqual(t, http.StatusOK, missingDetailResponse.StatusCode)
	require.Equal(t, missingDetailResponse.StatusCode, missingWatchersResponse.StatusCode)
}

func createTaskWatcherMachine(t *testing.T, client *http.Client, baseURL string, accessToken string, workspaceID string, name string) string {
	t.Helper()
	var response dto.CreateMachineResponse
	httpResponse := DoJSON(t, client, http.MethodPost, baseURL+"/api/v1/workspaces/"+workspaceID+"/machines", map[string]any{
		"name": name,
	}, AuthHeaders(accessToken), &response)
	require.Equal(t, http.StatusOK, httpResponse.StatusCode)
	return response.Machine.ID
}

func createTaskWatcherAgent(
	t *testing.T,
	runtime Runtime,
	client *http.Client,
	baseURL string,
	accessToken string,
	workspaceID string,
	machineID string,
	name string,
	avatarURL string,
) (agent.AppAgent, string) {
	t.Helper()
	var created agent.AppAgent
	response := DoJSON(t, client, http.MethodPost, baseURL+"/api/v1/workspaces/"+workspaceID+"/agents", map[string]any{
		"name":      name,
		"runtime":   "codex",
		"machineId": machineID,
	}, WorkspaceHeaders(accessToken, workspaceID), &created)
	require.Equal(t, http.StatusOK, response.StatusCode)
	var memberID string
	require.NoError(t, runtime.Pool.QueryRow(runtime.Ctx, `SELECT member_id FROM agents WHERE id = $1`, created.ID).Scan(&memberID))
	_, err := runtime.Pool.Exec(runtime.Ctx, `UPDATE agents SET avatar_url = $2 WHERE id = $1`, created.ID, avatarURL)
	require.NoError(t, err)
	return created, memberID
}

func seedTaskWatcherHumanMember(t *testing.T, runtime Runtime, workspaceID string, suffix string, displayName string, status string, now time.Time) string {
	t.Helper()
	userID := "usr_task_watcher_" + suffix
	memberID := "mem_task_watcher_" + suffix
	_, err := runtime.Pool.Exec(runtime.Ctx, `
INSERT INTO users (
  id, email, name, display_name, avatar_url, is_admin, password_hash,
  email_verified_at, created_at, updated_at
) VALUES ($1, $2, $3, $4, '', FALSE, 'hash', $5, $5, $5)
`, userID, suffix+"-task-watcher@example.com", suffix, displayName, now)
	require.NoError(t, err)
	_, err = runtime.Pool.Exec(runtime.Ctx, `
INSERT INTO members (
  id, workspace_id, type, display_name, user_id, workspace_role, status,
  created_at, updated_at
) VALUES ($1, $2, 'human', $3, $4, 'member', $5, $6, $6)
`, memberID, workspaceID, displayName, userID, status, now)
	require.NoError(t, err)
	return memberID
}

func taskWatcherDisplayNames(profiles []dto.AuthorProfile) []string {
	result := make([]string, 0, len(profiles))
	for _, profile := range profiles {
		result = append(result, profile.DisplayName)
	}
	return result
}

func taskWatcherPrincipalIDs(profiles []dto.AuthorProfile) []string {
	result := make([]string, 0, len(profiles))
	for _, profile := range profiles {
		result = append(result, profile.PrincipalID)
	}
	return result
}

func taskWatcherMemberIDs(profiles []dto.AuthorProfile) []string {
	result := make([]string, 0, len(profiles))
	for _, profile := range profiles {
		result = append(result, profile.MemberID)
	}
	return result
}

func requireTaskWatcherState(t *testing.T, runtime Runtime, taskScopeID string, memberID string, expected string) {
	t.Helper()
	var actual string
	require.NoError(t, runtime.Pool.QueryRow(runtime.Ctx, `
SELECT task_watch_state
FROM scope_notification_settings
WHERE scope_id = $1 AND member_id = $2
`, taskScopeID, memberID).Scan(&actual))
	require.Equal(t, expected, actual)
}
