package app

import (
	"context"
	"fmt"
	"sort"
	"strings"

	"github.com/tidbcloud/AgentTeam/internal/coredb"
	"github.com/tidbcloud/AgentTeam/internal/store/message"
)

// ChannelMemberMutationEffects are the deterministic, deduped transient effects
// of a channel member batch mutation, captured inside the mutation transaction
// for committed post-commit fanout. They are deliberately NOT persisted in the
// idempotency receipt: replay of a cached receipt must not re-emit them, and a
// rolled-back mutation discards them with the transaction.
//
// MembershipChanges are scope pairs (parent channel or a child reply-thread
// scope) rather than flat ids because downstream per-scope effects
// (ClearAgentWakeStatusesForChannel, unread invalidation) act on an exact scope
// and do not cascade. Only actually-changed memberships are recorded. An actor
// that is itself an explicit, actually-changed target remains affected; event
// authorship is tracked independently.
type ChannelMemberMutationEffects struct {
	EventResults       []message.ChannelMemberEventResult
	DirectMembers      []DirectMember
	MembershipChanges  []ChannelMembershipChange
	HumanSubscriptions []ChannelHumanSubscription
	// RealtimeRoomIDs is the stable channel room set (parent + active child
	// reply-thread scopes) captured by a locking current read in the mutation
	// transaction whenever HumanSubscriptions contains a removal. A human-remove
	// effect with an empty room set violates the pre-commit contract; post-commit
	// fanout must never try to repair it with a stale/fail-open topology read.
	RealtimeRoomIDs []string
}

// DirectMember is one directly-changed target of the request — the refs the
// caller asked to add/remove — projected deterministically from the canonical
// channelMemberEventTarget. The service uses it to build the receipt/wire
// MemberView without re-querying after commit. Cascade-removed private agents
// are deliberately NOT represented here (they live only in MembershipChanges).
type DirectMember struct {
	MemberID    string
	Kind        string
	DisplayName string
}

// ChannelMembershipChange is one membership that actually changed on one scope.
// Kind is a message.ChannelMemberEventTarget* constant. Action is a
// message.ChannelMemberEventAction* constant (add/remove) so downstream effects
// (e.g. agent wake clear) can gate on removal. Exactly one of UserID / AgentID
// is populated for the resolved principal.
type ChannelMembershipChange struct {
	ScopeID  string
	MemberID string
	Kind     string
	Action   string
	UserID   string
	AgentID  string
}

// ChannelHumanSubscription is one human whose channel-room realtime
// subscriptions changed. On add the human is subscribed to the parent channel
// scope; on remove the human is unsubscribed from the parent scope and every
// child reply-thread scope (enumerated post-commit) so live-only events stop
// without waiting for reconnect.
type ChannelHumanSubscription struct {
	UserID string
	Action string // message.ChannelMemberEventActionAdded | ...Removed
}

// AppendMembershipChange adds a deduped membership change. The executing Agent
// is the event author (recorded separately on the event result), but when it is
// itself the request's explicit target and its canonical membership actually
// changed it MUST be represented here — so there is no actor-id skip.
func (e *ChannelMemberMutationEffects) AppendMembershipChange(scopeID string, change ChannelMembershipChange) {
	scopeID = strings.TrimSpace(scopeID)
	change.ScopeID = scopeID
	change.MemberID = strings.TrimSpace(change.MemberID)
	change.UserID = strings.TrimSpace(change.UserID)
	change.AgentID = strings.TrimSpace(change.AgentID)
	if scopeID == "" || change.MemberID == "" {
		return
	}
	for _, existing := range e.MembershipChanges {
		if existing.ScopeID == change.ScopeID && existing.MemberID == change.MemberID && existing.Kind == change.Kind {
			return
		}
	}
	e.MembershipChanges = append(e.MembershipChanges, change)
}

// AppendDirectMember adds a deduped direct changed target. The executing Agent,
// when it is the request's explicit target, is a legitimate direct member and is
// NOT skipped (its authorship is recorded separately on the event result).
func (e *ChannelMemberMutationEffects) AppendDirectMember(member DirectMember) {
	member.MemberID = strings.TrimSpace(member.MemberID)
	member.Kind = strings.TrimSpace(member.Kind)
	if member.MemberID == "" {
		return
	}
	for _, existing := range e.DirectMembers {
		if existing.MemberID == member.MemberID && existing.Kind == member.Kind {
			return
		}
	}
	e.DirectMembers = append(e.DirectMembers, member)
}

// AppendHumanSubscription adds a deduped human subscription effect.
func (e *ChannelMemberMutationEffects) AppendHumanSubscription(userID, action, actorMemberID string, actorUserID string) {
	userID = strings.TrimSpace(userID)
	if userID == "" || userID == strings.TrimSpace(actorUserID) {
		return
	}
	for _, existing := range e.HumanSubscriptions {
		if existing.UserID == userID && existing.Action == action {
			return
		}
	}
	e.HumanSubscriptions = append(e.HumanSubscriptions, ChannelHumanSubscription{UserID: userID, Action: action})
}

