package social

import (
	"context"
	"errors"
	"fmt"
	"net/netip"
	"slices"
	"strings"
	"time"
	"unicode/utf8"

	"github.com/go-jet/jet/v2/postgres"
	"github.com/go-jet/jet/v2/qrm"
	"github.com/google/uuid"

	"scrabble/backend/internal/notify"
	"scrabble/backend/internal/postgres/jet/backend/model"
	"scrabble/backend/internal/postgres/jet/backend/table"
)

const (
	// maxChatRunes caps a chat message's length, keeping it to a quick reaction.
	maxChatRunes = 60
	// nudgeInterval is the minimum gap between two nudges by the same player in a game.
	nudgeInterval = time.Hour
	// kindMessage and kindNudge are the chat_messages.kind values.
	kindMessage = "message"
	kindNudge   = "nudge"
	// statusActive mirrors game.StatusActive: the status string a live game reports.
	statusActive = "active"
)

// Message is one persisted per-game chat entry. A nudge is a Message with Kind
// nudge and an empty Body. SenderIP is the gateway-forwarded client IP (empty when
// unknown), kept for moderation.
type Message struct {
	ID        uuid.UUID
	GameID    uuid.UUID
	SenderID  uuid.UUID
	Kind      string
	Body      string
	SenderIP  string
	CreatedAt time.Time
}

// PostMessage stores a chat message from senderID in gameID. The sender must be a
// seated player who has not disabled chat; the body must be non-empty, within the
// rune limit, and free of links/emails/phone numbers (the content filter). The
// gateway-forwarded senderIP is validated and stored for moderation.
func (svc *Service) PostMessage(ctx context.Context, gameID, senderID uuid.UUID, body, senderIP string) (Message, error) {
	seats, toMove, status, err := svc.games.Participants(ctx, gameID)
	if err != nil {
		return Message{}, err
	}
	idx := slices.Index(seats, senderID)
	if idx < 0 {
		return Message{}, ErrNotParticipant
	}
	// Chat is allowed only on the sender's own turn in an active game; the opponent's-turn
	// control is the nudge.
	if status != statusActive {
		return Message{}, ErrGameNotActive
	}
	// Chat is disabled in honest-AI games (the opponent is a robot).
	if vsAI, err := svc.games.VsAI(ctx, gameID); err != nil {
		return Message{}, err
	} else if vsAI {
		return Message{}, ErrGameVsAI
	}
	if idx != toMove {
		return Message{}, ErrChatNotYourTurn
	}
	// At most one chat message per turn. The turn's start (move-driven, so stable for the
	// whole turn) bounds the window: a prior message posted at or after it closes chat until
	// the next turn.
	turnStart, err := svc.games.TurnStartedAt(ctx, gameID)
	if err != nil {
		return Message{}, err
	}
	if last, ok, err := svc.store.lastMessageAt(ctx, gameID, senderID); err != nil {
		return Message{}, err
	} else if ok && !last.Before(turnStart) {
		return Message{}, ErrChatAlreadySentThisTurn
	}
	sender, err := svc.accounts.GetByID(ctx, senderID)
	if err != nil {
		return Message{}, err
	}
	if sender.BlockChat {
		return Message{}, ErrChatBlocked
	}
	body = strings.TrimSpace(body)
	if body == "" {
		return Message{}, ErrEmptyMessage
	}
	if utf8.RuneCountInString(body) > maxChatRunes {
		return Message{}, ErrMessageTooLong
	}
	if err := Clean(body); err != nil {
		return Message{}, err
	}
	// Blocker-side guard: a sender whose only opponents are people they have blocked cannot
	// post (the composer is hidden client-side; this is the server-side counterpart). In a
	// multi-player game with a non-blocked opponent the post is allowed and reaches them.
	if guard, err := svc.senderBlocksEveryOpponent(ctx, seats, senderID); err != nil {
		return Message{}, err
	} else if guard {
		return Message{}, ErrRecipientBlocked
	}
	// Recipients whose copy is born read so it never shows as unread: a disguised robot
	// opponent (never opens the chat) and any recipient who has blocked the sender — the
	// latter is also dropped from live delivery below, so the block stays invisible to the
	// blocked sender while the blocker sees nothing.
	autoRead, err := svc.robotRecipients(ctx, seats, senderID)
	if err != nil {
		return Message{}, err
	}
	suppressed, err := svc.blockedRecipients(ctx, seats, senderID)
	if err != nil {
		return Message{}, err
	}
	msg, err := svc.store.insertChatMessage(ctx, gameID, senderID, kindMessage, body, parseIP(senderIP), recipientMask(seats, senderID, mergeSeatSets(autoRead, suppressed)))
	if err != nil {
		return Message{}, err
	}
	svc.metrics.recordChat(ctx, kindMessage)
	svc.emitChat(seats, senderID, msg, suppressed)
	return msg, nil
}

