package matrix

import (
	"context"
	"log/slog"
	"strings"
	"sync"

	"maunium.net/go/mautrix"
	"maunium.net/go/mautrix/event"
	"maunium.net/go/mautrix/id"

	"github.com/enmanuel/agents/internal/config"
	"github.com/enmanuel/agents/pkg/decision"
	"github.com/enmanuel/agents/pkg/message"
)

// EventHandler is called for each incoming Matrix message that passes filters.
type EventHandler func(ctx context.Context, msgCtx decision.MessageContext, evt *event.Event)

// InterceptFunc is called for events in orchestrated rooms.
// It receives the parsed MessageContext. If it returns true, the event is not
// delivered to the bot's normal handler (the orchestrator handles it instead).
type InterceptFunc func(ctx context.Context, msgCtx decision.MessageContext) bool

// MembershipNotifyFunc is called when a StateMember event fires.
// Used by the orchestrator to detect when bots join or leave rooms.
type MembershipNotifyFunc func(ctx context.Context, roomID, userID, membership string)

// Listener attaches to a mautrix syncer and dispatches events to an EventHandler.
type Listener struct {
	client              *Client
	cfg                 config.MatrixCfg
	handler             EventHandler
	logger              *slog.Logger
	dmCache             map[id.RoomID]bool
	mu                  sync.RWMutex
	interceptor         InterceptFunc        // if set and returns true, event is forwarded to orchestrator
	membershipNotify    MembershipNotifyFunc // if set, called on all StateMember events
	encryptedRelatesTo  sync.Map             // id.EventID → map[string]any: cache m.relates_to from encrypted events
}

// NewListener creates a Listener for the given client.
func NewListener(client *Client, cfg config.MatrixCfg, handler EventHandler, logger *slog.Logger) *Listener {
	return &Listener{
		client:  client,
		cfg:     cfg,
		handler: handler,
		logger:  logger,
		dmCache: make(map[id.RoomID]bool),
	}
}

// SetInterceptor registers a function that can intercept event delivery.
// If the function returns true, the event is handled by the orchestrator
// and not delivered to the bot's normal handler.
func (l *Listener) SetInterceptor(fn InterceptFunc) {
	l.interceptor = fn
}

// SetMembershipNotify registers a callback for StateMember events.
func (l *Listener) SetMembershipNotify(fn MembershipNotifyFunc) {
	l.membershipNotify = fn
}

