// Package imbot handles inbound IM events for the alert bot:
// slash commands in alert topics drive the alert state machine, and all
// other discussion messages in bound topics are captured into the
// timeline (the progress SSOT). Free text is stored verbatim, never
// parsed for state.
package imbot

import (
	"context"
	"encoding/json"
	"fmt"
	"log/slog"
	"strconv"
	"strings"
	"sync"
	"time"

	"github.com/tidbcloud/alertstore/pkg/alert"
	"github.com/tidbcloud/alertstore/pkg/audit"
	"github.com/tidbcloud/alertstore/pkg/im"
	"github.com/tidbcloud/alertstore/pkg/metrics"
	"github.com/tidbcloud/alertstore/pkg/mute"
	"github.com/tidbcloud/alertstore/pkg/subscription"
	"github.com/tidbcloud/alertstore/pkg/timeline"
)

const sourceIM = "im"

// eventSeenTTL bounds the in-memory redelivery dedup window. Lark retries
// callbacks within minutes; DB-level uniq_ref catches anything older.
const eventSeenTTL = 10 * time.Minute

const helpText = `Commands (inside an alert topic):
/ack — acknowledge this alert
/resolve <resolution> [note] — close this alert:
  handled — a human intervened (fixed, mitigated, or notified the customer)
  self_recovered — the problem went away on its own; nothing needed doing
  noise — should not have fired (false positive, expected activity, duplicate)
/assign @user|email [reason] — reassign
/mute <duration> [reason] — mute this alertname+cluster, e.g. /mute 2h noisy during upgrade
/help — this message`

var validResolutions = map[string]bool{
	alert.ResolutionHandled:       true,
	alert.ResolutionSelfRecovered: true,
	alert.ResolutionNoise:         true,
}

type Bot struct {
	im            im.Provider
	alertStore    *alert.Store
	alertService  *alert.Service
	muteStore     *mute.Store
	timelineStore *timeline.Store
	auditStore    *audit.Store
	logger        *slog.Logger

	mu   sync.Mutex
	seen map[string]time.Time // event ID → first seen, for redelivery dedup
}

func New(
	imProvider im.Provider,
	alertStore *alert.Store,
	alertService *alert.Service,
	muteStore *mute.Store,
	timelineStore *timeline.Store,
	auditStore *audit.Store,
	logger *slog.Logger,
) *Bot {
	return &Bot{
		im:            imProvider,
		alertStore:    alertStore,
		alertService:  alertService,
		muteStore:     muteStore,
		timelineStore: timelineStore,
		auditStore:    auditStore,
		logger:        logger,
		seen:          make(map[string]time.Time),
	}
}

// HandleEvent processes a normalized inbound IM event. Processing errors are
// logged rather than returned because the Feishu long-connection callback must
// be acknowledged promptly.
func (b *Bot) HandleEvent(ctx context.Context, ev *im.IncomingEvent) {
	if ev.EventID != "" && b.alreadySeen(ev.EventID) {
		return
	}

	switch {
	case ev.Recall != nil:
		if err := b.timelineStore.MarkRecalled(ctx, ev.Recall.MessageID); err != nil {
			b.logger.Error("mark timeline recalled", "err", err, "message_id", ev.Recall.MessageID)
		}
	case ev.Message != nil:
		b.handleMessage(ctx, ev.Message)
	}
}

// HandleCardAction answers a button press. Unlike the other events it runs on
// the caller's goroutine and returns what to show whoever clicked, so it must
// not do anything slow.
func (b *Bot) HandleCardAction(ctx context.Context, action *im.IncomingCardAction) *im.CardActionResult {
	alertID := action.Value[im.ActionAlertID]
	if action.Value[im.ActionKey] != im.ActionAck || alertID == "" {
		b.logger.Warn("unrecognised card action", "value", action.Value, "user_id", action.UserID)
		return nil
	}
	metrics.IMCommands.WithLabelValues("card:" + im.ActionAck).Inc()

	a, err := b.alertStore.GetByID(ctx, alertID)
	if err != nil {
		b.logger.Error("load alert for card ack", "err", err, "alert_id", alertID)
		return &im.CardActionResult{Toast: "Could not load " + alertID, ToastError: true}
	}
	if a.State == alert.StateAcked {
		// Two people reaching for the same alert is normal; say who got there.
		// The stale button they pressed is cleared by the refresh the winning
		// ack already queued.
		return &im.CardActionResult{Toast: alertID + " was already acked by " + a.AckedBy}
	}

	actor := b.actorFor(ctx, action.UserID)
	if err := b.alertService.Ack(ctx, alertID, actor, sourceIM); err != nil {
		return &im.CardActionResult{Toast: err.Error(), ToastError: true}
	}

	// Acking claims the alert, so the toast says so — a click that quietly
	// changed who owns an incident should not be silent to the person who made
	// it.
	toast := "Acked " + alertID
	if a.AssignedTo != actor {
		toast = "Acked " + alertID + " — it is yours now"
	}
	return &im.CardActionResult{Toast: toast}
}

