package categorization_test

import (
	"testing"
	"time"

	"github.com/stretchr/testify/assert"
	"github.com/treytartt/honeydue-api/internal/models"
	"github.com/treytartt/honeydue-api/internal/task/categorization"
)

// Ensure assert is used (referenced in fuzz/property tests below)
var _ = assert.Equal

// Helper to create a time pointer
func timePtr(t time.Time) *time.Time {
	return &t
}

func TestCategorizeTask_PriorityOrder(t *testing.T) {
	now := time.Now().UTC()
	yesterday := now.AddDate(0, 0, -1)
	in5Days := now.AddDate(0, 0, 5)
	in60Days := now.AddDate(0, 0, 60)
	daysThreshold := 30

	tests := []struct {
		name     string
		task     *models.Task
		expected categorization.KanbanColumn
	}{
		// Priority 1: Cancelled
		{
			name: "cancelled takes priority over everything",
			task: &models.Task{
				IsCancelled: true,
				NextDueDate: timePtr(yesterday), // Would be overdue
				InProgress:  true,               // Would be in progress
				Completions: []models.TaskCompletion{{BaseModel: models.BaseModel{ID: 1}}}, // Would be completed if NextDueDate was nil
			},
			expected: categorization.ColumnCancelled,
		},

		// Priority 2: Completed
		{
			name: "completed: NextDueDate nil with completions",
			task: &models.Task{
				IsCancelled: false,
				IsArchived:  false,
				NextDueDate: nil,
				DueDate:     timePtr(yesterday), // Would be overdue if not completed
				Completions: []models.TaskCompletion{{BaseModel: models.BaseModel{ID: 1}}},
			},
			expected: categorization.ColumnCompleted,
		},
		{
			name: "not completed when NextDueDate set (recurring task with completions)",
			task: &models.Task{
				IsCancelled: false,
				IsArchived:  false,
				NextDueDate: timePtr(in5Days),
				Completions: []models.TaskCompletion{{BaseModel: models.BaseModel{ID: 1}}},
			},
			expected: categorization.ColumnDueSoon, // Falls through to due soon
		},

		// Priority 3: In Progress
		{
			name: "in progress takes priority over overdue",
			task: &models.Task{
				IsCancelled: false,
				IsArchived:  false,
				NextDueDate: timePtr(yesterday), // Would be overdue
				InProgress:  true,
				Completions: []models.TaskCompletion{},
			},
			expected: categorization.ColumnInProgress,
		},

		// Priority 4: Overdue
		{
			name: "overdue: effective date in past",
			task: &models.Task{
				IsCancelled: false,
				IsArchived:  false,
				NextDueDate: timePtr(yesterday),
				Completions: []models.TaskCompletion{},
			},
			expected: categorization.ColumnOverdue,
		},
		{
			name: "overdue: uses DueDate when NextDueDate nil (no completions)",
			task: &models.Task{
				IsCancelled: false,
				IsArchived:  false,
				NextDueDate: nil,
				DueDate:     timePtr(yesterday),
				Completions: []models.TaskCompletion{},
			},
			expected: categorization.ColumnOverdue,
		},

		// Priority 5: Due Soon
		{
			name: "due soon: within threshold",
			task: &models.Task{
				IsCancelled: false,
				IsArchived:  false,
				NextDueDate: timePtr(in5Days),
				Completions: []models.TaskCompletion{},
			},
			expected: categorization.ColumnDueSoon,
		},

		// Priority 6: Upcoming (default)
		{
			name: "upcoming: beyond threshold",
			task: &models.Task{
				IsCancelled: false,
				IsArchived:  false,
				NextDueDate: timePtr(in60Days),
				Completions: []models.TaskCompletion{},
			},
			expected: categorization.ColumnUpcoming,
		},
		{
			name: "upcoming: no due date",
			task: &models.Task{
				IsCancelled: false,
				IsArchived:  false,
				NextDueDate: nil,
				DueDate:     nil,
				Completions: []models.TaskCompletion{},
			},
			expected: categorization.ColumnUpcoming,
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			result := categorization.CategorizeTask(tt.task, daysThreshold)
			if result != tt.expected {
				t.Errorf("CategorizeTask() = %v, expected %v", result, tt.expected)
			}
		})
	}
}

