package diplomail

import (
	"context"
	"errors"
	"fmt"
	"strings"
	"unicode/utf8"

	"github.com/google/uuid"
	"go.uber.org/zap"
)

// previewMaxRunes bounds the body excerpt embedded in the push event
// so the gRPC payload stays small. The value matches the UI's
// "two lines" tease and is intentionally not configurable — clients
// drive their own truncation off the canonical fetch.
const previewMaxRunes = 120

// SendPersonal persists a single-recipient personal message and
// fan-outs a `diplomail.message.received` push event to the
// recipient. Validation rules:
//
//   - both sender and recipient must be active members of GameID;
//   - the recipient must differ from the sender;
//   - the body must be non-empty, valid UTF-8, and within the
//     configured byte limit;
//   - the subject must be valid UTF-8 and within the configured
//     byte limit (zero is allowed).
//
// On any rule violation the function returns ErrInvalidInput or
// ErrForbidden; the inserted Message is never persisted in those
// cases.
func (s *Service) SendPersonal(ctx context.Context, in SendPersonalInput) (Message, Recipient, error) {
	if in.SenderUserID == in.RecipientUserID {
		return Message{}, Recipient{}, fmt.Errorf("%w: cannot send mail to yourself", ErrInvalidInput)
	}
	subject := strings.TrimRight(in.Subject, " \t")
	body := strings.TrimRight(in.Body, " \t\n")
	if err := s.validateContent(subject, body); err != nil {
		return Message{}, Recipient{}, err
	}

	sender, err := s.deps.Memberships.GetActiveMembership(ctx, in.GameID, in.SenderUserID)
	if err != nil {
		if errors.Is(err, ErrNotFound) {
			return Message{}, Recipient{}, fmt.Errorf("%w: sender is not an active member of the game", ErrForbidden)
		}
		return Message{}, Recipient{}, fmt.Errorf("diplomail: load sender membership: %w", err)
	}
	recipient, err := s.deps.Memberships.GetActiveMembership(ctx, in.GameID, in.RecipientUserID)
	if err != nil {
		if errors.Is(err, ErrNotFound) {
			return Message{}, Recipient{}, fmt.Errorf("%w: recipient is not an active member of the game", ErrForbidden)
		}
		return Message{}, Recipient{}, fmt.Errorf("diplomail: load recipient membership: %w", err)
	}

	username := sender.UserName
	msgInsert := MessageInsert{
		MessageID:      uuid.New(),
		GameID:         in.GameID,
		GameName:       sender.GameName,
		Kind:           KindPersonal,
		SenderKind:     SenderKindPlayer,
		SenderUserID:   &in.SenderUserID,
		SenderUsername: &username,
		SenderIP:       in.SenderIP,
		Subject:        subject,
		Body:           body,
		BodyLang:       s.deps.Detector.Detect(body),
		BroadcastScope: BroadcastScopeSingle,
	}
	raceName := recipient.RaceName
	rcptInsert := buildRecipientInsert(
		msgInsert.MessageID,
		MemberSnapshot{
			UserID:            in.RecipientUserID,
			GameID:            in.GameID,
			GameName:          recipient.GameName,
			UserName:          recipient.UserName,
			RaceName:          raceName,
			PreferredLanguage: recipient.PreferredLanguage,
			Status:            "active",
		},
		msgInsert.BodyLang,
		s.nowUTC(),
	)

	msg, recipients, err := s.deps.Store.InsertMessageWithRecipients(ctx, msgInsert, []RecipientInsert{rcptInsert})
	if err != nil {
		return Message{}, Recipient{}, fmt.Errorf("diplomail: send personal: %w", err)
	}
	if len(recipients) != 1 {
		return Message{}, Recipient{}, fmt.Errorf("diplomail: send personal: unexpected recipient count %d", len(recipients))
	}

	if recipients[0].AvailableAt != nil {
		s.publishMessageReceived(ctx, msg, recipients[0])
	}
	return msg, recipients[0], nil
}

