package services

// DashboardService aggregates the logged-in landing-page payload. Scoped to
// Projects the caller can see — same predicate as ProjectService (team-based,
// v2 data model, t-paliad-024).

import (
	"context"
	"database/sql"
	"encoding/json"
	"errors"
	"fmt"
	"time"

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

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

// DashboardService reads paliad.projects/deadlines/appointments/project_events for
// the Dashboard page.
type DashboardService struct {
	db        *sqlx.DB
	users     *UserService
	approvals *ApprovalService
	pins      *PinService
}

func NewDashboardService(db *sqlx.DB, users *UserService) *DashboardService {
	return &DashboardService{db: db, users: users}
}

// SetApprovalService wires the inbox-approvals widget data source. Called
// post-construction so that DashboardService and ApprovalService can be
// stitched together at boot without a circular constructor dependency.
// Safe to leave nil — InboxSummary will then carry pending_count=0 and an
// empty entries list, and the widget renders its empty state.
func (s *DashboardService) SetApprovalService(a *ApprovalService) {
	s.approvals = a
}

// SetPinService wires the pinned-projects widget data source (Slice C).
// PinService pre-dates t-paliad-219 (mig 062/063) so no new schema is
// needed. Safe to leave nil — PinnedProjects then comes back empty and
// the widget renders its empty state.
func (s *DashboardService) SetPinService(p *PinService) {
	s.pins = p
}

// DashboardData is the full payload returned to the frontend.
type DashboardData struct {
	User                 *DashboardUser        `json:"user"`
	DeadlineSummary      DeadlineSummary       `json:"deadline_summary"`
	AppointmentSummary   AppointmentSummary    `json:"appointment_summary"`
	MatterSummary        MatterSummary         `json:"matter_summary"`
	UpcomingDeadlines    []UpcomingDeadline    `json:"upcoming_deadlines"`
	UpcomingAppointments []UpcomingAppointment `json:"upcoming_appointments"`
	RecentActivity       []ActivityEntry       `json:"recent_activity"`
	InboxSummary         InboxSummary          `json:"inbox_summary"`
	PinnedProjects       []PinnedProjectRef    `json:"pinned_projects"`
}

// PinnedProjectRef is one row in DashboardData.PinnedProjects — the
// minimum needed to render a clickable entry in the pinned-projects
// widget. Order matches PinService.ListPinned (pinned_at DESC).
type PinnedProjectRef struct {
	ProjectID    uuid.UUID `json:"project_id"        db:"id"`
	ProjectTitle string    `json:"project_title"     db:"title"`
	ProjectRef   string    `json:"project_reference" db:"reference"`
}

// PinnedProjectsCap caps the pinned-projects preview list. The widget
// count setting tops out at 20; we fetch the cap once and let the
// client trim further per the user's setting.
const PinnedProjectsCap = 20

// InboxSummary feeds the inbox-approvals widget on the configurable
// dashboard (t-paliad-219). PendingCount is the precise number of
// approval requests that await this user's approval; Top is a small
// preview list (up to InboxTopCap entries) ordered oldest-pending-first
// so the most urgent appears first.
//
// When the ApprovalService dependency is unwired (knowledge-platform-only
// deployments, tests), PendingCount=0 and Top=[] so the widget renders
// its empty state. The data path is read-only — no writes go through
// the dashboard payload.
type InboxSummary struct {
	PendingCount int          `json:"pending_count"`
	Top          []InboxEntry `json:"top"`
}

// InboxEntry is a single row in InboxSummary.Top — the minimum needed
// to render a clickable preview ("Frist X auf Akte Y, vorgeschlagen am Z").
type InboxEntry struct {
	RequestID    uuid.UUID `json:"id"`
	EntityType   string    `json:"entity_type"`
	EntityTitle  *string   `json:"entity_title,omitempty"`
	ProjectID    uuid.UUID `json:"project_id"`
	ProjectTitle string    `json:"project_title"`
	RequestedAt  time.Time `json:"requested_at"`
	RequesterID  uuid.UUID `json:"requester_id"`
	RequesterName string   `json:"requester_name"`
}

// InboxTopCap caps the preview list. The widget's count setting tops out
// at 10 (see WidgetCatalog inboxCounts); we fetch the cap once and let
// the client trim further per the user's setting.
const InboxTopCap = 10

type DashboardUser struct {
	ID          uuid.UUID `json:"id"`
	Email       string    `json:"email"`
	DisplayName string    `json:"display_name"`
	Office      string    `json:"office"`
	JobTitle    *string   `json:"job_title"`
	GlobalRole  string    `json:"global_role"`
}

// DeadlineSummary feeds the Dashboard "Fristen auf einen Blick" cards.
//
// Bucket math is identical to DeadlineService.SummaryCounts (single source
// of truth via computeDeadlineBucketBounds) so the Dashboard and the
// /deadlines summary cards always show the same numbers (t-paliad-106 →
// t-paliad-110). The Erledigt card is gone; status=completed stays
// reachable via the dropdown filter on the EventsPage but is no longer
// rendered as a card on the dashboard rail.
type DeadlineSummary 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"`
}

