package services

import (
	"context"
	"errors"
	"fmt"
	"path/filepath"
	"strings"
	"time"

	"github.com/rs/zerolog/log"
	"gorm.io/gorm"

	"github.com/treytartt/honeydue-api/internal/apperrors"
	"github.com/treytartt/honeydue-api/internal/dto/requests"
	"github.com/treytartt/honeydue-api/internal/dto/responses"
	"github.com/treytartt/honeydue-api/internal/models"
	"github.com/treytartt/honeydue-api/internal/repositories"
	"github.com/treytartt/honeydue-api/internal/task/categorization"
)

// Task-related errors (DEPRECATED - kept for reference, use apperrors instead)
// TODO: Migrate handlers to use apperrors instead of these constants
var (
	ErrTaskNotFound         = errors.New("task not found")
	ErrTaskAccessDenied     = errors.New("you do not have access to this task")
	ErrTaskAlreadyCancelled = errors.New("task is already cancelled")
	ErrTaskAlreadyArchived  = errors.New("task is already archived")
	ErrCompletionNotFound   = errors.New("task completion not found")
)

// TaskService handles task business logic
type TaskService struct {
	taskRepo            *repositories.TaskRepository
	residenceRepo       *repositories.ResidenceRepository
	residenceService    *ResidenceService
	notificationService *NotificationService
	emailService        *EmailService
	storageService      *StorageService
}

// NewTaskService creates a new task service
func NewTaskService(taskRepo *repositories.TaskRepository, residenceRepo *repositories.ResidenceRepository) *TaskService {
	return &TaskService{
		taskRepo:      taskRepo,
		residenceRepo: residenceRepo,
	}
}

// SetNotificationService sets the notification service (for breaking circular dependency)
func (s *TaskService) SetNotificationService(ns *NotificationService) {
	s.notificationService = ns
}

// SetEmailService sets the email service
func (s *TaskService) SetEmailService(es *EmailService) {
	s.emailService = es
}

// SetResidenceService sets the residence service (for getting summary in CRUD responses)
func (s *TaskService) SetResidenceService(rs *ResidenceService) {
	s.residenceService = rs
}

// SetStorageService sets the storage service (for reading completion images for emails)
func (s *TaskService) SetStorageService(ss *StorageService) {
	s.storageService = ss
}

// getSummaryForUser returns an empty summary placeholder.
// DEPRECATED: Summary calculation has been removed from CRUD responses for performance.
// Clients should calculate summary from kanban data instead (which already includes all tasks).
// The summary field is kept in responses for backward compatibility but will always be empty.
func (s *TaskService) getSummaryForUser(_ uint) responses.TotalSummary {
	// Return empty summary - clients should calculate from kanban data
	return responses.TotalSummary{}
}

// === Task CRUD ===

// GetTask gets a task by ID with access check
func (s *TaskService) GetTask(taskID, userID uint) (*responses.TaskResponse, error) {
	task, err := s.taskRepo.FindByID(taskID)
	if err != nil {
		if errors.Is(err, gorm.ErrRecordNotFound) {
			return nil, apperrors.NotFound("error.task_not_found")
		}
		return nil, apperrors.Internal(err)
	}

	// Check access via residence
	hasAccess, err := s.residenceRepo.HasAccess(task.ResidenceID, userID)
	if err != nil {
		return nil, apperrors.Internal(err)
	}
	if !hasAccess {
		return nil, apperrors.Forbidden("error.task_access_denied")
	}

	resp := responses.NewTaskResponse(task)
	return &resp, nil
}

// ListTasks lists all tasks accessible to a user as a kanban board.
// The `now` parameter should be the start of day in the user's timezone for accurate overdue detection.
func (s *TaskService) ListTasks(userID uint, daysThreshold int, now time.Time) (*responses.KanbanBoardResponse, error) {
	if daysThreshold <= 0 {
		daysThreshold = 30 // Default
	}

	// Get all residence IDs accessible to user (lightweight - no preloads)
	residenceIDs, err := s.residenceRepo.FindResidenceIDsByUser(userID)
	if err != nil {
		return nil, apperrors.Internal(err)
	}

	if len(residenceIDs) == 0 {
		// Return empty kanban board
		return &responses.KanbanBoardResponse{
			Columns:       []responses.KanbanColumnResponse{},
			DaysThreshold: daysThreshold,
			ResidenceID:   "all",
		}, nil
	}

	// Get kanban data aggregated across all residences using user's timezone-aware time
	board, err := s.taskRepo.GetKanbanDataForMultipleResidences(residenceIDs, daysThreshold, now)
	if err != nil {
		return nil, apperrors.Internal(err)
	}

	resp := responses.NewKanbanBoardResponseForAll(board, now)
	// NOTE: Summary statistics are calculated client-side from kanban data
	return &resp, nil
}

// GetTasksByResidence gets tasks for a specific residence (kanban board).
// The `now` parameter should be the start of day in the user's timezone for accurate overdue detection.
func (s *TaskService) GetTasksByResidence(residenceID, userID uint, daysThreshold int, now time.Time) (*responses.KanbanBoardResponse, error) {
	// Check access
	hasAccess, err := s.residenceRepo.HasAccess(residenceID, userID)
	if err != nil {
		return nil, apperrors.Internal(err)
	}
	if !hasAccess {
		return nil, apperrors.Forbidden("error.residence_access_denied")
	}

	if daysThreshold <= 0 {
		daysThreshold = 30 // Default
	}

	// Get kanban data using user's timezone-aware time
	board, err := s.taskRepo.GetKanbanData(residenceID, daysThreshold, now)
	if err != nil {
		return nil, apperrors.Internal(err)
	}

	resp := responses.NewKanbanBoardResponse(board, residenceID, now)
	// NOTE: Summary statistics are calculated client-side from kanban data
	return &resp, nil
}