func (b *Bot) alreadySeen(eventID string) bool {
	b.mu.Lock()
	defer b.mu.Unlock()
	now := time.Now()
	for id, t := range b.seen {
		if now.Sub(t) > eventSeenTTL {
			delete(b.seen, id)
		}
	}
	if _, ok := b.seen[eventID]; ok {
		return true
	}
	b.seen[eventID] = now
	return false
}

func (b *Bot) handleMessage(ctx context.Context, msg *im.IncomingMessage) {
	if msg.ThreadID == "" {
		return // not in a topic; nothing to bind to
	}
	a, err := b.alertStore.FindByIMThreadID(ctx, msg.ThreadID)
	if err != nil {
		b.logger.Error("find alert by thread", "err", err, "thread_id", msg.ThreadID)
		return
	}
	if a == nil {
		return // topic not bound to an alert
	}

	if cmd := strings.TrimSpace(msg.Text); strings.HasPrefix(cmd, "/") {
		metrics.IMCommands.WithLabelValues(strings.Fields(cmd)[0]).Inc()
		b.runCommand(ctx, a, msg, cmd)
		return
	}
	b.capture(ctx, a, msg)
}

// capture records a discussion message into the timeline.
func (b *Bot) capture(ctx context.Context, a *alert.Alert, msg *im.IncomingMessage) {
	text := msg.Text
	var detail map[string]interface{}
	if msg.MsgType != "text" {
		// Non-text content (image/post/file/...) — record a placeholder;
		// media persistence is phase 2.
		text = "[" + msg.MsgType + "]"
		detail = map[string]interface{}{"msg_type": msg.MsgType}
	}

	_, err := b.timelineStore.Insert(ctx, a.ID, timeline.KindIMMessage,
		b.actorFor(ctx, msg.SenderID), sourceIM, text, msg.MessageID, detail)
	if err != nil {
		b.logger.Error("capture timeline message", "err", err, "alert_id", a.ID, "message_id", msg.MessageID)
	}
}

func (b *Bot) runCommand(ctx context.Context, a *alert.Alert, msg *im.IncomingMessage, cmd string) {
	fields := strings.Fields(cmd)
	actor := b.actorFor(ctx, msg.SenderID)

	var reply string
	switch fields[0] {
	case "/ack":
		reply = b.cmdAck(ctx, a, actor)
	case "/resolve":
		reply = b.cmdResolve(ctx, a, actor, fields[1:])
	case "/assign":
		reply = b.cmdAssign(ctx, a, actor, msg, fields[1:])
	case "/mute":
		reply = b.cmdMute(ctx, a, actor, fields[1:])
	case "/help":
		reply = helpText
	default:
		reply = fmt.Sprintf("❌ Unknown command %s\n%s", fields[0], helpText)
	}

	b.reply(ctx, msg.MessageID, reply)
}

func (b *Bot) cmdAck(ctx context.Context, a *alert.Alert, actor string) string {
	if err := b.alertService.Ack(ctx, a.ID, actor, sourceIM); err != nil {
		return "❌ " + err.Error()
	}
	return fmt.Sprintf("✅ %s acked by %s", a.ID, actor)
}

