package bot

import (
	"context"
	"fmt"
	"strconv"
	"strings"
	"sync"
	"time"
	"unicode/utf16"

	tgbot "github.com/go-telegram/bot"
	"github.com/go-telegram/bot/models"
	"go.uber.org/zap"

	"scrabble/platform/telegram/internal/support"
)

// Support relay: the bot forwards a user's direct messages into a per-user forum
// topic in the operators' private support chat, and relays operator replies in that
// topic back to the user. The info card opening each topic carries a Block/Unblock
// toggle and a Clear button. State (topic mapping, block list, relayed message ids)
// lives in a small JSON file via internal/support.
const (
	// supportCallbackPrefix namespaces the info-card button callbacks
	// ("sup:<action>:<userID>").
	supportCallbackPrefix = "sup:"
	supportActionBlock    = "block"
	supportActionUnblock  = "unblock"
	supportActionClear    = "clear"
	// supportAdminTTL is how long the support chat's administrator set is cached.
	supportAdminTTL = time.Minute
	// supportTopicNameMax bounds the forum topic name (the Telegram limit is 128).
	supportTopicNameMax = 128
	// supportDeleteBatch is the maximum message ids per deleteMessages call (the
	// Telegram limit is 100).
	supportDeleteBatch = 100
)

// supportEnabled reports whether the support relay is configured.
func (t *Bot) supportEnabled() bool {
	return t.supportChatID != 0 && t.support != nil
}

// initSupport prepares the support relay at startup: it resolves the bot's own user
// id (getMe) for the loop guard, unless the chat-gating self-check already did, and
// logs readiness. The loop guard also tests From.IsBot, so an unresolved id is not
// fatal.
func (t *Bot) initSupport(ctx context.Context) {
	if t.botID == 0 {
		if me, err := t.api.GetMe(ctx); err != nil {
			t.log.Warn("support: getMe failed; relying on is_bot for the loop guard", zap.Error(err))
		} else {
			t.botID = me.ID
		}
	}
	t.log.Info("support relay ready", zap.Int64("support_chat_id", t.supportChatID))
}

// keyedMutex serialises work per int64 key (here, per user id) so two concurrent
// updates from the same user — the go-telegram/bot library runs handlers in
// goroutines — cannot both open a forum topic.
type keyedMutex struct {
	mu sync.Mutex
	m  map[int64]*sync.Mutex
}

func newKeyedMutex() *keyedMutex { return &keyedMutex{m: map[int64]*sync.Mutex{}} }

// lock acquires the per-key mutex and returns its unlock function.
func (k *keyedMutex) lock(key int64) func() {
	k.mu.Lock()
	mu, ok := k.m[key]
	if !ok {
		mu = &sync.Mutex{}
		k.m[key] = mu
	}
	k.mu.Unlock()
	mu.Lock()
	return mu.Unlock
}

// adminCache caches the support chat's administrator ids for a short TTL, so the bot
// does not call getChatAdministrators on every operator action.
type adminCache struct {
	mu        sync.Mutex
	ids       map[int64]bool
	fetchedAt time.Time
}

// isSupportAdmin reports whether userID administers the support chat, refreshing the
// cache when stale. On a refresh failure it falls back to the last known set
// (fail-closed when nothing is cached yet).
func (t *Bot) isSupportAdmin(ctx context.Context, userID int64) bool {
	t.admins.mu.Lock()
	if t.admins.ids != nil && time.Since(t.admins.fetchedAt) < supportAdminTTL {
		ok := t.admins.ids[userID]
		t.admins.mu.Unlock()
		return ok
	}
	t.admins.mu.Unlock()

	members, err := t.api.GetChatAdministrators(ctx, &tgbot.GetChatAdministratorsParams{ChatID: t.supportChatID})
	if err != nil {
		t.log.Warn("support: getChatAdministrators failed; using cached admin set", zap.Error(err))
		t.admins.mu.Lock()
		defer t.admins.mu.Unlock()
		return t.admins.ids[userID] // a nil map yields false (fail-closed)
	}
	ids := make(map[int64]bool, len(members))
	for _, m := range members {
		if u := chatMemberUser(m); u != nil {
			ids[u.ID] = true
		}
	}
	t.admins.mu.Lock()
	t.admins.ids = ids
	t.admins.fetchedAt = time.Now()
	t.admins.mu.Unlock()
	return ids[userID]
}

