package channel_test

import (
	"bytes"
	"context"
	"encoding/json"
	"fmt"
	"io"
	"mime/multipart"
	"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/archive"
	"github.com/tidbcloud/AgentTeam/internal/core/identifiers"
	"github.com/tidbcloud/AgentTeam/internal/core/secrets"
	"github.com/tidbcloud/AgentTeam/internal/passwords"
	"github.com/tidbcloud/AgentTeam/internal/store/agent"
	"github.com/tidbcloud/AgentTeam/internal/store/attachment"
	"github.com/tidbcloud/AgentTeam/internal/store/message"
)

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

	runtime := StartRuntime(t)

	router := gin.New()
	runtime.RegisterRoutes(router)
	server := httptest.NewServer(router)
	defer server.Close()

	client := server.Client()

	var auth dto.AuthTokensResponse
	response := DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/auth/register", map[string]any{
		"email":    "dismiss-owner@example.com",
		"password": TestUserPassword,
		"name":     "dismiss-owner",
	}, nil, &auth)
	require.Equal(t, http.StatusOK, response.StatusCode)

	var workspaces []dto.WorkspaceResponse
	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/workspaces", nil, AuthHeaders(auth.AccessToken), &workspaces)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.NotEmpty(t, workspaces)
	workspaceID := workspaces[0].ID

	var channels []ChannelResponse
	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/workspaces/"+workspaceID+"/channels", nil, WorkspaceHeaders(auth.AccessToken, workspaceID), &channels)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.NotEmpty(t, channels)

	var allChannelID string
	for _, channel := range channels {
		if channel.Name == "all" {
			allChannelID = channel.ID
			break
		}
	}
	require.NotEmpty(t, allChannelID)

	var appMessage message.AppMessage
	response = DoJSON(t, client, http.MethodPost, server.URL+WorkspaceScopeMessagesPath(workspaceID, allChannelID), map[string]any{
		"scopeId": allChannelID,
		"content": "dismiss me",
	}, WorkspaceHeaders(auth.AccessToken, workspaceID), &appMessage)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Greater(t, appMessage.Seq, int64(0))

	var apiErr struct {
		Error string `json:"error"`
	}
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/workspaces/"+workspaceID+"/channels/"+allChannelID+"/owner-context-dismiss", map[string]any{
		"dismissedThroughGlobalSeq": appMessage.Seq,
	}, WorkspaceHeaders(auth.AccessToken, workspaceID), &apiErr)
	require.Equal(t, http.StatusOK, response.StatusCode, apiErr.Error)

	var page message.AppMessagePage
	response = DoJSON(t, client, http.MethodGet, server.URL+WorkspaceScopeMessagesPathWithLimit(workspaceID, allChannelID, 50), nil, WorkspaceHeaders(auth.AccessToken, workspaceID), &page)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Equal(t, appMessage.Seq, page.OwnerContextDismissedThroughGlobalSeq)

	var firstStoredSeq int64
	err := runtime.Pool.QueryRow(runtime.Ctx, `
SELECT sm.owner_context_dismissed_through_global_seq
FROM scope_memberships sm
JOIN members m ON m.id = sm.member_id
WHERE m.user_id = $1
  AND sm.scope_id = $2
`, auth.User.ID, allChannelID).Scan(&firstStoredSeq)
	require.NoError(t, err)
	require.Equal(t, appMessage.Seq, firstStoredSeq)

	olderSeq := appMessage.Seq - 1
	if olderSeq < 0 {
		olderSeq = 0
	}
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/workspaces/"+workspaceID+"/channels/"+allChannelID+"/owner-context-dismiss", map[string]any{
		"dismissedThroughGlobalSeq": olderSeq,
	}, WorkspaceHeaders(auth.AccessToken, workspaceID), &apiErr)
	require.Equal(t, http.StatusOK, response.StatusCode, apiErr.Error)

	var storedSeqAfterOlderWrite int64
	err = runtime.Pool.QueryRow(runtime.Ctx, `
SELECT sm.owner_context_dismissed_through_global_seq
FROM scope_memberships sm
JOIN members m ON m.id = sm.member_id
WHERE m.user_id = $1
  AND sm.scope_id = $2
`, auth.User.ID, allChannelID).Scan(&storedSeqAfterOlderWrite)
	require.NoError(t, err)
	require.Equal(t, appMessage.Seq, storedSeqAfterOlderWrite)

	page = message.AppMessagePage{}
	response = DoJSON(t, client, http.MethodGet, server.URL+WorkspaceScopeMessagesPathWithLimit(workspaceID, allChannelID, 50), nil, WorkspaceHeaders(auth.AccessToken, workspaceID), &page)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Equal(t, appMessage.Seq, page.OwnerContextDismissedThroughGlobalSeq)
}

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

	runtime := StartRuntime(t)

	router := gin.New()
	runtime.RegisterRoutes(router)
	server := httptest.NewServer(router)
	defer server.Close()

	client := server.Client()

	registerAndAcceptInvite := func(email string, name string, inviteToken string) dto.AuthTokensResponse {
		var auth dto.AuthTokensResponse
		response := DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/auth/register", map[string]any{
			"email":    email,
			"password": TestUserPassword,
			"name":     name,
		}, nil, &auth)
		require.Equal(t, http.StatusOK, response.StatusCode)

		response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/auth/accept-invite", map[string]any{
			"token": inviteToken,
		}, AuthHeaders(auth.AccessToken), nil)
		require.Equal(t, http.StatusOK, response.StatusCode)
		return auth
	}

	var owner dto.AuthTokensResponse
	response := DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/auth/register", map[string]any{
		"email":    "owner-context-dismiss-owner@example.com",
		"password": TestUserPassword,
		"name":     "Owner",
	}, nil, &owner)
	require.Equal(t, http.StatusOK, response.StatusCode)

	var workspaces []dto.WorkspaceResponse
	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/workspaces", nil, AuthHeaders(owner.AccessToken), &workspaces)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.NotEmpty(t, workspaces)
	workspaceID := workspaces[0].ID

	const inviteToken = "tok_owner_context_dismiss_hidden"
	_, err := runtime.Store.CreateWorkspaceInvite(
		runtime.Ctx,
		owner.User.ID,
		workspaceID,
		"owner-context-dismiss-member@example.com",
		secrets.HashToken(inviteToken),
		time.Now().UTC().Add(10*time.Minute),
	)
	require.NoError(t, err)

	member := registerAndAcceptInvite("owner-context-dismiss-member@example.com", "Member", inviteToken)

	var restrictedChannel ChannelResponse
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/workspaces/"+workspaceID+"/channels", map[string]any{
		"name": "owner-context-dismiss-restricted",
	}, WorkspaceHeaders(owner.AccessToken, workspaceID), &restrictedChannel)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.NotEmpty(t, restrictedChannel.ID)

	var apiErr struct {
		Error string `json:"error"`
	}
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/workspaces/"+workspaceID+"/channels/"+restrictedChannel.ID+"/owner-context-dismiss", map[string]any{
		"dismissedThroughGlobalSeq": 99,
	}, WorkspaceHeaders(member.AccessToken, workspaceID), &apiErr)
	require.Equal(t, http.StatusNotFound, response.StatusCode)
	require.Equal(t, "not found", apiErr.Error)
}

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

	runtime := StartRuntime(t)

	router := gin.New()
	runtime.RegisterRoutes(router)
	server := httptest.NewServer(router)
	defer server.Close()

	client := server.Client()

	var auth dto.AuthTokensResponse
	response := DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/auth/register", map[string]any{
		"email":    "dismiss-future-owner@example.com",
		"password": TestUserPassword,
		"name":     "dismiss-future-owner",
	}, nil, &auth)
	require.Equal(t, http.StatusOK, response.StatusCode)

	var workspaces []dto.WorkspaceResponse
	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/workspaces", nil, AuthHeaders(auth.AccessToken), &workspaces)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.NotEmpty(t, workspaces)
	workspaceID := workspaces[0].ID

	var channels []ChannelResponse
	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/workspaces/"+workspaceID+"/channels", nil, WorkspaceHeaders(auth.AccessToken, workspaceID), &channels)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.NotEmpty(t, channels)

	var allChannelID string
	for _, channel := range channels {
		if channel.Name == "all" {
			allChannelID = channel.ID
			break
		}
	}
	require.NotEmpty(t, allChannelID)

	var appMessage message.AppMessage
	response = DoJSON(t, client, http.MethodPost, server.URL+WorkspaceScopeMessagesPath(workspaceID, allChannelID), map[string]any{
		"scopeId": allChannelID,
		"content": "future watermark baseline",
	}, WorkspaceHeaders(auth.AccessToken, workspaceID), &appMessage)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Greater(t, appMessage.Seq, int64(0))

	var apiErr struct {
		Error string `json:"error"`
	}
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/workspaces/"+workspaceID+"/channels/"+allChannelID+"/owner-context-dismiss", map[string]any{
		"dismissedThroughGlobalSeq": appMessage.Seq + 100,
	}, WorkspaceHeaders(auth.AccessToken, workspaceID), &apiErr)
	require.Equal(t, http.StatusBadRequest, response.StatusCode)
	require.Equal(t, "invalid dismissedThroughGlobalSeq", apiErr.Error)
}

func TestChannelsListReturnsOnlyJoinedChannels(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, "joined-list-owner@example.com", "joined-list-owner")
	member := AcceptTaskHTTPInvite(t, runtime, client, server.URL, owner.User.ID, workspaceID, "joined-list-member@example.com", "joined-list-member")

	var ownerMemberID string
	require.NoError(t, runtime.Pool.QueryRow(runtime.Ctx, `
SELECT id
FROM members
WHERE workspace_id = $1 AND user_id = $2 AND type = 'human'
`, workspaceID, owner.User.ID).Scan(&ownerMemberID))

	var privateChannel ChannelResponse
	response := DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/workspaces/"+workspaceID+"/channels", map[string]any{
		"name": "ops-private",
	}, WorkspaceHeaders(owner.AccessToken, workspaceID), &privateChannel)
	require.Equal(t, http.StatusOK, response.StatusCode)

	var publicChannel ChannelResponse
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/workspaces/"+workspaceID+"/channels", map[string]any{
		"name": "town-square",
	}, WorkspaceHeaders(owner.AccessToken, workspaceID), &publicChannel)
	require.Equal(t, http.StatusOK, response.StatusCode)
	response = DoJSON(t, client, http.MethodPatch, server.URL+"/api/v1/workspaces/"+workspaceID+"/channels/"+publicChannel.ID, map[string]any{
		"description": "workspace public channel",
		"visibility":  "public",
	}, WorkspaceHeaders(owner.AccessToken, workspaceID), &publicChannel)
	require.Equal(t, http.StatusOK, response.StatusCode)

	var privateVisibility string
	var privateOwnerMemberID *string
	require.NoError(t, runtime.Pool.QueryRow(runtime.Ctx, `
SELECT visibility, owner_member_id
FROM scope_channel_profiles
WHERE workspace_id = $1 AND scope_id = $2
`, workspaceID, privateChannel.ID).Scan(&privateVisibility, &privateOwnerMemberID))
	require.Equal(t, "private", privateVisibility)
	require.NotNil(t, privateOwnerMemberID)
	require.Equal(t, ownerMemberID, *privateOwnerMemberID)

	var publicVisibility string
	require.NoError(t, runtime.Pool.QueryRow(runtime.Ctx, `
SELECT visibility
FROM scope_channel_profiles
WHERE workspace_id = $1 AND scope_id = $2
`, workspaceID, publicChannel.ID).Scan(&publicVisibility))
	require.Equal(t, "public", publicVisibility)

	var ownerChannels []ChannelResponse
	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/workspaces/"+workspaceID+"/channels", nil, WorkspaceHeaders(owner.AccessToken, workspaceID), &ownerChannels)
	require.Equal(t, http.StatusOK, response.StatusCode)

	ownerChannelNames := make([]string, 0, len(ownerChannels))
	for _, channel := range ownerChannels {
		require.True(t, channel.Joined)
		ownerChannelNames = append(ownerChannelNames, channel.Name)
	}
	require.Contains(t, ownerChannelNames, "all")
	require.Contains(t, ownerChannelNames, "ops-private")
	require.Contains(t, ownerChannelNames, "town-square")

	var memberChannels []ChannelResponse
	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/workspaces/"+workspaceID+"/channels", nil, WorkspaceHeaders(member.AccessToken, workspaceID), &memberChannels)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Len(t, memberChannels, 1)
	require.Equal(t, "all", memberChannels[0].Name)
	require.True(t, memberChannels[0].Joined)
}