// GetMessage returns the InboxEntry for messageID addressed to
// userID. ErrNotFound is returned when the caller is not a recipient
// of the message — handlers translate that to 404 so the existence
// of the message is not leaked. The same sentinel is returned when
// the caller is no longer an active member of the game and the
// message is personal-kind: post-kick visibility is restricted to
// admin/system mail (item 8 of the spec).
//
// When `targetLang` is non-empty and differs from the message's
// `body_lang`, the function consults the translation cache; on a
// miss it asks the configured Translator to produce a rendering and
// persists the result. The noop translator returns the input
// unchanged with `engine == "noop"`, which is treated as
// "translation unavailable" — the entry comes back with `Translation
// == nil` and the caller renders the original body.
func (s *Service) GetMessage(ctx context.Context, userID, messageID uuid.UUID, targetLang string) (InboxEntry, error) {
	entry, err := s.deps.Store.LoadInboxEntry(ctx, messageID, userID)
	if err != nil {
		return InboxEntry{}, err
	}
	allowed, err := s.allowedKinds(ctx, entry.GameID, userID)
	if err != nil {
		return InboxEntry{}, err
	}
	if !allowed[entry.Kind] {
		return InboxEntry{}, ErrNotFound
	}
	if tr := s.resolveTranslation(ctx, entry.Message, targetLang); tr != nil {
		entry.Translation = tr
	}
	return entry, nil
}

// resolveTranslation returns the cached translation for
// (message, targetLang), lazily computing and persisting one on
// cache miss. Returns nil when no translation is needed (target is
// empty, matches `body_lang`, or the message body is itself
// undetermined) or when the configured translator declares the
// rendering unavailable.
func (s *Service) resolveTranslation(ctx context.Context, msg Message, targetLang string) *Translation {
	if targetLang == "" || targetLang == msg.BodyLang || msg.BodyLang == LangUndetermined {
		return nil
	}
	if existing, err := s.deps.Store.LoadTranslation(ctx, msg.MessageID, targetLang); err == nil {
		t := existing
		return &t
	} else if !errors.Is(err, ErrNotFound) {
		s.deps.Logger.Warn("load translation failed",
			zap.String("message_id", msg.MessageID.String()),
			zap.String("target_lang", targetLang),
			zap.Error(err))
		return nil
	}
	if s.deps.Translator == nil {
		return nil
	}
	result, err := s.deps.Translator.Translate(ctx, msg.BodyLang, targetLang, msg.Subject, msg.Body)
	if err != nil {
		s.deps.Logger.Warn("translator call failed",
			zap.String("message_id", msg.MessageID.String()),
			zap.String("target_lang", targetLang),
			zap.Error(err))
		return nil
	}
	if result.Engine == "" || result.Engine == "noop" {
		return nil
	}
	tr := Translation{
		TranslationID:    uuid.New(),
		MessageID:        msg.MessageID,
		TargetLang:       targetLang,
		TranslatedSubject: result.Subject,
		TranslatedBody:   result.Body,
		Translator:       result.Engine,
	}
	stored, err := s.deps.Store.InsertTranslation(ctx, tr)
	if err != nil {
		s.deps.Logger.Warn("insert translation failed",
			zap.String("message_id", msg.MessageID.String()),
			zap.String("target_lang", targetLang),
			zap.Error(err))
		return nil
	}
	return &stored
}

