package mailruntime

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

	"github.com/stretchr/testify/require"

	"github.com/tidbcloud/AgentTeam/internal/extension/providers/mail/mailstore"
	"github.com/tidbcloud/AgentTeam/internal/extension/providers/mail/protocol"
	"github.com/tidbcloud/AgentTeam/internal/extension/providers/mail/token"
	"github.com/tidbcloud/AgentTeam/internal/store/storeerr"
)

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

	t.Run("preflight generation changed before first load", func(t *testing.T) {
		fixture := newRuntimeFixture(t)
		invocation := fixture.invocation("create-draft", map[string]any{
			"clientRequestId": "req_create_1",
			"to":              []string{"recipient@example.com"},
			"subject":         "Subject",
			"body": map[string]any{
				"format": "text", "content": "body",
			},
		})
		changed := fixture.connection
		changed.EncryptedCredentials = append(
			append([]byte(nil), changed.EncryptedCredentials...),
			'\n',
		)
		fixture.connections.sequence = []mailstore.Connection{changed}

		_, err := fixture.service.Invoke(context.Background(), invocation)
		require.Error(t, err)
		require.Equal(t, ErrorConflict, mustErrorCode(t, err))
		require.Equal(t, 1, fixture.connections.calls)
		require.Equal(t, 0, fixture.imap.appendCalls)
	})

	t.Run("generation changed", func(t *testing.T) {
		fixture := newRuntimeFixture(t)
		changed := fixture.connection
		changed.Generation = "sha256:" + strings.Repeat("c", 64)
		fixture.connections.sequence = []mailstore.Connection{
			fixture.connection,
			changed,
		}
		_, err := fixture.service.Invoke(
			context.Background(),
			fixture.invocation("create-draft", map[string]any{
				"clientRequestId": "req_create_1",
				"to":              []string{"recipient@example.com"},
				"subject":         "Subject",
				"body": map[string]any{
					"format": "text", "content": "body",
				},
			}),
		)
		require.Error(t, err)
		require.Equal(t, ErrorConflict, mustErrorCode(t, err))
		require.Equal(t, 0, fixture.imap.appendCalls)
	})

	t.Run("generation changed after dispatch", func(t *testing.T) {
		fixture := newRuntimeFixture(t)
		changed := fixture.connection
		changed.Generation = "sha256:" + strings.Repeat("c", 64)
		fixture.connections.sequence = []mailstore.Connection{
			fixture.connection,
			fixture.connection,
			changed,
		}
		result, err := fixture.service.Invoke(
			context.Background(),
			fixture.invocation("create-draft", map[string]any{
				"clientRequestId": "req_create_1",
				"to":              []string{"recipient@example.com"},
				"subject":         "Subject",
				"body": map[string]any{
					"format": "text", "content": "body",
				},
			}),
		)
		require.NoError(t, err)
		var output draftMutationOutput
		require.NoError(t, json.Unmarshal(result, &output))
		require.Equal(t, "unknown", output.Status)
		require.Equal(t, 1, fixture.imap.appendCalls)
	})

	t.Run("capability missing", func(t *testing.T) {
		fixture := newRuntimeFixture(t)
		fixture.connection = connectionWithoutCapability(
			t,
			fixture.connection,
			"CONDSTORE",
		)
		fixture.connections.sequence = []mailstore.Connection{
			fixture.connection,
		}
		token := fixture.sealReference(
			t,
			token.KindDraft,
			"update-draft",
			protocol.MessageRef{
				Mailbox: "Drafts", UIDValidity: 52, UID: 9,
			},
			77,
			"",
		)
		_, err := fixture.service.Invoke(
			context.Background(),
			fixture.invocation("update-draft", map[string]any{
				"clientRequestId":  "req_update_1",
				"draft":            token,
				"expectedRevision": 77,
				"to":               []string{"recipient@example.com"},
				"subject":          "Updated",
				"body": map[string]any{
					"format": "text", "content": "body",
				},
			}),
		)
		require.Error(t, err)
		require.Equal(t, ErrorPermissionDenied, mustErrorCode(t, err))
		require.Equal(t, 0, fixture.imap.replaceCalls)
	})
}

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

	fixture := newRuntimeFixture(t)
	token := fixture.sealReference(
		t,
		token.KindMessage,
		"get-message",
		protocol.MessageRef{
			Mailbox: "INBOX", UIDValidity: 42, UID: 7,
		},
		0,
		"",
	)
	invocation := fixture.invocation("get-message", map[string]any{
		"message": token,
	})
	invocation.AgentID = "agt_other"
	_, err := fixture.service.Invoke(context.Background(), invocation)
	require.Error(t, err)
	require.Equal(t, ErrorValidation, mustErrorCode(t, err))
	require.Equal(t, 0, fixture.imap.messageCalls)
}

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

	fixture := newRuntimeFixture(t)
	token := fixture.sealReference(
		t,
		token.KindMessage,
		"list-attachments",
		protocol.MessageRef{
			Mailbox: "INBOX", UIDValidity: 42, UID: 7,
		},
		0,
		"",
	)
	_, err := fixture.service.Invoke(
		context.Background(),
		fixture.invocation("create-reply-draft", map[string]any{
			"clientRequestId": "req_reply_1",
			"message":         token,
			"body": map[string]any{
				"format": "text", "content": "reply",
			},
		}),
	)
	require.Error(t, err)
	require.Equal(t, ErrorValidation, mustErrorCode(t, err))
	require.Equal(t, 0, fixture.imap.messageCalls)
	require.Equal(t, 0, fixture.imap.appendCalls)
}

