// ABOUTME: Chat protocol functions composing low-level aweb-go client methods.
// ABOUTME: Provides Send, Open, History, Pending, ExtendWait, and ShowPending.

package chat

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"strings"
	"time"

	awid "github.com/awebai/aw/awid"
	"github.com/awebai/aw/internal/identityutil"
)

type signedEnvelopeMetadata struct {
	From         string `json:"from"`
	To           string `json:"to"`
	FromDID      string `json:"from_did"`
	ToDID        string `json:"to_did"`
	FromStableID string `json:"from_stable_id"`
	ToStableID   string `json:"to_stable_id"`
}

func parseSignedEnvelopeMetadata(payload string) (signedEnvelopeMetadata, bool) {
	if payload == "" {
		return signedEnvelopeMetadata{}, false
	}
	var meta signedEnvelopeMetadata
	if err := json.Unmarshal([]byte(payload), &meta); err != nil {
		return signedEnvelopeMetadata{}, false
	}
	return meta, true
}

const DefaultWait = 120 // Default wait timeout in seconds for replies

// maxStreamDeadline is the server-side SSE connection safety net.
// The local waitTimer manages actual wait semantics; this just prevents
// orphaned server connections. Must exceed any possible wait extension chain.
const maxStreamDeadline = 15 * time.Minute

// MaxSendTimeout is the maximum duration a Send() call can take,
// accounting for all possible wait extensions.
const MaxSendTimeout = 16 * time.Minute

func classifyChatTargets(targets []string) (aliases []string, dids []string, addresses []string) {
	for _, target := range targets {
		target = awid.NormalizeHostedHandleAddress(target)
		if target == "" {
			continue
		}
		switch {
		case strings.HasPrefix(target, "did:"):
			dids = append(dids, target)
		case strings.Contains(target, "/"):
			addresses = append(addresses, target)
		default:
			aliases = append(aliases, target)
		}
	}
	return aliases, dids, addresses
}

// sseResult wraps an SSE event or error for channel-based processing.
type sseResult struct {
	event *awid.SSEEvent
	err   error
}

// streamToChannel bridges SSEStream.Next() to a channel for select-based processing.
// Returns the event channel and a cleanup function. The cleanup function closes the
// stream, signals the goroutine to stop, and blocks until it has exited.
// The caller must call cleanup to avoid goroutine leaks.
func streamToChannel(ctx context.Context, stream *awid.SSEStream) (<-chan sseResult, func()) {
	ch := make(chan sseResult, 10)
	stopCtx, stopCancel := context.WithCancel(ctx)
	done := make(chan struct{})
	go func() {
		defer close(ch)
		defer close(done)
		for {
			ev, err := stream.Next()
			if err != nil {
				select {
				case ch <- sseResult{err: err}:
				case <-stopCtx.Done():
				}
				return
			}
			select {
			case ch <- sseResult{event: ev}:
			case <-stopCtx.Done():
				return
			}
		}
	}()
	cleanup := func() {
		stopCancel()
		stream.Close()
		<-done
	}
	return ch, cleanup
}

// parseSSEEvent converts an SSE event to a chat Event.
func parseSSEEvent(sseEvent *awid.SSEEvent) Event {
	ev := Event{
		Type: sseEvent.Event,
	}
	signedPayload := ""

	var data map[string]any
	if err := json.Unmarshal([]byte(sseEvent.Data), &data); err != nil {
		return ev
	}

	if v, ok := data["agent"].(string); ok {
		ev.Agent = v
	}
	if v, ok := data["session_id"].(string); ok {
		ev.SessionID = v
	}
	if v, ok := data["message_id"].(string); ok {
		ev.MessageID = v
	}
	if v, ok := data["from_agent"].(string); ok {
		ev.FromAgent = v
	} else if v, ok := data["from"].(string); ok {
		ev.FromAgent = v
	}
	if v, ok := data["from_address"].(string); ok {
		ev.FromAddress = v
	}
	if v, ok := data["to_address"].(string); ok {
		ev.ToAddress = v
	}
	if v, ok := data["body"].(string); ok {
		ev.Body = v
	}
	if v, ok := data["by"].(string); ok {
		ev.By = v
	}
	if v, ok := data["reason"].(string); ok {
		ev.Reason = v
	}
	if v, ok := data["timestamp"].(string); ok {
		ev.Timestamp = v
	}
	if v, ok := data["sender_leaving"].(bool); ok {
		ev.SenderLeaving = v
	}
	if v, ok := data["sender_waiting"].(bool); ok {
		ev.SenderWaiting = v
	}
	if v, ok := data["reader_alias"].(string); ok {
		ev.ReaderAlias = v
	}
	if v, ok := data["hang_on"].(bool); ok {
		ev.ExtendWait = v
	}
	if v, ok := data["extends_wait_seconds"].(float64); ok {
		ev.ExtendsWaitSeconds = int(v)
	}
	if v, ok := data["reply_to_message_id"].(string); ok {
		ev.ReplyToMessageID = v
	}
	if v, ok := data["from_did"].(string); ok {
		ev.FromDID = v
	}
	if v, ok := data["to_did"].(string); ok {
		ev.ToDID = v
	}
	if v, ok := data["from_stable_id"].(string); ok {
		ev.FromStableID = v
	}
	if v, ok := data["to_stable_id"].(string); ok {
		ev.ToStableID = v
	}
	if v, ok := data["signature"].(string); ok {
		ev.Signature = v
	}
	if v, ok := data["signing_key_id"].(string); ok {
		ev.SigningKeyID = v
	}
	if v, ok := data["signed_payload"].(string); ok {
		signedPayload = v
	}
	if v, ok := data["is_contact"].(bool); ok {
		ev.IsContact = &v
	}
	if raData, ok := data["rotation_announcement"].(map[string]any); ok {
		ev.RotationAnnouncement = &awid.RotationAnnouncement{}
		if v, ok := raData["old_did"].(string); ok {
			ev.RotationAnnouncement.OldDID = v
		}
		if v, ok := raData["new_did"].(string); ok {
			ev.RotationAnnouncement.NewDID = v
		}
		if v, ok := raData["timestamp"].(string); ok {
			ev.RotationAnnouncement.Timestamp = v
		}
		if v, ok := raData["old_key_signature"].(string); ok {
			ev.RotationAnnouncement.OldKeySignature = v
		}
	}
	if replData, ok := data["replacement_announcement"].(map[string]any); ok {
		ev.ReplacementAnnouncement = &awid.ReplacementAnnouncement{}
		if v, ok := replData["address"].(string); ok {
			ev.ReplacementAnnouncement.Address = v
		}
		if v, ok := replData["old_did"].(string); ok {
			ev.ReplacementAnnouncement.OldDID = v
		}
		if v, ok := replData["new_did"].(string); ok {
			ev.ReplacementAnnouncement.NewDID = v
		}
		if v, ok := replData["controller_did"].(string); ok {
			ev.ReplacementAnnouncement.ControllerDID = v
		}
		if v, ok := replData["timestamp"].(string); ok {
			ev.ReplacementAnnouncement.Timestamp = v
		}
		if v, ok := replData["controller_signature"].(string); ok {
			ev.ReplacementAnnouncement.ControllerSignature = v
		}
	}

	if meta, ok := parseSignedEnvelopeMetadata(signedPayload); ok {
		// The chat stream row may carry a stable DID in from_did/to_did when the
		// session participant was stored under did:aw. The signed payload is the
		// verification authority and carries the did:key that signed the message;
		// match ChatHistory normalization so live SSE rendering does not show a
		// verified message as [unverified].
		if meta.FromDID != "" {
			ev.FromDID = meta.FromDID
		}
		if meta.ToDID != "" {
			ev.ToDID = meta.ToDID
		}
		if ev.FromStableID == "" {
			ev.FromStableID = meta.FromStableID
		}
		if ev.ToStableID == "" {
			ev.ToStableID = meta.ToStableID
		}
		if ev.FromAddress == "" {
			ev.FromAddress = meta.From
		}
		if ev.ToAddress == "" {
			ev.ToAddress = meta.To
		}
	}

	// Verify message signature when identity fields are present.
	from := ev.FromAgent
	if ev.FromAddress != "" {
		from = ev.FromAddress
	}
	env := &awid.MessageEnvelope{
		From:         from,
		FromDID:      ev.FromDID,
		To:           ev.ToAddress,
		ToDID:        ev.ToDID,
		Type:         "chat",
		Body:         ev.Body,
		Timestamp:    ev.Timestamp,
		FromStableID: ev.FromStableID,
		ToStableID:   ev.ToStableID,
		MessageID:    ev.MessageID,
		Signature:    ev.Signature,
		SigningKeyID: ev.SigningKeyID,
	}
	// Error is encoded in VerificationStatus; discard it.
	if signedPayload != "" {
		ev.VerificationStatus, _ = awid.VerifySignedPayload(signedPayload, ev.Signature, ev.FromDID, ev.SigningKeyID)
	} else {
		ev.VerificationStatus, _ = awid.VerifyMessage(env)
	}

	return ev
}