// HasChanges reports whether the mutation produced any real, effect-bearing
// change: a changed event result, any membership scope change, or any human
// subscription change. It MUST NOT be derived from len(EventResults) or direct
// targets alone — a cleanup-only mutation (e.g. removePrivateEntriesForOwnerHostChannel
// retiring orphaned child/private memberships while the parent human target is
// already inactive) returns an empty direct result yet still requires
// wake/unread/unsubscribe effects on the actually-removed pairs.
func (e *ChannelMemberMutationEffects) HasChanges() bool {
	for _, r := range e.EventResults {
		if r.Changed {
			return true
		}
	}
	return len(e.MembershipChanges) > 0 || len(e.HumanSubscriptions) > 0
}

// Normalize sorts and dedupes all effect slices so the committed fanout is
// deterministic across concurrent batches that touch the same members.
func (e *ChannelMemberMutationEffects) Normalize() {
	sort.SliceStable(e.DirectMembers, func(i, j int) bool {
		if e.DirectMembers[i].Kind != e.DirectMembers[j].Kind {
			return e.DirectMembers[i].Kind < e.DirectMembers[j].Kind
		}
		return e.DirectMembers[i].MemberID < e.DirectMembers[j].MemberID
	})
	sort.SliceStable(e.MembershipChanges, func(i, j int) bool {
		if e.MembershipChanges[i].ScopeID != e.MembershipChanges[j].ScopeID {
			return e.MembershipChanges[i].ScopeID < e.MembershipChanges[j].ScopeID
		}
		if e.MembershipChanges[i].MemberID != e.MembershipChanges[j].MemberID {
			return e.MembershipChanges[i].MemberID < e.MembershipChanges[j].MemberID
		}
		return e.MembershipChanges[i].Kind < e.MembershipChanges[j].Kind
	})
	sort.SliceStable(e.HumanSubscriptions, func(i, j int) bool {
		if e.HumanSubscriptions[i].UserID != e.HumanSubscriptions[j].UserID {
			return e.HumanSubscriptions[i].UserID < e.HumanSubscriptions[j].UserID
		}
		return e.HumanSubscriptions[i].Action < e.HumanSubscriptions[j].Action
	})
	canonicalRoomIDs := make([]string, 0, len(e.RealtimeRoomIDs))
	seenRoomIDs := make(map[string]struct{}, len(e.RealtimeRoomIDs))
	for _, roomID := range e.RealtimeRoomIDs {
		roomID = strings.TrimSpace(roomID)
		if roomID == "" {
			continue
		}
		if _, seen := seenRoomIDs[roomID]; seen {
			continue
		}
		seenRoomIDs[roomID] = struct{}{}
		canonicalRoomIDs = append(canonicalRoomIDs, roomID)
	}
	sort.Strings(canonicalRoomIDs)
	e.RealtimeRoomIDs = canonicalRoomIDs
}

func (e *ChannelMemberMutationEffects) hasHumanRemoval() bool {
	if e == nil {
		return false
	}
	for _, subscription := range e.HumanSubscriptions {
		if subscription.Action == message.ChannelMemberEventActionRemoved {
			return true
		}
	}
	return false
}

// captureChannelRealtimeRoomIDsInTx captures the parent channel and every
// active child reply-thread scope using a locking current read. The channel
// member mutation already owns the parent channel lock, which is also the
// reply-thread membership fence; FOR UPDATE avoids TiDB's older transaction
// snapshot and observes every child whose membership commit preceded that
// fence. Query/scan/iteration failures and a missing parent are fatal so the
// transaction cannot commit a human removal with an incomplete revocation set.
func captureChannelRealtimeRoomIDsInTx(ctx context.Context, queryer coredb.Queryer, workspaceID string, channelID string) ([]string, error) {
	workspaceID = strings.TrimSpace(workspaceID)
	channelID = strings.TrimSpace(channelID)
	if workspaceID == "" || channelID == "" {
		return nil, fmt.Errorf("capture channel realtime rooms: workspace and channel are required")
	}

	rows, err := queryer.Query(ctx, `
SELECT s.id
FROM scopes s
WHERE s.workspace_id = $1
  AND s.status = 'active'
  AND s.archived_at IS NULL
  AND (
        (s.id = $2 AND s.type = 'channel')
        OR
        (s.parent_scope_id = $2 AND s.type = 'thread')
      )
ORDER BY s.id ASC
FOR UPDATE
`, workspaceID, channelID)
	if err != nil {
		return nil, fmt.Errorf("capture channel realtime rooms: %w", err)
	}
	defer rows.Close()

	roomIDs := make([]string, 0)
	seen := make(map[string]struct{})
	parentFound := false
	for rows.Next() {
		var roomID string
		if err := rows.Scan(&roomID); err != nil {
			return nil, fmt.Errorf("scan channel realtime room: %w", err)
		}
		roomID = strings.TrimSpace(roomID)
		if roomID == "" {
			continue
		}
		if roomID == channelID {
			parentFound = true
		}
		if _, exists := seen[roomID]; exists {
			continue
		}
		seen[roomID] = struct{}{}
		roomIDs = append(roomIDs, roomID)
	}
	if err := rows.Err(); err != nil {
		return nil, fmt.Errorf("iterate channel realtime rooms: %w", err)
	}
	if !parentFound {
		return nil, fmt.Errorf("capture channel realtime rooms: missing parent channel %s", channelID)
	}
	sort.Strings(roomIDs)
	return roomIDs, nil
}

