package services

import (
	"astrology-api/constants"
	dto "astrology-api/dto/consultation"
	models "astrology-api/models/usermodel"
	"astrology-api/repositories"
	"errors"
	"strings"
	"time"
)

// Chat transcripts.
//
// Agora RTM persists nothing, so the conversation exists only on the two
// devices until one of them uploads it. This is that upload — it is never on
// the realtime path, and the API never sees a message as it is sent.
//
// Both parties may upload the same conversation, including the messages they
// received. That redundancy is the whole point: only one device may survive a
// kill, and the unique key on (consultationId, clientMessageId) turns the
// second copy into a no-op.
//
// The contract that makes it work lives in the apps: clientMessageId is
// generated once by the SENDING device, travels inside the RTM payload, is
// stored verbatim by the receiver, and is echoed back by both on upload. If
// the receiving app mints its own id instead, every message is stored twice
// and nothing on this side can tell.
//
// Kept out of consultation_service.go deliberately. That file is the billing
// path, and storing what people said is not billing.

const (
	maxTranscriptBatch  = 500
	maxMessageBodyChars = 4000
	maxClientMessageID  = 64

	defaultTranscriptWindowHours = 72.0
)

type ConsultationMessageService interface {

	// SyncMessages stores a batch from the customer's app.
	SyncMessages(userID uint, request dto.SyncMessagesRequest) (*dto.SyncMessagesResponse, error)

	// SyncMessagesByAstrologer stores the same batch shape from the other
	// side. Same service method underneath — the astrologer stack calls into
	// this one rather than owning a second implementation, exactly as it does
	// for ending a session.
	SyncMessagesByAstrologer(astrologerUserID uint, request dto.SyncMessagesRequest) (*dto.SyncMessagesResponse, error)

	ListMessages(userID uint, consultationID uint, page int, limit int) (*dto.ConsultationMessagesResponse, error)

	ListMessagesByAstrologer(astrologerUserID uint, consultationID uint, page int, limit int) (*dto.ConsultationMessagesResponse, error)
}

type consultationMessageService struct {
	repository repositories.ConsultationRepository
}

func NewConsultationMessageService(
	repository repositories.ConsultationRepository,
) ConsultationMessageService {

	return &consultationMessageService{repository: repository}
}

//////////////////////////////////////////////////////////////
// Sync
//////////////////////////////////////////////////////////////

func (s *consultationMessageService) SyncMessages(
	userID uint,
	request dto.SyncMessagesRequest,
) (*dto.SyncMessagesResponse, error) {

	consultation, err := s.repository.GetConsultationByID(request.ConsultationID)

	if err != nil {
		return nil, err
	}

	if consultation == nil || consultation.UserID != userID {
		return nil, errors.New("consultation not found")
	}

	return s.sync(consultation, constants.SenderTypeUser, request)
}

func (s *consultationMessageService) SyncMessagesByAstrologer(
	astrologerUserID uint,
	request dto.SyncMessagesRequest,
) (*dto.SyncMessagesResponse, error) {

	astrologer, err := s.repository.GetAstrologerByUserID(astrologerUserID)

	if err != nil {
		return nil, err
	}

	if astrologer == nil {
		return nil, errors.New("astrologer not found")
	}

	consultation, err := s.repository.GetConsultationByID(request.ConsultationID)

	if err != nil {
		return nil, err
	}

	if consultation == nil || consultation.AstrologerID != astrologer.ID {
		return nil, errors.New("consultation not found")
	}

	return s.sync(consultation, constants.SenderTypeAstrologer, request)
}

