using PowderCoating.Application.DTOs.Quote;
using PowderCoating.Application.Interfaces;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Interfaces;
using Microsoft.Extensions.Logging;

namespace PowderCoating.Application.Services;

/// <summary>
/// Orchestrates the full quote item assembly pipeline: pricing calculation, entity construction,
/// AI prediction tracking, and automatic inventory record creation for incoming powder orders.
///
/// This service sits above <see cref="PricingCalculationService"/> — it knows HOW to build and
/// persist quote entities, while PricingCalculationService knows HOW to compute dollar amounts.
/// Keeping them separate means pricing logic can be unit-tested without any entity construction concerns.
///
/// Key responsibilities:
///   - <see cref="ApplyPricingSnapshot"/>  — stamps calculated totals onto the Quote entity so the
///     displayed price is frozen at quote time and won't change if operating costs are updated later.
///   - <see cref="CreateQuoteItemsAsync"/> — builds QuoteItem + coats + prep services for each DTO,
///     records AI prediction overrides, and auto-creates incoming inventory records when needed.
/// </summary>
public class QuotePricingAssemblyService : IQuotePricingAssemblyService
{
    private readonly IUnitOfWork _unitOfWork;
    private readonly IPricingCalculationService _pricingService;
    private readonly IInventoryAiLookupService _aiLookupService;
    private readonly ILogger<QuotePricingAssemblyService> _logger;

    public QuotePricingAssemblyService(
        IUnitOfWork unitOfWork,
        IPricingCalculationService pricingService,
        IInventoryAiLookupService aiLookupService,
        ILogger<QuotePricingAssemblyService> logger)
    {
        _unitOfWork = unitOfWork;
        _pricingService = pricingService;
        _aiLookupService = aiLookupService;
        _logger = logger;
    }

    /// <summary>
    /// Writes the calculated pricing breakdown onto the <see cref="Quote"/> entity as a snapshot.
    /// Snapshots are critical: once a quote is sent to a customer, operating cost changes must NOT
    /// silently alter the quoted amounts — the snapshot preserves what was presented at the time.
    /// </summary>
    public void ApplyPricingSnapshot(Quote quote, QuotePricingResult pricingResult)
    {
        ArgumentNullException.ThrowIfNull(quote);
        ArgumentNullException.ThrowIfNull(pricingResult);

        quote.MaterialCosts                = pricingResult.MaterialCosts;
        quote.LaborCosts                   = pricingResult.LaborCosts;
        quote.EquipmentCosts               = pricingResult.EquipmentCosts;
        quote.ItemsSubtotal                = pricingResult.ItemsSubtotal;
        quote.OvenBatchCost                = pricingResult.OvenBatchCost;
        quote.FacilityOverheadCost         = pricingResult.FacilityOverheadCost;
        quote.FacilityOverheadRatePerHour  = pricingResult.FacilityOverheadRatePerHour;
        quote.ShopSuppliesAmount           = pricingResult.ShopSuppliesAmount;
        quote.ShopSuppliesPercent          = pricingResult.ShopSuppliesPercent;
        quote.OverheadAmount               = pricingResult.OverheadCosts;
        quote.OverheadPercent              = pricingResult.OverheadPercent;
        quote.ProfitMargin                 = pricingResult.ProfitMargin;
        quote.ProfitPercent                = pricingResult.ProfitPercent;
        quote.SubTotal                     = pricingResult.SubtotalBeforeDiscount;
        quote.PricingTierDiscountAmount    = pricingResult.PricingTierDiscountAmount;
        quote.PricingTierDiscountPercent   = pricingResult.PricingTierDiscountPercent;
        quote.QuoteDiscountAmount          = pricingResult.QuoteDiscountAmount;
        quote.QuoteDiscountPercent         = pricingResult.QuoteDiscountPercent;
        quote.DiscountPercent              = pricingResult.DiscountPercent;
        quote.DiscountAmount               = pricingResult.DiscountAmount;
        quote.SubtotalAfterDiscount        = pricingResult.SubtotalAfterDiscount;
        quote.RushFee                      = pricingResult.RushFee;
        quote.TaxAmount                    = pricingResult.TaxAmount;
        quote.Total                        = pricingResult.Total;
    }