// scopeMembershipRow is one scope membership that a canonical cleanup helper
// actually retired, captured from the exact rows its UPDATE touched. ScopeID may
// be the parent channel scope or any child reply-thread scope, because the
// helpers cascade across (scope.id = $1 OR scope.parent_scope_id = $1).
// EffectiveFromSeq completes the scope_memberships PK so the retirement UPDATE
// can be narrowed to precisely these rows (captured == retired). MembershipChanges
// are built from ScopeID+MemberID (EffectiveFromSeq is only for the narrow UPDATE).
type scopeMembershipRow struct {
	ScopeID          string
	MemberID         string
	EffectiveFromSeq int64
}

// resolvedChannelMemberPrincipal is the canonical principal for one member id.
// Kind is a message.ChannelMemberEventTarget* constant, which equals members.type.
// Identity is resolved by member id (agents.member_id is UNIQUE) and is therefore
// stable across lifecycle state — an archived Agent still resolves its AgentID so
// cascade cleanup of a leftover archived-Agent membership is fully attributed.
// Callers must fail-closed if Kind or its principal is missing rather than emit a
// half-empty change.
type resolvedChannelMemberPrincipal struct {
	Kind    string
	UserID  string
	AgentID string
}

// resolveChannelMemberPrincipals resolves the canonical principal for every
// distinct member id referenced by rows in a single query, keyed by member id.
// Members that cannot be resolved are returned as zero-value entries; callers are
// expected to treat a missing/zero principal for a changed row as a hard error.
func resolveChannelMemberPrincipals(ctx context.Context, queryer coredb.Queryer, workspaceID string, rows []scopeMembershipRow) (map[string]resolvedChannelMemberPrincipal, error) {
	principals := make(map[string]resolvedChannelMemberPrincipal, len(rows))
	memberIDs := make([]string, 0, len(rows))
	for _, row := range rows {
		memberID := strings.TrimSpace(row.MemberID)
		if memberID == "" {
			continue
		}
		if _, seen := principals[memberID]; seen {
			continue
		}
		principals[memberID] = resolvedChannelMemberPrincipal{}
		memberIDs = append(memberIDs, memberID)
	}
	if len(memberIDs) == 0 {
		return principals, nil
	}
	placeholders := make([]string, len(memberIDs))
	args := make([]any, 0, len(memberIDs)+1)
	args = append(args, strings.TrimSpace(workspaceID))
	for index, memberID := range memberIDs {
		placeholders[index] = fmt.Sprintf("$%d", index+2)
		args = append(args, memberID)
	}
	queryRows, err := queryer.Query(ctx, fmt.Sprintf(`
SELECT m.id,
       m.type,
       COALESCE(NULLIF(m.user_id, ''), ''),
       COALESCE(a.id, '')
FROM members m
LEFT JOIN agents a
  ON a.workspace_id = m.workspace_id
 AND a.member_id = m.id
WHERE m.workspace_id = $1
  AND m.id IN (%s)
`, strings.Join(placeholders, ", ")), args...)
	if err != nil {
		return nil, fmt.Errorf("resolve channel member principals: %w", err)
	}
	defer queryRows.Close()
	for queryRows.Next() {
		var (
			memberID, memberType, userID, agentID string
		)
		if err := queryRows.Scan(&memberID, &memberType, &userID, &agentID); err != nil {
			return nil, fmt.Errorf("scan channel member principal: %w", err)
		}
		memberID = strings.TrimSpace(memberID)
		if memberID == "" {
			continue
		}
		principals[memberID] = resolvedChannelMemberPrincipal{
			Kind:    strings.TrimSpace(memberType),
			UserID:  strings.TrimSpace(userID),
			AgentID: strings.TrimSpace(agentID),
		}
	}
	if err := queryRows.Err(); err != nil {
		return nil, fmt.Errorf("read channel member principals: %w", err)
	}
	return principals, nil
}
