package services

// EventService is the unified read facade over Deadlines + Appointments.
// /deadlines and /appointments both render one EventsPage that calls
// /api/events?type=deadline|appointment|all — this service is what backs
// that endpoint and the matching summary counts.
//
// Visibility, validation, and event_type hydration are all delegated to
// DeadlineService / AppointmentService — this layer adds nothing on top
// other than the projection to EventListItem and the bucket math used by
// SummaryCounts. Mutations stay on the type-specific services; the
// handlers call them directly. See docs/design-events-unification-2026-05-04.md
// (t-paliad-109) for the design rationale.

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

	"github.com/google/uuid"
	"github.com/jmoiron/sqlx"

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

// EventTypeFilter selects which side of the union ListVisibleForUser
// returns. Empty string means "both"; the constants spell out the
// allowed values.
type EventTypeFilter string

const (
	EventTypeAll         EventTypeFilter = ""
	EventTypeDeadline    EventTypeFilter = "deadline"
	EventTypeAppointment EventTypeFilter = "appointment"
)

// EventService wraps the deadline + appointment services.
type EventService struct {
	db           *sqlx.DB
	deadlines    *DeadlineService
	appointments *AppointmentService
}

func NewEventService(db *sqlx.DB, deadlines *DeadlineService, appointments *AppointmentService) *EventService {
	return &EventService{db: db, deadlines: deadlines, appointments: appointments}
}

// EventListFilter narrows ListVisibleForUser. Most fields are type-specific;
// passing them with Type=Appointment (or vice versa) is a no-op rather than
// an error so the handler can stay shape-stable across type switches.
//
// PersonalOnly narrows BOTH rails to rows whose `created_by` matches the
// caller — backs the "Nur persönliche" filter on /events (t-paliad-128).
// When set, ProjectID is ignored (the two are contradictory: personal
// means "items I created", not "items in some project I picked").
//
// DirectOnly narrows the ProjectID filter from "this project + every
// descendant" (t-paliad-139 subtree default) to "this project only"
// (t-paliad-152). Backs the "direkt / inkl. Unterprojekte" toggle on
// /projects/{id} Fristen + Termine. No effect when ProjectID is nil or
// PersonalOnly is set.
type EventListFilter struct {
	Type EventTypeFilter

	// Deadline-only. AppointmentType applies only to appointments.
	Status          DeadlineStatusFilter
	EventTypeIDs    []uuid.UUID
	IncludeUntyped  bool
	AppointmentType *string

	// Common.
	ProjectID    *uuid.UUID
	From         *time.Time
	To           *time.Time
	PersonalOnly bool
	DirectOnly   bool
}

// EventListItem is one row of the unified events list. Type-specific
// columns are pointers so the JSON shape carries only the fields that
// apply; the frontend type-narrows on `type`.
type EventListItem struct {
	Type             string     `json:"type"` // "deadline" | "appointment"
	ID               uuid.UUID  `json:"id"`
	Title            string     `json:"title"`
	Description      *string    `json:"description,omitempty"`
	EventDate        time.Time  `json:"event_date"` // canonical sort key (deadline: due_date 00:00 UTC; appointment: start_at)
	ProjectID        *uuid.UUID `json:"project_id,omitempty"`
	ProjectReference *string    `json:"project_reference,omitempty"`
	ProjectTitle     *string    `json:"project_title,omitempty"`
	ProjectType      *string    `json:"project_type,omitempty"`
	CreatedBy        *uuid.UUID `json:"created_by,omitempty"`

	// Approval workflow (t-paliad-138). ApprovalStatus is "approved"
	// (default), "pending" (in-flight 4-eye request — pill rendered on
	// every list surface), or "legacy" (pre-4-eye row, no pill).
	ApprovalStatus *string `json:"approval_status,omitempty"`
	// RequesterKind is the kind of the in-flight approval request when
	// approval_status='pending': 'user' or 'agent' (Paliadin-drafted —
	// t-paliad-161). NULL otherwise. Drives the ✨ glyph alongside 👀.
	RequesterKind *string `json:"requester_kind,omitempty"`

	// Deadline-only.
	DueDate      *string      `json:"due_date,omitempty"` // YYYY-MM-DD
	Status       *string      `json:"status,omitempty"`
	CompletedAt  *time.Time   `json:"completed_at,omitempty"`
	Source       *string      `json:"source,omitempty"`
	RuleID       *uuid.UUID   `json:"rule_id,omitempty"`
	RuleCode     *string      `json:"rule_code,omitempty"`
	RuleName     *string      `json:"rule_name,omitempty"`
	RuleNameEN   *string      `json:"rule_name_en,omitempty"`
	EventTypeIDs []uuid.UUID  `json:"event_type_ids,omitempty"`

	// Appointment-only.
	StartAt         *time.Time `json:"start_at,omitempty"`
	EndAt           *time.Time `json:"end_at,omitempty"`
	Location        *string    `json:"location,omitempty"`
	AppointmentType *string    `json:"appointment_type,omitempty"`
}

