package services

import (
	"context"
	"os"
	"sort"
	"testing"
	"time"

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

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

// TestProjectDeadline_ShapeStable spot-checks projectDeadline so a future
// addition to DeadlineWithProject doesn't silently drop a field from the
// EventListItem JSON shape the frontend relies on.
func TestProjectDeadline_ShapeStable(t *testing.T) {
	due := time.Date(2026, 8, 31, 0, 0, 0, 0, time.UTC)
	descr := "Reply to Defence"
	src := "fristenrechner"
	rcode := "RoP.029"
	rname := "Replik"
	rnameEN := "Reply"
	d := models.DeadlineWithProject{
		Deadline: models.Deadline{
			ID:          uuid.New(),
			ProjectID:   uuid.New(),
			Title:       "Statement of Defence",
			Description: &descr,
			DueDate:     due,
			Source:      src,
			RuleCode:    &rcode,
			Status:      "pending",
			EventTypeIDs: []uuid.UUID{uuid.New()},
		},
		ProjectTitle: "Acme v. Foo",
		ProjectType:  "case",
		RuleName:     &rname,
		RuleNameEN:   &rnameEN,
	}
	out := projectDeadline(d)

	if out.Type != "deadline" {
		t.Fatalf("type = %q, want deadline", out.Type)
	}
	if !out.EventDate.Equal(due) {
		t.Errorf("event_date = %v, want %v", out.EventDate, due)
	}
	if out.DueDate == nil || *out.DueDate != "2026-08-31" {
		t.Errorf("due_date = %v, want 2026-08-31", out.DueDate)
	}
	if out.Status == nil || *out.Status != "pending" {
		t.Errorf("status = %v, want pending", out.Status)
	}
	if out.RuleCode == nil || *out.RuleCode != "RoP.029" {
		t.Errorf("rule_code = %v, want RoP.029", out.RuleCode)
	}
	if out.StartAt != nil || out.EndAt != nil || out.Location != nil || out.AppointmentType != nil {
		t.Errorf("appointment-only fields leaked onto deadline projection: %+v", out)
	}
	if len(out.EventTypeIDs) != 1 {
		t.Errorf("event_type_ids length = %d, want 1", len(out.EventTypeIDs))
	}
}

// TestProjectAppointment_ShapeStable does the same for the appointment side.
func TestProjectAppointment_ShapeStable(t *testing.T) {
	start := time.Date(2026, 9, 15, 9, 0, 0, 0, time.UTC)
	end := start.Add(2 * time.Hour)
	loc := "UPC LD München"
	atype := "hearing"
	pid := uuid.New()
	ptitle := "Acme v. Foo"
	ptype := "case"
	a := models.AppointmentWithProject{
		Appointment: models.Appointment{
			ID:              uuid.New(),
			ProjectID:       &pid,
			Title:           "Mündliche Verhandlung",
			StartAt:         start,
			EndAt:           &end,
			Location:        &loc,
			AppointmentType: &atype,
		},
		ProjectTitle: &ptitle,
		ProjectType:  &ptype,
	}
	out := projectAppointment(a)

	if out.Type != "appointment" {
		t.Fatalf("type = %q, want appointment", out.Type)
	}
	if !out.EventDate.Equal(start) {
		t.Errorf("event_date = %v, want %v", out.EventDate, start)
	}
	if out.StartAt == nil || !out.StartAt.Equal(start) {
		t.Errorf("start_at = %v, want %v", out.StartAt, start)
	}
	if out.EndAt == nil || !out.EndAt.Equal(end) {
		t.Errorf("end_at = %v, want %v", out.EndAt, end)
	}
	if out.AppointmentType == nil || *out.AppointmentType != "hearing" {
		t.Errorf("appointment_type = %v, want hearing", out.AppointmentType)
	}
	if out.DueDate != nil || out.Status != nil || out.RuleCode != nil || len(out.EventTypeIDs) != 0 {
		t.Errorf("deadline-only fields leaked onto appointment projection: %+v", out)
	}
}

// TestInDateWindow checks the inclusive-on-both-ends window math used to
// post-filter deadlines in ListVisibleForUser when the caller passes from/to.
func TestInDateWindow(t *testing.T) {
	due := time.Date(2026, 5, 7, 0, 0, 0, 0, time.UTC)
	before := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC)
	after := time.Date(2026, 5, 14, 0, 0, 0, 0, time.UTC)

	if !inDateWindow(due, nil, nil) {
		t.Error("nil/nil window must include any date")
	}
	if !inDateWindow(due, &before, &after) {
		t.Error("expected due ∈ [before, after]")
	}
	if inDateWindow(due, &after, nil) {
		t.Error("date strictly before `from` must be excluded")
	}
	if inDateWindow(due, nil, &before) {
		t.Error("date strictly after `to` must be excluded")
	}
	// boundary inclusivity
	if !inDateWindow(due, &due, &due) {
		t.Error("from == to == due must be inclusive on both ends")
	}
}

