package app

import (
	"context"
	"fmt"
	"strings"
	"testing"
	"time"

	"github.com/stretchr/testify/require"

	"github.com/tidbcloud/AgentTeam/internal/coredb"
	"github.com/tidbcloud/AgentTeam/internal/store/app/registry"
	"github.com/tidbcloud/AgentTeam/internal/store/message"
	"github.com/tidbcloud/AgentTeam/internal/store/model"
	"github.com/tidbcloud/AgentTeam/internal/store/serviceactor"
)

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

	ctx := context.Background()
	pool := startAppRollbackDB(t, ctx)
	appStore := New(pool, Options{AppCredentialStorageKey: testAppCredentialStorageKey()})
	now := time.Now().UTC()
	fixture := seedAppAdminFixture(t, pool, "bot_target_channel", now)
	installation := installAppAppForTargetBindingTests(t, ctx, pool, appStore, fixture, "channel", now)
	channelID := "chn_bot_target_channel_private"
	seedChannelParticipantProjectionForTests(t, pool, fixture.WorkspaceID, channelID, "app-target-channel", "App target channel", "private", fixture.MemberID, fixture.MemberID, now)

	_, err := appStore.AddChannelServiceForUserWithEvent(ctx, fixture.UserID, fixture.WorkspaceID, channelID, installation.MemberID)
	require.NoError(t, err)
	assertActiveAppTargetBinding(t, ctx, pool, fixture.WorkspaceID, installation.ID, installation.MemberID, channelID, "channel", channelID, channelID)

	// Re-adding the App service member keeps one active target binding and one active mention subscription.
	_, err = appStore.AddChannelServiceForUserWithEvent(ctx, fixture.UserID, fixture.WorkspaceID, channelID, installation.MemberID)
	require.NoError(t, err)
	requireAppTargetSingleInt(t, ctx, pool, 1, `
SELECT COUNT(*)
FROM service_target_bindings
WHERE service_installation_id = $1
  AND target_kind = 'channel'
  AND target_ref = $2
  AND status = 'active'
  AND removed_at IS NULL
`, installation.ID, channelID)
	requireAppTargetSingleInt(t, ctx, pool, 1, `
SELECT COUNT(*)
FROM service_event_subscriptions
WHERE service_installation_id = $1
  AND subscription_kind = $2
  AND target_kind = 'channel'
  AND target_ref = $3
  AND status = 'active'
  AND removed_at IS NULL
`, installation.ID, serviceactor.SubscriptionKindAppMention, channelID)

	_, err = appStore.RemoveChannelServiceForUserWithEvent(ctx, fixture.UserID, fixture.WorkspaceID, channelID, installation.MemberID)
	require.NoError(t, err)
	requireAppTargetSingleInt(t, ctx, pool, 0, `
SELECT COUNT(*)
FROM scope_memberships
WHERE scope_id = $1
  AND member_id = $2
  AND removed_at IS NULL
`, channelID, installation.MemberID)
	requireAppTargetSingleInt(t, ctx, pool, 0, `
SELECT COUNT(*)
FROM service_event_subscriptions
WHERE service_installation_id = $1
  AND subscription_kind = $2
  AND target_kind = 'channel'
  AND target_ref = $3
  AND removed_at IS NULL
`, installation.ID, serviceactor.SubscriptionKindAppMention, channelID)
	requireAppTargetSingleInt(t, ctx, pool, 1, `
SELECT COUNT(*)
FROM service_event_subscriptions
WHERE service_installation_id = $1
  AND subscription_kind = $2
  AND target_kind = 'channel'
  AND target_ref = $3
  AND removed_at IS NOT NULL
  AND active_subscription_key IS NULL
`, installation.ID, serviceactor.SubscriptionKindAppMention, channelID)
	var bindingStatus string
	var activeBindingKey string
	require.NoError(t, pool.QueryRow(ctx, `
SELECT status, COALESCE(active_binding_key, '')
FROM service_target_bindings
WHERE service_installation_id = $1
  AND target_kind = 'channel'
  AND target_ref = $2
ORDER BY updated_at DESC
LIMIT 1
`, installation.ID, channelID).Scan(&bindingStatus, &activeBindingKey))
	require.Equal(t, serviceactor.StatusDisabled, bindingStatus)
	require.Empty(t, activeBindingKey)
}

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

	ctx := context.Background()
	pool := startAppRollbackDB(t, ctx)
	appStore := New(pool, Options{AppCredentialStorageKey: testAppCredentialStorageKey()})
	now := time.Now().UTC()
	fixture := seedAppAdminFixture(t, pool, "bot_target_all_channel", now)
	installation := installAppAppForTargetBindingTests(t, ctx, pool, appStore, fixture, "all-channel", now)

	_, err := appStore.AddChannelServiceForUserWithEvent(
		ctx,
		fixture.UserID,
		fixture.WorkspaceID,
		fixture.AllChannelID,
		installation.MemberID,
	)
	require.ErrorIs(t, err, ErrBadRequest)
	require.ErrorContains(t, err, "Apps cannot be added to #all")

	requireAppTargetSingleInt(t, ctx, pool, 0, `
SELECT COUNT(*)
FROM scope_memberships
WHERE scope_id = $1
  AND member_id = $2
  AND removed_at IS NULL
`, fixture.AllChannelID, installation.MemberID)
	requireAppTargetSingleInt(t, ctx, pool, 0, `
SELECT COUNT(*)
FROM service_target_bindings
WHERE service_installation_id = $1
  AND status = 'active'
  AND removed_at IS NULL
`, installation.ID)
	requireAppTargetSingleInt(t, ctx, pool, 0, `
SELECT COUNT(*)
FROM service_event_subscriptions
WHERE service_installation_id = $1
  AND subscription_kind = $2
  AND status = 'active'
  AND removed_at IS NULL
`, installation.ID, serviceactor.SubscriptionKindAppMention)
}

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

	ctx := context.Background()
	pool := startAppRollbackDB(t, ctx)
	appStore := New(pool, Options{AppCredentialStorageKey: testAppCredentialStorageKey()})
	now := time.Now().UTC()
	fixture := seedAppAdminFixture(t, pool, "bot_target_batch", now)
	installation := installAppAppForTargetBindingTests(t, ctx, pool, appStore, fixture, "batch", now)
	channelID := "chn_bot_target_batch_private"
	seedChannelParticipantProjectionForTests(t, pool, fixture.WorkspaceID, channelID, "app-target-batch", "App target batch", "private", fixture.MemberID, fixture.MemberID, now)

	results, err := appStore.AddChannelMembersForUserWithEvents(ctx, fixture.UserID, fixture.WorkspaceID, channelID, []message.ChannelMemberAddRequest{
		{ServiceMemberID: installation.MemberID},
	})
	require.NoError(t, err)
	require.Len(t, results, 1)
	assertActiveAppTargetBinding(t, ctx, pool, fixture.WorkspaceID, installation.ID, installation.MemberID, channelID, "channel", channelID, channelID)

	appMessage, _, err := appStore.SendMessageForUser(
		ctx,
		model.User{ID: fixture.UserID, Name: fixture.UserID, DisplayName: "App Operator"},
		fixture.WorkspaceID,
		channelID,
		"@deploy-app-target-batch ping",
		nil,
	)
	require.NoError(t, err)
	record := loadAppMentionDeliveryForTest(t, ctx, pool, fixture.WorkspaceID, appMessage.ID, installation.ID)
	require.Equal(t, serviceactor.SubscriptionKindAppMention, record.SubscriptionKind)
	require.Equal(t, "channel", record.TargetKind)
	require.Equal(t, channelID, record.TargetRef)
}

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

	ctx := context.Background()
	pool := startAppRollbackDB(t, ctx)
	appStore := New(pool, Options{AppCredentialStorageKey: testAppCredentialStorageKey()})
	now := time.Now().UTC()
	fixture := seedAppAdminFixture(t, pool, "bot_target_matrix", now)
	installation := installAppAppForTargetBindingTests(t, ctx, pool, appStore, fixture, "matrix", now)

	channelID := "chn_bot_target_matrix_channel"
	seedChannelParticipantProjectionForTests(t, pool, fixture.WorkspaceID, channelID, "app-target-matrix", "App target matrix", "private", fixture.MemberID, fixture.MemberID, now)
	channelResult, err := appStore.bindAppInstallationTargetForUser(ctx, fixture.UserID, fixture.WorkspaceID, installation.ID, channelID, AppMentionCreatedViaTarget)
	require.NoError(t, err)
	require.Equal(t, "channel", channelResult.Target.TargetKind)
	assertActiveAppTargetBinding(t, ctx, pool, fixture.WorkspaceID, installation.ID, installation.MemberID, channelID, "channel", channelID, channelID)

	threadID := "thr_bot_target_matrix_thread"
	mustExec(t, pool, `
INSERT INTO scopes (
    id, workspace_id, type, parent_scope_id, root_message_id, root_message_short_id,
    identity_key, status, created_by_member_id, created_at, updated_at
) VALUES (
    $1, $2, 'thread', $3, $4, $5,
    CONCAT('thread/', $3, '/', $4), 'active', $6, $7, $7
)
`, threadID, fixture.WorkspaceID, channelID, "msg_bot_target_matrix_root", "rootmsg1", fixture.MemberID, now)
	threadResult, err := appStore.bindAppInstallationTargetForUser(ctx, fixture.UserID, fixture.WorkspaceID, installation.ID, threadID, AppMentionCreatedViaTarget)
	require.NoError(t, err)
	require.Equal(t, "thread", threadResult.Target.TargetKind)
	assertActiveAppTargetBinding(t, ctx, pool, fixture.WorkspaceID, installation.ID, installation.MemberID, threadID, "thread", threadID, channelID)

	peer := seedWorkspaceMemberFixture(t, pool, fixture.WorkspaceID, "bot_target_matrix_peer", now)
	var dm message.DMChannel
	require.NoError(t, coredb.WithTx(ctx, pool, func(tx coredb.Tx) error {
		var err error
		dm, err = message.OpenDMChannelForMember(ctx, tx, message.MemberActor{WorkspaceID: fixture.WorkspaceID, MemberID: fixture.MemberID}, peer.MemberID)
		return err
	}))
	dmResult, err := appStore.bindAppInstallationTargetForUser(ctx, fixture.UserID, fixture.WorkspaceID, installation.ID, dm.ID, AppMentionCreatedViaTarget)
	require.NoError(t, err)
	require.Equal(t, "dm", dmResult.Target.TargetKind)
	assertActiveAppTargetBinding(t, ctx, pool, fixture.WorkspaceID, installation.ID, installation.MemberID, dm.ID, "dm", dm.ID, dm.ID)

	taskID := "tsk_bot_target_matrix"
	seedTaskTargetForAppBindingTests(t, pool, fixture, taskID, now)
	taskResult, err := appStore.bindAppInstallationTargetForUser(ctx, fixture.UserID, fixture.WorkspaceID, installation.ID, "task:"+taskID, AppMentionCreatedViaTarget)
	require.NoError(t, err)
	require.Equal(t, "task", taskResult.Target.TargetKind)
	assertActiveAppTargetBinding(t, ctx, pool, fixture.WorkspaceID, installation.ID, installation.MemberID, taskID, "task", taskID, fixture.AllChannelID)
}

