package service

import (
	"bytes"
	"context"
	"errors"
	"image"
	"image/color"
	"image/jpeg"
	"io"
	"strings"
	"testing"

	"gorm.io/gorm"

	filemanager "wucher/internal/domain/file_manager"
	"wucher/internal/shared/pkg/logger"
	"wucher/internal/shared/pkg/uuidv7"
)

type fileProcessingStorageMock struct {
	getObjectFn func(context.Context, string) (io.ReadCloser, filemanager.ObjectMetadata, error)
	putObjectFn func(context.Context, filemanager.PutObjectInput) (filemanager.ObjectMetadata, error)
	deleteFn    func(context.Context, string) error
}

func (m *fileProcessingStorageMock) GetObject(ctx context.Context, key string) (io.ReadCloser, filemanager.ObjectMetadata, error) {
	if m.getObjectFn != nil {
		return m.getObjectFn(ctx, key)
	}
	return io.NopCloser(bytes.NewReader(nil)), filemanager.ObjectMetadata{}, nil
}

func (m *fileProcessingStorageMock) PutObject(ctx context.Context, input filemanager.PutObjectInput) (filemanager.ObjectMetadata, error) {
	if m.putObjectFn != nil {
		return m.putObjectFn(ctx, input)
	}
	return filemanager.ObjectMetadata{}, nil
}

func (m *fileProcessingStorageMock) DeleteObject(ctx context.Context, key string) error {
	if m.deleteFn != nil {
		return m.deleteFn(ctx, key)
	}
	return nil
}

func TestFileProcessingUseCase_Process_Success(t *testing.T) {
	fileID := uuidv7.MustBytes()
	fileUUID, err := uuidv7.BytesToString(fileID)
	if err != nil {
		t.Fatalf("uuid to string: %v", err)
	}

	row := &filemanager.File{
		ID:        append([]byte(nil), fileID...),
		Status:    filemanager.FileStatusUploaded,
		Bucket:    "bucket-a",
		ObjectKey: "files/2026/04/03/a.pdf",
		SizeBytes: 8,
		CreatedBy: uuidv7.MustBytes(),
	}
	repo := &fileManagerFileRepoMock{
		getByIDAnyResult: row,
		transitionStatusFn: func(_ context.Context, params filemanager.FileStatusTransitionParams) error {
			if filemanager.NormalizeFileStatus(row.Status) != filemanager.NormalizeFileStatus(params.ExpectedFrom) {
				return gorm.ErrRecordNotFound
			}
			row.Status = params.To
			row.ProcessingStartedAt = params.ProcessingStartedAt
			row.ProcessedAt = params.ProcessedAt
			row.ValidatedAt = params.ValidatedAt
			row.FailedAt = params.FailedAt
			if params.FailureReason != nil {
				row.FailureReason = *params.FailureReason
			}
			return nil
		},
	}
	useCase := NewFileProcessingUseCase(repo, FileProcessingUseCaseDependencies{Logger: logger.NewDiscard()})
	useCase.SetFileStatusRealtimeEventWriter(repo)

	err = useCase.Process(context.Background(), filemanager.FileProcessingJob{
		FileUUID:  fileUUID,
		Bucket:    row.Bucket,
		ObjectKey: row.ObjectKey,
		SizeBytes: row.SizeBytes,
	}, "corr-1")
	if err != nil {
		t.Fatalf("process: %v", err)
	}
	if filemanager.NormalizeFileStatus(row.Status) != filemanager.FileStatusReady {
		t.Fatalf("expected ready status, got %s", row.Status)
	}
	if repo.transitionStatusCalls != 2 {
		t.Fatalf("expected 2 transitions (processing+validated), got %d", repo.transitionStatusCalls)
	}
	if !repo.finalizeCalled {
		t.Fatalf("expected finalize to be called")
	}
	if len(repo.realtimeEvents) != 3 {
		t.Fatalf("expected 3 realtime events, got %d", len(repo.realtimeEvents))
	}
	if got := filemanager.NormalizeFileStatus(repo.realtimeEvents[0].Status); got != filemanager.FileStatusProcessing {
		t.Fatalf("expected first realtime status processing, got %s", got)
	}
	if got := filemanager.NormalizeFileStatus(repo.realtimeEvents[1].Status); got != filemanager.FileStatusValidated {
		t.Fatalf("expected second realtime status validated, got %s", got)
	}
	if got := filemanager.NormalizeFileStatus(repo.realtimeEvents[2].Status); got != filemanager.FileStatusReady {
		t.Fatalf("expected third realtime status ready, got %s", got)
	}
}