func TestSendDraftRereadsExactDraftAndSendsOnce(
	t *testing.T,
) {
	t.Parallel()

	fixture := newRuntimeFixture(t)
	ref := protocol.DraftRef{
		Mailbox: "Drafts", UIDValidity: 52, UID: 12, ModSeq: 91,
	}
	fixture.imap.draft = protocol.Draft{
		Ref: ref,
		Content: protocol.DraftContent{
			From:       "CaseSensitive@example.com",
			To:         []string{"recipient@example.com"},
			CC:         []string{"copy@example.com"},
			Subject:    "Send subject",
			BodyFormat: "text",
			Body:       "secret body",
		},
	}
	token := fixture.sealReference(
		t,
		token.KindDraft,
		actionSendDraft,
		protocol.MessageRef{
			Mailbox: ref.Mailbox, UIDValidity: ref.UIDValidity, UID: ref.UID,
		},
		ref.ModSeq,
		"",
	)
	result, err := fixture.service.Invoke(
		context.Background(),
		fixture.invocation(actionSendDraft, map[string]any{
			"clientRequestId":  "req_send_1",
			"draft":            token,
			"expectedRevision": 91,
		}),
	)
	require.NoError(t, err)
	var output sendDraftOutput
	require.NoError(t, json.Unmarshal(result, &output))
	require.Equal(t, protocol.ResultAccepted, output.Status)
	require.Equal(t, 2, output.RecipientCount)
	require.Equal(t, 1, fixture.imap.getDraftCalls)
	require.Equal(t, 1, fixture.smtp.calls)
	require.Equal(
		t,
		"CaseSensitive@example.com",
		fixture.smtp.frozen.SenderAddress,
	)
	require.Equal(
		t,
		[]string{"recipient@example.com", "copy@example.com"},
		append(fixture.smtp.frozen.To, fixture.smtp.frozen.CC...),
	)
	require.Equal(t, "Send subject", fixture.smtp.frozen.Subject)
	require.Equal(t, "secret body", fixture.smtp.frozen.Body)
}