func installAppAppForTargetBindingTests(t *testing.T, ctx context.Context, pool coredb.QueryExecer, appStore *Store, fixture appAdminFixture, suffix string, now time.Time) appTargetInstallationFixture {
	t.Helper()
	return installAppAppForTargetBindingTestsWithTransports(t, ctx, pool, appStore, fixture, suffix, now, []string{serviceactor.RuntimeTransportKindRunnerStream, serviceactor.RuntimeTransportKindCallback})
}

func installAppAppForTargetBindingTestsWithTransports(t *testing.T, ctx context.Context, pool coredb.QueryExecer, appStore *Store, fixture appAdminFixture, suffix string, now time.Time, transports []string) appTargetInstallationFixture {
	t.Helper()
	return installAppAppForTargetBindingTestsWithCapability(t, ctx, pool, appStore, fixture, suffix, now, transports, false)
}

func installAppNotificationActionAppForTargetBindingTestsWithTransports(t *testing.T, ctx context.Context, pool coredb.QueryExecer, appStore *Store, fixture appAdminFixture, suffix string, now time.Time, transports []string) appTargetInstallationFixture {
	t.Helper()
	installation := installAppAppForTargetBindingTestsWithCapability(t, ctx, pool, appStore, fixture, suffix, now, transports, true)
	require.Contains(t, installation.Grants, AppNotificationScopeWrite)
	require.Contains(t, installation.Grants, AppNotificationActionScopeRead)
	return installation
}

