package message

import (
	"context"
	"database/sql"
	"errors"
	"fmt"
	"strings"
	"time"

	"github.com/tidbcloud/AgentTeam/internal/core/domain"
	"github.com/tidbcloud/AgentTeam/internal/coredb"
)

// ScopeIDForThreadAccess returns the authoritative scope id for a resolved target.
func ScopeIDForThreadAccess(target ThreadAccess) string {
	if trimmed := strings.TrimSpace(target.ChannelID); trimmed != "" {
		return trimmed
	}
	return strings.TrimSpace(target.DMThreadID)
}

// EnsureScopeProjectionForThreadAccess returns the active authoritative scope for a
// resolved target. Scope creation is owned by the channel/DM/reply-thread write
// paths; this helper intentionally avoids rebuilding scope state from legacy
// topology tables.
func EnsureScopeProjectionForThreadAccess(ctx context.Context, execer coredb.QueryExecer, target ThreadAccess, _ string, _ time.Time) (ScopeRecord, error) {
	scopeID := ScopeIDForThreadAccess(target)
	if scopeID == "" {
		return ScopeRecord{}, invalidArgument("scope target is required")
	}

	record, err := NewScopeRepositories(execer).Scopes.LoadScope(ctx, scopeID)
	if err != nil {
		if errors.Is(err, ErrNotFound) {
			return ScopeRecord{}, ErrNotFound
		}
		return ScopeRecord{}, err
	}
	if record.ArchivedAt != nil || normalizedScopeStatus(record.Status) != "active" {
		return ScopeRecord{}, ErrNotFound
	}

	expectedType := string(domain.ThreadTypeChannel)
	if target.ReplyThread != nil {
		expectedType = "thread"
	} else if target.ThreadType == domain.ThreadTypeDM {
		expectedType = string(domain.ThreadTypeDM)
	}
	if strings.TrimSpace(record.Type) != expectedType {
		return ScopeRecord{}, fmt.Errorf("scope %s has type %q, expected %q", scopeID, record.Type, expectedType)
	}
	return record, nil
}

func ensureDMScopeProjectionForMembers(ctx context.Context, execer coredb.QueryExecer, workspaceID string, dmThreadID string, memberAID string, memberBID string, createdByMemberID string, now time.Time) (ScopeRecord, error) {
	trimmedDMThreadID := strings.TrimSpace(dmThreadID)
	trimmedWorkspaceID := strings.TrimSpace(workspaceID)
	if trimmedWorkspaceID == "" || trimmedDMThreadID == "" {
		return ScopeRecord{}, invalidArgument("dm scope workspace and id are required")
	}
	if now.IsZero() {
		now = time.Now().UTC()
	} else {
		now = now.UTC()
	}
	identityKey := fmt.Sprintf("dm:%s:%s:%s", trimmedWorkspaceID, strings.TrimSpace(memberAID), strings.TrimSpace(memberBID))

	record := ScopeRecord{
		ID:                trimmedDMThreadID,
		WorkspaceID:       trimmedWorkspaceID,
		Type:              string(domain.ThreadTypeDM),
		IdentityKey:       identityKey,
		Status:            "active",
		CreatedByMemberID: firstNonEmpty(strings.TrimSpace(createdByMemberID), strings.TrimSpace(memberAID)),
		CreatedAt:         now,
		UpdatedAt:         now,
	}
	repos := NewScopeRepositories(execer)
	if err := repos.Scopes.UpsertScope(ctx, record); err != nil {
		return ScopeRecord{}, err
	}
	resolvedScope, err := repos.Scopes.LoadActiveScopeByIdentityKey(ctx, trimmedWorkspaceID, string(domain.ThreadTypeDM), identityKey)
	if err != nil {
		if errors.Is(err, ErrNotFound) {
			return ScopeRecord{}, ErrNotFound
		}
		return ScopeRecord{}, fmt.Errorf("load dm scope projection: %w", err)
	}
	record.ID = resolvedScope.ID
	if err := EnsureScopeMembership(ctx, execer, record.ID, memberAID, "public", "", "auto-wake", 0, 0, record.CreatedAt); err != nil {
		return ScopeRecord{}, err
	}
	if err := EnsureScopeMembership(ctx, execer, record.ID, memberBID, "public", "", "auto-wake", 0, 0, record.CreatedAt); err != nil {
		return ScopeRecord{}, err
	}
	return record, nil
}