func TestSendDraftMapsUnknownProviderResult(
	t *testing.T,
) {
	t.Parallel()

	fixture := newRuntimeFixture(t)
	ref := protocol.DraftRef{
		Mailbox: "Drafts", UIDValidity: 52, UID: 12, ModSeq: 91,
	}
	fixture.imap.draft = protocol.Draft{
		Ref: ref,
		Content: protocol.DraftContent{
			From:       "CaseSensitive@example.com",
			To:         []string{"recipient@example.com"},
			Subject:    "Send subject",
			BodyFormat: "text",
			Body:       "secret body",
		},
	}
	fixture.smtp.status = protocol.ResultAccepted
	fixture.smtp.err = errors.New("connection lost after DATA")
	token := fixture.sealReference(
		t,
		token.KindDraft,
		actionSendDraft,
		protocol.MessageRef{
			Mailbox: ref.Mailbox, UIDValidity: ref.UIDValidity, UID: ref.UID,
		},
		ref.ModSeq,
		"",
	)
	result, err := fixture.service.Invoke(
		context.Background(),
		fixture.invocation(actionSendDraft, map[string]any{
			"clientRequestId":  "req_send_unknown",
			"draft":            token,
			"expectedRevision": 91,
		}),
	)
	require.NoError(t, err)
	var output sendDraftOutput
	require.NoError(t, json.Unmarshal(result, &output))
	require.Equal(t, protocol.ResultUnknown, output.Status)
	require.Equal(t, 1, fixture.smtp.calls)
}

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

	fixture := newRuntimeFixture(t)
	ref := protocol.DraftRef{
		Mailbox: "Drafts", UIDValidity: 52, UID: 12, ModSeq: 91,
	}
	fixture.imap.draft = protocol.Draft{
		Ref: ref,
		Content: protocol.DraftContent{
			From:       "CaseSensitive@example.com",
			To:         []string{"recipient@example.com"},
			Subject:    "Send subject",
			BodyFormat: "text",
			Body:       "secret body",
		},
		Attachments: []protocol.Attachment{{
			Part:      []int{2},
			Filename:  "report.pdf",
			MediaType: "application/pdf",
		}},
	}
	fixture.imap.attachmentBlobs = map[string][]byte{
		"2": []byte("%PDF-1.4 attachment-bytes"),
	}
	fixture.smtp.status = protocol.ResultAccepted
	token := fixture.sealReference(
		t,
		token.KindDraft,
		actionSendDraft,
		protocol.MessageRef{
			Mailbox:     ref.Mailbox,
			UIDValidity: ref.UIDValidity,
			UID:         ref.UID,
		},
		ref.ModSeq,
		"",
	)
	result, err := fixture.service.Invoke(
		context.Background(),
		fixture.invocation(actionSendDraft, map[string]any{
			"clientRequestId":  "req_send_attachments",
			"draft":            token,
			"expectedRevision": ref.ModSeq,
		}),
	)
	require.NoError(t, err)
	var output sendDraftOutput
	require.NoError(t, json.Unmarshal(result, &output))
	require.Equal(t, protocol.ResultAccepted, output.Status)
	require.Equal(t, 1, fixture.smtp.calls)
	require.Len(t, fixture.smtp.frozen.Attachments, 1)
	require.Equal(t, "report.pdf", fixture.smtp.frozen.Attachments[0].Filename)
	require.Equal(
		t,
		"application/pdf",
		fixture.smtp.frozen.Attachments[0].MediaType,
	)
	require.Equal(
		t,
		[]byte("%PDF-1.4 attachment-bytes"),
		fixture.smtp.frozen.Attachments[0].Blob,
	)
}

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

	fixture := newRuntimeFixture(t)
	ref := protocol.DraftRef{
		Mailbox: "Drafts", UIDValidity: 52, UID: 12, ModSeq: 91,
	}
	fixture.imap.draft = protocol.Draft{
		Ref: ref,
		Content: protocol.DraftContent{
			From:       "CaseSensitive@example.com",
			To:         []string{"recipient@example.com"},
			Subject:    "Send subject",
			BodyFormat: "text",
			Body:       "secret body",
		},
		// Attachment metadata bounding marks the whole Draft truncated, so the
		// attachment list cannot be trusted and send must fail closed before
		// any SMTP attempt.
		Truncated: true,
		Attachments: []protocol.Attachment{{
			Part:      []int{2},
			Filename:  "must-not-leak.pdf",
			MediaType: "application/pdf",
			Truncated: true,
		}},
	}
	token := fixture.sealReference(
		t,
		token.KindDraft,
		actionSendDraft,
		protocol.MessageRef{
			Mailbox:     ref.Mailbox,
			UIDValidity: ref.UIDValidity,
			UID:         ref.UID,
		},
		ref.ModSeq,
		"",
	)

	_, err := fixture.service.Invoke(
		context.Background(),
		fixture.invocation(actionSendDraft, map[string]any{
			"clientRequestId":  "req_send_truncated_attachment",
			"draft":            token,
			"expectedRevision": ref.ModSeq,
		}),
	)
	require.Error(t, err)
	require.Equal(t, ErrorConflict, mustErrorCode(t, err))
	require.NotContains(t, err.Error(), "must-not-leak.pdf")
	require.Equal(t, 0, fixture.smtp.calls)
}

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

	fixture := newRuntimeFixture(t)
	ref := protocol.DraftRef{
		Mailbox: "Drafts", UIDValidity: 52, UID: 12, ModSeq: 91,
	}
	fixture.imap.draft = protocol.Draft{
		Ref: ref,
		Content: protocol.DraftContent{
			From:       "CaseSensitive@example.com",
			To:         []string{"recipient@example.com"},
			Subject:    "Send subject",
			BodyFormat: "text",
			Body:       "secret body",
		},
	}
	fixture.smtp.status = protocol.ResultAccepted
	// The connection rotates right after the SMTP attempt: every recheck
	// before the send still resolves the original connection.
	rotated := fixture.connection
	rotated.EncryptedCredentials = append(
		append([]byte(nil), rotated.EncryptedCredentials...),
		'\n',
	)
	sequence := make([]mailstore.Connection, 0, 6)
	for index := 0; index < 5; index++ {
		sequence = append(sequence, fixture.connection)
	}
	sequence = append(sequence, rotated)
	fixture.connections.sequence = sequence
	token := fixture.sealReference(
		t,
		token.KindDraft,
		actionSendDraft,
		protocol.MessageRef{
			Mailbox:     ref.Mailbox,
			UIDValidity: ref.UIDValidity,
			UID:         ref.UID,
		},
		ref.ModSeq,
		"",
	)
	result, err := fixture.service.Invoke(
		context.Background(),
		fixture.invocation(actionSendDraft, map[string]any{
			"clientRequestId":  "req_send_rotate",
			"draft":            token,
			"expectedRevision": ref.ModSeq,
		}),
	)
	// The SMTP attempt happened, so the rotated connection can neither
	// confirm nor refute delivery: the result must be unknown, never an
	// error, accepted, or a typed conflict.
	require.NoError(t, err)
	var output sendDraftOutput
	require.NoError(t, json.Unmarshal(result, &output))
	require.Equal(t, protocol.ResultUnknown, output.Status)
	require.Equal(t, 1, fixture.smtp.calls)
}