func addressHandle(value string) string {
	return identityutil.HandleFromAddress(value)
}

func stableAlias(value string) string {
	value = strings.TrimSpace(value)
	if !strings.HasPrefix(value, "did:aw:") {
		return ""
	}
	return strings.TrimSpace(strings.TrimPrefix(value, "did:aw:"))
}

type chatParticipantRow struct {
	Alias    string
	Address  string
	DID      string
	StableID string
}

func newChatParticipantRow(alias string, address string, did string) chatParticipantRow {
	row := chatParticipantRow{
		Alias:   strings.TrimSpace(alias),
		Address: strings.TrimSpace(address),
		DID:     strings.TrimSpace(did),
	}
	if strings.HasPrefix(row.DID, "did:aw:") {
		row.StableID = row.DID
	}
	return row
}

func chatParticipantRows(participants []string, participantDIDs []string, participantAddresses []string) []chatParticipantRow {
	maxLen := len(participants)
	if len(participantDIDs) > maxLen {
		maxLen = len(participantDIDs)
	}
	if len(participantAddresses) > maxLen {
		maxLen = len(participantAddresses)
	}
	rows := make([]chatParticipantRow, 0, maxLen)
	for i := 0; i < maxLen; i++ {
		alias := ""
		if i < len(participants) {
			alias = participants[i]
		}
		address := ""
		if i < len(participantAddresses) {
			address = participantAddresses[i]
		}
		did := ""
		if i < len(participantDIDs) {
			did = participantDIDs[i]
		}
		rows = append(rows, newChatParticipantRow(alias, address, did))
	}
	return rows
}

func chatParticipantRowFromParticipant(participant awid.ChatParticipant) chatParticipantRow {
	return newChatParticipantRow(participant.Alias, participant.Address, participant.DID)
}

func (row chatParticipantRow) identityValues() []string {
	return []string{row.Alias, row.Address, row.DID}
}

func (row chatParticipantRow) matchesTarget(target string) bool {
	for _, candidate := range row.identityValues() {
		if chatIdentityMatchesTarget(candidate, target) {
			return true
		}
	}
	return false
}

func (row chatParticipantRow) matchesSessionTarget(target string) bool {
	for _, candidate := range row.identityValues() {
		if chatSessionIdentityMatchesTarget(candidate, target) {
			return true
		}
	}
	return false
}

func (row chatParticipantRow) matchesStrongIdentity(kind string, target string) bool {
	switch kind {
	case "address":
		return chatIdentityMatchesTarget(row.Address, target)
	case "did":
		return chatIdentityMatchesTarget(row.DID, target)
	default:
		return false
	}
}

func (row chatParticipantRow) concreteIdentityKey() string {
	if row.StableID != "" {
		return row.StableID
	}
	if row.DID != "" {
		return row.DID
	}
	return row.Address
}

func (row chatParticipantRow) label() string {
	if row.Address != "" {
		return row.Address
	}
	if row.StableID != "" {
		return row.StableID
	}
	if row.DID != "" {
		return row.DID
	}
	return row.Alias
}

func preferredChatIdentityLabel(alias string, address string, stableID string, did string) string {
	row := newChatParticipantRow(alias, address, did)
	if stableID = strings.TrimSpace(stableID); stableID != "" {
		row.StableID = stableID
	}
	return row.label()
}

func chatIdentityMatchesTarget(candidate string, target string) bool {
	candidate = strings.TrimSpace(candidate)
	target = strings.TrimSpace(target)
	if candidate == "" || target == "" {
		return false
	}
	if strings.EqualFold(candidate, target) {
		return true
	}
	if alias := stableAlias(candidate); alias != "" && strings.EqualFold(alias, target) {
		return true
	}
	if alias := stableAlias(target); alias != "" && strings.EqualFold(alias, candidate) {
		return true
	}
	if handle := addressHandle(candidate); handle != "" && strings.EqualFold(handle, target) {
		return true
	}
	if handle := addressHandle(target); handle != "" && strings.EqualFold(handle, candidate) {
		return true
	}
	return false
}

func chatSessionIdentityMatchesTarget(candidate string, target string) bool {
	candidate = strings.TrimSpace(candidate)
	target = strings.TrimSpace(target)
	if candidate == "" || target == "" {
		return false
	}
	if strings.EqualFold(candidate, target) {
		return true
	}
	if alias := stableAlias(candidate); alias != "" && strings.EqualFold(alias, target) {
		return true
	}
	if alias := stableAlias(target); alias != "" && strings.EqualFold(alias, candidate) {
		return true
	}
	if !strings.HasPrefix(target, "did:") && !strings.Contains(target, "/") {
		if handle := addressHandle(candidate); handle != "" && strings.EqualFold(handle, target) {
			return true
		}
	}
	return false
}

func exactParticipantMatch(participants []string, participantDIDs []string, participantAddresses []string, target string) bool {
	target = strings.TrimSpace(target)
	if target == "" {
		return false
	}
	for _, row := range chatParticipantRows(participants, participantDIDs, participantAddresses) {
		if row.matchesSessionTarget(target) {
			return true
		}
	}
	return false
}

func participantIdentityCount(participants []string, participantDIDs []string, participantAddresses []string) int {
	return len(chatParticipantRows(participants, participantDIDs, participantAddresses))
}

func pendingParticipantIdentityAt(participants []string, participantDIDs []string, participantAddresses []string, idx int) (alias, address, did string) {
	rows := chatParticipantRows(participants, participantDIDs, participantAddresses)
	if idx >= 0 && idx < len(rows) {
		row := rows[idx]
		return row.Alias, row.Address, row.DID
	}
	return "", "", ""
}

func pendingParticipantIdentityByLastFrom(participants []string, participantDIDs []string, participantAddresses []string, lastFrom string) (address, stableID, did string) {
	lastFrom = strings.TrimSpace(lastFrom)
	if lastFrom == "" {
		return "", "", ""
	}
	matchAddress := ""
	matchStableID := ""
	matchDID := ""
	matches := 0
	for _, row := range chatParticipantRows(participants, participantDIDs, participantAddresses) {
		if !row.matchesSessionTarget(lastFrom) {
			continue
		}
		matches++
		if matches > 1 {
			return "", "", ""
		}
		matchAddress = row.Address
		if row.StableID != "" {
			matchStableID = row.StableID
			matchDID = ""
		} else {
			matchStableID = ""
			matchDID = row.DID
		}
	}
	return matchAddress, matchStableID, matchDID
}