// handleSupportUserMessage relays a user's direct message into their forum topic in
// the support chat, opening the topic (and its info card) on first contact. A blocked
// user's message is dropped silently, and the user receives no reply — operators
// answer from the topic.
func (t *Bot) handleSupportUserMessage(ctx context.Context, m *models.Message) {
	if m.From == nil || m.From.IsBot {
		return
	}
	uid := m.From.ID
	unlock := t.supportLocks.lock(uid)
	defer unlock()

	rec, _ := t.support.Get(uid)
	if rec.Blocked {
		return
	}
	topicID, err := t.ensureTopic(ctx, m.From, rec)
	if err != nil {
		t.log.Warn("support: ensure topic failed", zap.Int64("user_id", uid), zap.Error(err))
		return
	}

	newID, err := t.copyToTopic(ctx, m.Chat.ID, m.ID, topicID)
	if err != nil && isTopicMissingErr(err) {
		// Backstop: the topic vanished between the liveness probe and the copy.
		t.log.Info("support: topic gone on copy, reopening", zap.Int64("user_id", uid), zap.Int("topic_id", topicID))
		if topicID, err = t.openSupportTopic(ctx, m.From); err == nil {
			newID, err = t.copyToTopic(ctx, m.Chat.ID, m.ID, topicID)
		}
	}
	if err != nil {
		t.log.Warn("support: relay to topic failed", zap.Int64("user_id", uid), zap.Error(err))
		return
	}
	if err := t.support.AppendMsg(uid, newID); err != nil {
		t.log.Warn("support: persist relayed id failed", zap.Int64("user_id", uid), zap.Error(err))
	}
}

// handleSupportGroupMessage relays an operator's reply in a user's topic back to that
// user. It ignores the bot's own posts (the loop guard), messages outside a topic,
// unknown topics, and non-administrators. The operator's message is tracked too, so a
// later clear removes it.
func (t *Bot) handleSupportGroupMessage(ctx context.Context, m *models.Message) {
	if m.From == nil || m.From.IsBot {
		return // the bot's own relayed copies (and other bots) — never loop them back
	}
	if t.botID != 0 && m.From.ID == t.botID {
		return
	}
	if m.MessageThreadID == 0 {
		return // the chat's general area, not a user's topic
	}
	uid, ok := t.support.ByTopic(m.MessageThreadID)
	if !ok {
		return
	}
	if !t.isSupportAdmin(ctx, m.From.ID) {
		return // only operators reach the user
	}
	if err := t.support.AppendMsg(uid, m.ID); err != nil {
		t.log.Warn("support: persist operator msg id failed", zap.Int64("user_id", uid), zap.Error(err))
	}
	if _, err := t.copyToUser(ctx, m.ID, uid); err != nil {
		t.log.Warn("support: relay reply to user failed", zap.Int64("user_id", uid), zap.Error(err))
	}
}

// handleSupportCallback handles the info-card buttons (block/unblock/clear). Only
// support-chat administrators may act; others get a denial. It always answers the
// callback query so the client's spinner clears.
func (t *Bot) handleSupportCallback(ctx context.Context, _ *tgbot.Bot, update *models.Update) {
	cq := update.CallbackQuery
	if cq == nil {
		return
	}
	action, uid, ok := parseSupportCallback(cq.Data)
	if !ok {
		t.answerSupportCallback(ctx, cq.ID, "")
		return
	}
	if !t.isSupportAdmin(ctx, cq.From.ID) {
		t.answerSupportCallback(ctx, cq.ID, "Недостаточно прав")
		return
	}
	switch action {
	case supportActionBlock:
		t.setSupportBlocked(ctx, cq, uid, true)
	case supportActionUnblock:
		t.setSupportBlocked(ctx, cq, uid, false)
	case supportActionClear:
		t.clearSupportTopic(ctx, cq, uid)
	default:
		t.answerSupportCallback(ctx, cq.ID, "")
	}
}