func TestCategorizeTasksIntoColumns(t *testing.T) {
	now := time.Now().UTC()
	yesterday := now.AddDate(0, 0, -1)
	in5Days := now.AddDate(0, 0, 5)
	in60Days := now.AddDate(0, 0, 60)
	daysThreshold := 30

	tasks := []models.Task{
		{BaseModel: models.BaseModel{ID: 1}, IsCancelled: true},                                                                             // Cancelled
		{BaseModel: models.BaseModel{ID: 2}, NextDueDate: nil, Completions: []models.TaskCompletion{{BaseModel: models.BaseModel{ID: 1}}}}, // Completed
		{BaseModel: models.BaseModel{ID: 3}, InProgress: true},                                                                              // In Progress
		{BaseModel: models.BaseModel{ID: 4}, NextDueDate: timePtr(yesterday)},                                                               // Overdue
		{BaseModel: models.BaseModel{ID: 5}, NextDueDate: timePtr(in5Days)},                                                                 // Due Soon
		{BaseModel: models.BaseModel{ID: 6}, NextDueDate: timePtr(in60Days)},                                                                // Upcoming
		{BaseModel: models.BaseModel{ID: 7}},                                                                                                // Upcoming (no due date)
	}

	result := categorization.CategorizeTasksIntoColumns(tasks, daysThreshold)

	// Check each column has the expected tasks
	if len(result[categorization.ColumnCancelled]) != 1 || result[categorization.ColumnCancelled][0].ID != 1 {
		t.Errorf("Expected task 1 in Cancelled column")
	}
	if len(result[categorization.ColumnCompleted]) != 1 || result[categorization.ColumnCompleted][0].ID != 2 {
		t.Errorf("Expected task 2 in Completed column")
	}
	if len(result[categorization.ColumnInProgress]) != 1 || result[categorization.ColumnInProgress][0].ID != 3 {
		t.Errorf("Expected task 3 in InProgress column")
	}
	if len(result[categorization.ColumnOverdue]) != 1 || result[categorization.ColumnOverdue][0].ID != 4 {
		t.Errorf("Expected task 4 in Overdue column")
	}
	if len(result[categorization.ColumnDueSoon]) != 1 || result[categorization.ColumnDueSoon][0].ID != 5 {
		t.Errorf("Expected task 5 in DueSoon column")
	}
	if len(result[categorization.ColumnUpcoming]) != 2 {
		t.Errorf("Expected 2 tasks in Upcoming column, got %d", len(result[categorization.ColumnUpcoming]))
	}
}

func TestDetermineKanbanColumn_ReturnsString(t *testing.T) {
	task := &models.Task{IsCancelled: true}
	result := categorization.DetermineKanbanColumn(task, 30)

	if result != "cancelled_tasks" {
		t.Errorf("DetermineKanbanColumn() = %v, expected %v", result, "cancelled_tasks")
	}
}

func TestKanbanColumnConstants(t *testing.T) {
	// Verify column string values match expected API values
	tests := []struct {
		column   categorization.KanbanColumn
		expected string
	}{
		{categorization.ColumnOverdue, "overdue_tasks"},
		{categorization.ColumnDueSoon, "due_soon_tasks"},
		{categorization.ColumnUpcoming, "upcoming_tasks"},
		{categorization.ColumnInProgress, "in_progress_tasks"},
		{categorization.ColumnCompleted, "completed_tasks"},
		{categorization.ColumnCancelled, "cancelled_tasks"},
	}

	for _, tt := range tests {
		if tt.column.String() != tt.expected {
			t.Errorf("Column %v.String() = %v, expected %v", tt.column, tt.column.String(), tt.expected)
		}
	}
}

func TestNewContext_DefaultThreshold(t *testing.T) {
	task := &models.Task{}

	// Zero threshold should default to 30
	ctx := categorization.NewContext(task, 0)
	if ctx.DaysThreshold != 30 {
		t.Errorf("NewContext with 0 threshold should default to 30, got %d", ctx.DaysThreshold)
	}

	// Negative threshold should default to 30
	ctx = categorization.NewContext(task, -5)
	if ctx.DaysThreshold != 30 {
		t.Errorf("NewContext with negative threshold should default to 30, got %d", ctx.DaysThreshold)
	}

	// Positive threshold should be used
	ctx = categorization.NewContext(task, 45)
	if ctx.DaysThreshold != 45 {
		t.Errorf("NewContext with 45 threshold should be 45, got %d", ctx.DaysThreshold)
	}
}

