package services

// ViewService extension on EventService — runs a FilterSpec across the
// 4 substrate sources (deadline, appointment, project_event,
// approval_request) and returns a unified []ViewRow.
//
// Design: docs/design-data-display-model-2026-05-06.md §3 + §6.3.
//
// EventService is extended (not renamed) so the existing handlers
// (/api/events, /api/events/summary) keep working unchanged. New
// handlers (/api/views/{slug}/run, /api/user-views/...) call RunSpec.

import (
	"context"
	"encoding/json"
	"fmt"
	"slices"
	"sort"
	"strings"
	"time"

	"github.com/google/uuid"
	"github.com/lib/pq"

	"mgit.msbls.de/m/paliad/internal/models"
)

// ViewRow is the unified row shape returned by RunSpec. Discriminated by
// `Kind`; type-specific fields live under `Detail` as a per-source struct
// marshalled via json.RawMessage.
type ViewRow struct {
	Kind  DataSource `json:"kind"`
	ID    uuid.UUID  `json:"id"`
	Title string     `json:"title"`

	// Subtitle: one short context line (e.g. "Frist", "Termin",
	// "Genehmigung von …"). Optional; UIs render it under the title.
	Subtitle *string `json:"subtitle,omitempty"`

	// EventDate is the canonical sort key per row. Source-determined:
	//   - deadline:         due_date at 00:00 UTC
	//   - appointment:      start_at
	//   - project_event:    created_at
	//   - approval_request: requested_at (or decided_at if status decided)
	EventDate time.Time `json:"event_date"`

	ProjectID        *uuid.UUID `json:"project_id,omitempty"`
	ProjectTitle     *string    `json:"project_title,omitempty"`
	ProjectReference *string    `json:"project_reference,omitempty"`
	ProjectType      *string    `json:"project_type,omitempty"`

	ActorID   *uuid.UUID `json:"actor_id,omitempty"`
	ActorName *string    `json:"actor_name,omitempty"`

	// Detail is the per-source typed payload as raw JSON. Frontend
	// type-narrows on Kind and parses Detail accordingly.
	Detail json.RawMessage `json:"detail"`
}

// ViewRunResult is the response shape of RunSpec — rows + a count of
// projects that contributed zero rows because the caller can't see them
// (Q17 fail-open attribution).
type ViewRunResult struct {
	Rows                  []ViewRow `json:"rows"`
	InaccessibleProjectIDs []uuid.UUID `json:"inaccessible_project_ids,omitempty"`
}

// RunSpec executes the FilterSpec against the substrate and returns
// merged rows sorted by EventDate (ascending for forward-looking,
// descending if any sort hint says so). Visibility is enforced via
// the per-source RLS predicates already used by the underlying tables;
// `userID` is the caller for context propagation.
//
// Caller has run spec.Validate() before us. We trust the spec.
func (s *EventService) RunSpec(ctx context.Context, userID uuid.UUID, spec FilterSpec, approval *ApprovalService) (*ViewRunResult, error) {
	if approval == nil && slices.Contains(spec.Sources, SourceApprovalRequest) {
		// Approval source requires the approval service. Return a clear
		// error rather than silently skipping it — handlers always pass
		// the bundle's approval service.
		return nil, fmt.Errorf("RunSpec: approval source selected but ApprovalService is nil")
	}

	rows := make([]ViewRow, 0, 256)
	bounds := computeViewSpecBounds(time.Now().UTC(), spec.Time)

	if spec.UnreadOnly && approval != nil {
		cursor, err := approval.InboxSeenAt(ctx, userID)
		if err != nil {
			return nil, fmt.Errorf("inbox cursor lookup: %w", err)
		}
		bounds.unreadOnly = true
		bounds.inboxSeenAt = cursor
	}

	for _, src := range spec.Sources {
		switch src {
		case SourceDeadline:
			batch, err := s.runDeadlines(ctx, userID, spec, bounds)
			if err != nil {
				return nil, err
			}
			rows = append(rows, batch...)

		case SourceAppointment:
			batch, err := s.runAppointments(ctx, userID, spec, bounds)
			if err != nil {
				return nil, err
			}
			rows = append(rows, batch...)

		case SourceProjectEvent:
			batch, err := s.runProjectEvents(ctx, userID, spec, bounds)
			if err != nil {
				return nil, err
			}
			rows = append(rows, batch...)

		case SourceApprovalRequest:
			batch, err := s.runApprovalRequests(ctx, userID, spec, approval, bounds)
			if err != nil {
				return nil, err
			}
			rows = append(rows, batch...)
		}
	}

	// Default sort: ascending. Per-source sort hints don't apply here —
	// Render-side sort (RenderSpec.List/Cards.Sort) is the user-facing
	// knob. We give the substrate a stable shape; the renderer flips it.
	sort.SliceStable(rows, func(i, j int) bool {
		if rows[i].EventDate.Equal(rows[j].EventDate) {
			// Tiebreaker: kind alphabetical, then title — deterministic.
			if rows[i].Kind != rows[j].Kind {
				return rows[i].Kind < rows[j].Kind
			}
			return rows[i].Title < rows[j].Title
		}
		return rows[i].EventDate.Before(rows[j].EventDate)
	})

	out := &ViewRunResult{Rows: rows}
	// Q17 fail-open attribution: if the caller specified explicit
	// project IDs, surface the ones they couldn't see. We do that with
	// one cheap check against can_see_project (via RLS-aware visibility
	// predicate), batched per call.
	if spec.Scope.Projects.Mode == ScopeExplicit {
		inaccessible, err := s.filterInaccessibleProjects(ctx, userID, spec.Scope.Projects.IDs)
		if err != nil {
			return nil, err
		}
		if len(inaccessible) > 0 {
			out.InaccessibleProjectIDs = inaccessible
		}
	}
	return out, nil
}