// setSupportBlocked sets the user's block state, flips the info-card toggle to match,
// and answers the callback. Blocking does not delete the topic — it stays.
func (t *Bot) setSupportBlocked(ctx context.Context, cq *models.CallbackQuery, uid int64, blocked bool) {
	if err := t.support.SetBlocked(uid, blocked); err != nil {
		t.log.Warn("support: set blocked failed", zap.Int64("user_id", uid), zap.Error(err))
		t.answerSupportCallback(ctx, cq.ID, "Ошибка")
		return
	}
	if rec, ok := t.support.Get(uid); ok && rec.HeaderMsgID != 0 {
		if _, err := t.api.EditMessageReplyMarkup(ctx, &tgbot.EditMessageReplyMarkupParams{
			ChatID:      t.supportChatID,
			MessageID:   rec.HeaderMsgID,
			ReplyMarkup: supportCardMarkup(uid, blocked),
		}); err != nil {
			t.log.Debug("support: update card markup failed", zap.Error(err))
		}
	}
	msg := "Разблокирован"
	if blocked {
		msg = "Заблокирован"
	}
	t.answerSupportCallback(ctx, cq.ID, msg)
}

// clearSupportTopic deletes the messages relayed into the user's topic (keeping the
// info card) and answers the callback.
func (t *Bot) clearSupportTopic(ctx context.Context, cq *models.CallbackQuery, uid int64) {
	ids, err := t.support.ClearMsgs(uid)
	if err != nil {
		t.log.Warn("support: clear failed", zap.Int64("user_id", uid), zap.Error(err))
		t.answerSupportCallback(ctx, cq.ID, "Ошибка")
		return
	}
	t.deleteSupportMessages(ctx, ids)
	t.answerSupportCallback(ctx, cq.ID, "Очищено")
}

// ensureTopic returns a live forum-topic id for the user, opening a fresh topic (and
// info card) when none exists or the previous one was deleted. Telegram silently
// routes a copy aimed at a deleted topic into the chat's General topic (no error), so
// the bot cannot rely on copyMessage failing; instead it probes the info card —
// re-applying the card's reply markup is a no-op that errors only when the card (hence
// the topic) is gone — and reopens on that signal. The probe also keeps the card's
// block button in sync with the stored state.
func (t *Bot) ensureTopic(ctx context.Context, u *models.User, rec support.User) (int, error) {
	if rec.TopicID == 0 || rec.HeaderMsgID == 0 {
		return t.openSupportTopic(ctx, u)
	}
	_, err := t.api.EditMessageReplyMarkup(ctx, &tgbot.EditMessageReplyMarkupParams{
		ChatID:      t.supportChatID,
		MessageID:   rec.HeaderMsgID,
		ReplyMarkup: supportCardMarkup(u.ID, rec.Blocked),
	})
	if isCardMissingErr(err) {
		t.log.Info("support: topic gone, reopening", zap.Int64("user_id", u.ID), zap.Int("topic_id", rec.TopicID))
		return t.openSupportTopic(ctx, u)
	}
	// Any other outcome (success, or a benign "message is not modified") means the
	// topic is alive; reuse it.
	return rec.TopicID, nil
}