func ensureReplyThreadScopeProjectionForParent(ctx context.Context, execer coredb.QueryExecer, workspaceID string, threadChannelID string, parentScopeID string, parentMessageID string, shortID string, createdByMemberID string, now time.Time) (ScopeRecord, error) {
	trimmedThreadChannelID := strings.TrimSpace(threadChannelID)
	trimmedWorkspaceID := strings.TrimSpace(workspaceID)
	trimmedParentScopeID := strings.TrimSpace(parentScopeID)
	trimmedParentMessageID := strings.TrimSpace(parentMessageID)
	trimmedShortID := strings.TrimSpace(shortID)
	if trimmedThreadChannelID == "" || trimmedWorkspaceID == "" || trimmedParentScopeID == "" || trimmedParentMessageID == "" {
		return ScopeRecord{}, invalidArgument("reply-thread scope, workspace, parent scope, and root message are required")
	}
	if trimmedShortID == "" {
		var err error
		trimmedShortID, err = threadShortIDFromMessageID(trimmedParentMessageID)
		if err != nil {
			return ScopeRecord{}, err
		}
	}
	if now.IsZero() {
		now = time.Now().UTC()
	} else {
		now = now.UTC()
	}

	record := ScopeRecord{
		ID:                 trimmedThreadChannelID,
		WorkspaceID:        trimmedWorkspaceID,
		Type:               "thread",
		ParentScopeID:      trimmedParentScopeID,
		RootMessageID:      trimmedParentMessageID,
		RootMessageShortID: trimmedShortID,
		IdentityKey:        "thread:" + trimmedParentScopeID + ":" + trimmedParentMessageID,
		Status:             "active",
		CreatedByMemberID:  strings.TrimSpace(createdByMemberID),
		CreatedAt:          now,
		UpdatedAt:          now,
	}
	repos := NewScopeRepositories(execer)
	if err := repos.Scopes.UpsertScope(ctx, record); err != nil {
		return ScopeRecord{}, err
	}
	record, err := repos.Scopes.LoadActiveScopeByIdentityKey(ctx, trimmedWorkspaceID, "thread", record.IdentityKey)
	if err != nil {
		if errors.Is(err, ErrNotFound) {
			return ScopeRecord{}, ErrNotFound
		}
		return ScopeRecord{}, fmt.Errorf("load reply thread scope projection: %w", err)
	}
	threadSlug := "thread-" + trimmedShortID
	if err := repos.Scopes.UpsertChannelProfile(ctx, ScopeChannelProfileRecord{
		ScopeID:      record.ID,
		WorkspaceID:  record.WorkspaceID,
		Slug:         threadSlug,
		DisplayName:  threadSlug,
		Description:  "",
		Visibility:   "private",
		IsAllChannel: false,
		CreatedAt:    record.CreatedAt,
		UpdatedAt:    record.UpdatedAt,
	}); err != nil {
		return ScopeRecord{}, err
	}
	return record, nil
}

// ErrScopeParentMembershipInactive is returned by the channel reply-thread
// membership fence when the would-be child member has no active membership in the
// parent channel (e.g. the agent was removed from the channel). The child write
// is refused so a removed channel member cannot be revived on a reply thread,
// which would be a permission leak.
var ErrScopeParentMembershipInactive = errors.New("scope membership parent is not active in channel")

// EnsureScopeMembership records an active membership and notification state for
// an already-resolved scope member. It does not create members and therefore is
// not a hidden invitation path. For a reply-thread whose parent is a channel, the
// write goes through the shared channel-reply-thread fence (parent channel scope
// lock + parent-membership revalidation) so a member removed from the channel is
// not revived on its reply threads; DM/task/non-channel-parent scopes use the
// plain path with no extra locks.
func EnsureScopeMembership(ctx context.Context, execer coredb.QueryExecer, scopeID string, memberID string, visibility string, ownerMemberID string, notificationState string, lastReadSeq int64, lastReadGlobalSeq int64, createdAt time.Time) error {
	trimmedScopeID := strings.TrimSpace(scopeID)
	trimmedMemberID := strings.TrimSpace(memberID)
	if trimmedScopeID == "" || trimmedMemberID == "" {
		return nil
	}
	now := createdAt.UTC()
	if now.IsZero() {
		now = time.Now().UTC()
	}
	normalizedVisibility := firstNonEmpty(strings.TrimSpace(visibility), "public")
	trimmedOwnerMemberID := strings.TrimSpace(ownerMemberID)
	return ensureChannelReplyThreadMembership(ctx, execer, trimmedScopeID, trimmedMemberID, normalizedVisibility, trimmedOwnerMemberID, notificationState, lastReadSeq, lastReadGlobalSeq, now)
}