    /// <summary>
    /// Builds and prices all <see cref="QuoteItem"/> entities from the incoming DTOs.
    /// For each item: constructs the entity, calculates pricing, records whether the user overrode
    /// an AI estimate, then attaches coats (including auto-creating incoming inventory entries when
    /// the user selects a catalog powder not yet in their inventory) and prep services.
    /// </summary>
    public async Task<IReadOnlyList<QuoteItem>> CreateQuoteItemsAsync(
        IEnumerable<CreateQuoteItemDto> itemDtos,
        int quoteId,
        int companyId,
        decimal? ovenRateOverride,
        DateTime createdAtUtc)
    {
        ArgumentNullException.ThrowIfNull(itemDtos);

        var dtoList = itemDtos.ToList();
        var items = new List<QuoteItem>();
        foreach (var itemDto in dtoList)
        {
            var item = BuildQuoteItem(itemDto, quoteId, companyId, createdAtUtc);
            await ApplyPricingAsync(item, itemDto, companyId, ovenRateOverride);
            await UpdateAiPredictionOverrideAsync(itemDto, item.UnitPrice);

            item.Coats = await BuildQuoteItemCoatsAsync(itemDto, companyId, createdAtUtc);
            item.PrepServices = BuildQuoteItemPrepServices(itemDto, companyId, createdAtUtc);
            items.Add(item);
        }

        // Option B: auto-create the Custom Powder Order item only on first save.
        // Once user-owned, they manage its price (e.g. to add shipping) — we never overwrite it.
        bool hasExistingCustomPowderOrder = dtoList.Any(d =>
            d.IsGenericItem && d.Description?.StartsWith("Custom Powder Order") == true);
        if (!hasExistingCustomPowderOrder)
        {
            var customPowderItem = await BuildCustomPowderOrderItemAsync(dtoList, quoteId, companyId, createdAtUtc);
            if (customPowderItem != null)
                items.Add(customPowderItem);
        }

        return items;
    }

    /// <summary>
    /// Routes a single item to the correct pricing path and stamps the result onto the entity.
    /// Priority order matches the routing table in <see cref="PricingCalculationService.CalculateQuoteItemPriceAsync"/>:
    /// AI items → Sales items → Catalog (no coats) → full calculation engine.
    /// Keeping pricing logic in PricingCalculationService means this method only decides WHICH
    /// path to take, never HOW to compute the price.
    /// </summary>
    private async Task ApplyPricingAsync(QuoteItem item, CreateQuoteItemDto itemDto, int companyId, decimal? ovenRateOverride)
    {
        if (itemDto.IsAiItem && itemDto.ManualUnitPrice.HasValue && itemDto.ManualUnitPrice.Value > 0)
        {
            item.UnitPrice = itemDto.ManualUnitPrice.Value;
            item.TotalPrice = itemDto.ManualUnitPrice.Value * itemDto.Quantity;
            _logger.LogInformation("AI item price: UnitPrice={Unit}, TotalPrice={Total}", item.UnitPrice, item.TotalPrice);
            return;
        }

        if (itemDto.IsSalesItem && itemDto.ManualUnitPrice.HasValue)
        {
            item.UnitPrice = itemDto.ManualUnitPrice.Value;
            item.TotalPrice = itemDto.ManualUnitPrice.Value * itemDto.Quantity;
            _logger.LogInformation("Sales item price: UnitPrice={Unit}, TotalPrice={Total}", item.UnitPrice, item.TotalPrice);
            return;
        }

        if (itemDto.IsCustomFormulaItem && itemDto.ManualUnitPrice.HasValue)
        {
            item.UnitPrice = itemDto.ManualUnitPrice.Value;
            item.TotalPrice = itemDto.ManualUnitPrice.Value * itemDto.Quantity;
            _logger.LogInformation("Custom formula item (FixedRate) price: UnitPrice={Unit}, TotalPrice={Total}", item.UnitPrice, item.TotalPrice);
            return;
        }

        if (itemDto.CatalogItemId.HasValue)
        {
            if (itemDto.Coats != null && itemDto.Coats.Any())
            {
                _logger.LogInformation("Calculating catalog item with {CoatCount} coats", itemDto.Coats.Count);
                var itemPricing = await _pricingService.CalculateQuoteItemPriceAsync(itemDto, companyId, ovenRateOverride);
                ApplyCalculatedPricing(item, itemPricing);
                return;
            }

            var catalogItem = await _unitOfWork.CatalogItems.GetByIdAsync(itemDto.CatalogItemId.Value);
            if (catalogItem != null)
            {
                var unitPrice = itemDto.PowderCostOverride is > 0
                    ? itemDto.PowderCostOverride.Value
                    : catalogItem.DefaultPrice;
                item.UnitPrice = unitPrice;
                item.TotalPrice = unitPrice * itemDto.Quantity;
                _logger.LogInformation("Catalog item no coats: UnitPrice={Unit}, TotalPrice={Total}", item.UnitPrice, item.TotalPrice);
            }

            return;
        }

        _logger.LogInformation("Calculating custom item with {CoatCount} coats", itemDto.Coats?.Count ?? 0);
        var pricing = await _pricingService.CalculateQuoteItemPriceAsync(itemDto, companyId, ovenRateOverride);
        ApplyCalculatedPricing(item, pricing);
    }

