using Microsoft.EntityFrameworkCore; using LittleShop.Data; using LittleShop.Models; using LittleShop.DTOs; namespace LittleShop.Services; public class ProductService : IProductService { private readonly LittleShopContext _context; private readonly IWebHostEnvironment _environment; public ProductService(LittleShopContext context, IWebHostEnvironment environment) { _context = context; _environment = environment; } public async Task> GetAllProductsAsync() { var products = await _context.Products .Include(p => p.Category) .Include(p => p.Photos) .Include(p => p.MultiBuys) .Where(p => p.IsActive) .ToListAsync(); Console.WriteLine($"[DEBUG] Loaded {products.Count} products"); // Manually load variants - using fresh query context to avoid Contains() json_each bug in EF Core 9 + SQLite // Load ALL active variants first, then filter in memory var allVariants = await _context.ProductVariants .Where(v => v.IsActive) .ToListAsync(); Console.WriteLine($"[DEBUG] Loaded {allVariants.Count} total variants from database"); // Filter to only variants that belong to loaded products var productIds = products.Select(p => p.Id).ToHashSet(); var matchingVariants = allVariants.Where(v => productIds.Contains(v.ProductId)).ToList(); Console.WriteLine($"[DEBUG] Filtered to {matchingVariants.Count} variants matching {productIds.Count} products"); foreach (var v in matchingVariants.Take(5)) { Console.WriteLine($"[DEBUG] - Variant: {v.Id}, ProductId: {v.ProductId}, Type: {v.VariantType}, Name: {v.Name}"); } // Group variants by ProductId for quick lookup var variantsByProduct = matchingVariants.GroupBy(v => v.ProductId) .ToDictionary(g => g.Key, g => g.ToList()); Console.WriteLine($"[DEBUG] Grouped variants into {variantsByProduct.Count} product groups"); return products.Select(p => new ProductDto { Id = p.Id, Name = p.Name, Description = p.Description, Price = p.Price, Weight = p.Weight, WeightUnit = p.WeightUnit, StockQuantity = p.StockQuantity, CategoryId = p.CategoryId, CategoryName = p.Category.Name, CreatedAt = p.CreatedAt, UpdatedAt = p.UpdatedAt, IsActive = p.IsActive, Photos = p.Photos.OrderBy(ph => ph.SortOrder).Select(ph => new ProductPhotoDto { Id = ph.Id, FileName = ph.FileName, FilePath = ph.FilePath, AltText = ph.AltText, SortOrder = ph.SortOrder }).ToList(), MultiBuys = p.MultiBuys.Select(mb => new ProductMultiBuyDto { Id = mb.Id, ProductId = mb.ProductId, Name = mb.Name, Description = mb.Description, Quantity = mb.Quantity, Price = mb.Price, PricePerUnit = mb.PricePerUnit, SortOrder = mb.SortOrder, IsActive = mb.IsActive, CreatedAt = mb.CreatedAt, UpdatedAt = mb.UpdatedAt }).ToList(), Variants = variantsByProduct.ContainsKey(p.Id) ? variantsByProduct[p.Id].Select(v => new ProductVariantDto { Id = v.Id, ProductId = v.ProductId, VariantType = v.VariantType, Name = v.Name, Price = v.Price, StockLevel = v.StockLevel, SortOrder = v.SortOrder, IsActive = v.IsActive, CreatedAt = v.CreatedAt, UpdatedAt = v.UpdatedAt }).ToList() : new List() }).ToList(); } public async Task> GetProductsByCategoryAsync(Guid categoryId) { var products = await _context.Products .Include(p => p.Category) .Include(p => p.Photos) .Include(p => p.MultiBuys) .Where(p => p.IsActive && p.CategoryId == categoryId) .ToListAsync(); // Manually load variants - using fresh query context to avoid Contains() json_each bug in EF Core 9 + SQLite // Load ALL active variants first, then filter in memory var allVariants = await _context.ProductVariants .Where(v => v.IsActive) .ToListAsync(); // Filter to only variants that belong to loaded products var productIds = products.Select(p => p.Id).ToHashSet(); var matchingVariants = allVariants.Where(v => productIds.Contains(v.ProductId)).ToList(); // Group variants by ProductId for quick lookup var variantsByProduct = matchingVariants.GroupBy(v => v.ProductId) .ToDictionary(g => g.Key, g => g.ToList()); return products.Select(p => new ProductDto { Id = p.Id, Name = p.Name, Description = p.Description, Price = p.Price, Weight = p.Weight, WeightUnit = p.WeightUnit, StockQuantity = p.StockQuantity, CategoryId = p.CategoryId, CategoryName = p.Category.Name, CreatedAt = p.CreatedAt, UpdatedAt = p.UpdatedAt, IsActive = p.IsActive, Photos = p.Photos.OrderBy(ph => ph.SortOrder).Select(ph => new ProductPhotoDto { Id = ph.Id, FileName = ph.FileName, FilePath = ph.FilePath, AltText = ph.AltText, SortOrder = ph.SortOrder }).ToList(), MultiBuys = p.MultiBuys.Select(mb => new ProductMultiBuyDto { Id = mb.Id, ProductId = mb.ProductId, Name = mb.Name, Description = mb.Description, Quantity = mb.Quantity, Price = mb.Price, PricePerUnit = mb.PricePerUnit, SortOrder = mb.SortOrder, IsActive = mb.IsActive, CreatedAt = mb.CreatedAt, UpdatedAt = mb.UpdatedAt }).ToList(), Variants = variantsByProduct.ContainsKey(p.Id) ? variantsByProduct[p.Id].Select(v => new ProductVariantDto { Id = v.Id, ProductId = v.ProductId, VariantType = v.VariantType, Name = v.Name, Price = v.Price, StockLevel = v.StockLevel, SortOrder = v.SortOrder, IsActive = v.IsActive, CreatedAt = v.CreatedAt, UpdatedAt = v.UpdatedAt }).ToList() : new List() }).ToList(); } public async Task GetProductByIdAsync(Guid id) { var product = await _context.Products .Include(p => p.Category) .Include(p => p.Photos) .Include(p => p.MultiBuys) .FirstOrDefaultAsync(p => p.Id == id); if (product == null) return null; // Manually load variants - using fresh query to avoid Contains() json_each bug in EF Core 9 + SQLite // Load ALL variants first to debug var allVariants = await _context.ProductVariants.ToListAsync(); Console.WriteLine($"[DEBUG GetProductById] Total variants in DB: {allVariants.Count}"); Console.WriteLine($"[DEBUG GetProductById] Looking for ProductId: {id} (Type: {id.GetType().Name})"); var matchingByProductId = allVariants.Where(v => v.ProductId == id).ToList(); Console.WriteLine($"[DEBUG GetProductById] Matching by ProductId: {matchingByProductId.Count}"); var matchingByIsActive = allVariants.Where(v => v.IsActive).ToList(); Console.WriteLine($"[DEBUG GetProductById] Matching by IsActive: {matchingByIsActive.Count}"); var variants = allVariants.Where(v => v.ProductId == id && v.IsActive).OrderBy(v => v.SortOrder).ToList(); Console.WriteLine($"[DEBUG GetProductById] Final variants loaded: {variants.Count}"); foreach (var v in variants.Take(3)) { Console.WriteLine($"[DEBUG GetProductById] - {v.VariantType}: {v.Name}, ProductId: {v.ProductId}"); } return new ProductDto { Id = product.Id, Name = product.Name, Description = product.Description, Price = product.Price, Weight = product.Weight, WeightUnit = product.WeightUnit, StockQuantity = product.StockQuantity, CategoryId = product.CategoryId, CategoryName = product.Category.Name, VariantCollectionId = product.VariantCollectionId, VariantsJson = product.VariantsJson, CreatedAt = product.CreatedAt, UpdatedAt = product.UpdatedAt, IsActive = product.IsActive, Photos = product.Photos.OrderBy(ph => ph.SortOrder).Select(ph => new ProductPhotoDto { Id = ph.Id, FileName = ph.FileName, FilePath = ph.FilePath, AltText = ph.AltText, SortOrder = ph.SortOrder }).ToList(), MultiBuys = product.MultiBuys.OrderBy(v => v.SortOrder).Select(v => new ProductMultiBuyDto { Id = v.Id, ProductId = v.ProductId, Name = v.Name, Description = v.Description, Quantity = v.Quantity, Price = v.Price, PricePerUnit = v.PricePerUnit, SortOrder = v.SortOrder, IsActive = v.IsActive, CreatedAt = v.CreatedAt, UpdatedAt = v.UpdatedAt }).ToList(), Variants = variants.Select(v => new ProductVariantDto { Id = v.Id, ProductId = v.ProductId, VariantType = v.VariantType, Name = v.Name, Price = v.Price, StockLevel = v.StockLevel, SortOrder = v.SortOrder, IsActive = v.IsActive, CreatedAt = v.CreatedAt, UpdatedAt = v.UpdatedAt }).ToList() }; } public async Task CreateProductAsync(CreateProductDto createProductDto) { var product = new Product { Id = Guid.NewGuid(), Name = createProductDto.Name, Description = string.IsNullOrEmpty(createProductDto.Description) ? " " : createProductDto.Description, Price = createProductDto.Price, Weight = createProductDto.Weight, WeightUnit = createProductDto.WeightUnit, StockQuantity = createProductDto.StockQuantity, CategoryId = createProductDto.CategoryId, VariantCollectionId = createProductDto.VariantCollectionId, VariantsJson = createProductDto.VariantsJson, CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow, IsActive = true }; _context.Products.Add(product); await _context.SaveChangesAsync(); var category = await _context.Categories.FindAsync(createProductDto.CategoryId); return new ProductDto { Id = product.Id, Name = product.Name, Description = product.Description, Price = product.Price, Weight = product.Weight, WeightUnit = product.WeightUnit, CategoryId = product.CategoryId, CategoryName = category?.Name ?? "", CreatedAt = product.CreatedAt, UpdatedAt = product.UpdatedAt, IsActive = product.IsActive, Photos = new List(), MultiBuys = new List(), Variants = new List() }; } public async Task UpdateProductAsync(Guid id, UpdateProductDto updateProductDto) { var product = await _context.Products.FindAsync(id); if (product == null) return false; if (!string.IsNullOrEmpty(updateProductDto.Name)) product.Name = updateProductDto.Name; if (!string.IsNullOrEmpty(updateProductDto.Description)) product.Description = updateProductDto.Description; if (updateProductDto.Price.HasValue) product.Price = updateProductDto.Price.Value; if (updateProductDto.Weight.HasValue) product.Weight = updateProductDto.Weight.Value; if (updateProductDto.WeightUnit.HasValue) product.WeightUnit = updateProductDto.WeightUnit.Value; if (updateProductDto.StockQuantity.HasValue) product.StockQuantity = updateProductDto.StockQuantity.Value; if (updateProductDto.CategoryId.HasValue) product.CategoryId = updateProductDto.CategoryId.Value; if (updateProductDto.VariantCollectionId.HasValue) product.VariantCollectionId = updateProductDto.VariantCollectionId.Value; if (updateProductDto.VariantsJson != null) product.VariantsJson = updateProductDto.VariantsJson; if (updateProductDto.IsActive.HasValue) product.IsActive = updateProductDto.IsActive.Value; product.UpdatedAt = DateTime.UtcNow; await _context.SaveChangesAsync(); return true; } public async Task DeleteProductAsync(Guid id) { var product = await _context.Products.FindAsync(id); if (product == null) return false; product.IsActive = false; await _context.SaveChangesAsync(); return true; } public async Task AddProductPhotoAsync(Guid productId, IFormFile file, string? altText = null) { var product = await _context.Products.FindAsync(productId); if (product == null) return false; var uploadsPath = Path.Combine(_environment.WebRootPath ?? "wwwroot", "uploads", "products"); Directory.CreateDirectory(uploadsPath); var fileName = $"{Guid.NewGuid()}_{file.FileName}"; var filePath = Path.Combine(uploadsPath, fileName); using (var stream = new FileStream(filePath, FileMode.Create)) { await file.CopyToAsync(stream); } var maxSortOrder = await _context.ProductPhotos .Where(pp => pp.ProductId == productId) .Select(pp => (int?)pp.SortOrder) .MaxAsync() ?? 0; var productPhoto = new ProductPhoto { Id = Guid.NewGuid(), ProductId = productId, FileName = fileName, FilePath = $"/uploads/products/{fileName}", AltText = altText, SortOrder = maxSortOrder + 1, CreatedAt = DateTime.UtcNow }; _context.ProductPhotos.Add(productPhoto); await _context.SaveChangesAsync(); return true; } public async Task RemoveProductPhotoAsync(Guid productId, Guid photoId) { var photo = await _context.ProductPhotos .FirstOrDefaultAsync(pp => pp.Id == photoId && pp.ProductId == productId); if (photo == null) return false; var physicalPath = Path.Combine(_environment.WebRootPath, photo.FilePath.TrimStart('/')); if (File.Exists(physicalPath)) { File.Delete(physicalPath); } _context.ProductPhotos.Remove(photo); await _context.SaveChangesAsync(); return true; } public async Task AddProductPhotoAsync(CreateProductPhotoDto photoDto) { var product = await _context.Products.FindAsync(photoDto.ProductId); if (product == null) return null; var existingPhotos = await _context.ProductPhotos .Where(pp => pp.ProductId == photoDto.ProductId) .ToListAsync(); var maxSortOrder = existingPhotos.Any() ? existingPhotos.Max(pp => pp.SortOrder) : 0; var productPhoto = new ProductPhoto { Id = Guid.NewGuid(), ProductId = photoDto.ProductId, FileName = Path.GetFileName(photoDto.PhotoUrl), FilePath = photoDto.PhotoUrl, AltText = photoDto.AltText, SortOrder = photoDto.DisplayOrder > 0 ? photoDto.DisplayOrder : maxSortOrder + 1, CreatedAt = DateTime.UtcNow }; _context.ProductPhotos.Add(productPhoto); await _context.SaveChangesAsync(); return new ProductPhotoDto { Id = productPhoto.Id, FileName = productPhoto.FileName, FilePath = productPhoto.FilePath, AltText = productPhoto.AltText, SortOrder = productPhoto.SortOrder }; } public async Task> SearchProductsAsync(string searchTerm) { var query = _context.Products .Include(p => p.Category) .Include(p => p.Photos) .Include(p => p.MultiBuys.Where(v => v.IsActive)) .Where(p => p.IsActive); if (!string.IsNullOrWhiteSpace(searchTerm)) { searchTerm = searchTerm.ToLower(); query = query.Where(p => p.Name.ToLower().Contains(searchTerm) || p.Description.ToLower().Contains(searchTerm)); } return await query.Select(p => new ProductDto { Id = p.Id, Name = p.Name, Description = p.Description, Price = p.Price, Weight = p.Weight, WeightUnit = p.WeightUnit, StockQuantity = p.StockQuantity, CategoryId = p.CategoryId, CategoryName = p.Category.Name, CreatedAt = p.CreatedAt, UpdatedAt = p.UpdatedAt, IsActive = p.IsActive, Photos = p.Photos.OrderBy(ph => ph.SortOrder).Select(ph => new ProductPhotoDto { Id = ph.Id, FileName = ph.FileName, FilePath = ph.FilePath, AltText = ph.AltText, SortOrder = ph.SortOrder }).ToList() }).ToListAsync(); } // Product Multi-Buy Methods public async Task CreateProductMultiBuyAsync(CreateProductMultiBuyDto createMultiBuyDto) { var product = await _context.Products.FindAsync(createMultiBuyDto.ProductId); if (product == null) throw new ArgumentException("Product not found"); // Check if multi-buy with this quantity already exists var existingMultiBuy = await _context.ProductMultiBuys .FirstOrDefaultAsync(v => v.ProductId == createMultiBuyDto.ProductId && v.Quantity == createMultiBuyDto.Quantity && v.IsActive); if (existingMultiBuy != null) throw new ArgumentException($"A multi-buy with quantity {createMultiBuyDto.Quantity} already exists for this product"); var pricePerUnit = createMultiBuyDto.Price / createMultiBuyDto.Quantity; var multiBuy = new ProductMultiBuy { Id = Guid.NewGuid(), ProductId = createMultiBuyDto.ProductId, Name = createMultiBuyDto.Name, Description = createMultiBuyDto.Description, Quantity = createMultiBuyDto.Quantity, Price = createMultiBuyDto.Price, PricePerUnit = pricePerUnit, SortOrder = createMultiBuyDto.SortOrder, IsActive = true, CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow }; _context.ProductMultiBuys.Add(multiBuy); try { await _context.SaveChangesAsync(); } catch (Microsoft.EntityFrameworkCore.DbUpdateException ex) when (ex.InnerException?.Message.Contains("UNIQUE constraint failed") == true) { throw new ArgumentException($"A multi-buy with quantity {createMultiBuyDto.Quantity} already exists for this product"); } return new ProductMultiBuyDto { Id = multiBuy.Id, ProductId = multiBuy.ProductId, Name = multiBuy.Name, Description = multiBuy.Description, Quantity = multiBuy.Quantity, Price = multiBuy.Price, PricePerUnit = multiBuy.PricePerUnit, SortOrder = multiBuy.SortOrder, IsActive = multiBuy.IsActive, CreatedAt = multiBuy.CreatedAt, UpdatedAt = multiBuy.UpdatedAt }; } public async Task UpdateProductMultiBuyAsync(Guid id, UpdateProductMultiBuyDto updateMultiBuyDto) { var multiBuy = await _context.ProductMultiBuys.FindAsync(id); if (multiBuy == null) return false; if (!string.IsNullOrEmpty(updateMultiBuyDto.Name)) multiBuy.Name = updateMultiBuyDto.Name; if (!string.IsNullOrEmpty(updateMultiBuyDto.Description)) multiBuy.Description = updateMultiBuyDto.Description; if (updateMultiBuyDto.Quantity.HasValue) multiBuy.Quantity = updateMultiBuyDto.Quantity.Value; if (updateMultiBuyDto.Price.HasValue) multiBuy.Price = updateMultiBuyDto.Price.Value; if (updateMultiBuyDto.Quantity.HasValue || updateMultiBuyDto.Price.HasValue) multiBuy.PricePerUnit = multiBuy.Price / multiBuy.Quantity; if (updateMultiBuyDto.SortOrder.HasValue) multiBuy.SortOrder = updateMultiBuyDto.SortOrder.Value; if (updateMultiBuyDto.IsActive.HasValue) multiBuy.IsActive = updateMultiBuyDto.IsActive.Value; multiBuy.UpdatedAt = DateTime.UtcNow; await _context.SaveChangesAsync(); return true; } public async Task DeleteProductMultiBuyAsync(Guid id) { var multiBuy = await _context.ProductMultiBuys.FindAsync(id); if (multiBuy == null) return false; multiBuy.IsActive = false; multiBuy.UpdatedAt = DateTime.UtcNow; await _context.SaveChangesAsync(); return true; } public async Task> GetProductMultiBuysAsync(Guid productId) { return await _context.ProductMultiBuys .Where(v => v.ProductId == productId && v.IsActive) .OrderBy(v => v.SortOrder) .Select(v => new ProductMultiBuyDto { Id = v.Id, ProductId = v.ProductId, Name = v.Name, Description = v.Description, Quantity = v.Quantity, Price = v.Price, PricePerUnit = v.PricePerUnit, SortOrder = v.SortOrder, IsActive = v.IsActive, CreatedAt = v.CreatedAt, UpdatedAt = v.UpdatedAt }) .ToListAsync(); } public async Task GetProductMultiBuyByIdAsync(Guid id) { var multiBuy = await _context.ProductMultiBuys.FindAsync(id); if (multiBuy == null) return null; return new ProductMultiBuyDto { Id = multiBuy.Id, ProductId = multiBuy.ProductId, Name = multiBuy.Name, Description = multiBuy.Description, Quantity = multiBuy.Quantity, Price = multiBuy.Price, PricePerUnit = multiBuy.PricePerUnit, SortOrder = multiBuy.SortOrder, IsActive = multiBuy.IsActive, CreatedAt = multiBuy.CreatedAt, UpdatedAt = multiBuy.UpdatedAt }; } // Product Variant Methods (for color/flavor options) public async Task CreateProductVariantAsync(CreateProductVariantDto createVariantDto) { var product = await _context.Products.FindAsync(createVariantDto.ProductId); if (product == null) throw new ArgumentException("Product not found"); // Check if variant with this name already exists var existingVariant = await _context.ProductVariants .FirstOrDefaultAsync(v => v.ProductId == createVariantDto.ProductId && v.Name == createVariantDto.Name && v.IsActive); if (existingVariant != null) throw new ArgumentException($"A variant named '{createVariantDto.Name}' already exists for this product"); var variant = new ProductVariant { Id = Guid.NewGuid(), ProductId = createVariantDto.ProductId, Name = createVariantDto.Name, VariantType = createVariantDto.VariantType, SortOrder = createVariantDto.SortOrder, StockLevel = createVariantDto.StockLevel, Price = createVariantDto.Price, IsActive = true, CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow }; _context.ProductVariants.Add(variant); await _context.SaveChangesAsync(); return new ProductVariantDto { Id = variant.Id, ProductId = variant.ProductId, Name = variant.Name, VariantType = variant.VariantType, SortOrder = variant.SortOrder, StockLevel = variant.StockLevel, Price = variant.Price, IsActive = variant.IsActive, CreatedAt = variant.CreatedAt, UpdatedAt = variant.UpdatedAt }; } public async Task UpdateProductVariantAsync(Guid id, UpdateProductVariantDto updateVariantDto) { var variant = await _context.ProductVariants.FindAsync(id); if (variant == null) return false; if (!string.IsNullOrEmpty(updateVariantDto.Name)) variant.Name = updateVariantDto.Name; if (!string.IsNullOrEmpty(updateVariantDto.VariantType)) variant.VariantType = updateVariantDto.VariantType; if (updateVariantDto.SortOrder.HasValue) variant.SortOrder = updateVariantDto.SortOrder.Value; if (updateVariantDto.StockLevel.HasValue) variant.StockLevel = updateVariantDto.StockLevel.Value; if (updateVariantDto.Price.HasValue) variant.Price = updateVariantDto.Price.Value; if (updateVariantDto.IsActive.HasValue) variant.IsActive = updateVariantDto.IsActive.Value; variant.UpdatedAt = DateTime.UtcNow; await _context.SaveChangesAsync(); return true; } public async Task DeleteProductVariantAsync(Guid id) { var variant = await _context.ProductVariants.FindAsync(id); if (variant == null) return false; variant.IsActive = false; variant.UpdatedAt = DateTime.UtcNow; await _context.SaveChangesAsync(); return true; } public async Task> GetProductVariantsAsync(Guid productId) { return await _context.ProductVariants .Where(v => v.ProductId == productId && v.IsActive) .OrderBy(v => v.SortOrder) .Select(v => new ProductVariantDto { Id = v.Id, ProductId = v.ProductId, Name = v.Name, VariantType = v.VariantType, SortOrder = v.SortOrder, StockLevel = v.StockLevel, Price = v.Price, IsActive = v.IsActive, CreatedAt = v.CreatedAt, UpdatedAt = v.UpdatedAt }) .ToListAsync(); } public async Task GetProductVariantByIdAsync(Guid id) { var variant = await _context.ProductVariants.FindAsync(id); if (variant == null) return null; return new ProductVariantDto { Id = variant.Id, ProductId = variant.ProductId, Name = variant.Name, VariantType = variant.VariantType, SortOrder = variant.SortOrder, StockLevel = variant.StockLevel, Price = variant.Price, IsActive = variant.IsActive, CreatedAt = variant.CreatedAt, UpdatedAt = variant.UpdatedAt }; } }