// Nudge records a nudge from senderID toward the player whose turn is awaited. The
// game must be active, the sender a seated player whose turn it is not, and the
// once-per-hour-per-game limit not yet hit.
func (svc *Service) Nudge(ctx context.Context, gameID, senderID uuid.UUID) (Message, error) {
	seats, toMove, status, err := svc.games.Participants(ctx, gameID)
	if err != nil {
		return Message{}, err
	}
	if status != statusActive {
		return Message{}, ErrGameNotActive
	}
	idx := slices.Index(seats, senderID)
	if idx < 0 {
		return Message{}, ErrNotParticipant
	}
	// Nudge is disabled in honest-AI games (the opponent is a robot).
	if vsAI, err := svc.games.VsAI(ctx, gameID); err != nil {
		return Message{}, err
	} else if vsAI {
		return Message{}, ErrGameVsAI
	}
	if idx == toMove {
		return Message{}, ErrNudgeOnOwnTurn
	}
	// The awaited player is the nudge's sole recipient.
	var target uuid.UUID
	if toMove >= 0 && toMove < len(seats) {
		target = seats[toMove]
	}
	// Blocker-side guard: a sender who has blocked the awaited player cannot nudge them
	// (the control is hidden client-side; this is the server-side counterpart).
	if target != uuid.Nil {
		if guard, err := svc.store.blockExists(ctx, senderID, target); err != nil {
			return Message{}, err
		} else if guard {
			return Message{}, ErrRecipientBlocked
		}
	}
	last, ok, err := svc.store.lastNudgeAt(ctx, gameID, senderID)
	if err != nil {
		return Message{}, err
	}
	if ok && svc.now().Sub(last) < nudgeInterval {
		// The cooldown resets once the sender has acted (moved or chatted) since the last
		// nudge — engagement clears the "don't spam" limit.
		acted, err := svc.actedSince(ctx, gameID, senderID, last)
		if err != nil {
			return Message{}, err
		}
		if !acted {
			return Message{}, ErrNudgeTooSoon
		}
	}
	// Suppress when the awaited player has blocked the sender: the nudge still persists and
	// counts toward the once-per-hour cooldown (the sender notices nothing) but is born read
	// (mask 0) and never delivered, so the blocker sees no nudge.
	var suppressed bool
	if target != uuid.Nil {
		suppressed, err = svc.store.blockExists(ctx, target, senderID)
		if err != nil {
			return Message{}, err
		}
	}
	var mask int16
	if !suppressed && target != uuid.Nil {
		mask = int16(1) << uint(toMove)
	}
	msg, err := svc.store.insertChatMessage(ctx, gameID, senderID, kindNudge, "", nil, mask)
	if err != nil {
		return Message{}, err
	}
	svc.metrics.recordChat(ctx, kindNudge)
	if !suppressed && target != uuid.Nil {
		// Name the sender by their per-game seat snapshot, so the toast and the out-of-app push
		// read "<name>: …"; an unresolved name (best-effort) falls back to the plain phrase.
		senderName, _ := svc.games.SeatName(ctx, gameID, senderID)
		nudge := notify.Nudge(target, gameID, senderID, senderName)
		svc.pub.Publish(nudge)
	}
	return msg, nil
}

