package access

import (
	"context"
	"errors"
	"fmt"
	"strings"

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

var (
	ErrForbidden = errors.New("forbidden")
	ErrNotFound  = errors.New("not found")
)

// HumanWorkspaceMember is the core-owned workspace membership lookup for one user.
type HumanWorkspaceMember struct {
	Member        model.Member
	WorkspaceRole string
}

// HumanChannelAccess is the core-owned lookup for one human user against one
// workspace channel.
type HumanChannelAccess struct {
	WorkspaceMember HumanWorkspaceMember
	Channel         Channel
	Joined          bool
	CanInvite       bool
	IsOwner         bool
}

// HumanDMAccess is the core-owned lookup for one human user against one DM.
type HumanDMAccess struct {
	WorkspaceMember HumanWorkspaceMember
	DMThreadID      string
	ThreadID        string
	PeerMemberID    string
	Participant     bool
}

// HumanThreadAccess is the core-owned lookup for one human user against one
// reply-thread channel.
type HumanThreadAccess struct {
	WorkspaceMember     HumanWorkspaceMember
	Channel             Channel
	ParentThreadType    domain.ThreadType
	ParentChannelID     string
	ParentChannelName   string
	ParentMessageID     string
	ParentMessageShort  string
	PeerMemberID        string
	ParentChannelJoined bool
	ParentDMParticipant bool
}

// HumanMachineAccess is the core-owned lookup for one human user against one
// machine in a workspace.
type HumanMachineAccess struct {
	WorkspaceMember HumanWorkspaceMember
	Machine         Machine
}

// HumanAgentAccess is the core-owned lookup for one human user against one
// agent in a workspace.
type HumanAgentAccess struct {
	WorkspaceMember HumanWorkspaceMember
	Agent           Agent
}

// VisibleThread is one message thread currently visible to a workspace member.
type VisibleThread struct {
	ThreadID         string
	ChannelID        string
	DMThreadID       string
	ParentChannelID  string
	ParentDMThreadID string
}

// Channel is the core-owned channel lookup used by compatibility adapters.
type Channel struct {
	ChannelID       string
	ThreadID        string
	Type            string
	Slug            string
	DisplayName     string
	Description     string
	Visibility      string
	Subsystem       string
	OwnerMemberID   *string
	TeamID          string
	TeamHandle      string
	TeamDisplayName string
}

// Machine is the core-owned runtime machine lookup used by compatibility adapters.
type Machine struct {
	MachineID string
}

// Agent is the core-owned runtime agent lookup used by compatibility adapters.
type Agent struct {
	AgentID   string
	MachineID string
	MemberID  string
}

func LoadHumanWorkspaceMember(ctx context.Context, queryer coredb.QueryRower, userID string, workspaceID string) (HumanWorkspaceMember, error) {
	return loadHumanWorkspaceMember(ctx, queryer, userID, workspaceID, false)
}

func LoadHumanWorkspaceMemberForUpdate(ctx context.Context, queryer coredb.QueryRower, userID string, workspaceID string) (HumanWorkspaceMember, error) {
	return loadHumanWorkspaceMember(ctx, queryer, userID, workspaceID, true)
}

func loadHumanWorkspaceMember(ctx context.Context, queryer coredb.QueryRower, userID string, workspaceID string, forUpdate bool) (HumanWorkspaceMember, error) {
	query := `
SELECT id, display_name, user_id, workspace_role
FROM members
WHERE user_id = $1 AND workspace_id = $2 AND type = 'human' AND status = 'active'
`
	if forUpdate {
		query += "FOR UPDATE\n"
	}
	row := queryer.QueryRow(ctx, query, userID, workspaceID)

	var membership HumanWorkspaceMember
	var workspaceRole string
	var memberUserID string
	if err := row.Scan(&membership.Member.ID, &membership.Member.DisplayName, &memberUserID, &workspaceRole); err != nil {
		if coredb.IsNotFound(err) {
			return HumanWorkspaceMember{}, ErrForbidden
		}
		return HumanWorkspaceMember{}, fmt.Errorf("load human workspace member: %w", err)
	}

	membership.Member.WorkspaceID = workspaceID
	membership.Member.Type = domain.MemberTypeHuman
	membership.Member.UserID = &memberUserID
	membership.WorkspaceRole = workspaceRole
	role := domain.WorkspaceRole(workspaceRole)
	membership.Member.WorkspaceRole = &role

	return membership, nil
}

func scanChannel(row coredb.Row, errorPrefix string) (Channel, error) {
	var channel Channel
	if err := row.Scan(
		&channel.ChannelID,
		&channel.ThreadID,
		&channel.Type,
		&channel.Slug,
		&channel.DisplayName,
		&channel.Description,
		&channel.Visibility,
		&channel.Subsystem,
		&channel.OwnerMemberID,
	); err != nil {
		if coredb.IsNotFound(err) {
			return Channel{}, ErrNotFound
		}
		return Channel{}, fmt.Errorf("%s: %w", errorPrefix, err)
	}
	return channel, nil
}

func LookupChannel(ctx context.Context, queryer coredb.QueryRower, workspaceID string, channelID string) (Channel, error) {
	row := queryer.QueryRow(ctx, `
SELECT
    s.id,
    s.id,
    s.type,
    COALESCE(NULLIF(p.slug, ''), CONCAT('thread-', COALESCE(s.root_message_short_id, s.id))),
    COALESCE(NULLIF(p.display_name, ''), NULLIF(p.slug, ''), CONCAT('thread-', COALESCE(s.root_message_short_id, s.id))),
    COALESCE(p.description, ''),
    COALESCE(p.visibility, 'private'),
    COALESCE(NULLIF(p.subsystem, ''), 'im'),
    p.owner_member_id
FROM scopes s
LEFT JOIN scope_channel_profiles p ON p.scope_id = s.id
WHERE s.workspace_id = $1
  AND s.id = $2
  AND s.type IN ('channel', 'thread')
  AND s.status = 'active'
  AND (s.type = 'thread' OR p.scope_id IS NOT NULL)
`, workspaceID, channelID)

	channel, err := scanChannel(row, "load channel scope")
	if err != nil {
		return Channel{}, err
	}
	if err := annotateChannelTeam(ctx, queryer, workspaceID, &channel); err != nil {
		return Channel{}, err
	}
	return channel, nil
}

// ResolveAccessibleChannelForMember resolves one joined workspace channel by slug
// for any member actor. Missing or unjoined channels intentionally collapse to
// ErrForbidden so compat callers do not learn channel existence from access
// errors alone.
func ResolveAccessibleChannelForMember(ctx context.Context, queryer coredb.QueryRower, workspaceID string, memberID string, channelSlug string) (Channel, error) {
	trimmedWorkspaceID := strings.TrimSpace(workspaceID)
	if trimmedWorkspaceID == "" {
		return Channel{}, fmt.Errorf("workspace id is required")
	}
	trimmedMemberID := strings.TrimSpace(memberID)
	if trimmedMemberID == "" {
		return Channel{}, fmt.Errorf("member id is required")
	}
	trimmedChannelSlug := strings.TrimSpace(channelSlug)
	if trimmedChannelSlug == "" {
		return Channel{}, fmt.Errorf("channel slug is required")
	}

	row := queryer.QueryRow(ctx, `
SELECT
    s.id,
    s.id,
    s.type,
    p.slug,
    COALESCE(NULLIF(p.display_name, ''), p.slug),
    COALESCE(p.description, ''),
    COALESCE(p.visibility, 'private'),
    COALESCE(NULLIF(p.subsystem, ''), 'im'),
    p.owner_member_id
FROM scopes s
JOIN scope_channel_profiles p ON p.scope_id = s.id
JOIN scope_memberships sm
  ON sm.scope_id = s.id
 AND sm.member_id = $2
 AND sm.removed_at IS NULL
 AND sm.effective_to_seq IS NULL
 AND sm.visibility = 'public'
WHERE s.workspace_id = $1
  AND s.type = 'channel'
  AND s.status = 'active' AND s.archived_at IS NULL
  AND p.slug = $3
`, trimmedWorkspaceID, trimmedMemberID, trimmedChannelSlug)

	channel, err := scanChannel(row, "resolve accessible channel scope membership")
	if errors.Is(err, ErrNotFound) {
		return Channel{}, ErrForbidden
	}
	if err != nil {
		return Channel{}, err
	}
	if err := annotateChannelTeam(ctx, queryer, trimmedWorkspaceID, &channel); err != nil {
		return Channel{}, err
	}
	return channel, nil
}

func annotateChannelTeam(ctx context.Context, queryer coredb.QueryRower, workspaceID string, channel *Channel) error {
	if channel == nil || strings.TrimSpace(channel.ChannelID) == "" {
		return nil
	}
	var teamID, handle, displayName string
	err := queryer.QueryRow(ctx, `
SELECT id, handle, display_name
FROM workspace_teams
WHERE workspace_id = $1
  AND channel_id = $2
  AND status = 'active'
LIMIT 1
`, strings.TrimSpace(workspaceID), strings.TrimSpace(channel.ChannelID)).Scan(&teamID, &handle, &displayName)
	if err != nil {
		if coredb.IsNotFound(err) {
			return nil
		}
		return fmt.Errorf("load channel team owner: %w", err)
	}
	channel.TeamID = strings.TrimSpace(teamID)
	channel.TeamHandle = strings.TrimSpace(handle)
	channel.TeamDisplayName = strings.TrimSpace(displayName)
	return nil
}

func LoadHumanChannelAccess(ctx context.Context, queryer coredb.QueryRower, userID string, workspaceID string, channelID string) (HumanChannelAccess, error) {
	workspaceMember, err := LoadHumanWorkspaceMember(ctx, queryer, userID, workspaceID)
	if err != nil {
		return HumanChannelAccess{}, err
	}

	channel, err := LookupChannel(ctx, queryer, workspaceID, channelID)
	if err != nil {
		return HumanChannelAccess{}, err
	}

	joined, err := IsPublicMemberInChannel(ctx, queryer, workspaceMember.Member.ID, channelID)
	if err != nil {
		return HumanChannelAccess{}, err
	}
	if channel.Slug != "all" && channel.Visibility == "private" && channel.OwnerMemberID == nil && strings.TrimSpace(channel.TeamID) == "" {
		return HumanChannelAccess{}, ErrNotFound
	}

	teamManaged := strings.TrimSpace(channel.TeamID) != ""
	isOwner := joined && !teamManaged && channel.OwnerMemberID != nil && *channel.OwnerMemberID == workspaceMember.Member.ID

	return HumanChannelAccess{
		WorkspaceMember: workspaceMember,
		Channel:         channel,
		Joined:          joined,
		CanInvite:       joined && !teamManaged,
		IsOwner:         isOwner,
	}, nil
}

func LoadHumanDMAccess(ctx context.Context, queryer coredb.QueryRower, userID string, workspaceID string, dmThreadID string) (HumanDMAccess, error) {
	workspaceMember, err := LoadHumanWorkspaceMember(ctx, queryer, userID, workspaceID)
	if err != nil {
		return HumanDMAccess{}, err
	}
	return loadScopeDMAccessForWorkspaceMember(ctx, queryer, workspaceMember, workspaceID, dmThreadID)
}

func LoadHumanThreadAccess(ctx context.Context, queryer coredb.QueryRower, userID string, workspaceID string, threadChannelID string) (HumanThreadAccess, error) {
	workspaceMember, err := LoadHumanWorkspaceMember(ctx, queryer, userID, workspaceID)
	if err != nil {
		return HumanThreadAccess{}, err
	}

	row := queryer.QueryRow(ctx, `
SELECT
    thread_scope.id,
    thread_scope.id,
    thread_scope.type,
    COALESCE(NULLIF(thread_profile.slug, ''), CONCAT('thread-', COALESCE(thread_scope.root_message_short_id, thread_scope.id))),
    COALESCE(NULLIF(thread_profile.display_name, ''), NULLIF(thread_profile.slug, ''), CONCAT('thread-', COALESCE(thread_scope.root_message_short_id, thread_scope.id))),
    COALESCE(thread_profile.description, ''),
    COALESCE(thread_profile.visibility, 'private'),
    COALESCE(NULLIF(thread_profile.subsystem, ''), 'im'),
    thread_profile.owner_member_id,
    parent_scope.type,
    parent_scope.id,
    thread_scope.root_message_id,
    COALESCE(thread_scope.root_message_short_id, ''),
    COALESCE(parent_profile.slug, '')
FROM scopes thread_scope
JOIN scopes parent_scope
  ON parent_scope.id = thread_scope.parent_scope_id
 AND parent_scope.workspace_id = thread_scope.workspace_id
 AND parent_scope.status = 'active'
LEFT JOIN scope_channel_profiles thread_profile ON thread_profile.scope_id = thread_scope.id
LEFT JOIN scope_channel_profiles parent_profile ON parent_profile.scope_id = parent_scope.id
WHERE thread_scope.workspace_id = $1
  AND thread_scope.id = $2
  AND thread_scope.type = 'thread'
  AND thread_scope.status = 'active'
`, workspaceID, threadChannelID)

	access := HumanThreadAccess{WorkspaceMember: workspaceMember}
	var parentType string
	var parentScopeID string
	if err := row.Scan(
		&access.Channel.ChannelID,
		&access.Channel.ThreadID,
		&access.Channel.Type,
		&access.Channel.Slug,
		&access.Channel.DisplayName,
		&access.Channel.Description,
		&access.Channel.Visibility,
		&access.Channel.Subsystem,
		&access.Channel.OwnerMemberID,
		&parentType,
		&parentScopeID,
		&access.ParentMessageID,
		&access.ParentMessageShort,
		&access.ParentChannelName,
	); err != nil {
		if coredb.IsNotFound(err) {
			return HumanThreadAccess{}, ErrNotFound
		}
		return HumanThreadAccess{}, fmt.Errorf("load human thread scope access: %w", err)
	}

	switch parentType {
	case string(domain.ThreadTypeChannel):
		access.ParentThreadType = domain.ThreadTypeChannel
		access.ParentChannelID = strings.TrimSpace(parentScopeID)
		parentJoined, err := IsPublicMemberInChannel(ctx, queryer, workspaceMember.Member.ID, parentScopeID)
		if err != nil {
			return HumanThreadAccess{}, err
		}
		access.ParentChannelJoined = parentJoined
	case string(domain.ThreadTypeDM):
		childJoined, err := IsActiveMemberInScope(ctx, queryer, workspaceMember.Member.ID, threadChannelID)
		if err != nil {
			return HumanThreadAccess{}, err
		}
		access.ParentThreadType = domain.ThreadTypeDM
		// ParentChannelID is the canonical parent scope ID for a reply thread.
		// Despite the historical field name, callers use it for both channel
		// and DM parents when resolving visibility and exact Agent routing.
		access.ParentChannelID = strings.TrimSpace(parentScopeID)
		parentDMAccess, err := loadScopeDMAccessForWorkspaceMember(ctx, queryer, workspaceMember, workspaceID, parentScopeID)
		if err != nil {
			return HumanThreadAccess{}, err
		}
		access.ParentDMParticipant = childJoined || parentDMAccess.Participant
		access.PeerMemberID = parentDMAccess.PeerMemberID
	default:
		return HumanThreadAccess{}, fmt.Errorf("load human thread parent: invalid parent scope type %q", parentType)
	}

	return access, nil
}

func loadScopeDMAccessForWorkspaceMember(ctx context.Context, queryer coredb.QueryRower, workspaceMember HumanWorkspaceMember, workspaceID string, dmThreadID string) (HumanDMAccess, error) {
	row := queryer.QueryRow(ctx, `
SELECT
    COALESCE(self_sm.member_id, ''),
    COALESCE(peer_sm.member_id, '')
FROM scopes s
LEFT JOIN scope_memberships self_sm
  ON self_sm.scope_id = s.id
 AND self_sm.member_id = $3
 AND self_sm.removed_at IS NULL
 AND self_sm.effective_to_seq IS NULL
LEFT JOIN scope_memberships peer_sm
  ON peer_sm.scope_id = s.id
 AND peer_sm.member_id <> $3
 AND peer_sm.removed_at IS NULL
 AND peer_sm.effective_to_seq IS NULL
WHERE s.workspace_id = $1
  AND s.id = $2
  AND s.type = 'dm'
  AND s.status = 'active'
ORDER BY peer_sm.member_id ASC
LIMIT 1
`, workspaceID, dmThreadID, workspaceMember.Member.ID)

	access := HumanDMAccess{
		WorkspaceMember: workspaceMember,
		DMThreadID:      strings.TrimSpace(dmThreadID),
		ThreadID:        strings.TrimSpace(dmThreadID),
	}
	var selfMemberID string
	var peerMemberID string
	if err := row.Scan(&selfMemberID, &peerMemberID); err != nil {
		if coredb.IsNotFound(err) {
			return HumanDMAccess{}, ErrNotFound
		}
		return HumanDMAccess{}, fmt.Errorf("load human dm access scope memberships: %w", err)
	}
	if strings.TrimSpace(selfMemberID) == workspaceMember.Member.ID {
		access.Participant = true
		access.PeerMemberID = strings.TrimSpace(peerMemberID)
	}
	return access, nil
}

func IsActiveMemberInScope(ctx context.Context, queryer coredb.QueryRower, memberID string, scopeID string) (bool, error) {
	row := queryer.QueryRow(ctx, `
SELECT 1
FROM scope_memberships
WHERE scope_id = $1
  AND member_id = $2
  AND removed_at IS NULL
  AND effective_to_seq IS NULL
LIMIT 1
`, scopeID, memberID)

	var marker int
	if err := row.Scan(&marker); err != nil {
		if coredb.IsNotFound(err) {
			return false, nil
		}
		return false, fmt.Errorf("check active scope membership: %w", err)
	}
	return true, nil
}

func LookupMachine(ctx context.Context, queryer coredb.QueryRower, workspaceID string, machineID string) (Machine, error) {
	row := queryer.QueryRow(ctx, `
SELECT id
FROM machines
WHERE workspace_id = $1 AND id = $2
  AND status = 'active'
`, workspaceID, machineID)

	var machine Machine
	if err := row.Scan(&machine.MachineID); err != nil {
		if coredb.IsNotFound(err) {
			return Machine{}, ErrNotFound
		}
		return Machine{}, fmt.Errorf("load machine: %w", err)
	}
	return machine, nil
}

func LookupAgent(ctx context.Context, queryer coredb.QueryRower, workspaceID string, agentID string) (Agent, error) {
	row := queryer.QueryRow(ctx, `
SELECT a.id, a.machine_id, a.member_id
FROM agents a
JOIN machines mac
  ON mac.workspace_id = a.workspace_id
 AND mac.id = a.machine_id
WHERE a.workspace_id = $1 AND a.id = $2
  AND a.status <> 'archived'
  AND mac.status = 'active'
`, workspaceID, agentID)

	var agent Agent
	if err := row.Scan(&agent.AgentID, &agent.MachineID, &agent.MemberID); err != nil {
		if coredb.IsNotFound(err) {
			return Agent{}, ErrNotFound
		}
		return Agent{}, fmt.Errorf("load agent: %w", err)
	}
	return agent, nil
}

func LoadHumanMachineAccess(ctx context.Context, queryer coredb.QueryRower, userID string, workspaceID string, machineID string) (HumanMachineAccess, error) {
	workspaceMember, err := LoadHumanWorkspaceMember(ctx, queryer, userID, workspaceID)
	if err != nil {
		return HumanMachineAccess{}, err
	}

	machine, err := LookupMachine(ctx, queryer, workspaceID, machineID)
	if err != nil {
		return HumanMachineAccess{}, err
	}

	return HumanMachineAccess{
		WorkspaceMember: workspaceMember,
		Machine:         machine,
	}, nil
}

func LoadHumanAgentAccess(ctx context.Context, queryer coredb.QueryRower, userID string, workspaceID string, agentID string) (HumanAgentAccess, error) {
	workspaceMember, err := LoadHumanWorkspaceMember(ctx, queryer, userID, workspaceID)
	if err != nil {
		return HumanAgentAccess{}, err
	}

	agent, err := LookupAgent(ctx, queryer, workspaceID, agentID)
	if err != nil {
		return HumanAgentAccess{}, err
	}

	return HumanAgentAccess{
		WorkspaceMember: workspaceMember,
		Agent:           agent,
	}, nil
}

func ListVisibleThreads(ctx context.Context, queryer coredb.Queryer, workspaceID string, memberID string) ([]VisibleThread, error) {
	trimmedWorkspaceID := strings.TrimSpace(workspaceID)
	if trimmedWorkspaceID == "" {
		return nil, fmt.Errorf("workspace id is required")
	}
	trimmedMemberID := strings.TrimSpace(memberID)
	if trimmedMemberID == "" {
		return nil, fmt.Errorf("member id is required")
	}
	if err := EnsureMemberInWorkspace(ctx, queryer, trimmedWorkspaceID, trimmedMemberID); err != nil {
		return nil, err
	}

	rows, err := queryer.Query(ctx, `
	SELECT thread_id, channel_id, dm_thread_id, parent_channel_id, parent_dm_thread_id
	FROM (
		SELECT
			s.id AS thread_id,
			s.id AS channel_id,
			'' AS dm_thread_id,
			s.id AS parent_channel_id,
			'' AS parent_dm_thread_id
		FROM scopes s
		JOIN scope_memberships sm
		  ON sm.scope_id = s.id
		 AND sm.member_id = $2
		 AND sm.removed_at IS NULL
		 AND sm.effective_to_seq IS NULL
		 AND sm.visibility = 'public'
	WHERE s.workspace_id = $1
	  AND s.type = 'channel'
	  AND s.status = 'active'

	UNION ALL

		SELECT
			s.id AS thread_id,
			'' AS channel_id,
			s.id AS dm_thread_id,
			'' AS parent_channel_id,
			s.id AS parent_dm_thread_id
		FROM scopes s
		JOIN scope_memberships sm
		  ON sm.scope_id = s.id
		 AND sm.member_id = $2
		 AND sm.removed_at IS NULL
		 AND sm.effective_to_seq IS NULL
	WHERE s.workspace_id = $1
	  AND s.type = 'dm'
	  AND s.status = 'active'

	UNION ALL

		SELECT
			s.id AS thread_id,
			s.id AS channel_id,
			'' AS dm_thread_id,
			CASE WHEN parent.type = 'channel' THEN parent.id ELSE '' END AS parent_channel_id,
			CASE WHEN parent.type = 'dm' THEN parent.id ELSE '' END AS parent_dm_thread_id
		FROM scopes s
		JOIN scopes parent
		  ON parent.id = s.parent_scope_id
		 AND parent.workspace_id = $1
		 AND parent.status = 'active'
	LEFT JOIN scope_memberships child_sm
	  ON child_sm.scope_id = s.id
	 AND child_sm.member_id = $2
	 AND child_sm.removed_at IS NULL
	 AND child_sm.effective_to_seq IS NULL
	LEFT JOIN scope_memberships parent_sm
	  ON parent_sm.scope_id = parent.id
	 AND parent_sm.member_id = $2
	 AND parent_sm.removed_at IS NULL
	 AND parent_sm.effective_to_seq IS NULL
	 AND (parent.type = 'dm' OR parent_sm.visibility = 'public')
	WHERE s.workspace_id = $1
	  AND s.type = 'thread'
	  AND s.status = 'active'
	  AND (
		  (parent.type = 'channel' AND parent_sm.member_id IS NOT NULL)
		  OR (parent.type = 'dm' AND (child_sm.member_id IS NOT NULL OR parent_sm.member_id IS NOT NULL))
	  )
) visible_threads
ORDER BY thread_id ASC
`, trimmedWorkspaceID, trimmedMemberID)
	if err != nil {
		return nil, fmt.Errorf("list visible threads: %w", err)
	}
	defer rows.Close()

	threads := make([]VisibleThread, 0)
	for rows.Next() {
		var thread VisibleThread
		if err := rows.Scan(&thread.ThreadID, &thread.ChannelID, &thread.DMThreadID, &thread.ParentChannelID, &thread.ParentDMThreadID); err != nil {
			return nil, fmt.Errorf("scan visible thread: %w", err)
		}
		threads = append(threads, thread)
	}
	if err := rows.Err(); err != nil {
		return nil, fmt.Errorf("iterate visible threads: %w", err)
	}
	return threads, nil
}

func EnsureMemberInWorkspace(ctx context.Context, queryer coredb.QueryRower, workspaceID string, memberID string) error {
	row := queryer.QueryRow(ctx, `SELECT 1 FROM members WHERE workspace_id = $1 AND id = $2`, workspaceID, memberID)

	var marker int
	if err := row.Scan(&marker); err != nil {
		if coredb.IsNotFound(err) {
			return ErrNotFound
		}
		return fmt.Errorf("ensure member in workspace: %w", err)
	}
	return nil
}

// IsPublicMemberInChannel answers the ordinary channel-join question used by
// human/channel gates. Private scope memberships intentionally do not satisfy
// this helper; they must go through owner/private authority paths instead of
// becoming regular channel participants.
func IsPublicMemberInChannel(ctx context.Context, queryer coredb.QueryRower, memberID string, channelID string) (bool, error) {
	row := queryer.QueryRow(ctx, `
SELECT 1
FROM scope_memberships
WHERE scope_id = $1
  AND member_id = $2
  AND removed_at IS NULL
  AND effective_to_seq IS NULL
  AND visibility = 'public'
LIMIT 1
`, channelID, memberID)

	var marker int
	if err := row.Scan(&marker); err != nil {
		if coredb.IsNotFound(err) {
			return false, nil
		}
		return false, fmt.Errorf("check scope membership: %w", err)
	}
	return true, nil
}