// AppointmentSummary feeds the Dashboard "Termine auf einen Blick" rail
// (t-paliad-110). Three cards: Heute / Diese Woche / Später. No Überfällig
// (past appointments aren't urgent — they happened) and no Nächste Woche
// (low-value distinction for appointments per the design doc).
type AppointmentSummary struct {
	Today    int `json:"today"     db:"today"`
	ThisWeek int `json:"this_week" db:"this_week"`
	Later    int `json:"later"     db:"later"`
}

// MatterSummary counts visible Projects by status. Field names kept as
// "matter" for JSON API compatibility with the dashboard client.
type MatterSummary struct {
	Active   int `json:"active"   db:"active"`
	Archived int `json:"archived" db:"archived"`
	Total    int `json:"total"    db:"total"`
}

// UpcomingDeadline is one row for "Kommende Deadlines".
type UpcomingDeadline struct {
	ID           uuid.UUID `json:"id"           db:"id"`
	Title        string    `json:"title"        db:"title"`
	DueDate      string    `json:"due_date"     db:"due_date"`
	ProjectID    uuid.UUID `json:"project_id"   db:"project_id"`
	ProjectTitle string    `json:"project_title" db:"project_title"`
	ProjectRef   string    `json:"project_reference"  db:"project_reference"`
	Urgency      string    `json:"urgency"`
}

// UpcomingAppointment is one row for "Kommende Appointments".
type UpcomingAppointment struct {
	ID           uuid.UUID  `json:"id"            db:"id"`
	Title        string     `json:"title"         db:"title"`
	StartAt      time.Time  `json:"start_at"      db:"start_at"`
	EndAt        *time.Time `json:"end_at"        db:"end_at"`
	Type         *string    `json:"type"          db:"appointment_type"`
	ProjectID    *uuid.UUID `json:"project_id"    db:"project_id"`
	ProjectTitle *string    `json:"project_title" db:"project_title"`
	ProjectRef   *string    `json:"project_reference"   db:"project_reference"`
}

// ActivityEntry is one row in the "Letzte Aktivität" feed.
type ActivityEntry struct {
	Timestamp    time.Time       `json:"timestamp"         db:"timestamp"`
	ActorEmail   *string         `json:"actor_email"       db:"actor_email"`
	ActorName    *string         `json:"actor_name"        db:"actor_name"`
	ProjectID    uuid.UUID       `json:"project_id"        db:"project_id"`
	ProjectTitle string          `json:"project_title"     db:"project_title"`
	ProjectRef   string          `json:"project_reference" db:"project_reference"`
	Action       *string         `json:"action"            db:"action"`
	Details      string          `json:"details"           db:"details"`
	Description  *string         `json:"description"       db:"description"`
	Metadata     json.RawMessage `json:"metadata"          db:"metadata"`
}