    /// <summary>
    /// Builds <see cref="QuoteItemCoat"/> entities for a single item, including per-coat pricing.
    /// When a coat references the platform catalog (<c>CatalogItemId</c> set), the ID is stored on
    /// <see cref="QuoteItemCoat.PowderCatalogItemId"/> so that at <em>approval</em> time the system
    /// can create exactly one <see cref="InventoryItem"/> per unique powder across all coats on the
    /// quote (deduplication). No inventory is created during quote save.
    /// </summary>
    private async Task<List<QuoteItemCoat>> BuildQuoteItemCoatsAsync(CreateQuoteItemDto itemDto, int companyId, DateTime createdAtUtc)
    {
        if (itemDto.Coats == null || itemDto.Coats.Count == 0)
            return [];

        var coats = new List<QuoteItemCoat>();
        for (var coatIndex = 0; coatIndex < itemDto.Coats.Count; coatIndex++)
        {
            var coatDto = itemDto.Coats[coatIndex];

            // Incoming-inventory creation is intentionally deferred to quote approval.
            // PowderCatalogItemId is persisted on the coat entity for later use.

            var coat = BuildQuoteItemCoat(coatDto, companyId, createdAtUtc);
            var coatPricing = await _pricingService.CalculateCoatPriceAsync(
                coatDto,
                itemDto.SurfaceAreaSqFt,
                itemDto.Quantity,
                coatIndex,
                itemDto.EstimatedMinutes,
                companyId);

            coat.CoatMaterialCost = coatPricing.CoatMaterialCost;
            coat.CoatLaborCost = coatPricing.CoatLaborCost;
            coat.CoatTotalCost = coatPricing.CoatTotalCost;
            coats.Add(coat);
        }

        return coats;
    }

    /// <summary>Constructs <see cref="QuoteItemPrepService"/> entities from the item DTO's prep service list.</summary>
    private static List<QuoteItemPrepService> BuildQuoteItemPrepServices(CreateQuoteItemDto itemDto, int companyId, DateTime createdAtUtc)
    {
        if (itemDto.PrepServices == null || itemDto.PrepServices.Count == 0)
            return [];

        return itemDto.PrepServices
            .Select(ps => new QuoteItemPrepService
            {
                PrepServiceId = ps.PrepServiceId,
                EstimatedMinutes = ps.EstimatedMinutes,
                BlastSetupId = ps.BlastSetupId,
                CompanyId = companyId,
                CreatedAt = createdAtUtc
            })
            .ToList();
    }