// ListVisibleForUser returns events the user can see, sorted by event_date
// ascending. Deadlines and appointments are merged when Type is "" / "all".
func (s *EventService) ListVisibleForUser(ctx context.Context, userID uuid.UUID, filter EventListFilter) ([]EventListItem, error) {
	wantDeadlines := filter.Type == EventTypeAll || filter.Type == EventTypeDeadline
	wantAppointments := filter.Type == EventTypeAll || filter.Type == EventTypeAppointment

	out := make([]EventListItem, 0, 64)

	if wantDeadlines {
		df := ListFilter{
			Status:         filter.Status,
			ProjectID:      filter.ProjectID,
			EventTypeIDs:   filter.EventTypeIDs,
			IncludeUntyped: filter.IncludeUntyped,
			DirectOnly:     filter.DirectOnly,
		}
		if filter.PersonalOnly {
			uid := userID
			df.CreatedBy = &uid
			df.ProjectID = nil
		}
		rows, err := s.deadlines.ListVisibleForUser(ctx, userID, df)
		if err != nil {
			return nil, err
		}
		for _, r := range rows {
			if !inDateWindow(r.DueDate, filter.From, filter.To) {
				continue
			}
			out = append(out, projectDeadline(r))
		}
	}

	if wantAppointments {
		// Status is a deadline-only filter, but it doubles as the bucket
		// click-through target on the unified events page. So when the
		// caller set a bucket-style status (today / this_week / next_week
		// / later), apply the matching date window to the appointment side
		// too — clicking "Heute" then shows today's deadlines AND today's
		// appointments. For overdue/completed (no appointment analogue),
		// skip the appointment query entirely.
		if shouldExcludeAppointmentsForStatus(filter.Status) {
			// no-op
		} else {
			af := AppointmentListFilter{
				ProjectID:  filter.ProjectID,
				From:       filter.From,
				To:         filter.To,
				Type:       filter.AppointmentType,
				DirectOnly: filter.DirectOnly,
			}
			if filter.PersonalOnly {
				uid := userID
				af.CreatedBy = &uid
				af.ProjectID = nil
			}
			bounds := computeDeadlineBucketBounds(time.Now().UTC())
			from, to := bucketAppointmentWindow(filter.Status, bounds)
			af.From = pickLater(af.From, from)
			af.To = pickEarlier(af.To, to)
			rows, err := s.appointments.ListVisibleForUser(ctx, userID, af)
			if err != nil {
				return nil, err
			}
			for _, r := range rows {
				out = append(out, projectAppointment(r))
			}
		}
	}

	sort.SliceStable(out, func(i, j int) bool {
		if out[i].EventDate.Equal(out[j].EventDate) {
			// Stable tiebreaker: deadlines before appointments on the same
			// instant, then alphabetic by title — matches AgendaService.
			if out[i].Type != out[j].Type {
				return out[i].Type == "deadline"
			}
			return out[i].Title < out[j].Title
		}
		return out[i].EventDate.Before(out[j].EventDate)
	})

	return out, nil
}

// projectDeadline projects a DeadlineWithProject row into the union shape.
func projectDeadline(d models.DeadlineWithProject) EventListItem {
	pid := d.ProjectID
	pt := d.ProjectTitle
	ptype := d.ProjectType
	due := d.DueDate.Format("2006-01-02")
	status := d.Status
	src := d.Source
	approvalStatus := d.ApprovalStatus

	return EventListItem{
		Type:             "deadline",
		ID:               d.ID,
		Title:            d.Title,
		Description:      d.Description,
		EventDate:        time.Date(d.DueDate.Year(), d.DueDate.Month(), d.DueDate.Day(), 0, 0, 0, 0, time.UTC),
		ProjectID:        &pid,
		ProjectReference: d.ProjectReference,
		ProjectTitle:     &pt,
		ProjectType:      &ptype,
		CreatedBy:        d.CreatedBy,
		ApprovalStatus:   &approvalStatus,
		RequesterKind:    d.RequesterKind,
		DueDate:          &due,
		Status:           &status,
		CompletedAt:      d.CompletedAt,
		Source:           &src,
		RuleID:           d.RuleID,
		RuleCode:         d.RuleCode,
		RuleName:         d.RuleName,
		RuleNameEN:       d.RuleNameEN,
		EventTypeIDs:     d.EventTypeIDs,
	}
}