// viewSpecBounds carries the resolved [from, to) window the spec
// translates into. Either bound can be nil (open-ended).
//
// inboxSeenAt is set by RunSpec when spec.UnreadOnly=true: the caller's
// users.inbox_seen_at cursor pre-resolved once so each source-runner can
// overlay it without re-querying the users table. nil means "never
// visited" — every row is unread.
type viewSpecBounds struct {
	from        *time.Time
	to          *time.Time
	unreadOnly  bool
	inboxSeenAt *time.Time
}

func computeViewSpecBounds(now time.Time, ts TimeSpec) viewSpecBounds {
	now = now.UTC()
	day := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
	tomorrow := day.AddDate(0, 0, 1)
	switch ts.Horizon {
	case HorizonNext1d:
		from := day
		to := day.AddDate(0, 0, 1)
		return viewSpecBounds{from: &from, to: &to}
	case HorizonNext7d:
		from := day
		to := day.AddDate(0, 0, 7)
		return viewSpecBounds{from: &from, to: &to}
	case HorizonNext14d:
		from := day
		to := day.AddDate(0, 0, 14)
		return viewSpecBounds{from: &from, to: &to}
	case HorizonNext30d:
		from := day
		to := day.AddDate(0, 0, 30)
		return viewSpecBounds{from: &from, to: &to}
	case HorizonNext90d:
		from := day
		to := day.AddDate(0, 0, 90)
		return viewSpecBounds{from: &from, to: &to}
	case HorizonNextAll:
		// One-sided unbounded — from today onwards, no upper bound.
		// Distinct from HorizonAll (bidirectional unbounded) and
		// HorizonAny (no time filter at all).
		from := day
		return viewSpecBounds{from: &from}
	case HorizonPast1d:
		from := day.AddDate(0, 0, -1)
		return viewSpecBounds{from: &from, to: &tomorrow}
	case HorizonPast7d:
		from := day.AddDate(0, 0, -7)
		return viewSpecBounds{from: &from, to: &tomorrow}
	case HorizonPast14d:
		from := day.AddDate(0, 0, -14)
		return viewSpecBounds{from: &from, to: &tomorrow}
	case HorizonPast30d:
		from := day.AddDate(0, 0, -30)
		return viewSpecBounds{from: &from, to: &tomorrow}
	case HorizonPast90d:
		from := day.AddDate(0, 0, -90)
		return viewSpecBounds{from: &from, to: &tomorrow}
	case HorizonPastAll:
		// One-sided unbounded — up to and including today, no lower bound.
		return viewSpecBounds{to: &tomorrow}
	case HorizonAny, HorizonAll:
		return viewSpecBounds{}
	case HorizonCustom:
		return viewSpecBounds{from: ts.From, to: ts.To}
	}
	return viewSpecBounds{}
}