// ============================================================================
// TIMEZONE TESTS
// These tests verify that kanban categorization works correctly across timezones.
// The key insight: a task's due date is stored as a date (YYYY-MM-DD), but
// categorization depends on "what day is it NOW" in the user's timezone.
// ============================================================================

func TestTimezone_SameTaskDifferentCategorization(t *testing.T) {
	// Scenario: A task due on Dec 17, 2025
	// At 11 PM UTC on Dec 16 (still Dec 16 in UTC)
	// But 8 AM on Dec 17 in Tokyo (+9 hours)
	// The task should be "due_soon" for UTC user but already in "due_soon" for Tokyo
	// (not overdue yet for either - both are still on or before Dec 17)

	// Task due Dec 17, 2025 (stored as midnight UTC)
	taskDueDate := time.Date(2025, 12, 17, 0, 0, 0, 0, time.UTC)

	task := &models.Task{
		NextDueDate: timePtr(taskDueDate),
		IsCancelled: false,
		IsArchived:  false,
		Completions: []models.TaskCompletion{},
	}

	// User in UTC: It's Dec 16, 2025 at 11 PM UTC
	utcTime := time.Date(2025, 12, 16, 23, 0, 0, 0, time.UTC)

	// User in Tokyo: Same instant but it's Dec 17, 2025 at 8 AM local
	tokyo, _ := time.LoadLocation("Asia/Tokyo")
	tokyoTime := utcTime.In(tokyo) // Same instant, different representation

	// For UTC user: Dec 17 is tomorrow (1 day away) - should be due_soon
	resultUTC := categorization.CategorizeTaskWithTime(task, 30, utcTime)
	if resultUTC != categorization.ColumnDueSoon {
		t.Errorf("UTC (Dec 16): expected due_soon, got %v", resultUTC)
	}

	// For Tokyo user: Dec 17 is TODAY - should still be due_soon (not overdue)
	resultTokyo := categorization.CategorizeTaskWithTime(task, 30, tokyoTime)
	if resultTokyo != categorization.ColumnDueSoon {
		t.Errorf("Tokyo (Dec 17): expected due_soon, got %v", resultTokyo)
	}
}

func TestTimezone_TaskBecomesOverdue_DifferentTimezones(t *testing.T) {
	// Scenario: A task due on Dec 16, 2025
	// At 11 PM UTC on Dec 16 (still Dec 16 in UTC) - due_soon
	// At 8 AM UTC on Dec 17 - now overdue
	// But for Tokyo user at 11 PM UTC (8 AM Dec 17 Tokyo) - already overdue

	taskDueDate := time.Date(2025, 12, 16, 0, 0, 0, 0, time.UTC)

	task := &models.Task{
		NextDueDate: timePtr(taskDueDate),
		IsCancelled: false,
		IsArchived:  false,
		Completions: []models.TaskCompletion{},
	}

	// Case 1: UTC user at 11 PM on Dec 16 - task is due TODAY, so due_soon
	utcDec16Evening := time.Date(2025, 12, 16, 23, 0, 0, 0, time.UTC)
	resultUTCEvening := categorization.CategorizeTaskWithTime(task, 30, utcDec16Evening)
	if resultUTCEvening != categorization.ColumnDueSoon {
		t.Errorf("UTC Dec 16 evening: expected due_soon, got %v", resultUTCEvening)
	}

	// Case 2: UTC user at 8 AM on Dec 17 - task is now OVERDUE
	utcDec17Morning := time.Date(2025, 12, 17, 8, 0, 0, 0, time.UTC)
	resultUTCMorning := categorization.CategorizeTaskWithTime(task, 30, utcDec17Morning)
	if resultUTCMorning != categorization.ColumnOverdue {
		t.Errorf("UTC Dec 17 morning: expected overdue, got %v", resultUTCMorning)
	}

	// Case 3: Tokyo user at the same instant as case 1
	// 11 PM UTC = 8 AM Dec 17 in Tokyo
	// For Tokyo user, Dec 16 was yesterday, so task is OVERDUE
	tokyo, _ := time.LoadLocation("Asia/Tokyo")
	tokyoTime := utcDec16Evening.In(tokyo)
	resultTokyo := categorization.CategorizeTaskWithTime(task, 30, tokyoTime)
	if resultTokyo != categorization.ColumnOverdue {
		t.Errorf("Tokyo (same instant as UTC Dec 16 evening): expected overdue, got %v", resultTokyo)
	}
}