// ensureChannelReplyThreadMembership is the shared fence for child membership
// writes on a channel reply-thread. For any other scope (DM, task, channel
// itself, or a reply-thread whose parent is not a channel) it writes directly
// with no extra locks. For a channel reply-thread it serializes with channel
// member removal under a fixed lock order — parent channel scope FOR UPDATE,
// then parent scope_membership(member) revalidated active, then the child
// membership + notification — refusing (ErrScopeParentMembershipInactive) if the
// member is no longer active in the parent channel. The fence + child write run
// in one transaction: a caller-supplied Tx is reused; a TxBeginner gets a new tx;
// anything else fails closed.
func ensureChannelReplyThreadMembership(ctx context.Context, execer coredb.QueryExecer, scopeID string, memberID string, visibility string, ownerMemberID string, notificationState string, lastReadSeq int64, lastReadGlobalSeq int64, now time.Time) error {
	parentChannelID, fenced, err := detectChannelReplyThreadScope(ctx, execer, scopeID)
	if err != nil {
		return err
	}
	if !fenced {
		return writeScopeMembershipAndNotification(ctx, execer, scopeID, memberID, visibility, ownerMemberID, notificationState, lastReadSeq, lastReadGlobalSeq, now)
	}
	// The parent-membership fence applies only to human/agent members, whose
	// reply-thread access derives from a parent channel membership. Service (App)
	// targets carry their own direct-thread authority (target binding / installation)
	// and bypass the fence — they may legitimately be bound/mentioned on a channel
	// reply thread with no parent channel membership.
	memberType, err := loadMemberTypeByID(ctx, execer, memberID)
	if err != nil {
		return fmt.Errorf("load channel reply-thread member type: %w", err)
	}
	if memberType == domain.MemberTypeService {
		return writeScopeMembershipAndNotification(ctx, execer, scopeID, memberID, visibility, ownerMemberID, notificationState, lastReadSeq, lastReadGlobalSeq, now)
	}
	return runInMembershipTx(ctx, execer, func(tx coredb.Tx) error {
		if err := lockChannelScopeForUpdate(ctx, tx, parentChannelID); err != nil {
			return err
		}
		if err := requireActiveChannelMembership(ctx, tx, parentChannelID, memberID); err != nil {
			return err
		}
		return writeScopeMembershipAndNotification(ctx, tx, scopeID, memberID, visibility, ownerMemberID, notificationState, lastReadSeq, lastReadGlobalSeq, now)
	})
}

// detectChannelReplyThreadScope reports whether scopeID is a reply-thread whose
// parent is a channel, returning the parent channel id. A missing scope is
// treated as "not fenced" so the downstream write surfaces the real error.
func detectChannelReplyThreadScope(ctx context.Context, queryer coredb.Queryer, scopeID string) (string, bool, error) {
	var scopeType, parentType string
	var parentID sql.NullString
	if err := queryer.QueryRow(ctx, `
SELECT scope.type,
       scope.parent_scope_id,
       COALESCE(parent.type, '')
FROM scopes scope
LEFT JOIN scopes parent
  ON parent.id = scope.parent_scope_id
WHERE scope.id = $1
`, scopeID).Scan(&scopeType, &parentID, &parentType); err != nil {
		if errors.Is(err, sql.ErrNoRows) {
			return "", false, nil
		}
		return "", false, fmt.Errorf("detect channel reply-thread scope: %w", err)
	}
	if scopeType == "thread" && parentType == "channel" && parentID.Valid {
		if parentChannelID := strings.TrimSpace(parentID.String); parentChannelID != "" {
			return parentChannelID, true, nil
		}
	}
	return "", false, nil
}

// runInMembershipTx runs fn in a transaction, reusing a caller-supplied Tx or
// using the standard coredb.WithTx lifecycle for a TxBeginner (begin/fn/commit
// with rollback on error or panic). It fails closed if execer has no transaction
// capability.
func runInMembershipTx(ctx context.Context, execer coredb.QueryExecer, fn func(coredb.Tx) error) error {
	if tx, ok := execer.(coredb.Tx); ok {
		return fn(tx)
	}
	if beginner, ok := execer.(coredb.TxBeginner); ok {
		return coredb.WithTx(ctx, beginner, fn)
	}
	return fmt.Errorf("channel reply-thread membership fence requires transaction capability")
}