func TestPublicChannelDiscoverAndJoinFlow(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, "discover-owner@example.com", "discover-owner")
	member := AcceptTaskHTTPInvite(t, runtime, client, server.URL, owner.User.ID, workspaceID, "discover-member@example.com", "discover-member")

	var publicChannel ChannelResponse
	response := DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/workspaces/"+workspaceID+"/channels", map[string]any{
		"name": "announcements",
	}, WorkspaceHeaders(owner.AccessToken, workspaceID), &publicChannel)
	require.Equal(t, http.StatusOK, response.StatusCode)
	response = DoJSON(t, client, http.MethodPatch, server.URL+"/api/v1/workspaces/"+workspaceID+"/channels/"+publicChannel.ID, map[string]any{
		"description": "Public release notes",
		"visibility":  "public",
	}, WorkspaceHeaders(owner.AccessToken, workspaceID), &publicChannel)
	require.Equal(t, http.StatusOK, response.StatusCode)

	var discoverList []map[string]any
	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/workspaces/"+workspaceID+"/channels/discover", nil, WorkspaceHeaders(member.AccessToken, workspaceID), &discoverList)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Len(t, discoverList, 1)
	require.Equal(t, publicChannel.ID, discoverList[0]["id"])
	require.Equal(t, "announcements", discoverList[0]["name"])
	require.Equal(t, "Public release notes", discoverList[0]["description"])
	_, hasMessages := discoverList[0]["messages"]
	require.False(t, hasMessages)
	_, hasMembers := discoverList[0]["members"]
	require.False(t, hasMembers)

	var preview map[string]any
	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/workspaces/"+workspaceID+"/channels/discover/"+publicChannel.ID, nil, WorkspaceHeaders(member.AccessToken, workspaceID), &preview)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Equal(t, publicChannel.ID, preview["id"])
	require.Equal(t, "announcements", preview["name"])
	require.Equal(t, "Public release notes", preview["description"])
	_, hasThreads := preview["threads"]
	require.False(t, hasThreads)
	_, hasPreviewMessages := preview["messages"]
	require.False(t, hasPreviewMessages)

	var beforeJoin []ChannelResponse
	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/workspaces/"+workspaceID+"/channels", nil, WorkspaceHeaders(member.AccessToken, workspaceID), &beforeJoin)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Len(t, beforeJoin, 1)
	require.Equal(t, "all", beforeJoin[0].Name)

	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/workspaces/"+workspaceID+"/channels/"+publicChannel.ID+"/join", nil, WorkspaceHeaders(member.AccessToken, workspaceID), nil)
	require.Equal(t, http.StatusOK, response.StatusCode)

	var afterJoin []ChannelResponse
	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/workspaces/"+workspaceID+"/channels", nil, WorkspaceHeaders(member.AccessToken, workspaceID), &afterJoin)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Len(t, afterJoin, 2)

	channelNames := make([]string, 0, len(afterJoin))
	for _, channel := range afterJoin {
		channelNames = append(channelNames, channel.Name)
	}
	require.Contains(t, channelNames, "all")
	require.Contains(t, channelNames, "announcements")

	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/workspaces/"+workspaceID+"/channels/discover", nil, WorkspaceHeaders(member.AccessToken, workspaceID), &discoverList)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Empty(t, discoverList)
}

func TestPrivateChannelIsHiddenFromNonMembers(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, "hidden-owner@example.com", "hidden-owner")
	admin := AcceptTaskHTTPInvite(t, runtime, client, server.URL, owner.User.ID, workspaceID, "hidden-admin@example.com", "hidden-admin")
	guest := AcceptTaskHTTPInvite(t, runtime, client, server.URL, owner.User.ID, workspaceID, "hidden-guest@example.com", "hidden-guest")

	adminMemberID := TaskWorkspaceMemberIDForUser(t, runtime, workspaceID, admin.User.ID)
	response := DoJSON(t, client, http.MethodPatch, server.URL+"/api/v1/workspaces/"+workspaceID+"/members/"+adminMemberID, map[string]any{
		"role": "admin",
	}, AuthHeaders(owner.AccessToken), nil)
	require.Equal(t, http.StatusOK, response.StatusCode)

	var privateChannel ChannelResponse
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/workspaces/"+workspaceID+"/channels", map[string]any{
		"name": "exec-room",
	}, WorkspaceHeaders(owner.AccessToken, workspaceID), &privateChannel)
	require.Equal(t, http.StatusOK, response.StatusCode)

	var adminChannels []ChannelResponse
	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/workspaces/"+workspaceID+"/channels", nil, WorkspaceHeaders(admin.AccessToken, workspaceID), &adminChannels)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Len(t, adminChannels, 1)
	require.Equal(t, "all", adminChannels[0].Name)

	var discoverList []map[string]any
	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/workspaces/"+workspaceID+"/channels/discover", nil, WorkspaceHeaders(admin.AccessToken, workspaceID), &discoverList)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Empty(t, discoverList)

	var apiErr struct {
		Error string `json:"error"`
	}
	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/workspaces/"+workspaceID+"/channels/discover/"+privateChannel.ID, nil, WorkspaceHeaders(admin.AccessToken, workspaceID), &apiErr)
	require.Equal(t, http.StatusNotFound, response.StatusCode)
	require.Equal(t, "not found", apiErr.Error)

	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/workspaces/"+workspaceID+"/channels/"+privateChannel.ID+"/members", nil, WorkspaceHeaders(admin.AccessToken, workspaceID), &apiErr)
	require.Equal(t, http.StatusNotFound, response.StatusCode)
	require.Equal(t, "not found", apiErr.Error)

	response = DoJSON(t, client, http.MethodPatch, server.URL+"/api/v1/workspaces/"+workspaceID+"/channels/"+privateChannel.ID, map[string]any{
		"description": "admin should not see this channel",
	}, WorkspaceHeaders(admin.AccessToken, workspaceID), &apiErr)
	require.Equal(t, http.StatusNotFound, response.StatusCode)
	require.Equal(t, "not found", apiErr.Error)

	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/workspaces/"+workspaceID+"/channels/"+privateChannel.ID+"/members", map[string]any{
		"userId": guest.User.ID,
	}, WorkspaceHeaders(admin.AccessToken, workspaceID), &apiErr)
	require.Equal(t, http.StatusNotFound, response.StatusCode)
	require.Equal(t, "not found", apiErr.Error)

	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/workspaces/"+workspaceID+"/channels/"+privateChannel.ID+"/join", nil, WorkspaceHeaders(admin.AccessToken, workspaceID), &apiErr)
	require.Equal(t, http.StatusNotFound, response.StatusCode)
	require.Equal(t, "not found", apiErr.Error)
}

func TestChannelOwnerMustTransferBeforeLeaving(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, "owner-transfer-owner@example.com", "owner-transfer-owner")
	member := AcceptTaskHTTPInvite(t, runtime, client, server.URL, owner.User.ID, workspaceID, "owner-transfer-member@example.com", "owner-transfer-member")

	var channel ChannelResponse
	response := DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/workspaces/"+workspaceID+"/channels", map[string]any{
		"name": "handoff-room",
	}, WorkspaceHeaders(owner.AccessToken, workspaceID), &channel)
	require.Equal(t, http.StatusOK, response.StatusCode)

	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/workspaces/"+workspaceID+"/channels/"+channel.ID+"/members", map[string]any{
		"userId": member.User.ID,
	}, WorkspaceHeaders(owner.AccessToken, workspaceID), nil)
	require.Equal(t, http.StatusOK, response.StatusCode)

	var apiErr struct {
		Error string `json:"error"`
	}
	response = DoJSON(t, client, http.MethodDelete, server.URL+"/api/v1/workspaces/"+workspaceID+"/channels/"+channel.ID+"/members/user/"+owner.User.ID, nil, WorkspaceHeaders(owner.AccessToken, workspaceID), &apiErr)
	require.Equal(t, http.StatusBadRequest, response.StatusCode)
	require.Equal(t, "channel owner must transfer ownership before leaving", apiErr.Error)

	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/workspaces/"+workspaceID+"/channels/"+channel.ID+"/leave", nil, WorkspaceHeaders(owner.AccessToken, workspaceID), &apiErr)
	require.Equal(t, http.StatusBadRequest, response.StatusCode)
	require.Equal(t, "channel owner must transfer ownership before leaving", apiErr.Error)

	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/workspaces/"+workspaceID+"/channels/"+channel.ID+"/owner", map[string]any{
		"userId": member.User.ID,
	}, WorkspaceHeaders(owner.AccessToken, workspaceID), nil)
	require.Equal(t, http.StatusOK, response.StatusCode)

	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/workspaces/"+workspaceID+"/channels/"+channel.ID+"/leave", nil, WorkspaceHeaders(owner.AccessToken, workspaceID), nil)
	require.Equal(t, http.StatusOK, response.StatusCode)

	var ownerMemberID string
	require.NoError(t, runtime.Pool.QueryRow(runtime.Ctx, `
SELECT m.id
FROM members m
WHERE m.workspace_id = $1 AND m.user_id = $2 AND m.type = 'human'
`, workspaceID, member.User.ID).Scan(&ownerMemberID))

	var currentOwnerMemberID *string
	require.NoError(t, runtime.Pool.QueryRow(runtime.Ctx, `
SELECT owner_member_id
FROM scope_channel_profiles
WHERE scope_id = $1
`, channel.ID).Scan(&currentOwnerMemberID))
	require.NotNil(t, currentOwnerMemberID)
	require.Equal(t, ownerMemberID, *currentOwnerMemberID)
}