// ListInbox returns every non-deleted message addressed to userID in
// gameID, newest first. Read state is preserved per entry; the HTTP
// layer renders both the message and the recipient row. Personal
// messages are filtered out when the caller is no longer an active
// member of the game so a kicked player keeps read access to the
// admin/system explanation of the kick but not to historical
// player-to-player threads.
//
// When `targetLang` is non-empty and differs from a row's body
// language, the function consults the translation cache (without
// re-translating on miss; the per-message read endpoint owns that
// path so the bulk listing never blocks on translator I/O).
func (s *Service) ListInbox(ctx context.Context, gameID, userID uuid.UUID, targetLang string) ([]InboxEntry, error) {
	entries, err := s.deps.Store.ListInbox(ctx, gameID, userID)
	if err != nil {
		return nil, err
	}
	allowed, err := s.allowedKinds(ctx, gameID, userID)
	if err != nil {
		return nil, err
	}
	out := entries
	if !(allowed[KindPersonal] && allowed[KindAdmin]) {
		out = make([]InboxEntry, 0, len(entries))
		for _, e := range entries {
			if allowed[e.Kind] {
				out = append(out, e)
			}
		}
	}
	if targetLang == "" {
		return out, nil
	}
	for i := range out {
		out[i].Translation = s.lookupCachedTranslation(ctx, out[i].Message, targetLang)
	}
	return out, nil
}

// lookupCachedTranslation reads an existing translation row without
// asking the Translator to compute one. The bulk inbox listing uses
// this to avoid per-row translator I/O; GetMessage uses the full
// `resolveTranslation` helper which falls through to the translator
// on cache miss.
func (s *Service) lookupCachedTranslation(ctx context.Context, msg Message, targetLang string) *Translation {
	if targetLang == "" || targetLang == msg.BodyLang || msg.BodyLang == LangUndetermined {
		return nil
	}
	existing, err := s.deps.Store.LoadTranslation(ctx, msg.MessageID, targetLang)
	if err != nil {
		if !errors.Is(err, ErrNotFound) {
			s.deps.Logger.Debug("inbox translation lookup failed",
				zap.String("message_id", msg.MessageID.String()),
				zap.Error(err))
		}
		return nil
	}
	out := existing
	return &out
}

// allowedKinds resolves the set of message kinds the caller may read
// in gameID. An active member can read everything; a former member
// (status removed or blocked) can read admin-kind only. A user who
// has never been a member of the game but is still listed as a
// recipient (legacy / system message) is granted the same admin-only
// view. The function never returns an empty set: even non-members
// keep their read access to admin mail.
func (s *Service) allowedKinds(ctx context.Context, gameID, userID uuid.UUID) (map[string]bool, error) {
	if s.deps.Memberships == nil {
		return map[string]bool{KindPersonal: true, KindAdmin: true}, nil
	}
	if _, err := s.deps.Memberships.GetActiveMembership(ctx, gameID, userID); err == nil {
		return map[string]bool{KindPersonal: true, KindAdmin: true}, nil
	} else if !errors.Is(err, ErrNotFound) {
		return nil, err
	}
	return map[string]bool{KindAdmin: true}, nil
}

// ListSent returns personal messages authored by senderUserID in
// gameID, newest first. Admin/system rows have no `sender_user_id`
// and are therefore excluded; the user surface does not need them.
func (s *Service) ListSent(ctx context.Context, gameID, senderUserID uuid.UUID) ([]Message, error) {
	return s.deps.Store.ListSent(ctx, gameID, senderUserID)
}

// MarkRead transitions a recipient row to `read`. Idempotent: a
// second call on an already-read row is a no-op. Returns the
// resulting Recipient. ErrNotFound is surfaced when the caller is
// not a recipient of the message.
func (s *Service) MarkRead(ctx context.Context, userID, messageID uuid.UUID) (Recipient, error) {
	return s.deps.Store.MarkRead(ctx, messageID, userID, s.nowUTC())
}

// DeleteMessage soft-deletes the recipient row identified by
// (messageID, userID). The row must already have `read_at` set, or
// the call returns ErrConflict (item 10 of the spec: open-then-delete).
// Returns ErrNotFound when the caller is not a recipient.
func (s *Service) DeleteMessage(ctx context.Context, userID, messageID uuid.UUID) (Recipient, error) {
	return s.deps.Store.SoftDelete(ctx, messageID, userID, s.nowUTC())
}

