package mailstore

import (
	"context"
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"errors"
	"os"
	"testing"
	"time"

	"github.com/stretchr/testify/require"

	"github.com/tidbcloud/AgentTeam/internal/coredb"
	"github.com/tidbcloud/AgentTeam/internal/coredb/testkit"
	"github.com/tidbcloud/AgentTeam/internal/extension/providers/mail/connection"
	"github.com/tidbcloud/AgentTeam/internal/store/app"
	"github.com/tidbcloud/AgentTeam/internal/store/app/registry"
	"github.com/tidbcloud/AgentTeam/internal/store/extension"
	"github.com/tidbcloud/AgentTeam/internal/store/storeerr"
)

var mailStatelessTiDB testkit.TiDBCoreDBFixture

func TestMain(m *testing.M) {
	os.Exit(testkit.RunTestsWithTiDBCoreDBFixture(m, "mail_stateless", &mailStatelessTiDB))
}

type mailStatelessFixture struct {
	UserID      string
	WorkspaceID string
	db          coredb.DB
	store       *Store
	appStore    *app.Store
}

func newMailStatelessFixture(
	t *testing.T,
	ctx context.Context,
	suffix string,
) mailStatelessFixture {
	t.Helper()
	db := mailStatelessTiDB.RollbackDB(t, ctx)
	now := time.Now().UTC()
	userID := "usr_mailstate_" + suffix
	workspaceID := "wsp_mailstate_" + suffix
	mustExecStateless(t, db, `
INSERT INTO users (id, email, name, display_name, password_hash, created_at, updated_at)
VALUES ($1, $2, $3, $3, 'hash', $4, $4)
`, userID, suffix+"@example.com", suffix, now)
	mustExecStateless(t, db, `
INSERT INTO organizations (id, slug, name, created_by_user_id, created_at, updated_at)
VALUES ($1, $2, $2, $3, $4, $4)
`, "org_mailstate_"+suffix, "mailstate-"+suffix, userID, now)
	mustExecStateless(t, db, `
INSERT INTO workspaces (id, organization_id, slug, name, created_at, updated_at)
VALUES ($1, $2, $3, $3, $4, $4)
`, workspaceID, "org_mailstate_"+suffix, "mailstate-"+suffix, now)
	mustExecStateless(t, db, `
INSERT INTO members (id, workspace_id, type, display_name, user_id, workspace_role, status, created_at, updated_at)
VALUES ($1, $2, 'human', 'Admin', $3, 'owner', 'active', $4, $4)
`, "mem_mailstate_"+suffix, workspaceID, userID, now)
	appStore := app.New(db, app.Options{
		AppCredentialStorageKey: []byte("0123456789abcdef0123456789abcdef"),
	})
	return mailStatelessFixture{
		UserID:      userID,
		WorkspaceID: workspaceID,
		db:          db,
		store:       NewWithNamedApps(db, appStore),
		appStore:    appStore,
	}
}

func mustExecStateless(
	t testing.TB,
	db coredb.QueryExecer,
	query string,
	args ...any,
) {
	t.Helper()
	_, err := db.Exec(context.Background(), query, args...)
	require.NoError(t, err)
}

// statelessCredentialKey mirrors the App credential storage key configured
// on the test App store; the Mail HMAC subject identity derives from it.
var statelessCredentialKey = []byte("0123456789abcdef0123456789abcdef")

// productionMailboxConfig builds the canonical, digest-verified connection
// config the real Mail catalog policy accepts: canonically sorted IMAP
// capabilities and a ComputeDigest-pinned capability digest.
func productionMailboxConfig(sender string) map[string]any {
	config := connection.Config{
		SchemaVersion:         1,
		SenderAddress:         sender,
		SenderAddressAccepted: true,
		IMAP: connection.Endpoint{
			Host: "imap.example.com", Port: 993, TLSMode: "implicit",
			Capabilities: []string{
				"CONDSTORE", "IMAP4REV1", "SPECIAL-USE", "UIDPLUS",
			},
			DraftMailbox: "Drafts",
		},
		SMTP: connection.Endpoint{
			Host: "smtp.example.com", Port: 465, TLSMode: "implicit",
		},
	}
	digest, err := config.ComputeDigest()
	if err != nil {
		panic(err)
	}
	config.CapabilityDigest = digest
	return config.JSONObject()
}