func unanimousChatRowValue(rows []chatParticipantRow, pick func(chatParticipantRow) string) string {
	candidate := ""
	for _, row := range rows {
		value := strings.TrimSpace(pick(row))
		if value == "" {
			continue
		}
		if candidate != "" && !strings.EqualFold(candidate, value) {
			return ""
		}
		candidate = value
	}
	return candidate
}

func unanimousPendingParticipantIdentity(participants []string, participantDIDs []string, participantAddresses []string) (address, stableID, did string) {
	rows := chatParticipantRows(participants, participantDIDs, participantAddresses)
	address = unanimousChatRowValue(rows, func(row chatParticipantRow) string { return row.Address })
	stableID = unanimousChatRowValue(rows, func(row chatParticipantRow) string { return row.StableID })
	if stableID == "" {
		did = unanimousChatRowValue(rows, func(row chatParticipantRow) string {
			if row.StableID != "" {
				return ""
			}
			return row.DID
		})
	}
	return address, stableID, did
}

func matchedParticipantIdentityKeys(participants []string, participantDIDs []string, participantAddresses []string, target string) []string {
	target = strings.TrimSpace(target)
	if target == "" {
		return nil
	}
	keys := []string{}
	appendUnique := func(value string) {
		value = strings.TrimSpace(value)
		if value == "" {
			return
		}
		for _, existing := range keys {
			if strings.EqualFold(existing, value) {
				return
			}
		}
		keys = append(keys, value)
	}
	for _, row := range chatParticipantRows(participants, participantDIDs, participantAddresses) {
		if !row.matchesSessionTarget(target) {
			continue
		}
		if key := row.concreteIdentityKey(); key != "" {
			appendUnique(key)
		}
	}
	return keys
}

func normalizeMatchedIdentityKeys(ctx context.Context, client *awid.Client, keys []string) []string {
	if len(keys) == 0 || client == nil {
		return keys
	}
	normalized := []string{}
	appendUnique := func(value string) {
		value = strings.TrimSpace(value)
		if value == "" {
			return
		}
		for _, existing := range normalized {
			if strings.EqualFold(existing, value) {
				return
			}
		}
		normalized = append(normalized, value)
	}
	for _, key := range keys {
		key = strings.TrimSpace(key)
		if key == "" {
			continue
		}
		if identity, err := client.ResolveIdentity(ctx, key); err == nil && identity != nil {
			if stableID := strings.TrimSpace(identity.StableID); stableID != "" {
				appendUnique(stableID)
				continue
			}
			if did := strings.TrimSpace(identity.DID); did != "" {
				appendUnique(did)
				continue
			}
		}
		appendUnique(key)
	}
	return normalized
}

func uniqueHandleParticipantMatch(participants []string, _ []string, _ []string, target string) bool {
	handle := addressHandle(target)
	if handle == "" {
		return false
	}
	for _, participant := range participants {
		if strings.TrimSpace(participant) == handle {
			return true
		}
	}
	return false
}

func normalizeSessionTarget(ctx context.Context, client *awid.Client, target string) string {
	target = strings.TrimSpace(target)
	if target == "" || !strings.HasPrefix(target, "did:") || client == nil {
		return target
	}
	identity, err := client.ResolveIdentity(ctx, target)
	if err != nil || identity == nil {
		return target
	}
	if address := strings.TrimSpace(identity.Address); address != "" {
		return address
	}
	if handle := strings.TrimSpace(identity.Handle); handle != "" {
		return handle
	}
	if did := strings.TrimSpace(identity.DID); did != "" {
		return did
	}
	return target
}

func sessionActivity(session awid.ChatSessionItem) string {
	if strings.TrimSpace(session.LastActivity) != "" {
		return session.LastActivity
	}
	return session.CreatedAt
}

// findSession finds the session ID for a conversation with targetAlias.
// Checks pending first (captures sender_waiting), falls back to listing sessions.
//
// Selection priority for pending sessions:
//  1. sender_waiting sessions over non-waiting (urgent conversations first)
//  2. smallest participant count (1:1 over group)
//  3. most recent LastActivity (tiebreaker)
//
// Selection priority for fallback (all sessions):
//  1. smallest participant count
//  2. most recent LastActivity, falling back to CreatedAt (tiebreaker)
func findSession(ctx context.Context, client *awid.Client, targetAlias string) (sessionID string, senderWaiting bool, err error) {
	return findSessionWithOptions(ctx, client, targetAlias, true)
}

func findLatestSession(ctx context.Context, client *awid.Client, targetAlias string) (sessionID string, senderWaiting bool, err error) {
	return findSessionWithOptions(ctx, client, targetAlias, false)
}