    /// <summary>
    /// Constructs a bare <see cref="QuoteItem"/> entity from the DTO — no pricing or coats yet.
    /// Pricing is applied separately by <see cref="ApplyPricingAsync"/> to keep the construction
    /// and calculation steps distinct and individually testable.
    /// </summary>
    private static QuoteItem BuildQuoteItem(CreateQuoteItemDto itemDto, int quoteId, int companyId, DateTime createdAtUtc)
    {
        return new QuoteItem
        {
            QuoteId = quoteId,
            Description = itemDto.Description,
            Quantity = itemDto.Quantity,
            SurfaceAreaSqFt = itemDto.SurfaceAreaSqFt,
            CatalogItemId = itemDto.CatalogItemId,
            IsGenericItem = itemDto.IsGenericItem,
            ManualUnitPrice = itemDto.ManualUnitPrice,
            PowderCostOverride = itemDto.PowderCostOverride,
            IsLaborItem = itemDto.IsLaborItem,
            IsSalesItem = itemDto.IsSalesItem,
            Sku = itemDto.Sku,
            RequiresSandblasting = itemDto.RequiresSandblasting,
            RequiresMasking = itemDto.RequiresMasking,
            EstimatedMinutes = itemDto.EstimatedMinutes,
            IncludePrepCost = itemDto.IncludePrepCost,
            Notes = itemDto.Notes,
            Complexity = itemDto.Complexity,
            IsAiItem = itemDto.IsAiItem,
            AiTags = itemDto.AiTags,
            AiPredictionId = itemDto.AiPredictionId,
            IsCustomFormulaItem = itemDto.IsCustomFormulaItem,
            CustomItemTemplateId = itemDto.CustomItemTemplateId,
            FormulaFieldValuesJson = itemDto.FormulaFieldValuesJson,
            CompanyId = companyId,
            CreatedAt = createdAtUtc
        };
    }

    /// <summary>Constructs a <see cref="QuoteItemCoat"/> entity from the coat DTO. Per-coat pricing is applied by the caller.</summary>
    private static QuoteItemCoat BuildQuoteItemCoat(CreateQuoteItemCoatDto coatDto, int companyId, DateTime createdAtUtc)
    {
        return new QuoteItemCoat
        {
            CoatName = coatDto.CoatName,
            Sequence = coatDto.Sequence,
            InventoryItemId = coatDto.InventoryItemId,
            PowderCatalogItemId = coatDto.CatalogItemId,
            ColorName = coatDto.ColorName,
            VendorId = coatDto.VendorId,
            ColorCode = coatDto.ColorCode,
            Finish = coatDto.Finish,
            CoverageSqFtPerLb = coatDto.CoverageSqFtPerLb,
            TransferEfficiency = coatDto.TransferEfficiency,
            PowderCostPerLb = coatDto.PowderCostPerLb,
            PowderToOrder = coatDto.PowderToOrder,
            NoExtraLayerCharge = coatDto.NoExtraLayerCharge,
            Notes = coatDto.Notes,
            CompanyId = companyId,
            CreatedAt = createdAtUtc
        };
    }

    /// <summary>
    /// Stamps the pricing result onto the quote item entity.
    /// Broken out as a separate method because it's called from multiple branches of ApplyPricingAsync.
    /// </summary>
    private static void ApplyCalculatedPricing(QuoteItem item, QuoteItemPricingResult pricing)
    {
        item.UnitPrice = pricing.UnitPrice;
        item.TotalPrice = pricing.TotalPrice;
        item.ItemMaterialCost = pricing.MaterialCost;
        item.ItemLaborCost = pricing.LaborCost;
        item.ItemEquipmentCost = pricing.EquipmentCost;
    }

    /// <summary>
    /// Checks whether the user changed the AI's surface area or price estimates before saving,
    /// and sets <c>UserOverrodeEstimate = true</c> on the prediction record if they did.
    /// This flag feeds the AI analytics reports — over time it reveals how accurate the AI is
    /// and whether certain item types consistently need manual correction.
    /// A tolerance of $0.01 / 0.01 sqft is used to ignore floating-point rounding noise.
    /// </summary>
    private async Task UpdateAiPredictionOverrideAsync(CreateQuoteItemDto itemDto, decimal finalUnitPrice)
    {
        if (!itemDto.AiPredictionId.HasValue) return;

        var prediction = await _unitOfWork.AiItemPredictions.GetByIdAsync(itemDto.AiPredictionId.Value);
        if (prediction == null) return;

        var sqftDiff = Math.Abs(prediction.PredictedSurfaceAreaSqFt - itemDto.SurfaceAreaSqFt);
        var priceDiff = Math.Abs(prediction.PredictedUnitPrice - finalUnitPrice);
        prediction.UserOverrodeEstimate = sqftDiff > 0.01m || priceDiff > 0.01m;
        prediction.UpdatedAt = DateTime.UtcNow;
    }