// CreateTask creates a new task.
// The `now` parameter should be the start of day in the user's timezone for accurate kanban categorization.
func (s *TaskService) CreateTask(req *requests.CreateTaskRequest, userID uint, now time.Time) (*responses.TaskWithSummaryResponse, error) {
	// Check residence access
	hasAccess, err := s.residenceRepo.HasAccess(req.ResidenceID, userID)
	if err != nil {
		return nil, apperrors.Internal(err)
	}
	if !hasAccess {
		return nil, apperrors.Forbidden("error.residence_access_denied")
	}

	dueDate := req.DueDate.ToTimePtr()
	task := &models.Task{
		ResidenceID:        req.ResidenceID,
		CreatedByID:        userID,
		Title:              req.Title,
		Description:        req.Description,
		CategoryID:         req.CategoryID,
		PriorityID:         req.PriorityID,
		FrequencyID:        req.FrequencyID,
		CustomIntervalDays: req.CustomIntervalDays,
		InProgress:         req.InProgress,
		AssignedToID:       req.AssignedToID,
		DueDate:            dueDate,
		NextDueDate:        dueDate, // Initialize next_due_date to due_date
		EstimatedCost:      req.EstimatedCost,
		ContractorID:       req.ContractorID,
		TaskTemplateID:     req.TemplateID,
	}

	if err := s.taskRepo.Create(task); err != nil {
		return nil, apperrors.Internal(err)
	}

	// Reload with relations
	task, err = s.taskRepo.FindByID(task.ID)
	if err != nil {
		return nil, apperrors.Internal(err)
	}

	return &responses.TaskWithSummaryResponse{
		Data:    responses.NewTaskResponseWithTime(task, 30, now),
		Summary: s.getSummaryForUser(userID),
	}, nil
}

// BulkCreateTasks inserts all tasks in a single transaction. If any task
// fails validation or insert, the entire batch is rolled back. The top-level
// ResidenceID overrides whatever was set on individual entries so that a
// single access check covers the whole batch.
//
// `now` should be the start of day in the user's timezone for accurate
// kanban column categorisation on the returned task list.
func (s *TaskService) BulkCreateTasks(req *requests.BulkCreateTasksRequest, userID uint, now time.Time) (*responses.BulkCreateTasksResponse, error) {
	if len(req.Tasks) == 0 {
		return nil, apperrors.BadRequest("error.task_list_empty")
	}

	// Check residence access once.
	hasAccess, err := s.residenceRepo.HasAccess(req.ResidenceID, userID)
	if err != nil {
		return nil, apperrors.Internal(err)
	}
	if !hasAccess {
		return nil, apperrors.Forbidden("error.residence_access_denied")
	}

	createdIDs := make([]uint, 0, len(req.Tasks))

	err = s.taskRepo.DB().Transaction(func(tx *gorm.DB) error {
		for i := range req.Tasks {
			entry := req.Tasks[i]
			// Force the residence ID to the batch-level value so clients
			// can't straddle residences in one call.
			entry.ResidenceID = req.ResidenceID

			dueDate := entry.DueDate.ToTimePtr()
			task := &models.Task{
				ResidenceID:        req.ResidenceID,
				CreatedByID:        userID,
				Title:              entry.Title,
				Description:        entry.Description,
				CategoryID:         entry.CategoryID,
				PriorityID:         entry.PriorityID,
				FrequencyID:        entry.FrequencyID,
				CustomIntervalDays: entry.CustomIntervalDays,
				InProgress:         entry.InProgress,
				AssignedToID:       entry.AssignedToID,
				DueDate:            dueDate,
				NextDueDate:        dueDate,
				EstimatedCost:      entry.EstimatedCost,
				ContractorID:       entry.ContractorID,
				TaskTemplateID:     entry.TemplateID,
			}
			if err := s.taskRepo.CreateTx(tx, task); err != nil {
				return fmt.Errorf("create task %d of %d: %w", i+1, len(req.Tasks), err)
			}
			createdIDs = append(createdIDs, task.ID)
		}
		return nil
	})
	if err != nil {
		return nil, apperrors.Internal(err)
	}

	// Reload the just-created tasks with preloads for the response. Reads
	// happen outside the transaction — rows are already committed.
	created := make([]responses.TaskResponse, 0, len(createdIDs))
	for _, id := range createdIDs {
		t, ferr := s.taskRepo.FindByID(id)
		if ferr != nil {
			return nil, apperrors.Internal(ferr)
		}
		created = append(created, responses.NewTaskResponseWithTime(t, 30, now))
	}

	return &responses.BulkCreateTasksResponse{
		Tasks:        created,
		Summary:      s.getSummaryForUser(userID),
		CreatedCount: len(created),
	}, nil
}