func findSessionWithOptions(ctx context.Context, client *awid.Client, targetAlias string, preferWaiting bool) (sessionID string, senderWaiting bool, err error) {
	rawTarget := strings.TrimSpace(targetAlias)
	targetAlias = normalizeSessionTarget(ctx, client, rawTarget)
	bareAliasTarget := rawTarget != "" &&
		!strings.HasPrefix(rawTarget, "did:") &&
		!strings.Contains(rawTarget, "/") &&
		!strings.Contains(rawTarget, "~")
	currentTeamID := ""
	if client != nil {
		currentTeamID = client.TeamID()
	}
	requireUniqueExact := strings.HasPrefix(rawTarget, "did:") &&
		targetAlias != "" &&
		targetAlias != rawTarget &&
		!strings.Contains(targetAlias, "/")
	requireUniqueConcreteAlias := bareAliasTarget
	pendingResp, err := client.ChatPending(ctx)
	if err != nil {
		return "", false, fmt.Errorf("getting pending chats: %w", err)
	}
	matchesSelectedTeam := func(itemTeamID string) bool {
		if !bareAliasTarget || currentTeamID == "" || strings.TrimSpace(itemTeamID) == "" {
			return true
		}
		return strings.EqualFold(strings.TrimSpace(itemTeamID), currentTeamID)
	}

	selectPending := func(match func([]string, []string, []string, string) bool, requireUnique bool, trackConcreteIdentity bool) (string, bool, error) {
		var bestPendingID string
		var bestPendingWaiting bool
		var bestPendingActivity string
		bestPendingSize := -1
		matchCount := 0
		identityKeys := []string{}
		appendIdentityKey := func(value string) {
			value = strings.TrimSpace(value)
			if value == "" {
				return
			}
			for _, existing := range identityKeys {
				if strings.EqualFold(existing, value) {
					return
				}
			}
			identityKeys = append(identityKeys, value)
		}
		for _, p := range pendingResp.Pending {
			if !matchesSelectedTeam(p.TeamID) {
				continue
			}
			if !match(p.Participants, p.ParticipantDIDs, p.ParticipantAddresses, targetAlias) {
				continue
			}
			matchCount++
			if trackConcreteIdentity {
				for _, key := range matchedParticipantIdentityKeys(p.Participants, p.ParticipantDIDs, p.ParticipantAddresses, targetAlias) {
					appendIdentityKey(key)
				}
			}
			size := participantIdentityCount(p.Participants, p.ParticipantDIDs, p.ParticipantAddresses)
			better := bestPendingSize < 0
			if !better && preferWaiting {
				// Prefer sender_waiting over non-waiting.
				if p.SenderWaiting && !bestPendingWaiting {
					better = true
				} else if !p.SenderWaiting && bestPendingWaiting {
					better = false
				} else if size < bestPendingSize {
					better = true
				} else if size == bestPendingSize && p.LastActivity > bestPendingActivity {
					better = true
				}
			} else if !better {
				if size < bestPendingSize {
					better = true
				} else if size == bestPendingSize && p.LastActivity > bestPendingActivity {
					better = true
				}
			}
			if better {
				bestPendingID = p.SessionID
				bestPendingWaiting = p.SenderWaiting
				bestPendingSize = size
				bestPendingActivity = p.LastActivity
			}
		}
		if requireUnique && matchCount > 1 {
			if trackConcreteIdentity && len(identityKeys) > 1 {
				identityKeys = normalizeMatchedIdentityKeys(ctx, client, identityKeys)
			}
			if !(trackConcreteIdentity && len(identityKeys) == 1) {
				return "", false, fmt.Errorf("multiple conversations match %s; run `aw chat pending` to choose one", targetAlias)
			}
		}
		if requireUniqueConcreteAlias && trackConcreteIdentity && len(identityKeys) > 1 {
			identityKeys = normalizeMatchedIdentityKeys(ctx, client, identityKeys)
		}
		if requireUniqueConcreteAlias && trackConcreteIdentity && len(identityKeys) > 1 {
			return "", false, fmt.Errorf("multiple conversations match %s; run `aw chat pending` to choose one", targetAlias)
		}
		if bestPendingID != "" {
			return bestPendingID, bestPendingWaiting, nil
		}
		return "", false, nil
	}
	bestPendingID, bestPendingWaiting, err := selectPending(exactParticipantMatch, requireUniqueExact, true)
	if err != nil {
		return "", false, err
	}
	if bestPendingID != "" {
		return bestPendingID, bestPendingWaiting, nil
	}
	if bareAliasTarget {
		bestPendingID, bestPendingWaiting, err = selectPending(uniqueHandleParticipantMatch, true, false)
		if err != nil {
			return "", false, err
		}
		if bestPendingID != "" {
			return bestPendingID, bestPendingWaiting, nil
		}
	}

	// Fallback to listing all sessions.
	sessionsResp, err := client.ChatListSessions(ctx)
	if err != nil {
		return "", false, fmt.Errorf("listing chat sessions: %w", err)
	}
	selectSession := func(match func([]string, []string, []string, string) bool, requireUnique bool, trackConcreteIdentity bool) (string, error) {
		var bestSessionID string
		var bestSessionActivity string
		bestSessionSize := -1
		matchCount := 0
		identityKeys := []string{}
		appendIdentityKey := func(value string) {
			value = strings.TrimSpace(value)
			if value == "" {
				return
			}
			for _, existing := range identityKeys {
				if strings.EqualFold(existing, value) {
					return
				}
			}
			identityKeys = append(identityKeys, value)
		}
		for _, s := range sessionsResp.Sessions {
			if !matchesSelectedTeam(s.TeamID) {
				continue
			}
			if !match(s.Participants, s.ParticipantDIDs, s.ParticipantAddresses, targetAlias) {
				continue
			}
			matchCount++
			if trackConcreteIdentity {
				for _, key := range matchedParticipantIdentityKeys(s.Participants, s.ParticipantDIDs, s.ParticipantAddresses, targetAlias) {
					appendIdentityKey(key)
				}
			}
			size := participantIdentityCount(s.Participants, s.ParticipantDIDs, s.ParticipantAddresses)
			better := bestSessionSize < 0
			if !better {
				if size < bestSessionSize {
					better = true
				} else if size == bestSessionSize && sessionActivity(s) > bestSessionActivity {
					better = true
				}
			}
			if better {
				bestSessionID = s.SessionID
				bestSessionSize = size
				bestSessionActivity = sessionActivity(s)
			}
		}
		if requireUnique && matchCount > 1 {
			if trackConcreteIdentity && len(identityKeys) > 1 {
				identityKeys = normalizeMatchedIdentityKeys(ctx, client, identityKeys)
			}
			if !(trackConcreteIdentity && len(identityKeys) == 1) {
				return "", fmt.Errorf("multiple conversations match %s; run `aw chat pending` to choose one", targetAlias)
			}
		}
		if requireUniqueConcreteAlias && trackConcreteIdentity && len(identityKeys) > 1 {
			identityKeys = normalizeMatchedIdentityKeys(ctx, client, identityKeys)
		}
		if requireUniqueConcreteAlias && trackConcreteIdentity && len(identityKeys) > 1 {
			return "", fmt.Errorf("multiple conversations match %s; run `aw chat pending` to choose one", targetAlias)
		}
		return bestSessionID, nil
	}
	bestSessionID, err := selectSession(exactParticipantMatch, requireUniqueExact, true)
	if err != nil {
		return "", false, err
	}
	if bestSessionID != "" {
		return bestSessionID, false, nil
	}
	if bareAliasTarget {
		bestSessionID, err = selectSession(uniqueHandleParticipantMatch, true, false)
		if err != nil {
			return "", false, err
		}
		if bestSessionID != "" {
			return bestSessionID, false, nil
		}
	}

	return "", false, fmt.Errorf("no conversation found with %s", targetAlias)
}

// buildMessages converts ChatMessage slice to Event slice.
func buildMessages(messages []awid.ChatMessage) []Event {
	events := make([]Event, len(messages))
	for i, m := range messages {
		events[i] = Event{
			Type:                    "message",
			MessageID:               m.MessageID,
			FromAgent:               m.FromAgent,
			FromAddress:             m.FromAddress,
			ToAddress:               m.ToAddress,
			Body:                    m.Body,
			Timestamp:               m.Timestamp,
			SenderLeaving:           m.SenderLeaving,
			ReplyToMessageID:        m.ReplyToMessageID,
			FromDID:                 m.FromDID,
			ToDID:                   m.ToDID,
			FromStableID:            m.FromStableID,
			ToStableID:              m.ToStableID,
			Signature:               m.Signature,
			SigningKeyID:            m.SigningKeyID,
			RotationAnnouncement:    m.RotationAnnouncement,
			ReplacementAnnouncement: m.ReplacementAnnouncement,
			VerificationStatus:      m.VerificationStatus,
			IsContact:               m.IsContact,
		}
	}
	return events
}

func markReadBestEffort(ctx context.Context, client *awid.Client, sessionID, messageID string) bool {
	if client == nil || sessionID == "" || strings.TrimSpace(messageID) == "" {
		return false
	}
	req := &awid.ChatMarkReadRequest{UpToMessageID: messageID}
	if _, err := client.ChatMarkRead(ctx, sessionID, req); err == nil {
		return true
	}
	timer := time.NewTimer(100 * time.Millisecond)
	defer timer.Stop()
	select {
	case <-ctx.Done():
		return false
	case <-timer.C:
	}
	_, err := client.ChatMarkRead(ctx, sessionID, req)
	return err == nil
}

// markLastRead marks the last received message as read (best-effort).
// This prevents the notify hook from showing messages that were already
// delivered via SSE during send-and-wait or listen.
func markLastRead(ctx context.Context, client *awid.Client, sessionID string, events []Event) {
	if sessionID == "" {
		return
	}
	for i := len(events) - 1; i >= 0; i-- {
		if events[i].Type == "message" && events[i].MessageID != "" {
			_ = markReadBestEffort(ctx, client, sessionID, events[i].MessageID)
			return
		}
	}
}

// streamOpener opens an SSE stream for a chat session.
// after controls replay: non-nil replays messages after that timestamp; nil skips replay.
type streamOpener func(ctx context.Context, sessionID string, deadline time.Time, after *time.Time) (*awid.SSEStream, error)

// messageAcceptor decides how to handle a received message event during the wait loop.
//
//	accept=true:  treat as the awaited reply
//	skip=true:    silently ignore (e.g., replayed own message)
//	both false:   unrelated message, continue waiting
type messageAcceptor func(ev Event) (accept, skip bool)

