package commerce

import (
	"bytes"
	"context"
	"crypto/rand"
	"encoding/hex"
	"encoding/json"
	"errors"
	"fmt"
	"image"
	"image/jpeg"
	"image/png"
	"io"
	"mime/multipart"
	"net/http"
	"os"
	"path/filepath"
	"regexp"
	"sort"
	"strings"
	"time"

	"gorm.io/gorm"
	"gorm.io/gorm/clause"
)

const maxTicketImageBytes = 5 * 1024 * 1024

var ticketImageTokenRE = regexp.MustCompile(`attachments/images/(?:file|thumbnail)/([A-Za-z0-9_-]+)`)

type TicketImageUploadResponse struct {
	ID           int64  `json:"id"`
	AttachmentID int64  `json:"attachment_id"`
	Token        string `json:"token"`
	Name         string `json:"name"`
	URL          string `json:"url"`
	ThumbnailURL string `json:"thumbnail_url"`
	LegacyURL    string `json:"legacy_url"`
	Path         string `json:"path"`
	Size         int    `json:"size"`
	ContentType  string `json:"content_type"`
}

type TicketFileResponse struct {
	Path        string
	Name        string
	ContentType string
}

type AdminTicketQuery struct {
	Status       string
	Priority     string
	UserID       *int64
	DepartmentID *int64
	AssigneeID   *int64
	Search       string
	Limit        int
	Offset       int
}

type AdminTicketUpdateRequest struct {
	Status       *string `json:"status"`
	Priority     *string `json:"priority"`
	DepartmentID *int64  `json:"department_id"`
	AssigneeID   *int64  `json:"assignee_id"`
}

type AdminTicketReplyRequest struct {
	Content     string           `json:"content"`
	Attachments []map[string]any `json:"attachments"`
	IsInternal  bool             `json:"is_internal"`
}

type AdminTicketTemplateReplyRequest struct {
	TemplateID  int64            `json:"template_id"`
	Attachments []map[string]any `json:"attachments"`
	IsInternal  bool             `json:"is_internal"`
}

type TicketReplyTemplateRequest struct {
	Title        string `json:"title"`
	Content      string `json:"content"`
	DepartmentID *int64 `json:"department_id"`
	IsEnabled    *bool  `json:"is_enabled"`
	SortOrder    *int   `json:"sort_order"`
}

func (s *Service) UploadTicketImage(ctx context.Context, uploaderType string, uploaderID int64, file multipart.File, header *multipart.FileHeader) (TicketImageUploadResponse, error) {
	if err := s.requireDB(); err != nil {
		return TicketImageUploadResponse{}, err
	}
	if header == nil {
		return TicketImageUploadResponse{}, BadRequest("missing upload file")
	}
	defer file.Close()
	raw, err := io.ReadAll(io.LimitReader(file, maxTicketImageBytes+1))
	if err != nil {
		return TicketImageUploadResponse{}, err
	}
	if len(raw) == 0 {
		return TicketImageUploadResponse{}, BadRequest("empty upload file")
	}
	if len(raw) > maxTicketImageBytes {
		return TicketImageUploadResponse{}, BadRequest("ticket image must be <= 5MB")
	}
	contentType := strings.TrimSpace(header.Header.Get("Content-Type"))
	detected := http.DetectContentType(raw)
	if !strings.HasPrefix(strings.ToLower(contentType), "image/") {
		contentType = detected
	}
	contentType = normalizeTicketImageContentType(contentType, header.Filename)
	if !allowedTicketImageContentType(contentType) {
		return TicketImageUploadResponse{}, BadRequest("unsupported ticket image type")
	}
	token, err := randomTicketToken()
	if err != nil {
		return TicketImageUploadResponse{}, err
	}
	ext := ticketImageExt(contentType, header.Filename)
	now := time.Now().UTC()
	relDir := filepath.Join("ticket-images", now.Format("2006"), now.Format("01"), now.Format("02"))
	fileName := fmt.Sprintf("%d_%s%s", now.UnixNano(), token[:12], ext)
	relPath := filepath.Join(relDir, fileName)
	fullPath := filepath.Join("data", "ticket-attachments", relPath)
	if err := os.MkdirAll(filepath.Dir(fullPath), 0o755); err != nil {
		return TicketImageUploadResponse{}, err
	}
	if err := os.WriteFile(fullPath, raw, 0o644); err != nil {
		return TicketImageUploadResponse{}, err
	}
	_ = writeTicketImageThumbnail(fullPath, raw, contentType)
	originalName := strings.TrimSpace(header.Filename)
	attachment := TicketAttachment{
		Token:          token,
		StoragePath:    filepath.ToSlash(relPath),
		OriginalName:   stringPtr(originalName),
		ContentType:    contentType,
		Size:           len(raw),
		UploadedByType: normalizeTicketActorType(uploaderType),
		UploadedByID:   &uploaderID,
		IsClaimed:      false,
	}
	if err := s.db.WithContext(ctx).Create(&attachment).Error; err != nil {
		return TicketImageUploadResponse{}, err
	}
	return ticketImageUploadResponse(attachment), nil
}