// UnreadCountsForUser returns the lobby badge breakdown.
func (s *Service) UnreadCountsForUser(ctx context.Context, userID uuid.UUID) ([]UnreadCount, error) {
	return s.deps.Store.UnreadCountsForUser(ctx, userID)
}

// validateContent enforces the body/subject byte limits and rejects
// non-UTF-8 input. Stage A applies the rules to plain text only; HTML
// is treated as plain text by the server (the UI renders via
// textContent) and gets no special handling.
func (s *Service) validateContent(subject, body string) error {
	if body == "" {
		return fmt.Errorf("%w: body must not be empty", ErrInvalidInput)
	}
	if !utf8.ValidString(body) {
		return fmt.Errorf("%w: body must be valid UTF-8", ErrInvalidInput)
	}
	if len(body) > s.deps.Config.MaxBodyBytes {
		return fmt.Errorf("%w: body exceeds %d bytes", ErrInvalidInput, s.deps.Config.MaxBodyBytes)
	}
	if subject != "" {
		if !utf8.ValidString(subject) {
			return fmt.Errorf("%w: subject must be valid UTF-8", ErrInvalidInput)
		}
		if len(subject) > s.deps.Config.MaxSubjectBytes {
			return fmt.Errorf("%w: subject exceeds %d bytes", ErrInvalidInput, s.deps.Config.MaxSubjectBytes)
		}
	}
	return nil
}

// publishMessageReceived emits the per-recipient push notification.
// Failures are logged at debug level: notifications are best-effort
// over the gRPC stream, and clients always have the unread-counts
// endpoint as the durable fallback.
func (s *Service) publishMessageReceived(ctx context.Context, msg Message, recipient Recipient) {
	unreadGame, err := s.deps.Store.UnreadCountForUserGame(ctx, msg.GameID, recipient.UserID)
	if err != nil {
		s.deps.Logger.Warn("compute unread count for push payload failed",
			zap.String("message_id", msg.MessageID.String()),
			zap.String("recipient", recipient.UserID.String()),
			zap.Error(err))
		unreadGame = 0
	}
	unreadTotals, err := s.deps.Store.UnreadCountsForUser(ctx, recipient.UserID)
	if err != nil {
		s.deps.Logger.Warn("compute unread totals for push payload failed",
			zap.String("recipient", recipient.UserID.String()),
			zap.Error(err))
		unreadTotals = nil
	}
	unreadTotal := 0
	for _, u := range unreadTotals {
		unreadTotal += u.Unread
	}

	payload := map[string]any{
		"message_id":   msg.MessageID.String(),
		"game_id":      msg.GameID.String(),
		"kind":         msg.Kind,
		"sender_kind":  msg.SenderKind,
		"subject":      msg.Subject,
		"preview":      preview(msg.Body, previewMaxRunes),
		"preview_lang": msg.BodyLang,
		"unread_total": unreadTotal,
		"unread_game":  unreadGame,
	}
	ev := DiplomailNotification{
		Kind:           "diplomail.message.received",
		IdempotencyKey: "diplomail.message.received:" + msg.MessageID.String() + ":" + recipient.UserID.String(),
		Recipient:      recipient.UserID,
		Payload:        payload,
	}
	if err := s.deps.Notification.PublishDiplomailEvent(ctx, ev); err != nil {
		s.deps.Logger.Warn("publish diplomail event failed",
			zap.String("message_id", msg.MessageID.String()),
			zap.String("recipient", recipient.UserID.String()),
			zap.Error(err))
	}
}

// preview truncates s to at most max runes and appends a horizontal
// ellipsis when truncation actually happened. The function operates
// on runes, not bytes, so multibyte UTF-8 sequences (Cyrillic,
// emoji) survive without corruption.
func preview(s string, max int) string {
	if max <= 0 || utf8.RuneCountInString(s) <= max {
		return s
	}
	count := 0
	for i := range s {
		if count == max {
			return s[:i] + "…"
		}
		count++
	}
	return s
}