func installAppAppForTargetBindingTestsWithCapability(t *testing.T, ctx context.Context, pool coredb.QueryExecer, appStore *Store, fixture appAdminFixture, suffix string, now time.Time, transports []string, notificationActions bool) appTargetInstallationFixture {
	t.Helper()
	if len(transports) == 0 {
		transports = []string{serviceactor.RuntimeTransportKindRunnerStream, serviceactor.RuntimeTransportKindCallback}
	}
	appEvents := []string{registry.AppEventMessageMention}
	appScopes := []string{AppMentionScopeRead, AppRuntimeScopeMessageRead, AppRuntimeScopeMessageWrite, AppRuntimeScopeThreadRead}
	runtimeCapability := map[string]any{
		"runtime": map[string]any{
			"transports":        transports,
			"default_transport": transports[0],
		},
		"events": appEvents,
		"scopes": appScopes,
		"config_schema": map[string]any{
			"type":                 "object",
			"additionalProperties": false,
			"properties": map[string]any{
				"environment": map[string]any{"type": "string"},
			},
		},
	}
	if notificationActions {
		runtimeCapability["events"] = append(appEvents, registry.AppEventNotificationAction)
		runtimeCapability["scopes"] = append(appScopes, AppNotificationScopeWrite, AppNotificationActionScopeRead)
		runtimeCapability["notifications"] = map[string]any{
			"targets": []string{registry.AppNotificationTargetBoundScope},
			"content": []string{registry.AppNotificationContentInteractiveCard},
		}
	}
	slugSuffix := strings.ReplaceAll(suffix, "_", "-")
	appID := fmt.Sprintf("app_deploy_bot_target_%s", suffix)
	slug := fmt.Sprintf("deploy-app-target-%s", slugSuffix)
	insertExternalAppAppForTests(t, pool, externalAppAppSeed{
		AppID:       appID,
		VersionID:   "appver_" + appID + "_1_0_0",
		Slug:        slug,
		Name:        "Deploy App " + suffix,
		Visibility:  "unlisted",
		Version:     "1.0.0",
		Scopes:      []string{"profile.read"},
		CallbackURL: "https://example.com/app",
		CreatedAt:   now,
		Manifest: map[string]any{
			"schema_version": "1",
			"slug":           slug,
			"name":           "Deploy App " + suffix,
			"version":        "1.0.0",
			"scopes":         []string{"profile.read"},
			"capabilities": map[string]any{
				"runtime": runtimeCapability,
			},
		},
	})

	installation := requestAndApproveExternalAppInstallationForTests(
		t,
		ctx,
		appStore,
		fixture.UserID,
		fixture.UserID,
		fixture.WorkspaceID,
		appID,
		slug,
		map[string]any{"environment": "staging"},
	)
	require.Contains(t, installation.Grants, AppMentionScopeRead)
	require.Contains(t, installation.Grants, AppRuntimeScopeMessageRead)
	require.Contains(t, installation.Grants, AppRuntimeScopeMessageWrite)
	require.Contains(t, installation.Grants, AppRuntimeScopeThreadRead)
	return appTargetInstallationFixture{ID: installation.ID, AppID: appID, MemberID: installation.MemberID, Grants: installation.Grants}
}

