using AutoMapper; using GoodWood.Application.DTOs; using GoodWood.Application.Interfaces; using GoodWood.Domain.Entities; using GoodWood.Domain.Enums; using GoodWood.Domain.Interfaces; using GoodWood.Infrastructure.Data; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; namespace GoodWood.Application.Services; /// /// Service for managing game day operations within the current club context. /// public class DayService : IDayService { private readonly IDbContextFactory _contextFactory; private readonly IDayRepository _dayRepository; private readonly IPersonRepository _personRepository; private readonly ICurrentClubContext _clubContext; private readonly IMapper _mapper; private readonly IEmailService _emailService; private readonly ICashBookService _cashBookService; private readonly ILogger _logger; /// /// Initializes a new instance of the class. /// public DayService( IDbContextFactory contextFactory, IDayRepository dayRepository, IPersonRepository personRepository, ICurrentClubContext clubContext, IMapper mapper, IEmailService emailService, ICashBookService cashBookService, ILogger logger) { _contextFactory = contextFactory; _dayRepository = dayRepository; _personRepository = personRepository; _clubContext = clubContext; _mapper = mapper; _emailService = emailService; _cashBookService = cashBookService; _logger = logger; } /// public async Task> GetAllAsync(DayFilterDto? filter = null, CancellationToken ct = default) { await using var context = await _contextFactory.CreateDbContextAsync(ct); var query = context.Days .Where(d => d.ClubId == _clubContext.ClubId && !d.IsDeleted) .Include(d => d.DayPersons) .AsQueryable(); // Apply year filter if (filter?.Year.HasValue == true) { query = query.Where(d => d.PostDate.Year == filter.Year.Value); } // Apply status filter if (filter?.Status.HasValue == true) { query = query.Where(d => d.Status == filter.Status.Value); } var totalCount = await query.CountAsync(ct); // Order by PostDate descending (newest first) var days = await query .OrderByDescending(d => d.PostDate) .ToListAsync(ct); var items = days.Select(d => new DaySummaryDto { Id = d.Id, PostDate = d.PostDate, Status = d.Status, ParticipantCount = d.DayPersons.Count }).ToList(); return new PagedResultDto { Items = items, TotalCount = totalCount }; } /// public async Task GetByIdAsync(Guid id, CancellationToken ct = default) { await using var context = await _contextFactory.CreateDbContextAsync(ct); var day = await context.Days .Where(d => d.Id == id && d.ClubId == _clubContext.ClubId && !d.IsDeleted) .Include(d => d.DayPersons) .ThenInclude(dp => dp.Person) .Include(d => d.Club) .FirstOrDefaultAsync(ct); if (day is null) return null; return new DayDto { Id = day.Id, PostDate = day.PostDate, Status = day.Status, ClubId = day.ClubId, ClubName = day.Club?.Name, ParticipantCount = day.DayPersons.Count, Participants = day.DayPersons.Select(dp => new DayParticipantDto { PersonId = dp.PersonId, PersonName = dp.Person?.Name ?? string.Empty, PersonStatus = dp.Person?.PersonStatus ?? PersonStatus.Member }).ToList(), CreatedAt = day.CreatedAt, ModifiedAt = day.ModifiedAt }; } /// public async Task CreateAsync(CreateDayDto dto, CancellationToken ct = default) { await using var context = await _contextFactory.CreateDbContextAsync(ct); var day = new Day { Id = Guid.NewGuid(), PostDate = dto.PostDate, Status = DayStatus.New, ClubId = _clubContext.ClubId, CreatedAt = DateTime.UtcNow, IsDeleted = false }; // Add initial participants foreach (var personId in dto.ParticipantIds) { day.DayPersons.Add(new DayPerson { DayId = day.Id, PersonId = personId, ClubId = _clubContext.ClubId }); } context.Days.Add(day); await context.SaveChangesAsync(ct); return await GetByIdAsync(day.Id, ct) ?? throw new InvalidOperationException("Failed to create day."); } /// public async Task UpdateAsync(UpdateDayDto dto, CancellationToken ct = default) { await using var context = await _contextFactory.CreateDbContextAsync(ct); var day = await context.Days .Where(d => d.Id == dto.Id && d.ClubId == _clubContext.ClubId && !d.IsDeleted) .FirstOrDefaultAsync(ct) ?? throw new InvalidOperationException($"Day with id {dto.Id} not found."); day.PostDate = dto.PostDate; day.Status = dto.Status; day.ModifiedAt = DateTime.UtcNow; await context.SaveChangesAsync(ct); return await GetByIdAsync(day.Id, ct) ?? throw new InvalidOperationException("Failed to update day."); } /// public async Task DeleteAsync(Guid id, CancellationToken ct = default) { await using var context = await _contextFactory.CreateDbContextAsync(ct); var day = await context.Days .Where(d => d.Id == id && d.ClubId == _clubContext.ClubId && !d.IsDeleted) .FirstOrDefaultAsync(ct); if (day is null) return false; day.IsDeleted = true; day.ModifiedAt = DateTime.UtcNow; await context.SaveChangesAsync(ct); return true; } /// public async Task GetActiveDayAsync(CancellationToken ct = default) { await using var context = await _contextFactory.CreateDbContextAsync(ct); var today = DateTime.Today; var day = await context.Days .Where(d => d.ClubId == _clubContext.ClubId && !d.IsDeleted && d.PostDate.Date == today && d.Status != DayStatus.Closed) .Include(d => d.DayPersons) .ThenInclude(dp => dp.Person) .Include(d => d.Club) .FirstOrDefaultAsync(ct); if (day is null) return null; return new DayDto { Id = day.Id, PostDate = day.PostDate, Status = day.Status, ClubId = day.ClubId, ClubName = day.Club?.Name, ParticipantCount = day.DayPersons.Count, Participants = day.DayPersons.Select(dp => new DayParticipantDto { PersonId = dp.PersonId, PersonName = dp.Person?.Name ?? string.Empty, PersonStatus = dp.Person?.PersonStatus ?? PersonStatus.Member }).ToList(), CreatedAt = day.CreatedAt, ModifiedAt = day.ModifiedAt }; } /// public async Task AddParticipantAsync(AddDayParticipantDto dto, CancellationToken ct = default) { await using var context = await _contextFactory.CreateDbContextAsync(ct); var day = await context.Days .Where(d => d.Id == dto.DayId && d.ClubId == _clubContext.ClubId && !d.IsDeleted) .Include(d => d.DayPersons) .FirstOrDefaultAsync(ct) ?? throw new InvalidOperationException($"Day with id {dto.DayId} not found."); // Check if participant already exists if (day.DayPersons.Any(dp => dp.PersonId == dto.PersonId)) throw new InvalidOperationException("Person is already a participant of this day."); // Verify person belongs to club var person = await context.Persons .Where(p => p.Id == dto.PersonId && p.ClubId == _clubContext.ClubId && !p.IsDeleted) .FirstOrDefaultAsync(ct) ?? throw new InvalidOperationException($"Person with id {dto.PersonId} not found."); day.DayPersons.Add(new DayPerson { DayId = day.Id, PersonId = dto.PersonId, ClubId = _clubContext.ClubId }); day.ModifiedAt = DateTime.UtcNow; await context.SaveChangesAsync(ct); return await GetByIdAsync(day.Id, ct) ?? throw new InvalidOperationException("Failed to add participant."); } /// public async Task RemoveParticipantAsync(RemoveDayParticipantDto dto, CancellationToken ct = default) { await using var context = await _contextFactory.CreateDbContextAsync(ct); var day = await context.Days .Where(d => d.Id == dto.DayId && d.ClubId == _clubContext.ClubId && !d.IsDeleted) .Include(d => d.DayPersons) .FirstOrDefaultAsync(ct) ?? throw new InvalidOperationException($"Day with id {dto.DayId} not found."); var dayPerson = day.DayPersons.FirstOrDefault(dp => dp.PersonId == dto.PersonId); if (dayPerson is null) throw new InvalidOperationException("Person is not a participant of this day."); day.DayPersons.Remove(dayPerson); day.ModifiedAt = DateTime.UtcNow; await context.SaveChangesAsync(ct); return await GetByIdAsync(day.Id, ct) ?? throw new InvalidOperationException("Failed to remove participant."); } /// public async Task AdvanceStatusAsync(Guid id, CancellationToken ct = default) { await using var context = await _contextFactory.CreateDbContextAsync(ct); var day = await context.Days .Where(d => d.Id == id && d.ClubId == _clubContext.ClubId && !d.IsDeleted) .FirstOrDefaultAsync(ct) ?? throw new InvalidOperationException($"Day with id {id} not found."); // Advance status following workflow: New → Started → Closed day.Status = day.Status switch { DayStatus.New => DayStatus.Started, DayStatus.Started => DayStatus.Closed, DayStatus.Postponed => DayStatus.Closed, DayStatus.Closed => throw new InvalidOperationException("Day is already closed."), _ => throw new InvalidOperationException($"Unknown day status: {day.Status}") }; // Create absent member expenses when closing if (day.Status == DayStatus.Closed) { await CreateAbsentMemberExpensesAsync(context, day.Id, ct); } day.ModifiedAt = DateTime.UtcNow; await context.SaveChangesAsync(ct); // Create cash book entries for penalties when closing if (day.Status == DayStatus.Closed) { try { var entriesCreated = await _cashBookService.CreatePenaltyEntriesForDayAsync(day.Id, ct); _logger.LogInformation("Created {Count} cash book entries for day {DayId}", entriesCreated, day.Id); } catch (Exception ex) { _logger.LogError(ex, "Failed to create cash book entries for day {DayId}", day.Id); // Don't rethrow - cashbook failure should not fail day closing } } // Send protocol emails after closing (fire and forget, log errors) if (day.Status == DayStatus.Closed) { try { var emailsSent = await _emailService.SendDayProtocolAsync(day.Id, _clubContext.ClubId, ct); _logger.LogInformation("Sent {Count} day protocol emails for day {DayId}", emailsSent, day.Id); } catch (Exception ex) { _logger.LogError(ex, "Failed to send day protocol emails for day {DayId}", day.Id); // Don't rethrow - email failure should not fail day closing } } return await GetByIdAsync(day.Id, ct) ?? throw new InvalidOperationException("Failed to advance status."); } /// /// Creates PersonExpense for absent members when day closes. /// private async Task CreateAbsentMemberExpensesAsync( AppDbContext context, Guid dayId, CancellationToken ct) { // 1. Load day with club and participants var day = await context.Days .Include(d => d.Club) .Include(d => d.DayPersons) .FirstAsync(d => d.Id == dayId, ct); var club = day.Club; // 2. Skip if ExpenseCalculation is None if (club.ExpenseCalculation == ExpenseCalculation.None) return; // 3. Get total expenses per participant for this day var personExpenseTotals = await context.PersonExpenses .Where(pe => pe.DayId == dayId && !pe.IsDeleted) .GroupBy(pe => pe.PersonId) .Select(g => g.Sum(pe => pe.Price)) .ToListAsync(ct); // 4. Edge case: No expenses - skip if (personExpenseTotals.Count == 0) return; // 5. Calculate price based on per-person totals decimal calculatedPrice = club.ExpenseCalculation switch { ExpenseCalculation.Average => Math.Round(personExpenseTotals.Average(), 2), ExpenseCalculation.Maximum => personExpenseTotals.Max(), _ => 0m }; if (calculatedPrice <= 0) return; // 6. Get Expense linked to Absent trigger var absentExpense = await context.ExpenseTriggers .Where(et => et.ClubId == club.Id && et.Trigger.ExpenseTriggerType == ExpenseTriggerType.Absent && !et.Expense.IsDeleted) .Select(et => et.Expense) .FirstOrDefaultAsync(ct); // 7. No Absent expense configured - skip if (absentExpense is null) return; // 8. Get all club members var allMemberIds = await context.Persons .Where(p => p.ClubId == club.Id && p.PersonStatus == PersonStatus.Member && !p.IsDeleted) .Select(p => p.Id) .ToListAsync(ct); // 9. Get participant IDs var participantIds = day.DayPersons .Select(dp => dp.PersonId) .ToHashSet(); // 10. Absent members = members NOT in DayPersons var absentMemberIds = allMemberIds .Where(id => !participantIds.Contains(id)) .ToList(); if (absentMemberIds.Count == 0) return; // 11. Create PersonExpense for each absent member foreach (var memberId in absentMemberIds) { var personExpense = new PersonExpense { Id = Guid.NewGuid(), PersonId = memberId, ExpenseId = absentExpense.Id, DayId = dayId, GameId = null, ClubId = club.Id, Name = absentExpense.Name, Price = calculatedPrice, ExpenseType = absentExpense.ExpenseType, PersonExpenseStatus = PersonExpenseStatus.Open, AssignedById = Guid.Empty, CreatedAt = DateTime.UtcNow, IsDeleted = false }; context.PersonExpenses.Add(personExpense); } // SaveChanges called by AdvanceStatusAsync } }