func (s *Service) TicketAttachmentFile(ctx context.Context, userID int64, token string, admin bool, thumbnail bool) (TicketFileResponse, error) {
	if err := s.requireDB(); err != nil {
		return TicketFileResponse{}, err
	}
	token = strings.TrimSpace(token)
	if token == "" {
		return TicketFileResponse{}, BadRequest("missing attachment token")
	}
	var item TicketAttachment
	err := s.db.WithContext(ctx).Where("token = ?", token).First(&item).Error
	if errors.Is(err, gorm.ErrRecordNotFound) {
		return TicketFileResponse{}, NotFound("attachment not found")
	}
	if err != nil {
		return TicketFileResponse{}, err
	}
	if !admin && !s.canUserReadTicketAttachment(ctx, userID, item) {
		return TicketFileResponse{}, NotFound("attachment not found")
	}
	return ticketAttachmentFileResponse(item, thumbnail), nil
}

func (s *Service) TicketAttachmentFileByPath(ctx context.Context, userID int64, rawPath string, admin bool, thumbnail bool) (TicketFileResponse, error) {
	if err := s.requireDB(); err != nil {
		return TicketFileResponse{}, err
	}
	storagePath, err := normalizeTicketStoragePath(rawPath)
	if err != nil {
		return TicketFileResponse{}, err
	}
	var item TicketAttachment
	err = s.db.WithContext(ctx).Where("storage_path = ?", storagePath).First(&item).Error
	if errors.Is(err, gorm.ErrRecordNotFound) {
		return TicketFileResponse{}, NotFound("attachment not found")
	}
	if err != nil {
		return TicketFileResponse{}, err
	}
	if !admin && !s.canUserReadTicketAttachment(ctx, userID, item) {
		return TicketFileResponse{}, NotFound("attachment not found")
	}
	return ticketAttachmentFileResponse(item, thumbnail), nil
}