    /// <summary>
    /// Creates one "incoming" <see cref="InventoryItem"/> from a platform catalog entry.
    /// Called at quote-approval time (not during quote save) so inventory records only appear
    /// when a job is actually going to be created. The caller groups coats by
    /// <c>PowderCatalogItemId</c> and calls this once per unique catalog item, preventing
    /// duplicate records when the same powder appears on multiple items in the same quote.
    ///
    /// Category resolution prefers the company's "POWDER" category (CategoryCode=="POWDER")
    /// so the item always lands in the right bucket regardless of how many IsCoating categories
    /// the company has defined. Falls back to the lowest-DisplayOrder IsCoating category.
    ///
    /// AI augmentation fills in missing technical specs (cure temp/time, coverage, color families)
    /// from the manufacturer product page. Best-effort — item is still created from catalog data
    /// if the AI call fails.
    /// </summary>
    private async Task<int?> CreateIncomingInventoryItemAsync(int catalogItemId, int companyId)
    {
        try
        {
            var catalogItem = await _unitOfWork.PowderCatalog.GetByIdAsync(catalogItemId);
            if (catalogItem == null) return null;

            var categories = await _unitOfWork.InventoryCategoryLookups.GetAllAsync();
            // Prefer the canonical "POWDER" category so catalog-sourced items never land in an
            // unrelated coating category (e.g. "Cerakote") that happens to have IsCoating=true.
            var coatingCategory = categories.FirstOrDefault(c => c.IsActive && c.IsCoating
                                      && c.CategoryCode.Equals("POWDER", StringComparison.OrdinalIgnoreCase))
                               ?? categories
                                      .Where(c => c.IsActive && c.IsCoating)
                                      .OrderBy(c => c.DisplayOrder)
                                      .FirstOrDefault();

            var vendors = await _unitOfWork.Vendors.GetAllAsync();
            var vendorNameLower = catalogItem.VendorName.ToLower();
            var matchedVendor = vendors.FirstOrDefault(v =>
                v.CompanyName.ToLower().Contains(vendorNameLower) ||
                vendorNameLower.Contains(v.CompanyName.ToLower()));

            var code = coatingCategory != null
                ? (coatingCategory.CategoryCode.Length >= 4
                    ? coatingCategory.CategoryCode[..4].ToUpperInvariant()
                    : coatingCategory.CategoryCode.ToUpperInvariant().PadRight(4, 'X'))
                : "POWD";
            var prefix = $"{code}-{DateTime.Now:yyMM}-";
            var allItems = await _unitOfWork.InventoryItems.GetAllAsync(ignoreQueryFilters: true);
            var maxSeq = allItems
                .Where(i => i.SKU.StartsWith(prefix))
                .Select(i => int.TryParse(i.SKU[prefix.Length..], out var n) ? n : 0)
                .DefaultIfEmpty(0)
                .Max();
            var sku = $"{prefix}{(maxSeq + 1):D4}";

            var name = System.Globalization.CultureInfo.CurrentCulture.TextInfo
                .ToTitleCase(catalogItem.ColorName.Trim().ToLower());

            var description = catalogItem.Description;
            var finish = catalogItem.Finish;
            var colorFamilies = catalogItem.ColorFamilies;
            var cureTemp = catalogItem.CureTemperatureF;
            var cureTime = catalogItem.CureTimeMinutes;
            var coverage = catalogItem.CoverageSqFtPerLb;
            var transferEff = catalogItem.TransferEfficiency;
            var specificGravity = catalogItem.SpecificGravity;
            var imageUrl = catalogItem.ImageUrl;
            var sdsUrl = catalogItem.SdsUrl;
            var tdsUrl = catalogItem.TdsUrl;

            var needsAugment = !string.IsNullOrWhiteSpace(catalogItem.ProductUrl) &&
                               (string.IsNullOrWhiteSpace(description) ||
                                string.IsNullOrWhiteSpace(colorFamilies) ||
                                cureTemp == null || cureTime == null);
            if (needsAugment)
            {
                try
                {
                    var augmented = await _aiLookupService.LookupByUrlAsync(catalogItem.ProductUrl!, catalogItem.ColorName, catalogItem.TdsUrl);
                    if (augmented.Success)
                    {
                        description = string.IsNullOrWhiteSpace(description) ? augmented.Description : description;
                        finish = string.IsNullOrWhiteSpace(finish) ? augmented.Finish : finish;
                        colorFamilies = string.IsNullOrWhiteSpace(colorFamilies) ? augmented.ColorFamilies : colorFamilies;
                        cureTemp ??= augmented.CureTemperatureF;
                        cureTime ??= augmented.CureTimeMinutes;
                        coverage ??= augmented.CoverageSqFtPerLb;
                        transferEff ??= augmented.TransferEfficiency;
                        specificGravity ??= augmented.SpecificGravity;
                        imageUrl = string.IsNullOrWhiteSpace(imageUrl) ? augmented.ImageUrl : imageUrl;
                        sdsUrl = string.IsNullOrWhiteSpace(sdsUrl) ? augmented.SdsUrl : sdsUrl;
                        tdsUrl = string.IsNullOrWhiteSpace(tdsUrl) ? augmented.TdsUrl : tdsUrl;
                        _logger.LogInformation("AI-augmented incoming inventory item for catalog {CatalogId}", catalogItem.Id);
                    }
                }
                catch (Exception ex)
                {
                    _logger.LogWarning(ex, "AI augment failed for catalog {CatalogId}, continuing with catalog data", catalogItem.Id);
                }
            }

            var item = new InventoryItem
            {
                SKU = sku,
                Name = name,
                Description = description,
                ColorName = catalogItem.ColorName,
                Manufacturer = catalogItem.VendorName,
                ManufacturerPartNumber = catalogItem.Sku,
                Finish = finish,
                ColorFamilies = colorFamilies,
                RequiresClearCoat = catalogItem.RequiresClearCoat ?? false,
                CoverageSqFtPerLb = coverage ?? 30m,
                TransferEfficiency = transferEff ?? 65m,
                CureTemperatureF = cureTemp,
                CureTimeMinutes = cureTime,
                SpecificGravity = specificGravity,
                SpecPageUrl = catalogItem.ProductUrl,
                ImageUrl = imageUrl,
                SdsUrl = sdsUrl,
                TdsUrl = tdsUrl,
                UnitCost = catalogItem.UnitPrice,
                AverageCost = catalogItem.UnitPrice,
                LastPurchasePrice = catalogItem.UnitPrice,
                QuantityOnHand = 0,
                UnitOfMeasure = "lbs",
                PrimaryVendorId = matchedVendor?.Id,
                InventoryCategoryId = coatingCategory?.Id,
                Category = coatingCategory?.DisplayName ?? "Powder Coating",
                IsActive = true,
                IsIncoming = true,
                CompanyId = companyId,
                CreatedAt = DateTime.UtcNow,
            };

            await _unitOfWork.InventoryItems.AddAsync(item);
            await _unitOfWork.SaveChangesAsync();

            _logger.LogInformation("Created incoming inventory item {Id} ({Name}) from catalog {CatalogId} at quote approval",
                item.Id, item.Name, catalogItemId);

            return item.Id;
        }
        catch (Exception ex)
        {
            _logger.LogWarning(ex, "Failed to create incoming inventory item from catalog {CatalogId}, continuing without inventory link",
                catalogItemId);
            return null;
        }
    }