// projectAppointment projects an AppointmentWithProject row into the union shape.
func projectAppointment(a models.AppointmentWithProject) EventListItem {
	startCopy := a.StartAt
	approvalStatus := a.ApprovalStatus
	return EventListItem{
		Type:             "appointment",
		ID:               a.ID,
		Title:            a.Title,
		Description:      a.Description,
		EventDate:        a.StartAt,
		ProjectID:        a.ProjectID,
		ProjectReference: a.ProjectReference,
		ProjectTitle:     a.ProjectTitle,
		ProjectType:      a.ProjectType,
		CreatedBy:        a.CreatedBy,
		ApprovalStatus:   &approvalStatus,
		RequesterKind:    a.RequesterKind,
		StartAt:          &startCopy,
		EndAt:            a.EndAt,
		Location:         a.Location,
		AppointmentType:  a.AppointmentType,
	}
}

// shouldExcludeAppointmentsForStatus returns true for deadline-status
// values that have no appointment analogue (overdue, completed). When
// the user sets one of those, the appointment rail collapses to empty.
func shouldExcludeAppointmentsForStatus(status DeadlineStatusFilter) bool {
	switch status {
	case DeadlineFilterOverdue, DeadlineFilterCompleted:
		return true
	}
	return false
}

// bucketAppointmentWindow returns the [from, to) date window that
// matches a bucket-style deadline status — used to filter the
// appointment side when the user clicks a card on the unified events
// page. Returns (nil, nil) for non-bucket statuses (pending / all /
// "" / overdue / completed — those are handled separately).
//
// DeadlineFilterUpcoming maps to "start_at >= today" so legacy
// `?status=upcoming` URLs hide past appointments instead of falling
// through to the unfiltered query (m/paliad#54 — the UI option that
// surfaced this status has been removed, but bookmarks may persist).
func bucketAppointmentWindow(status DeadlineStatusFilter, b deadlineBucketBounds) (*time.Time, *time.Time) {
	switch status {
	case DeadlineFilterToday:
		t := b.tomorrow
		return &b.today, &t
	case DeadlineFilterThisWeek:
		t := b.nextMonday
		return &b.tomorrow, &t
	case DeadlineFilterNextWeek:
		t := b.weekAfter
		return &b.nextMonday, &t
	case DeadlineFilterLater:
		return &b.weekAfter, nil
	case DeadlineFilterUpcoming:
		return &b.today, nil
	}
	return nil, nil
}

// pickLater returns whichever of the two times is later (nil propagates
// the non-nil value; both nil → nil). Used to intersect bucket-derived
// windows with caller-supplied from filters.
func pickLater(a, b *time.Time) *time.Time {
	if a == nil {
		return b
	}
	if b == nil {
		return a
	}
	if a.After(*b) {
		return a
	}
	return b
}

// pickEarlier returns whichever of the two times is earlier (nil
// propagates the non-nil value; both nil → nil).
func pickEarlier(a, b *time.Time) *time.Time {
	if a == nil {
		return b
	}
	if b == nil {
		return a
	}
	if a.Before(*b) {
		return a
	}
	return b
}

// inDateWindow returns true when due is inside [from, to]. Both ends are
// optional. The deadline ListFilter has no date-range support today, so we
// post-filter in memory — fine because the per-user deadline set is small.
func inDateWindow(due time.Time, from, to *time.Time) bool {
	if from != nil && due.Before(from.UTC()) {
		return false
	}
	if to != nil && due.After(to.UTC()) {
		return false
	}
	return true
}

// EventSummaryFilter narrows SummaryCounts. Today only `Type`,
// `ProjectID`, and `PersonalOnly` matter; status/event_type filters
// intentionally don't shape the bucket counts (the cards are global
// "what's coming?" indicators).
//
// PersonalOnly mirrors EventListFilter.PersonalOnly — narrows both
// bucket queries to rows the caller created. ProjectID is ignored when
// PersonalOnly is set.
//
// DirectOnly mirrors EventListFilter.DirectOnly — narrows ProjectID from
// "this project + every descendant" to "this project only". No effect
// when ProjectID is nil or PersonalOnly is set.
type EventSummaryFilter struct {
	Type         EventTypeFilter
	ProjectID    *uuid.UUID
	PersonalOnly bool
	DirectOnly   bool
}