func normalizeTicketStoragePath(rawPath string) (string, error) {
	value := strings.TrimSpace(strings.ReplaceAll(rawPath, `\`, "/"))
	for _, prefix := range []string{"/media/", "media/", "/data/ticket-attachments/", "data/ticket-attachments/"} {
		if strings.HasPrefix(value, prefix) {
			value = strings.TrimPrefix(value, prefix)
			break
		}
	}
	value = strings.TrimLeft(value, "/")
	clean := filepath.ToSlash(filepath.Clean(filepath.FromSlash(value)))
	if value == "" || clean == "." || clean == ".." || strings.HasPrefix(clean, "../") || filepath.IsAbs(filepath.FromSlash(value)) || !strings.HasPrefix(clean, "ticket-images/") {
		return "", BadRequest("invalid ticket image path")
	}
	return clean, nil
}

func ticketAttachmentFileResponse(item TicketAttachment, thumbnail bool) TicketFileResponse {
	fullPath := filepath.Join("data", "ticket-attachments", filepath.FromSlash(item.StoragePath))
	if thumbnail {
		if thumb := ticketThumbnailPath(fullPath); fileExists(thumb) {
			fullPath = thumb
		}
	}
	name := strings.TrimSpace(derefString(item.OriginalName))
	if name == "" {
		name = filepath.Base(fullPath)
	}
	return TicketFileResponse{Path: fullPath, Name: name, ContentType: item.ContentType}
}

func (s *Service) ListAdminTickets(ctx context.Context, query AdminTicketQuery) (map[string]any, error) {
	if err := s.requireDB(); err != nil {
		return nil, err
	}
	db := s.db.WithContext(ctx).Model(&Ticket{})
	db = applyAdminTicketFilters(db, query)
	var total int64
	if err := db.Count(&total).Error; err != nil {
		return nil, err
	}
	var tickets []Ticket
	if err := db.Order("last_message_at DESC, id DESC").Limit(query.Limit).Offset(query.Offset).Find(&tickets).Error; err != nil {
		return nil, err
	}
	counts, err := s.ticketMessageCounts(ctx, ticketIDs(tickets))
	if err != nil {
		return nil, err
	}
	list := make([]map[string]any, 0, len(tickets))
	for _, ticket := range tickets {
		list = append(list, s.adminTicketSummary(ctx, ticket, counts[ticket.ID]))
	}
	return map[string]any{
		"list": list,
		"meta": map[string]any{"total": total, "limit": query.Limit, "offset": query.Offset},
	}, nil
}

func (s *Service) GetAdminTicket(ctx context.Context, ticketID int64) (map[string]any, error) {
	if err := s.requireDB(); err != nil {
		return nil, err
	}
	var ticket Ticket
	err := s.db.WithContext(ctx).Where("id = ?", ticketID).First(&ticket).Error
	if errors.Is(err, gorm.ErrRecordNotFound) {
		return nil, NotFound("ticket not found")
	}
	if err != nil {
		return nil, err
	}
	_ = s.db.WithContext(ctx).Model(&Ticket{}).Where("id = ?", ticket.ID).Update("admin_unread_count", 0).Error
	ticket.AdminUnreadCount = 0
	var messages []TicketMessage
	if err := s.db.WithContext(ctx).Where("ticket_id = ?", ticket.ID).Order("id ASC").Find(&messages).Error; err != nil {
		return nil, err
	}
	var events []TicketEvent
	if err := s.db.WithContext(ctx).Where("ticket_id = ?", ticket.ID).Order("id ASC").Find(&events).Error; err != nil {
		return nil, err
	}
	data := s.adminTicketSummary(ctx, ticket, len(messages))
	data["messages"] = ticketMessageMaps(messages)
	data["events"] = ticketEventMaps(events)
	return data, nil
}

func (s *Service) UpdateAdminTicket(ctx context.Context, adminID int64, ticketID int64, req AdminTicketUpdateRequest) (map[string]any, error) {
	if err := s.requireDB(); err != nil {
		return nil, err
	}
	var out map[string]any
	err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
		txService := &Service{db: tx, redis: s.redis, logger: s.logger, plugins: s.plugins}
		ticket, err := txService.lockTicket(ctx, ticketID)
		if err != nil {
			return err
		}
		updates := map[string]any{}
		if req.Status != nil {
			status := normalizeTicketStatus(*req.Status)
			if status == "" {
				return BadRequest("invalid ticket status")
			}
			if status != ticket.Status {
				if err := txService.addTicketEvent(ctx, ticket.ID, "admin", &adminID, "status_changed", ticket.Status, status, "Admin changed status", nil); err != nil {
					return err
				}
				updates["status"] = status
				ticket.Status = status
				if status == "closed" || status == "resolved" {
					now := time.Now().UTC()
					updates["closed_at"] = now
					ticket.ClosedAt = &now
				}
			}
		}
		if req.Priority != nil {
			priority := normalizeTicketPriority(*req.Priority)
			if priority != ticket.Priority {
				if err := txService.addTicketEvent(ctx, ticket.ID, "admin", &adminID, "priority_changed", ticket.Priority, priority, "Admin changed priority", nil); err != nil {
					return err
				}
				updates["priority"] = priority
				ticket.Priority = priority
			}
		}
		if req.DepartmentID != nil {
			departmentName, err := txService.departmentNameForTicket(ctx, *req.DepartmentID)
			if err != nil {
				return err
			}
			old := fmt.Sprint(ticket.DepartmentID)
			if ticket.DepartmentID == nil || *ticket.DepartmentID != *req.DepartmentID {
				if err := txService.addTicketEvent(ctx, ticket.ID, "admin", &adminID, "department_changed", old, fmt.Sprint(*req.DepartmentID), "Admin changed department", map[string]any{"department_name": departmentName}); err != nil {
					return err
				}
				updates["department_id"] = *req.DepartmentID
				updates["department_name"] = departmentName
				ticket.DepartmentID = req.DepartmentID
				ticket.DepartmentName = &departmentName
			}
		}
		if req.AssigneeID != nil {
			if *req.AssigneeID <= 0 {
				updates["assignee_id"] = nil
				updates["assigned_at"] = nil
				ticket.AssigneeID = nil
				ticket.AssignedAt = nil
			} else {
				if err := txService.ensureUserExists(ctx, *req.AssigneeID); err != nil {
					return err
				}
				if ticket.AssigneeID == nil || *ticket.AssigneeID != *req.AssigneeID {
					now := time.Now().UTC()
					if err := txService.addTicketEvent(ctx, ticket.ID, "admin", &adminID, "assigned", fmt.Sprint(ticket.AssigneeID), fmt.Sprint(*req.AssigneeID), "Admin assigned ticket", nil); err != nil {
						return err
					}
					updates["assignee_id"] = *req.AssigneeID
					updates["assigned_at"] = now
					ticket.AssigneeID = req.AssigneeID
					ticket.AssignedAt = &now
				}
			}
		}
		if len(updates) > 0 {
			if err := tx.Model(&Ticket{}).Where("id = ?", ticket.ID).Updates(updates).Error; err != nil {
				return err
			}
		}
		out = txService.adminTicketSummary(ctx, ticket, 0)
		return nil
	})
	return out, err
}

func (s *Service) AdminReplyTicket(ctx context.Context, adminID int64, ticketID int64, req AdminTicketReplyRequest) (map[string]any, error) {
	if err := s.requireDB(); err != nil {
		return nil, err
	}
	content := strings.TrimSpace(req.Content)
	if len([]rune(content)) < 1 || len([]rune(content)) > 8000 {
		return nil, BadRequest("ticket reply length must be 1-8000")
	}
	var out map[string]any
	err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
		txService := &Service{db: tx, redis: s.redis, logger: s.logger, plugins: s.plugins}
		ticket, err := txService.lockTicket(ctx, ticketID)
		if err != nil {
			return err
		}
		if ticket.Status == "closed" || ticket.Status == "resolved" {
			return BadRequest("ticket is closed, reopen it before replying")
		}
		if ticket.AssigneeID == nil {
			now := time.Now().UTC()
			if err := tx.Model(&Ticket{}).Where("id = ?", ticket.ID).Updates(map[string]any{"assignee_id": adminID, "assigned_at": now}).Error; err != nil {
				return err
			}
			ticket.AssigneeID = &adminID
			ticket.AssignedAt = &now
		}
		oldStatus := ticket.Status
		nextStatus := "answered"
		message, err := txService.addTicketMessageWithOptions(ctx, &ticket, "admin", &adminID, content, req.Attachments, nextStatus, req.IsInternal)
		if err != nil {
			return err
		}
		eventType := "admin_replied"
		if req.IsInternal {
			eventType = "internal_note"
		}
		if err := txService.addTicketEvent(ctx, ticket.ID, "admin", &adminID, eventType, oldStatus, nextStatus, "Admin replied", map[string]any{"is_internal": req.IsInternal}); err != nil {
			return err
		}
		out = ticketMessageMap(message)
		return nil
	})
	return out, err
}

func (s *Service) AdminReplyTicketWithTemplate(ctx context.Context, adminID int64, ticketID int64, req AdminTicketTemplateReplyRequest) (map[string]any, error) {
	if err := s.requireDB(); err != nil {
		return nil, err
	}
	if req.TemplateID <= 0 {
		return nil, BadRequest("template_id is required")
	}
	var template TicketReplyTemplate
	err := s.db.WithContext(ctx).Where("id = ? AND is_enabled = ?", req.TemplateID, true).First(&template).Error
	if errors.Is(err, gorm.ErrRecordNotFound) {
		return nil, NotFound("reply template not found")
	}
	if err != nil {
		return nil, err
	}
	out, err := s.AdminReplyTicket(ctx, adminID, ticketID, AdminTicketReplyRequest{
		Content:     template.Content,
		Attachments: req.Attachments,
		IsInternal:  req.IsInternal,
	})
	if err == nil {
		_ = s.db.WithContext(ctx).Model(&TicketReplyTemplate{}).Where("id = ?", template.ID).Update("usage_count", gorm.Expr("usage_count + 1")).Error
	}
	return out, err
}

func (s *Service) AdminCloseTicket(ctx context.Context, adminID int64, ticketID int64) (map[string]any, error) {
	return s.adminChangeTicketStatus(ctx, adminID, ticketID, "closed", "closed", "Admin closed ticket")
}

func (s *Service) AdminReopenTicket(ctx context.Context, adminID int64, ticketID int64) (map[string]any, error) {
	return s.adminChangeTicketStatus(ctx, adminID, ticketID, "open", "reopened", "Admin reopened ticket")
}

func (s *Service) ListTicketReplyTemplates(ctx context.Context, departmentID *int64, includeDisabled bool) ([]map[string]any, error) {
	if err := s.requireDB(); err != nil {
		return nil, err
	}
	db := s.db.WithContext(ctx).Model(&TicketReplyTemplate{})
	if departmentID != nil {
		db = db.Where("department_id IS NULL OR department_id = ?", *departmentID)
	}
	if !includeDisabled {
		db = db.Where("is_enabled = ?", true)
	}
	var items []TicketReplyTemplate
	if err := db.Order("sort_order ASC, usage_count DESC, id DESC").Find(&items).Error; err != nil {
		return nil, err
	}
	out := make([]map[string]any, 0, len(items))
	for _, item := range items {
		out = append(out, ticketReplyTemplateMap(item))
	}
	return out, nil
}

func (s *Service) CreateTicketReplyTemplate(ctx context.Context, adminID int64, req TicketReplyTemplateRequest) (map[string]any, error) {
	if err := s.requireDB(); err != nil {
		return nil, err
	}
	title, content, err := validateTicketReplyTemplate(req)
	if err != nil {
		return nil, err
	}
	if req.DepartmentID != nil {
		if _, err := s.departmentNameForTicket(ctx, *req.DepartmentID); err != nil {
			return nil, err
		}
	}
	enabled := true
	if req.IsEnabled != nil {
		enabled = *req.IsEnabled
	}
	sortOrder := 0
	if req.SortOrder != nil {
		sortOrder = *req.SortOrder
	}
	item := TicketReplyTemplate{
		Title:        title,
		Content:      content,
		DepartmentID: req.DepartmentID,
		IsEnabled:    enabled,
		SortOrder:    sortOrder,
		CreatedByID:  &adminID,
		UpdatedByID:  &adminID,
	}
	if err := s.db.WithContext(ctx).Create(&item).Error; err != nil {
		return nil, err
	}
	return ticketReplyTemplateMap(item), nil
}

func (s *Service) UpdateTicketReplyTemplate(ctx context.Context, adminID int64, templateID int64, req TicketReplyTemplateRequest) (map[string]any, error) {
	if err := s.requireDB(); err != nil {
		return nil, err
	}
	var item TicketReplyTemplate
	err := s.db.WithContext(ctx).Where("id = ?", templateID).First(&item).Error
	if errors.Is(err, gorm.ErrRecordNotFound) {
		return nil, NotFound("reply template not found")
	}
	if err != nil {
		return nil, err
	}
	updates := map[string]any{"updated_by_id": adminID}
	if strings.TrimSpace(req.Title) != "" {
		if len([]rune(strings.TrimSpace(req.Title))) > 120 {
			return nil, BadRequest("template title must be <= 120")
		}
		updates["title"] = strings.TrimSpace(req.Title)
		item.Title = strings.TrimSpace(req.Title)
	}
	if strings.TrimSpace(req.Content) != "" {
		if len([]rune(strings.TrimSpace(req.Content))) > 8000 {
			return nil, BadRequest("template content must be <= 8000")
		}
		updates["content"] = strings.TrimSpace(req.Content)
		item.Content = strings.TrimSpace(req.Content)
	}
	if req.DepartmentID != nil {
		if *req.DepartmentID <= 0 {
			updates["department_id"] = nil
			item.DepartmentID = nil
		} else {
			if _, err := s.departmentNameForTicket(ctx, *req.DepartmentID); err != nil {
				return nil, err
			}
			updates["department_id"] = *req.DepartmentID
			item.DepartmentID = req.DepartmentID
		}
	}
	if req.IsEnabled != nil {
		updates["is_enabled"] = *req.IsEnabled
		item.IsEnabled = *req.IsEnabled
	}
	if req.SortOrder != nil {
		updates["sort_order"] = *req.SortOrder
		item.SortOrder = *req.SortOrder
	}
	if err := s.db.WithContext(ctx).Model(&TicketReplyTemplate{}).Where("id = ?", item.ID).Updates(updates).Error; err != nil {
		return nil, err
	}
	return ticketReplyTemplateMap(item), nil
}

func (s *Service) DeleteTicketReplyTemplate(ctx context.Context, templateID int64) (map[string]any, error) {
	if err := s.requireDB(); err != nil {
		return nil, err
	}
	result := s.db.WithContext(ctx).Delete(&TicketReplyTemplate{}, templateID)
	if result.Error != nil {
		return nil, result.Error
	}
	if result.RowsAffected == 0 {
		return nil, NotFound("reply template not found")
	}
	return map[string]any{"deleted": true, "id": templateID}, nil
}

func (s *Service) addTicketMessageWithOptions(ctx context.Context, ticket *Ticket, senderType string, senderID *int64, content string, attachments []map[string]any, nextStatus string, isInternal bool) (TicketMessage, error) {
	rawAttachments, _ := json.Marshal(attachments)
	if attachments == nil {
		rawAttachments = []byte("[]")
	}
	message := TicketMessage{
		TicketID:    ticket.ID,
		SenderType:  senderType,
		SenderID:    senderID,
		Content:     content,
		Attachments: JSONRaw(rawAttachments),
		IsInternal:  isInternal,
	}
	if err := s.db.WithContext(ctx).Create(&message).Error; err != nil {
		return TicketMessage{}, err
	}
	claimed, err := s.claimMessageAttachments(ctx, ticket, &message, senderType, senderID, content, attachments)
	if err != nil {
		return TicketMessage{}, err
	}
	if claimed != nil {
		message.Attachments = jsonRawFromAny(claimed)
		if err := s.db.WithContext(ctx).Model(&TicketMessage{}).Where("id = ?", message.ID).Update("attachments", message.Attachments).Error; err != nil {
			return TicketMessage{}, err
		}
	}
	now := time.Now().UTC()
	updates := map[string]any{
		"status":          nextStatus,
		"last_message_at": now,
		"last_reply_by":   senderType,
	}
	if senderType == "user" {
		updates["admin_unread_count"] = gorm.Expr("admin_unread_count + 1")
	} else if senderType == "admin" && !isInternal {
		updates["user_unread_count"] = gorm.Expr("user_unread_count + 1")
	}
	if err := s.db.WithContext(ctx).Model(&Ticket{}).Where("id = ?", ticket.ID).Updates(updates).Error; err != nil {
		return TicketMessage{}, err
	}
	ticket.Status = nextStatus
	ticket.LastMessageAt = &now
	ticket.LastReplyBy = &senderType
	return message, nil
}

func (s *Service) claimMessageAttachments(ctx context.Context, ticket *Ticket, message *TicketMessage, senderType string, senderID *int64, content string, attachments []map[string]any) ([]map[string]any, error) {
	tokens := attachmentTokens(content, attachments)
	if len(tokens) == 0 {
		return attachments, nil
	}
	claimed := make([]map[string]any, 0, len(tokens))
	for _, token := range tokens {
		var item TicketAttachment
		err := s.db.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).Where("token = ?", token).First(&item).Error
		if errors.Is(err, gorm.ErrRecordNotFound) {
			return nil, BadRequest("ticket attachment not found: " + token)
		}
		if err != nil {
			return nil, err
		}
		if !ticketAttachmentOwnedBy(item, senderType, senderID) {
			return nil, BadRequest("ticket attachment owner mismatch")
		}
		if item.IsClaimed && (item.TicketID == nil || *item.TicketID != ticket.ID || item.MessageID == nil || *item.MessageID != message.ID) {
			return nil, BadRequest("ticket attachment already claimed")
		}
		if err := s.db.WithContext(ctx).Model(&TicketAttachment{}).Where("id = ?", item.ID).Updates(map[string]any{
			"ticket_id":  ticket.ID,
			"message_id": message.ID,
			"is_claimed": true,
		}).Error; err != nil {
			return nil, err
		}
		item.TicketID = &ticket.ID
		item.MessageID = &message.ID
		item.IsClaimed = true
		claimed = append(claimed, ticketAttachmentMap(item))
	}
	return claimed, nil
}

func (s *Service) canUserReadTicketAttachment(ctx context.Context, userID int64, item TicketAttachment) bool {
	if item.UploadedByType == "user" && item.UploadedByID != nil && *item.UploadedByID == userID && !item.IsClaimed {
		return true
	}
	if item.TicketID == nil {
		return false
	}
	var count int64
	err := s.db.WithContext(ctx).Model(&Ticket{}).Where("id = ? AND user_id = ?", *item.TicketID, userID).Count(&count).Error
	return err == nil && count > 0
}

func (s *Service) lockTicket(ctx context.Context, ticketID int64) (Ticket, error) {
	var ticket Ticket
	err := s.db.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", ticketID).First(&ticket).Error
	if errors.Is(err, gorm.ErrRecordNotFound) {
		return Ticket{}, NotFound("ticket not found")
	}
	return ticket, err
}

func (s *Service) adminChangeTicketStatus(ctx context.Context, adminID int64, ticketID int64, status string, eventType string, message string) (map[string]any, error) {
	if err := s.requireDB(); err != nil {
		return nil, err
	}
	var out map[string]any
	err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
		txService := &Service{db: tx, redis: s.redis, logger: s.logger, plugins: s.plugins}
		ticket, err := txService.lockTicket(ctx, ticketID)
		if err != nil {
			return err
		}
		oldStatus := ticket.Status
		now := time.Now().UTC()
		updates := map[string]any{"status": status}
		if status == "closed" || status == "resolved" {
			updates["closed_at"] = now
			ticket.ClosedAt = &now
		} else {
			updates["closed_at"] = nil
			ticket.ClosedAt = nil
		}
		if err := tx.Model(&Ticket{}).Where("id = ?", ticket.ID).Updates(updates).Error; err != nil {
			return err
		}
		ticket.Status = status
		if err := txService.addTicketEvent(ctx, ticket.ID, "admin", &adminID, eventType, oldStatus, status, message, nil); err != nil {
			return err
		}
		out = txService.adminTicketSummary(ctx, ticket, 0)
		return nil
	})
	return out, err
}

func (s *Service) adminTicketSummary(ctx context.Context, ticket Ticket, messageCount int) map[string]any {
	data := s.ticketSummary(ctx, ticket, messageCount)
	data["unread_count"] = ticket.AdminUnreadCount
	data["user_id"] = ticket.UserID
	data["user"] = s.ticketUser(ctx, ticket.UserID)
	data["assignee"] = s.ticketUserPtr(ctx, ticket.AssigneeID)
	data["admin_unread_count"] = ticket.AdminUnreadCount
	data["user_unread_count"] = ticket.UserUnreadCount
	return data
}

func (s *Service) ticketUser(ctx context.Context, userID int64) any {
	return s.ticketUserPtr(ctx, &userID)
}

func (s *Service) ticketUserPtr(ctx context.Context, userID *int64) any {
	if userID == nil || *userID <= 0 {
		return nil
	}
	var user UserAccount
	if err := s.db.WithContext(ctx).Where("id = ?", *userID).First(&user).Error; err != nil {
		return map[string]any{"id": *userID}
	}
	return map[string]any{"id": user.ID, "username": user.Username, "email": user.Email, "phone": user.Phone}
}

func (s *Service) departmentNameForTicket(ctx context.Context, departmentID int64) (string, error) {
	var department TicketDepartment
	err := s.db.WithContext(ctx).Where("id = ? AND is_enabled = ?", departmentID, true).First(&department).Error
	if errors.Is(err, gorm.ErrRecordNotFound) {
		return "", NotFound("ticket department not found")
	}
	return department.Name, err
}

func (s *Service) ensureUserExists(ctx context.Context, userID int64) error {
	var count int64
	if err := s.db.WithContext(ctx).Model(&UserAccount{}).Where("id = ?", userID).Count(&count).Error; err != nil {
		return err
	}
	if count == 0 {
		return NotFound("user not found")
	}
	return nil
}

func applyAdminTicketFilters(db *gorm.DB, query AdminTicketQuery) *gorm.DB {
	if strings.TrimSpace(query.Status) != "" {
		db = db.Where("status = ?", strings.TrimSpace(query.Status))
	}
	if strings.TrimSpace(query.Priority) != "" {
		db = db.Where("priority = ?", strings.TrimSpace(query.Priority))
	}
	if query.UserID != nil {
		db = db.Where("user_id = ?", *query.UserID)
	}
	if query.DepartmentID != nil {
		db = db.Where("department_id = ?", *query.DepartmentID)
	}
	if query.AssigneeID != nil {
		if *query.AssigneeID <= 0 {
			db = db.Where("assignee_id IS NULL")
		} else {
			db = db.Where("assignee_id = ?", *query.AssigneeID)
		}
	}
	if search := strings.TrimSpace(query.Search); search != "" {
		like := "%" + search + "%"
		db = db.Where("ticket_no LIKE ? OR subject LIKE ?", like, like)
	}
	return db
}

func validateTicketReplyTemplate(req TicketReplyTemplateRequest) (string, string, error) {
	title := strings.TrimSpace(req.Title)
	content := strings.TrimSpace(req.Content)
	if title == "" || len([]rune(title)) > 120 {
		return "", "", BadRequest("template title length must be 1-120")
	}
	if content == "" || len([]rune(content)) > 8000 {
		return "", "", BadRequest("template content length must be 1-8000")
	}
	return title, content, nil
}

func ticketReplyTemplateMap(item TicketReplyTemplate) map[string]any {
	return map[string]any{
		"id":            item.ID,
		"title":         item.Title,
		"content":       item.Content,
		"department_id": item.DepartmentID,
		"is_enabled":    item.IsEnabled,
		"sort_order":    item.SortOrder,
		"usage_count":   item.UsageCount,
		"created_by_id": item.CreatedByID,
		"updated_by_id": item.UpdatedByID,
		"created_at":    item.CreatedAt,
		"updated_at":    item.UpdatedAt,
	}
}

func attachmentTokens(content string, attachments []map[string]any) []string {
	seen := map[string]struct{}{}
	for _, match := range ticketImageTokenRE.FindAllStringSubmatch(content, -1) {
		if len(match) > 1 {
			seen[match[1]] = struct{}{}
		}
	}
	for _, item := range attachments {
		for _, key := range []string{"token", "attachment_token"} {
			if token := strings.TrimSpace(fmt.Sprint(item[key])); token != "" && token != "<nil>" {
				seen[token] = struct{}{}
			}
		}
		for _, key := range []string{"url", "path", "thumbnail_url", "legacy_url"} {
			text := strings.TrimSpace(fmt.Sprint(item[key]))
			for _, match := range ticketImageTokenRE.FindAllStringSubmatch(text, -1) {
				if len(match) > 1 {
					seen[match[1]] = struct{}{}
				}
			}
		}
	}
	out := make([]string, 0, len(seen))
	for token := range seen {
		out = append(out, token)
	}
	sort.Strings(out)
	return out
}

func ticketAttachmentOwnedBy(item TicketAttachment, senderType string, senderID *int64) bool {
	if item.UploadedByType != normalizeTicketActorType(senderType) {
		return false
	}
	if senderID == nil || item.UploadedByID == nil {
		return senderID == nil && item.UploadedByID == nil
	}
	return *senderID == *item.UploadedByID
}

func ticketAttachmentMap(item TicketAttachment) map[string]any {
	return map[string]any{
		"id":            item.ID,
		"attachment_id": item.ID,
		"token":         item.Token,
		"name":          derefString(item.OriginalName),
		"url":           ticketAttachmentURL("file", item.Token),
		"thumbnail_url": ticketAttachmentURL("thumbnail", item.Token),
		"legacy_url":    ticketAttachmentURL("file", item.Token),
		"path":          item.StoragePath,
		"size":          item.Size,
		"content_type":  item.ContentType,
		"is_claimed":    item.IsClaimed,
		"created_at":    item.CreatedAt,
	}
}

func ticketImageUploadResponse(item TicketAttachment) TicketImageUploadResponse {
	return TicketImageUploadResponse{
		ID:           item.ID,
		AttachmentID: item.ID,
		Token:        item.Token,
		Name:         derefString(item.OriginalName),
		URL:          ticketAttachmentURL("file", item.Token),
		ThumbnailURL: ticketAttachmentURL("thumbnail", item.Token),
		LegacyURL:    ticketAttachmentURL("file", item.Token),
		Path:         item.StoragePath,
		Size:         item.Size,
		ContentType:  item.ContentType,
	}
}

func ticketAttachmentURL(kind string, token string) string {
	return "/api/v1/tickets/attachments/images/" + kind + "/" + token
}

func allowedTicketImageContentType(value string) bool {
	switch strings.ToLower(strings.TrimSpace(value)) {
	case "image/jpeg", "image/png", "image/gif", "image/webp":
		return true
	default:
		return false
	}
}

func normalizeTicketImageContentType(contentType string, name string) string {
	value := strings.ToLower(strings.TrimSpace(strings.Split(contentType, ";")[0]))
	if allowedTicketImageContentType(value) {
		return value
	}
	switch strings.ToLower(filepath.Ext(name)) {
	case ".jpg", ".jpeg":
		return "image/jpeg"
	case ".png":
		return "image/png"
	case ".gif":
		return "image/gif"
	case ".webp":
		return "image/webp"
	default:
		return value
	}
}

func ticketImageExt(contentType string, name string) string {
	ext := strings.ToLower(filepath.Ext(name))
	if ext == ".jpg" || ext == ".jpeg" || ext == ".png" || ext == ".gif" || ext == ".webp" {
		return ext
	}
	switch contentType {
	case "image/png":
		return ".png"
	case "image/gif":
		return ".gif"
	case "image/webp":
		return ".webp"
	default:
		return ".jpg"
	}
}

func normalizeTicketActorType(value string) string {
	switch strings.ToLower(strings.TrimSpace(value)) {
	case "admin", "system":
		return strings.ToLower(strings.TrimSpace(value))
	default:
		return "user"
	}
}

func normalizeTicketStatus(value string) string {
	switch strings.ToLower(strings.TrimSpace(value)) {
	case "open", "pending", "processing", "waiting_user", "answered", "resolved", "closed":
		return strings.ToLower(strings.TrimSpace(value))
	default:
		return ""
	}
}

func randomTicketToken() (string, error) {
	raw := make([]byte, 16)
	if _, err := rand.Read(raw); err != nil {
		return "", err
	}
	return hex.EncodeToString(raw), nil
}

func writeTicketImageThumbnail(fullPath string, raw []byte, contentType string) error {
	if contentType != "image/jpeg" && contentType != "image/png" {
		return nil
	}
	src, _, err := image.Decode(bytes.NewReader(raw))
	if err != nil {
		return err
	}
	bounds := src.Bounds()
	width := bounds.Dx()
	height := bounds.Dy()
	if width <= 0 || height <= 0 {
		return nil
	}
	const maxSide = 360
	if width <= maxSide && height <= maxSide {
		return nil
	}
	newWidth, newHeight := scaledImageSize(width, height, maxSide)
	dst := image.NewRGBA(image.Rect(0, 0, newWidth, newHeight))
	for y := 0; y < newHeight; y++ {
		srcY := bounds.Min.Y + y*height/newHeight
		for x := 0; x < newWidth; x++ {
			srcX := bounds.Min.X + x*width/newWidth
			dst.Set(x, y, src.At(srcX, srcY))
		}
	}
	outPath := ticketThumbnailPath(fullPath)
	out, err := os.Create(outPath)
	if err != nil {
		return err
	}
	defer out.Close()
	if contentType == "image/png" {
		return png.Encode(out, dst)
	}
	return jpeg.Encode(out, dst, &jpeg.Options{Quality: 82})
}

func scaledImageSize(width int, height int, maxSide int) (int, int) {
	if width >= height {
		newWidth := maxSide
		newHeight := height * maxSide / width
		if newHeight < 1 {
			newHeight = 1
		}
		return newWidth, newHeight
	}
	newHeight := maxSide
	newWidth := width * maxSide / height
	if newWidth < 1 {
		newWidth = 1
	}
	return newWidth, newHeight
}

func ticketThumbnailPath(fullPath string) string {
	return filepath.Join(filepath.Dir(fullPath), "thumb_"+filepath.Base(fullPath))
}

func fileExists(path string) bool {
	info, err := os.Stat(path)
	return err == nil && !info.IsDir()
}

func derefString(value *string) string {
	if value == nil {
		return ""
	}
	return *value
}
