package media

import (
	"bytes"
	"errors"
	"fmt"
	"mime"
	"net/http"
	"net/url"
	"strings"
	"time"

	"github.com/gin-gonic/gin"

	"github.com/tidbcloud/AgentTeam/internal/api"
	"github.com/tidbcloud/AgentTeam/internal/api/dto"
	"github.com/tidbcloud/AgentTeam/internal/api/dto/converter"
	"github.com/tidbcloud/AgentTeam/internal/store/agent"
	"github.com/tidbcloud/AgentTeam/internal/store/attachment"
)

const (
	maxAttachmentCount        = 10
	maxImageUploadSizeB       = 10 * 1024 * 1024
	maxMessageAttachmentSizeB = 20 * 1024 * 1024
	maxAttachmentMemForm      = 32 * 1024 * 1024
	maxAttachmentRequestBodyB = maxAttachmentMemForm + maxAttachmentCount*maxMessageAttachmentSizeB
	maxAvatarRequestBodyB     = maxAttachmentMemForm + maxImageUploadSizeB

	attachmentImageCacheMaxAge = 24 * time.Hour
)

// AttachmentHandler owns browser-facing attachment and human avatar routes.
type AttachmentHandler struct {
	authStore                 api.SessionAuthenticator
	workspaceStore            api.WorkspaceResolver
	agentTokenStore           AgentTokenStore
	agentStore                AgentAvatarProfileStore
	store                     AttachmentStore
	emailVerificationRequired bool
}

// Upload handles POST /api/v1/workspaces/:workspaceId/attachments.
// It stores message attachments for the authenticated user in the current workspace.
func (h *AttachmentHandler) Upload(ctx *gin.Context) {
	authenticated, ok := api.AuthenticatedUserFromContext(ctx)
	if !ok {
		api.Error(ctx, http.StatusUnauthorized, "Unauthorized")
		return
	}
	workspace, ok := api.CurrentWorkspaceFromContext(ctx)
	if !ok {
		api.Error(ctx, http.StatusBadRequest, "No workspace selected")
		return
	}

	ctx.Request.Body = http.MaxBytesReader(ctx.Writer, ctx.Request.Body, maxAttachmentRequestBodyB)
	if err := ctx.Request.ParseMultipartForm(maxAttachmentMemForm); err != nil {
		api.Error(ctx, multipartFormErrorStatus(err), "Invalid multipart form")
		return
	}
	if ctx.Request.MultipartForm != nil {
		defer func() { _ = ctx.Request.MultipartForm.RemoveAll() }()
	}

	scopeID := strings.TrimSpace(ctx.PostForm("scopeId"))
	if scopeID == "" {
		api.Error(ctx, http.StatusBadRequest, "scopeId is required")

		return
	}

	params, ok := uploadedAttachmentParams(ctx, "files", maxAttachmentCount, maxMessageAttachmentSizeB, false)
	if !ok {
		return
	}

	attachments, err := h.store.UploadAttachmentsForUser(ctx.Request.Context(), authenticated.User.ID, workspace.WorkspaceID, scopeID, params)
	if err != nil {
		api.StoreError(ctx, err)
		return
	}

	responseAttachments := converter.ToAppAttachments(attachments)
	if responseAttachments == nil {
		responseAttachments = []dto.AppAttachment{}
	}
	ctx.JSON(http.StatusOK, dto.AppAttachmentUploadResponse{Attachments: responseAttachments})
}

func multipartFormErrorStatus(err error) int {
	var maxBytesError *http.MaxBytesError
	if errors.As(err, &maxBytesError) {
		return http.StatusRequestEntityTooLarge
	}
	return http.StatusBadRequest
}