func TestTimezone_InternationalDateLine(t *testing.T) {
	// Test across the international date line
	// Auckland (UTC+13) vs Honolulu (UTC-10)
	// 23 hour difference!

	// Task due Dec 17, 2025
	taskDueDate := time.Date(2025, 12, 17, 0, 0, 0, 0, time.UTC)

	task := &models.Task{
		NextDueDate: timePtr(taskDueDate),
		IsCancelled: false,
		IsArchived:  false,
		Completions: []models.TaskCompletion{},
	}

	// At midnight UTC on Dec 17
	utcTime := time.Date(2025, 12, 17, 0, 0, 0, 0, time.UTC)

	// Auckland: Dec 17 midnight UTC = Dec 17, 1 PM local (UTC+13)
	// Task is due today in Auckland - should be due_soon
	auckland, _ := time.LoadLocation("Pacific/Auckland")
	aucklandTime := utcTime.In(auckland)
	resultAuckland := categorization.CategorizeTaskWithTime(task, 30, aucklandTime)
	if resultAuckland != categorization.ColumnDueSoon {
		t.Errorf("Auckland (Dec 17, 1 PM): expected due_soon, got %v", resultAuckland)
	}

	// Honolulu: Dec 17 midnight UTC = Dec 16, 2 PM local (UTC-10)
	// Task is due tomorrow in Honolulu - should be due_soon
	honolulu, _ := time.LoadLocation("Pacific/Honolulu")
	honoluluTime := utcTime.In(honolulu)
	resultHonolulu := categorization.CategorizeTaskWithTime(task, 30, honoluluTime)
	if resultHonolulu != categorization.ColumnDueSoon {
		t.Errorf("Honolulu (Dec 16, 2 PM): expected due_soon, got %v", resultHonolulu)
	}

	// Now advance to Dec 18 midnight UTC
	// Auckland: Dec 18, 1 PM local - task due Dec 17 is now OVERDUE
	// Honolulu: Dec 17, 2 PM local - task due Dec 17 is TODAY (due_soon)
	utcDec18 := time.Date(2025, 12, 18, 0, 0, 0, 0, time.UTC)

	aucklandDec18 := utcDec18.In(auckland)
	resultAuckland2 := categorization.CategorizeTaskWithTime(task, 30, aucklandDec18)
	if resultAuckland2 != categorization.ColumnOverdue {
		t.Errorf("Auckland (Dec 18): expected overdue, got %v", resultAuckland2)
	}

	honoluluDec17 := utcDec18.In(honolulu)
	resultHonolulu2 := categorization.CategorizeTaskWithTime(task, 30, honoluluDec17)
	if resultHonolulu2 != categorization.ColumnDueSoon {
		t.Errorf("Honolulu (Dec 17): expected due_soon, got %v", resultHonolulu2)
	}
}