// waitForMessage opens an SSE stream and waits for a message matching the acceptor.
// Handles read receipts, extend-wait messages, and wait extensions.
// after controls SSE replay: non-nil replays messages after that timestamp; nil skips replay.
func waitForMessage(ctx context.Context, client *awid.Client, openStream streamOpener, sessionID string, participants []awid.ChatParticipant, selfAlias string, waitSeconds int, after *time.Time, callback StatusCallback, accept messageAcceptor) (*SendResult, error) {
	result := &SendResult{
		SessionID: sessionID,
		Status:    "timeout",
		Events:    []Event{},
	}

	waitTimeout := time.Duration(waitSeconds) * time.Second
	waitDeadline := time.Now().Add(waitTimeout)
	waitStart := time.Now()

	// The server deadline is a safety net for orphaned connections —
	// the local waitTimer manages actual wait semantics.
	stream, err := openStream(ctx, sessionID, time.Now().Add(maxStreamDeadline), after)
	if err != nil {
		if ctx.Err() != nil {
			return nil, ctx.Err()
		}
		if isCleanEOF(err) {
			if client != nil {
				_, _ = client.ChatHistory(ctx, awid.ChatHistoryParams{
					SessionID: sessionID,
					Limit:     1,
				})
			}
			result.WaitedSeconds = int(time.Since(waitStart).Seconds())
			return result, nil
		}
		return nil, fmt.Errorf("connecting to SSE: %w", err)
	}
	events, streamCleanup := streamToChannel(ctx, stream)
	defer streamCleanup()

	waitTimer := time.NewTimer(waitTimeout)
	defer func() {
		if !waitTimer.Stop() {
			select {
			case <-waitTimer.C:
			default:
			}
		}
	}()

	extendWait := func(extendsSeconds int, reason string) {
		if extendsSeconds <= 0 {
			return
		}
		if time.Now().After(waitDeadline) {
			waitDeadline = time.Now()
		}
		waitDeadline = waitDeadline.Add(time.Duration(extendsSeconds) * time.Second)

		if !waitTimer.Stop() {
			select {
			case <-waitTimer.C:
			default:
			}
		}
		waitTimer.Reset(time.Until(waitDeadline))

		if callback != nil {
			minutes := extendsSeconds / 60
			if minutes > 0 {
				callback("wait_extended", fmt.Sprintf("wait extended by %d min (%s)", minutes, reason))
			} else {
				callback("wait_extended", fmt.Sprintf("wait extended by %ds (%s)", extendsSeconds, reason))
			}
		}
	}

	for {
		select {
		case <-ctx.Done():
			return nil, ctx.Err()
		case <-waitTimer.C:
			result.WaitedSeconds = int(time.Since(waitStart).Seconds())
			return result, nil
		case sr, ok := <-events:
			if !ok || sr.err != nil {
				result.WaitedSeconds = int(time.Since(waitStart).Seconds())
				return result, nil
			}

			chatEvent := parseSSEEvent(sr.event)
			tofuFrom := chatEventTrustAddress(chatEvent, participants)
			chatEvent.VerificationStatus, chatEvent.IsContact = client.NormalizeSenderTrust(ctx, chatEvent.VerificationStatus, tofuFrom, chatEvent.FromDID, chatEvent.FromStableID, chatEvent.RotationAnnouncement, chatEvent.ReplacementAnnouncement, chatEvent.IsContact)
			chatEvent.VerificationStatus = client.NormalizeRecipientBinding(chatEvent.VerificationStatus, chatEvent.ToDID, chatEvent.ToStableID)

			if chatEvent.Type == "read_receipt" {
				readerLabel := inferReadReceiptLabel(ctx, client, selfAlias, chatEvent.ReaderAlias, participants)
				if readerLabel != "" {
					chatEvent.ReaderAlias = readerLabel
				}
				result.Events = append(result.Events, chatEvent)
				if callback != nil {
					callback("read_receipt", fmt.Sprintf("%s opened the conversation", chatEvent.ReaderAlias))
				}
				if chatEvent.ExtendsWaitSeconds > 0 {
					extendWait(chatEvent.ExtendsWaitSeconds, fmt.Sprintf("%s opened the conversation", chatEvent.ReaderAlias))
				}
				continue
			}

			if chatEvent.Type == "message" {
				accepted, skip := accept(chatEvent)
				if skip {
					continue
				}

				result.Events = append(result.Events, chatEvent)

				if !accepted {
					continue
				}

				if chatEvent.ExtendWait {
					from := chatEventSenderLabel(chatEvent, participants)
					if callback != nil {
						callback("extend_wait", fmt.Sprintf("%s: %s", from, chatEvent.Body))
					}
					if chatEvent.ExtendsWaitSeconds > 0 {
						extendWait(chatEvent.ExtendsWaitSeconds, fmt.Sprintf("%s requested more time", from))
					}
					continue
				}

				result.SenderWaiting = chatEvent.SenderWaiting

				if chatEvent.SenderLeaving {
					result.Status = "sender_left"
					result.Reply = chatEvent.Body
					return result, nil
				}

				result.Status = "replied"
				result.Reply = chatEvent.Body
				return result, nil
			}
		}
	}
}

func isCleanEOF(err error) bool {
	if err == nil {
		return false
	}
	return errors.Is(err, io.EOF)
}

// sendResponse normalizes the response from ChatCreateSession or NetworkCreateChat.
type sendResponse struct {
	SessionID        string
	MessageID        string
	Participants     []awid.ChatParticipant
	TargetsConnected []string
	TargetsLeft      []string
}

// Send sends a message to target agents and optionally waits for a reply.
//
// Wait logic:
//   - opts.Leaving: send with leaving=true, exit immediately
//   - opts.Wait == 0: send, return immediately
//   - opts.StartConversation: ignore targets_left, use 5min wait unless WaitExplicit
//   - default: send, if all targets in targets_left → skip wait; else wait opts.Wait seconds
func Send(ctx context.Context, client *awid.Client, myAlias string, targets []string, message string, opts SendOptions, callback StatusCallback) (*SendResult, error) {
	sentAt := time.Now()

	// Compute the actual wait duration so the server can track it.
	waitSeconds := opts.Wait
	if opts.StartConversation && !opts.WaitExplicit && !opts.Leaving {
		waitSeconds = 300
	}

	aliases, dids, addresses := classifyChatTargets(targets)
	req := &awid.ChatCreateSessionRequest{
		ToAliases:   aliases,
		ToDIDs:      dids,
		ToAddresses: addresses,
		Message:     message,
		Leaving:     opts.Leaving,
	}
	if waitSeconds > 0 {
		req.WaitSeconds = &waitSeconds
	}
	if len(targets) == 1 && !opts.StartConversation && shouldProbeExistingSession(targets[0]) {
		if sessionID, _, findErr := findLatestSession(ctx, client, targets[0]); findErr == nil && sessionID != "" {
			msgResp, err := client.ChatSendMessage(ctx, sessionID, &awid.ChatSendMessageRequest{
				Body:    message,
				Leaving: opts.Leaving,
			})
			if err != nil {
				return nil, fmt.Errorf("sending message: %w", err)
			}
			return sendCommon(ctx, client, client.ChatStream, sendResponse{
				SessionID: sessionID,
				MessageID: msgResp.MessageID,
			}, myAlias, targets, message, waitSeconds, opts, &sentAt, callback)
		} else if findErr != nil &&
			strings.Contains(findErr.Error(), "multiple conversations match") {
			return nil, findErr
		}
	}
	createResp, err := client.ChatCreateSession(ctx, req)
	if err != nil {
		return nil, fmt.Errorf("sending message: %w", err)
	}

	return sendCommon(ctx, client, client.ChatStream, sendResponse{
		SessionID:        createResp.SessionID,
		MessageID:        createResp.MessageID,
		Participants:     createResp.Participants,
		TargetsConnected: createResp.TargetsConnected,
		TargetsLeft:      createResp.TargetsLeft,
	}, myAlias, targets, message, waitSeconds, opts, &sentAt, callback)
}