// UploadAvatar handles PUT /api/v1/me/avatar.
// It stores a human avatar image using an explicit bearer token.
func (h *AttachmentHandler) UploadAvatar(ctx *gin.Context) {
	token, ok := api.BearerToken(ctx)
	if !ok {
		api.Error(ctx, http.StatusUnauthorized, "Missing access token")
		return
	}

	authenticated, err := h.authStore.AuthenticateSession(ctx.Request.Context(), token)
	if err != nil {
		api.Error(ctx, http.StatusUnauthorized, "Invalid access token")
		return
	}
	if h.emailVerificationRequired && !authenticated.User.HasVerifiedIdentity() {
		api.ErrorWithCode(ctx, http.StatusForbidden, "Email or mobile not verified", "email_not_verified")
		return
	}

	workspaceID, workspaceOK := api.WorkspaceID(ctx)
	if !workspaceOK {
		api.Error(ctx, http.StatusBadRequest, "No workspace selected")
		return
	}
	workspace, workspaceErr := h.workspaceStore.GetWorkspaceForUser(ctx.Request.Context(), authenticated.User.ID, workspaceID)
	if workspaceErr != nil {
		api.Error(ctx, http.StatusForbidden, "Forbidden")
		return
	}

	param, ok := uploadedAvatarParam(ctx)
	if !ok {
		return
	}

	avatarID, err := h.store.UploadUserAvatar(ctx.Request.Context(), authenticated.User.ID, workspace.WorkspaceID, param)
	if err != nil {
		api.StoreError(ctx, err)
		return
	}

	ctx.JSON(http.StatusOK, dto.AppAvatarUploadResponse{
		ID:  avatarID,
		URL: attachment.VersionedUserAvatarURL(authenticated.User.ID, avatarID),
	})
}

// UploadAgentAvatar handles PUT
// /api/v1/workspaces/:workspaceId/agents/:agentId/avatar. It checks that the
// current user manages the Agent before storing and applying the new avatar.
func (h *AttachmentHandler) UploadAgentAvatar(ctx *gin.Context) {
	authenticated, ok := api.AuthenticatedUserFromContext(ctx)
	if !ok {
		api.Error(ctx, http.StatusUnauthorized, "Unauthorized")
		return
	}
	workspace, ok := api.CurrentWorkspaceFromContext(ctx)
	if !ok {
		api.Error(ctx, http.StatusBadRequest, "No workspace selected")
		return
	}
	agentID := strings.TrimSpace(ctx.Param("agentId"))
	if agentID == "" {
		api.Error(ctx, http.StatusBadRequest, "agentId is required")
		return
	}
	if _, err := h.agentStore.GetAgentForUser(ctx.Request.Context(), authenticated.User.ID, workspace.WorkspaceID, agentID); err != nil {
		api.StoreError(ctx, err)
		return
	}

	param, ok := uploadedAvatarParam(ctx)
	if !ok {
		return
	}
	avatarID, err := h.store.UploadAgentAvatar(ctx.Request.Context(), workspace.WorkspaceID, agentID, param)
	if err != nil {
		api.StoreError(ctx, err)
		return
	}
	avatarURL := attachment.VersionedAgentAvatarURL(workspace.WorkspaceID, agentID, avatarID)
	if _, err := h.agentStore.UpdateAgentForUser(
		ctx.Request.Context(),
		authenticated.User.ID,
		workspace.WorkspaceID,
		agentID,
		agent.UpdateAgentParams{AvatarURL: &avatarURL},
	); err != nil {
		api.StoreError(ctx, err)
		return
	}

	ctx.JSON(http.StatusOK, dto.AppAvatarUploadResponse{ID: avatarID, URL: avatarURL})
}

func uploadedAvatarParam(ctx *gin.Context) (attachment.CreateAttachmentParams, bool) {
	ctx.Request.Body = http.MaxBytesReader(ctx.Writer, ctx.Request.Body, maxAvatarRequestBodyB)
	if err := ctx.Request.ParseMultipartForm(maxImageUploadSizeB); err != nil {
		api.Error(ctx, multipartFormErrorStatus(err), "Invalid multipart form")
		return attachment.CreateAttachmentParams{}, false
	}
	if ctx.Request.MultipartForm != nil {
		defer func() { _ = ctx.Request.MultipartForm.RemoveAll() }()
	}
	return uploadedSingleAttachmentParam(ctx, "file", maxImageUploadSizeB, true)
}