func TestTimezone_DueSoonThreshold_CrossesTimezones(t *testing.T) {
	// Test that the 30-day threshold is calculated correctly in different timezones

	// Task due 29 days from now (within threshold for both timezones)
	// Task due 31 days from now (outside threshold)

	now := time.Date(2025, 12, 16, 12, 0, 0, 0, time.UTC)

	// Task due in 29 days
	due29Days := time.Date(2026, 1, 14, 0, 0, 0, 0, time.UTC)
	task29 := &models.Task{
		NextDueDate: timePtr(due29Days),
		IsCancelled: false,
		IsArchived:  false,
		Completions: []models.TaskCompletion{},
	}

	// Task due in 31 days
	due31Days := time.Date(2026, 1, 16, 0, 0, 0, 0, time.UTC)
	task31 := &models.Task{
		NextDueDate: timePtr(due31Days),
		IsCancelled: false,
		IsArchived:  false,
		Completions: []models.TaskCompletion{},
	}

	// UTC user
	result29UTC := categorization.CategorizeTaskWithTime(task29, 30, now)
	if result29UTC != categorization.ColumnDueSoon {
		t.Errorf("29 days (UTC): expected due_soon, got %v", result29UTC)
	}

	result31UTC := categorization.CategorizeTaskWithTime(task31, 30, now)
	if result31UTC != categorization.ColumnUpcoming {
		t.Errorf("31 days (UTC): expected upcoming, got %v", result31UTC)
	}

	// Tokyo user at same instant
	tokyo, _ := time.LoadLocation("Asia/Tokyo")
	tokyoNow := now.In(tokyo)

	result29Tokyo := categorization.CategorizeTaskWithTime(task29, 30, tokyoNow)
	if result29Tokyo != categorization.ColumnDueSoon {
		t.Errorf("29 days (Tokyo): expected due_soon, got %v", result29Tokyo)
	}

	result31Tokyo := categorization.CategorizeTaskWithTime(task31, 30, tokyoNow)
	if result31Tokyo != categorization.ColumnUpcoming {
		t.Errorf("31 days (Tokyo): expected upcoming, got %v", result31Tokyo)
	}
}

func TestTimezone_StartOfDayNormalization(t *testing.T) {
	// Test that times are normalized to start of day in the given timezone

	// A task due Dec 17
	taskDueDate := time.Date(2025, 12, 17, 0, 0, 0, 0, time.UTC)
	task := &models.Task{
		NextDueDate: timePtr(taskDueDate),
		IsCancelled: false,
		IsArchived:  false,
		Completions: []models.TaskCompletion{},
	}

	// Test that different times on the SAME DAY produce the SAME result
	// All of these should evaluate to "Dec 16" (today), making Dec 17 "due_soon"
	times := []time.Time{
		time.Date(2025, 12, 16, 0, 0, 0, 0, time.UTC),   // Midnight
		time.Date(2025, 12, 16, 6, 0, 0, 0, time.UTC),   // 6 AM
		time.Date(2025, 12, 16, 12, 0, 0, 0, time.UTC),  // Noon
		time.Date(2025, 12, 16, 18, 0, 0, 0, time.UTC),  // 6 PM
		time.Date(2025, 12, 16, 23, 59, 59, 0, time.UTC), // Just before midnight
	}

	for _, nowTime := range times {
		result := categorization.CategorizeTaskWithTime(task, 30, nowTime)
		if result != categorization.ColumnDueSoon {
			t.Errorf("At %v: expected due_soon, got %v", nowTime.Format("15:04:05"), result)
		}
	}
}

func TestTimezone_DST_Transitions(t *testing.T) {
	// Test behavior during daylight saving time transitions
	// Los Angeles transitions from PDT to PST in early November

	la, err := time.LoadLocation("America/Los_Angeles")
	if err != nil {
		t.Skip("America/Los_Angeles timezone not available")
	}

	// Task due Nov 3, 2025 (DST ends in LA on Nov 2, 2025)
	taskDueDate := time.Date(2025, 11, 3, 0, 0, 0, 0, time.UTC)
	task := &models.Task{
		NextDueDate: timePtr(taskDueDate),
		IsCancelled: false,
		IsArchived:  false,
		Completions: []models.TaskCompletion{},
	}

	// Nov 2 at 11 PM LA time (during DST transition)
	// This should still be Nov 2, so Nov 3 is tomorrow (due_soon)
	laNov2Late := time.Date(2025, 11, 2, 23, 0, 0, 0, la)
	result := categorization.CategorizeTaskWithTime(task, 30, laNov2Late)
	if result != categorization.ColumnDueSoon {
		t.Errorf("Nov 2 late evening LA: expected due_soon, got %v", result)
	}

	// Nov 3 at 1 AM LA time (after DST ends)
	// This is Nov 3, so task is due today (due_soon)
	laNov3Early := time.Date(2025, 11, 3, 1, 0, 0, 0, la)
	result = categorization.CategorizeTaskWithTime(task, 30, laNov3Early)
	if result != categorization.ColumnDueSoon {
		t.Errorf("Nov 3 early morning LA: expected due_soon, got %v", result)
	}

	// Nov 4 at any time (after due date)
	laNov4 := time.Date(2025, 11, 4, 8, 0, 0, 0, la)
	result = categorization.CategorizeTaskWithTime(task, 30, laNov4)
	if result != categorization.ColumnOverdue {
		t.Errorf("Nov 4 LA: expected overdue, got %v", result)
	}
}