// TestEventService_ListAndSummary_Live exercises the union list + bucket
// counts against a real database. Skipped when TEST_DATABASE_URL is unset.
func TestEventService_ListAndSummary_Live(t *testing.T) {
	url := os.Getenv("TEST_DATABASE_URL")
	if url == "" {
		t.Skip("TEST_DATABASE_URL not set — skipping live DB test")
	}
	if err := db.ApplyMigrations(url); err != nil {
		t.Fatalf("apply migrations: %v", err)
	}
	pool, err := sqlx.Connect("postgres", url)
	if err != nil {
		t.Fatalf("connect: %v", err)
	}
	defer pool.Close()

	ctx := context.Background()
	adminID := uuid.New()
	otherID := uuid.New() // co-admin used to seed items NOT created by adminID
	projectID := uuid.New()
	projectID2 := uuid.New() // second project so personal_only crosses projects
	d1 := uuid.New()         // overdue pending (admin)
	d2 := uuid.New()         // today pending (admin)
	d3 := uuid.New()         // next week pending (admin)
	d4 := uuid.New()         // completed (admin)
	d5 := uuid.New()         // pending in projectID created by otherID
	d6 := uuid.New()         // pending in projectID2 created by adminID
	a1 := uuid.New()         // today appointment (admin, projectID)
	a2 := uuid.New()         // later appointment (admin, projectID)
	a3 := uuid.New()         // soon appointment created by otherID (projectID)
	a4 := uuid.New()         // personal calendar appointment by adminID (no project)

	cleanup := func() {
		pool.ExecContext(ctx, `DELETE FROM paliad.appointments WHERE id IN ($1, $2, $3, $4)`, a1, a2, a3, a4)
		pool.ExecContext(ctx, `DELETE FROM paliad.deadlines    WHERE id IN ($1, $2, $3, $4, $5, $6)`, d1, d2, d3, d4, d5, d6)
		pool.ExecContext(ctx, `DELETE FROM paliad.project_events WHERE project_id IN ($1, $2)`, projectID, projectID2)
		pool.ExecContext(ctx, `DELETE FROM paliad.project_teams WHERE project_id IN ($1, $2)`, projectID, projectID2)
		pool.ExecContext(ctx, `DELETE FROM paliad.projects      WHERE id IN ($1, $2)`, projectID, projectID2)
		pool.ExecContext(ctx, `DELETE FROM paliad.users         WHERE id IN ($1, $2)`, adminID, otherID)
		pool.ExecContext(ctx, `DELETE FROM auth.users           WHERE id IN ($1, $2)`, adminID, otherID)
	}
	cleanup()
	defer cleanup()

	if _, err := pool.ExecContext(ctx,
		`INSERT INTO auth.users (id, email) VALUES ($1, $2), ($3, $4)`,
		adminID, "vis-events@hlc.com", otherID, "vis-events-other@hlc.com"); err != nil {
		t.Fatalf("seed auth.users: %v", err)
	}
	if _, err := pool.ExecContext(ctx,
		`INSERT INTO paliad.users (id, email, display_name, office, global_role, lang)
		 VALUES ($1, $2, 'Vis Events', 'munich', 'global_admin', 'de'),
		        ($3, $4, 'Vis Events Other', 'munich', 'global_admin', 'de')`,
		adminID, "vis-events@hlc.com", otherID, "vis-events-other@hlc.com"); err != nil {
		t.Fatalf("seed paliad.users: %v", err)
	}
	if _, err := pool.ExecContext(ctx,
		`INSERT INTO paliad.projects (id, type, path, title, reference, status, created_by)
		 VALUES ($1, 'project', $1::text, 'Vis Events Project', '2026/9994', 'active', $2),
		        ($3, 'project', $3::text, 'Vis Events Project 2', '2026/9995', 'active', $2)`,
		projectID, adminID, projectID2); err != nil {
		t.Fatalf("seed paliad.projects: %v", err)
	}

	now := time.Now().UTC()
	today := now.Truncate(24 * time.Hour)
	yesterday := today.AddDate(0, 0, -1)
	nextMon := today.AddDate(0, 0, ((7-int(today.Weekday()))%7)+1) // Mon-of-next-week
	farFuture := today.AddDate(0, 1, 0)

	// Six deadlines: 4 spanning bucket shapes (admin/projectID), one pending
	// in projectID created by otherID (so personal_only filters it out), one
	// pending in projectID2 created by adminID (so personal_only spans
	// projects).
	for _, d := range []struct {
		id        uuid.UUID
		project   uuid.UUID
		due       time.Time
		status    string
		createdBy uuid.UUID
	}{
		{d1, projectID, yesterday, "pending", adminID},
		{d2, projectID, today, "pending", adminID},
		{d3, projectID, nextMon, "pending", adminID},
		{d4, projectID, today, "completed", adminID},
		{d5, projectID, nextMon, "pending", otherID},
		{d6, projectID2, nextMon, "pending", adminID},
	} {
		if _, err := pool.ExecContext(ctx,
			`INSERT INTO paliad.deadlines
			   (id, project_id, title, due_date, source, status, completed_at, created_by)
			 VALUES ($1, $2, 'D', $3::date, 'manual', $4,
			         CASE WHEN $4 = 'completed' THEN $5::timestamptz END, $6)`,
			d.id, d.project, d.due.Format("2006-01-02"), d.status, now, d.createdBy); err != nil {
			t.Fatalf("seed deadline %s: %v", d.id, err)
		}
	}

	// Four appointments: admin's today + far-future on projectID; otherID's
	// soon appointment on projectID (foreign creator); admin's personal
	// calendar entry with NULL project_id.
	soon := today.Add(36 * time.Hour)
	for _, a := range []struct {
		id        uuid.UUID
		project   *uuid.UUID
		start     time.Time
		createdBy uuid.UUID
	}{
		{a1, &projectID, today.Add(13 * time.Hour), adminID},
		{a2, &projectID, farFuture, adminID},
		{a3, &projectID, soon, otherID},
		{a4, nil, today.Add(15 * time.Hour), adminID},
	} {
		if _, err := pool.ExecContext(ctx,
			`INSERT INTO paliad.appointments
			   (id, project_id, title, start_at, appointment_type, created_by)
			 VALUES ($1, $2, 'A', $3, 'meeting', $4)`,
			a.id, a.project, a.start, a.createdBy); err != nil {
			t.Fatalf("seed appointment %s: %v", a.id, err)
		}
	}

	users := NewUserService(pool)
	projects := NewProjectService(pool, users)
	eventTypes := NewEventTypeService(pool, users)
	deadlines := NewDeadlineService(pool, projects, eventTypes)
	appointments := NewAppointmentService(pool, projects)
	events := NewEventService(pool, deadlines, appointments)

	t.Run("ListVisibleForUser type=all merges + sorts", func(t *testing.T) {
		rows, err := events.ListVisibleForUser(ctx, adminID, EventListFilter{Type: EventTypeAll})
		if err != nil {
			t.Fatalf("List all: %v", err)
		}
		// Filter to seed rows so unrelated projects in the live DB don't
		// confuse the assertions.
		seedSet := map[uuid.UUID]string{
			d1: "deadline", d2: "deadline", d3: "deadline", d4: "deadline",
			a1: "appointment", a2: "appointment",
		}
		seen := []EventListItem{}
		for _, r := range rows {
			if _, ok := seedSet[r.ID]; ok {
				seen = append(seen, r)
			}
		}
		if len(seen) != len(seedSet) {
			t.Fatalf("merged rows = %d, want %d", len(seen), len(seedSet))
		}
		// Confirm sort: every neighbour pair is non-decreasing on EventDate.
		if !sort.SliceIsSorted(seen, func(i, j int) bool {
			return seen[i].EventDate.Before(seen[j].EventDate)
		}) {
			for _, r := range seen {
				t.Logf("  %s %s %s", r.Type, r.ID, r.EventDate.Format(time.RFC3339))
			}
			t.Fatal("merged rows not sorted by event_date asc")
		}
		// Type discriminator must round-trip.
		for _, r := range seen {
			if want := seedSet[r.ID]; want != r.Type {
				t.Errorf("row %s: type = %q, want %q", r.ID, r.Type, want)
			}
		}
	})

	t.Run("ListVisibleForUser type=deadline excludes appointments", func(t *testing.T) {
		rows, err := events.ListVisibleForUser(ctx, adminID, EventListFilter{Type: EventTypeDeadline})
		if err != nil {
			t.Fatalf("List deadline: %v", err)
		}
		for _, r := range rows {
			if r.Type != "deadline" {
				t.Errorf("type=deadline returned %q row", r.Type)
			}
			if r.ID == a1 || r.ID == a2 {
				t.Errorf("type=deadline leaked appointment %s", r.ID)
			}
		}
	})

	t.Run("ListVisibleForUser type=appointment excludes deadlines", func(t *testing.T) {
		rows, err := events.ListVisibleForUser(ctx, adminID, EventListFilter{Type: EventTypeAppointment})
		if err != nil {
			t.Fatalf("List appointment: %v", err)
		}
		for _, r := range rows {
			if r.Type != "appointment" {
				t.Errorf("type=appointment returned %q row", r.Type)
			}
		}
	})

	// t-paliad-123: the date-bucket Status filter (today/this_week/next_week/
	// later) must apply to appointments too, narrowing by start_at. The
	// frontend exposes these bucket values in the Termine view's Status
	// dropdown — backend must honour them.
	t.Run("ListVisibleForUser type=appointment + status=today narrows to today's appointments", func(t *testing.T) {
		rows, err := events.ListVisibleForUser(ctx, adminID, EventListFilter{
			Type:   EventTypeAppointment,
			Status: DeadlineFilterToday,
		})
		if err != nil {
			t.Fatalf("List appointment+today: %v", err)
		}
		sawA1 := false
		for _, r := range rows {
			if r.ID == a1 {
				sawA1 = true
			}
			if r.ID == a2 {
				t.Errorf("status=today must exclude far-future appointment %s", a2)
			}
		}
		if !sawA1 {
			t.Errorf("status=today must include today's appointment %s", a1)
		}
	})

	t.Run("ListVisibleForUser type=appointment + status=later narrows to far-future", func(t *testing.T) {
		rows, err := events.ListVisibleForUser(ctx, adminID, EventListFilter{
			Type:   EventTypeAppointment,
			Status: DeadlineFilterLater,
		})
		if err != nil {
			t.Fatalf("List appointment+later: %v", err)
		}
		sawA2 := false
		for _, r := range rows {
			if r.ID == a1 {
				t.Errorf("status=later must exclude today's appointment %s", a1)
			}
			if r.ID == a2 {
				sawA2 = true
			}
		}
		if !sawA2 {
			t.Errorf("status=later must include far-future appointment %s", a2)
		}
	})

	// Defensive: deadline-only statuses (overdue, completed) collapse the
	// appointment rail — useful when type=all so the appointment side
	// disappears alongside the deadline filter (the dropdown excludes
	// these for type=appointment, but URL-hacking still must behave).
	t.Run("ListVisibleForUser type=appointment + status=completed returns no appointments", func(t *testing.T) {
		rows, err := events.ListVisibleForUser(ctx, adminID, EventListFilter{
			Type:   EventTypeAppointment,
			Status: DeadlineFilterCompleted,
		})
		if err != nil {
			t.Fatalf("List appointment+completed: %v", err)
		}
		for _, r := range rows {
			if r.ID == a1 || r.ID == a2 {
				t.Errorf("status=completed must collapse appointments rail (got %s)", r.ID)
			}
		}
	})

	t.Run("SummaryCounts type=all has both rails populated", func(t *testing.T) {
		s, err := events.SummaryCounts(ctx, adminID, EventSummaryFilter{Type: EventTypeAll, ProjectID: &projectID})
		if err != nil {
			t.Fatalf("Summary all: %v", err)
		}
		if s.Deadlines == nil {
			t.Fatal("deadlines bucket missing")
		}
		if s.Appointments == nil {
			t.Fatal("appointments bucket missing")
		}
		// The seed has 1 overdue, 1 today, 1 next-week, 1 completed.
		if s.Deadlines.Overdue < 1 {
			t.Errorf("Deadlines.Overdue = %d, want >= 1", s.Deadlines.Overdue)
		}
		if s.Deadlines.Today < 1 {
			t.Errorf("Deadlines.Today = %d, want >= 1", s.Deadlines.Today)
		}
		if s.Deadlines.NextWeek < 1 {
			t.Errorf("Deadlines.NextWeek = %d, want >= 1", s.Deadlines.NextWeek)
		}
		if s.Deadlines.Completed < 1 {
			t.Errorf("Deadlines.Completed = %d, want >= 1", s.Deadlines.Completed)
		}
		// Appointments: 1 today, 1 later.
		if s.Appointments.Today < 1 {
			t.Errorf("Appointments.Today = %d, want >= 1", s.Appointments.Today)
		}
		if s.Appointments.Later < 1 {
			t.Errorf("Appointments.Later = %d, want >= 1", s.Appointments.Later)
		}
	})

	t.Run("SummaryCounts type=deadline omits appointments rail", func(t *testing.T) {
		s, err := events.SummaryCounts(ctx, adminID, EventSummaryFilter{Type: EventTypeDeadline, ProjectID: &projectID})
		if err != nil {
			t.Fatalf("Summary deadline: %v", err)
		}
		if s.Deadlines == nil {
			t.Fatal("deadlines bucket missing on type=deadline")
		}
		if s.Appointments != nil {
			t.Errorf("appointments rail must be omitted on type=deadline (got %+v)", s.Appointments)
		}
	})

	t.Run("SummaryCounts type=appointment omits deadlines rail", func(t *testing.T) {
		s, err := events.SummaryCounts(ctx, adminID, EventSummaryFilter{Type: EventTypeAppointment, ProjectID: &projectID})
		if err != nil {
			t.Fatalf("Summary appointment: %v", err)
		}
		if s.Appointments == nil {
			t.Fatal("appointments bucket missing on type=appointment")
		}
		if s.Deadlines != nil {
			t.Errorf("deadlines rail must be omitted on type=appointment (got %+v)", s.Deadlines)
		}
	})

	// t-paliad-128: PersonalOnly narrows BOTH rails to rows the caller
	// created. Spans projects (so a deadline created by adminID on
	// projectID2 still shows up) and excludes rows otherID created on a
	// project adminID can otherwise see.
	seedDeadlines := map[uuid.UUID]bool{d1: true, d2: true, d3: true, d4: true, d5: true, d6: true}
	seedAppointments := map[uuid.UUID]bool{a1: true, a2: true, a3: true, a4: true}

	t.Run("PersonalOnly type=deadline returns only caller-created deadlines across projects", func(t *testing.T) {
		rows, err := events.ListVisibleForUser(ctx, adminID, EventListFilter{
			Type:         EventTypeDeadline,
			PersonalOnly: true,
		})
		if err != nil {
			t.Fatalf("List deadline+personal: %v", err)
		}
		seen := map[uuid.UUID]bool{}
		for _, r := range rows {
			if !seedDeadlines[r.ID] {
				continue
			}
			if r.Type != "deadline" {
				t.Errorf("type=deadline returned %q row", r.Type)
			}
			if r.CreatedBy == nil || *r.CreatedBy != adminID {
				t.Errorf("personal_only leaked row %s with created_by=%v", r.ID, r.CreatedBy)
			}
			seen[r.ID] = true
		}
		// admin's: d1 d2 d3 d4 d6 — all should appear. d5 (otherID) must not.
		for _, want := range []uuid.UUID{d1, d2, d3, d4, d6} {
			if !seen[want] {
				t.Errorf("personal_only dropped admin-created deadline %s", want)
			}
		}
		if seen[d5] {
			t.Errorf("personal_only must exclude other-created deadline %s", d5)
		}
	})

	t.Run("PersonalOnly type=appointment returns only caller-created appointments (with + without project)", func(t *testing.T) {
		rows, err := events.ListVisibleForUser(ctx, adminID, EventListFilter{
			Type:         EventTypeAppointment,
			PersonalOnly: true,
		})
		if err != nil {
			t.Fatalf("List appointment+personal: %v", err)
		}
		seen := map[uuid.UUID]bool{}
		for _, r := range rows {
			if !seedAppointments[r.ID] {
				continue
			}
			if r.Type != "appointment" {
				t.Errorf("type=appointment returned %q row", r.Type)
			}
			if r.CreatedBy == nil || *r.CreatedBy != adminID {
				t.Errorf("personal_only leaked row %s with created_by=%v", r.ID, r.CreatedBy)
			}
			seen[r.ID] = true
		}
		// admin's: a1, a2 (project-attached), a4 (no project). a3 (otherID) must not.
		for _, want := range []uuid.UUID{a1, a2, a4} {
			if !seen[want] {
				t.Errorf("personal_only dropped admin-created appointment %s", want)
			}
		}
		if seen[a3] {
			t.Errorf("personal_only must exclude other-created appointment %s", a3)
		}
	})

	t.Run("PersonalOnly type=all returns the union of both", func(t *testing.T) {
		rows, err := events.ListVisibleForUser(ctx, adminID, EventListFilter{
			Type:         EventTypeAll,
			PersonalOnly: true,
		})
		if err != nil {
			t.Fatalf("List all+personal: %v", err)
		}
		seenD := map[uuid.UUID]bool{}
		seenA := map[uuid.UUID]bool{}
		for _, r := range rows {
			if r.CreatedBy == nil || *r.CreatedBy != adminID {
				if seedDeadlines[r.ID] || seedAppointments[r.ID] {
					t.Errorf("personal_only leaked row %s with created_by=%v", r.ID, r.CreatedBy)
				}
				continue
			}
			if r.Type == "deadline" && seedDeadlines[r.ID] {
				seenD[r.ID] = true
			}
			if r.Type == "appointment" && seedAppointments[r.ID] {
				seenA[r.ID] = true
			}
		}
		for _, want := range []uuid.UUID{d1, d2, d3, d4, d6} {
			if !seenD[want] {
				t.Errorf("personal_only union dropped admin-created deadline %s", want)
			}
		}
		for _, want := range []uuid.UUID{a1, a2, a4} {
			if !seenA[want] {
				t.Errorf("personal_only union dropped admin-created appointment %s", want)
			}
		}
	})
}