// UploadAvatarAsset handles PUT
// /api/v1/workspaces/:workspaceId/avatar-assets/:ownerKind/:ownerId. It stores
// an immutable image asset; the owning service-account or app API applies the
// returned id only after its own authorization checks.
func (h *AttachmentHandler) UploadAvatarAsset(ctx *gin.Context) {
	workspace, ok := api.CurrentWorkspaceFromContext(ctx)
	if !ok {
		api.Error(ctx, http.StatusBadRequest, "No workspace selected")
		return
	}
	ownerKind := strings.TrimSpace(ctx.Param("ownerKind"))
	ownerID := strings.TrimSpace(ctx.Param("ownerId"))
	if ownerKind == "" || ownerID == "" {
		api.Error(ctx, http.StatusBadRequest, "ownerKind and ownerId are required")
		return
	}

	ctx.Request.Body = http.MaxBytesReader(ctx.Writer, ctx.Request.Body, maxAvatarRequestBodyB)
	if err := ctx.Request.ParseMultipartForm(maxImageUploadSizeB); err != nil {
		api.Error(ctx, multipartFormErrorStatus(err), "Invalid multipart form")
		return
	}
	if ctx.Request.MultipartForm != nil {
		defer func() { _ = ctx.Request.MultipartForm.RemoveAll() }()
	}
	param, ok := uploadedSingleAttachmentParam(ctx, "file", maxImageUploadSizeB, true)
	if !ok {
		return
	}
	avatarID, err := h.store.UploadResourceAvatar(
		ctx.Request.Context(), workspace.WorkspaceID, ownerKind, ownerID, param,
	)
	if err != nil {
		api.StoreError(ctx, err)
		return
	}
	ctx.JSON(http.StatusOK, dto.AppAvatarUploadResponse{
		ID: avatarID, URL: attachment.AvatarAssetURL(avatarID),
	})
}

// GetUserAvatar handles GET /api/v1/users/:userId/avatar.
// It serves the current avatar for a user resource.
func (h *AttachmentHandler) GetUserAvatar(ctx *gin.Context) {
	userID := strings.TrimSpace(ctx.Param("userId"))
	if userID == "" {
		api.Error(ctx, http.StatusBadRequest, "userId is required")
		return
	}

	download, err := h.store.LoadUserAvatar(ctx.Request.Context(), userID)
	h.serveAvatarDownload(ctx, download, err, avatarResourceCacheControl(ctx.Query("v")))
}

// GetAgentAvatar handles GET /api/v1/workspaces/:workspaceId/agents/:agentId/avatar.
// It serves the current avatar for an agent resource.
// GetDeprecatedAvatarObject handles GET /api/avatars/:avatarId.
//
// Deprecated: kept for historical avatar object URLs created before
// 2026-06-12. New callers must use canonical user/avatar resource URLs:
// /api/v1/users/:userId/avatar, /api/v1/me/avatar, or the workspace-scoped
// agent avatar route. Remove after the avatar object URL compatibility window.
func (h *AttachmentHandler) GetDeprecatedAvatarObject(ctx *gin.Context) {
	avatarID := strings.TrimSpace(ctx.Param("avatarId"))
	if avatarID == "" {
		api.Error(ctx, http.StatusBadRequest, "avatarId is required")
		return
	}

	download, err := h.store.LoadAvatar(ctx.Request.Context(), avatarID)
	h.serveAvatarDownload(ctx, download, err, immutableAvatarCacheControl)
}

// GetAvatarAsset handles GET /api/v1/avatar-assets/:avatarId.
func (h *AttachmentHandler) GetAvatarAsset(ctx *gin.Context) {
	avatarID := strings.TrimSpace(ctx.Param("avatarId"))
	if avatarID == "" {
		api.Error(ctx, http.StatusBadRequest, "avatarId is required")
		return
	}
	download, err := h.store.LoadAvatar(ctx.Request.Context(), avatarID)
	h.serveAvatarDownload(ctx, download, err, immutableAvatarCacheControl)
}

func (h *AttachmentHandler) GetAgentAvatar(ctx *gin.Context) {
	workspaceID := strings.TrimSpace(ctx.Param("workspaceId"))
	if workspaceID == "" {
		api.Error(ctx, http.StatusBadRequest, "workspaceId is required")
		return
	}
	agentID := strings.TrimSpace(ctx.Param("agentId"))
	if agentID == "" {
		api.Error(ctx, http.StatusBadRequest, "agentId is required")
		return
	}

	download, err := h.store.LoadAgentAvatar(ctx.Request.Context(), workspaceID, agentID)
	h.serveAvatarDownload(ctx, download, err, avatarResourceCacheControl(ctx.Query("v")))
}