func TestJoinedMemberCanInviteButOnlyOwnerCanRemoveOrUpdate(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, "member-invite-owner@example.com", "member-invite-owner")
	member := AcceptTaskHTTPInvite(t, runtime, client, server.URL, owner.User.ID, workspaceID, "member-invite-member@example.com", "member-invite-member")
	guest := AcceptTaskHTTPInvite(t, runtime, client, server.URL, owner.User.ID, workspaceID, "member-invite-guest@example.com", "member-invite-guest")

	var channel ChannelResponse
	response := DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/workspaces/"+workspaceID+"/channels", map[string]any{
		"name": "member-invite-room",
	}, WorkspaceHeaders(owner.AccessToken, workspaceID), &channel)
	require.Equal(t, http.StatusOK, response.StatusCode)

	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/workspaces/"+workspaceID+"/channels/"+channel.ID+"/members", map[string]any{
		"userId": member.User.ID,
	}, WorkspaceHeaders(owner.AccessToken, workspaceID), nil)
	require.Equal(t, http.StatusOK, response.StatusCode)

	var addable dto.ChannelAddablePeopleResponse
	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/workspaces/"+workspaceID+"/channels/"+channel.ID+"/addable-people", nil, WorkspaceHeaders(member.AccessToken, workspaceID), &addable)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Empty(t, addable.Services)
	addableUserIDs := make([]string, 0, len(addable.Humans))
	for _, human := range addable.Humans {
		addableUserIDs = append(addableUserIDs, human.UserID)
	}
	require.Contains(t, addableUserIDs, guest.User.ID)

	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/workspaces/"+workspaceID+"/channels/"+channel.ID+"/members", map[string]any{
		"userId": guest.User.ID,
	}, WorkspaceHeaders(member.AccessToken, workspaceID), nil)
	require.Equal(t, http.StatusOK, response.StatusCode)

	var machineResponse dto.CreateMachineResponse
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/workspaces/"+workspaceID+"/machines", map[string]any{
		"name": "member-invite-machine",
	}, AuthHeaders(member.AccessToken), &machineResponse)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.NotEmpty(t, machineResponse.Machine.ID)

	var agent agent.AppAgent
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/workspaces/"+workspaceID+"/agents", map[string]any{
		"name":      "member-invite-agent",
		"runtime":   "codex",
		"machineId": machineResponse.Machine.ID,
	}, WorkspaceHeaders(member.AccessToken, workspaceID), &agent)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.NotEmpty(t, agent.ID)

	var apiErr struct {
		Error string `json:"error"`
	}
	apiErr = struct {
		Error string `json:"error"`
	}{}
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/workspaces/"+workspaceID+"/channels/"+channel.ID+"/members/batch", map[string]any{
		"members": []map[string]any{
			{"agentId": agent.ID},
		},
	}, WorkspaceHeaders(member.AccessToken, workspaceID), nil)
	require.Equal(t, http.StatusOK, response.StatusCode)

	var afterMemberAdds message.ChannelMemberList
	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/workspaces/"+workspaceID+"/channels/"+channel.ID+"/members", nil, WorkspaceHeaders(owner.AccessToken, workspaceID), &afterMemberAdds)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Len(t, afterMemberAdds.Humans, 3)
	require.Len(t, afterMemberAdds.PrivateAgents, 1)
	require.Empty(t, afterMemberAdds.Services)

	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/workspaces/"+workspaceID+"/channels/"+channel.ID+"/members", map[string]any{
		"agentId": agent.ID,
	}, WorkspaceHeaders(member.AccessToken, workspaceID), nil)
	require.Equal(t, http.StatusOK, response.StatusCode)

	var invitedMemberList message.ChannelMemberList
	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/workspaces/"+workspaceID+"/channels/"+channel.ID+"/members", nil, WorkspaceHeaders(member.AccessToken, workspaceID), &invitedMemberList)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Len(t, invitedMemberList.Humans, 3)
	require.Len(t, invitedMemberList.Agents, 0)
	require.Len(t, invitedMemberList.PrivateAgents, 1)
	require.Empty(t, invitedMemberList.Services)
	require.Equal(t, agent.ID, invitedMemberList.PrivateAgents[0].ID)

	response = DoJSON(t, client, http.MethodPatch, server.URL+"/api/v1/workspaces/"+workspaceID+"/channels/"+channel.ID, map[string]any{
		"description": "member should not be allowed to update",
	}, WorkspaceHeaders(member.AccessToken, workspaceID), &apiErr)
	require.Equal(t, http.StatusForbidden, response.StatusCode)
	require.Equal(t, "forbidden", apiErr.Error)

	response = DoJSON(t, client, http.MethodDelete, server.URL+"/api/v1/workspaces/"+workspaceID+"/channels/"+channel.ID+"/members/user/"+guest.User.ID, nil, WorkspaceHeaders(member.AccessToken, workspaceID), &apiErr)
	require.Equal(t, http.StatusForbidden, response.StatusCode)
	require.Equal(t, "forbidden", apiErr.Error)

	response = DoJSON(t, client, http.MethodDelete, server.URL+"/api/v1/workspaces/"+workspaceID+"/channels/"+channel.ID, nil, WorkspaceHeaders(member.AccessToken, workspaceID), &apiErr)
	require.Equal(t, http.StatusForbidden, response.StatusCode)
	require.Equal(t, "forbidden", apiErr.Error)

	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/workspaces/"+workspaceID+"/channels/"+channel.ID+"/owner", map[string]any{
		"userId": owner.User.ID,
	}, WorkspaceHeaders(member.AccessToken, workspaceID), &apiErr)
	require.Equal(t, http.StatusForbidden, response.StatusCode)
	require.Equal(t, "forbidden", apiErr.Error)

	var memberList message.ChannelMemberList
	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/workspaces/"+workspaceID+"/channels/"+channel.ID+"/members", nil, WorkspaceHeaders(owner.AccessToken, workspaceID), &memberList)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Len(t, memberList.Humans, 3)
	require.Len(t, memberList.Agents, 0)
	require.Len(t, memberList.PrivateAgents, 1)
	require.Empty(t, memberList.Services)
}

func TestPrivateChannelRemovalDropsUnreadAndSyncVisibility(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, "removal-owner@example.com", "removal-owner")
	member := AcceptTaskHTTPInvite(t, runtime, client, server.URL, owner.User.ID, workspaceID, "removal-member@example.com", "removal-member")

	var privateChannel ChannelResponse
	response := DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/workspaces/"+workspaceID+"/channels", map[string]any{
		"name": "removal-room",
	}, WorkspaceHeaders(owner.AccessToken, workspaceID), &privateChannel)
	require.Equal(t, http.StatusOK, response.StatusCode)

	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/workspaces/"+workspaceID+"/channels/"+privateChannel.ID+"/members", map[string]any{
		"userId": member.User.ID,
	}, WorkspaceHeaders(owner.AccessToken, workspaceID), nil)
	require.Equal(t, http.StatusOK, response.StatusCode)

	var postedMessage message.AppMessage
	response = DoJSON(t, client, http.MethodPost, server.URL+WorkspaceScopeMessagesPath(workspaceID, privateChannel.ID), map[string]any{
		"scopeId": privateChannel.ID,
		"content": "removal-hidden-needle",
	}, WorkspaceHeaders(owner.AccessToken, workspaceID), &postedMessage)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Equal(t, privateChannel.ID, postedMessage.ChannelID)

	var memberWorkspaces []dto.WorkspaceResponse
	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/workspaces", nil, AuthHeaders(member.AccessToken), &memberWorkspaces)
	require.Equal(t, http.StatusOK, response.StatusCode)
	requireWorkspaceUnreadCount(t, memberWorkspaces, workspaceID, 0)

	var memberPage message.AppMessagePage
	response = DoJSON(t, client, http.MethodGet, server.URL+WorkspaceScopeMessagesPathWithLimit(workspaceID, privateChannel.ID, 50), nil, WorkspaceHeaders(member.AccessToken, workspaceID), &memberPage)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Contains(t, CollectAppMessageIDs(memberPage.Messages), postedMessage.ID)

	response = DoJSON(t, client, http.MethodDelete, server.URL+"/api/v1/workspaces/"+workspaceID+"/channels/"+privateChannel.ID+"/members/user/"+member.User.ID, nil, WorkspaceHeaders(owner.AccessToken, workspaceID), nil)
	require.Equal(t, http.StatusOK, response.StatusCode)

	memberWorkspaces = nil
	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/workspaces", nil, AuthHeaders(member.AccessToken), &memberWorkspaces)
	require.Equal(t, http.StatusOK, response.StatusCode)
	requireWorkspaceUnreadCount(t, memberWorkspaces, workspaceID, 0)

	var removedPageError struct {
		Error string `json:"error"`
	}
	response = DoJSON(t, client, http.MethodGet, server.URL+WorkspaceScopeMessagesPathWithLimit(workspaceID, privateChannel.ID, 50), nil, WorkspaceHeaders(member.AccessToken, workspaceID), &removedPageError)
	require.Equal(t, http.StatusNotFound, response.StatusCode)

	var apiErr struct {
		Error string `json:"error"`
	}
	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/workspaces/"+workspaceID+"/channels/"+privateChannel.ID+"/threads?limit=100", nil, WorkspaceHeaders(member.AccessToken, workspaceID), &apiErr)
	require.Equal(t, http.StatusNotFound, response.StatusCode)
	require.Equal(t, "not found", apiErr.Error)
}

func uploadChannelTestAttachment(t *testing.T, client *http.Client, baseURL string, accessToken string, workspaceID string, channelID string, filename string, content []byte) *http.Response {
	t.Helper()

	var multipartBody bytes.Buffer
	writer := multipart.NewWriter(&multipartBody)
	require.NoError(t, writer.WriteField("scopeId", channelID))
	part, err := writer.CreateFormFile("files", filename)
	require.NoError(t, err)
	_, err = part.Write(content)
	require.NoError(t, err)
	require.NoError(t, writer.Close())

	request, err := http.NewRequestWithContext(context.Background(), http.MethodPost, baseURL+"/api/v1/workspaces/"+workspaceID+"/attachments", &multipartBody)
	require.NoError(t, err)
	request.Header.Set("Content-Type", writer.FormDataContentType())
	request.Header.Set("Authorization", fmt.Sprintf("Bearer %s", accessToken))
	request.Header.Set("X-Workspace-Id", workspaceID)

	response, err := client.Do(request)
	require.NoError(t, err)
	t.Cleanup(func() {
		_ = response.Body.Close()
	})
	return response
}