func TestFileProcessingUseCase_Process_MarkFailedOnValidationError(t *testing.T) {
	fileID := uuidv7.MustBytes()
	fileUUID, err := uuidv7.BytesToString(fileID)
	if err != nil {
		t.Fatalf("uuid to string: %v", err)
	}

	row := &filemanager.File{
		ID:        append([]byte(nil), fileID...),
		Status:    filemanager.FileStatusUploaded,
		Bucket:    "bucket-a",
		ObjectKey: "files/2026/04/03/a.pdf",
		SizeBytes: 8,
		CreatedBy: uuidv7.MustBytes(),
	}
	repo := &fileManagerFileRepoMock{
		getByIDAnyResult: row,
		transitionStatusFn: func(_ context.Context, params filemanager.FileStatusTransitionParams) error {
			if filemanager.NormalizeFileStatus(row.Status) != filemanager.NormalizeFileStatus(params.ExpectedFrom) {
				return gorm.ErrRecordNotFound
			}
			row.Status = params.To
			row.ProcessingStartedAt = params.ProcessingStartedAt
			row.ProcessedAt = params.ProcessedAt
			row.ValidatedAt = params.ValidatedAt
			row.FailedAt = params.FailedAt
			if params.FailureReason != nil {
				row.FailureReason = *params.FailureReason
			}
			return nil
		},
	}
	useCase := NewFileProcessingUseCase(repo, FileProcessingUseCaseDependencies{Logger: logger.NewDiscard()})
	useCase.SetFileStatusRealtimeEventWriter(repo)

	err = useCase.Process(context.Background(), filemanager.FileProcessingJob{
		FileUUID:  fileUUID,
		Bucket:    "bucket-other",
		ObjectKey: row.ObjectKey,
		SizeBytes: row.SizeBytes,
	}, "corr-2")
	if err != nil {
		t.Fatalf("process: %v", err)
	}
	if filemanager.NormalizeFileStatus(row.Status) != filemanager.FileStatusFailed {
		t.Fatalf("expected failed status, got %s", row.Status)
	}
	if row.FailureReason == "" {
		t.Fatalf("expected failure reason set")
	}
	if repo.transitionStatusCalls != 2 {
		t.Fatalf("expected 2 transitions (processing+failed), got %d", repo.transitionStatusCalls)
	}
	if len(repo.realtimeEvents) != 2 {
		t.Fatalf("expected 2 realtime events, got %d", len(repo.realtimeEvents))
	}
	if got := filemanager.NormalizeFileStatus(repo.realtimeEvents[0].Status); got != filemanager.FileStatusProcessing {
		t.Fatalf("expected first realtime status processing, got %s", got)
	}
	if got := filemanager.NormalizeFileStatus(repo.realtimeEvents[1].Status); got != filemanager.FileStatusFailed {
		t.Fatalf("expected second realtime status failed, got %s", got)
	}
	if strings.TrimSpace(repo.realtimeEvents[1].FailureReason) == "" {
		t.Fatalf("expected failure reason on failed realtime event")
	}
}