// actedSince reports whether senderID made a move or posted a chat message in the game
// after t — the events that reset the nudge cooldown.
func (svc *Service) actedSince(ctx context.Context, gameID, senderID uuid.UUID, t time.Time) (bool, error) {
	if mv, ok, err := svc.games.LastMoveAt(ctx, gameID, senderID); err != nil {
		return false, err
	} else if ok && mv.After(t) {
		return true, nil
	}
	if msg, ok, err := svc.store.lastMessageAt(ctx, gameID, senderID); err != nil {
		return false, err
	} else if ok && msg.After(t) {
		return true, nil
	}
	return false, nil
}

// emitChat pushes a chat message to every seated player except the sender and any
// recipient in suppressed — a recipient who has blocked the sender, who must never see
// it (best-effort live delivery; the other recipients still read it via Messages).
func (svc *Service) emitChat(seats []uuid.UUID, senderID uuid.UUID, m Message, suppressed map[uuid.UUID]bool) {
	intents := make([]notify.Intent, 0, len(seats))
	for _, id := range seats {
		if id == senderID || suppressed[id] {
			continue
		}
		intents = append(intents, notify.ChatMessage(id, m.GameID, m.SenderID, m.ID.String(), m.Kind, m.Body, m.CreatedAt))
	}
	svc.pub.Publish(intents...)
}

// LastNudgeAt returns the time of the most recent nudge senderID sent in the game
// and true, or the zero time and false when there is none. The robot opponent
// uses it to notice a human nudge on its turn and answer promptly.
func (svc *Service) LastNudgeAt(ctx context.Context, gameID, senderID uuid.UUID) (time.Time, bool, error) {
	return svc.store.lastNudgeAt(ctx, gameID, senderID)
}

// Messages returns the per-game chat visible to viewerID: the viewer must be a
// seated player. Messages from a sender the viewer has blocked are dropped — one
// direction only, so a blocked player still sees the blocker's messages and never
// notices the block — and if the viewer has disabled chat only nudges remain.
func (svc *Service) Messages(ctx context.Context, gameID, viewerID uuid.UUID) ([]Message, error) {
	seats, _, _, err := svc.games.Participants(ctx, gameID)
	if err != nil {
		return nil, err
	}
	if !slices.Contains(seats, viewerID) {
		return nil, ErrNotParticipant
	}
	viewer, err := svc.accounts.GetByID(ctx, viewerID)
	if err != nil {
		return nil, err
	}
	blocked := make(map[uuid.UUID]bool)
	for _, seat := range seats {
		if seat == viewerID {
			continue
		}
		yes, err := svc.store.blockExists(ctx, viewerID, seat)
		if err != nil {
			return nil, err
		}
		if yes {
			blocked[seat] = true
		}
	}
	all, err := svc.store.listChatMessages(ctx, gameID)
	if err != nil {
		return nil, err
	}
	out := make([]Message, 0, len(all))
	for _, m := range all {
		if blocked[m.SenderID] {
			continue
		}
		if m.Kind == kindMessage && viewer.BlockChat {
			continue
		}
		out = append(out, m)
	}
	return out, nil
}

// parseIP returns a validated canonical IP string, or nil when raw is empty or
// not a valid address.
func parseIP(raw string) *string {
	addr, err := netip.ParseAddr(strings.TrimSpace(raw))
	if err != nil {
		return nil
	}
	canon := addr.String()
	return &canon
}