// EventSummary is the response shape of /api/events/summary. Either side
// is omitted when the matching Type filter excludes it; the frontend reads
// presence and renders the appropriate rail.
//
// The four universal cards are Heute / Diese Woche / Nächste Woche /
// Später. Überfällig is deadline-only and conditional (count > 0). Erledigt
// stays in the response so the dropdown filter can render the unread badge
// but is no longer rendered as a card (t-paliad-110, supersedes t-106).
type EventSummary struct {
	Deadlines    *DeadlineBuckets    `json:"deadlines,omitempty"`
	Appointments *AppointmentBuckets `json:"appointments,omitempty"`
}

// DeadlineBuckets counts deadlines across the five disjoint pending
// buckets plus the all-time completed total.
type DeadlineBuckets struct {
	Overdue   int `json:"overdue"   db:"overdue"`
	Today     int `json:"today"     db:"today"`
	ThisWeek  int `json:"this_week" db:"this_week"`
	NextWeek  int `json:"next_week" db:"next_week"`
	Later     int `json:"later"     db:"later"`
	Completed int `json:"completed" db:"completed"`
	Total     int `json:"total"     db:"total"`
}

// AppointmentBuckets counts appointments by start-date bucket. Past
// appointments do not get a bucket (per t-paliad-110 §F Q14: appointments
// have no completed_at; past ones are reachable via filter / pagination
// but don't contribute to a card).
type AppointmentBuckets struct {
	Today    int `json:"today"     db:"today"`
	ThisWeek int `json:"this_week" db:"this_week"`
	NextWeek int `json:"next_week" db:"next_week"`
	Later    int `json:"later"     db:"later"`
	Total    int `json:"total"     db:"total"`
}

// SummaryCounts returns the bucket counts for the user's visible events.
//
// The five disjoint buckets share their cutoffs with computeDeadlineBucketBounds
// (deadline_service.go) so /api/events/summary, /api/deadlines/summary, and
// the dashboard's deadline rail can never disagree.
//
//	Overdue   — pending AND due_date < today (deadlines only)
//	Today     — pending AND due_date = today (deadlines)
//	            start_at within [today, tomorrow) (appointments)
//	ThisWeek  — pending AND tomorrow <= due_date <= upcoming Sunday (deadlines)
//	            tomorrow <= start_at < Mon-next-week (appointments)
//	NextWeek  — Mon-next-week <= due_date < Mon-week-after (deadlines)
//	            Mon-next-week <= start_at < Mon-week-after (appointments)
//	Later     — due_date >= Mon-week-after (deadlines)
//	            start_at >= Mon-week-after (appointments)
//	Completed — status='completed' (deadlines only; all-time count)
func (s *EventService) SummaryCounts(ctx context.Context, userID uuid.UUID, filter EventSummaryFilter) (*EventSummary, error) {
	user, err := s.deadlines.users().GetByID(ctx, userID)
	if err != nil {
		return nil, err
	}
	if user == nil {
		return &EventSummary{}, nil
	}

	out := &EventSummary{}
	wantDeadlines := filter.Type == EventTypeAll || filter.Type == EventTypeDeadline
	wantAppointments := filter.Type == EventTypeAll || filter.Type == EventTypeAppointment

	bounds := computeDeadlineBucketBounds(time.Now().UTC())

	projectID := filter.ProjectID
	if filter.PersonalOnly {
		// PersonalOnly is mutually exclusive with ProjectID — see the
		// EventSummaryFilter doc comment.
		projectID = nil
	}

	if wantDeadlines {
		buckets, err := s.deadlineBuckets(ctx, userID, projectID, bounds, filter.PersonalOnly, filter.DirectOnly)
		if err != nil {
			return nil, err
		}
		out.Deadlines = buckets
	}

	if wantAppointments {
		buckets, err := s.appointmentBuckets(ctx, userID, projectID, bounds, filter.PersonalOnly, filter.DirectOnly)
		if err != nil {
			return nil, err
		}
		out.Appointments = buckets
	}

	return out, nil
}