// openSupportTopic creates a forum topic for the user and posts its info card with
// the block/clear buttons, persisting both. It returns the new topic id. A failure to
// persist is logged but not fatal: the in-memory mapping still lets the relay proceed.
func (t *Bot) openSupportTopic(ctx context.Context, u *models.User) (int, error) {
	topic, err := t.api.CreateForumTopic(ctx, &tgbot.CreateForumTopicParams{
		ChatID: t.supportChatID,
		Name:   supportTopicName(u),
	})
	if err != nil {
		return 0, fmt.Errorf("create forum topic: %w", err)
	}
	headerID := 0
	cardText, cardEntities := supportCard(u)
	header, err := t.api.SendMessage(ctx, &tgbot.SendMessageParams{
		ChatID:          t.supportChatID,
		MessageThreadID: topic.MessageThreadID,
		Text:            cardText,
		Entities:        cardEntities,
		ReplyMarkup:     supportCardMarkup(u.ID, false),
	})
	if err != nil {
		t.log.Warn("support: post info card failed (topic opened without it)", zap.Error(err))
	} else {
		headerID = header.ID
	}
	if err := t.support.SetTopic(u.ID, topic.MessageThreadID, headerID, u.FirstName, u.LastName, u.Username); err != nil {
		t.log.Warn("support: persist topic state failed (kept in memory)", zap.Int64("user_id", u.ID), zap.Error(err))
	}
	return topic.MessageThreadID, nil
}

// copyToTopic copies a message into the user's forum topic and returns the new
// message id.
func (t *Bot) copyToTopic(ctx context.Context, fromChatID int64, messageID, topicID int) (int, error) {
	if err := t.throttle(ctx); err != nil {
		return 0, err
	}
	res, err := t.api.CopyMessage(ctx, &tgbot.CopyMessageParams{
		ChatID:          t.supportChatID,
		MessageThreadID: topicID,
		FromChatID:      fromChatID,
		MessageID:       messageID,
	})
	if err != nil {
		return 0, err
	}
	return res.ID, nil
}

// copyToUser copies a support-chat message to the user's private chat and returns the
// new message id.
func (t *Bot) copyToUser(ctx context.Context, messageID int, userID int64) (int, error) {
	if err := t.throttle(ctx); err != nil {
		return 0, err
	}
	res, err := t.api.CopyMessage(ctx, &tgbot.CopyMessageParams{
		ChatID:     userID,
		FromChatID: t.supportChatID,
		MessageID:  messageID,
	})
	if err != nil {
		return 0, err
	}
	return res.ID, nil
}

// deleteSupportMessages best-effort deletes the given support-chat messages in
// batches; individual failures (for example a message already removed) are ignored.
func (t *Bot) deleteSupportMessages(ctx context.Context, ids []int) {
	for start := 0; start < len(ids); start += supportDeleteBatch {
		end := min(start+supportDeleteBatch, len(ids))
		if _, err := t.api.DeleteMessages(ctx, &tgbot.DeleteMessagesParams{
			ChatID:     t.supportChatID,
			MessageIDs: ids[start:end],
		}); err != nil {
			t.log.Debug("support: delete batch failed", zap.Error(err))
		}
	}
}

// answerSupportCallback answers a callback query, logging a failure at debug (the
// only effect of a miss is a lingering client spinner).
func (t *Bot) answerSupportCallback(ctx context.Context, id, text string) {
	if _, err := t.api.AnswerCallbackQuery(ctx, &tgbot.AnswerCallbackQueryParams{
		CallbackQueryID: id,
		Text:            text,
	}); err != nil {
		t.log.Debug("support: answer callback failed", zap.Error(err))
	}
}

// parseSupportCallback parses "sup:<action>:<userID>" callback data.
func parseSupportCallback(data string) (action string, userID int64, ok bool) {
	rest, found := strings.CutPrefix(data, supportCallbackPrefix)
	if !found {
		return "", 0, false
	}
	action, idStr, found := strings.Cut(rest, ":")
	if !found {
		return "", 0, false
	}
	userID, err := strconv.ParseInt(idStr, 10, 64)
	if err != nil {
		return "", 0, false
	}
	return action, userID, true
}