func TestFileProcessingUseCase_Process_MarkFailedOnFinalizeError(t *testing.T) {
	fileID := uuidv7.MustBytes()
	fileUUID, err := uuidv7.BytesToString(fileID)
	if err != nil {
		t.Fatalf("uuid to string: %v", err)
	}

	row := &filemanager.File{
		ID:             append([]byte(nil), fileID...),
		Status:         filemanager.FileStatusUploaded,
		Bucket:         "bucket-a",
		ObjectKey:      "files/2026/04/03/a.pdf",
		Name:           "a.pdf",
		NameNormalized: "a.pdf",
		SizeBytes:      8,
		CreatedBy:      uuidv7.MustBytes(),
	}
	repo := &fileManagerFileRepoMock{
		getByIDAnyResult: row,
		transitionStatusFn: func(_ context.Context, params filemanager.FileStatusTransitionParams) error {
			if filemanager.NormalizeFileStatus(row.Status) != filemanager.NormalizeFileStatus(params.ExpectedFrom) {
				return gorm.ErrRecordNotFound
			}
			row.Status = params.To
			row.ProcessingStartedAt = params.ProcessingStartedAt
			row.ProcessedAt = params.ProcessedAt
			row.ValidatedAt = params.ValidatedAt
			row.FailedAt = params.FailedAt
			if params.FailureReason != nil {
				row.FailureReason = *params.FailureReason
			}
			return nil
		},
		finalizeErr: gorm.ErrDuplicatedKey,
	}
	useCase := NewFileProcessingUseCase(repo, FileProcessingUseCaseDependencies{Logger: logger.NewDiscard()})
	useCase.SetFileStatusRealtimeEventWriter(repo)

	err = useCase.Process(context.Background(), filemanager.FileProcessingJob{
		FileUUID:  fileUUID,
		Bucket:    row.Bucket,
		ObjectKey: row.ObjectKey,
		SizeBytes: row.SizeBytes,
	}, "corr-5")
	if err != nil {
		t.Fatalf("process: %v", err)
	}
	if filemanager.NormalizeFileStatus(row.Status) != filemanager.FileStatusFailed {
		t.Fatalf("expected failed status, got %s", row.Status)
	}
	if repo.transitionStatusCalls != 3 {
		t.Fatalf("expected 3 transitions (processing+validated+failed), got %d", repo.transitionStatusCalls)
	}
	if len(repo.realtimeEvents) != 3 {
		t.Fatalf("expected 3 realtime events, got %d", len(repo.realtimeEvents))
	}
	if got := filemanager.NormalizeFileStatus(repo.realtimeEvents[2].Status); got != filemanager.FileStatusFailed {
		t.Fatalf("expected third realtime status failed, got %s", got)
	}
}

func TestFileProcessingUseCase_Process_IgnoreNonEligibleStatus(t *testing.T) {
	fileID := uuidv7.MustBytes()
	fileUUID, err := uuidv7.BytesToString(fileID)
	if err != nil {
		t.Fatalf("uuid to string: %v", err)
	}

	row := &filemanager.File{
		ID:        append([]byte(nil), fileID...),
		Status:    filemanager.FileStatusReady,
		Bucket:    "bucket-a",
		ObjectKey: "files/2026/04/03/a.pdf",
		SizeBytes: 8,
	}
	repo := &fileManagerFileRepoMock{
		getByIDAnyResult: row,
	}
	useCase := NewFileProcessingUseCase(repo, FileProcessingUseCaseDependencies{Logger: logger.NewDiscard()})

	err = useCase.Process(context.Background(), filemanager.FileProcessingJob{
		FileUUID:  fileUUID,
		Bucket:    row.Bucket,
		ObjectKey: row.ObjectKey,
		SizeBytes: row.SizeBytes,
	}, "corr-3")
	if err != nil {
		t.Fatalf("process: %v", err)
	}
	if repo.transitionStatusCalls != 0 {
		t.Fatalf("expected no transition for ready status")
	}
}

func TestFileProcessingUseCase_Process_FileNotFoundPermanent(t *testing.T) {
	fileUUID, err := uuidv7.String(uuidv7.MustNew())
	if err != nil {
		t.Fatalf("uuid string: %v", err)
	}
	repo := &fileManagerFileRepoMock{}
	useCase := NewFileProcessingUseCase(repo, FileProcessingUseCaseDependencies{Logger: logger.NewDiscard()})

	err = useCase.Process(context.Background(), filemanager.FileProcessingJob{
		FileUUID:  fileUUID,
		Bucket:    "bucket-a",
		ObjectKey: "files/2026/04/03/a.pdf",
		SizeBytes: 8,
	}, "corr-4")
	if err == nil {
		t.Fatalf("expected not found error")
	}
	if !IsFileProcessingPermanentError(err) {
		t.Fatalf("expected permanent classification")
	}
	if !errors.Is(err, filemanager.ErrFileNotFound) {
		t.Fatalf("expected ErrFileNotFound, got %v", err)
	}
}

