package service

import (
	"bytes"
	"context"
	"errors"
	"fmt"
	"image"
	_ "image/gif"
	"image/jpeg"
	_ "image/jpeg"
	"image/png"
	_ "image/png"
	"io"
	"log/slog"
	"mime/multipart"
	"net/http"
	"path/filepath"
	"strings"
	"time"

	xdraw "golang.org/x/image/draw"
	_ "golang.org/x/image/webp"
	"gorm.io/gorm"

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

type fileProcessingPermanentError struct {
	err error
}

func (e *fileProcessingPermanentError) Error() string {
	if e == nil || e.err == nil {
		return "file processing permanent error"
	}
	return e.err.Error()
}

func (e *fileProcessingPermanentError) Unwrap() error {
	if e == nil {
		return nil
	}
	return e.err
}

func wrapFileProcessingPermanent(err error) error {
	if err == nil {
		return nil
	}
	var marker *fileProcessingPermanentError
	if errors.As(err, &marker) {
		return err
	}
	return &fileProcessingPermanentError{err: err}
}

func IsFileProcessingPermanentError(err error) bool {
	var marker *fileProcessingPermanentError
	return errors.As(err, &marker)
}

type FileProcessingUseCase struct {
	fileRepo         filemanager.FileRepository
	storage          FileProcessingObjectStorage
	logger           *slog.Logger
	fileStatusEvents filemanager.FileStatusRealtimeEventWriter
	gotenbergURL     string
}

type FileProcessingObjectStorage interface {
	GetObject(ctx context.Context, objectKey string) (io.ReadCloser, filemanager.ObjectMetadata, error)
	PutObject(ctx context.Context, input filemanager.PutObjectInput) (filemanager.ObjectMetadata, error)
	DeleteObject(ctx context.Context, objectKey string) error
}

type FileProcessingUseCaseDependencies struct {
	Logger           *slog.Logger
	Storage          FileProcessingObjectStorage
	FileStatusEvents filemanager.FileStatusRealtimeEventWriter
	GotenbergURL     string
}

const (
	jpegContentType        = "image/jpeg"
	pngContentType         = "image/png"
	jpegCompressionQuality = 80
	thumbnailMaxDimension  = 512
)

func stringPointerOrNil(value string) *string {
	value = strings.TrimSpace(value)
	if value == "" {
		return nil
	}
	return &value
}

func NewFileProcessingUseCase(fileRepo filemanager.FileRepository, deps FileProcessingUseCaseDependencies) *FileProcessingUseCase {
	logger := deps.Logger
	if logger == nil {
		logger = slog.Default()
	}
	return &FileProcessingUseCase{
		fileRepo:         fileRepo,
		logger:           logger,
		storage:          deps.Storage,
		fileStatusEvents: deps.FileStatusEvents,
		gotenbergURL:     strings.TrimSpace(deps.GotenbergURL),
	}
}

func (u *FileProcessingUseCase) SetFileStatusRealtimeEventWriter(writer filemanager.FileStatusRealtimeEventWriter) {
	u.fileStatusEvents = writer
}

func (u *FileProcessingUseCase) SetObjectStorage(storage FileProcessingObjectStorage) {
	u.storage = storage
}

func (u *FileProcessingUseCase) Process(ctx context.Context, job filemanager.FileProcessingJob, correlationID string) error {
	if u.fileRepo == nil {
		return wrapFileProcessingPermanent(errors.New("file repository is required"))
	}

	job = canonicalFileProcessingJob(job)
	if err := validateFileProcessingJob(job); err != nil {
		return wrapFileProcessingPermanent(err)
	}

	fileID, err := uuidv7.ParseString(job.FileUUID)
	if err != nil {
		return wrapFileProcessingPermanent(fmt.Errorf("parse file uuid: %w", err))
	}

	row, err := u.fileRepo.GetByIDAny(ctx, fileID)
	if err != nil {
		return err
	}
	if row == nil {
		return wrapFileProcessingPermanent(filemanager.ErrFileNotFound)
	}

	status := filemanager.NormalizeFileStatus(row.Status)
	switch status {
	case filemanager.FileStatusValidated, filemanager.FileStatusFailed, filemanager.FileStatusReady, filemanager.FileStatusTrashed:
		return nil
	case filemanager.FileStatusUploaded:
		changed, err := u.transitionToProcessing(ctx, fileID)
		if err != nil {
			return err
		}
		if changed {
			row.Status = filemanager.FileStatusProcessing
			row.UpdatedAt = time.Now().UTC()
			u.emitStatusEvent(ctx, row, filemanager.FileStatusProcessing, "")
		}
	case filemanager.FileStatusProcessing:
		// Continue from processing state for at-least-once delivery semantics.
	default:
		return wrapFileProcessingPermanent(fmt.Errorf("unexpected file status %q", status))
	}

	if err := validateFileProcessingPayload(row, job); err != nil {
		reason := truncateFailureReason(err.Error())
		changed, markErr := u.markFailedFrom(ctx, fileID, filemanager.FileStatusProcessing, reason)
		if markErr != nil {
			return markErr
		}
		if changed {
			row.Status = filemanager.FileStatusFailed
			row.FailureReason = reason
			row.UpdatedAt = time.Now().UTC()
			u.emitStatusEvent(ctx, row, filemanager.FileStatusFailed, reason)
		}
		u.logger.Warn("file processing failed",
			slog.String("file_uuid", job.FileUUID),
			slog.String("reason", reason),
			slog.String("correlation_id", correlationID),
		)
		return nil
	}

	if err := u.compressObjectIfNeeded(ctx, row, correlationID); err != nil {
		return err
	}

	if err := u.convertDocumentIfNeeded(ctx, row, correlationID); err != nil {
		return err
	}

	changed, err := u.markValidated(ctx, fileID)
	if err != nil {
		return err
	}
	if changed {
		row.Status = filemanager.FileStatusValidated
		row.FailureReason = ""
		row.UpdatedAt = time.Now().UTC()
		u.emitStatusEvent(ctx, row, filemanager.FileStatusValidated, "")
	}

	readyChanged, err := u.markReady(ctx, fileID, row)
	if err != nil {
		reason := truncateFailureReason(err.Error())
		failedChanged, markErr := u.markFailedFrom(ctx, fileID, filemanager.FileStatusValidated, reason)
		if markErr != nil {
			return markErr
		}
		if failedChanged {
			row.Status = filemanager.FileStatusFailed
			row.FailureReason = reason
			row.UpdatedAt = time.Now().UTC()
			u.emitStatusEvent(ctx, row, filemanager.FileStatusFailed, reason)
		}
		u.logger.Warn("file processing finalize failed",
			slog.String("file_uuid", job.FileUUID),
			slog.String("reason", reason),
			slog.String("correlation_id", correlationID),
		)
		return nil
	}
	if readyChanged {
		row.Status = filemanager.FileStatusReady
		row.FailureReason = ""
		row.UpdatedAt = time.Now().UTC()
		u.emitStatusEvent(ctx, row, filemanager.FileStatusReady, "")
	}

	u.logger.Info("file processing completed",
		slog.String("file_uuid", job.FileUUID),
		slog.String("correlation_id", correlationID),
	)
	return nil
}

func (u *FileProcessingUseCase) compressObjectIfNeeded(ctx context.Context, row *filemanager.File, correlationID string) error {
	if row == nil || u.storage == nil {
		return nil
	}
	originalMimeType := normalizeUploadedMimeType(row.MimeType)
	if !isCompressibleImageMimeType(originalMimeType) {
		return nil
	}

	body, _, err := u.storage.GetObject(ctx, row.ObjectKey)
	if err != nil {
		return err
	}
	defer body.Close()

	raw, err := io.ReadAll(body)
	if err != nil {
		return err
	}

	compressed, thumbnailContentType, err := compressImageForPreview(raw, originalMimeType)
	if err != nil {
		u.logger.Warn("file compression skipped: decode/encode failed",
			slog.String("object_key", row.ObjectKey),
			slog.String("mime_type", originalMimeType),
			slog.String("error", err.Error()),
			slog.String("correlation_id", correlationID),
		)
		return nil
	}
	if len(compressed) == 0 {
		return nil
	}

	originalObjectKey := strings.TrimSpace(row.ObjectKey)
	targetObjectKey := thumbnailObjectKey(originalObjectKey, thumbnailContentType)
	meta, err := u.storage.PutObject(ctx, filemanager.PutObjectInput{
		ObjectKey:   targetObjectKey,
		ContentType: thumbnailContentType,
		SizeBytes:   int64(len(compressed)),
		Body:        bytes.NewReader(compressed),
	})
	if err != nil {
		return err
	}

	row.ThumbnailMimeType = stringPointerOrNil(thumbnailContentType)
	row.ThumbnailSizeBytes = int64(len(compressed))
	thumbnailBucket := strings.TrimSpace(meta.Bucket)
	if thumbnailBucket == "" {
		thumbnailBucket = strings.TrimSpace(row.Bucket)
	}
	row.ThumbnailBucket = stringPointerOrNil(thumbnailBucket)
	thumbnailObjectKey := strings.TrimSpace(meta.ObjectKey)
	if thumbnailObjectKey == "" {
		thumbnailObjectKey = targetObjectKey
	}
	row.ThumbnailObjectKey = stringPointerOrNil(thumbnailObjectKey)
	row.ThumbnailETag = stringPointerOrNil(meta.ETag)
	row.ThumbnailObjectVersion = stringPointerOrNil(meta.VersionID)
	if err := u.fileRepo.Update(ctx, row); err != nil {
		return err
	}

	u.logger.Info("file thumbnail generated",
		slog.String("object_key", row.ObjectKey),
		slog.String("thumbnail_object_key", thumbnailObjectKey),
		slog.String("mime_type_before", originalMimeType),
		slog.String("mime_type_after", thumbnailContentType),
		slog.Int("before_bytes", len(raw)),
		slog.Int("after_bytes", len(compressed)),
		slog.String("correlation_id", correlationID),
	)
	return nil
}

func isCompressibleImageMimeType(mimeType string) bool {
	mimeType = strings.ToLower(strings.TrimSpace(mimeType))
	if !strings.HasPrefix(mimeType, "image/") {
		return false
	}
	switch mimeType {
	case "image/svg+xml", "image/webp":
		return false
	default:
		return true
	}
}

func compressImageForPreview(raw []byte, originalMimeType string) ([]byte, string, error) {
	if len(raw) == 0 {
		return nil, "", errors.New("empty image payload")
	}
	img, _, err := image.Decode(bytes.NewReader(raw))
	if err != nil {
		return nil, "", err
	}
	img = downscaleForThumbnail(img, thumbnailMaxDimension)

	var out bytes.Buffer
	switch normalizeUploadedMimeType(originalMimeType) {
	case pngContentType, "image/gif":
		encoder := png.Encoder{CompressionLevel: png.BestCompression}
		if err := encoder.Encode(&out, img); err != nil {
			return nil, "", err
		}
		return out.Bytes(), pngContentType, nil
	default:
		if err := jpeg.Encode(&out, img, &jpeg.Options{Quality: jpegCompressionQuality}); err != nil {
			return nil, "", err
		}
		return out.Bytes(), jpegContentType, nil
	}
}

func downscaleForThumbnail(src image.Image, maxDim int) image.Image {
	if src == nil || maxDim <= 0 {
		return src
	}
	bounds := src.Bounds()
	width := bounds.Dx()
	height := bounds.Dy()
	if width <= 0 || height <= 0 {
		return src
	}
	if width <= maxDim && height <= maxDim {
		return src
	}

	scale := float64(maxDim) / float64(width)
	if height > width {
		scale = float64(maxDim) / float64(height)
	}
	newWidth := int(float64(width) * scale)
	newHeight := int(float64(height) * scale)
	if newWidth < 1 {
		newWidth = 1
	}
	if newHeight < 1 {
		newHeight = 1
	}

	dst := image.NewRGBA(image.Rect(0, 0, newWidth, newHeight))
	xdraw.CatmullRom.Scale(dst, dst.Bounds(), src, bounds, xdraw.Over, nil)
	return dst
}

func (u *FileProcessingUseCase) transitionToProcessing(ctx context.Context, fileID []byte) (bool, error) {
	now := time.Now().UTC()
	if err := u.fileRepo.TransitionStatus(ctx, filemanager.FileStatusTransitionParams{
		ID:                  fileID,
		ExpectedFrom:        filemanager.FileStatusUploaded,
		To:                  filemanager.FileStatusProcessing,
		ProcessingStartedAt: &now,
	}); err != nil {
		if !errors.Is(err, gorm.ErrRecordNotFound) {
			return false, err
		}
		current, getErr := u.fileRepo.GetByIDAny(ctx, fileID)
		if getErr != nil {
			return false, getErr
		}
		if current == nil {
			return false, wrapFileProcessingPermanent(filemanager.ErrFileNotFound)
		}
		switch filemanager.NormalizeFileStatus(current.Status) {
		case filemanager.FileStatusProcessing, filemanager.FileStatusValidated, filemanager.FileStatusFailed, filemanager.FileStatusReady, filemanager.FileStatusTrashed:
			return false, nil
		default:
			return false, err
		}
	}
	return true, nil
}

func (u *FileProcessingUseCase) markValidated(ctx context.Context, fileID []byte) (bool, error) {
	now := time.Now().UTC()
	if err := u.fileRepo.TransitionStatus(ctx, filemanager.FileStatusTransitionParams{
		ID:           fileID,
		ExpectedFrom: filemanager.FileStatusProcessing,
		To:           filemanager.FileStatusValidated,
		ProcessedAt:  &now,
		ValidatedAt:  &now,
	}); err != nil {
		if !errors.Is(err, gorm.ErrRecordNotFound) {
			return false, err
		}
		current, getErr := u.fileRepo.GetByIDAny(ctx, fileID)
		if getErr != nil {
			return false, getErr
		}
		if current == nil {
			return false, nil
		}
		switch filemanager.NormalizeFileStatus(current.Status) {
		case filemanager.FileStatusValidated, filemanager.FileStatusFailed, filemanager.FileStatusReady, filemanager.FileStatusTrashed:
			return false, nil
		default:
			return false, err
		}
	}
	return true, nil
}

func (u *FileProcessingUseCase) markFailedFrom(ctx context.Context, fileID []byte, expectedFrom string, reason string) (bool, error) {
	now := time.Now().UTC()
	if err := u.fileRepo.TransitionStatus(ctx, filemanager.FileStatusTransitionParams{
		ID:            fileID,
		ExpectedFrom:  expectedFrom,
		To:            filemanager.FileStatusFailed,
		ProcessedAt:   &now,
		FailedAt:      &now,
		FailureReason: &reason,
	}); err != nil {
		if !errors.Is(err, gorm.ErrRecordNotFound) {
			return false, err
		}
		current, getErr := u.fileRepo.GetByIDAny(ctx, fileID)
		if getErr != nil {
			return false, getErr
		}
		if current == nil {
			return false, nil
		}
		switch filemanager.NormalizeFileStatus(current.Status) {
		case filemanager.FileStatusFailed, filemanager.FileStatusReady, filemanager.FileStatusTrashed:
			return false, nil
		default:
			return false, err
		}
	}
	return true, nil
}

func (u *FileProcessingUseCase) markReady(ctx context.Context, fileID []byte, row *filemanager.File) (bool, error) {
	if row == nil {
		return false, errors.New("file row is required")
	}
	nameNormalized := strings.TrimSpace(row.NameNormalized)
	if nameNormalized == "" {
		nameNormalized = strings.ToLower(strings.TrimSpace(row.Name))
	}

	updatedBy := row.UpdatedBy
	if len(updatedBy) != 16 {
		updatedBy = row.CreatedBy
	}

	if err := u.fileRepo.Finalize(ctx, filemanager.FileFinalizeParams{
		ID:             fileID,
		ExpectedFrom:   filemanager.FileStatusValidated,
		FolderID:       append([]byte(nil), row.FolderID...),
		Name:           row.Name,
		NameNormalized: nameNormalized,
		Extension:      normalizeExtensionToken(firstNonEmptyString(row.Extension, extensionFromName(row.Name))),
		UpdatedBy:      updatedBy,
	}); err != nil {
		if errors.Is(err, gorm.ErrDuplicatedKey) {
			return false, filemanager.ErrDuplicateName
		}
		if !errors.Is(err, gorm.ErrRecordNotFound) {
			return false, err
		}
		current, getErr := u.fileRepo.GetByIDAny(ctx, fileID)
		if getErr != nil {
			return false, getErr
		}
		if current == nil {
			return false, nil
		}
		switch filemanager.NormalizeFileStatus(current.Status) {
		case filemanager.FileStatusReady, filemanager.FileStatusFailed, filemanager.FileStatusTrashed:
			return false, nil
		default:
			return false, err
		}
	}
	return true, nil
}

func (u *FileProcessingUseCase) emitStatusEvent(ctx context.Context, row *filemanager.File, status, failureReason string) {
	if u.fileStatusEvents == nil || row == nil {
		return
	}
	userID := row.CreatedBy
	if len(userID) != 16 {
		userID = row.UpdatedBy
	}
	if len(userID) != 16 {
		return
	}

	occurredAt := row.UpdatedAt
	if occurredAt.IsZero() {
		occurredAt = time.Now().UTC()
	}
	_ = u.fileStatusEvents.CreateFileStatusRealtimeEvent(ctx, filemanager.FileStatusRealtimeEventInput{
		FileID:        append([]byte(nil), row.ID...),
		UserID:        append([]byte(nil), userID...),
		Status:        status,
		Name:          row.Name,
		FailureReason: strings.TrimSpace(failureReason),
		OccurredAt:    occurredAt,
	})
}

func canonicalFileProcessingJob(job filemanager.FileProcessingJob) filemanager.FileProcessingJob {
	job.JobID = strings.TrimSpace(job.JobID)
	job.FileUUID = strings.TrimSpace(job.FileUUID)
	job.Bucket = strings.TrimSpace(job.Bucket)
	job.ObjectKey = strings.TrimSpace(job.ObjectKey)
	job.UploadedBy = strings.TrimSpace(job.UploadedBy)
	job.MimeType = strings.ToLower(strings.TrimSpace(job.MimeType))
	job.TraceID = strings.TrimSpace(job.TraceID)
	job.RequestID = strings.TrimSpace(job.RequestID)
	return job
}

func validateFileProcessingJob(job filemanager.FileProcessingJob) error {
	if strings.TrimSpace(job.FileUUID) == "" {
		return errors.New("file uuid is required")
	}
	if strings.TrimSpace(job.Bucket) == "" {
		return errors.New("bucket is required")
	}
	if strings.TrimSpace(job.ObjectKey) == "" {
		return errors.New("object key is required")
	}
	if job.SizeBytes < 0 {
		return errors.New("size bytes must be non-negative")
	}
	return nil
}

func validateFileProcessingPayload(row *filemanager.File, job filemanager.FileProcessingJob) error {
	if row == nil {
		return errors.New("file row is missing")
	}
	if strings.TrimSpace(row.Bucket) == "" {
		return errors.New("file bucket is empty")
	}
	if strings.TrimSpace(row.ObjectKey) == "" {
		return errors.New("file object key is empty")
	}
	if !strings.EqualFold(strings.TrimSpace(row.Bucket), strings.TrimSpace(job.Bucket)) {
		return errors.New("file bucket mismatch")
	}
	rowObjectKey := strings.TrimSpace(row.ObjectKey)
	jobObjectKey := strings.TrimSpace(job.ObjectKey)
	if rowObjectKey != jobObjectKey {
		// Keep compatibility with records converted by earlier worker behavior.
		if rowObjectKey != webpObjectKey(jobObjectKey) {
			return errors.New("file object key mismatch")
		}
	}
	if row.SizeBytes < 0 {
		return errors.New("file size is invalid")
	}
	return nil
}

func webpObjectKey(objectKey string) string {
	objectKey = strings.TrimSpace(objectKey)
	if objectKey == "" {
		return objectKey
	}
	ext := strings.ToLower(strings.TrimSpace(filepath.Ext(objectKey)))
	if ext == ".webp" {
		return objectKey
	}
	if ext == "" {
		return objectKey + ".webp"
	}
	return strings.TrimSuffix(objectKey, ext) + ".webp"
}

func thumbnailObjectKey(objectKey, mimeType string) string {
	objectKey = strings.TrimSpace(objectKey)
	if objectKey == "" {
		return objectKey
	}
	thumbExt := ".jpg"
	switch normalizeUploadedMimeType(mimeType) {
	case pngContentType:
		thumbExt = ".png"
	}
	ext := strings.TrimSpace(filepath.Ext(objectKey))
	if ext == "" {
		return objectKey + ".thumb" + thumbExt
	}
	return strings.TrimSuffix(objectKey, ext) + ".thumb" + thumbExt
}

func previewObjectKey(objectKey string) string {
	objectKey = strings.TrimSpace(objectKey)
	if objectKey == "" {
		return objectKey
	}
	ext := strings.TrimSpace(filepath.Ext(objectKey))
	if ext == "" {
		return objectKey + ".preview.pdf"
	}
	return strings.TrimSuffix(objectKey, ext) + ".preview.pdf"
}

func isConvertibleDocumentMimeType(mimeType string) bool {
	switch strings.ToLower(strings.TrimSpace(mimeType)) {
	case "application/vnd.openxmlformats-officedocument.wordprocessingml.document", // docx
		"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",         // xlsx
		"application/vnd.openxmlformats-officedocument.presentationml.presentation", // pptx
		"application/msword",           // doc
		"application/vnd.ms-excel",     // xls
		"application/vnd.ms-powerpoint": // ppt
		return true
	default:
		return false
	}
}

func (u *FileProcessingUseCase) convertDocumentIfNeeded(ctx context.Context, row *filemanager.File, correlationID string) error {
	if row == nil || u.storage == nil || u.gotenbergURL == "" {
		return nil
	}
	if !isConvertibleDocumentMimeType(row.MimeType) {
		return nil
	}

	body, _, err := u.storage.GetObject(ctx, row.ObjectKey)
	if err != nil {
		return fmt.Errorf("document preview: get original from storage: %w", err)
	}
	defer body.Close()

	raw, err := io.ReadAll(body)
	if err != nil {
		return fmt.Errorf("document preview: read original: %w", err)
	}

	pdfBytes, err := u.callGotenberg(ctx, raw, row.Name)
	if err != nil {
		return err
	}

	targetKey := previewObjectKey(strings.TrimSpace(row.ObjectKey))
	meta, err := u.storage.PutObject(ctx, filemanager.PutObjectInput{
		ObjectKey:   targetKey,
		ContentType: "application/pdf",
		SizeBytes:   int64(len(pdfBytes)),
		Body:        bytes.NewReader(pdfBytes),
	})
	if err != nil {
		return fmt.Errorf("document preview: upload preview to storage: %w", err)
	}

	previewMimeType := "application/pdf"
	row.PreviewMimeType = &previewMimeType
	row.PreviewSizeBytes = int64(len(pdfBytes))
	previewBucket := strings.TrimSpace(meta.Bucket)
	if previewBucket == "" {
		previewBucket = strings.TrimSpace(row.Bucket)
	}
	row.PreviewBucket = &previewBucket
	previewKey := strings.TrimSpace(meta.ObjectKey)
	if previewKey == "" {
		previewKey = targetKey
	}
	row.PreviewObjectKey = &previewKey
	row.PreviewETag = stringPointerOrNil(meta.ETag)
	row.PreviewObjectVersion = stringPointerOrNil(meta.VersionID)

	if err := u.fileRepo.Update(ctx, row); err != nil {
		return fmt.Errorf("document preview: save preview metadata: %w", err)
	}

	u.logger.Info("document preview generated",
		slog.String("object_key", row.ObjectKey),
		slog.String("preview_object_key", previewKey),
		slog.String("mime_type", row.MimeType),
		slog.Int("preview_bytes", len(pdfBytes)),
		slog.String("correlation_id", correlationID),
	)
	return nil
}

func (u *FileProcessingUseCase) callGotenberg(ctx context.Context, raw []byte, filename string) ([]byte, error) {
	var body bytes.Buffer
	writer := multipart.NewWriter(&body)

	part, err := writer.CreateFormFile("files", filename)
	if err != nil {
		return nil, wrapFileProcessingPermanent(fmt.Errorf("document preview: create multipart field: %w", err))
	}
	if _, err := part.Write(raw); err != nil {
		return nil, wrapFileProcessingPermanent(fmt.Errorf("document preview: write file to multipart: %w", err))
	}
	if err := writer.Close(); err != nil {
		return nil, wrapFileProcessingPermanent(fmt.Errorf("document preview: close multipart writer: %w", err))
	}

	req, err := http.NewRequestWithContext(ctx, http.MethodPost, u.gotenbergURL+"/forms/libreoffice/convert", &body)
	if err != nil {
		return nil, wrapFileProcessingPermanent(fmt.Errorf("document preview: build gotenberg request: %w", err))
	}
	req.Header.Set("Content-Type", writer.FormDataContentType())

	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		// Network or connection error — transient, allow retry.
		return nil, fmt.Errorf("document preview: gotenberg request failed: %w", err)
	}
	defer resp.Body.Close()

	respBody, err := io.ReadAll(resp.Body)
	if err != nil {
		return nil, fmt.Errorf("document preview: read gotenberg response: %w", err)
	}

	if resp.StatusCode >= http.StatusBadRequest && resp.StatusCode < http.StatusInternalServerError {
		// 4xx: bad document or unsupported format — permanent failure.
		return nil, wrapFileProcessingPermanent(fmt.Errorf("document preview: gotenberg rejected document (status %d)", resp.StatusCode))
	}
	if resp.StatusCode != http.StatusOK {
		// 5xx or unexpected — transient, allow retry.
		return nil, fmt.Errorf("document preview: gotenberg returned status %d", resp.StatusCode)
	}

	if len(respBody) == 0 {
		return nil, wrapFileProcessingPermanent(fmt.Errorf("document preview: gotenberg returned empty PDF"))
	}

	return respBody, nil
}