func shouldProbeExistingSession(target string) bool {
	// Probe for any non-empty target. The previous narrow gate (did:/-prefixed
	// or address-shaped only) skipped bare aliases, which made the CLI fall
	// through to ChatCreateSession with a fresh auto-generated session_id
	// (cli/go/awid/chat.go:159 generates UUID4 when signingKey is set). With
	// the post-aame server-side 'one active 1:1' dedup, that fresh UUID
	// conflicts with the existing session and 409s. Probing for bare aliases
	// lets findLatestSession discover the existing session_id, route via
	// ChatSendMessage on the per-session endpoint, and bypass the conflict
	// entirely. Ambiguous targets are surfaced by findLatestSession as
	// 'multiple conversations match' and propagate up.
	return strings.TrimSpace(target) != ""
}

// sendCommon handles the post-send wait logic after a message has been created.
// resolvedWait is the actual wait duration in seconds, already accounting for
// StartConversation upgrades. This must match what was sent to the server.
func sendCommon(ctx context.Context, client *awid.Client, openStream streamOpener, resp sendResponse, myAlias string, targets []string, message string, resolvedWait int, opts SendOptions, after *time.Time, callback StatusCallback) (*SendResult, error) {
	result := &SendResult{
		SessionID:   resp.SessionID,
		Status:      "sent",
		TargetAgent: strings.Join(targets, ", "),
		Events:      []Event{},
	}
	targetStatusNames := make([][]string, 0, len(targets))
	for _, target := range targets {
		targetStatusNames = append(targetStatusNames, normalizedChatTargetNames(ctx, client, target, resp.Participants))
	}

	if opts.Leaving {
		return result, nil
	}

	if opts.Wait == 0 {
		return result, nil
	}

	// Check if any target has left
	targetHasLeft := false
	for _, leftAlias := range resp.TargetsLeft {
		leftNames := normalizedChatTargetNames(ctx, client, leftAlias, resp.Participants)
		for _, targetNames := range targetStatusNames {
			if chatTargetNameListsOverlap(targetNames, leftNames) {
				targetHasLeft = true
				break
			}
		}
		if targetHasLeft {
			break
		}
	}

	if targetHasLeft && !opts.StartConversation {
		result.Status = "targets_left"
		return result, nil
	}

	// Check target connection status (informational)
	allTargetsConnected := true
	for _, targetNames := range targetStatusNames {
		found := false
		for _, alias := range resp.TargetsConnected {
			connectedNames := normalizedChatTargetNames(ctx, client, alias, resp.Participants)
			if chatTargetNameListsOverlap(targetNames, connectedNames) {
				found = true
				break
			}
		}
		if !found {
			allTargetsConnected = false
			break
		}
	}
	if !allTargetsConnected {
		result.TargetNotConnected = true
	}

	// Build message acceptor: skip replays, accept only from targets.
	// The gate opens when we see our sent message by ID. If the server
	// didn't return a message ID (sentMessageID==""), the gate starts open.
	sentMessageID := resp.MessageID
	seenSentMessage := sentMessageID == ""
	acceptor := func(ev Event) (accept, skip bool) {
		if !seenSentMessage {
			if ev.MessageID == sentMessageID {
				seenSentMessage = true
			}
			return false, true
		}
		eventNames := normalizedChatEventNames(ev, resp.Participants)
		for _, targetNames := range targetStatusNames {
			if chatTargetNameListsOverlap(targetNames, eventNames) {
				return true, false
			}
		}
		return false, false
	}

	waitResult, err := waitForMessage(ctx, client, openStream, resp.SessionID, resp.Participants, myAlias, resolvedWait, after, callback, acceptor)
	if err != nil {
		return nil, err
	}

	markLastRead(ctx, client, resp.SessionID, waitResult.Events)

	result.Status = waitResult.Status
	result.Reply = waitResult.Reply
	result.Events = waitResult.Events
	result.SenderWaiting = waitResult.SenderWaiting
	result.WaitedSeconds = waitResult.WaitedSeconds
	return result, nil
}

// Listen waits for a message in an existing conversation without sending.
// Returns on any message in the session (not filtered by sender).
func Listen(ctx context.Context, client *awid.Client, targetAlias string, waitSeconds int, callback StatusCallback) (*SendResult, error) {
	sessionID, _, err := findSession(ctx, client, targetAlias)
	if err != nil {
		return nil, err
	}

	acceptAll := func(ev Event) (bool, bool) { return true, false }

	result, err := waitForMessage(ctx, client, client.ChatStream, sessionID, nil, "", waitSeconds, nil, callback, acceptAll)
	if err != nil {
		return nil, err
	}

	markLastRead(ctx, client, sessionID, result.Events)

	result.TargetAgent = targetAlias
	return result, nil
}

// Open fetches unread messages for a conversation and marks them as read.
func Open(ctx context.Context, client *awid.Client, targetAlias string) (*OpenResult, error) {
	sessionID, senderWaiting, err := findSession(ctx, client, targetAlias)
	if err != nil {
		return nil, err
	}

	messagesResp, err := client.ChatHistory(ctx, awid.ChatHistoryParams{
		SessionID:  sessionID,
		UnreadOnly: true,
		Limit:      1000,
	})
	if err != nil {
		return nil, fmt.Errorf("getting unread messages: %w", err)
	}

	result := &OpenResult{
		SessionID:     sessionID,
		TargetAgent:   targetAlias,
		SenderWaiting: senderWaiting,
	}

	if len(messagesResp.Messages) == 0 {
		result.UnreadWasEmpty = true
		return result, nil
	}

	filteredMessages := FilterDeliveredMessages(messagesResp.Messages)
	result.Messages = buildMessages(filteredMessages)

	if ids := DeliveredMessageIDs(messagesResp.Messages); len(ids) > 0 {
		_ = SaveDeliveredIDs(ids)
	}

	lastMessageID := messagesResp.Messages[len(messagesResp.Messages)-1].MessageID
	if markReadBestEffort(ctx, client, sessionID, lastMessageID) {
		result.MarkedRead = len(messagesResp.Messages)
	}
	if len(result.Messages) == 0 {
		result.UnreadWasEmpty = true
	}

	return result, nil
}

// History fetches all messages in a conversation.
func History(ctx context.Context, client *awid.Client, targetAlias string) (*HistoryResult, error) {
	sessionID, _, err := findLatestSession(ctx, client, targetAlias)
	if err != nil {
		return nil, err
	}

	messagesResp, err := client.ChatHistory(ctx, awid.ChatHistoryParams{
		SessionID: sessionID,
		Limit:     1000,
	})
	if err != nil {
		return nil, fmt.Errorf("getting messages: %w", err)
	}

	return &HistoryResult{
		SessionID: sessionID,
		Messages:  buildMessages(messagesResp.Messages),
	}, nil
}

// Pending lists conversations with unread messages.
func Pending(ctx context.Context, client *awid.Client) (*PendingResult, error) {
	resp, err := client.ChatPending(ctx)
	if err != nil {
		return nil, fmt.Errorf("getting pending chats: %w", err)
	}

	result := &PendingResult{
		Pending:         make([]PendingConversation, 0, len(resp.Pending)),
		MessagesWaiting: resp.MessagesWaiting,
	}
	for _, p := range resp.Pending {
		mappedAddress, mappedStableID, mappedDID := pendingParticipantIdentityByLastFrom(
			p.Participants,
			p.ParticipantDIDs,
			p.ParticipantAddresses,
			p.LastFrom,
		)
		lastFromAddress := strings.TrimSpace(p.LastFromAddress)
		if lastFromAddress == "" {
			lastFromAddress = mappedAddress
		}
		lastFromStableID := strings.TrimSpace(p.LastFromStableID)
		lastFromDID := strings.TrimSpace(p.LastFromDID)
		if lastFromStableID == "" && lastFromDID == "" {
			lastFromStableID = mappedStableID
			lastFromDID = mappedDID
		}
		result.Pending = append(result.Pending, PendingConversation{
			SessionID:            p.SessionID,
			Participants:         p.Participants,
			ParticipantDIDs:      p.ParticipantDIDs,
			ParticipantAddresses: p.ParticipantAddresses,
			LastMessage:          p.LastMessage,
			LastFrom:             p.LastFrom,
			LastFromStableID:     lastFromStableID,
			LastFromDID:          lastFromDID,
			LastFromAddress:      lastFromAddress,
			UnreadCount:          p.UnreadCount,
			LastActivity:         p.LastActivity,
			SenderWaiting:        p.SenderWaiting,
			TimeRemainingSeconds: p.TimeRemainingSeconds,
		})
	}

	return result, nil
}