// UpdateTask updates a task.
// The `now` parameter should be the start of day in the user's timezone for accurate kanban categorization.
func (s *TaskService) UpdateTask(taskID, userID uint, req *requests.UpdateTaskRequest, now time.Time) (*responses.TaskWithSummaryResponse, error) {
	task, err := s.taskRepo.FindByID(taskID)
	if err != nil {
		if errors.Is(err, gorm.ErrRecordNotFound) {
			return nil, apperrors.NotFound("error.task_not_found")
		}
		return nil, apperrors.Internal(err)
	}

	// Check access
	hasAccess, err := s.residenceRepo.HasAccess(task.ResidenceID, userID)
	if err != nil {
		return nil, apperrors.Internal(err)
	}
	if !hasAccess {
		return nil, apperrors.Forbidden("error.task_access_denied")
	}

	// Apply updates
	if req.Title != nil {
		task.Title = *req.Title
	}
	if req.Description != nil {
		task.Description = *req.Description
	}
	if req.CategoryID != nil {
		task.CategoryID = req.CategoryID
	}
	if req.PriorityID != nil {
		task.PriorityID = req.PriorityID
	}
	if req.FrequencyID != nil {
		task.FrequencyID = req.FrequencyID
	}
	if req.CustomIntervalDays != nil {
		task.CustomIntervalDays = req.CustomIntervalDays
	}
	if req.InProgress != nil {
		task.InProgress = *req.InProgress
	}
	if req.AssignedToID != nil {
		task.AssignedToID = req.AssignedToID
	}
	if req.DueDate != nil {
		newDueDate := req.DueDate.ToTimePtr()
		task.DueDate = newDueDate
		// Always update NextDueDate when user explicitly edits due date.
		// Completion logic will recalculate NextDueDate when task is completed,
		// but manual edits should take precedence.
		task.NextDueDate = newDueDate
	}
	if req.EstimatedCost != nil {
		task.EstimatedCost = req.EstimatedCost
	}
	if req.ActualCost != nil {
		task.ActualCost = req.ActualCost
	}
	if req.ContractorID != nil {
		task.ContractorID = req.ContractorID
	}

	if err := s.taskRepo.Update(task); err != nil {
		if errors.Is(err, repositories.ErrVersionConflict) {
			return nil, apperrors.Conflict("error.version_conflict")
		}
		return nil, apperrors.Internal(err)
	}

	// Reload
	task, err = s.taskRepo.FindByID(task.ID)
	if err != nil {
		return nil, apperrors.Internal(err)
	}

	return &responses.TaskWithSummaryResponse{
		Data:    responses.NewTaskResponseWithTime(task, 30, now),
		Summary: s.getSummaryForUser(userID),
	}, nil
}

// DeleteTask deletes a task
func (s *TaskService) DeleteTask(taskID, userID uint) (*responses.DeleteWithSummaryResponse, error) {
	task, err := s.taskRepo.FindByID(taskID)
	if err != nil {
		if errors.Is(err, gorm.ErrRecordNotFound) {
			return nil, apperrors.NotFound("error.task_not_found")
		}
		return nil, apperrors.Internal(err)
	}

	// Check access
	hasAccess, err := s.residenceRepo.HasAccess(task.ResidenceID, userID)
	if err != nil {
		return nil, apperrors.Internal(err)
	}
	if !hasAccess {
		return nil, apperrors.Forbidden("error.task_access_denied")
	}

	if err := s.taskRepo.Delete(taskID); err != nil {
		return nil, apperrors.Internal(err)
	}

	return &responses.DeleteWithSummaryResponse{
		Data:    "task deleted",
		Summary: s.getSummaryForUser(userID),
	}, nil
}

// === Task Actions ===

// MarkInProgress marks a task as in progress.
// The `now` parameter should be the start of day in the user's timezone for accurate kanban categorization.
func (s *TaskService) MarkInProgress(taskID, userID uint, now time.Time) (*responses.TaskWithSummaryResponse, error) {
	task, err := s.taskRepo.FindByID(taskID)
	if err != nil {
		if errors.Is(err, gorm.ErrRecordNotFound) {
			return nil, apperrors.NotFound("error.task_not_found")
		}
		return nil, apperrors.Internal(err)
	}

	// Check access
	hasAccess, err := s.residenceRepo.HasAccess(task.ResidenceID, userID)
	if err != nil {
		return nil, apperrors.Internal(err)
	}
	if !hasAccess {
		return nil, apperrors.Forbidden("error.task_access_denied")
	}

	if err := s.taskRepo.MarkInProgress(taskID, task.Version); err != nil {
		if errors.Is(err, repositories.ErrVersionConflict) {
			return nil, apperrors.Conflict("error.version_conflict")
		}
		return nil, apperrors.Internal(err)
	}

	// Reload
	task, err = s.taskRepo.FindByID(taskID)
	if err != nil {
		return nil, apperrors.Internal(err)
	}

	return &responses.TaskWithSummaryResponse{
		Data:    responses.NewTaskResponseWithTime(task, 30, now),
		Summary: s.getSummaryForUser(userID),
	}, nil
}

// CancelTask cancels a task.
// The `now` parameter should be the start of day in the user's timezone for accurate kanban categorization.
func (s *TaskService) CancelTask(taskID, userID uint, now time.Time) (*responses.TaskWithSummaryResponse, error) {
	task, err := s.taskRepo.FindByID(taskID)
	if err != nil {
		if errors.Is(err, gorm.ErrRecordNotFound) {
			return nil, apperrors.NotFound("error.task_not_found")
		}
		return nil, apperrors.Internal(err)
	}

	// Check access
	hasAccess, err := s.residenceRepo.HasAccess(task.ResidenceID, userID)
	if err != nil {
		return nil, apperrors.Internal(err)
	}
	if !hasAccess {
		return nil, apperrors.Forbidden("error.task_access_denied")
	}

	if task.IsCancelled {
		return nil, apperrors.BadRequest("error.task_already_cancelled")
	}

	if err := s.taskRepo.Cancel(taskID, task.Version); err != nil {
		if errors.Is(err, repositories.ErrVersionConflict) {
			return nil, apperrors.Conflict("error.version_conflict")
		}
		return nil, apperrors.Internal(err)
	}

	// Reload
	task, err = s.taskRepo.FindByID(taskID)
	if err != nil {
		return nil, apperrors.Internal(err)
	}

	return &responses.TaskWithSummaryResponse{
		Data:    responses.NewTaskResponseWithTime(task, 30, now),
		Summary: s.getSummaryForUser(userID),
	}, nil
}