// runDeadlines projects DeadlineWithProject rows from the existing
// DeadlineService.ListVisibleForUser onto ViewRow, applying spec narrowing.
func (s *EventService) runDeadlines(ctx context.Context, userID uuid.UUID, spec FilterSpec, bounds viewSpecBounds) ([]ViewRow, error) {
	df := ListFilter{}
	if spec.Scope.PersonalOnly {
		uid := userID
		df.CreatedBy = &uid
	}
	if spec.Predicates != nil && spec.Predicates.Deadline != nil {
		dp := spec.Predicates.Deadline
		// Status: ListFilter has DeadlineStatusFilter (single-value filter).
		// If the spec asks for both pending+completed → no narrowing; if
		// only pending → DeadlineFilterPending; only completed → Completed.
		switch {
		case len(dp.Status) == 1 && dp.Status[0] == "pending":
			df.Status = DeadlineFilterPending
		case len(dp.Status) == 1 && dp.Status[0] == "completed":
			df.Status = DeadlineFilterCompleted
		default:
			df.Status = DeadlineFilterAll
		}
		df.EventTypeIDs = dp.EventTypeIDs
		df.IncludeUntyped = dp.IncludeUntyped
	}
	if spec.Scope.Projects.Mode == ScopeExplicit && len(spec.Scope.Projects.IDs) > 0 {
		// DeadlineService takes one project id; we filter post-load when
		// spec selects multiple projects (the visibility predicate already
		// bounds to the caller's set, and explicit IDs are a refinement).
	}

	rows, err := s.deadlines.ListVisibleForUser(ctx, userID, df)
	if err != nil {
		return nil, err
	}

	out := make([]ViewRow, 0, len(rows))
	allowedProjects := explicitProjectSet(spec)

	for _, r := range rows {
		eventDate := time.Date(r.DueDate.Year(), r.DueDate.Month(), r.DueDate.Day(), 0, 0, 0, 0, time.UTC)
		if !inSpecWindow(eventDate, bounds) {
			continue
		}
		if allowedProjects != nil && !allowedProjects[r.ProjectID] {
			continue
		}
		// Approval-status narrowing (entity-side pill).
		if !approvalStatusMatches(r.ApprovalStatus, spec, SourceDeadline) {
			continue
		}

		detail, _ := json.Marshal(map[string]any{
			"due_date":         r.DueDate.Format("2006-01-02"),
			"status":           r.Status,
			"approval_status":  r.ApprovalStatus,
			"source":           r.Source,
			"rule_id":          r.RuleID,
			"rule_code":        r.RuleCode,
			"rule_name":        r.RuleName,
			"event_type_ids":   r.EventTypeIDs,
			"description":      r.Description,
			"completed_at":     r.CompletedAt,
		})
		pid := r.ProjectID
		pt := r.ProjectTitle
		ptype := r.ProjectType
		out = append(out, ViewRow{
			Kind:             SourceDeadline,
			ID:               r.ID,
			Title:            r.Title,
			EventDate:        eventDate,
			ProjectID:        &pid,
			ProjectTitle:     &pt,
			ProjectReference: r.ProjectReference,
			ProjectType:      &ptype,
			ActorID:          r.CreatedBy,
			Detail:           detail,
		})
	}
	return out, nil
}