// GetCurrentUserAvatar handles GET /api/v1/me/avatar.
func (h *AttachmentHandler) GetCurrentUserAvatar(ctx *gin.Context) {
	authenticated, ok := api.AuthenticatedUserFromContext(ctx)
	if !ok {
		api.Error(ctx, http.StatusUnauthorized, "Unauthorized")
		return
	}

	download, err := h.store.LoadUserAvatar(ctx.Request.Context(), authenticated.User.ID)
	h.serveAvatarDownload(ctx, download, err, avatarResourceCacheControl(ctx.Query("v")))
}

// GetUserAvatarThumbnail handles GET /api/v1/users/:userId/avatar/thumbnail.
// It serves a lazily-generated avatar derivative (?size=128 default, 512 for
// the enlarged preview) so inline avatar renders never download the original.
func (h *AttachmentHandler) GetUserAvatarThumbnail(r api.PublicRequest) (api.Response, error) {
	userID := strings.TrimSpace(r.Gin.Param("userId"))
	if userID == "" {
		return api.Response{}, api.NewHTTPError(http.StatusBadRequest, "userId is required")
	}
	ref, err := h.store.ResolveUserAvatarRef(r.Gin.Request.Context(), userID)
	return h.serveAvatarThumbnail(
		r,
		ref,
		attachment.CanonicalUserAvatarURL(userID),
		err,
		avatarResourceCacheControl(r.Gin.Query("v")),
		attachment.AvatarThumbnailFitCover,
	)
}

// GetCurrentUserAvatarThumbnail handles GET /api/v1/me/avatar/thumbnail.
func (h *AttachmentHandler) GetCurrentUserAvatarThumbnail(r api.AuthenticatedRequest) (api.Response, error) {
	ref, err := h.store.ResolveUserAvatarRef(r.Context(), r.UserID())
	return h.serveAvatarThumbnail(
		api.PublicRequest{Gin: r.Gin},
		ref,
		attachment.CanonicalUserAvatarURL(r.UserID()),
		err,
		avatarResourceCacheControl(r.Gin.Query("v")),
		attachment.AvatarThumbnailFitCover,
	)
}

// GetAgentAvatarThumbnail handles GET /api/v1/workspaces/:workspaceId/agents/:agentId/avatar/thumbnail.
func (h *AttachmentHandler) GetAgentAvatarThumbnail(r api.PublicRequest) (api.Response, error) {
	workspaceID := strings.TrimSpace(r.Gin.Param("workspaceId"))
	if workspaceID == "" {
		return api.Response{}, api.NewHTTPError(http.StatusBadRequest, "workspaceId is required")
	}
	agentID := strings.TrimSpace(r.Gin.Param("agentId"))
	if agentID == "" {
		return api.Response{}, api.NewHTTPError(http.StatusBadRequest, "agentId is required")
	}
	ref, err := h.store.ResolveAgentAvatarRef(r.Gin.Request.Context(), workspaceID, agentID)
	return h.serveAvatarThumbnail(
		r,
		ref,
		attachment.CanonicalAgentAvatarURL(workspaceID, agentID),
		err,
		avatarResourceCacheControl(r.Gin.Query("v")),
		attachment.AvatarThumbnailFitCover,
	)
}

// GetAvatarAssetThumbnail handles GET /api/v1/avatar-assets/:avatarId/thumbnail.
func (h *AttachmentHandler) GetAvatarAssetThumbnail(r api.PublicRequest) (api.Response, error) {
	avatarID := strings.TrimSpace(r.Gin.Param("avatarId"))
	if avatarID == "" {
		return api.Response{}, api.NewHTTPError(http.StatusBadRequest, "avatarId is required")
	}
	ref := attachment.AvatarVariantRef{AvatarID: avatarID}
	// Avatar asset IDs are immutable, so the derivative caches like the
	// original asset route rather than revalidating without ?v=.
	return h.serveAvatarThumbnail(
		r,
		ref,
		attachment.AvatarAssetURL(avatarID),
		nil,
		immutableAvatarCacheControl,
		attachment.AvatarThumbnailFitContain,
	)
}