// withChannelReplyThreadFence runs fn under the channel reply-thread fence when
// scopeID is a reply-thread whose parent is a channel — parent channel scope lock
// + parent-membership revalidation in the SAME transaction as fn — and refuses
// (ErrScopeParentMembershipInactive) if the member is no longer active in the
// parent channel. Any other scope (DM/task/non-channel parent) runs fn directly
// with no extra locks. fn receives the execer to write through: the caller Tx
// when reused, or a new tx begun from a TxBeginner.
func withChannelReplyThreadFence(ctx context.Context, execer coredb.QueryExecer, scopeID string, memberID string, fn func(coredb.QueryExecer) error) error {
	parentChannelID, fenced, err := detectChannelReplyThreadScope(ctx, execer, scopeID)
	if err != nil {
		return err
	}
	if !fenced {
		return fn(execer)
	}
	return runInMembershipTx(ctx, execer, func(tx coredb.Tx) error {
		if err := lockChannelScopeForUpdate(ctx, tx, parentChannelID); err != nil {
			return err
		}
		if err := requireActiveChannelMembership(ctx, tx, parentChannelID, memberID); err != nil {
			return err
		}
		return fn(tx)
	})
}

// lockChannelScopeForUpdate takes the parent channel scope fence, the same row
// locked by channel member removal, so a remover and a reply-thread writer
// serialize on it.
func lockChannelScopeForUpdate(ctx context.Context, tx coredb.Tx, channelID string) error {
	var exists int
	if err := tx.QueryRow(ctx, `
SELECT 1
FROM scopes
WHERE id = $1
  AND type = 'channel'
  AND status = 'active'
  AND archived_at IS NULL
FOR UPDATE
`, channelID).Scan(&exists); err != nil {
		if coredb.IsNotFound(err) {
			return ErrScopeParentMembershipInactive
		}
		return fmt.Errorf("lock parent channel scope for reply-thread membership: %w", err)
	}
	return nil
}

// requireActiveChannelMembership revalidates under the parent scope lock that the
// member still holds an active membership in the parent channel; a removed member
// (e.g. a retired agent) is refused so it cannot be revived on a reply thread.
func requireActiveChannelMembership(ctx context.Context, tx coredb.Tx, channelID string, memberID string) error {
	var exists int
	if err := tx.QueryRow(ctx, `
SELECT 1
FROM scope_memberships
WHERE scope_id = $1
  AND member_id = $2
  AND removed_at IS NULL
  AND archived_at IS NULL
  AND effective_to_seq IS NULL
LIMIT 1
FOR UPDATE
`, channelID, memberID).Scan(&exists); err != nil {
		if coredb.IsNotFound(err) {
			return ErrScopeParentMembershipInactive
		}
		return fmt.Errorf("revalidate parent channel membership: %w", err)
	}
	return nil
}