func TestTimezone_MultipleTasksIntoColumns(t *testing.T) {
	// Test CategorizeTasksIntoColumnsWithTime with timezone-aware categorization

	// Tasks with various due dates
	dec16 := time.Date(2025, 12, 16, 0, 0, 0, 0, time.UTC)
	dec17 := time.Date(2025, 12, 17, 0, 0, 0, 0, time.UTC)
	jan15 := time.Date(2026, 1, 15, 0, 0, 0, 0, time.UTC)

	tasks := []models.Task{
		{BaseModel: models.BaseModel{ID: 1}, NextDueDate: timePtr(dec16)}, // Due Dec 16
		{BaseModel: models.BaseModel{ID: 2}, NextDueDate: timePtr(dec17)}, // Due Dec 17
		{BaseModel: models.BaseModel{ID: 3}, NextDueDate: timePtr(jan15)}, // Due Jan 15
	}

	// Categorize as of Dec 17 midnight UTC
	now := time.Date(2025, 12, 17, 0, 0, 0, 0, time.UTC)
	result := categorization.CategorizeTasksIntoColumnsWithTime(tasks, 30, now)

	// Dec 16 should be overdue (yesterday)
	if len(result[categorization.ColumnOverdue]) != 1 || result[categorization.ColumnOverdue][0].ID != 1 {
		t.Errorf("Expected task 1 (Dec 16) in overdue column, got %d tasks", len(result[categorization.ColumnOverdue]))
	}

	// Dec 17 (today) and Jan 15 (29 days away) should both be in due_soon
	// Dec 17 to Jan 15 = 29 days (Dec 17-31 = 14 days, Jan 1-15 = 15 days)
	dueSoonTasks := result[categorization.ColumnDueSoon]
	if len(dueSoonTasks) != 2 {
		t.Errorf("Expected 2 tasks in due_soon column, got %d", len(dueSoonTasks))
	}

	// Verify both task 2 and 3 are in due_soon
	foundTask2 := false
	foundTask3 := false
	for _, task := range dueSoonTasks {
		if task.ID == 2 {
			foundTask2 = true
		}
		if task.ID == 3 {
			foundTask3 = true
		}
	}

	if !foundTask2 {
		t.Errorf("Expected task 2 (Dec 17) in due_soon column")
	}
	if !foundTask3 {
		t.Errorf("Expected task 3 (Jan 15) in due_soon column")
	}
}

// ============================================================================
// FUZZ / PROPERTY TESTS
// These tests verify invariants that must hold for ALL possible task states,
// not just specific hand-crafted examples.
//
// validColumns is defined in chain_breakit_test.go and shared across test files
// in the categorization_test package.
// ============================================================================