// UncancelTask uncancels a task.
// The `now` parameter should be the start of day in the user's timezone for accurate kanban categorization.
func (s *TaskService) UncancelTask(taskID, userID uint, now time.Time) (*responses.TaskWithSummaryResponse, error) {
	task, err := s.taskRepo.FindByID(taskID)
	if err != nil {
		if errors.Is(err, gorm.ErrRecordNotFound) {
			return nil, apperrors.NotFound("error.task_not_found")
		}
		return nil, apperrors.Internal(err)
	}

	// Check access
	hasAccess, err := s.residenceRepo.HasAccess(task.ResidenceID, userID)
	if err != nil {
		return nil, apperrors.Internal(err)
	}
	if !hasAccess {
		return nil, apperrors.Forbidden("error.task_access_denied")
	}

	if err := s.taskRepo.Uncancel(taskID, task.Version); err != nil {
		if errors.Is(err, repositories.ErrVersionConflict) {
			return nil, apperrors.Conflict("error.version_conflict")
		}
		return nil, apperrors.Internal(err)
	}

	// Reload
	task, err = s.taskRepo.FindByID(taskID)
	if err != nil {
		return nil, apperrors.Internal(err)
	}

	return &responses.TaskWithSummaryResponse{
		Data:    responses.NewTaskResponseWithTime(task, 30, now),
		Summary: s.getSummaryForUser(userID),
	}, nil
}

// ArchiveTask archives a task.
// The `now` parameter should be the start of day in the user's timezone for accurate kanban categorization.
func (s *TaskService) ArchiveTask(taskID, userID uint, now time.Time) (*responses.TaskWithSummaryResponse, error) {
	task, err := s.taskRepo.FindByID(taskID)
	if err != nil {
		if errors.Is(err, gorm.ErrRecordNotFound) {
			return nil, apperrors.NotFound("error.task_not_found")
		}
		return nil, apperrors.Internal(err)
	}

	// Check access
	hasAccess, err := s.residenceRepo.HasAccess(task.ResidenceID, userID)
	if err != nil {
		return nil, apperrors.Internal(err)
	}
	if !hasAccess {
		return nil, apperrors.Forbidden("error.task_access_denied")
	}

	if task.IsArchived {
		return nil, apperrors.BadRequest("error.task_already_archived")
	}

	if err := s.taskRepo.Archive(taskID, task.Version); err != nil {
		if errors.Is(err, repositories.ErrVersionConflict) {
			return nil, apperrors.Conflict("error.version_conflict")
		}
		return nil, apperrors.Internal(err)
	}

	// Reload
	task, err = s.taskRepo.FindByID(taskID)
	if err != nil {
		return nil, apperrors.Internal(err)
	}

	return &responses.TaskWithSummaryResponse{
		Data:    responses.NewTaskResponseWithTime(task, 30, now),
		Summary: s.getSummaryForUser(userID),
	}, nil
}

// UnarchiveTask unarchives a task.
// The `now` parameter should be the start of day in the user's timezone for accurate kanban categorization.
func (s *TaskService) UnarchiveTask(taskID, userID uint, now time.Time) (*responses.TaskWithSummaryResponse, error) {
	task, err := s.taskRepo.FindByID(taskID)
	if err != nil {
		if errors.Is(err, gorm.ErrRecordNotFound) {
			return nil, apperrors.NotFound("error.task_not_found")
		}
		return nil, apperrors.Internal(err)
	}

	// Check access
	hasAccess, err := s.residenceRepo.HasAccess(task.ResidenceID, userID)
	if err != nil {
		return nil, apperrors.Internal(err)
	}
	if !hasAccess {
		return nil, apperrors.Forbidden("error.task_access_denied")
	}

	if err := s.taskRepo.Unarchive(taskID, task.Version); err != nil {
		if errors.Is(err, repositories.ErrVersionConflict) {
			return nil, apperrors.Conflict("error.version_conflict")
		}
		return nil, apperrors.Internal(err)
	}

	// Reload
	task, err = s.taskRepo.FindByID(taskID)
	if err != nil {
		return nil, apperrors.Internal(err)
	}

	return &responses.TaskWithSummaryResponse{
		Data:    responses.NewTaskResponseWithTime(task, 30, now),
		Summary: s.getSummaryForUser(userID),
	}, nil
}

// === Task Completions ===

