package mailruntime

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

	"github.com/stretchr/testify/require"

	"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"
)

type recordingAttachmentLoader struct {
	mu       sync.Mutex
	calls    int
	requests []loadedAttachmentRequest
	byID     map[string]mailruntimeLoadedAttachment
	err      error
}

type loadedAttachmentRequest struct {
	WorkspaceID string
	MemberID    string
	Attachment  string
}

type mailruntimeLoadedAttachment struct {
	filename  string
	mediaType string
	blob      []byte
}

func (loader *recordingAttachmentLoader) LoadAgentAttachment(
	_ context.Context,
	workspaceID string,
	agentMemberID string,
	attachmentID string,
) (LoadedAttachment, error) {
	loader.mu.Lock()
	defer loader.mu.Unlock()
	loader.calls++
	loader.requests = append(loader.requests, loadedAttachmentRequest{
		WorkspaceID: workspaceID,
		MemberID:    agentMemberID,
		Attachment:  attachmentID,
	})
	if loader.err != nil {
		return LoadedAttachment{}, loader.err
	}
	loaded, ok := loader.byID[attachmentID]
	if !ok {
		return LoadedAttachment{}, storeerr.ErrNotFound
	}
	return LoadedAttachment{
		Filename:  loaded.filename,
		MediaType: loaded.mediaType,
		Blob:      loaded.blob,
	}, nil
}

type recordingMaterializer struct {
	mu        sync.Mutex
	calls     int
	targets   []string
	uploads   []AttachmentUpload
	idsBySeq  []string
	err       error
	sizeDelta int64
}