func (s *EventService) deadlineBuckets(ctx context.Context, userID uuid.UUID, projectID *uuid.UUID, b deadlineBucketBounds, personalOnly, directOnly bool) (*DeadlineBuckets, error) {
	conds := []string{visibilityPredicate("p")}
	args := map[string]any{
		"user_id":     userID,
		"today":       b.today,
		"tomorrow":    b.tomorrow,
		"next_monday": b.nextMonday,
		"week_after":  b.weekAfter,
	}
	if projectID != nil {
		if directOnly {
			conds = append(conds, `f.project_id = :project_id`)
		} else {
			conds = append(conds, projectDescendantPredicate("p"))
		}
		args["project_id"] = *projectID
	}
	if personalOnly {
		conds = append(conds, `f.created_by = :user_id`)
	}

	query := `
		SELECT
		  COUNT(*) FILTER (WHERE f.status = 'pending'  AND f.due_date <  :today)                                            AS overdue,
		  COUNT(*) FILTER (WHERE f.status = 'pending'  AND f.due_date =  :today)                                            AS today,
		  COUNT(*) FILTER (WHERE f.status = 'pending'  AND f.due_date >= :tomorrow    AND f.due_date < :next_monday)        AS this_week,
		  COUNT(*) FILTER (WHERE f.status = 'pending'  AND f.due_date >= :next_monday AND f.due_date < :week_after)         AS next_week,
		  COUNT(*) FILTER (WHERE f.status = 'pending'  AND f.due_date >= :week_after)                                       AS later,
		  COUNT(*) FILTER (WHERE f.status = 'completed')                                                                    AS completed,
		  COUNT(*)                                                                                                          AS total
		  FROM paliad.deadlines f
		  JOIN paliad.projects p ON p.id = f.project_id
		 WHERE ` + strings.Join(conds, " AND ")

	stmt, err := s.db.PrepareNamedContext(ctx, query)
	if err != nil {
		return nil, fmt.Errorf("prepare deadline summary: %w", err)
	}
	defer stmt.Close()

	var c DeadlineBuckets
	if err := stmt.GetContext(ctx, &c, args); err != nil {
		return nil, fmt.Errorf("event deadline summary: %w", err)
	}
	return &c, nil
}

func (s *EventService) appointmentBuckets(ctx context.Context, userID uuid.UUID, projectID *uuid.UUID, b deadlineBucketBounds, personalOnly, directOnly bool) (*AppointmentBuckets, error) {
	visibility := `(
		(t.project_id IS NULL AND t.created_by = :user_id)
		OR (t.project_id IS NOT NULL AND ` + visibilityPredicate("p") + `)
	)`
	conds := []string{visibility}
	args := map[string]any{
		"user_id":     userID,
		"today":       b.today,
		"tomorrow":    b.tomorrow,
		"next_monday": b.nextMonday,
		"week_after":  b.weekAfter,
	}
	if projectID != nil {
		if directOnly {
			conds = append(conds, `t.project_id = :project_id`)
		} else {
			conds = append(conds, projectDescendantPredicate("p"))
		}
		args["project_id"] = *projectID
	}
	if personalOnly {
		// Narrow to rows the caller created. AND-joined with the visibility
		// predicate above so an appointment a user created on a team they
		// have since left still doesn't leak through.
		conds = append(conds, `t.created_by = :user_id`)
	}

	query := `
		SELECT
		  COUNT(*) FILTER (WHERE t.start_at >= :today       AND t.start_at <  :tomorrow)    AS today,
		  COUNT(*) FILTER (WHERE t.start_at >= :tomorrow    AND t.start_at <  :next_monday) AS this_week,
		  COUNT(*) FILTER (WHERE t.start_at >= :next_monday AND t.start_at <  :week_after)  AS next_week,
		  COUNT(*) FILTER (WHERE t.start_at >= :week_after)                                 AS later,
		  COUNT(*) FILTER (WHERE t.start_at >= :today)                                      AS total
		  FROM paliad.appointments t
		  LEFT JOIN paliad.projects p ON p.id = t.project_id
		 WHERE ` + strings.Join(conds, " AND ")

	stmt, err := s.db.PrepareNamedContext(ctx, query)
	if err != nil {
		return nil, fmt.Errorf("prepare appointment summary: %w", err)
	}
	defer stmt.Close()

	var c AppointmentBuckets
	if err := stmt.GetContext(ctx, &c, args); err != nil {
		return nil, fmt.Errorf("event appointment summary: %w", err)
	}
	return &c, nil
}