// FuzzCategorizeTaskExtended feeds random task states into CategorizeTask using
// separate boolean flags for date presence and day-offset integers for date
// values. This complements FuzzCategorizeTask (in chain_breakit_test.go) by
// exercising the nil-date paths more directly.
func FuzzCategorizeTaskExtended(f *testing.F) {
	// Seed corpus: cover a representative spread of boolean/date combinations.
	//   isCancelled, isArchived, inProgress, hasCompletions,
	//   hasDueDate, dueDateOffsetDays, hasNextDueDate, nextDueDateOffsetDays
	f.Add(false, false, false, false, false, 0, false, 0)
	f.Add(true, false, false, false, false, 0, false, 0)
	f.Add(false, true, false, false, false, 0, false, 0)
	f.Add(false, false, true, false, false, 0, false, 0)
	f.Add(false, false, false, true, false, 0, false, 0)   // completed (no next due, has completions)
	f.Add(false, false, false, false, true, -5, false, 0)   // overdue via DueDate
	f.Add(false, false, false, false, false, 0, true, -5)   // overdue via NextDueDate
	f.Add(false, false, false, false, false, 0, true, 5)    // due soon
	f.Add(false, false, false, false, false, 0, true, 60)   // upcoming
	f.Add(true, true, true, true, true, -10, true, -10)     // everything set
	f.Add(false, false, false, false, true, 100, true, 100) // far future

	f.Fuzz(func(t *testing.T,
		isCancelled, isArchived, inProgress, hasCompletions bool,
		hasDueDate bool, dueDateOffsetDays int,
		hasNextDueDate bool, nextDueDateOffsetDays int,
	) {
		now := time.Date(2025, 12, 15, 0, 0, 0, 0, time.UTC)

		task := &models.Task{
			IsCancelled: isCancelled,
			IsArchived:  isArchived,
			InProgress:  inProgress,
		}

		if hasDueDate {
			d := now.AddDate(0, 0, dueDateOffsetDays)
			task.DueDate = &d
		}
		if hasNextDueDate {
			d := now.AddDate(0, 0, nextDueDateOffsetDays)
			task.NextDueDate = &d
		}
		if hasCompletions {
			task.Completions = []models.TaskCompletion{
				{BaseModel: models.BaseModel{ID: 1}},
			}
		} else {
			task.Completions = []models.TaskCompletion{}
		}

		result := categorization.CategorizeTask(task, 30)

		// Invariant 1: result must never be the empty string.
		if result == "" {
			t.Fatalf("CategorizeTask returned empty string for task %+v", task)
		}

		// Invariant 2: result must be one of the valid KanbanColumn constants.
		if !validColumns[result] {
			t.Fatalf("CategorizeTask returned invalid column %q for task %+v", result, task)
		}
	})
}

// TestCategorizeTask_MutuallyExclusive exhaustively enumerates all boolean
// state combinations (IsCancelled, IsArchived, InProgress, hasCompletions)
// crossed with representative date positions (no date, past, today, within
// threshold, beyond threshold) and asserts that every task maps to exactly
// one valid, non-empty column.
func TestCategorizeTask_MutuallyExclusive(t *testing.T) {
	now := time.Date(2025, 12, 15, 0, 0, 0, 0, time.UTC)
	daysThreshold := 30

	// Date scenarios relative to "now" for both DueDate and NextDueDate.
	type dateScenario struct {
		name    string
		dueDate *time.Time
		nextDue *time.Time
	}

	past := now.AddDate(0, 0, -5)
	today := now
	withinThreshold := now.AddDate(0, 0, 10)
	beyondThreshold := now.AddDate(0, 0, 60)

	dateScenarios := []dateScenario{
		{"no dates", nil, nil},
		{"DueDate past only", &past, nil},
		{"DueDate today only", &today, nil},
		{"DueDate within threshold", &withinThreshold, nil},
		{"DueDate beyond threshold", &beyondThreshold, nil},
		{"NextDueDate past", nil, &past},
		{"NextDueDate today", nil, &today},
		{"NextDueDate within threshold", nil, &withinThreshold},
		{"NextDueDate beyond threshold", nil, &beyondThreshold},
		{"both past", &past, &past},
		{"DueDate past NextDueDate future", &past, &withinThreshold},
		{"both beyond threshold", &beyondThreshold, &beyondThreshold},
	}

	boolCombos := []struct {
		cancelled, archived, inProgress, hasCompletions bool
	}{
		{false, false, false, false},
		{true, false, false, false},
		{false, true, false, false},
		{false, false, true, false},
		{false, false, false, true},
		{true, true, false, false},
		{true, false, true, false},
		{true, false, false, true},
		{false, true, true, false},
		{false, true, false, true},
		{false, false, true, true},
		{true, true, true, false},
		{true, true, false, true},
		{true, false, true, true},
		{false, true, true, true},
		{true, true, true, true},
	}

	for _, ds := range dateScenarios {
		for _, bc := range boolCombos {
			task := &models.Task{
				IsCancelled: bc.cancelled,
				IsArchived:  bc.archived,
				InProgress:  bc.inProgress,
				DueDate:     ds.dueDate,
				NextDueDate: ds.nextDue,
			}
			if bc.hasCompletions {
				task.Completions = []models.TaskCompletion{
					{BaseModel: models.BaseModel{ID: 1}},
				}
			} else {
				task.Completions = []models.TaskCompletion{}
			}

			result := categorization.CategorizeTaskWithTime(task, daysThreshold, now)

			assert.NotEmpty(t, result,
				"empty column for dates=%s cancelled=%v archived=%v inProgress=%v completions=%v",
				ds.name, bc.cancelled, bc.archived, bc.inProgress, bc.hasCompletions)

			assert.True(t, validColumns[result],
				"invalid column %q for dates=%s cancelled=%v archived=%v inProgress=%v completions=%v",
				result, ds.name, bc.cancelled, bc.archived, bc.inProgress, bc.hasCompletions)
		}
	}
}