// runAppointments projects AppointmentWithProject onto ViewRow.
func (s *EventService) runAppointments(ctx context.Context, userID uuid.UUID, spec FilterSpec, bounds viewSpecBounds) ([]ViewRow, error) {
	af := AppointmentListFilter{}
	if spec.Scope.PersonalOnly {
		uid := userID
		af.CreatedBy = &uid
	}
	af.From = bounds.from
	af.To = bounds.to
	if spec.Predicates != nil && spec.Predicates.Appointment != nil {
		ap := spec.Predicates.Appointment
		// AppointmentListFilter takes a single Type today; narrow to first
		// listed value, fall back to all if multiple.
		if len(ap.AppointmentTypes) == 1 {
			t := ap.AppointmentTypes[0]
			af.Type = &t
		}
	}

	rows, err := s.appointments.ListVisibleForUser(ctx, userID, af)
	if err != nil {
		return nil, err
	}

	out := make([]ViewRow, 0, len(rows))
	allowedProjects := explicitProjectSet(spec)
	allowedTypes := allowedAppointmentTypes(spec)

	for _, r := range rows {
		if !inSpecWindow(r.StartAt, bounds) {
			continue
		}
		if r.ProjectID != nil && allowedProjects != nil && !allowedProjects[*r.ProjectID] {
			continue
		}
		if r.ProjectID == nil && allowedProjects != nil {
			continue
		}
		if !approvalStatusMatches(r.ApprovalStatus, spec, SourceAppointment) {
			continue
		}
		if allowedTypes != nil {
			if r.AppointmentType == nil || !allowedTypes[*r.AppointmentType] {
				continue
			}
		}

		detail, _ := json.Marshal(map[string]any{
			"start_at":         r.StartAt,
			"end_at":           r.EndAt,
			"location":         r.Location,
			"appointment_type": r.AppointmentType,
			"approval_status":  r.ApprovalStatus,
			"description":      r.Description,
		})
		out = append(out, ViewRow{
			Kind:             SourceAppointment,
			ID:               r.ID,
			Title:            r.Title,
			EventDate:        r.StartAt,
			ProjectID:        r.ProjectID,
			ProjectTitle:     r.ProjectTitle,
			ProjectReference: r.ProjectReference,
			ProjectType:      r.ProjectType,
			ActorID:          r.CreatedBy,
			Detail:           detail,
		})
	}
	return out, nil
}

// runProjectEvents queries paliad.project_events with the visibility
// predicate. The audit table doesn't have a service wrapper today; we
// run our own SQL bounded by the spec.
//
// Inbox semantics (t-paliad-249) kick in when bounds.unreadOnly is set:
// the caller's own events are excluded (you don't notify yourself) and
// rows older than bounds.inboxSeenAt are dropped. Cursor lookup happens
// once in RunSpec — runProjectEvents only consumes the resolved value.
func (s *EventService) runProjectEvents(ctx context.Context, userID uuid.UUID, spec FilterSpec, bounds viewSpecBounds) ([]ViewRow, error) {
	conds := []string{visibilityPredicatePositional("p", 1)}
	args := []any{userID}

	allowedKinds := allowedProjectEventKinds(spec)
	if len(allowedKinds) > 0 {
		args = append(args, pq.Array(allowedKinds))
		conds = append(conds, fmt.Sprintf("pe.event_type = ANY($%d)", len(args)))
	}
	if bounds.from != nil {
		args = append(args, *bounds.from)
		conds = append(conds, fmt.Sprintf("pe.created_at >= $%d", len(args)))
	}
	if bounds.to != nil {
		args = append(args, *bounds.to)
		conds = append(conds, fmt.Sprintf("pe.created_at < $%d", len(args)))
	}
	if spec.Scope.Projects.Mode == ScopeExplicit && len(spec.Scope.Projects.IDs) > 0 {
		args = append(args, spec.Scope.Projects.IDs)
		conds = append(conds, fmt.Sprintf("pe.project_id = ANY($%d)", len(args)))
	}
	if bounds.unreadOnly {
		// Inbox mode: hide the caller's own actions (no self-notify).
		conds = append(conds, "pe.created_by IS DISTINCT FROM $1")
		if bounds.inboxSeenAt != nil {
			args = append(args, *bounds.inboxSeenAt)
			conds = append(conds, fmt.Sprintf("pe.created_at > $%d", len(args)))
		}
	}

	q := `
		SELECT pe.id, pe.project_id, pe.event_type, pe.title, pe.description,
		       pe.event_date, pe.created_by, pe.created_at,
		       p.title     AS project_title,
		       p.type      AS project_type,
		       p.reference AS project_reference,
		       u.display_name AS actor_name
		  FROM paliad.project_events pe
		  JOIN paliad.projects       p  ON p.id = pe.project_id
		  LEFT JOIN paliad.users     u  ON u.id = pe.created_by
		 WHERE ` + strings.Join(conds, " AND ") + `
		 ORDER BY pe.created_at DESC
		 LIMIT 500`

	type row struct {
		ID               uuid.UUID  `db:"id"`
		ProjectID        uuid.UUID  `db:"project_id"`
		EventType        *string    `db:"event_type"`
		Title            string     `db:"title"`
		Description      *string    `db:"description"`
		EventDate        *time.Time `db:"event_date"`
		CreatedBy        *uuid.UUID `db:"created_by"`
		CreatedAt        time.Time  `db:"created_at"`
		ProjectTitle     string     `db:"project_title"`
		ProjectType      string     `db:"project_type"`
		ProjectReference *string    `db:"project_reference"`
		ActorName        *string    `db:"actor_name"`
	}

	var dbRows []row
	if err := s.db.SelectContext(ctx, &dbRows, q, args...); err != nil {
		return nil, fmt.Errorf("project_events query: %w", err)
	}

	out := make([]ViewRow, 0, len(dbRows))
	for _, r := range dbRows {
		detail, _ := json.Marshal(map[string]any{
			"event_type":  r.EventType,
			"description": r.Description,
			"event_date":  r.EventDate,
		})
		pid := r.ProjectID
		pt := r.ProjectTitle
		ptype := r.ProjectType
		out = append(out, ViewRow{
			Kind:             SourceProjectEvent,
			ID:               r.ID,
			Title:            r.Title,
			EventDate:        r.CreatedAt,
			ProjectID:        &pid,
			ProjectTitle:     &pt,
			ProjectReference: r.ProjectReference,
			ProjectType:      &ptype,
			ActorID:          r.CreatedBy,
			ActorName:        r.ActorName,
			Detail:           detail,
		})
	}
	return out, nil
}