// supportCardMarkup builds the info-card buttons: a Block/Unblock toggle reflecting
// the current state, and a Clear button.
func supportCardMarkup(userID int64, blocked bool) *models.InlineKeyboardMarkup {
	toggleText, toggleAction := "Заблокировать", supportActionBlock
	if blocked {
		toggleText, toggleAction = "Разблокировать", supportActionUnblock
	}
	return &models.InlineKeyboardMarkup{
		InlineKeyboard: [][]models.InlineKeyboardButton{{
			{Text: toggleText, CallbackData: fmt.Sprintf("%s%s:%d", supportCallbackPrefix, toggleAction, userID)},
			{Text: "Очистить", CallbackData: fmt.Sprintf("%s%s:%d", supportCallbackPrefix, supportActionClear, userID)},
		}},
	}
}

// supportTopicName builds the forum topic title for a user: full name and @username,
// trimmed to the Telegram length limit.
func supportTopicName(u *models.User) string {
	name := strings.TrimSpace(u.FirstName + " " + u.LastName)
	if name == "" {
		name = "user " + strconv.FormatInt(u.ID, 10)
	}
	if u.Username != "" {
		name += " @" + u.Username
	}
	if r := []rune(name); len(r) > supportTopicNameMax {
		return string(r[:supportTopicNameMax])
	}
	return name
}

// supportCard renders the topic info card describing the user and the message
// entities for it. The display name is covered by a text_mention entity: it links to
// the user's profile (the reliable way to mention a user who has no public username —
// the bot has seen them, since they messaged it) and, because the name sits inside an
// entity, Telegram does not auto-detect a leading "/" as a bot command or a stray
// "@handle" inside the name as a mention. The remaining lines are plain text; a public
// @username, when present, is left for Telegram to auto-link. Entity offsets are in
// UTF-16 code units, as the Bot API requires; the name is at offset 0.
func supportCard(u *models.User) (string, []models.MessageEntity) {
	name := strings.TrimSpace(u.FirstName + " " + u.LastName)
	if name == "" {
		name = "user " + strconv.FormatInt(u.ID, 10)
	}
	username := "—"
	if u.Username != "" {
		username = "@" + u.Username
	}
	lang := u.LanguageCode
	if lang == "" {
		lang = "—"
	}
	premium := "нет"
	if u.IsPremium {
		premium = "да"
	}
	var b strings.Builder
	b.WriteString(name)
	fmt.Fprintf(&b, "\nUsername: %s", username)
	fmt.Fprintf(&b, "\nID: %d", u.ID)
	fmt.Fprintf(&b, "\nЯзык: %s · Premium: %s", lang, premium)
	entities := []models.MessageEntity{{
		Type:   models.MessageEntityTypeTextMention,
		Offset: 0,
		Length: len(utf16.Encode([]rune(name))),
		User:   &models.User{ID: u.ID},
	}}
	return b.String(), entities
}

// isCardMissingErr reports whether err means the info-card message no longer exists
// (the operators deleted the topic), as opposed to a benign "message is not modified"
// no-op when the card is still there.
func isCardMissingErr(err error) bool {
	if err == nil {
		return false
	}
	s := strings.ToLower(err.Error())
	return strings.Contains(s, "message to edit not found") ||
		strings.Contains(s, "message can't be edited") ||
		strings.Contains(s, "message_id_invalid")
}

// isTopicMissingErr reports whether err is Telegram's deleted/absent forum-topic
// error, signalling the topic must be reopened.
func isTopicMissingErr(err error) bool {
	if err == nil {
		return false
	}
	s := strings.ToLower(err.Error())
	return strings.Contains(s, "thread not found") ||
		strings.Contains(s, "thread_not_found") ||
		strings.Contains(s, "topic deleted") ||
		strings.Contains(s, "topic_deleted")
}