// TestCategorizeTask_CancelledAlwaysCancelled verifies the property that any
// task with IsCancelled=true is always categorized into ColumnCancelled,
// regardless of all other field values.
func TestCategorizeTask_CancelledAlwaysCancelled(t *testing.T) {
	now := time.Date(2025, 12, 15, 0, 0, 0, 0, time.UTC)
	daysThreshold := 30

	past := now.AddDate(0, 0, -5)
	future := now.AddDate(0, 0, 10)
	farFuture := now.AddDate(0, 0, 60)

	dates := []*time.Time{nil, &past, &future, &farFuture}
	bools := []bool{true, false}

	for _, isArchived := range bools {
		for _, inProgress := range bools {
			for _, hasCompletions := range bools {
				for _, dueDate := range dates {
					for _, nextDueDate := range dates {
						task := &models.Task{
							IsCancelled: true, // always cancelled
							IsArchived:  isArchived,
							InProgress:  inProgress,
							DueDate:     dueDate,
							NextDueDate: nextDueDate,
						}
						if hasCompletions {
							task.Completions = []models.TaskCompletion{
								{BaseModel: models.BaseModel{ID: 1}},
							}
						} else {
							task.Completions = []models.TaskCompletion{}
						}

						result := categorization.CategorizeTaskWithTime(task, daysThreshold, now)

						assert.Equal(t, categorization.ColumnCancelled, result,
							"cancelled task should always map to ColumnCancelled, got %q "+
								"(archived=%v inProgress=%v completions=%v dueDate=%v nextDueDate=%v)",
							result, isArchived, inProgress, hasCompletions, dueDate, nextDueDate)
					}
				}
			}
		}
	}
}

// TestCategorizeTask_ArchivedAlwaysArchived verifies the property that any
// task with IsArchived=true and IsCancelled=false is always categorized into
// ColumnCancelled (archived tasks share the cancelled column as both represent
// "inactive" states), regardless of all other field values.
func TestCategorizeTask_ArchivedAlwaysArchived(t *testing.T) {
	now := time.Date(2025, 12, 15, 0, 0, 0, 0, time.UTC)
	daysThreshold := 30

	past := now.AddDate(0, 0, -5)
	future := now.AddDate(0, 0, 10)
	farFuture := now.AddDate(0, 0, 60)

	dates := []*time.Time{nil, &past, &future, &farFuture}
	bools := []bool{true, false}

	for _, inProgress := range bools {
		for _, hasCompletions := range bools {
			for _, dueDate := range dates {
				for _, nextDueDate := range dates {
					task := &models.Task{
						IsCancelled: false, // not cancelled
						IsArchived:  true,  // always archived
						InProgress:  inProgress,
						DueDate:     dueDate,
						NextDueDate: nextDueDate,
					}
					if hasCompletions {
						task.Completions = []models.TaskCompletion{
							{BaseModel: models.BaseModel{ID: 1}},
						}
					} else {
						task.Completions = []models.TaskCompletion{}
					}

					result := categorization.CategorizeTaskWithTime(task, daysThreshold, now)

					assert.Equal(t, categorization.ColumnCancelled, result,
						"archived (non-cancelled) task should always map to ColumnCancelled, got %q "+
							"(inProgress=%v completions=%v dueDate=%v nextDueDate=%v)",
						result, inProgress, hasCompletions, dueDate, nextDueDate)
				}
			}
		}
	}
}