// runApprovalRequests projects approval_request rows via the existing
// ApprovalService inbox queries. ViewerRole picks which underlying
// query runs.
func (s *EventService) runApprovalRequests(ctx context.Context, userID uuid.UUID, spec FilterSpec, approval *ApprovalService, bounds viewSpecBounds) ([]ViewRow, error) {
	var ap *ApprovalRequestPredicates
	if spec.Predicates != nil {
		ap = spec.Predicates.ApprovalRequest
	}
	role := "approver_eligible"
	if ap != nil && ap.ViewerRole != "" {
		role = ap.ViewerRole
	}

	filter := InboxFilter{}
	if ap != nil {
		// InboxFilter takes a single status today. If the spec says
		// only one, narrow; if multiple, leave open.
		if len(ap.Status) == 1 {
			filter.Status = ap.Status[0]
		}
		if len(ap.EntityTypes) == 1 {
			filter.EntityType = ap.EntityTypes[0]
		}
	}
	if spec.Scope.Projects.Mode == ScopeExplicit && len(spec.Scope.Projects.IDs) == 1 {
		pid := spec.Scope.Projects.IDs[0]
		filter.ProjectID = &pid
	}

	var rows []ApprovalRequestView
	var err error
	switch role {
	case "approver_eligible":
		rows, err = approval.ListPendingForApprover(ctx, userID, filter)
	case "self_requested":
		rows, err = approval.ListSubmittedByUser(ctx, userID, filter)
	case "any_visible":
		// any_visible is the broadest read — RLS bounds it. The existing
		// ApprovalService doesn't have a "list all visible" call; we
		// approximate by running both inbox queries and de-duping. Future
		// optimization: dedicated service method.
		a, errA := approval.ListPendingForApprover(ctx, userID, filter)
		if errA != nil {
			return nil, errA
		}
		b, errB := approval.ListSubmittedByUser(ctx, userID, filter)
		if errB != nil {
			return nil, errB
		}
		seen := make(map[uuid.UUID]bool, len(a)+len(b))
		for _, r := range a {
			if !seen[r.ID] {
				rows = append(rows, r)
				seen[r.ID] = true
			}
		}
		for _, r := range b {
			if !seen[r.ID] {
				rows = append(rows, r)
				seen[r.ID] = true
			}
		}
	default:
		return nil, fmt.Errorf("%w: approval_request.viewer_role %q", ErrInvalidInput, role)
	}
	if err != nil {
		return nil, err
	}

	out := make([]ViewRow, 0, len(rows))
	allowedStatuses := allowedRequestStatuses(spec)
	allowedEntityTypes := allowedRequestEntityTypes(spec)
	allowedProjects := explicitProjectSet(spec)

	for _, r := range rows {
		// Spec status filter (when the inbox query received broad results).
		if allowedStatuses != nil && !allowedStatuses[r.Status] {
			continue
		}
		if allowedEntityTypes != nil && !allowedEntityTypes[r.EntityType] {
			continue
		}
		if allowedProjects != nil && !allowedProjects[r.ProjectID] {
			continue
		}

		// Sort key: decided_at if decided, else requested_at.
		eventDate := r.RequestedAt
		if r.DecidedAt != nil {
			eventDate = *r.DecidedAt
		}
		if !inSpecWindow(eventDate, bounds) {
			continue
		}

		// Inbox unread-only carve-out (t-paliad-249, design §3):
		// pending requests always survive; decided rows are subject to
		// the cursor like any other audit-style item.
		if bounds.unreadOnly && r.Status != RequestStatusPending {
			if bounds.inboxSeenAt != nil && !eventDate.After(*bounds.inboxSeenAt) {
				continue
			}
		}

		title := approvalRowTitle(r)
		subtitle := approvalRowSubtitle(r)
		detail, _ := json.Marshal(r) // request view already carries everything the UI needs
		actorID := r.RequestedBy
		actorName := r.RequesterName
		pid := r.ProjectID
		pt := r.ProjectTitle
		out = append(out, ViewRow{
			Kind:         SourceApprovalRequest,
			ID:           r.ID,
			Title:        title,
			Subtitle:     &subtitle,
			EventDate:    eventDate,
			ProjectID:    &pid,
			ProjectTitle: &pt,
			ActorID:      &actorID,
			ActorName:    &actorName,
			Detail:       detail,
		})
	}
	return out, nil
}