// Get builds the full dashboard payload.
func (s *DashboardService) Get(ctx context.Context, userID uuid.UUID) (*DashboardData, error) {
	user, err := s.users.GetByID(ctx, userID)
	if err != nil {
		return nil, err
	}

	data := &DashboardData{
		UpcomingDeadlines:    []UpcomingDeadline{},
		UpcomingAppointments: []UpcomingAppointment{},
		RecentActivity:       []ActivityEntry{},
		PinnedProjects:       []PinnedProjectRef{},
	}
	if user == nil {
		return data, nil
	}
	data.User = &DashboardUser{
		ID:          user.ID,
		Email:       user.Email,
		DisplayName: user.DisplayName,
		Office:      user.Office,
		JobTitle:    user.JobTitle,
		GlobalRole:  user.GlobalRole,
	}

	now := time.Now()
	today := now.Format("2006-01-02")
	// t-paliad-219 §18 Note B: widen the upcoming windows from 7d → 60d
	// so the per-widget horizon dropdown (7/14/30/60) can filter client-
	// side without re-querying. LIMIT bumps from 10 to 40 for the same
	// reason — the widget's count setting tops out at 20 plus headroom
	// for the agenda widget which can read from the same payload.
	endOfWindow := now.AddDate(0, 0, 60).Format("2006-01-02")
	bounds := computeDeadlineBucketBounds(now.UTC())

	if err := s.loadSummary(ctx, data, user, bounds); err != nil {
		return nil, err
	}
	if err := s.loadUpcomingDeadlines(ctx, data, user, today, endOfWindow); err != nil {
		return nil, err
	}
	if err := s.loadUpcomingAppointments(ctx, data, user, now); err != nil {
		return nil, err
	}
	if err := s.loadRecentActivity(ctx, data, user); err != nil {
		return nil, err
	}
	if err := s.loadInboxSummary(ctx, data, user); err != nil {
		return nil, err
	}
	if err := s.loadPinnedProjects(ctx, data, user); err != nil {
		return nil, err
	}

	annotateUrgency(data.UpcomingDeadlines, now)
	return data, nil
}

// loadPinnedProjects populates DashboardData.PinnedProjects (Slice C).
// Reads PinService.ListPinned for ordering, then materialises titles +
// references via a single visibility-filtered SELECT. When PinService
// is unwired or the user has no pins, the field comes back empty and
// the widget renders its empty state.
func (s *DashboardService) loadPinnedProjects(ctx context.Context, data *DashboardData, user *models.User) error {
	if s.pins == nil {
		return nil
	}
	ids, err := s.pins.ListPinned(ctx, user.ID)
	if err != nil {
		return fmt.Errorf("dashboard pinned ids: %w", err)
	}
	if len(ids) == 0 {
		return nil
	}
	if len(ids) > PinnedProjectsCap {
		ids = ids[:PinnedProjectsCap]
	}
	query := `
SELECT p.id,
       p.title,
       COALESCE(p.reference, '') AS reference
  FROM paliad.projects p
 WHERE p.id = ANY($2::uuid[])
   AND ` + visibilityPredicatePositional("p", 1)
	rows := []PinnedProjectRef{}
	if err := s.db.SelectContext(ctx, &rows, query, user.ID, idsToArray(ids)); err != nil {
		return fmt.Errorf("dashboard pinned projects: %w", err)
	}
	// Restore pinned-at order: PinService.ListPinned ordered DESC; the
	// SELECT above doesn't preserve that. Build a position map and
	// re-sort.
	pos := make(map[uuid.UUID]int, len(ids))
	for i, id := range ids {
		pos[id] = i
	}
	out := make([]PinnedProjectRef, 0, len(rows))
	for _, r := range rows {
		out = append(out, r)
	}
	// Tiny n (cap=20); a simple insertion-style swap is enough.
	for i := 0; i < len(out); i++ {
		for j := i + 1; j < len(out); j++ {
			if pos[out[j].ProjectID] < pos[out[i].ProjectID] {
				out[i], out[j] = out[j], out[i]
			}
		}
	}
	data.PinnedProjects = out
	return nil
}

// idsToArray flips a []uuid.UUID into a Postgres uuid[] payload via
// pq.Array — mirrors the pattern in rule_editor_orphans.go.
func idsToArray(ids []uuid.UUID) interface{} {
	out := make([]string, len(ids))
	for i, id := range ids {
		out[i] = id.String()
	}
	return pq.Array(out)
}