func (materializer *recordingMaterializer) MaterializeAgentAttachment(
	_ context.Context,
	workspaceID string,
	agentID string,
	agentMemberID string,
	target string,
	upload AttachmentUpload,
) (MaterializedAttachment, error) {
	materializer.mu.Lock()
	defer materializer.mu.Unlock()
	materializer.calls++
	materializer.targets = append(materializer.targets, target)
	materializer.uploads = append(materializer.uploads, upload)
	if materializer.err != nil {
		return MaterializedAttachment{}, materializer.err
	}
	index := materializer.calls - 1
	id := fmt.Sprintf("att_materialized_%d", index+1)
	if index < len(materializer.idsBySeq) {
		id = materializer.idsBySeq[index]
	}
	return MaterializedAttachment{
		AttachmentID: id,
		Filename:     upload.Filename,
		MediaType:    upload.MediaType,
		SizeBytes:    int64(len(upload.Blob)) + materializer.sizeDelta,
	}, nil
}

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

	fixture := newRuntimeFixture(t)
	fixture.attachments.byID = map[string]mailruntimeLoadedAttachment{
		"att_report": {
			filename:  "report.pdf",
			mediaType: "application/pdf",
			blob:      []byte("%PDF-1.4 outbound"),
		},
	}
	result, err := fixture.service.Invoke(
		context.Background(),
		fixture.invocation(actionCreateDraft, map[string]any{
			"clientRequestId": "req_create_attach",
			"to":              []any{"customer@example.com"},
			"subject":         "Report",
			"body":            map[string]any{"format": "text", "content": "See attached"},
			"attachments":     []any{map[string]any{"attachmentId": "att_report"}},
		}),
	)
	require.NoError(t, err)
	var output draftMutationOutput
	require.NoError(t, json.Unmarshal(result, &output))
	require.Equal(t, "accepted", output.Status)
	require.Equal(t, 1, fixture.imap.appendCalls)
	require.Len(t, fixture.imap.appended, 1)
	require.Len(t, fixture.imap.appended[0].Attachments, 1)
	require.Equal(t, "report.pdf", fixture.imap.appended[0].Attachments[0].Filename)
	require.Equal(
		t,
		"application/pdf",
		fixture.imap.appended[0].Attachments[0].MediaType,
	)
	require.Equal(
		t,
		[]byte("%PDF-1.4 outbound"),
		fixture.imap.appended[0].Attachments[0].Blob,
	)
	require.Equal(t, 1, fixture.attachments.calls)
	require.Equal(t, "wsp_1", fixture.attachments.requests[0].WorkspaceID)
	require.Equal(t, "mem_agent_1", fixture.attachments.requests[0].MemberID)
}

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

	t.Run("unreadable attachment id is permission denied", func(t *testing.T) {
		fixture := newRuntimeFixture(t)
		_, err := fixture.service.Invoke(
			context.Background(),
			fixture.invocation(actionCreateDraft, map[string]any{
				"clientRequestId": "req_create_attach_denied",
				"to":              []any{"customer@example.com"},
				"subject":         "Report",
				"body":            map[string]any{"format": "text", "content": "See attached"},
				"attachments":     []any{map[string]any{"attachmentId": "att_missing"}},
			}),
		)
		require.Error(t, err)
		require.Equal(t, ErrorPermissionDenied, mustErrorCode(t, err))
		require.Equal(t, 0, fixture.imap.appendCalls)
	})

	t.Run("count over limit is rejected before loading", func(t *testing.T) {
		fixture := newRuntimeFixture(t)
		inputs := make([]any, 0, 11)
		for index := 0; index < 11; index++ {
			inputs = append(inputs, map[string]any{
				"attachmentId": fmt.Sprintf("att_%d", index),
			})
		}
		_, err := fixture.service.Invoke(
			context.Background(),
			fixture.invocation(actionCreateDraft, map[string]any{
				"clientRequestId": "req_create_attach_count",
				"to":              []any{"customer@example.com"},
				"subject":         "Report",
				"body":            map[string]any{"format": "text", "content": "See attached"},
				"attachments":     inputs,
			}),
		)
		require.Error(t, err)
		require.Equal(t, ErrorValidation, mustErrorCode(t, err))
		require.Equal(t, 0, fixture.attachments.calls)
		require.Equal(t, 0, fixture.imap.appendCalls)
	})

	t.Run("loader infrastructure failure is upstream", func(t *testing.T) {
		fixture := newRuntimeFixture(t)
		fixture.attachments.err = errors.New("storage unavailable")
		_, err := fixture.service.Invoke(
			context.Background(),
			fixture.invocation(actionCreateDraft, map[string]any{
				"clientRequestId": "req_create_attach_upstream",
				"to":              []any{"customer@example.com"},
				"subject":         "Report",
				"body":            map[string]any{"format": "text", "content": "See attached"},
				"attachments":     []any{map[string]any{"attachmentId": "att_report"}},
			}),
		)
		require.Error(t, err)
		require.Equal(t, ErrorUpstream, mustErrorCode(t, err))
		require.Equal(t, 0, fixture.imap.appendCalls)
	})
}

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

	fixture := newRuntimeFixture(t)
	sourceRef := protocol.MessageRef{
		Mailbox: "INBOX", UIDValidity: 41, UID: 77,
	}
	fixture.imap.message = protocol.Message{
		Ref:    sourceRef,
		From:   "sender@example.com",
		To:     []string{"recipient@example.com"},
		Body:   protocol.Body{Format: "text", Content: "source body"},
		SentAt: fixture.now,
	}
	fixture.imap.attachments = []protocol.Attachment{{
		Part:      []int{2},
		Filename:  "contract.pdf",
		MediaType: "application/pdf",
	}}
	fixture.imap.attachmentBlobs = map[string][]byte{
		"2": []byte("%PDF-1.4 source-bytes"),
	}
	token := fixture.sealReference(
		t,
		token.KindMessage,
		actionCreateForwardDraft,
		sourceRef,
		0,
		"",
	)
	result, err := fixture.service.Invoke(
		context.Background(),
		fixture.invocation(actionCreateForwardDraft, map[string]any{
			"clientRequestId": "req_forward_attach",
			"message":         token,
			"to":              []any{"colleague@example.com"},
			"body":            map[string]any{"format": "text", "content": "FYI"},
		}),
	)
	require.NoError(t, err)
	var output draftMutationOutput
	require.NoError(t, json.Unmarshal(result, &output))
	require.Equal(t, "accepted", output.Status)
	require.Equal(t, 1, fixture.imap.appendCalls)
	require.Len(t, fixture.imap.appended, 1)
	require.Len(t, fixture.imap.appended[0].Attachments, 1)
	require.Equal(t, "contract.pdf", fixture.imap.appended[0].Attachments[0].Filename)
	require.Equal(
		t,
		[]byte("%PDF-1.4 source-bytes"),
		fixture.imap.appended[0].Attachments[0].Blob,
	)
}

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

	fixture := newRuntimeFixture(t)
	sourceRef := protocol.MessageRef{
		Mailbox: "INBOX", UIDValidity: 41, UID: 78,
	}
	fixture.imap.attachments = []protocol.Attachment{{
		Part:      []int{2},
		Filename:  "invoice.txt",
		MediaType: "text/plain",
	}}
	messageToken := fixture.sealReference(
		t,
		token.KindMessage,
		actionListAttachments,
		sourceRef,
		0,
		"",
	)
	result, err := fixture.service.Invoke(
		context.Background(),
		fixture.invocation(actionListAttachments, map[string]any{
			"message": messageToken,
		}),
	)
	require.NoError(t, err)
	var output attachmentListOutput
	require.NoError(t, json.Unmarshal(result, &output))
	require.Len(t, output.Attachments, 1)

	_, err = fixture.tokens.Open(
		output.Attachments[0].Attachment,
		token.Binding{
			Kind:           token.KindAttachment,
			WorkspaceID:    "wsp_1",
			InstallationID: "appinst_1",
			ConnectionID:   "appconn_1",
			Generation:     fixture.connection.Generation,
			AgentID:        "agt_1",
			Action:         actionGetAttachment,
		},
	)
	require.NoError(t, err)
}