// mailSubjectRef derives the Mail h1 HMAC subject identity for the test
// credential key. It mirrors the parent mail package's ExternalSubjectRef
// (the store package cannot import its parent); the assertion surface is
// the catalog policy's accepted identity shape.
func mailSubjectRef(sender string) string {
	mac := hmac.New(sha256.New, statelessCredentialKey)
	_, _ = mac.Write([]byte("agentteam/email/external-subject/v1\x00"))
	_, _ = mac.Write([]byte(sender))
	return "email:h1:" + hex.EncodeToString(mac.Sum(nil))
}

func validMaterial(name, sender string) StatelessConnectionMaterial {
	subjectRef := mailSubjectRef(sender)
	return StatelessConnectionMaterial{
		SenderAddress:      sender,
		ExternalSubjectRef: subjectRef,
		DisplayName:        name,
		ExtensionName:      name,
		Credentials: map[string]any{
			"schemaVersion": 1,
			"imapUsername":  sender,
			"imapPassword":  "secret",
		},
		Config: productionMailboxConfig(sender),
	}
}

// TestStatelessInstallsTwoNamedMailboxesInOneWorkspace proves the multi-mailbox
// main path: one Workspace materializes two named Email installations with
// independent members, selections, connections, and unique instance keys.
func TestStatelessInstallsTwoNamedMailboxesInOneWorkspace(t *testing.T) {
	t.Parallel()
	ctx := context.Background()
	fixture := newMailStatelessFixture(t, ctx, "two")

	first, err := fixture.store.FinalizeStatelessInstallation(
		ctx, fixture.UserID, fixture.WorkspaceID, validMaterial("team", "team@example.com"))
	require.NoError(t, err)
	second, err := fixture.store.FinalizeStatelessInstallation(
		ctx, fixture.UserID, fixture.WorkspaceID, validMaterial("personal", "me@example.com"))
	require.NoError(t, err)

	require.NotEqual(t, first.Installation.ID, second.Installation.ID)
	require.NotEqual(t, first.Connection.ID, second.Connection.ID)
	require.NotEqual(t, first.Extension.ID, second.Extension.ID)
	require.Equal(t, "mail", first.Extension.DefinitionDomain)
	require.NotEqual(t, first.Extension.ExtensionName, second.Extension.ExtensionName)

	installations, err := fixture.appStore.ListAppInstallationsForUser(
		ctx, fixture.UserID, fixture.WorkspaceID)
	require.NoError(t, err)
	require.Len(t, installations, 2)

	// Named mailbox identity is canonical-only: every service installation
	// carries its own instance key and service member.
	rows, err := fixture.db.Query(ctx, `
SELECT installation.id, installation.instance_key, member.member_id
FROM service_installations installation
JOIN service_members member
  ON member.service_installation_id = installation.id
 AND member.workspace_id = installation.workspace_id
WHERE installation.workspace_id = $1
  AND installation.service_definition_id = 'app_email'
ORDER BY installation.id`, fixture.WorkspaceID)
	require.NoError(t, err)
	defer rows.Close()
	type canonicalRow struct {
		id          string
		instanceKey string
		memberID    string
	}
	seenMembers := map[string]string{}
	seenInstanceKeys := map[string]string{}
	canonicalCount := 0
	for rows.Next() {
		var row canonicalRow
		require.NoError(t, rows.Scan(&row.id, &row.instanceKey, &row.memberID))
		require.NotEmpty(t, row.instanceKey)
		previous, clash := seenMembers[row.memberID]
		require.False(t, clash,
			"member %q shared by installations %q and %q",
			row.memberID, previous, row.id)
		seenMembers[row.memberID] = row.id
		previousKey, clashKey := seenInstanceKeys[row.instanceKey]
		require.False(t, clashKey,
			"instance key %q shared by installations %q and %q",
			row.instanceKey, previousKey, row.id)
		seenInstanceKeys[row.instanceKey] = row.id
		canonicalCount++
	}
	require.NoError(t, rows.Err())
	require.Equal(t, 2, canonicalCount,
		"exactly one canonical row per named mailbox")
}