    /// <summary>
    /// Scans all coat DTOs for powder that must be ordered (custom or catalog-sourced) and returns a
    /// single "Custom Powder Order" QuoteItem aggregating all material costs and color names.
    /// Returns null when no such coats are found. Used by <see cref="CreateQuoteItemsAsync"/>
    /// on the first save only — Option B means the user owns the price after creation.
    ///
    /// Coat types that qualify:
    ///   - Custom powder: no InventoryItemId, manual PowderCostPerLb &gt; 0 (user-entered)
    ///   - Catalog-sourced pending incoming: CatalogItemId set, no InventoryItemId, PowderCostPerLb
    ///     pre-filled from catalog unit price (inventory creation deferred to approval)
    ///   - Legacy path: InventoryItemId set and item.IsIncoming == true (pre-fix records)
    /// </summary>
    private async Task<QuoteItem?> BuildCustomPowderOrderItemAsync(
        IReadOnlyList<CreateQuoteItemDto> itemDtos, int quoteId, int companyId, DateTime createdAtUtc)
    {
        var colorNames = new List<string>();
        decimal totalCost = 0m;

        foreach (var itemDto in itemDtos)
        {
            if (itemDto.Coats == null) continue;
            foreach (var coat in itemDto.Coats)
            {
                if (!coat.InventoryItemId.HasValue &&
                    coat.PowderToOrder.HasValue && coat.PowderToOrder.Value > 0 &&
                    coat.PowderCostPerLb.HasValue && coat.PowderCostPerLb.Value > 0)
                {
                    // Custom powder (manual cost) or catalog-sourced incoming (cost pre-filled from catalog).
                    // Both arrive here the same way: PowderCostPerLb set, no inventory link yet.
                    totalCost += coat.PowderToOrder.Value * coat.PowderCostPerLb.Value;
                    if (!string.IsNullOrWhiteSpace(coat.ColorName))
                        colorNames.Add(coat.ColorName);
                }
                else if (coat.InventoryItemId.HasValue && coat.PowderToOrder.HasValue && coat.PowderToOrder.Value > 0)
                {
                    // Legacy path: inventory was already created (quotes saved before the deferred-creation fix).
                    // PowderCostPerLb was cleared on those coats so cost must come from inventory.
                    var invItem = await _unitOfWork.InventoryItems.GetByIdAsync(coat.InventoryItemId.Value);
                    if (invItem?.IsIncoming == true)
                    {
                        totalCost += coat.PowderToOrder.Value * invItem.UnitCost;
                        var colorName = !string.IsNullOrWhiteSpace(coat.ColorName) ? coat.ColorName : invItem.Name;
                        if (!string.IsNullOrWhiteSpace(colorName))
                            colorNames.Add(colorName);
                    }
                }
            }
        }

        if (totalCost <= 0) return null;

        var uniqueColors = colorNames
            .Distinct(StringComparer.OrdinalIgnoreCase)
            .ToList();
        var description = uniqueColors.Any()
            ? $"Custom Powder Order ({string.Join(", ", uniqueColors)})"
            : "Custom Powder Order";

        return new QuoteItem
        {
            QuoteId = quoteId,
            Description = description,
            Quantity = 1,
            IsGenericItem = true,
            ManualUnitPrice = totalCost,
            UnitPrice = totalCost,
            TotalPrice = totalCost,
            ItemMaterialCost = totalCost,
            CompanyId = companyId,
            CreatedAt = createdAtUtc,
            Coats = [],
            PrepServices = []
        };
    }