// serveAvatarThumbnail resolves the ?size= variant, serves the cached
// derivative from object storage, and lazily generates + persists it on miss.
// External avatar URLs and undecodable formats fall back to the canonical
// avatar URL so no client ever sees a broken avatar from this route.
func (h *AttachmentHandler) serveAvatarThumbnail(
	r api.PublicRequest,
	ref attachment.AvatarVariantRef,
	canonicalURL string,
	resolveErr error,
	cacheControl string,
	fit attachment.AvatarThumbnailFit,
) (api.Response, error) {
	if resolveErr != nil {
		return api.Response{}, resolveErr
	}
	requestContext := r.Gin.Request.Context()
	variant, edge, ok := attachment.AvatarThumbnailSpec(strings.TrimSpace(r.Gin.Query("size")), fit)
	if !ok {
		return api.Response{}, api.NewHTTPError(http.StatusBadRequest, "size must be 128 or 512")
	}
	if ref.ExternalURL != "" || ref.AvatarID == "" {
		// Non-AgentTeam avatar: delegate to the canonical route, which redirects
		// to the external URL.
		return avatarThumbnailRedirectResponse(
			versionedAvatarURL(canonicalURL, r.Gin.Query("v")),
			cacheControl,
		), nil
	}

	// 1. Serve an already-generated derivative (no original fetch).
	if cached, loadErr := h.store.LoadAvatarThumbnail(requestContext, ref.AvatarID, variant); loadErr == nil {
		return avatarThumbnailResponse(ref.AvatarID, variant, cached.ContentType, cached.Blob, cacheControl), nil
	} else if !errors.Is(loadErr, attachment.ErrNotFound) {
		return api.Response{}, loadErr
	}

	// 2. Miss — load the original, generate, persist, serve.
	download, err := h.store.LoadAvatar(requestContext, ref.AvatarID)
	if err != nil {
		return api.Response{}, err
	}
	gen, err := attachment.GenerateAvatarThumbnail(download.Blob, download.ContentType, edge, fit)
	if err != nil {
		// Undecodable format (SVG/WebP/...): serve the original instead.
		return avatarThumbnailRedirectResponse(
			versionedAvatarURL(canonicalURL, r.Gin.Query("v")),
			cacheControl,
		), nil
	}
	saved, err := h.store.SaveAvatarThumbnail(requestContext, ref.AvatarID, variant, gen)
	if err != nil {
		// The derivative cache is an optimization. If the original was read
		// and the thumbnail was generated successfully, a cache write outage
		// must not turn an otherwise usable avatar into a 5xx response.
		return avatarThumbnailResponse(ref.AvatarID, variant, gen.ContentType, gen.Blob, cacheControl), nil
	}
	return avatarThumbnailResponse(ref.AvatarID, variant, saved.ContentType, saved.Blob, cacheControl), nil
}

func avatarThumbnailResponse(
	avatarID string,
	variant string,
	contentType string,
	blob []byte,
	cacheControl string,
) api.Response {
	return api.Content(
		http.StatusOK,
		"avatar-thumbnail",
		contentType,
		bytes.NewReader(blob),
		time.Time{},
		http.Header{
			"Cache-Control": []string{cacheControl},
			"ETag":          []string{fmt.Sprintf("%q", avatarID+":"+variant)},
		},
	)
}

func avatarThumbnailRedirectResponse(location string, cacheControl string) api.Response {
	return api.Response{
		Status: http.StatusTemporaryRedirect,
		NoBody: true,
		Headers: http.Header{
			"Cache-Control": []string{cacheControl},
			"Location":      []string{location},
		},
	}
}

// versionedAvatarURL keeps the ?v= cache-busting parameter when a
// thumbnail request falls back to the canonical avatar route.
func versionedAvatarURL(canonicalURL string, version string) string {
	trimmedVersion := strings.TrimSpace(version)
	if trimmedVersion == "" {
		return canonicalURL
	}
	return canonicalURL + "?v=" + url.QueryEscape(trimmedVersion)
}

const immutableAvatarCacheControl = "public, max-age=86400"