func testSendDraftToken(
	t *testing.T,
	fixture *runtimeFixture,
	ref protocol.DraftRef,
) string {
	t.Helper()
	return fixture.sealReference(
		t,
		token.KindDraft,
		actionSendDraft,
		protocol.MessageRef{
			Mailbox:     ref.Mailbox,
			UIDValidity: ref.UIDValidity,
			UID:         ref.UID,
		},
		ref.ModSeq,
		"",
	)
}

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

	fixture := newRuntimeFixture(t)
	ref := protocol.DraftRef{
		Mailbox: "Drafts", UIDValidity: 52, UID: 12, ModSeq: 91,
	}
	fixture.imap.draft = protocol.Draft{
		Ref: ref,
		Content: protocol.DraftContent{
			From:       "CaseSensitive@example.com",
			To:         []string{"recipient@example.com"},
			Subject:    "Send subject",
			BodyFormat: "text",
			Body:       "secret body",
		},
	}
	fixture.smtp.status = protocol.ResultAccepted
	fixture.authorizer.err = storeerr.ErrForbidden
	_, err := fixture.service.Invoke(
		context.Background(),
		fixture.invocation(actionSendDraft, map[string]any{
			"clientRequestId":  "req_send_revoked",
			"draft":            testSendDraftToken(t, fixture, ref),
			"expectedRevision": ref.ModSeq,
		}),
	)
	require.Error(t, err)
	require.Equal(t, ErrorPermissionDenied, mustErrorCode(t, err))
	require.Equal(t, 0, fixture.smtp.calls)
	require.Equal(t, 1, fixture.authorizer.calls)
	require.Equal(
		t,
		[]string{ScopeDraftWrite, ScopeSend},
		fixture.authorizer.last.RequiredScopes,
	)
}

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

	fixture := newRuntimeFixture(t)
	fixture.authorizer.afterOK = true
	result, err := fixture.service.Invoke(
		context.Background(),
		fixture.invocation("create-draft", map[string]any{
			"clientRequestId": "req_create_revoke_after",
			"to":              []string{"recipient@example.com"},
			"subject":         "Subject",
			"body": map[string]any{
				"format": "text", "content": "body",
			},
		}),
	)
	require.NoError(t, err)
	var output draftMutationOutput
	require.NoError(t, json.Unmarshal(result, &output))
	require.Equal(t, protocol.ResultUnknown, output.Status)
	require.Equal(t, 1, fixture.imap.appendCalls)
}

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

	fixture := newRuntimeFixture(t)
	ref := protocol.DraftRef{
		Mailbox: "Drafts", UIDValidity: 52, UID: 12, ModSeq: 91,
	}
	fixture.imap.draft = protocol.Draft{
		Ref: ref,
		Content: protocol.DraftContent{
			From:       "CaseSensitive@example.com",
			To:         []string{"recipient@example.com"},
			Subject:    "Send subject",
			BodyFormat: "text",
			Body:       "secret body",
		},
	}
	fixture.smtp.status = protocol.ResultAccepted
	// The authorization is revoked only after the SMTP attempt: the
	// revocation is discovered by the post-send fence.
	fixture.authorizer.afterOK = true
	token := testSendDraftToken(t, fixture, ref)
	result, err := fixture.service.Invoke(
		context.Background(),
		fixture.invocation(actionSendDraft, map[string]any{
			"clientRequestId":  "req_send_revoke_after",
			"draft":            token,
			"expectedRevision": ref.ModSeq,
		}),
	)
	require.NoError(t, err)
	var output sendDraftOutput
	require.NoError(t, json.Unmarshal(result, &output))
	require.Equal(t, protocol.ResultUnknown, output.Status)
	require.Equal(t, 1, fixture.smtp.calls)
	require.Equal(t, 2, fixture.authorizer.calls)
}