// CreateCompletion creates a task completion.
// The `now` parameter should be the start of day in the user's timezone for accurate kanban categorization.
func (s *TaskService) CreateCompletion(req *requests.CreateTaskCompletionRequest, userID uint, now time.Time) (*responses.TaskCompletionWithSummaryResponse, error) {
	// Get the task
	task, err := s.taskRepo.FindByID(req.TaskID)
	if err != nil {
		if errors.Is(err, gorm.ErrRecordNotFound) {
			return nil, apperrors.NotFound("error.task_not_found")
		}
		return nil, apperrors.Internal(err)
	}

	// Check access
	hasAccess, err := s.residenceRepo.HasAccess(task.ResidenceID, userID)
	if err != nil {
		return nil, apperrors.Internal(err)
	}
	if !hasAccess {
		return nil, apperrors.Forbidden("error.task_access_denied")
	}

	completedAt := time.Now().UTC()
	if req.CompletedAt != nil {
		completedAt = *req.CompletedAt
	}

	// Capture the kanban column BEFORE mutating NextDueDate/InProgress,
	// so we know what state the task was in when the user completed it.
	completedFromColumn := categorization.DetermineKanbanColumnWithTime(task, 30, now)

	completion := &models.TaskCompletion{
		TaskID:              req.TaskID,
		CompletedByID:       userID,
		CompletedAt:         completedAt,
		Notes:               req.Notes,
		ActualCost:          req.ActualCost,
		Rating:              req.Rating,
		CompletedFromColumn: completedFromColumn,
	}

	// Determine interval days for NextDueDate calculation before entering the transaction.
	// - If frequency is "Once" (days = nil or 0), set next_due_date to nil (marks as completed)
	// - If frequency is "Custom", use task.CustomIntervalDays for recurrence
	// - If frequency is recurring, calculate next_due_date = completion_date + frequency_days
	//   and reset in_progress to false so task shows in correct kanban column
	//
	// Note: Frequency is no longer preloaded for performance, so we load it separately if needed
	var intervalDays *int
	if task.FrequencyID != nil {
		frequency, err := s.taskRepo.GetFrequencyByID(*task.FrequencyID)
		if err == nil && frequency != nil {
			if frequency.Name == "Custom" {
				// Custom frequency - use task's custom_interval_days
				intervalDays = task.CustomIntervalDays
			} else {
				// Standard frequency - use frequency's days
				intervalDays = frequency.Days
			}
		}
	}

	if intervalDays == nil || *intervalDays == 0 {
		// One-time task - clear next_due_date (completion is determined by NextDueDate == nil + has completions)
		task.NextDueDate = nil
		task.InProgress = false
	} else {
		// Recurring task - calculate next due date from completion date + interval
		nextDue := completedAt.AddDate(0, 0, *intervalDays)
		task.NextDueDate = &nextDue

		// Reset in_progress to false so task appears in upcoming/due_soon
		// instead of staying in "In Progress" column
		task.InProgress = false
	}

	// P1-5 + B-07: Wrap completion creation, task update, and image creation
	// in a single transaction for atomicity. If any operation fails, all are rolled back.
	txErr := s.taskRepo.DB().Transaction(func(tx *gorm.DB) error {
		if err := s.taskRepo.CreateCompletionTx(tx, completion); err != nil {
			return err
		}
		if err := s.taskRepo.UpdateTx(tx, task); err != nil {
			return err
		}
		// B-07: Create images inside the same transaction as completion
		for _, imageURL := range req.ImageURLs {
			if imageURL != "" {
				img := &models.TaskCompletionImage{
					CompletionID: completion.ID,
					ImageURL:     imageURL,
				}
				if err := tx.Create(img).Error; err != nil {
					return fmt.Errorf("failed to create completion image: %w", err)
				}
			}
		}
		return nil
	})
	if txErr != nil {
		// P1-6: Return the error instead of swallowing it.
		if errors.Is(txErr, repositories.ErrVersionConflict) {
			return nil, apperrors.Conflict("error.version_conflict")
		}
		log.Error().Err(txErr).Uint("task_id", task.ID).Msg("Failed to create completion and update task")
		return nil, apperrors.Internal(txErr)
	}

	// Reload completion with user info and images
	completion, err = s.taskRepo.FindCompletionByID(completion.ID)
	if err != nil {
		return nil, apperrors.Internal(err)
	}

	// Reload task with updated completions (so client can update kanban column)
	task, err = s.taskRepo.FindByID(req.TaskID)
	if err != nil {
		// Non-fatal - still return the completion, just without the task
		log.Warn().Err(err).Uint("task_id", req.TaskID).Msg("Failed to reload task after completion")
		resp := responses.NewTaskCompletionResponse(completion)
		return &responses.TaskCompletionWithSummaryResponse{
			Data:    resp,
			Summary: s.getSummaryForUser(userID),
		}, nil
	}

	// Send notification to residence owner and other users
	s.sendTaskCompletedNotification(task, completion)

	// Return completion with updated task (includes kanban_column for UI update)
	resp := responses.NewTaskCompletionWithTaskResponseWithTime(completion, task, 30, now)
	return &responses.TaskCompletionWithSummaryResponse{
		Data:    resp,
		Summary: s.getSummaryForUser(userID),
	}, nil
}