// TestStatelessReconnectRotatesGeneration proves reconnect creates a new
// Connection generation on the exact installation while identity stays.
func TestStatelessReconnectRotatesGeneration(t *testing.T) {
	t.Parallel()
	ctx := context.Background()
	fixture := newMailStatelessFixture(t, ctx, "reconnect")

	installed, err := fixture.store.FinalizeStatelessInstallation(
		ctx, fixture.UserID, fixture.WorkspaceID, validMaterial("team", "team@example.com"))
	require.NoError(t, err)
	var credentialsBefore string
	var subjectBefore, configBefore []byte
	require.NoError(t, fixture.db.QueryRow(ctx, `
SELECT credentials, external_subject_ref, COALESCE(config, JSON_OBJECT())
FROM service_connections WHERE id = $1`,
		installed.Connection.ID).
		Scan(&credentialsBefore, &subjectBefore, &configBefore))

	rotated, err := fixture.store.FinalizeStatelessReconnect(
		ctx, fixture.UserID, fixture.WorkspaceID, installed.Installation.ID,
		func() StatelessConnectionMaterial {
			rotated := validMaterial("team", "team@example.com")
			rotated.DisplayName = ""
			rotated.ExtensionName = ""
			rotated.Credentials = map[string]any{
				"schemaVersion": 1,
				"imapUsername":  "team@example.com",
				"imapPassword":  "rotated",
			}
			return rotated
		}())
	require.NoError(t, err)
	require.Equal(t, installed.Connection.ID, rotated.ID)
	// Reconnect materializes a new generation on the same connection
	// identity; the stored credentials change.
	require.Equal(t, installed.Connection.ExternalSubjectRef, rotated.ExternalSubjectRef)

	// The durable encrypted credential material changed, which is what
	// rotates the connection generation the reauthorization envelope
	// compares.
	var credentialsAfter string
	var subjectAfter, configAfter []byte
	require.NoError(t, fixture.db.QueryRow(ctx, `
SELECT credentials, external_subject_ref, config
FROM service_connections WHERE id = $1`,
		installed.Connection.ID).
		Scan(&credentialsAfter, &subjectAfter, &configAfter))
	require.NotEqual(t, credentialsBefore, credentialsAfter,
		"reconnect must replace the stored credential material")

	// The RuntimeConnectionGeneration itself rotates: the reauthorization
	// envelope binds the new generation, so a stale envelope cannot ride a
	// rotated credential.
	generationBefore := extension.RuntimeConnectionGeneration(
		installed.Connection.ID,
		string(subjectBefore),
		extension.CredentialEnvelopeDigest([]byte(credentialsBefore)),
		configBefore,
	)
	generationAfter := extension.RuntimeConnectionGeneration(
		installed.Connection.ID,
		string(subjectAfter),
		extension.CredentialEnvelopeDigest([]byte(credentialsAfter)),
		configAfter,
	)
	require.NotEqual(t, generationBefore, generationAfter,
		"reconnect must rotate the runtime connection generation")
}

// TestStatelessDisconnectRemovesActiveConnection proves disconnect closes the
// active connection without deleting the named installation row.
func TestStatelessDisconnectRemovesActiveConnection(t *testing.T) {
	t.Parallel()
	ctx := context.Background()
	fixture := newMailStatelessFixture(t, ctx, "disconnect")

	installed, err := fixture.store.FinalizeStatelessInstallation(
		ctx, fixture.UserID, fixture.WorkspaceID, validMaterial("team", "team@example.com"))
	require.NoError(t, err)
	require.NoError(t, fixture.store.DisconnectStatelessConnection(
		ctx, fixture.UserID, fixture.WorkspaceID,
		installed.Installation.ID, installed.Connection.ID))

	_, err = fixture.store.LoadActiveConnection(
		ctx, fixture.WorkspaceID, installed.Installation.ID, installed.Connection.ID)
	require.ErrorIs(t, err, storeerr.ErrNotFound)
}