// approvalRowTitle returns a one-line title describing the approval
// request — used as the ViewRow.Title.
func approvalRowTitle(r ApprovalRequestView) string {
	if r.EntityTitle != nil && *r.EntityTitle != "" {
		return *r.EntityTitle
	}
	return fmt.Sprintf("%s %s", r.EntityType, r.LifecycleEvent)
}

// approvalRowSubtitle returns a one-line context for the request.
func approvalRowSubtitle(r ApprovalRequestView) string {
	switch r.Status {
	case "pending":
		return fmt.Sprintf("Genehmigung angefragt von %s", r.RequesterName)
	case "approved":
		if r.DeciderName != nil {
			return fmt.Sprintf("Genehmigt von %s", *r.DeciderName)
		}
		return "Genehmigt"
	case "rejected":
		if r.DeciderName != nil {
			return fmt.Sprintf("Abgelehnt von %s", *r.DeciderName)
		}
		return "Abgelehnt"
	case "revoked":
		return "Widerrufen"
	case "changes_requested":
		if r.DeciderName != nil {
			return fmt.Sprintf("Abgelehnt mit Vorschlag von %s", *r.DeciderName)
		}
		return "Abgelehnt mit Vorschlag"
	}
	return r.Status
}

// inSpecWindow returns true when ts is within [from, to). nil bounds
// are open-ended.
func inSpecWindow(ts time.Time, b viewSpecBounds) bool {
	if b.from != nil && ts.Before(*b.from) {
		return false
	}
	if b.to != nil && !ts.Before(*b.to) {
		return false
	}
	return true
}

// explicitProjectSet returns nil when the scope isn't explicit, otherwise
// a set membership map for fast filtering.
func explicitProjectSet(spec FilterSpec) map[uuid.UUID]bool {
	if spec.Scope.Projects.Mode != ScopeExplicit {
		return nil
	}
	out := make(map[uuid.UUID]bool, len(spec.Scope.Projects.IDs))
	for _, id := range spec.Scope.Projects.IDs {
		out[id] = true
	}
	return out
}

// approvalStatusMatches checks the entity-side approval_status filter.
// Returns true when the row passes (no filter set → always true).
func approvalStatusMatches(rowStatus string, spec FilterSpec, src DataSource) bool {
	if spec.Predicates == nil {
		return true
	}
	var allowed []string
	switch src {
	case SourceDeadline:
		if spec.Predicates.Deadline != nil {
			allowed = spec.Predicates.Deadline.ApprovalStatus
		}
	case SourceAppointment:
		if spec.Predicates.Appointment != nil {
			allowed = spec.Predicates.Appointment.ApprovalStatus
		}
	}
	if len(allowed) == 0 {
		return true
	}
	return slices.Contains(allowed, rowStatus)
}

// allowedAppointmentTypes returns nil when the filter is open, otherwise
// a set of legal appointment_type values.
func allowedAppointmentTypes(spec FilterSpec) map[string]bool {
	if spec.Predicates == nil || spec.Predicates.Appointment == nil {
		return nil
	}
	ap := spec.Predicates.Appointment
	if len(ap.AppointmentTypes) <= 1 {
		return nil // single-value already pushed down via AppointmentListFilter.Type
	}
	out := make(map[string]bool, len(ap.AppointmentTypes))
	for _, t := range ap.AppointmentTypes {
		out[t] = true
	}
	return out
}