// QuickComplete creates a minimal task completion (for widget use).
// LE-01: The entire operation (completion creation + task update) is wrapped in a
// transaction for atomicity.
// Returns only success/error, no response body.
func (s *TaskService) QuickComplete(taskID uint, userID uint) error {
	// Get the task
	task, err := s.taskRepo.FindByID(taskID)
	if err != nil {
		if errors.Is(err, gorm.ErrRecordNotFound) {
			return apperrors.NotFound("error.task_not_found")
		}
		return apperrors.Internal(err)
	}

	// Check access
	hasAccess, err := s.residenceRepo.HasAccess(task.ResidenceID, userID)
	if err != nil {
		return apperrors.Internal(err)
	}
	if !hasAccess {
		return apperrors.Forbidden("error.task_access_denied")
	}

	completedAt := time.Now().UTC()

	// Capture kanban column before state mutation
	completedFromColumn := categorization.DetermineKanbanColumn(task, 30)

	completion := &models.TaskCompletion{
		TaskID:              taskID,
		CompletedByID:       userID,
		CompletedAt:         completedAt,
		Notes:               "Completed from widget",
		CompletedFromColumn: completedFromColumn,
	}

	// Update next_due_date and in_progress based on frequency
	// Determine interval days: Custom frequency uses task.CustomIntervalDays, otherwise use frequency.Days
	// Note: Frequency is no longer preloaded for performance, so we load it separately if needed
	var quickIntervalDays *int
	var frequencyName = "unknown"
	if task.FrequencyID != nil {
		frequency, err := s.taskRepo.GetFrequencyByID(*task.FrequencyID)
		if err == nil && frequency != nil {
			frequencyName = frequency.Name
			if frequency.Name == "Custom" {
				quickIntervalDays = task.CustomIntervalDays
			} else {
				quickIntervalDays = frequency.Days
			}
		}
	}

	if quickIntervalDays == nil || *quickIntervalDays == 0 {
		// One-time task - clear next_due_date (completion is determined by NextDueDate == nil + has completions)
		log.Info().
			Uint("task_id", task.ID).
			Bool("has_frequency", task.FrequencyID != nil).
			Msg("QuickComplete: One-time task, clearing next_due_date")
		task.NextDueDate = nil
		task.InProgress = false
	} else {
		// Recurring task - calculate next due date from completion date + interval
		nextDue := completedAt.AddDate(0, 0, *quickIntervalDays)
		log.Info().
			Uint("task_id", task.ID).
			Str("frequency_name", frequencyName).
			Int("interval_days", *quickIntervalDays).
			Time("completed_at", completedAt).
			Time("next_due_date", nextDue).
			Msg("QuickComplete: Recurring task, setting next_due_date")
		task.NextDueDate = &nextDue

		// Reset in_progress to false
		task.InProgress = false
	}

	// LE-01: Wrap completion creation and task update in a transaction for atomicity
	txErr := s.taskRepo.DB().Transaction(func(tx *gorm.DB) error {
		if err := s.taskRepo.CreateCompletionTx(tx, completion); err != nil {
			return err
		}
		if err := s.taskRepo.UpdateTx(tx, task); err != nil {
			return err
		}
		return nil
	})
	if txErr != nil {
		if errors.Is(txErr, repositories.ErrVersionConflict) {
			return apperrors.Conflict("error.version_conflict")
		}
		log.Error().Err(txErr).Uint("task_id", task.ID).Msg("Failed to create completion and update task in QuickComplete")
		return apperrors.Internal(txErr)
	}
	log.Info().Uint("task_id", task.ID).Msg("QuickComplete: Task updated successfully")

	// Send notification (fire and forget with panic recovery)
	go func() {
		defer func() {
			if r := recover(); r != nil {
				log.Error().Interface("panic", r).Uint("task_id", task.ID).Msg("Panic in quick-complete notification goroutine")
			}
		}()
		s.sendTaskCompletedNotification(task, completion)
	}()

	return nil
}

// sendTaskCompletedNotification sends notifications when a task is completed
func (s *TaskService) sendTaskCompletedNotification(task *models.Task, completion *models.TaskCompletion) {
	// Get all users with access to this residence
	users, err := s.residenceRepo.GetResidenceUsers(task.ResidenceID)
	if err != nil {
		log.Error().Err(err).Uint("task_id", task.ID).Msg("Failed to get residence users for notification")
		return
	}

	// Get residence name
	residence, err := s.residenceRepo.FindByIDSimple(task.ResidenceID)
	residenceName := "your property"
	if err == nil && residence != nil {
		residenceName = residence.Name
	}

	completedByName := "Someone"
	if completion.CompletedBy.ID > 0 {
		completedByName = completion.CompletedBy.GetFullName()
	}

	// Load completion images for email (only if storage service is available)
	var emailImages []EmbeddedImage
	if s.storageService != nil && len(completion.Images) > 0 {
		emailImages = s.loadCompletionImagesForEmail(completion.Images)
	}

	// Notify all users synchronously to avoid unbounded goroutine spawning.
	// This method is already called from a goroutine (QuickComplete) or inline
	// (CreateCompletion) where blocking is acceptable for notification delivery.
	for _, user := range users {
		isCompleter := user.ID == completion.CompletedByID

		// Send push notification (to everyone EXCEPT the person who completed it)
		if !isCompleter && s.notificationService != nil {
			ctx := context.Background()
			if err := s.notificationService.CreateAndSendTaskNotification(
				ctx,
				user.ID,
				models.NotificationTaskCompleted,
				task,
			); err != nil {
				log.Error().Err(err).Uint("user_id", user.ID).Uint("task_id", task.ID).Msg("Failed to send task completion push notification")
			}
		}

		// Send email notification (to everyone INCLUDING the person who completed it)
		// Check user's email notification preferences first
		if s.emailService != nil && user.Email != "" && s.notificationService != nil {
			prefs, prefsErr := s.notificationService.GetPreferences(user.ID)
			// LE-06: Log fail-open behavior when preferences cannot be loaded
			if prefsErr != nil {
				log.Warn().
					Err(prefsErr).
					Uint("user_id", user.ID).
					Uint("task_id", task.ID).
					Msg("Failed to load notification preferences, falling back to sending email (fail-open)")
			}
			if prefsErr != nil || (prefs != nil && prefs.EmailTaskCompleted) {
				// Send email if we couldn't get prefs (fail-open) or if email notifications are enabled
				if err := s.emailService.SendTaskCompletedEmail(
					user.Email,
					user.GetFullName(),
					task.Title,
					completedByName,
					residenceName,
					emailImages,
				); err != nil {
					log.Error().Err(err).Str("email", user.Email).Uint("task_id", task.ID).Msg("Failed to send task completion email")
				} else {
					log.Info().Str("email", user.Email).Uint("task_id", task.ID).Int("images", len(emailImages)).Msg("Task completion email sent")
				}
			}
		}
	}
}

// loadCompletionImagesForEmail reads completion images from disk and prepares them for email embedding.
// Uses StorageService.ReadFile to transparently handle encrypted files.
func (s *TaskService) loadCompletionImagesForEmail(images []models.TaskCompletionImage) []EmbeddedImage {
	var emailImages []EmbeddedImage

	for i, img := range images {
		// Read file via storage service (handles encryption transparently)
		data, mimeType, err := s.storageService.ReadFile(img.ImageURL)
		if err != nil {
			log.Warn().Err(err).Str("image_url", img.ImageURL).Msg("Failed to read completion image for email")
			continue
		}

		// Use detected MIME type, fall back to extension-based detection
		if mimeType == "application/octet-stream" {
			mimeType = s.getContentTypeFromPath(img.ImageURL)
		}

		// Create embedded image with unique Content-ID
		emailImages = append(emailImages, EmbeddedImage{
			ContentID:   fmt.Sprintf("completion-image-%d", i+1),
			Filename:    filepath.Base(img.ImageURL),
			ContentType: mimeType,
			Data:        data,
		})

		log.Debug().Str("image_url", img.ImageURL).Int("size", len(data)).Msg("Loaded completion image for email")
	}

	return emailImages
}