func avatarResourceCacheControl(version string) string {
	if strings.TrimSpace(version) == "" {
		return "no-cache"
	}
	return immutableAvatarCacheControl
}

func (h *AttachmentHandler) serveAvatarDownload(ctx *gin.Context, download attachment.AttachmentDownload, err error, cacheControl string) {
	if err != nil {
		api.StoreError(ctx, err)
		return
	}
	ctx.Header("Cache-Control", cacheControl)
	// AgentTeam-managed avatars are served from the backend as same-origin image
	// media object has a public object-storage URL. A PublicURL without a blob
	// represents an external user avatar URL; keep that legacy behavior as a
	// redirect instead of proxying arbitrary external hosts.
	if len(download.Blob) == 0 && strings.TrimSpace(download.PublicURL) != "" {
		ctx.Redirect(http.StatusTemporaryRedirect, download.PublicURL)
		return
	}

	ctx.Header(
		"Content-Disposition",
		mime.FormatMediaType(
			attachmentContentDisposition(download.ContentType),
			map[string]string{"filename": download.Filename},
		),
	)
	ctx.Data(http.StatusOK, download.ContentType, download.Blob)
}

func attachmentImageCacheControl() string {
	return fmt.Sprintf("private, max-age=%d", int(attachmentImageCacheMaxAge.Seconds()))
}

func attachmentImageCacheETag(attachmentID string) string {
	return fmt.Sprintf("%q", strings.TrimSpace(attachmentID))
}

func attachmentImageCacheVary() string {
	return strings.Join([]string{"Authorization", api.HeaderWorkspaceID}, ", ")
}

func isImageAttachmentContentType(contentType string) bool {
	mediaType, _, err := mime.ParseMediaType(strings.TrimSpace(contentType))
	if err != nil {
		mediaType = strings.TrimSpace(contentType)
	}
	return strings.HasPrefix(strings.ToLower(mediaType), "image/")
}

func attachmentContentDisposition(contentType string) string {
	mediaType, _, err := mime.ParseMediaType(strings.TrimSpace(contentType))
	if err != nil {
		mediaType = strings.TrimSpace(contentType)
	}
	switch strings.ToLower(mediaType) {
	case "application/javascript",
		"application/xhtml+xml",
		"application/xml",
		"image/svg+xml",
		"text/html",
		"text/javascript",
		"text/xml":
		return "attachment"
	default:
		return "inline"
	}
}

// Get handles GET /api/v1/workspaces/:workspaceId/attachments/:attachmentId.
// It serves an attachment for either an authenticated user or an agent bearer token.
func (h *AttachmentHandler) Get(ctx *gin.Context) {
	h.serveAttachment(ctx, strings.TrimSpace(ctx.Param("workspaceId")), true)
}

// GetDeprecatedAttachmentObject handles GET /api/attachments/:attachmentId.
// Deprecated: use the workspace-scoped attachment route instead.
func (h *AttachmentHandler) GetDeprecatedAttachmentObject(ctx *gin.Context) {
	h.serveAttachment(ctx, strings.TrimSpace(ctx.GetHeader(api.HeaderRouteWorkspaceID)), false)
}