// Run starts the Matrix sync loop. Blocks until ctx is cancelled.
func (l *Listener) Run(ctx context.Context) error {
	syncer := l.client.raw.Syncer.(*mautrix.DefaultSyncer)

	// Cache m.relates_to from encrypted events BEFORE they are decrypted.
	// mautrix-go only copies m.relates_to into the decrypted content when
	// EncryptedEventContent.RelatesTo != nil (i.e. the outer event has it).
	// Older Element / matrix-js-sdk versions may not include it in the outer
	// event, so we store it here and use it as a fallback for thread detection.
	// Global listeners fire before type-specific ones, so this runs before
	// CryptoHelper decrypts and dispatches the event.EventMessage.
	syncer.OnEvent(func(ctx context.Context, evt *event.Event) {
		if evt.Type != event.EventEncrypted {
			return
		}
		if relatesTo, ok := evt.Content.Raw["m.relates_to"].(map[string]any); ok {
			l.encryptedRelatesTo.Store(evt.ID, relatesTo)
		}
	})

	// Auto-join rooms when invited. Without this, the bot stays in "invited"
	// state and never receives m.room.message events.
	syncer.OnEventType(event.StateMember, func(ctx context.Context, evt *event.Event) {
		stateKey := evt.GetStateKey()
		membership := evt.Content.AsMember().Membership

		// Notify orchestrator of all membership changes (for any user)
		if l.membershipNotify != nil {
			l.membershipNotify(ctx, evt.RoomID.String(), stateKey, string(membership))
		}

		// Auto-join: only process invites for ourselves
		if stateKey != l.cfg.UserID {
			return
		}
		if membership != event.MembershipInvite {
			return
		}

		l.logger.Info("received room invite, joining", "room", evt.RoomID, "inviter", evt.Sender)
		if _, err := l.client.raw.JoinRoom(ctx, evt.RoomID.String(), "", nil); err != nil {
			l.logger.Error("failed to auto-join room", "room", evt.RoomID, "err", err)
		} else {
			l.logger.Info("auto-joined room", "room", evt.RoomID)
		}
	})

	syncer.OnEventType(event.EventMessage, func(ctx context.Context, evt *event.Event) {
		l.logger.Debug("event received", "sender", evt.Sender, "room", evt.RoomID)

		if !l.shouldHandle(evt) {
			return
		}

		body, ok := evt.Content.Raw["body"].(string)
		if !ok || body == "" {
			l.logger.Debug("event has no body, skipping", "room", evt.RoomID)
			return
		}

		powerLevel := 0
		isDM := l.checkIsDM(ctx, evt.RoomID)

		// Extract m.mentions for reliable mention detection (modern Matrix spec).
		var mentionedUsers []string
		if mentions, ok := evt.Content.Raw["m.mentions"].(map[string]any); ok {
			if userIDs, ok := mentions["user_ids"].([]any); ok {
				for _, uid := range userIDs {
					if s, ok := uid.(string); ok {
						mentionedUsers = append(mentionedUsers, s)
					}
				}
			}
		}

		opts := message.ParseOptions{
			CommandPrefix:    l.cfg.Filters.CommandPrefix,
			BotUserID:        l.cfg.UserID,
			MentionedUserIDs: mentionedUsers,
		}

		msgCtx := message.Parse(
			body,
			evt.Sender.String(),
			evt.RoomID.String(),
			powerLevel,
			isDM,
			opts,
		)
		msgCtx.EventID = evt.ID.String()

		// Extract thread root from m.relates_to (Matrix thread support).
		// Three methods in order of preference:
		//   1. Raw map from decrypted content (fast path)
		//   2. Typed parsed content (robust after E2EE decryption)
		//   3. Encrypted event cache (for older clients that omit outer m.relates_to)
		if l.cfg.Threads.Enabled {
			if relatesTo, ok := evt.Content.Raw["m.relates_to"].(map[string]any); ok {
				if relType, _ := relatesTo["rel_type"].(string); relType == "m.thread" {
					if threadRoot, _ := relatesTo["event_id"].(string); threadRoot != "" {
						msgCtx.ThreadID = threadRoot
					}
				}
			}
			// Fallback 2: use typed content (more robust after E2EE decryption)
			if msgCtx.ThreadID == "" {
				if msgContent, ok := evt.Content.Parsed.(*event.MessageEventContent); ok && msgContent.RelatesTo != nil {
					if threadParent := msgContent.RelatesTo.GetThreadParent(); threadParent != "" {
						msgCtx.ThreadID = string(threadParent)
						l.logger.Debug("thread detected via typed content fallback", "thread_id", msgCtx.ThreadID)
					}
				}
			}
			// Fallback 3: encrypted event cache — for E2EE messages where the outer
			// m.relates_to was NOT propagated into the decrypted content by mautrix-go
			// (happens with older Element / matrix-js-sdk versions).
			if msgCtx.ThreadID == "" {
				if cached, found := l.encryptedRelatesTo.LoadAndDelete(evt.ID); found {
					if rt, ok := cached.(map[string]any); ok {
						if relType, _ := rt["rel_type"].(string); relType == "m.thread" {
							if threadRoot, _ := rt["event_id"].(string); threadRoot != "" {
								msgCtx.ThreadID = threadRoot
								l.logger.Debug("thread detected via encrypted event cache", "thread_id", msgCtx.ThreadID)
							}
						}
					}
				}
			}
		}

		l.logger.Debug("message parsed",
			"sender", msgCtx.SenderID,
			"room", msgCtx.RoomID,
			"is_dm", msgCtx.IsDirectMsg,
			"is_mention", msgCtx.IsMention,
			"command", msgCtx.Command,
			"content_preview", truncate(msgCtx.Content, 80),
		)

		// Orchestrator intercept: if this room is managed, the orchestrator
		// handles routing instead of the bot's normal handler.
		if l.interceptor != nil && l.interceptor(ctx, msgCtx) {
			l.logger.Debug("event intercepted by orchestrator", "room", evt.RoomID)
			return
		}

		go l.handler(ctx, msgCtx, evt)
	})

	l.logger.Info("starting matrix sync")
	return l.client.raw.SyncWithContext(ctx)
}

// checkIsDM returns true if the room has exactly 2 joined members.
// The result is cached so the API is only called once per room.
func (l *Listener) checkIsDM(ctx context.Context, roomID id.RoomID) bool {
	l.mu.RLock()
	if v, ok := l.dmCache[roomID]; ok {
		l.mu.RUnlock()
		return v
	}
	l.mu.RUnlock()

	members, err := l.client.raw.JoinedMembers(ctx, roomID)
	if err != nil {
		l.logger.Warn("could not fetch room members for DM check", "room", roomID, "err", err)
		return false
	}

	isDM := len(members.Joined) == 2

	l.mu.Lock()
	l.dmCache[roomID] = isDM
	l.mu.Unlock()

	return isDM
}

// shouldHandle applies the configured filters to an event.
func (l *Listener) shouldHandle(evt *event.Event) bool {
	f := l.cfg.Filters

	// Don't handle our own messages
	if evt.Sender == id.UserID(l.cfg.UserID) {
		l.logger.Debug("ignoring own message", "room", evt.RoomID)
		return false
	}

	// Ignore bots
	if f.IgnoreBots && strings.HasSuffix(evt.Sender.String(), "-bot:"+serverName(l.cfg.UserID)) {
		l.logger.Debug("ignoring bot sender", "sender", evt.Sender)
		return false
	}

	// Ignore specific users
	for _, u := range f.IgnoreUsers {
		if evt.Sender.String() == u {
			l.logger.Debug("ignoring blocked user", "sender", evt.Sender)
			return false
		}
	}

	// Check if room is in the listen list
	if len(l.cfg.Rooms.Listen) > 0 {
		allowed := false
		for _, r := range l.cfg.Rooms.Listen {
			if evt.RoomID.String() == r {
				allowed = true
				break
			}
		}
		if !allowed {
			l.logger.Debug("ignoring event: room not in listen list", "room", evt.RoomID)
			return false
		}
	}

	return true
}

// truncate shortens s to at most n runes for log preview.
func truncate(s string, n int) string {
	runes := []rune(s)
	if len(runes) <= n {
		return s
	}
	return string(runes[:n]) + "…"
}

func serverName(userID string) string {
	parts := strings.SplitN(userID, ":", 2)
	if len(parts) == 2 {
		return parts[1]
	}
	return ""
}