// resolveImageFilePath converts a stored URL to an actual file path.
// Returns empty string if the URL is empty or the resolved path would escape
// the upload directory (path traversal attempt).
func (s *TaskService) resolveImageFilePath(storedURL, uploadDir string) string {
	if storedURL == "" {
		return ""
	}

	// Strip legacy /uploads/ prefix to get relative path
	relativePath := storedURL
	if strings.HasPrefix(storedURL, "/uploads/") {
		relativePath = strings.TrimPrefix(storedURL, "/uploads/")
	}

	// Use SafeResolvePath to validate containment within upload directory
	resolved, err := SafeResolvePath(uploadDir, relativePath)
	if err != nil {
		// Path traversal or invalid path — return empty to signal file not found
		return ""
	}

	return resolved
}

// getContentTypeFromPath returns the MIME type based on file extension
func (s *TaskService) getContentTypeFromPath(path string) string {
	ext := strings.ToLower(filepath.Ext(path))
	switch ext {
	case ".jpg", ".jpeg":
		return "image/jpeg"
	case ".png":
		return "image/png"
	case ".gif":
		return "image/gif"
	case ".webp":
		return "image/webp"
	default:
		return "application/octet-stream"
	}
}

// GetCompletion gets a task completion by ID
func (s *TaskService) GetCompletion(completionID, userID uint) (*responses.TaskCompletionResponse, error) {
	completion, err := s.taskRepo.FindCompletionByID(completionID)
	if err != nil {
		if errors.Is(err, gorm.ErrRecordNotFound) {
			return nil, apperrors.NotFound("error.completion_not_found")
		}
		return nil, apperrors.Internal(err)
	}

	// Check access via task's residence
	hasAccess, err := s.residenceRepo.HasAccess(completion.Task.ResidenceID, userID)
	if err != nil {
		return nil, apperrors.Internal(err)
	}
	if !hasAccess {
		return nil, apperrors.Forbidden("error.task_access_denied")
	}

	resp := responses.NewTaskCompletionResponse(completion)
	return &resp, nil
}

// ListCompletions lists all task completions for a user
func (s *TaskService) ListCompletions(userID uint) ([]responses.TaskCompletionResponse, error) {
	// Get all residence IDs (lightweight - no preloads)
	residenceIDs, err := s.residenceRepo.FindResidenceIDsByUser(userID)
	if err != nil {
		return nil, apperrors.Internal(err)
	}

	if len(residenceIDs) == 0 {
		return []responses.TaskCompletionResponse{}, nil
	}

	completions, err := s.taskRepo.FindCompletionsByUser(userID, residenceIDs)
	if err != nil {
		return nil, apperrors.Internal(err)
	}

	return responses.NewTaskCompletionListResponse(completions), nil
}

// UpdateCompletion updates an existing task completion
func (s *TaskService) UpdateCompletion(completionID, userID uint, req *requests.UpdateTaskCompletionRequest) (*responses.TaskCompletionResponse, error) {
	completion, err := s.taskRepo.FindCompletionByID(completionID)
	if err != nil {
		if errors.Is(err, gorm.ErrRecordNotFound) {
			return nil, apperrors.NotFound("error.completion_not_found")
		}
		return nil, apperrors.Internal(err)
	}

	// Check access via task's residence
	hasAccess, err := s.residenceRepo.HasAccess(completion.Task.ResidenceID, userID)
	if err != nil {
		return nil, apperrors.Internal(err)
	}
	if !hasAccess {
		return nil, apperrors.Forbidden("error.task_access_denied")
	}

	// Apply updates
	if req.Notes != nil {
		completion.Notes = *req.Notes
	}
	if req.ActualCost != nil {
		completion.ActualCost = req.ActualCost
	}
	if req.Rating != nil {
		completion.Rating = req.Rating
	}

	if err := s.taskRepo.UpdateCompletion(completion); err != nil {
		return nil, apperrors.Internal(err)
	}

	// Add any new images
	for _, imageURL := range req.ImageURLs {
		image := &models.TaskCompletionImage{
			CompletionID: completion.ID,
			ImageURL:     imageURL,
		}
		if err := s.taskRepo.CreateCompletionImage(image); err != nil {
			log.Error().Err(err).Uint("completion_id", completion.ID).Msg("Failed to create completion image during update")
		}
	}

	// Reload to get full associations
	updated, err := s.taskRepo.FindCompletionByID(completionID)
	if err != nil {
		return nil, apperrors.Internal(err)
	}

	resp := responses.NewTaskCompletionResponse(updated)
	return &resp, nil
}