// TestStatelessInstallationRejectsDuplicateSenderLabel proves duplicate
// connection identity within one workspace fails closed and rolls back the
// whole atomic mutation (no orphan installation rows).
func TestStatelessInstallationRejectsDuplicateSenderLabel(t *testing.T) {
	t.Parallel()
	ctx := context.Background()
	fixture := newMailStatelessFixture(t, ctx, "dupe")

	_, err := fixture.store.FinalizeStatelessInstallation(
		ctx, fixture.UserID, fixture.WorkspaceID, validMaterial("team", "team@example.com"))
	require.NoError(t, err)

	_, err = fixture.store.FinalizeStatelessInstallation(
		ctx, fixture.UserID, fixture.WorkspaceID, validMaterial("team2", "team@example.com"))
	require.Error(t, err)

	installations, err := fixture.appStore.ListAppInstallationsForUser(
		ctx, fixture.UserID, fixture.WorkspaceID)
	require.NoError(t, err)
	require.Len(t, installations, 1,
		"a failed duplicate mutation must roll back its installation row")
}

// TestStatelessInstallationRejectsDuplicateExtensionName proves the flattened
// Extension name is unique per workspace regardless of sender: a second
// mailbox reusing the first one's ExtensionName fails closed and rolls back.
func TestStatelessInstallationRejectsDuplicateExtensionName(t *testing.T) {
	t.Parallel()
	ctx := context.Background()
	fixture := newMailStatelessFixture(t, ctx, "dupname")

	_, err := fixture.store.FinalizeStatelessInstallation(
		ctx, fixture.UserID, fixture.WorkspaceID, validMaterial("team", "team@example.com"))
	require.NoError(t, err)

	_, err = fixture.store.FinalizeStatelessInstallation(
		ctx, fixture.UserID, fixture.WorkspaceID, validMaterial("team", "other@example.com"))
	require.Error(t, err, "a second mailbox must not reuse the Extension name")

	installations, err := fixture.appStore.ListAppInstallationsForUser(
		ctx, fixture.UserID, fixture.WorkspaceID)
	require.NoError(t, err)
	require.Len(t, installations, 1)
}

// TestStatelessDisconnectConvergesCatalogInputs proves a disconnect converges
// the App-hosted catalog projection inputs: the selection is disabled and no
// active connection remains for the named installation.
func TestStatelessDisconnectConvergesCatalogInputs(t *testing.T) {
	t.Parallel()
	ctx := context.Background()
	fixture := newMailStatelessFixture(t, ctx, "catconv")

	installed, err := fixture.store.FinalizeStatelessInstallation(
		ctx, fixture.UserID, fixture.WorkspaceID, validMaterial("team", "team@example.com"))
	require.NoError(t, err)
	require.NoError(t, fixture.store.DisconnectStatelessConnection(
		ctx, fixture.UserID, fixture.WorkspaceID,
		installed.Installation.ID, installed.Connection.ID))

	// The selection row itself stays active: the catalog converges at the
	// projection layer, which drops a ConnectionRequired selection that has
	// no active connection left.
	var selectionStatus string
	require.NoError(t, fixture.db.QueryRow(ctx, `
SELECT status FROM service_installation_extensions
WHERE id = $1 AND workspace_id = $2`,
		installed.Extension.ID, fixture.WorkspaceID).Scan(&selectionStatus))
	require.Equal(t, "active", selectionStatus)

	var activeConnections int
	require.NoError(t, fixture.db.QueryRow(ctx, `
SELECT COUNT(*) FROM service_connections
WHERE service_installation_id = $1 AND workspace_id = $2 AND status = 'active'`,
		installed.Installation.ID, fixture.WorkspaceID).Scan(&activeConnections))
	require.Zero(t, activeConnections,
		"a disconnected installation must have no active connection, so the ConnectionRequired projection drops it")

	var selections []extension.AppHostedExtensionSelection
	require.NoError(t, coredb.WithTx(ctx, fixture.db, func(tx coredb.Tx) error {
		var listErr error
		selections, listErr = extension.New(fixture.db).
			InTx(tx).
			ListActiveAppHostedExtensionSelections(ctx, fixture.WorkspaceID)
		return listErr
	}))
	mailboxSelections := 0
	for _, selection := range selections {
		if selection.Selection.ID == installed.Extension.ID {
			mailboxSelections++
			require.Empty(t, selection.Connections,
				"the catalog input must carry no active connection for the disconnected mailbox")
		}
	}
	require.Equal(t, 1, mailboxSelections)
}