// ExtendWait sends an extend-wait message requesting more time to reply.
func ExtendWait(ctx context.Context, client *awid.Client, targetAlias string, message string) (*ExtendWaitResult, error) {
	sessionID, _, err := findSession(ctx, client, targetAlias)
	if err != nil {
		return nil, err
	}

	msgResp, err := client.ChatSendMessage(ctx, sessionID, &awid.ChatSendMessageRequest{
		Body:       message,
		ExtendWait: true,
	})
	if err != nil {
		return nil, fmt.Errorf("sending extend-wait message: %w", err)
	}

	return &ExtendWaitResult{
		SessionID:          sessionID,
		TargetAgent:        targetAlias,
		Message:            message,
		ExtendsWaitSeconds: msgResp.ExtendsWaitSeconds,
	}, nil
}

// ShowPending shows the pending conversation with a specific agent.
func ShowPending(ctx context.Context, client *awid.Client, targetAlias string) (*SendResult, error) {
	sessionID, _, err := findSession(ctx, client, targetAlias)
	if err != nil {
		return nil, err
	}

	pendingResp, err := client.ChatPending(ctx)
	if err != nil {
		return nil, fmt.Errorf("getting pending chats: %w", err)
	}

	for _, p := range pendingResp.Pending {
		if p.SessionID != sessionID {
			continue
		}
		mappedAddress, mappedStableID, mappedDID := pendingParticipantIdentityByLastFrom(
			p.Participants,
			p.ParticipantDIDs,
			p.ParticipantAddresses,
			p.LastFrom,
		)
		fromAddress := strings.TrimSpace(p.LastFromAddress)
		if fromAddress == "" {
			fromAddress = mappedAddress
		}
		if fromAddress == "" {
			unanimousAddress, _, _ := unanimousPendingParticipantIdentity(
				p.Participants,
				p.ParticipantDIDs,
				p.ParticipantAddresses,
			)
			fromAddress = unanimousAddress
		}
		fromStableID := ""
		fromDID := ""
		if value := strings.TrimSpace(p.LastFromStableID); value != "" {
			fromStableID = value
		}
		if fromStableID == "" && fromDID == "" {
			fromStableID = mappedStableID
			fromDID = mappedDID
		}
		if fromStableID == "" {
			if value := strings.TrimSpace(p.LastFromDID); value != "" {
				if strings.HasPrefix(value, "did:aw:") {
					fromStableID = value
				} else {
					fromDID = value
				}
			}
		}
		if fromStableID == "" {
			_, unanimousStableID, _ := unanimousPendingParticipantIdentity(
				p.Participants,
				p.ParticipantDIDs,
				p.ParticipantAddresses,
			)
			fromStableID = unanimousStableID
		}
		return &SendResult{
			SessionID:     p.SessionID,
			Status:        "pending",
			TargetAgent:   targetAlias,
			Reply:         p.LastMessage,
			SenderWaiting: p.SenderWaiting,
			Events: []Event{
				{
					Type:         "message",
					FromAgent:    p.LastFrom,
					FromAddress:  fromAddress,
					FromStableID: fromStableID,
					FromDID:      fromDID,
					Body:         p.LastMessage,
					Timestamp:    p.LastActivity,
				},
			},
		}, nil
	}

	return nil, fmt.Errorf("no pending conversation with %s", targetAlias)
}

func chatEventMatchesTarget(ev Event, target string) bool {
	target = strings.TrimSpace(target)
	if target == "" {
		return false
	}
	for _, candidate := range []string{
		strings.TrimSpace(ev.FromAgent),
		strings.TrimSpace(ev.FromAddress),
		strings.TrimSpace(ev.FromStableID),
		strings.TrimSpace(ev.FromDID),
	} {
		if candidate != "" && candidate == target {
			return true
		}
	}
	return false
}

func normalizedChatEventNames(ev Event, participants []awid.ChatParticipant) []string {
	names := []string{}
	appendUnique := func(value string) {
		value = strings.TrimSpace(value)
		if value == "" {
			return
		}
		for _, existing := range names {
			if existing == value {
				return
			}
		}
		names = append(names, value)
	}

	hasStrongIdentity := strings.TrimSpace(ev.FromAddress) != "" ||
		strings.TrimSpace(ev.FromStableID) != "" ||
		strings.TrimSpace(ev.FromDID) != ""

	for _, value := range []string{
		ev.FromAddress,
		ev.FromStableID,
		ev.FromDID,
		addressHandle(ev.FromAddress),
	} {
		appendUnique(value)
	}

	matchedParticipants := matchingChatParticipantsForEventIdentity(participants, ev)
	for _, match := range matchedParticipants {
		appendUnique(strings.TrimSpace(match.Alias))
		appendUnique(strings.TrimSpace(match.Address))
		appendUnique(strings.TrimSpace(match.DID))
	}
	if !hasStrongIdentity || len(participants) == 0 {
		appendUnique(ev.FromAgent)
	}

	return names
}

func normalizedChatTargetNames(ctx context.Context, client *awid.Client, target string, participants []awid.ChatParticipant) []string {
	names := []string{}
	appendUnique := func(value string) {
		value = strings.TrimSpace(value)
		if value == "" {
			return
		}
		for _, existing := range names {
			if existing == value {
				return
			}
		}
		names = append(names, value)
	}
	removeValue := func(value string) {
		value = strings.TrimSpace(value)
		if value == "" {
			return
		}
		filtered := names[:0]
		for _, existing := range names {
			if !strings.EqualFold(existing, value) {
				filtered = append(filtered, existing)
			}
		}
		names = filtered
	}
	appendResolved := func(identifier string) {
		identifier = strings.TrimSpace(identifier)
		if identifier == "" || client == nil {
			return
		}
		if !strings.HasPrefix(identifier, "did:") && !strings.Contains(identifier, "/") {
			return
		}
		identity, err := client.ResolveIdentity(ctx, identifier)
		if err != nil || identity == nil {
			return
		}
		appendUnique(strings.TrimSpace(identity.Address))
		appendUnique(strings.TrimSpace(identity.StableID))
		appendUnique(strings.TrimSpace(identity.DID))
	}

	appendUnique(target)
	normalized := normalizeSessionTarget(ctx, client, target)
	appendUnique(normalized)
	appendResolved(target)
	appendResolved(normalized)
	participantAliasIsUnique := func(alias string) bool {
		alias = strings.TrimSpace(alias)
		if alias == "" {
			return false
		}
		matches := 0
		for _, participant := range participants {
			if strings.EqualFold(strings.TrimSpace(participant.Alias), alias) {
				matches++
				if matches > 1 {
					return false
				}
			}
		}
		return matches == 1
	}
	appendParticipant := func(participant awid.ChatParticipant) {
		if alias := strings.TrimSpace(participant.Alias); participantAliasIsUnique(alias) {
			appendUnique(alias)
		}
		appendUnique(strings.TrimSpace(participant.Address))
		appendUnique(strings.TrimSpace(participant.DID))
	}
	matchedParticipants := []awid.ChatParticipant{}
	for _, participant := range participants {
		if chatParticipantMatchesSessionTarget(participant, target) || chatParticipantMatchesSessionTarget(participant, normalized) {
			matchedParticipants = append(matchedParticipants, participant)
		}
	}
	if len(matchedParticipants) == 1 {
		appendParticipant(matchedParticipants[0])
	} else if len(matchedParticipants) > 1 {
		if !strings.HasPrefix(strings.TrimSpace(target), "did:") && !strings.Contains(strings.TrimSpace(target), "/") {
			removeValue(target)
		}
		if normalized != target && !strings.HasPrefix(strings.TrimSpace(normalized), "did:") && !strings.Contains(strings.TrimSpace(normalized), "/") {
			removeValue(normalized)
		}
	} else if len(matchedParticipants) == 0 {
		handleMatches := []awid.ChatParticipant{}
		for _, candidate := range []string{addressHandle(target), addressHandle(normalized)} {
			if candidate == "" {
				continue
			}
			handleMatches = handleMatches[:0]
			for _, participant := range participants {
				for _, identity := range []string{
					strings.TrimSpace(participant.Alias),
					addressHandle(strings.TrimSpace(participant.Address)),
					stableAlias(strings.TrimSpace(participant.DID)),
				} {
					if identity != "" && strings.EqualFold(identity, candidate) {
						handleMatches = append(handleMatches, participant)
						break
					}
				}
			}
			if len(handleMatches) == 1 {
				appendParticipant(handleMatches[0])
				break
			}
		}
		if len(participants) == 0 {
			appendUnique(addressHandle(target))
			appendUnique(addressHandle(normalized))
		}
	}
	return names
}