// allowedProjectEventKinds returns the slice of project_event.event_type
// values the spec narrows to, or nil for "all known kinds".
//
// Inbox de-dup (t-paliad-249): when the spec also fans out
// SourceApprovalRequest, every `*_approval_*` audit event is dropped
// — the approval_request row itself is the canonical signal, and we
// don't want both rows showing up side-by-side. The drop applies to
// both the explicit caller list and the implicit "all kinds" path.
func allowedProjectEventKinds(spec FilterSpec) []string {
	var pe *ProjectEventPredicates
	if spec.Predicates != nil {
		pe = spec.Predicates.ProjectEvent
	}
	dedupApprovals := slices.Contains(spec.Sources, SourceApprovalRequest)

	var requested []string
	switch {
	case pe != nil && len(pe.EventTypes) > 0:
		requested = pe.EventTypes
	case dedupApprovals:
		// No explicit narrowing, but ApprovalRequest is in sources —
		// rebuild the implicit "all" list so we can subtract approvals.
		requested = KnownProjectEventKinds
	default:
		return nil
	}

	if !dedupApprovals {
		return requested
	}
	filtered := make([]string, 0, len(requested))
	for _, k := range requested {
		if isApprovalAuditKind(k) {
			continue
		}
		filtered = append(filtered, k)
	}
	return filtered
}

// isApprovalAuditKind matches the `*_approval_*` audit event_types that
// every approval mutation emits alongside the approval_request row.
// Dropped from inbox project_event reads (see allowedProjectEventKinds).
func isApprovalAuditKind(kind string) bool {
	return strings.Contains(kind, "_approval_") || kind == "approval_decided"
}

// allowedRequestStatuses returns nil for "no narrowing" (or "single value
// already pushed into InboxFilter.Status").
func allowedRequestStatuses(spec FilterSpec) map[string]bool {
	if spec.Predicates == nil || spec.Predicates.ApprovalRequest == nil {
		return nil
	}
	ap := spec.Predicates.ApprovalRequest
	if len(ap.Status) <= 1 {
		return nil
	}
	out := make(map[string]bool, len(ap.Status))
	for _, s := range ap.Status {
		out[s] = true
	}
	return out
}

func allowedRequestEntityTypes(spec FilterSpec) map[string]bool {
	if spec.Predicates == nil || spec.Predicates.ApprovalRequest == nil {
		return nil
	}
	ap := spec.Predicates.ApprovalRequest
	if len(ap.EntityTypes) <= 1 {
		return nil
	}
	out := make(map[string]bool, len(ap.EntityTypes))
	for _, t := range ap.EntityTypes {
		out[t] = true
	}
	return out
}

// filterInaccessibleProjects returns the subset of `requested` that the
// caller cannot see. Implementation: SELECT id FROM paliad.projects
// WHERE id = ANY(...) (RLS filters the visible ones); the missing ones
// are inaccessible. One DB hit per RunSpec when scope is explicit.
func (s *EventService) filterInaccessibleProjects(ctx context.Context, userID uuid.UUID, requested []uuid.UUID) ([]uuid.UUID, error) {
	if len(requested) == 0 {
		return nil, nil
	}
	q := `SELECT p.id
	        FROM paliad.projects p
	       WHERE p.id = ANY($1)
	         AND ` + visibilityPredicatePositional("p", 2)
	var visible []uuid.UUID
	if err := s.db.SelectContext(ctx, &visible, q, requested, userID); err != nil {
		return nil, fmt.Errorf("filter inaccessible projects: %w", err)
	}
	visibleSet := make(map[uuid.UUID]bool, len(visible))
	for _, id := range visible {
		visibleSet[id] = true
	}
	out := make([]uuid.UUID, 0)
	for _, id := range requested {
		if !visibleSet[id] {
			out = append(out, id)
		}
	}
	return out, nil
}

// Compile-time guards: the substrate's source loaders read fields off
// known model shapes. If a model rename breaks this, the build fails
// here rather than at runtime in production.
var (
	_ = models.DeadlineWithProject{}
	_ = models.AppointmentWithProject{}
	_ = models.ProjectEvent{}
)