// writeScopeMembershipAndNotification records/revives the membership and its
// notification state for an already-resolved scope member. It is the shared core
// used by both the plain path and the channel-reply-thread fence.
func writeScopeMembershipAndNotification(ctx context.Context, execer coredb.QueryExecer, scopeID string, memberID string, visibility string, ownerMemberID string, notificationState string, lastReadSeq int64, lastReadGlobalSeq int64, now time.Time) error {
	repos := NewScopeRepositories(execer)
	openMembershipUpdate, err := execer.Exec(ctx, `
UPDATE scope_memberships
SET unread_generation = CASE
        WHEN visibility <> $3
          OR COALESCE(owner_member_id, '') <> $4
          OR GREATEST(last_read_seq, $5) <> last_read_seq
          OR GREATEST(last_read_global_seq, $6) <> last_read_global_seq
          OR removed_at IS NOT NULL
          OR archived_at IS NOT NULL THEN unread_generation + 1
        ELSE unread_generation
    END,
    visibility = $3,
    owner_member_id = NULLIF($4, ''),
    last_read_seq = GREATEST(last_read_seq, $5),
    last_read_global_seq = GREATEST(last_read_global_seq, $6),
    updated_at = CASE
        WHEN visibility <> $3
          OR COALESCE(owner_member_id, '') <> $4
          OR GREATEST(last_read_seq, $5) <> last_read_seq
          OR GREATEST(last_read_global_seq, $6) <> last_read_global_seq
          OR removed_at IS NOT NULL
          OR archived_at IS NOT NULL THEN $7
        ELSE updated_at
    END,
    authority_from_at = CASE
        WHEN removed_at IS NOT NULL OR archived_at IS NOT NULL OR effective_to_seq IS NOT NULL THEN LEAST($7, CURRENT_TIMESTAMP(6))
        ELSE COALESCE(authority_from_at, created_at)
    END,
    authority_to_at = CASE
        WHEN removed_at IS NOT NULL OR archived_at IS NOT NULL OR effective_to_seq IS NOT NULL THEN NULL
        ELSE authority_to_at
    END,
    removed_at = NULL,
    archived_at = NULL
WHERE scope_id = $1
  AND member_id = $2
  AND effective_to_seq IS NULL
`, scopeID, memberID, normalizedMembershipVisibility(visibility), ownerMemberID, lastReadSeq, lastReadGlobalSeq, now)
	if err != nil {
		return fmt.Errorf("update open scope membership: %w", err)
	}
	if openMembershipUpdate.RowsAffected() == 0 {
		var openMembershipExists int
		if err := execer.QueryRow(ctx, `
SELECT 1
FROM scope_memberships
WHERE scope_id = $1
  AND member_id = $2
  AND effective_to_seq IS NULL
LIMIT 1
`, scopeID, memberID).Scan(&openMembershipExists); err != nil && !errors.Is(err, sql.ErrNoRows) {
			return fmt.Errorf("check open scope membership after update: %w", err)
		}
		if openMembershipExists == 0 {
			if err := repos.Memberships.Upsert(ctx, ScopeMembershipRecord{
				ScopeID:           scopeID,
				MemberID:          memberID,
				Visibility:        visibility,
				OwnerMemberID:     ownerMemberID,
				LastReadSeq:       lastReadSeq,
				LastReadGlobalSeq: lastReadGlobalSeq,
				EffectiveFromSeq:  1,
				CreatedAt:         now,
				UpdatedAt:         now,
			}); err != nil {
				return err
			}
		}
	}
	state, err := scopeNotificationStateForMember(ctx, execer, memberID, notificationState)
	if err != nil {
		return err
	}
	if state != "" {
		openNotificationUpdate, err := execer.Exec(ctx, `
UPDATE scope_notification_settings
SET unread_generation = CASE
        WHEN state <> $3 THEN unread_generation + 1
        ELSE unread_generation
    END,
    updated_at = CASE
        WHEN state <> $3 THEN $4
        ELSE updated_at
    END,
    state = CASE
        WHEN state <> $3 THEN $3
        ELSE state
    END
WHERE scope_id = $1
  AND member_id = $2
`, scopeID, memberID, state, now)
		if err != nil {
			return fmt.Errorf("update open scope notification setting: %w", err)
		}
		if openNotificationUpdate.RowsAffected() != 0 {
			return nil
		}
		var openNotificationExists int
		if err := execer.QueryRow(ctx, `
SELECT 1
FROM scope_notification_settings
WHERE scope_id = $1
  AND member_id = $2
LIMIT 1
`, scopeID, memberID).Scan(&openNotificationExists); err != nil && !errors.Is(err, sql.ErrNoRows) {
			return fmt.Errorf("check open scope notification setting after update: %w", err)
		}
		if openNotificationExists == 0 {
			if err := repos.Notifications.Upsert(ctx, ScopeNotificationSettingRecord{
				ScopeID:   scopeID,
				MemberID:  memberID,
				State:     state,
				CreatedAt: now,
				UpdatedAt: now,
			}); err != nil {
				return err
			}
		}
	}
	return nil
}

func scopeNotificationStateForMember(ctx context.Context, queryer coredb.QueryRower, memberID string, state string) (string, error) {
	trimmedState := strings.TrimSpace(state)
	if trimmedState == "" {
		return "", nil
	}
	if trimmedState != "auto-wake" && trimmedState != "no-wake" {
		return trimmedState, nil
	}

	var memberType string
	if err := queryer.QueryRow(ctx, `
SELECT type
FROM members
WHERE id = $1
`, strings.TrimSpace(memberID)).Scan(&memberType); err != nil {
		return "", fmt.Errorf("load scope notification member type: %w", err)
	}
	if strings.TrimSpace(memberType) == string(domain.MemberTypeAgent) {
		return trimmedState, nil
	}
	return "active", nil
}