// recipientMask is the initial unread bitmask for a text message: a set bit for every
// seated recipient (by seat index) other than the sender, except recipients in autoRead —
// a disguised robot opponent, whose message is born read since it never opens the chat.
// Still-empty (open) seats are skipped. With at most four seats the result always fits the
// smallint column.
func recipientMask(seats []uuid.UUID, senderID uuid.UUID, autoRead map[uuid.UUID]bool) int16 {
	var mask int16
	for i, id := range seats {
		if id == uuid.Nil || id == senderID || autoRead[id] {
			continue
		}
		mask |= int16(1) << uint(i)
	}
	return mask
}

// robotRecipients returns the seated recipients (every seat but the sender) that are robots.
// A disguised robot opponent never reads chat, so a message to it is born read (its unread
// bit is never set) — it would otherwise linger unread forever, skewing the unread count and
// the publish-to-read metric.
func (svc *Service) robotRecipients(ctx context.Context, seats []uuid.UUID, senderID uuid.UUID) (map[uuid.UUID]bool, error) {
	var robots map[uuid.UUID]bool
	for _, id := range seats {
		if id == uuid.Nil || id == senderID {
			continue
		}
		isRobot, err := svc.accounts.IsRobot(ctx, id)
		if err != nil {
			return nil, err
		}
		if isRobot {
			if robots == nil {
				robots = make(map[uuid.UUID]bool)
			}
			robots[id] = true
		}
	}
	return robots, nil
}

// blockedRecipients returns the seated recipients (every non-empty seat but the sender)
// that have blocked the sender. Their copy of a message or nudge is born read and is not
// delivered — the store-but-hide that keeps a block invisible to the blocked sender while
// denying the blocker any sight of what they send.
func (svc *Service) blockedRecipients(ctx context.Context, seats []uuid.UUID, senderID uuid.UUID) (map[uuid.UUID]bool, error) {
	var out map[uuid.UUID]bool
	for _, id := range seats {
		if id == uuid.Nil || id == senderID {
			continue
		}
		yes, err := svc.store.blockExists(ctx, id, senderID)
		if err != nil {
			return nil, err
		}
		if yes {
			if out == nil {
				out = make(map[uuid.UUID]bool)
			}
			out[id] = true
		}
	}
	return out, nil
}

// senderBlocksEveryOpponent reports whether senderID has blocked every other seated
// player (and there is at least one). It is the blocker-side chat guard: a player whose
// only opponents are people they have blocked cannot post. In a multi-player game that
// still has a non-blocked opponent it is false, so the player can talk to the others.
func (svc *Service) senderBlocksEveryOpponent(ctx context.Context, seats []uuid.UUID, senderID uuid.UUID) (bool, error) {
	opponents := 0
	for _, id := range seats {
		if id == uuid.Nil || id == senderID {
			continue
		}
		opponents++
		blocked, err := svc.store.blockExists(ctx, senderID, id)
		if err != nil {
			return false, err
		}
		if !blocked {
			return false, nil
		}
	}
	return opponents > 0, nil
}

// mergeSeatSets returns the union of two seat sets; either may be nil. It folds the
// born-read recipients (disguised robots and recipients who blocked the sender) into one
// mask input.
func mergeSeatSets(a, b map[uuid.UUID]bool) map[uuid.UUID]bool {
	if len(b) == 0 {
		return a
	}
	if len(a) == 0 {
		return b
	}
	out := make(map[uuid.UUID]bool, len(a)+len(b))
	for id := range a {
		out[id] = true
	}
	for id := range b {
		out[id] = true
	}
	return out
}