func TestPublicChannelPreviewDoesNotExposeMessagesBeforeJoin(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, "preview-owner@example.com", "preview-owner")
	member := AcceptTaskHTTPInvite(t, runtime, client, server.URL, owner.User.ID, workspaceID, "preview-member@example.com", "preview-member")

	var publicChannel ChannelResponse
	response := DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/workspaces/"+workspaceID+"/channels", map[string]any{
		"name": "preview-lobby",
	}, WorkspaceHeaders(owner.AccessToken, workspaceID), &publicChannel)
	require.Equal(t, http.StatusOK, response.StatusCode)
	response = DoJSON(t, client, http.MethodPatch, server.URL+"/api/v1/workspaces/"+workspaceID+"/channels/"+publicChannel.ID, map[string]any{
		"description": "public preview should stay metadata-only",
		"visibility":  "public",
	}, WorkspaceHeaders(owner.AccessToken, workspaceID), &publicChannel)
	require.Equal(t, http.StatusOK, response.StatusCode)

	var postedMessage message.AppMessage
	response = DoJSON(t, client, http.MethodPost, server.URL+WorkspaceScopeMessagesPath(workspaceID, publicChannel.ID), map[string]any{
		"scopeId": publicChannel.ID,
		"content": "public-preview-hidden-needle",
	}, WorkspaceHeaders(owner.AccessToken, workspaceID), &postedMessage)
	require.Equal(t, http.StatusOK, response.StatusCode)

	var preview map[string]any
	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/workspaces/"+workspaceID+"/channels/discover/"+publicChannel.ID, nil, WorkspaceHeaders(member.AccessToken, workspaceID), &preview)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Equal(t, publicChannel.ID, preview["id"])
	require.Equal(t, "preview-lobby", preview["name"])
	require.Equal(t, "public preview should stay metadata-only", preview["description"])
	_, hasMessages := preview["messages"]
	require.False(t, hasMessages)
	_, hasThreads := preview["threads"]
	require.False(t, hasThreads)

	var apiErr struct {
		Error string `json:"error"`
	}
	response = DoJSON(t, client, http.MethodGet, server.URL+WorkspaceScopeMessagesPathWithLimit(workspaceID, publicChannel.ID, 50), nil, WorkspaceHeaders(member.AccessToken, workspaceID), &apiErr)
	require.Equal(t, http.StatusForbidden, response.StatusCode)
	require.Equal(t, "forbidden", apiErr.Error)

	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/workspaces/"+workspaceID+"/channels/"+publicChannel.ID+"/threads?limit=100", nil, WorkspaceHeaders(member.AccessToken, workspaceID), &apiErr)
	require.Equal(t, http.StatusForbidden, response.StatusCode)
	require.Equal(t, "forbidden", apiErr.Error)

}

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

	runtime := StartRuntime(t)

	router := gin.New()
	runtime.RegisterRoutes(router)
	server := httptest.NewServer(router)
	defer server.Close()

	client := server.Client()

	registerAndAcceptInvite := func(email string, name string, inviteToken string) dto.AuthTokensResponse {
		t.Helper()

		var auth dto.AuthTokensResponse
		response := DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/auth/register", map[string]any{
			"email":    email,
			"password": TestUserPassword,
			"name":     name,
		}, nil, &auth)
		require.Equal(t, http.StatusOK, response.StatusCode)

		response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/auth/accept-invite", map[string]any{
			"token": inviteToken,
		}, map[string]string{
			"Authorization": fmt.Sprintf("Bearer %s", auth.AccessToken),
		}, nil)
		require.Equal(t, http.StatusOK, response.StatusCode)

		return auth
	}

	uploadAttachment := func(accessToken string, workspaceID string, channelID string, filename string, content []byte) *http.Response {
		t.Helper()

		var multipartBody bytes.Buffer
		writer := multipart.NewWriter(&multipartBody)
		require.NoError(t, writer.WriteField("scopeId", channelID))
		part, err := writer.CreateFormFile("files", filename)
		require.NoError(t, err)
		_, err = part.Write(content)
		require.NoError(t, err)
		require.NoError(t, writer.Close())

		request, err := http.NewRequestWithContext(context.Background(), http.MethodPost, server.URL+"/api/v1/workspaces/"+workspaceID+"/attachments", &multipartBody)
		require.NoError(t, err)
		request.Header.Set("Content-Type", writer.FormDataContentType())
		request.Header.Set("Authorization", fmt.Sprintf("Bearer %s", accessToken))
		request.Header.Set("X-Workspace-Id", workspaceID)

		response, err := client.Do(request)
		require.NoError(t, err)
		t.Cleanup(func() {
			_ = response.Body.Close()
		})
		return response
	}

	var owner dto.AuthTokensResponse
	response := DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/auth/register", map[string]any{
		"email":    "owner@example.com",
		"password": TestUserPassword,
		"name":     "Owner Name",
	}, nil, &owner)
	require.Equal(t, http.StatusOK, response.StatusCode)

	var workspaces []dto.WorkspaceResponse
	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/workspaces", nil, map[string]string{
		"Authorization": fmt.Sprintf("Bearer %s", owner.AccessToken),
	}, &workspaces)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.NotEmpty(t, workspaces)
	workspaceID := workspaces[0].ID

	adminInviteToken := "tok_private_channel_message_admin"
	_, err := runtime.Store.CreateWorkspaceInvite(
		runtime.Ctx,
		owner.User.ID,
		workspaceID,
		"admin@example.com",
		secrets.HashToken(adminInviteToken),
		time.Now().UTC().Add(10*time.Minute),
	)
	require.NoError(t, err)

	admin := registerAndAcceptInvite("admin@example.com", "Admin Name", adminInviteToken)

	var privateChannel ChannelResponse
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/workspaces/"+workspaceID+"/channels", map[string]any{
		"name": "restricted",
	}, map[string]string{
		"Authorization":  fmt.Sprintf("Bearer %s", owner.AccessToken),
		"X-Workspace-Id": workspaceID,
	}, &privateChannel)
	require.Equal(t, http.StatusOK, response.StatusCode)

	ownerUploadResponse := uploadAttachment(owner.AccessToken, workspaceID, privateChannel.ID, "private.txt", []byte("owner private attachment"))
	require.Equal(t, http.StatusOK, ownerUploadResponse.StatusCode)
	var uploaded struct {
		Attachments []attachment.AppAttachment `json:"attachments"`
	}
	require.NoError(t, json.NewDecoder(ownerUploadResponse.Body).Decode(&uploaded))
	require.Len(t, uploaded.Attachments, 1)

	var parentMessage message.AppMessage
	response = DoJSON(t, client, http.MethodPost, server.URL+WorkspaceScopeMessagesPath(workspaceID, privateChannel.ID), map[string]any{
		"scopeId":       privateChannel.ID,
		"content":       "private parent",
		"attachmentIds": []string{uploaded.Attachments[0].ID},
	}, map[string]string{
		"Authorization":  fmt.Sprintf("Bearer %s", owner.AccessToken),
		"X-Workspace-Id": workspaceID,
	}, &parentMessage)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Len(t, parentMessage.Attachments, 1)

	adminHeaders := map[string]string{
		"Authorization":  fmt.Sprintf("Bearer %s", admin.AccessToken),
		"X-Workspace-Id": workspaceID,
	}
	assertForbiddenMessageSpace := func() {
		t.Helper()

		var apiErr struct {
			Error string `json:"error"`
		}

		response := DoJSON(t, client, http.MethodGet, server.URL+WorkspaceScopeMessagesPathWithLimit(workspaceID, privateChannel.ID, 50), nil, adminHeaders, &apiErr)
		require.Equal(t, http.StatusNotFound, response.StatusCode)
		require.Equal(t, "not found", apiErr.Error)

		response = DoJSON(t, client, http.MethodPost, server.URL+WorkspaceScopeMessagesPath(workspaceID, privateChannel.ID), map[string]any{
			"scopeId": privateChannel.ID,
			"content": "admin should still be blocked",
		}, adminHeaders, &apiErr)
		require.Equal(t, http.StatusNotFound, response.StatusCode)
		require.Equal(t, "not found", apiErr.Error)

		uploadResponse := uploadAttachment(admin.AccessToken, workspaceID, privateChannel.ID, "blocked.txt", []byte("blocked"))
		require.Equal(t, http.StatusNotFound, uploadResponse.StatusCode)
		require.NoError(t, json.NewDecoder(uploadResponse.Body).Decode(&apiErr))
		require.Equal(t, "not found", apiErr.Error)

		response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/workspaces/"+workspaceID+"/channels/"+privateChannel.ID+"/threads?limit=100", nil, adminHeaders, &apiErr)
		require.Equal(t, http.StatusNotFound, response.StatusCode)
		require.Equal(t, "not found", apiErr.Error)

		response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/workspaces/"+workspaceID+"/channels/"+privateChannel.ID+"/threads", map[string]any{
			"parentMessageId": parentMessage.ID,
		}, adminHeaders, &apiErr)
		require.Equal(t, http.StatusNotFound, response.StatusCode)
		require.Equal(t, "not found", apiErr.Error)

		downloadRequest, err := http.NewRequestWithContext(context.Background(), http.MethodGet, server.URL+"/api/v1/workspaces/"+workspaceID+"/attachments/"+uploaded.Attachments[0].ID, nil)
		require.NoError(t, err)
		downloadRequest.Header.Set("Authorization", fmt.Sprintf("Bearer %s", admin.AccessToken))
		downloadResponse, err := client.Do(downloadRequest)
		require.NoError(t, err)
		defer downloadResponse.Body.Close()
		require.Equal(t, http.StatusNotFound, downloadResponse.StatusCode)
		require.NoError(t, json.NewDecoder(downloadResponse.Body).Decode(&apiErr))
		require.Equal(t, "not found", apiErr.Error)

		var adminWorkspaces []dto.WorkspaceResponse
		response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/workspaces", nil, AuthHeaders(admin.AccessToken), &adminWorkspaces)
		require.Equal(t, http.StatusOK, response.StatusCode)
		requireWorkspaceUnreadCount(t, adminWorkspaces, workspaceID, 0)
	}

	assertForbiddenMessageSpace()

	adminMemberID := TaskWorkspaceMemberIDForUser(t, runtime, workspaceID, admin.User.ID)
	response = DoJSON(t, client, http.MethodPatch, server.URL+"/api/v1/workspaces/"+workspaceID+"/members/"+adminMemberID, map[string]any{
		"role": "admin",
	}, map[string]string{
		"Authorization": fmt.Sprintf("Bearer %s", owner.AccessToken),
	}, nil)
	require.Equal(t, http.StatusOK, response.StatusCode)

	assertForbiddenMessageSpace()

	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/workspaces/"+workspaceID+"/channels/"+privateChannel.ID+"/members", map[string]any{
		"userId": admin.User.ID,
	}, map[string]string{
		"Authorization":  fmt.Sprintf("Bearer %s", owner.AccessToken),
		"X-Workspace-Id": workspaceID,
	}, nil)
	require.Equal(t, http.StatusOK, response.StatusCode)

	var page message.AppMessagePage
	response = DoJSON(t, client, http.MethodGet, server.URL+WorkspaceScopeMessagesPathWithLimit(workspaceID, privateChannel.ID, 50), nil, adminHeaders, &page)
	require.Equal(t, http.StatusOK, response.StatusCode)
	var visibleParent *message.AppMessage
	for i := range page.Messages {
		if page.Messages[i].ID == parentMessage.ID {
			visibleParent = &page.Messages[i]
			break
		}
	}
	require.NotNil(t, visibleParent)
	require.Len(t, visibleParent.Attachments, 1)

	adminUploadResponse := uploadAttachment(admin.AccessToken, workspaceID, privateChannel.ID, "allowed.txt", []byte("allowed"))
	require.Equal(t, http.StatusOK, adminUploadResponse.StatusCode)
	var adminUploaded struct {
		Attachments []attachment.AppAttachment `json:"attachments"`
	}
	require.NoError(t, json.NewDecoder(adminUploadResponse.Body).Decode(&adminUploaded))
	require.Len(t, adminUploaded.Attachments, 1)

	var sentMessage message.AppMessage
	response = DoJSON(t, client, http.MethodPost, server.URL+WorkspaceScopeMessagesPath(workspaceID, privateChannel.ID), map[string]any{
		"scopeId":       privateChannel.ID,
		"content":       "admin can post after join",
		"attachmentIds": []string{adminUploaded.Attachments[0].ID},
	}, adminHeaders, &sentMessage)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Len(t, sentMessage.Attachments, 1)

	var threadSummaries map[string]message.ReplyThreadSummary
	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/workspaces/"+workspaceID+"/channels/"+privateChannel.ID+"/threads?limit=100", nil, adminHeaders, &threadSummaries)
	require.Equal(t, http.StatusOK, response.StatusCode)

	var replyThread message.ReplyThreadSummary
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/workspaces/"+workspaceID+"/channels/"+privateChannel.ID+"/threads", map[string]any{
		"parentMessageId": parentMessage.ID,
	}, adminHeaders, &replyThread)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.NotEmpty(t, replyThread.ThreadChannelID)

	downloadRequest, err := http.NewRequestWithContext(context.Background(), http.MethodGet, server.URL+"/api/v1/workspaces/"+workspaceID+"/attachments/"+uploaded.Attachments[0].ID, nil)
	require.NoError(t, err)
	downloadRequest.Header.Set("Authorization", fmt.Sprintf("Bearer %s", admin.AccessToken))
	downloadResponse, err := client.Do(downloadRequest)
	require.NoError(t, err)
	defer downloadResponse.Body.Close()
	require.Equal(t, http.StatusOK, downloadResponse.StatusCode)
	downloadedBody, err := io.ReadAll(downloadResponse.Body)
	require.NoError(t, err)
	require.Equal(t, []byte("owner private attachment"), downloadedBody)
}

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

	runtime := StartRuntime(t)

	router := gin.New()
	runtime.RegisterRoutes(router)
	server := httptest.NewServer(router)
	defer server.Close()

	client := server.Client()

	var auth dto.AuthTokensResponse
	response := DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/auth/register", map[string]any{
		"email":    "member@example.com",
		"password": TestUserPassword,
		"name":     "member",
	}, nil, &auth)
	require.Equal(t, http.StatusOK, response.StatusCode)

	var workspaces []dto.WorkspaceResponse
	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/workspaces", nil, map[string]string{
		"Authorization": fmt.Sprintf("Bearer %s", auth.AccessToken),
	}, &workspaces)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.NotEmpty(t, workspaces)
	workspaceID := workspaces[0].ID

	var channels []ChannelResponse
	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/workspaces/"+workspaceID+"/channels", nil, map[string]string{
		"Authorization":  fmt.Sprintf("Bearer %s", auth.AccessToken),
		"X-Workspace-Id": workspaceID,
	}, &channels)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.NotEmpty(t, channels)

	var allChannelID string
	for _, channel := range channels {
		if channel.Name == "all" {
			allChannelID = channel.ID
			break
		}
	}
	require.NotEmpty(t, allChannelID)

	var archiveWorkspaceID string
	var parentThreadID string
	require.NoError(t, runtime.Pool.QueryRow(runtime.Ctx, `
SELECT workspace_id, id
FROM scopes
WHERE id = $1
  AND type = 'channel'
`, allChannelID).Scan(&archiveWorkspaceID, &parentThreadID))

	archivedAt := time.Now().UTC().Add(-48 * time.Hour)
	archiveChunk := make([]archive.Message, 0, archive.ChunkMessageCount)
	parentMessageID := ""
	for seq := int64(1); seq <= archive.ChunkMessageCount; seq++ {
		messageID := fmt.Sprintf("%08x%024x", seq, seq)
		if seq == 42 {
			parentMessageID = messageID
		}
		archiveChunk = append(archiveChunk, archive.Message{
			ID:         messageID,
			Seq:        seq,
			SenderType: "human",
			SenderName: "mem_archived_parent",
			Content:    fmt.Sprintf("archived message %d", seq),
			Timestamp:  archivedAt.Add(time.Duration(seq) * time.Second),
		})
	}
	require.NotEmpty(t, parentMessageID)
	require.NoError(t, runtime.ArchiveStorage.WriteChunk(runtime.Ctx, archiveWorkspaceID, parentThreadID, 1, archiveChunk))
	_, err := runtime.Pool.Exec(runtime.Ctx, `
UPDATE scopes
SET latest_seq = $1,
    latest_global_seq = $1,
    archived_until_seq = $1,
    latest_message_at = $2,
    updated_at = $2
WHERE id = $3
	`, archive.ChunkMessageCount, archivedAt, parentThreadID)
	require.NoError(t, err)

	openArchivedParentThread := func(out any) {
		encoded, err := json.Marshal(map[string]any{
			"parentMessageId": parentMessageID,
		})
		require.NoError(t, err)

		request, err := http.NewRequestWithContext(
			context.Background(),
			http.MethodPost,
			server.URL+"/api/v1/workspaces/"+workspaceID+"/channels/"+allChannelID+"/threads",
			bytes.NewReader(encoded),
		)
		require.NoError(t, err)
		request.Header.Set("Content-Type", "application/json")
		request.Header.Set("Authorization", fmt.Sprintf("Bearer %s", auth.AccessToken))
		request.Header.Set("X-Workspace-Id", workspaceID)

		response, err := client.Do(request)
		require.NoError(t, err)
		defer response.Body.Close()

		body, err := io.ReadAll(response.Body)
		require.NoError(t, err)
		require.Equal(t, http.StatusOK, response.StatusCode, string(body))
		require.NoError(t, json.Unmarshal(body, out), string(body))
	}

	var opened message.ReplyThreadSummary
	openArchivedParentThread(&opened)
	require.NotEmpty(t, opened.ThreadChannelID)
	require.Zero(t, opened.ReplyCount)
	require.Nil(t, opened.LastReplyAt)
	require.Empty(t, opened.ParticipantIDs)

	var reopened message.ReplyThreadSummary
	openArchivedParentThread(&reopened)
	require.Equal(t, opened.ThreadChannelID, reopened.ThreadChannelID)

	var storedParentMessageID string
	var storedParentShortID string
	require.NoError(t, runtime.Pool.QueryRow(runtime.Ctx, `
SELECT root_message_id, root_message_short_id
FROM scopes
WHERE id = $1
`, opened.ThreadChannelID).Scan(&storedParentMessageID, &storedParentShortID))
	require.Equal(t, parentMessageID, storedParentMessageID)
	require.Equal(t, parentMessageID[:8], storedParentShortID)
}

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

	runtime := StartRuntime(t)

	router := gin.New()
	runtime.RegisterRoutes(router)
	server := httptest.NewServer(router)
	defer server.Close()

	client := server.Client()

	var auth dto.AuthTokensResponse
	response := DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/auth/register", map[string]any{
		"email":    "owner@example.com",
		"password": TestUserPassword,
		"name":     "owner",
	}, nil, &auth)
	require.Equal(t, http.StatusOK, response.StatusCode)

	var workspaces []dto.WorkspaceResponse
	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/workspaces", nil, map[string]string{
		"Authorization": fmt.Sprintf("Bearer %s", auth.AccessToken),
	}, &workspaces)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.NotEmpty(t, workspaces)
	workspaceID := workspaces[0].ID

	var channels []ChannelResponse
	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/workspaces/"+workspaceID+"/channels", nil, map[string]string{
		"Authorization":  fmt.Sprintf("Bearer %s", auth.AccessToken),
		"X-Workspace-Id": workspaceID,
	}, &channels)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.NotEmpty(t, channels)
	channelID := channels[0].ID

	var ownerMemberID string
	require.NoError(t, runtime.Pool.QueryRow(runtime.Ctx, `
SELECT id
FROM members
WHERE workspace_id = $1 AND user_id = $2 AND type = 'human'
`, workspaceID, auth.User.ID).Scan(&ownerMemberID))
	require.NotEmpty(t, ownerMemberID)

	var parentMessage message.AppMessage
	response = DoJSON(t, client, http.MethodPost, server.URL+WorkspaceScopeMessagesPath(workspaceID, channelID), map[string]any{
		"scopeId": channelID,
		"content": "owner parent message",
	}, map[string]string{
		"Authorization":  fmt.Sprintf("Bearer %s", auth.AccessToken),
		"X-Workspace-Id": workspaceID,
	}, &parentMessage)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.NotEmpty(t, parentMessage.ID)

	var replyThread message.ReplyThreadSummary
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/workspaces/"+workspaceID+"/channels/"+channelID+"/threads", map[string]any{
		"parentMessageId": parentMessage.ID,
	}, map[string]string{
		"Authorization":  fmt.Sprintf("Bearer %s", auth.AccessToken),
		"X-Workspace-Id": workspaceID,
	}, &replyThread)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.NotEmpty(t, replyThread.ThreadChannelID)

	peerPasswordHash, err := passwords.Hash(TestUserPassword)
	require.NoError(t, err)
	peerUser, err := runtime.Store.Identity().CreateVerifiedUser(runtime.Ctx, "peer@example.com", "peer", "Peer", peerPasswordHash)
	require.NoError(t, err)

	const peerMemberID = "peer001"
	now := time.Now().UTC()
	_, err = runtime.Pool.Exec(runtime.Ctx, `
INSERT INTO members (id, workspace_id, type, display_name, user_id, workspace_role, created_at, updated_at)
VALUES ($1, $2, 'human', $3, $4, 'member', $5, $5)
`, peerMemberID, workspaceID, "Peer", peerUser.ID, now)
	require.NoError(t, err)
	_, err = runtime.Pool.Exec(runtime.Ctx, `
INSERT INTO scope_memberships (
  scope_id, member_id, visibility, owner_member_id, last_read_seq,
  last_read_global_seq, owner_context_dismissed_through_global_seq,
  effective_from_seq, created_at, updated_at
) VALUES ($1, $2, 'public', NULL, 0, 0, 0, 0, $3, $3)
`, channelID, peerMemberID, now)
	require.NoError(t, err)
	_, err = runtime.Pool.Exec(runtime.Ctx, `
INSERT INTO scope_notification_settings (scope_id, member_id, state, created_at, updated_at)
VALUES ($1, $2, 'active', $3, $3)
`, channelID, peerMemberID, now)
	require.NoError(t, err)

	_, _, err = runtime.Store.App().SendMessageForUser(runtime.Ctx, peerUser, workspaceID, replyThread.ThreadChannelID, "first reply", nil)
	require.NoError(t, err)

	getOwnerChannelSummary := func() message.ReplyThreadSummary {
		var summaries map[string]message.ReplyThreadSummary
		response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/workspaces/"+workspaceID+"/channels/"+channelID+"/threads?limit=100", nil, map[string]string{
			"Authorization":  fmt.Sprintf("Bearer %s", auth.AccessToken),
			"X-Workspace-Id": workspaceID,
		}, &summaries)
		require.Equal(t, http.StatusOK, response.StatusCode)
		require.Contains(t, summaries, parentMessage.ID)
		return summaries[parentMessage.ID]
	}
	require.Empty(t, getOwnerChannelSummary().SubscriptionState, "another member's subscription must not leak into the viewer state")

	var threads struct {
		Threads []message.SubscribedThread `json:"threads"`
		HasMore bool                       `json:"hasMore"`
	}
	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/workspaces/"+workspaceID+"/threads?limit=100", nil, map[string]string{
		"Authorization":  fmt.Sprintf("Bearer %s", auth.AccessToken),
		"X-Workspace-Id": workspaceID,
	}, &threads)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Empty(t, threads.Threads)

	var membership message.ThreadMembership
	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/workspaces/"+workspaceID+"/threads/"+replyThread.ThreadChannelID+"/membership", nil, map[string]string{
		"Authorization":  fmt.Sprintf("Bearer %s", auth.AccessToken),
		"X-Workspace-Id": workspaceID,
	}, &membership)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.False(t, membership.Subscribed)

	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/workspaces/"+workspaceID+"/threads/"+replyThread.ThreadChannelID+"/subscribe", map[string]any{}, map[string]string{
		"Authorization":  fmt.Sprintf("Bearer %s", auth.AccessToken),
		"X-Workspace-Id": workspaceID,
	}, &map[string]any{})
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Equal(t, message.HumanScopeSubscriptionStateActive, getOwnerChannelSummary().SubscriptionState)

	for _, state := range []string{
		message.HumanScopeSubscriptionStateQuiet,
		message.HumanScopeSubscriptionStateMute,
	} {
		response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/workspaces/"+workspaceID+"/threads/"+replyThread.ThreadChannelID+"/subscribe", map[string]any{
			"state": state,
		}, map[string]string{
			"Authorization":  fmt.Sprintf("Bearer %s", auth.AccessToken),
			"X-Workspace-Id": workspaceID,
		}, &map[string]any{})
		require.Equal(t, http.StatusOK, response.StatusCode)
		require.Equal(t, state, getOwnerChannelSummary().SubscriptionState)
	}

	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/workspaces/"+workspaceID+"/threads?limit=100", nil, map[string]string{
		"Authorization":  fmt.Sprintf("Bearer %s", auth.AccessToken),
		"X-Workspace-Id": workspaceID,
	}, &threads)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Len(t, threads.Threads, 1)
	require.Equal(t, replyThread.ThreadChannelID, threads.Threads[0].ThreadChannelID)
	require.ElementsMatch(t, []string{auth.User.ID, peerUser.ID}, threads.Threads[0].ParticipantIDs)

	_, err = runtime.Pool.Exec(runtime.Ctx, `
INSERT INTO im_conversation_index (
  workspace_id, member_id, conversation_id, scope_id, kind, sort_at,
  last_event_seq, pinned_at, archived_at, projection_json, created_at, updated_at
)
VALUES ($1, $2, $3, $4, 'thread', $5, 0, NULL, NULL, JSON_OBJECT('kind', 'replyThread', 'route', $6, 'title', 'thread row'), $5, $5)
ON DUPLICATE KEY UPDATE
  archived_at = NULL,
  projection_json = VALUES(projection_json),
  updated_at = VALUES(updated_at)
`, workspaceID, ownerMemberID, "thread:"+replyThread.ThreadChannelID, replyThread.ThreadChannelID, now.Add(3*time.Minute), "/inbox/channel/"+channelID+"/thread/"+replyThread.ThreadChannelID)
	require.NoError(t, err)

	var indexedThreads dto.ConversationPage
	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/workspaces/"+workspaceID+"/conversations?source=index&kinds=replyThread&limit=100", nil, map[string]string{
		"Authorization":  fmt.Sprintf("Bearer %s", auth.AccessToken),
		"X-Workspace-Id": workspaceID,
	}, &indexedThreads)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Len(t, indexedThreads.Items, 1)
	require.Equal(t, "replyThread", indexedThreads.Items[0].Kind)
	require.Equal(t, replyThread.ThreadChannelID, indexedThreads.Items[0].ScopeID)

	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/workspaces/"+workspaceID+"/threads/"+replyThread.ThreadChannelID+"/unsubscribe", map[string]any{}, map[string]string{
		"Authorization":  fmt.Sprintf("Bearer %s", auth.AccessToken),
		"X-Workspace-Id": workspaceID,
	}, &map[string]any{})
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Empty(t, getOwnerChannelSummary().SubscriptionState)

	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/workspaces/"+workspaceID+"/threads?limit=100", nil, map[string]string{
		"Authorization":  fmt.Sprintf("Bearer %s", auth.AccessToken),
		"X-Workspace-Id": workspaceID,
	}, &threads)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Empty(t, threads.Threads)

	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/workspaces/"+workspaceID+"/conversations?source=index&kinds=replyThread&limit=100", nil, map[string]string{
		"Authorization":  fmt.Sprintf("Bearer %s", auth.AccessToken),
		"X-Workspace-Id": workspaceID,
	}, &indexedThreads)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Empty(t, indexedThreads.Items)

	_, _, err = runtime.Store.App().SendMessageForUser(runtime.Ctx, peerUser, workspaceID, replyThread.ThreadChannelID, "ordinary reply after owner unsubscribe", nil)
	require.NoError(t, err)

	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/workspaces/"+workspaceID+"/threads?limit=100", nil, map[string]string{
		"Authorization":  fmt.Sprintf("Bearer %s", auth.AccessToken),
		"X-Workspace-Id": workspaceID,
	}, &threads)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Empty(t, threads.Threads)

	_, _, err = runtime.Store.App().SendMessageForUser(runtime.Ctx, peerUser, workspaceID, replyThread.ThreadChannelID, "@"+ownerMemberID+" please check", nil)
	require.NoError(t, err)

	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/workspaces/"+workspaceID+"/threads?limit=100", nil, map[string]string{
		"Authorization":  fmt.Sprintf("Bearer %s", auth.AccessToken),
		"X-Workspace-Id": workspaceID,
	}, &threads)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Len(t, threads.Threads, 1)
	require.Equal(t, replyThread.ThreadChannelID, threads.Threads[0].ThreadChannelID)
	require.ElementsMatch(t, []string{auth.User.ID, peerUser.ID}, threads.Threads[0].ParticipantIDs)

	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/workspaces/"+workspaceID+"/threads/"+replyThread.ThreadChannelID+"/unsubscribe", map[string]any{}, map[string]string{
		"Authorization":  fmt.Sprintf("Bearer %s", auth.AccessToken),
		"X-Workspace-Id": workspaceID,
	}, &map[string]any{})
	require.Equal(t, http.StatusOK, response.StatusCode)

	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/workspaces/"+workspaceID+"/threads?limit=100", nil, map[string]string{
		"Authorization":  fmt.Sprintf("Bearer %s", auth.AccessToken),
		"X-Workspace-Id": workspaceID,
	}, &threads)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Empty(t, threads.Threads)

	var peerParentMessage message.AppMessage
	peerParentMessage, _, err = runtime.Store.App().SendMessageForUser(runtime.Ctx, peerUser, workspaceID, channelID, "peer parent message", nil)
	require.NoError(t, err)
	require.NotEmpty(t, peerParentMessage.ID)

	var peerReplyThread message.ReplyThreadSummary
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/workspaces/"+workspaceID+"/channels/"+channelID+"/threads", map[string]any{
		"parentMessageId": peerParentMessage.ID,
	}, map[string]string{
		"Authorization":  fmt.Sprintf("Bearer %s", auth.AccessToken),
		"X-Workspace-Id": workspaceID,
	}, &peerReplyThread)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.NotEmpty(t, peerReplyThread.ThreadChannelID)

	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/workspaces/"+workspaceID+"/threads?limit=100", nil, map[string]string{
		"Authorization":  fmt.Sprintf("Bearer %s", auth.AccessToken),
		"X-Workspace-Id": workspaceID,
	}, &threads)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Empty(t, threads.Threads, "opening another member's thread for reading must not subscribe the viewer")

	var ownerPeerReply message.AppMessage
	response = DoJSON(t, client, http.MethodPost, server.URL+WorkspaceScopeMessagesPath(workspaceID, peerReplyThread.ThreadChannelID), map[string]any{
		"scopeId": peerReplyThread.ThreadChannelID,
		"content": "owner joins peer thread",
	}, map[string]string{
		"Authorization":  fmt.Sprintf("Bearer %s", auth.AccessToken),
		"X-Workspace-Id": workspaceID,
	}, &ownerPeerReply)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.NotEmpty(t, ownerPeerReply.ID)

	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/workspaces/"+workspaceID+"/threads?limit=100", nil, map[string]string{
		"Authorization":  fmt.Sprintf("Bearer %s", auth.AccessToken),
		"X-Workspace-Id": workspaceID,
	}, &threads)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Len(t, threads.Threads, 1)
	require.Equal(t, peerReplyThread.ThreadChannelID, threads.Threads[0].ThreadChannelID)
	require.ElementsMatch(t, []string{auth.User.ID}, threads.Threads[0].ParticipantIDs)

	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/workspaces/"+workspaceID+"/threads/"+peerReplyThread.ThreadChannelID+"/subscribe", map[string]any{}, map[string]string{
		"Authorization":  fmt.Sprintf("Bearer %s", auth.AccessToken),
		"X-Workspace-Id": workspaceID,
	}, &map[string]any{})
	require.Equal(t, http.StatusOK, response.StatusCode)

	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/workspaces/"+workspaceID+"/threads?limit=100", nil, map[string]string{
		"Authorization":  fmt.Sprintf("Bearer %s", auth.AccessToken),
		"X-Workspace-Id": workspaceID,
	}, &threads)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Len(t, threads.Threads, 1)
	require.Equal(t, peerReplyThread.ThreadChannelID, threads.Threads[0].ThreadChannelID)
	require.ElementsMatch(t, []string{auth.User.ID}, threads.Threads[0].ParticipantIDs)

	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/workspaces/"+workspaceID+"/threads/"+peerReplyThread.ThreadChannelID+"/unsubscribe", map[string]any{}, map[string]string{
		"Authorization":  fmt.Sprintf("Bearer %s", auth.AccessToken),
		"X-Workspace-Id": workspaceID,
	}, &map[string]any{})
	require.Equal(t, http.StatusOK, response.StatusCode)

	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/workspaces/"+workspaceID+"/threads?limit=100", nil, map[string]string{
		"Authorization":  fmt.Sprintf("Bearer %s", auth.AccessToken),
		"X-Workspace-Id": workspaceID,
	}, &threads)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Empty(t, threads.Threads)

	_, _, err = runtime.Store.App().SendMessageForUser(runtime.Ctx, peerUser, workspaceID, peerReplyThread.ThreadChannelID, "ordinary reply after participant unsubscribe", nil)
	require.NoError(t, err)

	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/workspaces/"+workspaceID+"/threads?limit=100", nil, map[string]string{
		"Authorization":  fmt.Sprintf("Bearer %s", auth.AccessToken),
		"X-Workspace-Id": workspaceID,
	}, &threads)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Empty(t, threads.Threads)

	_, _, err = runtime.Store.App().SendMessageForUser(runtime.Ctx, peerUser, workspaceID, peerReplyThread.ThreadChannelID, "@"+ownerMemberID+" peer thread mention", nil)
	require.NoError(t, err)

	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/workspaces/"+workspaceID+"/threads?limit=100", nil, map[string]string{
		"Authorization":  fmt.Sprintf("Bearer %s", auth.AccessToken),
		"X-Workspace-Id": workspaceID,
	}, &threads)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Len(t, threads.Threads, 1)
	require.Equal(t, peerReplyThread.ThreadChannelID, threads.Threads[0].ThreadChannelID)
	require.ElementsMatch(t, []string{auth.User.ID, peerUser.ID}, threads.Threads[0].ParticipantIDs)
}

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

	runtime := StartRuntime(t)

	router := gin.New()
	runtime.RegisterRoutes(router)
	server := httptest.NewServer(router)
	defer server.Close()

	client := server.Client()

	var auth dto.AuthTokensResponse
	response := DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/auth/register", map[string]any{
		"email":    "thread-delete-owner@example.com",
		"password": TestUserPassword,
		"name":     "owner",
	}, nil, &auth)
	require.Equal(t, http.StatusOK, response.StatusCode)

	var workspaces []dto.WorkspaceResponse
	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/workspaces", nil, map[string]string{
		"Authorization": fmt.Sprintf("Bearer %s", auth.AccessToken),
	}, &workspaces)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.NotEmpty(t, workspaces)
	workspaceID := workspaces[0].ID

	var channel ChannelResponse
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/workspaces/"+workspaceID+"/channels", map[string]any{
		"name": "channel-delete-target",
	}, map[string]string{
		"Authorization":  fmt.Sprintf("Bearer %s", auth.AccessToken),
		"X-Workspace-Id": workspaceID,
	}, &channel)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.NotEmpty(t, channel.ID)
	channelID := channel.ID

	var parentMessage message.AppMessage
	response = DoJSON(t, client, http.MethodPost, server.URL+WorkspaceScopeMessagesPath(workspaceID, channelID), map[string]any{
		"scopeId": channelID,
		"content": "owner parent message",
	}, map[string]string{
		"Authorization":  fmt.Sprintf("Bearer %s", auth.AccessToken),
		"X-Workspace-Id": workspaceID,
	}, &parentMessage)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.NotEmpty(t, parentMessage.ID)

	var replyThread message.ReplyThreadSummary
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/workspaces/"+workspaceID+"/channels/"+channelID+"/threads", map[string]any{
		"parentMessageId": parentMessage.ID,
	}, map[string]string{
		"Authorization":  fmt.Sprintf("Bearer %s", auth.AccessToken),
		"X-Workspace-Id": workspaceID,
	}, &replyThread)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.NotEmpty(t, replyThread.ThreadChannelID)

	response = DoJSON(t, client, http.MethodPost, server.URL+WorkspaceScopeMessagesPath(workspaceID, replyThread.ThreadChannelID), map[string]any{
		"scopeId": replyThread.ThreadChannelID,
		"content": "owner reply before explicit subscribe",
	}, map[string]string{
		"Authorization":  fmt.Sprintf("Bearer %s", auth.AccessToken),
		"X-Workspace-Id": workspaceID,
	}, &message.AppMessage{})
	require.Equal(t, http.StatusOK, response.StatusCode)

	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/workspaces/"+workspaceID+"/threads/"+replyThread.ThreadChannelID+"/subscribe", map[string]any{}, map[string]string{
		"Authorization":  fmt.Sprintf("Bearer %s", auth.AccessToken),
		"X-Workspace-Id": workspaceID,
	}, &map[string]any{})
	require.Equal(t, http.StatusOK, response.StatusCode)

	var threadsBeforeDelete struct {
		Threads []message.SubscribedThread `json:"threads"`
		HasMore bool                       `json:"hasMore"`
	}
	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/workspaces/"+workspaceID+"/threads?limit=100", nil, map[string]string{
		"Authorization":  fmt.Sprintf("Bearer %s", auth.AccessToken),
		"X-Workspace-Id": workspaceID,
	}, &threadsBeforeDelete)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Len(t, threadsBeforeDelete.Threads, 1)

	require.NoError(t, runtime.Store.App().DeleteChannelOrDMForUser(runtime.Ctx, auth.User.ID, workspaceID, channelID))

	var threadsAfterDelete struct {
		Threads []message.SubscribedThread `json:"threads"`
		HasMore bool                       `json:"hasMore"`
	}
	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/workspaces/"+workspaceID+"/threads?limit=100", nil, map[string]string{
		"Authorization":  fmt.Sprintf("Bearer %s", auth.AccessToken),
		"X-Workspace-Id": workspaceID,
	}, &threadsAfterDelete)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Empty(t, threadsAfterDelete.Threads)

	var scopeCount int
	require.NoError(t, runtime.Pool.QueryRow(runtime.Ctx, `
SELECT COUNT(*)
FROM scopes
WHERE id = $1
`, replyThread.ThreadChannelID).Scan(&scopeCount))
	require.Zero(t, scopeCount)

	var profileCount int
	require.NoError(t, runtime.Pool.QueryRow(runtime.Ctx, `
SELECT COUNT(*)
FROM scope_channel_profiles
WHERE scope_id = $1
`, replyThread.ThreadChannelID).Scan(&profileCount))
	require.Zero(t, profileCount)
}

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

	runtime := StartRuntime(t)

	router := gin.New()
	runtime.RegisterRoutes(router)
	server := httptest.NewServer(router)
	defer server.Close()

	client := server.Client()
	auth, workspaceID, _ := SetupTaskHTTPActor(t, client, server.URL, "recreate-channel-owner@example.com", "recreate-channel-owner")
	headers := WorkspaceHeaders(auth.AccessToken, workspaceID)

	const (
		channelDisplayName = "Recreate Conflict Channel"
		channelSlug        = "recreate-conflict-channel"
	)
	var original ChannelResponse
	response := DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/workspaces/"+workspaceID+"/channels", map[string]any{
		"name": channelDisplayName,
	}, headers, &original)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Equal(t, channelSlug, original.Slug)

	response = DoJSON(t, client, http.MethodDelete, server.URL+"/api/v1/workspaces/"+workspaceID+"/channels/"+original.ID, nil, headers, nil)
	require.Equal(t, http.StatusOK, response.StatusCode)

	var recreated ChannelResponse
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/workspaces/"+workspaceID+"/channels", map[string]any{
		"name": channelDisplayName,
	}, headers, &recreated)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.NotEqual(t, original.ID, recreated.ID)
	require.Equal(t, channelSlug, recreated.Slug)

	var originalStatus, originalSlug, originalDisplayName string
	require.NoError(t, runtime.Pool.QueryRow(runtime.Ctx, `
SELECT s.status, p.slug, p.display_name
FROM scopes s
JOIN scope_channel_profiles p ON p.scope_id = s.id
WHERE s.workspace_id = $1 AND s.id = $2
`, workspaceID, original.ID).Scan(&originalStatus, &originalSlug, &originalDisplayName))
	require.Equal(t, "archived", originalStatus)
	require.NotEqual(t, channelSlug, originalSlug)
	require.Equal(t, channelDisplayName, originalDisplayName)

	var activeSlugCount int
	require.NoError(t, runtime.Pool.QueryRow(runtime.Ctx, `
SELECT COUNT(*)
FROM scopes s
JOIN scope_channel_profiles p ON p.scope_id = s.id
WHERE s.workspace_id = $1
  AND s.status = 'active'
  AND p.slug = $2
`, workspaceID, channelSlug).Scan(&activeSlugCount))
	require.Equal(t, 1, activeSlugCount)
}

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

	runtime := StartRuntime(t)

	router := gin.New()
	runtime.RegisterRoutes(router)
	server := httptest.NewServer(router)
	defer server.Close()

	client := server.Client()
	auth, workspaceID, _ := SetupTaskHTTPActor(t, client, server.URL, "legacy-recreate-owner@example.com", "legacy-recreate-owner")
	headers := WorkspaceHeaders(auth.AccessToken, workspaceID)

	const (
		channelDisplayName = "Legacy Archived Channel"
		channelSlug        = "legacy-archived-channel"
	)
	var original ChannelResponse
	response := DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/workspaces/"+workspaceID+"/channels", map[string]any{
		"name": channelDisplayName,
	}, headers, &original)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Equal(t, channelSlug, original.Slug)

	response = DoJSON(t, client, http.MethodDelete, server.URL+"/api/v1/workspaces/"+workspaceID+"/channels/"+original.ID, nil, headers, nil)
	require.Equal(t, http.StatusOK, response.StatusCode)

	// Simulate data produced before deleted channels released their user-visible
	// slug. The archived scope still holds the original slug and would block
	// recreation unless the create path repairs the legacy holder.
	_, err := runtime.Pool.Exec(runtime.Ctx, `
UPDATE scope_channel_profiles
SET slug = $3
WHERE workspace_id = $1 AND scope_id = $2
`, workspaceID, original.ID, channelSlug)
	require.NoError(t, err)

	var recreated ChannelResponse
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/workspaces/"+workspaceID+"/channels", map[string]any{
		"name": channelDisplayName,
	}, headers, &recreated)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.NotEqual(t, original.ID, recreated.ID)
	require.Equal(t, channelSlug, recreated.Slug)

	var repairedOriginalSlug string
	require.NoError(t, runtime.Pool.QueryRow(runtime.Ctx, `
SELECT p.slug
FROM scopes s
JOIN scope_channel_profiles p ON p.scope_id = s.id
WHERE s.workspace_id = $1 AND s.id = $2 AND s.status = 'archived'
`, workspaceID, original.ID).Scan(&repairedOriginalSlug))
	require.NotEqual(t, channelSlug, repairedOriginalSlug)

	var apiErr struct {
		Error string `json:"error"`
		Code  string `json:"code"`
	}
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/workspaces/"+workspaceID+"/channels", map[string]any{
		"name": channelDisplayName,
	}, headers, &apiErr)
	require.Equal(t, http.StatusConflict, response.StatusCode)
	require.Equal(t, "channel link identifier conflicts with an existing channel in this workspace", apiErr.Error)
	require.Equal(t, "channel_slug_conflict", apiErr.Code)
}

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

	runtime := StartRuntime(t)

	router := gin.New()
	runtime.RegisterRoutes(router)
	server := httptest.NewServer(router)
	defer server.Close()

	client := server.Client()

	var auth dto.AuthTokensResponse
	response := DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/auth/register", map[string]any{
		"email":    "thread-delete-dm-owner@example.com",
		"password": TestUserPassword,
		"name":     "owner",
	}, nil, &auth)
	require.Equal(t, http.StatusOK, response.StatusCode)

	var workspaces []dto.WorkspaceResponse
	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/workspaces", nil, map[string]string{
		"Authorization": fmt.Sprintf("Bearer %s", auth.AccessToken),
	}, &workspaces)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.NotEmpty(t, workspaces)
	workspaceID := workspaces[0].ID

	peerPasswordHash, err := passwords.Hash(TestUserPassword)
	require.NoError(t, err)
	peerUser, err := runtime.Store.Identity().CreateVerifiedUser(runtime.Ctx, "thread-delete-dm-peer@example.com", "peer", "Peer", peerPasswordHash)
	require.NoError(t, err)

	peerMemberID, err := identifiers.NewMemberID()
	require.NoError(t, err)
	now := time.Now().UTC()
	_, err = runtime.Pool.Exec(runtime.Ctx, `
INSERT INTO members (id, workspace_id, type, display_name, user_id, workspace_role, created_at, updated_at)
VALUES ($1, $2, 'human', $3, $4, 'member', $5, $5)
`, peerMemberID, workspaceID, "Peer", peerUser.ID, now)
	require.NoError(t, err)

	dm, err := runtime.Store.App().OpenDMChannelWithUser(runtime.Ctx, auth.User.ID, workspaceID, peerUser.ID)
	require.NoError(t, err)

	var parentMessage message.AppMessage
	response = DoJSON(t, client, http.MethodPost, server.URL+WorkspaceScopeMessagesPath(workspaceID, dm.ID), map[string]any{
		"scopeId": dm.ID,
		"content": "owner parent message",
	}, map[string]string{
		"Authorization":  fmt.Sprintf("Bearer %s", auth.AccessToken),
		"X-Workspace-Id": workspaceID,
	}, &parentMessage)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.NotEmpty(t, parentMessage.ID)

	var replyThread message.ReplyThreadSummary
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/workspaces/"+workspaceID+"/channels/"+dm.ID+"/threads", map[string]any{
		"parentMessageId": parentMessage.ID,
	}, map[string]string{
		"Authorization":  fmt.Sprintf("Bearer %s", auth.AccessToken),
		"X-Workspace-Id": workspaceID,
	}, &replyThread)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.NotEmpty(t, replyThread.ThreadChannelID)

	response = DoJSON(t, client, http.MethodPost, server.URL+WorkspaceScopeMessagesPath(workspaceID, replyThread.ThreadChannelID), map[string]any{
		"scopeId": replyThread.ThreadChannelID,
		"content": "owner reply before explicit subscribe",
	}, map[string]string{
		"Authorization":  fmt.Sprintf("Bearer %s", auth.AccessToken),
		"X-Workspace-Id": workspaceID,
	}, &message.AppMessage{})
	require.Equal(t, http.StatusOK, response.StatusCode)

	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/workspaces/"+workspaceID+"/threads/"+replyThread.ThreadChannelID+"/subscribe", map[string]any{}, map[string]string{
		"Authorization":  fmt.Sprintf("Bearer %s", auth.AccessToken),
		"X-Workspace-Id": workspaceID,
	}, &map[string]any{})
	require.Equal(t, http.StatusOK, response.StatusCode)

	var threadsBeforeDelete struct {
		Threads []message.SubscribedThread `json:"threads"`
		HasMore bool                       `json:"hasMore"`
	}
	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/workspaces/"+workspaceID+"/threads?limit=100", nil, map[string]string{
		"Authorization":  fmt.Sprintf("Bearer %s", auth.AccessToken),
		"X-Workspace-Id": workspaceID,
	}, &threadsBeforeDelete)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Len(t, threadsBeforeDelete.Threads, 1)

	require.NoError(t, runtime.Store.App().DeleteChannelOrDMForUser(runtime.Ctx, auth.User.ID, workspaceID, dm.ID))

	var threadsAfterDelete struct {
		Threads []message.SubscribedThread `json:"threads"`
		HasMore bool                       `json:"hasMore"`
	}
	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/workspaces/"+workspaceID+"/threads?limit=100", nil, map[string]string{
		"Authorization":  fmt.Sprintf("Bearer %s", auth.AccessToken),
		"X-Workspace-Id": workspaceID,
	}, &threadsAfterDelete)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Empty(t, threadsAfterDelete.Threads)

	var scopeCount int
	require.NoError(t, runtime.Pool.QueryRow(runtime.Ctx, `
SELECT COUNT(*)
FROM scopes
WHERE id = $1
`, replyThread.ThreadChannelID).Scan(&scopeCount))
	require.Zero(t, scopeCount)

	var profileCount int
	require.NoError(t, runtime.Pool.QueryRow(runtime.Ctx, `
SELECT COUNT(*)
FROM scope_channel_profiles
WHERE scope_id = $1
`, replyThread.ThreadChannelID).Scan(&profileCount))
	require.Zero(t, profileCount)
}

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

	runtime := StartRuntime(t)

	router := gin.New()
	runtime.RegisterRoutes(router)
	server := httptest.NewServer(router)
	defer server.Close()

	client := server.Client()

	var auth dto.AuthTokensResponse
	response := DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/auth/register", map[string]any{
		"email":    "deleted-agent-thread-owner@example.com",
		"password": TestUserPassword,
		"name":     "owner",
	}, nil, &auth)
	require.Equal(t, http.StatusOK, response.StatusCode)

	var workspaces []dto.WorkspaceResponse
	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/workspaces", nil, map[string]string{
		"Authorization": fmt.Sprintf("Bearer %s", auth.AccessToken),
	}, &workspaces)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.NotEmpty(t, workspaces)
	workspaceID := workspaces[0].ID

	var ownerMemberID string
	require.NoError(t, runtime.Pool.QueryRow(runtime.Ctx, `
SELECT id
FROM members
WHERE workspace_id = $1 AND user_id = $2 AND type = 'human'
`, workspaceID, auth.User.ID).Scan(&ownerMemberID))
	require.NotEmpty(t, ownerMemberID)

	machine, _, err := runtime.Store.App().CreateMachineForWorkspace(runtime.Ctx, auth.User.ID, workspaceID, "deleted-agent-thread-machine")
	require.NoError(t, err)

	createdAgent, err := runtime.Store.CreateAgentForUser(runtime.Ctx, auth.User.ID, workspaceID, machine.ID, agent.CreateAgentParams{
		Name:        "deleted-agent-handle",
		DisplayName: "Deleted Agent Display",
		Runtime:     "codex",
	})
	require.NoError(t, err)

	delivery, err := runtime.Store.SendAgentMessage(runtime.Ctx, agent.AuthenticatedAgent{
		AgentID:     createdAgent.AgentID,
		WorkspaceID: workspaceID,
		MemberID:    createdAgent.MemberID,
	}, message.SendMessageParams{
		DMTo:    ownerMemberID,
		Content: "agent parent message",
	})
	require.NoError(t, err)
	require.NotNil(t, delivery.AppMessage)
	require.NotEmpty(t, delivery.AppMessage.ChannelID)
	require.NotEmpty(t, delivery.MessageID)

	var replyThread message.ReplyThreadSummary
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/workspaces/"+workspaceID+"/channels/"+delivery.AppMessage.ChannelID+"/threads", map[string]any{
		"parentMessageId": delivery.MessageID,
	}, map[string]string{
		"Authorization":  fmt.Sprintf("Bearer %s", auth.AccessToken),
		"X-Workspace-Id": workspaceID,
	}, &replyThread)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.NotEmpty(t, replyThread.ThreadChannelID)

	response = DoJSON(t, client, http.MethodPost, server.URL+WorkspaceScopeMessagesPath(workspaceID, replyThread.ThreadChannelID), map[string]any{
		"scopeId": replyThread.ThreadChannelID,
		"content": "owner reply before explicit subscribe",
	}, map[string]string{
		"Authorization":  fmt.Sprintf("Bearer %s", auth.AccessToken),
		"X-Workspace-Id": workspaceID,
	}, &message.AppMessage{})
	require.Equal(t, http.StatusOK, response.StatusCode)

	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/workspaces/"+workspaceID+"/threads/"+replyThread.ThreadChannelID+"/subscribe", map[string]any{}, map[string]string{
		"Authorization":  fmt.Sprintf("Bearer %s", auth.AccessToken),
		"X-Workspace-Id": workspaceID,
	}, &map[string]any{})
	require.Equal(t, http.StatusOK, response.StatusCode)

	var threadsBeforeDelete struct {
		Threads []message.SubscribedThread `json:"threads"`
		HasMore bool                       `json:"hasMore"`
	}
	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/workspaces/"+workspaceID+"/threads?limit=100", nil, map[string]string{
		"Authorization":  fmt.Sprintf("Bearer %s", auth.AccessToken),
		"X-Workspace-Id": workspaceID,
	}, &threadsBeforeDelete)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Len(t, threadsBeforeDelete.Threads, 1)
	require.Equal(t, "Deleted Agent Display", threadsBeforeDelete.Threads[0].ParentLabel)
	require.Equal(t, "Deleted Agent Display", threadsBeforeDelete.Threads[0].ParentMessageAuthor)
	require.ElementsMatch(t, []string{createdAgent.AgentID, auth.User.ID}, threadsBeforeDelete.Threads[0].ParticipantIDs)

	_, err = runtime.Pool.Exec(runtime.Ctx, `DELETE FROM agents WHERE workspace_id = $1 AND id = $2`, workspaceID, createdAgent.AgentID)
	require.NoError(t, err)

	var threads struct {
		Threads []message.SubscribedThread `json:"threads"`
		HasMore bool                       `json:"hasMore"`
	}
	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/workspaces/"+workspaceID+"/threads?limit=100", nil, map[string]string{
		"Authorization":  fmt.Sprintf("Bearer %s", auth.AccessToken),
		"X-Workspace-Id": workspaceID,
	}, &threads)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Len(t, threads.Threads, 1)
	require.Equal(t, "dm", threads.Threads[0].ParentChannelType)
	require.ElementsMatch(t, []string{auth.User.ID}, threads.Threads[0].ParticipantIDs)
	require.Equal(t, "Deleted Agent Display", threads.Threads[0].ParentLabel)
	require.Equal(t, "Deleted Agent Display", threads.Threads[0].ParentMessageAuthor)
}

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

	runtime := StartRuntime(t)

	router := gin.New()
	runtime.RegisterRoutes(router)
	server := httptest.NewServer(router)
	defer server.Close()

	client := server.Client()

	var auth dto.AuthTokensResponse
	response := DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/auth/register", map[string]any{
		"email":    "member@example.com",
		"password": TestUserPassword,
		"name":     "member",
	}, nil, &auth)
	require.Equal(t, http.StatusOK, response.StatusCode)

	var workspaces []dto.WorkspaceResponse
	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/workspaces", nil, map[string]string{
		"Authorization": fmt.Sprintf("Bearer %s", auth.AccessToken),
	}, &workspaces)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.NotEmpty(t, workspaces)

	workspaceID := workspaces[0].ID

	var channels []ChannelResponse
	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/workspaces/"+workspaceID+"/channels", nil, map[string]string{
		"Authorization":  fmt.Sprintf("Bearer %s", auth.AccessToken),
		"X-Workspace-Id": workspaceID,
	}, &channels)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.NotEmpty(t, channels)

	var allChannelID string
	for _, channel := range channels {
		if channel.Name == "all" {
			allChannelID = channel.ID
			break
		}
	}
	require.NotEmpty(t, allChannelID)

	var apiErr struct {
		Error string `json:"error"`
	}
	response = DoJSON(t, client, http.MethodPatch, server.URL+"/api/v1/workspaces/"+workspaceID+"/channels/"+allChannelID, map[string]any{
		"name": "renamed-all",
	}, map[string]string{
		"Authorization":  fmt.Sprintf("Bearer %s", auth.AccessToken),
		"X-Workspace-Id": workspaceID,
	}, &apiErr)
	require.Equal(t, http.StatusBadRequest, response.StatusCode)
	require.Equal(t, "The #all channel cannot be renamed", apiErr.Error)
}

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

	runtime := StartRuntime(t)

	router := gin.New()
	runtime.RegisterRoutes(router)
	server := httptest.NewServer(router)
	defer server.Close()

	client := server.Client()

	var auth dto.AuthTokensResponse
	response := DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/auth/register", map[string]any{
		"email":    "member@example.com",
		"password": TestUserPassword,
		"name":     "member",
	}, nil, &auth)
	require.Equal(t, http.StatusOK, response.StatusCode)

	var workspaces []dto.WorkspaceResponse
	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/workspaces", nil, map[string]string{
		"Authorization": fmt.Sprintf("Bearer %s", auth.AccessToken),
	}, &workspaces)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.NotEmpty(t, workspaces)

	workspaceID := workspaces[0].ID

	var channels []ChannelResponse
	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/workspaces/"+workspaceID+"/channels", nil, map[string]string{
		"Authorization":  fmt.Sprintf("Bearer %s", auth.AccessToken),
		"X-Workspace-Id": workspaceID,
	}, &channels)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.NotEmpty(t, channels)

	var allChannelID string
	for _, channel := range channels {
		if channel.Name == "all" {
			allChannelID = channel.ID
			break
		}
	}
	require.NotEmpty(t, allChannelID)

	var apiErr struct {
		Error string `json:"error"`
	}
	response = DoJSON(t, client, http.MethodDelete, server.URL+"/api/v1/workspaces/"+workspaceID+"/channels/"+allChannelID+"/members/user/"+auth.User.ID, nil, map[string]string{
		"Authorization":  fmt.Sprintf("Bearer %s", auth.AccessToken),
		"X-Workspace-Id": workspaceID,
	}, &apiErr)
	require.Equal(t, http.StatusBadRequest, response.StatusCode)
	require.Equal(t, "cannot remove members from #all", apiErr.Error)
}

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

	runtime := StartRuntime(t)

	router := gin.New()
	runtime.RegisterRoutes(router)
	server := httptest.NewServer(router)
	defer server.Close()

	client := server.Client()

	var owner dto.AuthTokensResponse
	response := DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/auth/register", map[string]any{
		"email":    "archive-owner@example.com",
		"password": TestUserPassword,
		"name":     "archive-owner",
	}, nil, &owner)
	require.Equal(t, http.StatusOK, response.StatusCode)

	var workspaces []dto.WorkspaceResponse
	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/workspaces", nil, AuthHeaders(owner.AccessToken), &workspaces)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.NotEmpty(t, workspaces)
	workspaceID := workspaces[0].ID

	inviteToken := "tok_archive_member"
	_, err := runtime.Store.CreateWorkspaceInvite(
		runtime.Ctx,
		owner.User.ID,
		workspaceID,
		"archive-member@example.com",
		secrets.HashToken(inviteToken),
		time.Now().UTC().Add(10*time.Minute),
	)
	require.NoError(t, err)

	var member dto.AuthTokensResponse
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/auth/register", map[string]any{
		"email":    "archive-member@example.com",
		"password": TestUserPassword,
		"name":     "archive-member",
	}, nil, &member)
	require.Equal(t, http.StatusOK, response.StatusCode)

	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/auth/accept-invite", map[string]any{
		"token": inviteToken,
	}, AuthHeaders(member.AccessToken), nil)
	require.Equal(t, http.StatusOK, response.StatusCode)

	var targetChannel ChannelResponse
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/workspaces/"+workspaceID+"/channels", map[string]any{
		"name": "archive-target",
	}, WorkspaceHeaders(owner.AccessToken, workspaceID), &targetChannel)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.False(t, targetChannel.Archived)

	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/workspaces/"+workspaceID+"/channels/"+targetChannel.ID+"/members", map[string]any{
		"userId": member.User.ID,
	}, WorkspaceHeaders(owner.AccessToken, workspaceID), nil)
	require.Equal(t, http.StatusOK, response.StatusCode)

	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/workspaces/"+workspaceID+"/channels/"+targetChannel.ID+"/archive", nil, WorkspaceHeaders(owner.AccessToken, workspaceID), &targetChannel)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.True(t, targetChannel.Archived)

	var ownerChannels []ChannelResponse
	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/workspaces/"+workspaceID+"/channels", nil, WorkspaceHeaders(owner.AccessToken, workspaceID), &ownerChannels)
	require.Equal(t, http.StatusOK, response.StatusCode)

	var archivedOwnerChannel *ChannelResponse
	for i := range ownerChannels {
		if ownerChannels[i].ID == targetChannel.ID {
			archivedOwnerChannel = &ownerChannels[i]
			break
		}
	}
	require.NotNil(t, archivedOwnerChannel)
	require.True(t, archivedOwnerChannel.Archived)
	require.True(t, archivedOwnerChannel.Joined)

	var memberChannels []ChannelResponse
	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/workspaces/"+workspaceID+"/channels", nil, WorkspaceHeaders(member.AccessToken, workspaceID), &memberChannels)
	require.Equal(t, http.StatusOK, response.StatusCode)

	var memberChannel *ChannelResponse
	for i := range memberChannels {
		if memberChannels[i].ID == targetChannel.ID {
			memberChannel = &memberChannels[i]
			break
		}
	}
	require.NotNil(t, memberChannel)
	require.False(t, memberChannel.Archived)
	require.True(t, memberChannel.Joined)

	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/workspaces/"+workspaceID+"/channels/"+targetChannel.ID+"/unarchive", nil, WorkspaceHeaders(owner.AccessToken, workspaceID), &targetChannel)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.False(t, targetChannel.Archived)
}

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

	runtime := StartRuntime(t)

	router := gin.New()
	runtime.RegisterRoutes(router)
	server := httptest.NewServer(router)
	defer server.Close()

	client := server.Client()

	var auth dto.AuthTokensResponse
	response := DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/auth/register", map[string]any{
		"email":    "member@example.com",
		"password": TestUserPassword,
		"name":     "member",
	}, nil, &auth)
	require.Equal(t, http.StatusOK, response.StatusCode)

	var workspaces []dto.WorkspaceResponse
	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/workspaces", nil, map[string]string{
		"Authorization": fmt.Sprintf("Bearer %s", auth.AccessToken),
	}, &workspaces)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.NotEmpty(t, workspaces)

	workspaceID := workspaces[0].ID

	var channels []ChannelResponse
	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/workspaces/"+workspaceID+"/channels", nil, map[string]string{
		"Authorization":  fmt.Sprintf("Bearer %s", auth.AccessToken),
		"X-Workspace-Id": workspaceID,
	}, &channels)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.NotEmpty(t, channels)

	var allChannelID string
	for _, channel := range channels {
		if channel.Name == "all" {
			allChannelID = channel.ID
			break
		}
	}
	require.NotEmpty(t, allChannelID)

	var memberList message.ChannelMemberList
	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/workspaces/"+workspaceID+"/channels/"+allChannelID+"/members", nil, map[string]string{
		"Authorization":  fmt.Sprintf("Bearer %s", auth.AccessToken),
		"X-Workspace-Id": workspaceID,
	}, &memberList)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.NotNil(t, memberList.Agents)
	require.NotNil(t, memberList.Humans)
	require.NotEmpty(t, memberList.Humans)
	require.Len(t, memberList.Agents, 0)
}

func requireWorkspaceUnreadCount(t *testing.T, workspaces []dto.WorkspaceResponse, workspaceID string, want int64) {
	t.Helper()
	for _, workspace := range workspaces {
		if workspace.ID == workspaceID {
			require.Equal(t, want, workspace.UnreadCount)
			return
		}
	}
	require.Failf(t, "workspace not found", "workspace %q was not found in %#v", workspaceID, workspaces)
}