    /// <summary>
    /// Called at quote approval time to create exactly one <see cref="InventoryItem"/> per unique
    /// powder catalog entry referenced across all coats on the quote, then links each coat to its
    /// new (or existing) inventory record.
    ///
    /// WHY deferred: during quoting the job may never be approved, so creating inventory records at
    /// quote-save time produces orphaned, never-ordered items. Deferring to approval ensures inventory
    /// only reflects powders the shop is actually going to process.
    ///
    /// Deduplication: multiple items on the same quote that use the same catalog powder receive the
    /// same InventoryItemId — no duplicate records are created.
    ///
    /// Idempotent: coats that already have an InventoryItemId are skipped, so calling this method
    /// on an already-approved quote (e.g. retry after a transient error) is safe.
    /// </summary>
    public async Task EnsureIncomingInventoryForApprovedQuoteAsync(int quoteId, int companyId)
    {
        // Load all QuoteItems for this quote with their coats so we can inspect PowderCatalogItemId.
        var quoteItems = await _unitOfWork.QuoteItems.FindAsync(
            qi => qi.QuoteId == quoteId && qi.CompanyId == companyId,
            false,
            qi => qi.Coats);

        var pendingCoats = quoteItems
            .SelectMany(qi => qi.Coats)
            .Where(c => c.PowderCatalogItemId.HasValue && !c.InventoryItemId.HasValue)
            .ToList();

        if (pendingCoats.Count == 0) return;

        // Group by catalog item ID so each unique powder generates exactly one inventory record.
        var groups = pendingCoats
            .GroupBy(c => c.PowderCatalogItemId!.Value)
            .ToList();

        foreach (var group in groups)
        {
            var newInventoryId = await CreateIncomingInventoryItemAsync(group.Key, companyId);
            if (newInventoryId == null) continue;

            // Link every coat in this group to the single newly-created inventory record.
            foreach (var coat in group)
            {
                coat.InventoryItemId = newInventoryId;
                coat.UpdatedAt = DateTime.UtcNow;
                await _unitOfWork.QuoteItemCoats.UpdateAsync(coat);
            }
        }

        await _unitOfWork.SaveChangesAsync();
    }
}