// insertChatMessage stores one chat row, seeding its unread bitmask, and returns it.
func (s *Store) insertChatMessage(ctx context.Context, gameID, senderID uuid.UUID, kind, body string, ip *string, unreadMask int16) (Message, error) {
	id, err := uuid.NewV7()
	if err != nil {
		return Message{}, fmt.Errorf("social: new message id: %w", err)
	}
	var ipVal any = postgres.NULL
	if ip != nil {
		ipVal = postgres.String(*ip)
	}
	stmt := table.ChatMessages.INSERT(
		table.ChatMessages.MessageID, table.ChatMessages.GameID, table.ChatMessages.SenderID,
		table.ChatMessages.Kind, table.ChatMessages.Body, table.ChatMessages.SenderIP,
		table.ChatMessages.UnreadSeats,
	).VALUES(id, gameID, senderID, kind, body, ipVal, unreadMask).
		RETURNING(table.ChatMessages.AllColumns)
	var row model.ChatMessages
	if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
		return Message{}, fmt.Errorf("social: insert chat message: %w", err)
	}
	return messageFromRow(row), nil
}

// listChatMessages returns a game's chat in chronological order.
func (s *Store) listChatMessages(ctx context.Context, gameID uuid.UUID) ([]Message, error) {
	stmt := postgres.SELECT(table.ChatMessages.AllColumns).
		FROM(table.ChatMessages).
		WHERE(table.ChatMessages.GameID.EQ(postgres.UUID(gameID))).
		ORDER_BY(table.ChatMessages.CreatedAt.ASC(), table.ChatMessages.MessageID.ASC())
	var rows []model.ChatMessages
	if err := stmt.QueryContext(ctx, s.db, &rows); err != nil {
		return nil, fmt.Errorf("social: list chat: %w", err)
	}
	out := make([]Message, 0, len(rows))
	for _, r := range rows {
		out = append(out, messageFromRow(r))
	}
	return out, nil
}

// lastNudgeAt returns the time of senderID's most recent nudge in gameID, if any.
func (s *Store) lastNudgeAt(ctx context.Context, gameID, senderID uuid.UUID) (time.Time, bool, error) {
	stmt := postgres.SELECT(table.ChatMessages.CreatedAt).
		FROM(table.ChatMessages).
		WHERE(
			table.ChatMessages.GameID.EQ(postgres.UUID(gameID)).
				AND(table.ChatMessages.SenderID.EQ(postgres.UUID(senderID))).
				AND(table.ChatMessages.Kind.EQ(postgres.String(kindNudge))),
		).ORDER_BY(table.ChatMessages.CreatedAt.DESC()).LIMIT(1)
	var row model.ChatMessages
	if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
		if errors.Is(err, qrm.ErrNoRows) {
			return time.Time{}, false, nil
		}
		return time.Time{}, false, fmt.Errorf("social: last nudge: %w", err)
	}
	return row.CreatedAt, true, nil
}

// lastMessageAt returns the time of senderID's most recent non-nudge chat message in
// gameID, if any. The nudge cooldown resets when the player chats (or moves), so a stale
// nudge no longer blocks a new one.
func (s *Store) lastMessageAt(ctx context.Context, gameID, senderID uuid.UUID) (time.Time, bool, error) {
	stmt := postgres.SELECT(table.ChatMessages.CreatedAt).
		FROM(table.ChatMessages).
		WHERE(
			table.ChatMessages.GameID.EQ(postgres.UUID(gameID)).
				AND(table.ChatMessages.SenderID.EQ(postgres.UUID(senderID))).
				AND(table.ChatMessages.Kind.EQ(postgres.String(kindMessage))),
		).ORDER_BY(table.ChatMessages.CreatedAt.DESC()).LIMIT(1)
	var row model.ChatMessages
	if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
		if errors.Is(err, qrm.ErrNoRows) {
			return time.Time{}, false, nil
		}
		return time.Time{}, false, fmt.Errorf("social: last message: %w", err)
	}
	return row.CreatedAt, true, nil
}

// messageFromRow projects a generated row into the public Message.
func messageFromRow(r model.ChatMessages) Message {
	m := Message{
		ID:        r.MessageID,
		GameID:    r.GameID,
		SenderID:  r.SenderID,
		Kind:      r.Kind,
		Body:      r.Body,
		CreatedAt: r.CreatedAt,
	}
	if r.SenderIP != nil {
		m.SenderIP = *r.SenderIP
	}
	return m
}