func TestFileProcessingUseCase_Process_CompressesJPEGInWorker(t *testing.T) {
	fileID := uuidv7.MustBytes()
	fileUUID, err := uuidv7.BytesToString(fileID)
	if err != nil {
		t.Fatalf("uuid to string: %v", err)
	}

	// Create a JPEG and ensure worker converts image payload to WebP.
	img := image.NewRGBA(image.Rect(0, 0, 1600, 900))
	for y := 0; y < 900; y++ {
		for x := 0; x < 1600; x++ {
			img.Set(x, y, color.RGBA{R: uint8((x + y) % 255), G: uint8(x % 255), B: uint8(y % 255), A: 255})
		}
	}
	var source bytes.Buffer
	if err := jpeg.Encode(&source, img, &jpeg.Options{Quality: 100}); err != nil {
		t.Fatalf("encode source jpeg: %v", err)
	}
	row := &filemanager.File{
		ID:        append([]byte(nil), fileID...),
		Status:    filemanager.FileStatusUploaded,
		Bucket:    "bucket-a",
		ObjectKey: "files/2026/04/03/a.jpg",
		SizeBytes: int64(source.Len()),
		MimeType:  "image/jpeg",
		CreatedBy: uuidv7.MustBytes(),
	}
	repo := &fileManagerFileRepoMock{
		getByIDAnyResult: row,
		transitionStatusFn: func(_ context.Context, params filemanager.FileStatusTransitionParams) error {
			if filemanager.NormalizeFileStatus(row.Status) != filemanager.NormalizeFileStatus(params.ExpectedFrom) {
				return gorm.ErrRecordNotFound
			}
			row.Status = params.To
			return nil
		},
	}

	var putCalled bool
	var uploadedBytes int
	originalObjectKey := row.ObjectKey
	thumbnailObjectKey := thumbnailObjectKey(originalObjectKey, jpegContentType)
	storage := &fileProcessingStorageMock{
		getObjectFn: func(_ context.Context, key string) (io.ReadCloser, filemanager.ObjectMetadata, error) {
			if key != originalObjectKey {
				t.Fatalf("unexpected object key %q", key)
			}
			return io.NopCloser(bytes.NewReader(source.Bytes())), filemanager.ObjectMetadata{
				Bucket:    row.Bucket,
				ObjectKey: originalObjectKey,
				SizeBytes: int64(source.Len()),
			}, nil
		},
		putObjectFn: func(_ context.Context, input filemanager.PutObjectInput) (filemanager.ObjectMetadata, error) {
			putCalled = true
			if input.ObjectKey != thumbnailObjectKey {
				t.Fatalf("unexpected put object key %q", input.ObjectKey)
			}
			if input.ContentType != jpegContentType {
				t.Fatalf("expected content-type %q, got %q", jpegContentType, input.ContentType)
			}
			body, err := io.ReadAll(input.Body)
			if err != nil {
				return filemanager.ObjectMetadata{}, err
			}
			uploadedBytes = len(body)
			return filemanager.ObjectMetadata{
				Bucket:    row.Bucket,
				ObjectKey: thumbnailObjectKey,
				ETag:      "etag-new",
				VersionID: "v2",
			}, nil
		},
	}

	useCase := NewFileProcessingUseCase(repo, FileProcessingUseCaseDependencies{Logger: logger.NewDiscard()})
	useCase.SetObjectStorage(storage)

	err = useCase.Process(context.Background(), filemanager.FileProcessingJob{
		FileUUID:  fileUUID,
		Bucket:    row.Bucket,
		ObjectKey: row.ObjectKey,
		SizeBytes: row.SizeBytes,
	}, "corr-compress")
	if err != nil {
		t.Fatalf("process: %v", err)
	}

	if !putCalled {
		t.Fatalf("expected compressed object upload")
	}
	if !repo.updateCalled {
		t.Fatalf("expected file metadata update after compression")
	}
	if uploadedBytes <= 0 {
		t.Fatalf("expected non-empty encoded thumbnail bytes")
	}
	if row.ThumbnailSizeBytes != int64(uploadedBytes) {
		t.Fatalf("expected thumbnail size updated to %d, got %d", uploadedBytes, row.ThumbnailSizeBytes)
	}
	if row.MimeType != "image/jpeg" {
		t.Fatalf("expected original mime type untouched, got %q", row.MimeType)
	}
	if row.ThumbnailMimeType == nil || *row.ThumbnailMimeType != jpegContentType {
		t.Fatalf("expected thumbnail mime type %q, got %v", jpegContentType, row.ThumbnailMimeType)
	}
	if row.ObjectKey != originalObjectKey {
		t.Fatalf("expected original object key untouched, got %q", row.ObjectKey)
	}
	if row.ThumbnailObjectKey == nil || *row.ThumbnailObjectKey != thumbnailObjectKey {
		t.Fatalf("expected thumbnail object key %q, got %v", thumbnailObjectKey, row.ThumbnailObjectKey)
	}
	if filemanager.NormalizeFileStatus(row.Status) != filemanager.FileStatusReady {
		t.Fatalf("expected ready status, got %s", row.Status)
	}
}