// DeleteCompletion deletes a task completion and recalculates the task's NextDueDate.
//
// P1-7: After deleting a completion, NextDueDate must be recalculated:
//   - If no completions remain: restore NextDueDate = DueDate (original schedule)
//   - If completions remain (recurring): recalculate from latest remaining completion + frequency days
func (s *TaskService) DeleteCompletion(completionID, userID uint) (*responses.DeleteWithSummaryResponse, error) {
	completion, err := s.taskRepo.FindCompletionByID(completionID)
	if err != nil {
		if errors.Is(err, gorm.ErrRecordNotFound) {
			return nil, apperrors.NotFound("error.completion_not_found")
		}
		return nil, apperrors.Internal(err)
	}

	// Check access
	hasAccess, err := s.residenceRepo.HasAccess(completion.Task.ResidenceID, userID)
	if err != nil {
		return nil, apperrors.Internal(err)
	}
	if !hasAccess {
		return nil, apperrors.Forbidden("error.task_access_denied")
	}

	taskID := completion.TaskID

	if err := s.taskRepo.DeleteCompletion(completionID); err != nil {
		return nil, apperrors.Internal(err)
	}

	// Recalculate NextDueDate based on remaining completions
	task, err := s.taskRepo.FindByID(taskID)
	if err != nil {
		// Non-fatal for the delete operation itself, but log the error
		log.Error().Err(err).Uint("task_id", taskID).Msg("Failed to reload task after completion deletion for NextDueDate recalculation")
		return &responses.DeleteWithSummaryResponse{
			Data:    "completion deleted",
			Summary: s.getSummaryForUser(userID),
		}, nil
	}

	// Get remaining completions for this task
	remainingCompletions, err := s.taskRepo.FindCompletionsByTask(taskID)
	if err != nil {
		log.Error().Err(err).Uint("task_id", taskID).Msg("Failed to query remaining completions after deletion")
		return &responses.DeleteWithSummaryResponse{
			Data:    "completion deleted",
			Summary: s.getSummaryForUser(userID),
		}, nil
	}

	// Determine the task's frequency interval
	var intervalDays *int
	if task.FrequencyID != nil {
		frequency, freqErr := s.taskRepo.GetFrequencyByID(*task.FrequencyID)
		if freqErr == nil && frequency != nil {
			if frequency.Name == "Custom" {
				intervalDays = task.CustomIntervalDays
			} else {
				intervalDays = frequency.Days
			}
		}
	}

	if len(remainingCompletions) == 0 {
		// No completions remain: restore NextDueDate to the original DueDate
		task.NextDueDate = task.DueDate
	} else if intervalDays != nil && *intervalDays > 0 {
		// Recurring task with remaining completions: recalculate from the latest completion
		// remainingCompletions is ordered by completed_at DESC, so index 0 is the latest
		latestCompletion := remainingCompletions[0]
		nextDue := latestCompletion.CompletedAt.AddDate(0, 0, *intervalDays)
		task.NextDueDate = &nextDue
	} else {
		// One-time task with remaining completions (unusual case): keep NextDueDate as nil
		// since the task is still considered completed
		task.NextDueDate = nil
	}

	if err := s.taskRepo.Update(task); err != nil {
		log.Error().Err(err).Uint("task_id", taskID).Msg("Failed to update task NextDueDate after completion deletion")
		// The completion was already deleted; return success but log the update failure
	}

	return &responses.DeleteWithSummaryResponse{
		Data:    "completion deleted",
		Summary: s.getSummaryForUser(userID),
	}, nil
}

// GetCompletionsByTask gets all completions for a specific task
func (s *TaskService) GetCompletionsByTask(taskID, userID uint) ([]responses.TaskCompletionResponse, error) {
	// Get the task to check access
	task, err := s.taskRepo.FindByID(taskID)
	if err != nil {
		if errors.Is(err, gorm.ErrRecordNotFound) {
			return nil, apperrors.NotFound("error.task_not_found")
		}
		return nil, apperrors.Internal(err)
	}

	// Check access via residence
	hasAccess, err := s.residenceRepo.HasAccess(task.ResidenceID, userID)
	if err != nil {
		return nil, apperrors.Internal(err)
	}
	if !hasAccess {
		return nil, apperrors.Forbidden("error.task_access_denied")
	}

	// Get completions for the task
	completions, err := s.taskRepo.FindCompletionsByTask(taskID)
	if err != nil {
		return nil, apperrors.Internal(err)
	}

	return responses.NewTaskCompletionListResponse(completions), nil
}

// === Lookups ===

// GetCategories returns all task categories
func (s *TaskService) GetCategories() ([]responses.TaskCategoryResponse, error) {
	categories, err := s.taskRepo.GetAllCategories()
	if err != nil {
		return nil, apperrors.Internal(err)
	}

	result := make([]responses.TaskCategoryResponse, len(categories))
	for i, c := range categories {
		result[i] = *responses.NewTaskCategoryResponse(&c)
	}
	return result, nil
}

// GetPriorities returns all task priorities
func (s *TaskService) GetPriorities() ([]responses.TaskPriorityResponse, error) {
	priorities, err := s.taskRepo.GetAllPriorities()
	if err != nil {
		return nil, apperrors.Internal(err)
	}

	result := make([]responses.TaskPriorityResponse, len(priorities))
	for i, p := range priorities {
		result[i] = *responses.NewTaskPriorityResponse(&p)
	}
	return result, nil
}

// GetFrequencies returns all task frequencies
func (s *TaskService) GetFrequencies() ([]responses.TaskFrequencyResponse, error) {
	frequencies, err := s.taskRepo.GetAllFrequencies()
	if err != nil {
		return nil, apperrors.Internal(err)
	}

	result := make([]responses.TaskFrequencyResponse, len(frequencies))
	for i, f := range frequencies {
		result[i] = *responses.NewTaskFrequencyResponse(&f)
	}
	return result, nil
}

// === Timezone ===

// UpdateUserTimezone updates the user's timezone for background job calculations.
// This is called from handlers when the X-Timezone header is present.
// Delegates to NotificationService since timezone is stored in notification preferences.
func (s *TaskService) UpdateUserTimezone(userID uint, timezone string) {
	if s.notificationService != nil {
		s.notificationService.UpdateUserTimezone(userID, timezone)
	}
}