func chatTargetNameListContains(candidates []string, value string) bool {
	value = strings.TrimSpace(value)
	if value == "" {
		return false
	}
	for _, candidate := range candidates {
		if candidate == value {
			return true
		}
	}
	return false
}

func chatTargetNameListsOverlap(left []string, right []string) bool {
	for _, candidate := range right {
		if chatTargetNameListContains(left, candidate) {
			return true
		}
	}
	return false
}

func chatEventSenderLabel(ev Event, participants []awid.ChatParticipant) string {
	for _, participant := range matchingChatParticipantsForEventIdentity(participants, ev) {
		if value := preferredChatIdentityLabel(
			strings.TrimSpace(participant.Alias),
			strings.TrimSpace(participant.Address),
			func() string {
				row := chatParticipantRowFromParticipant(participant)
				if row.StableID != "" {
					return row.StableID
				}
				return strings.TrimSpace(ev.FromStableID)
			}(),
			func() string {
				row := chatParticipantRowFromParticipant(participant)
				if row.DID != "" {
					return row.DID
				}
				return strings.TrimSpace(ev.FromDID)
			}(),
		); value != "" {
			return value
		}
	}
	if value := strings.TrimSpace(ev.FromAddress); value != "" {
		return value
	}
	if value := strings.TrimSpace(ev.FromStableID); value != "" {
		return value
	}
	if value := strings.TrimSpace(ev.FromDID); value != "" {
		return value
	}
	return strings.TrimSpace(ev.FromAgent)
}

func chatEventTrustAddress(ev Event, participants []awid.ChatParticipant) string {
	for _, participant := range matchingChatParticipantsForEventIdentity(participants, ev) {
		if value := strings.TrimSpace(participant.Address); value != "" {
			return value
		}
		if value := strings.TrimSpace(participant.Alias); value != "" {
			return value
		}
	}
	if value := strings.TrimSpace(ev.FromAddress); value != "" {
		return value
	}
	return strings.TrimSpace(ev.FromAgent)
}

func inferReadReceiptLabel(ctx context.Context, client *awid.Client, selfAlias string, readerAlias string, participants []awid.ChatParticipant) string {
	readerAlias = strings.TrimSpace(readerAlias)
	if readerAlias != "" {
		return readerAlias
	}
	candidates := []string{}
	appendUnique := func(value string) {
		value = strings.TrimSpace(value)
		if value == "" {
			return
		}
		for _, existing := range candidates {
			if existing == value {
				return
			}
		}
		candidates = append(candidates, value)
	}
	for _, participant := range participants {
		if chatParticipantMatchesSelf(participant, client, selfAlias) {
			continue
		}
		appendUnique(chatParticipantLabel(ctx, client, participant))
	}
	if len(candidates) == 1 {
		return candidates[0]
	}
	return ""
}

func chatParticipantMatchesSelf(participant awid.ChatParticipant, client *awid.Client, selfAlias string) bool {
	selfAlias = strings.TrimSpace(selfAlias)
	selfAddress := ""
	selfStableID := ""
	selfDID := ""
	if client != nil {
		selfAddress = strings.TrimSpace(client.Address())
		selfStableID = strings.TrimSpace(client.StableID())
		selfDID = strings.TrimSpace(client.DID())
	}
	row := chatParticipantRowFromParticipant(participant)
	return identityutil.MatchesSelfStrict(
		row.Alias,
		row.Address,
		row.StableID,
		row.DID,
		selfAlias,
		selfAddress,
		selfStableID,
		selfDID,
	)
}

func chatParticipantLabel(ctx context.Context, client *awid.Client, participant awid.ChatParticipant) string {
	row := chatParticipantRowFromParticipant(participant)
	if row.Address != "" {
		return row.Address
	}
	if row.StableID != "" {
		return row.StableID
	}
	if row.DID != "" && client != nil {
		if identity, err := client.ResolveIdentity(ctx, row.DID); err == nil && identity != nil {
			if value := strings.TrimSpace(identity.Address); value != "" {
				return value
			}
			if value := strings.TrimSpace(identity.StableID); value != "" {
				return value
			}
			if value := strings.TrimSpace(identity.DID); value != "" {
				return value
			}
		}
	}
	if row.DID != "" {
		return row.DID
	}
	return row.Alias
}

func chatParticipantMatchesTarget(participant awid.ChatParticipant, target string) bool {
	return chatParticipantRowFromParticipant(participant).matchesTarget(target)
}

func chatParticipantMatchesSessionTarget(participant awid.ChatParticipant, target string) bool {
	return chatParticipantRowFromParticipant(participant).matchesSessionTarget(target)
}

func matchingChatParticipantsForEventIdentity(participants []awid.ChatParticipant, ev Event) []awid.ChatParticipant {
	type candidateSpec struct {
		value  string
		strong bool
		kind   string
	}
	hasStrongIdentity := strings.TrimSpace(ev.FromAddress) != "" ||
		strings.TrimSpace(ev.FromStableID) != "" ||
		strings.TrimSpace(ev.FromDID) != ""
	matchParticipants := func(candidate candidateSpec) []awid.ChatParticipant {
		candidate.value = strings.TrimSpace(candidate.value)
		if candidate.value == "" {
			return nil
		}
		matches := []awid.ChatParticipant{}
		if candidate.strong {
			for _, participant := range participants {
				if chatParticipantRowFromParticipant(participant).matchesStrongIdentity(candidate.kind, candidate.value) {
					matches = append(matches, participant)
				}
			}
			return matches
		}
		for _, participant := range participants {
			if chatParticipantMatchesTarget(participant, candidate.value) {
				matches = append(matches, participant)
			}
		}
		return matches
	}

	candidates := []candidateSpec{
		{value: ev.FromAddress, strong: true, kind: "address"},
		{value: ev.FromStableID, strong: true, kind: "did"},
		{value: ev.FromDID, strong: true, kind: "did"},
	}
	if !hasStrongIdentity {
		candidates = append(candidates, candidateSpec{value: ev.FromAgent})
	}
	for _, candidate := range candidates {
		if matches := matchParticipants(candidate); len(matches) > 0 {
			return matches
		}
	}
	return nil
}