func (s *consultationMessageService) sync(
	consultation *models.Consultation,
	uploader string,
	request dto.SyncMessagesRequest,
) (*dto.SyncMessagesResponse, error) {

	if consultation.Medium != constants.MediumChat {
		return nil, errors.New("transcripts are only stored for chat consultations")
	}

	if len(request.Messages) == 0 {
		return nil, errors.New("messages is required")
	}

	// Refuse the whole batch rather than silently truncating it, so the app's
	// retry is simply "split and resend" instead of having to work out what
	// was dropped.
	if len(request.Messages) > maxTranscriptBatch {
		return nil, errors.New("a transcript batch may contain at most 500 messages")
	}

	if err := s.assertUploadable(consultation); err != nil {
		return nil, err
	}

	//------------------------------------------------
	// Validate
	//------------------------------------------------

	response := &dto.SyncMessagesResponse{
		ConsultationID: consultation.ID,
		Received:       len(request.Messages),
	}

	candidates := make([]models.ChatMessage, 0, len(request.Messages))
	ids := make([]string, 0, len(request.Messages))

	now := time.Now()

	for _, item := range request.Messages {

		clientID := strings.TrimSpace(item.ClientMessageID)

		if clientID == "" || len(clientID) > maxClientMessageID {

			response.Rejected = append(response.Rejected, dto.RejectedMessage{
				ClientMessageID: item.ClientMessageID,
				Reason:          "client_message_id is required and must be at most 64 characters",
			})

			continue
		}

		senderType := strings.ToUpper(strings.TrimSpace(item.SenderType))

		// senderId is resolved from the consultation's two parties, never
		// read off the body. Otherwise either side could forge messages
		// attributed to the other, in the record support reads to settle a
		// dispute.
		var senderID uint

		switch senderType {

		case constants.SenderTypeUser:
			senderID = consultation.UserID

		case constants.SenderTypeAstrologer:
			senderID = consultation.AstrologerID

		case constants.SenderTypeSystem:
			senderID = 0

		default:

			response.Rejected = append(response.Rejected, dto.RejectedMessage{
				ClientMessageID: clientID,
				Reason:          "sender_type must be USER, ASTROLOGER or SYSTEM",
			})

			continue
		}

		messageType := strings.ToUpper(strings.TrimSpace(item.MessageType))

		if messageType == "" {
			messageType = constants.MessageTypeText
		}

		if messageType != constants.MessageTypeText && messageType != constants.MessageTypeSystem {

			response.Rejected = append(response.Rejected, dto.RejectedMessage{
				ClientMessageID: clientID,
				Reason:          "only TEXT messages are stored",
			})

			continue
		}

		// Truncated rather than rejected: losing one long message is bad,
		// losing the whole conversation because of it is worse.
		body := item.Body

		if len([]rune(body)) > maxMessageBodyChars {
			body = truncate(body, maxMessageBodyChars)
			response.Truncated++
		}

		message := models.ChatMessage{
			ConsultationID:  consultation.ID,
			UserID:          consultation.UserID,
			AstrologerID:    consultation.AstrologerID,
			SenderType:      senderType,
			SenderID:        senderID,
			ClientMessageID: clientID,
			MessageType:     messageType,
			Body:            body,
			Sequence:        item.Sequence,
			UploadedBy:      uploader,
			CreatedAt:       &now,
			UpdatedAt:       &now,
		}

		if sentAt := parseStamp(item.SentAt); sentAt != nil {
			message.SentAt = sentAt
		} else {
			message.SentAt = &now
		}

		candidates = append(candidates, message)
		ids = append(ids, clientID)
	}

	if len(candidates) == 0 {

		total, _ := s.repository.CountChatMessages(consultation.ID)

		response.TotalStored = total
		response.WindowClosesAt = s.windowClosesAt(consultation)

		return response, nil
	}

	//------------------------------------------------
	// Store
	//------------------------------------------------
	//
	// The existing ids are read first so the counts reported back are exact.
	// RowsAffected cannot be used for this: MySQL reports it differently for
	// an insert and a skipped conflict, so the number would be meaningless.

	existing, err := s.repository.ExistingClientMessageIDs(consultation.ID, ids)

	if err != nil {
		return nil, err
	}

	fresh := make([]models.ChatMessage, 0, len(candidates))

	for _, message := range candidates {

		if existing[message.ClientMessageID] {
			response.Duplicate++
			continue
		}

		fresh = append(fresh, message)
	}

	if len(fresh) > 0 {

		// The conflict clause is still the race guard between two uploads
		// landing at the same instant, even though the duplicates have
		// already been filtered out above.
		if err := s.repository.CreateChatMessages(nil, fresh); err != nil {
			return nil, err
		}
	}

	response.Stored = len(fresh)

	total, err := s.repository.CountChatMessages(consultation.ID)

	if err == nil {
		response.TotalStored = total
	}

	response.WindowClosesAt = s.windowClosesAt(consultation)

	return response, nil
}