// loadSummary fills DeadlineSummary + AppointmentSummary + MatterSummary.
//
// Bucket math comes from computeDeadlineBucketBounds (deadline_service.go) so
// the buckets stay in lockstep with /api/deadlines/summary and the unified
// /api/events/summary used by the EventsPage (t-paliad-110).
//
// Bucket model on the Dashboard:
//   - Fristen rail: Überfällig (conditional alarm) · Heute · Diese Woche ·
//     Nächste Woche · Später. Erledigt is no longer rendered as a card —
//     status=completed stays reachable via the EventsPage filter dropdown.
//   - Termine rail: Heute · Diese Woche · Später. No Überfällig, no
//     Nächste Woche (low-value distinction for appointments).
//
// Visibility predicate: see internal/services/visibility.go — global_admin
// shortcut OR any ancestor-or-direct team membership. Applied once via a CTE;
// downstream queries reuse the same helper.
func (s *DashboardService) loadSummary(ctx context.Context, data *DashboardData, user *models.User, bounds deadlineBucketBounds) error {
	query := `
WITH visible_projekte AS (
    SELECT p.id, p.status
      FROM paliad.projects p
     WHERE ` + visibilityPredicatePositional("p", 1) + `
),
deadline_stats AS (
    SELECT
        COUNT(*) FILTER (WHERE f.status = 'pending'  AND f.due_date <  $2::date)                                                    AS overdue,
        COUNT(*) FILTER (WHERE f.status = 'pending'  AND f.due_date =  $2::date)                                                    AS today,
        COUNT(*) FILTER (WHERE f.status = 'pending'  AND f.due_date >= $3::date AND f.due_date <  $4::date)                         AS this_week,
        COUNT(*) FILTER (WHERE f.status = 'pending'  AND f.due_date >= $4::date AND f.due_date <  $5::date)                         AS next_week,
        COUNT(*) FILTER (WHERE f.status = 'pending'  AND f.due_date >= $5::date)                                                    AS later
      FROM paliad.deadlines f
      JOIN visible_projekte v ON v.id = f.project_id
),
appointment_stats AS (
    SELECT
        COUNT(*) FILTER (WHERE t.start_at >= $2       AND t.start_at <  $3)        AS today,
        COUNT(*) FILTER (WHERE t.start_at >= $3       AND t.start_at <  $4)        AS this_week,
        COUNT(*) FILTER (WHERE t.start_at >= $5)                                   AS later
      FROM paliad.appointments t
      LEFT JOIN visible_projekte v ON v.id = t.project_id
     WHERE (t.project_id IS NULL AND t.created_by = $1)
        OR (t.project_id IS NOT NULL AND v.id IS NOT NULL)
),
matter_stats AS (
    SELECT
        COUNT(*) FILTER (WHERE status = 'active')   AS active,
        COUNT(*) FILTER (WHERE status = 'archived') AS archived,
        COUNT(*)                                    AS total
      FROM visible_projekte
)
SELECT ds.overdue, ds.today, ds.this_week, ds.next_week, ds.later,
       aps.today AS appt_today, aps.this_week AS appt_this_week, aps.later AS appt_later,
       ms.active, ms.archived, ms.total
  FROM deadline_stats ds, appointment_stats aps, matter_stats ms`

	var row struct {
		DeadlineSummary
		ApptToday    int `db:"appt_today"`
		ApptThisWeek int `db:"appt_this_week"`
		ApptLater    int `db:"appt_later"`
		MatterSummary
	}
	err := s.db.GetContext(ctx, &row, query,
		user.ID, bounds.today, bounds.tomorrow, bounds.nextMonday, bounds.weekAfter)
	if errors.Is(err, sql.ErrNoRows) {
		return nil
	}
	if err != nil {
		return fmt.Errorf("dashboard summary: %w", err)
	}
	data.DeadlineSummary = row.DeadlineSummary
	data.AppointmentSummary = AppointmentSummary{
		Today:    row.ApptToday,
		ThisWeek: row.ApptThisWeek,
		Later:    row.ApptLater,
	}
	data.MatterSummary = row.MatterSummary
	return nil
}

func (s *DashboardService) loadUpcomingDeadlines(ctx context.Context, data *DashboardData, user *models.User, today, endOfWeek string) error {
	query := `
SELECT f.id,
       f.title,
       to_char(f.due_date, 'YYYY-MM-DD')                 AS due_date,
       p.id                                              AS project_id,
       p.title                                           AS project_title,
       COALESCE(p.reference, '')                         AS project_reference
  FROM paliad.deadlines f
  JOIN paliad.projects p ON p.id = f.project_id
 WHERE f.status = 'pending'
   AND f.due_date >= $2::date
   AND f.due_date <= $3::date
   AND ` + visibilityPredicatePositional("p", 1) + `
 ORDER BY f.due_date ASC
 LIMIT 40`
	if err := s.db.SelectContext(ctx, &data.UpcomingDeadlines, query,
		user.ID, today, endOfWeek); err != nil {
		return fmt.Errorf("dashboard upcoming deadlines: %w", err)
	}
	return nil
}