func requireChannelAddablePeopleContainsService(t *testing.T, ctx context.Context, appStore *Store, userID string, workspaceID string, channelID string, installationID string) {
	t.Helper()
	page, err := appStore.ListChannelAddablePeopleForUser(ctx, userID, workspaceID, channelID, ChannelAddablePeopleOptions{Limit: 100})
	require.NoError(t, err)
	for _, service := range page.Services {
		if strings.TrimSpace(service.ID) == strings.TrimSpace(installationID) {
			return
		}
	}
	require.Failf(t, "service missing from channel addable people", "installation %s was not addable", installationID)
}

func requireChannelAddablePeopleOmitsService(t *testing.T, ctx context.Context, appStore *Store, userID string, workspaceID string, channelID string, installationID string) {
	t.Helper()
	page, err := appStore.ListChannelAddablePeopleForUser(ctx, userID, workspaceID, channelID, ChannelAddablePeopleOptions{Limit: 100})
	require.NoError(t, err)
	for _, service := range page.Services {
		require.NotEqual(t, strings.TrimSpace(installationID), strings.TrimSpace(service.ID))
	}
}

func requireChannelServiceCanRemove(t *testing.T, ctx context.Context, appStore *Store, userID string, workspaceID string, channelID string, serviceMemberID string, expected bool) {
	t.Helper()
	members, err := appStore.ChannelMembersForUser(ctx, userID, workspaceID, channelID)
	require.NoError(t, err)
	for _, service := range members.Services {
		if strings.TrimSpace(service.MemberID) == strings.TrimSpace(serviceMemberID) ||
			strings.TrimSpace(service.ID) == strings.TrimSpace(serviceMemberID) {
			require.Equal(t, expected, service.CanRemove)
			return
		}
	}
	require.Failf(t, "service missing from channel members", "service member %s was not listed", serviceMemberID)
}