// TestStatelessMutationRollsBackOnHookFailure proves a feature hook vetoing
// the mutation leaves zero durable rows: the whole materialize transaction
// rolls back atomically (no installation, member, selection, or connection).
func TestStatelessMutationRollsBackOnHookFailure(t *testing.T) {
	t.Parallel()
	ctx := context.Background()
	fixture := newMailStatelessFixture(t, ctx, "hookfail")

	material := validMaterial("team", "team@example.com")
	instanceKey := "mailbox_rollback_probe"
	capturedMember := make(chan string, 1)
	_, err := fixture.appStore.FinalizeNamedAppInstallationForWorkspaceAdmin(
		ctx,
		fixture.UserID,
		fixture.WorkspaceID,
		app.NamedAppInstallationParams{
			AppID:           registry.EmailAppID,
			AppSlug:         registry.EmailAppSlug,
			InstanceKey:     instanceKey,
			Handle:          "email-rollback-probe",
			DisplayName:     material.DisplayName,
			ExtensionDomain: "mail",
			ExtensionName:   material.ExtensionName,
			Connection: app.NamedAppConnectionParams{
				ExternalSubjectRef: material.ExternalSubjectRef,
				DisplayLabel:       material.SenderAddress,
				Credentials:        material.Credentials,
				Config:             material.Config,
			},
		},
		app.NamedAppMutationHooks{
			BeforeCommit: func(
				hookCtx context.Context,
				tx coredb.Tx,
				result app.NamedAppMutationResult,
				_ time.Time,
			) error {
				// Capture the service member the mutation materialized
				// before vetoing, so the rollback assertion can query it
				// directly instead of through the rolled-back installation.
				var memberID string
				memberRow, memberErr := tx.Query(
					hookCtx,
					`SELECT member_id FROM service_members
WHERE service_installation_id = $1 LIMIT 1`,
					result.Installation.ID,
				)
				if memberErr == nil {
					defer memberRow.Close()
					if memberRow.Next() {
						_ = memberRow.Scan(&memberID)
					}
				}
				capturedMember <- memberID
				return errors.New("feature hook veto")
			},
		},
	)
	require.Error(t, err)
	rollbackMemberID := <-capturedMember
	require.NotEmpty(t, rollbackMemberID,
		"the mutation must materialize a service member before commit")

	for table, query := range map[string]string{
		"member substrate": `
SELECT COUNT(*) FROM members WHERE workspace_id = $1 AND id = $2`,
		"service member substrate": `
SELECT COUNT(*) FROM service_members WHERE workspace_id = $1 AND member_id = $2`,
		"service installation": `
SELECT COUNT(*) FROM service_installations
WHERE workspace_id = $1 AND instance_key = $2`,
		"selection": `
SELECT COUNT(*) FROM service_installation_extensions
WHERE workspace_id = $1 AND extension_name = $2`,
		"connection": `
SELECT COUNT(*) FROM service_connections
WHERE workspace_id = $1 AND external_subject_ref = $2`,
	} {
		args := []any{fixture.WorkspaceID, instanceKey}
		switch table {
		case "member substrate", "service member substrate":
			args = []any{fixture.WorkspaceID, rollbackMemberID}
		case "legacy installation":
			args = []any{fixture.WorkspaceID}
		case "connection":
			args = []any{fixture.WorkspaceID, material.ExternalSubjectRef}
		case "selection":
			args = []any{fixture.WorkspaceID, material.ExtensionName}
		}
		var count int
		require.NoError(
			t,
			fixture.db.QueryRow(ctx, query, args...).Scan(&count),
			"%s rollback probe failed",
			table,
		)
		require.Zero(t, count, "a vetoed mutation must not leave a durable %s row", table)
	}
}