// loadInboxSummary populates DashboardData.InboxSummary — the open-
// approval count + top InboxTopCap entries for the inbox-approvals
// widget (t-paliad-219). When ApprovalService is unwired (knowledge-
// platform-only deployments, tests), the function is a no-op and the
// widget renders its empty state.
func (s *DashboardService) loadInboxSummary(ctx context.Context, data *DashboardData, user *models.User) error {
	data.InboxSummary = InboxSummary{Top: []InboxEntry{}}
	if s.approvals == nil {
		return nil
	}
	cnt, err := s.approvals.PendingCountForUser(ctx, user.ID)
	if err != nil {
		return fmt.Errorf("dashboard inbox count: %w", err)
	}
	data.InboxSummary.PendingCount = cnt
	if cnt == 0 {
		return nil
	}
	rows, err := s.approvals.ListPendingForApprover(ctx, user.ID, InboxFilter{Limit: InboxTopCap})
	if err != nil {
		return fmt.Errorf("dashboard inbox top: %w", err)
	}
	top := make([]InboxEntry, 0, len(rows))
	for _, r := range rows {
		top = append(top, InboxEntry{
			RequestID:     r.ID,
			EntityType:    r.EntityType,
			EntityTitle:   r.EntityTitle,
			ProjectID:     r.ProjectID,
			ProjectTitle:  r.ProjectTitle,
			RequestedAt:   r.RequestedAt,
			RequesterID:   r.RequestedBy,
			RequesterName: r.RequesterName,
		})
	}
	data.InboxSummary.Top = top
	return nil
}

func (s *DashboardService) loadUpcomingAppointments(ctx context.Context, data *DashboardData, user *models.User, now time.Time) error {
	query := `
SELECT t.id,
       t.title,
       t.start_at,
       t.end_at,
       t.appointment_type,
       t.project_id,
       p.title                    AS project_title,
       COALESCE(p.reference, NULL) AS project_reference
  FROM paliad.appointments t
  LEFT JOIN paliad.projects p ON p.id = t.project_id
 WHERE t.start_at >= $2
   AND t.start_at <  ($2 + interval '60 days')
   AND (
        (t.project_id IS NULL AND t.created_by = $1)
        OR (t.project_id IS NOT NULL AND ` + visibilityPredicatePositional("p", 1) + `)
       )
 ORDER BY t.start_at ASC
 LIMIT 40`
	if err := s.db.SelectContext(ctx, &data.UpcomingAppointments, query,
		user.ID, now); err != nil {
		return fmt.Errorf("dashboard upcoming appointments: %w", err)
	}
	return nil
}

func (s *DashboardService) loadRecentActivity(ctx context.Context, data *DashboardData, user *models.User) error {
	query := `
SELECT COALESCE(e.event_date, e.created_at) AS timestamp,
       u.email                              AS actor_email,
       u.display_name                       AS actor_name,
       e.project_id,
       p.title                              AS project_title,
       COALESCE(p.reference, '')            AS project_reference,
       e.event_type                         AS action,
       e.title                              AS details,
       e.description,
       e.metadata                           AS metadata
  FROM paliad.project_events e
  JOIN paliad.projects p ON p.id = e.project_id
  LEFT JOIN paliad.users u ON u.id = e.created_by
 WHERE ` + visibilityPredicatePositional("p", 1) + `
 ORDER BY COALESCE(e.event_date, e.created_at) DESC
 LIMIT 10`
	if err := s.db.SelectContext(ctx, &data.RecentActivity, query, user.ID); err != nil {
		return fmt.Errorf("dashboard recent activity: %w", err)
	}
	return nil
}

func annotateUrgency(deadlines []UpcomingDeadline, now time.Time) {
	today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
	for i := range deadlines {
		due, err := time.ParseInLocation("2006-01-02", deadlines[i].DueDate, now.Location())
		if err != nil {
			deadlines[i].Urgency = "soon"
			continue
		}
		days := int(due.Sub(today).Hours() / 24)
		switch {
		case days < 0:
			deadlines[i].Urgency = "overdue"
		case days == 0:
			deadlines[i].Urgency = "today"
		case days <= 2:
			deadlines[i].Urgency = "urgent"
		default:
			deadlines[i].Urgency = "soon"
		}
	}
}