// assertUploadable decides whether this session can still receive a
// transcript.
//
// Uploading AFTER the session is over is the normal case — that is when the
// app flushes its buffer — so COMPLETED is allowed, bounded by a window. What
// is refused is a session that never connected: a request an astrologer
// declined has no conversation, and allowing one to be posted would let a
// customer fabricate evidence against them.
func (s *consultationMessageService) assertUploadable(consultation *models.Consultation) error {

	switch consultation.Status {

	case constants.ConsultationStatusOngoing, constants.ConsultationStatusAccepted:
		return nil

	case constants.ConsultationStatusCompleted:

		if consultation.EndedAt == nil {
			return nil
		}

		window := s.repository.GetSystemFlagFloat(
			constants.FlagChatTranscriptWindowHours,
			defaultTranscriptWindowHours,
		)

		if window <= 0 {
			return nil
		}

		if time.Since(*consultation.EndedAt) > time.Duration(window)*time.Hour {
			return errors.New("the transcript upload window for this session has closed")
		}

		return nil

	default:
		return errors.New("no session took place")
	}
}

func (s *consultationMessageService) windowClosesAt(consultation *models.Consultation) string {

	if consultation.EndedAt == nil {
		return ""
	}

	window := s.repository.GetSystemFlagFloat(
		constants.FlagChatTranscriptWindowHours,
		defaultTranscriptWindowHours,
	)

	if window <= 0 {
		return ""
	}

	return consultation.EndedAt.Add(time.Duration(window) * time.Hour).Format(stampLayout)
}

//////////////////////////////////////////////////////////////
// List
//////////////////////////////////////////////////////////////

func (s *consultationMessageService) ListMessages(
	userID uint,
	consultationID uint,
	page int,
	limit int,
) (*dto.ConsultationMessagesResponse, error) {

	consultation, err := s.repository.GetConsultationByID(consultationID)

	if err != nil {
		return nil, err
	}

	if consultation == nil || consultation.UserID != userID {
		return nil, errors.New("consultation not found")
	}

	return s.list(consultation, page, limit)
}

func (s *consultationMessageService) ListMessagesByAstrologer(
	astrologerUserID uint,
	consultationID uint,
	page int,
	limit int,
) (*dto.ConsultationMessagesResponse, error) {

	astrologer, err := s.repository.GetAstrologerByUserID(astrologerUserID)

	if err != nil {
		return nil, err
	}

	if astrologer == nil {
		return nil, errors.New("astrologer not found")
	}

	consultation, err := s.repository.GetConsultationByID(consultationID)

	if err != nil {
		return nil, err
	}

	if consultation == nil || consultation.AstrologerID != astrologer.ID {
		return nil, errors.New("consultation not found")
	}

	return s.list(consultation, page, limit)
}

func (s *consultationMessageService) list(
	consultation *models.Consultation,
	page int,
	limit int,
) (*dto.ConsultationMessagesResponse, error) {

	if page <= 0 {
		page = 1
	}

	if limit <= 0 || limit > 500 {
		limit = 200
	}

	records, total, err := s.repository.ListChatMessages(consultation.ID, page, limit)

	if err != nil {
		return nil, err
	}

	items := make([]dto.ChatMessageItem, 0, len(records))

	for _, record := range records {

		items = append(items, dto.ChatMessageItem{
			ID:              record.ID,
			ClientMessageID: record.ClientMessageID,
			SenderType:      record.SenderType,
			SenderID:        record.SenderID,
			MessageType:     record.MessageType,
			Body:            record.Body,
			SentAt:          formatStamp(record.SentAt),
		})
	}

	totalPages := int(total) / limit

	if int(total)%limit != 0 {
		totalPages++
	}

	return &dto.ConsultationMessagesResponse{
		ConsultationID: consultation.ID,
		ConsultationNo: consultation.ConsultationNo,
		Items:          items,
		Page:           page,
		Limit:          limit,
		Total:          total,
		TotalPages:     totalPages,
	}, nil
}

// parseStamp accepts the two shapes an app is likely to send, and gives up
// quietly rather than rejecting a message over a timestamp.
func parseStamp(value string) *time.Time {

	value = strings.TrimSpace(value)

	if value == "" {
		return nil
	}

	layouts := []string{
		"2006-01-02 15:04:05.000",
		stampLayout,
		time.RFC3339,
	}

	for _, layout := range layouts {

		if parsed, err := time.ParseInLocation(layout, value, time.Local); err == nil {
			return &parsed
		}
	}

	return nil
}