func attachmentInvocation(
	fixture *runtimeFixture,
	action string,
	input any,
	contextJSON string,
) Invocation {
	payload := map[string]any{}
	raw, _ := json.Marshal(input)
	_ = json.Unmarshal(raw, &payload)
	var context struct {
		Target             string `json:"target"`
		ConversationTarget string `json:"conversationTarget"`
	}
	_ = json.Unmarshal([]byte(contextJSON), &context)
	if target := strings.TrimSpace(context.Target); target != "" {
		payload["target"] = target
	} else if target := strings.TrimSpace(context.ConversationTarget); target != "" {
		payload["target"] = target
	}
	return fixture.invocation(action, payload)
}

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

	fixture := newRuntimeFixture(t)
	fixture.imap.attachments = []protocol.Attachment{{
		Part:      []int{2},
		Filename:  "notes.txt",
		MediaType: "text/plain",
	}}
	fixture.imap.attachmentBlobs = map[string][]byte{
		"2": []byte("attachment-body-bytes"),
	}
	attachmentToken := fixture.sealReference(
		t,
		token.KindAttachment,
		actionGetAttachment,
		protocol.MessageRef{
			Mailbox: "INBOX", UIDValidity: 41, UID: 79,
		},
		0,
		"2",
	)
	result, err := fixture.service.Invoke(
		context.Background(),
		attachmentInvocation(
			fixture,
			actionGetAttachment,
			map[string]any{"attachment": attachmentToken},
			`{"target":"#general","conversationTarget":"#general"}`,
		),
	)
	require.NoError(t, err)
	var output getAttachmentOutput
	require.NoError(t, json.Unmarshal(result, &output))
	require.Equal(t, "materialized", output.Status)
	require.Equal(t, "att_materialized_1", output.AttachmentID)
	require.NotContains(t, string(result), "attachment-body-bytes")
	require.Equal(t, 1, fixture.materializer.calls)
	require.Equal(t, "#general", fixture.materializer.targets[0])
	require.Equal(t, "attachment-body-bytes", string(fixture.materializer.uploads[0].Blob))
}

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

	attachmentTokenFor := func(fixture *runtimeFixture) string {
		return fixture.sealReference(
			t,
			token.KindAttachment,
			actionGetAttachment,
			protocol.MessageRef{
				Mailbox: "INBOX", UIDValidity: 41, UID: 80,
			},
			0,
			"2",
		)
	}

	cases := []struct {
		name    string
		context string
	}{
		{"missing target", ""},
		{"null target", `null`},
		{"empty target", `{"target":"  "}`},
	}
	for _, testCase := range cases {
		t.Run(testCase.name, func(t *testing.T) {
			fixture := newRuntimeFixture(t)
			_, err := fixture.service.Invoke(
				context.Background(),
				attachmentInvocation(
					fixture,
					actionGetAttachment,
					map[string]any{"attachment": attachmentTokenFor(fixture)},
					testCase.context,
				),
			)
			require.Error(t, err)
			require.Equal(t, ErrorValidation, mustErrorCode(t, err))
			require.Equal(t, 0, fixture.materializer.calls)
		})
	}
}

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

	fixture := newRuntimeFixture(t)
	fixture.materializer.err = storeerr.ErrForbidden
	attachmentToken := fixture.sealReference(
		t,
		token.KindAttachment,
		actionGetAttachment,
		protocol.MessageRef{
			Mailbox: "INBOX", UIDValidity: 41, UID: 81,
		},
		0,
		"2",
	)
	_, err := fixture.service.Invoke(
		context.Background(),
		attachmentInvocation(
			fixture,
			actionGetAttachment,
			map[string]any{"attachment": attachmentToken},
			`{"target":"#general"}`,
		),
	)
	require.Error(t, err)
	require.Equal(t, ErrorPermissionDenied, mustErrorCode(t, err))
}

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

	fixture := newRuntimeFixture(t)
	fixture.imap.attachments = []protocol.Attachment{{
		Part:      []int{2},
		Filename:  "notes.txt",
		MediaType: "text/plain",
	}}
	fixture.imap.attachmentBlobs = map[string][]byte{"2": []byte("bytes")}
	fixture.materializer.sizeDelta = 7
	attachmentToken := fixture.sealReference(
		t,
		token.KindAttachment,
		actionGetAttachment,
		protocol.MessageRef{
			Mailbox: "INBOX", UIDValidity: 41, UID: 82,
		},
		0,
		"2",
	)
	result, err := fixture.service.Invoke(
		context.Background(),
		attachmentInvocation(
			fixture,
			actionGetAttachment,
			map[string]any{"attachment": attachmentToken},
			`{"target":"#general"}`,
		),
	)
	require.NoError(t, err)
	var output getAttachmentOutput
	require.NoError(t, json.Unmarshal(result, &output))
	require.Equal(t, "unknown", output.Status)
	require.Empty(t, output.AttachmentID)
	require.NotContains(t, string(result), "att_materialized")
}

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

	fixture := newRuntimeFixture(t)
	fixture.imap.attachments = []protocol.Attachment{{
		Part:      []int{2},
		Filename:  "notes.txt",
		MediaType: "text/plain",
	}}
	fixture.imap.attachmentBlobs = map[string][]byte{"2": []byte("bytes")}
	// The upload may have committed before the store acked an
	// infrastructure error; a retry would duplicate the attachment.
	fixture.materializer.err = errors.New("commit transport failed")
	attachmentToken := fixture.sealReference(
		t,
		token.KindAttachment,
		actionGetAttachment,
		protocol.MessageRef{
			Mailbox: "INBOX", UIDValidity: 41, UID: 83,
		},
		0,
		"2",
	)
	result, err := fixture.service.Invoke(
		context.Background(),
		attachmentInvocation(
			fixture,
			actionGetAttachment,
			map[string]any{"attachment": attachmentToken},
			`{"target":"#general"}`,
		),
	)
	require.NoError(t, err)
	var output getAttachmentOutput
	require.NoError(t, json.Unmarshal(result, &output))
	require.Equal(t, "unknown", output.Status)
	require.Empty(t, output.AttachmentID)
}

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

	fixture := newRuntimeFixture(t)
	fixture.imap.attachments = []protocol.Attachment{{
		Part:      []int{2},
		Filename:  "notes.txt",
		MediaType: "text/plain",
	}}
	fixture.authorizer.err = storeerr.ErrForbidden
	attachmentToken := fixture.sealReference(
		t,
		token.KindAttachment,
		actionGetAttachment,
		protocol.MessageRef{
			Mailbox: "INBOX", UIDValidity: 41, UID: 84,
		},
		0,
		"2",
	)
	_, err := fixture.service.Invoke(
		context.Background(),
		attachmentInvocation(
			fixture,
			actionGetAttachment,
			map[string]any{"attachment": attachmentToken},
			`{"target":"#general"}`,
		),
	)
	require.Error(t, err)
	require.Equal(t, ErrorPermissionDenied, mustErrorCode(t, err))
	require.Equal(t, 0, fixture.materializer.calls)
	require.Equal(
		t,
		[]string{ScopeMetadataRead, ScopeContentRead},
		fixture.authorizer.last.RequiredScopes,
	)
}