func assertActiveAppTargetBinding(t *testing.T, ctx context.Context, pool coredb.Queryer, workspaceID string, installationID string, serviceMemberID string, deliveryScopeID string, targetKind string, targetRef string, permissionScopeID string) {
	t.Helper()
	var (
		bindingID        string
		bindingScopeID   string
		bindingPermScope string
		bindingStatus    string
		bindingKey       string
	)
	require.NoError(t, pool.QueryRow(ctx, `
SELECT id, delivery_scope_id, permission_check_scope_id, status, COALESCE(active_binding_key, '')
FROM service_target_bindings
WHERE workspace_id = $1
  AND service_installation_id = $2
  AND target_kind = $3
  AND target_ref = $4
  AND removed_at IS NULL
ORDER BY updated_at DESC
LIMIT 1
`, workspaceID, installationID, targetKind, targetRef).Scan(&bindingID, &bindingScopeID, &bindingPermScope, &bindingStatus, &bindingKey))
	require.NotEmpty(t, bindingID)
	require.Equal(t, deliveryScopeID, bindingScopeID)
	require.Equal(t, permissionScopeID, bindingPermScope)
	require.Equal(t, serviceactor.StatusActive, bindingStatus)
	require.NotEmpty(t, bindingKey)

	var (
		subscriptionKind string
		eventFilterKey   string
		eventFilters     string
		deliveryPolicy   string
		notifyPrincipal  bool
		connectionRef    string
	)
	require.NoError(t, pool.QueryRow(ctx, `
SELECT subscription_kind, event_filter_key, event_filters, delivery_policy, notify_principal, service_connection_ref
FROM service_event_subscriptions
WHERE workspace_id = $1
  AND service_installation_id = $2
  AND service_target_binding_id = $3
  AND target_kind = $4
  AND target_ref = $5
  AND status = 'active'
  AND removed_at IS NULL
ORDER BY updated_at DESC
LIMIT 1
`, workspaceID, installationID, bindingID, targetKind, targetRef).Scan(&subscriptionKind, &eventFilterKey, &eventFilters, &deliveryPolicy, &notifyPrincipal, &connectionRef))
	require.Equal(t, serviceactor.SubscriptionKindAppMention, subscriptionKind)
	require.Equal(t, "agentteam.message.mention", eventFilterKey)
	require.JSONEq(t, `{"events":["agentteam.message.mention"]}`, eventFilters)
	require.Equal(t, AppMentionDeliveryPolicy, deliveryPolicy)
	require.False(t, notifyPrincipal)
	require.Equal(t, AppMentionServiceConnectionRef, connectionRef)

	requireAppTargetSingleInt(t, ctx, pool, 1, `
SELECT COUNT(*)
FROM scope_memberships
WHERE scope_id = $1
  AND member_id = $2
  AND visibility = 'public'
  AND removed_at IS NULL
`, deliveryScopeID, serviceMemberID)
}

func requireAppTargetSingleInt(t *testing.T, ctx context.Context, queryer coredb.Queryer, want int, query string, args ...any) {
	t.Helper()
	var got int
	require.NoError(t, queryer.QueryRow(ctx, query, args...).Scan(&got))
	require.Equal(t, want, got)
}

func seedTaskTargetForAppBindingTests(t *testing.T, pool coredb.QueryExecer, fixture appAdminFixture, taskID string, now time.Time) {
	t.Helper()
	seedTaskTargetForAppBindingTestsOnChannel(t, pool, fixture, fixture.AllChannelID, taskID, now)
}

func seedTaskTargetForAppBindingTestsOnChannel(t *testing.T, pool coredb.QueryExecer, fixture appAdminFixture, channelID string, taskID string, now time.Time) {
	t.Helper()
	boardID := taskID + "_board"
	mustExec(t, pool, `
INSERT INTO task_boards (channel_id, workspace_id, next_task_number, created_at, updated_at, id)
VALUES ($1, $2, 2, $3, $3, $4)
	`, channelID, fixture.WorkspaceID, now, boardID)
	mustExec(t, pool, `
INSERT INTO task_counters (board_id, next_task_number, updated_at)
VALUES ($1, 2, $2)
`, boardID, now)
	mustExec(t, pool, `
INSERT INTO scopes (id, workspace_id, type, identity_key, status, created_by_member_id, created_at, updated_at)
VALUES ($1, $2, 'task', CONCAT('scope/task/', $1), 'active', $3, $4, $4)
`, taskID, fixture.WorkspaceID, fixture.MemberID, now)
	mustExec(t, pool, `
INSERT INTO tasks (
    id, task_scope_id, board_id, channel_id, task_number, title, status,
    created_by_member_id, created_by_ref, created_at, updated_at
) VALUES ($1, $1, $2, $3, 1, 'App target binding task', 'in_progress', $4, $4, $5, $5)
	`, taskID, boardID, channelID, fixture.MemberID, now)
}