func (h *AttachmentHandler) serveAttachment(ctx *gin.Context, workspaceID string, requireWorkspaceID bool) {
	token, ok := api.BearerToken(ctx)
	if !ok {
		api.Error(ctx, http.StatusUnauthorized, "Missing access token")
		return
	}

	authenticated, err := h.authStore.AuthenticateSession(ctx.Request.Context(), token)
	isAgentToken := false
	var agentMemberID string
	if err != nil {
		if !errors.Is(err, ErrUnauthorized) {
			api.StoreError(ctx, err)
			return
		}
		memberID, lookupErr := h.agentTokenStore.MemberIDForAgentAuthToken(ctx.Request.Context(), token)
		if lookupErr != nil {
			api.Error(ctx, http.StatusUnauthorized, "Invalid access token")
			return
		}
		isAgentToken = true
		agentMemberID = memberID
	}
	if !isAgentToken && h.emailVerificationRequired && !authenticated.User.HasVerifiedIdentity() {
		api.ErrorWithCode(ctx, http.StatusForbidden, "Email or mobile not verified", "email_not_verified")
		return
	}

	if requireWorkspaceID && workspaceID == "" {
		api.Error(ctx, http.StatusBadRequest, "workspaceId is required")
		return
	}

	attachmentID := strings.TrimSpace(ctx.Param("attachmentId"))
	if attachmentID == "" {
		api.Error(ctx, http.StatusBadRequest, "attachmentId is required")
		return
	}

	download, err := h.loadAttachmentForRequest(ctx.Request.Context(), isAgentToken, agentMemberID, authenticated.User.ID, workspaceID, attachmentID)
	if err != nil {
		api.StoreError(ctx, err)
		return
	}

	// Attachment previews and opens in the web client use fetch(...).blob()
	// with bearer auth before turning the blob into an object URL. Keep this
	// route same-origin even when the backing object storage has a public URL,
	// otherwise browser fetches cross-origin media URLs and can fail on CORS.
	ctx.Header(
		"Content-Disposition",
		mime.FormatMediaType(
			"attachment",
			map[string]string{"filename": download.Filename},
		),
	)
	if isImageAttachmentContentType(download.ContentType) {
		ctx.Header("Cache-Control", attachmentImageCacheControl())
		ctx.Header("ETag", attachmentImageCacheETag(attachmentID))
		ctx.Header("Vary", attachmentImageCacheVary())
		ctx.Header("Content-Type", download.ContentType)
		http.ServeContent(ctx.Writer, ctx.Request, download.Filename, download.CreatedAt.UTC(), bytes.NewReader(download.Blob))
		return
	}
	ctx.Data(http.StatusOK, download.ContentType, download.Blob)
}

// GetThumbnail handles GET /api/v1/workspaces/:workspaceId/attachments/:attachmentId/thumbnail.
// It serves a lazily-generated, ~480px JPEG thumbnail for the authenticated
// workspace user. Undecodable formats return 404 so the client keeps the
// placeholder and opens the original on tap.
func (h *AttachmentHandler) GetThumbnail(r api.WorkspaceScopedRequest) (api.Response, error) {
	attachmentID := strings.TrimSpace(r.Gin.Param("attachmentId"))
	if attachmentID == "" {
		return api.Response{}, api.NewHTTPError(http.StatusBadRequest, "attachmentId is required")
	}

	// 1. Lightweight auth: check read permission WITHOUT loading the original
	// blob (just the record + message-read check). This prevents a cached
	// thumbnail from being served to any valid token that knows the IDs.
	if err := h.store.AuthorizeAttachmentRead(r.Context(), r.WorkspaceID(), attachmentID, r.UserID(), false, ""); err != nil {
		return api.Response{}, err
	}

	// 2. Serve an already-generated thumbnail if present (no original fetch).
	if variant, loadErr := h.store.LoadAttachmentThumbnail(r.Context(), r.WorkspaceID(), attachmentID); loadErr == nil {
		return thumbnailResponse(attachmentID, variant), nil
	} else if !errors.Is(loadErr, attachment.ErrNotFound) {
		return api.Response{}, loadErr
	}

	// 3. Miss — load the original (auth is already done), generate, store, serve.
	download, err := h.store.LoadAttachmentForUser(r.Context(), r.UserID(), r.WorkspaceID(), attachmentID)
	if err != nil {
		return api.Response{}, err
	}
	gen, err := attachment.GenerateThumbnail(download.Blob, download.ContentType)
	if err != nil {
		return api.Response{}, api.NewHTTPError(http.StatusNotFound, "Thumbnail not available for this attachment")
	}
	variant, err := h.store.SaveAttachmentThumbnail(r.Context(), r.WorkspaceID(), attachmentID, gen)
	if err != nil {
		return api.Response{}, err
	}
	return thumbnailResponse(attachmentID, variant), nil
}

func thumbnailResponse(attachmentID string, variant attachment.AttachmentVariant) api.Response {
	return api.Content(http.StatusOK, "thumbnail.jpg", variant.ContentType, bytes.NewReader(variant.Blob), time.Time{}, http.Header{
		"Cache-Control": []string{attachmentImageCacheControl()},
		"ETag":          []string{attachmentImageCacheETag(attachmentID + ":thumbnail")},
		"Vary":          []string{attachmentImageCacheVary()},
	})
}