func (b *Bot) cmdResolve(ctx context.Context, a *alert.Alert, actor string, args []string) string {
	if len(args) == 0 || !validResolutions[args[0]] {
		return "❌ Usage: /resolve <resolution> [note]\nresolution: handled (a human intervened) | self_recovered (went away on its own) | noise (should not have fired)"
	}
	note := strings.Join(args[1:], " ")
	if err := b.alertService.Resolve(ctx, a.ID, actor, sourceIM, args[0], note); err != nil {
		return "❌ " + err.Error()
	}
	return fmt.Sprintf("✅ %s resolved (%s) by %s", a.ID, args[0], actor)
}

func (b *Bot) cmdAssign(ctx context.Context, a *alert.Alert, actor string, msg *im.IncomingMessage, args []string) string {
	assignee := ""
	reason := strings.Join(args, " ")

	// Prefer an @-mention; otherwise the first argument is an email.
	if len(msg.Mentions) > 0 {
		if email, err := b.im.GetUserEmail(ctx, msg.Mentions[0].UserID); err == nil {
			assignee = email
		} else {
			assignee = msg.Mentions[0].UserID
		}
	} else if len(args) > 0 && strings.Contains(args[0], "@") {
		assignee = args[0]
		reason = strings.Join(args[1:], " ")
	}
	if assignee == "" {
		return "❌ Usage: /assign @user|email [reason]"
	}

	if err := b.alertService.Assign(ctx, a.ID, actor, sourceIM, assignee, reason); err != nil {
		return "❌ " + err.Error()
	}
	return fmt.Sprintf("✅ %s assigned to %s by %s", a.ID, assignee, actor)
}

func (b *Bot) cmdMute(ctx context.Context, a *alert.Alert, actor string, args []string) string {
	if len(args) == 0 {
		return "❌ Usage: /mute <duration> [reason], e.g. /mute 2h noisy during upgrade"
	}
	d, err := parseDuration(args[0])
	if err != nil {
		return "❌ Invalid duration " + args[0] + " (use 30m / 2h / 1d)"
	}
	reason := strings.Join(args[1:], " ")

	// Scope: this alertname on this cluster (alertname only when the
	// alert has no cluster).
	f := subscription.SubscriptionFilter{Alertname: subscription.Eq(a.Alertname)}
	scope := a.Alertname
	if a.ClusterID != "" {
		f.ClusterID = subscription.Eq(a.ClusterID)
		scope += " on " + a.ClusterID
	}
	filterJSON, _ := json.Marshal(f)

	expiresAt := time.Now().Add(d)
	rule, err := b.muteStore.Create(ctx, filterJSON, reason, actor, expiresAt)
	if err != nil {
		return "❌ " + err.Error()
	}

	b.auditStore.Log(ctx, a.ID, "mute_created", "", "", actor, sourceIM, map[string]interface{}{
		"rule_id":    rule.ID,
		"filter":     string(filterJSON),
		"expires_at": expiresAt.UTC().Format(time.RFC3339),
		"reason":     reason,
	})
	return fmt.Sprintf("🔇 Muted %s until %s (rule %s)", scope, expiresAt.UTC().Format("2006-01-02 15:04 UTC"), rule.ID)
}

// actorFor resolves an IM user ID to an email for audit identity,
// consistent with web and issue-tracker actors. Falls back to the raw ID.
func (b *Bot) actorFor(ctx context.Context, senderID string) string {
	email, err := b.im.GetUserEmail(ctx, senderID)
	if err != nil {
		b.logger.Warn("resolve user email", "err", err, "user_id", senderID)
		return senderID
	}
	return email
}

func (b *Bot) reply(ctx context.Context, messageID, text string) {
	content, _ := json.Marshal(map[string]string{"text": text})
	if err := b.im.ReplyToThread(ctx, messageID, string(content)); err != nil {
		b.logger.Error("reply command result", "err", err, "message_id", messageID)
	}
}

// parseDuration is time.ParseDuration plus day support ("1d", "7d").
func parseDuration(s string) (time.Duration, error) {
	if strings.HasSuffix(s, "d") {
		days, err := strconv.Atoi(strings.TrimSuffix(s, "d"))
		if err != nil || days <= 0 {
			return 0, fmt.Errorf("invalid duration %q", s)
		}
		return time.Duration(days) * 24 * time.Hour, nil
	}
	d, err := time.ParseDuration(s)
	if err != nil || d <= 0 {
		return 0, fmt.Errorf("invalid duration %q", s)
	}
	return d, nil
}
