using System.Globalization;
using System.Text;
using CsvHelper;
using CsvHelper.Configuration;
using Microsoft.Extensions.Logging;
using PowderCoating.Application.DTOs.Import;
using PowderCoating.Application.Interfaces;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Enums;
using PowderCoating.Core.Interfaces;

namespace PowderCoating.Infrastructure.Services;

public class CsvImportService : ICsvImportService
{
    private readonly IUnitOfWork _unitOfWork;
    private readonly ILogger<CsvImportService> _logger;

    public CsvImportService(IUnitOfWork unitOfWork, ILogger<CsvImportService> logger)
    {
        _unitOfWork = unitOfWork;
        _logger = logger;
    }

    #region Template Generation

    /// <summary>
    /// Generates a downloadable CSV template pre-populated with a single example customer row.
    /// The template is produced using the same <see cref="CustomerImportDto"/> class that the
    /// importer reads, so column names and data types are always in sync with the import logic.
    /// </summary>
    public byte[] GenerateCustomerTemplate()
    {
        using var memoryStream = new MemoryStream();
        using var writer = new StreamWriter(memoryStream);
        using var csv = new CsvWriter(writer, new CsvConfiguration(CultureInfo.InvariantCulture));

        // Write header
        csv.WriteHeader<CustomerImportDto>();
        csv.NextRecord();

        // Row 1: commercial customer with company name
        csv.WriteRecord(new CustomerImportDto
        {
            CompanyName = "Example Company Inc.",
            ContactFirstName = "John",
            ContactLastName = "Doe",
            Email = "john@example.com",
            Phone = "555-1234",
            MobilePhone = "555-5678",
            Address = "123 Main St",
            City = "Springfield",
            State = "IL",
            ZipCode = "62701",
            Country = "USA",
            CustomerType = "Commercial",
            PricingTierCode = "Gold",
            CreditLimit = 5000,
            PaymentTerms = "Net 30",
            TaxExempt = false,
            TaxId = "12-3456789",
            IsActive = true,
            Notes = "Sample commercial customer"
        });
        csv.NextRecord();

        // Row 2: individual/non-commercial — CompanyName is blank, first+last name used instead
        csv.WriteRecord(new CustomerImportDto
        {
            CompanyName = "",
            ContactFirstName = "Jane",
            ContactLastName = "Smith",
            Email = "jane@example.com",
            Phone = "555-9999",
            CustomerType = "Non-Commercial",
            IsActive = true,
            Notes = "Individual customer — no company name needed"
        });
        csv.NextRecord();

        writer.Flush();
        return memoryStream.ToArray();
    }

    /// <summary>
    /// Generates a downloadable CSV template pre-populated with two example catalog item rows
    /// covering different category paths (Automotive/Wheels and Industrial/Railings).
    /// Multiple examples help users understand the slash-delimited CategoryPath hierarchy format.
    /// </summary>
    public byte[] GenerateCatalogItemTemplate()
    {
        using var memoryStream = new MemoryStream();
        using var writer = new StreamWriter(memoryStream);
        using var csv = new CsvWriter(writer, new CsvConfiguration(CultureInfo.InvariantCulture));

        // Write header
        csv.WriteHeader<CatalogItemImportDto>();
        csv.NextRecord();

        // Write example rows
        csv.WriteRecord(new CatalogItemImportDto
        {
            CategoryPath = "Automotive/Wheels",
            ItemName = "Car Wheel - Standard 16\"",
            SKU = "WHL-16-STD",
            Description = "Standard 16 inch car wheel",
            BasePrice = 75.00m,
            ApproximateArea = 4.5m,
            EstimatedMinutes = 45,
            RequiresSandblasting = true,
            RequiresMasking = true,
            IsActive = true
        });
        csv.NextRecord();

        csv.WriteRecord(new CatalogItemImportDto
        {
            CategoryPath = "Industrial/Railings",
            ItemName = "Handrail - 10 ft section",
            SKU = "RAIL-10FT",
            Description = "10 foot handrail section",
            BasePrice = 150.00m,
            ApproximateArea = 12.0m,
            EstimatedMinutes = 90,
            RequiresSandblasting = true,
            RequiresMasking = false,
            IsActive = true
        });
        csv.NextRecord();

        writer.Flush();
        return memoryStream.ToArray();
    }

    /// <summary>
    /// Generates a downloadable CSV template pre-populated with two powder coating inventory items.
    /// Two rows are provided to show how color-specific fields (ColorName, ColorCode, Finish) and
    /// powder-specific coverage/efficiency fields differ between SKUs.
    /// </summary>
    public byte[] GenerateInventoryItemTemplate()
    {
        using var memoryStream = new MemoryStream();
        using var writer = new StreamWriter(memoryStream);
        using var csv = new CsvWriter(writer, new CsvConfiguration(CultureInfo.InvariantCulture));

        // Write header
        csv.WriteHeader<InventoryItemImportDto>();
        csv.NextRecord();

        // Write example rows
        csv.WriteRecord(new InventoryItemImportDto
        {
            SKU = "PWD-BLK-001",
            ItemName = "Black Powder Coating",
            Description = "Gloss black powder coat for general metal work",
            CategoryName = "Powder",
            Manufacturer = "Tiger Drylac",
            ManufacturerPartNumber = "049/90005",
            ColorName = "Black",
            ColorCode = "RAL 9005",
            Finish = "Gloss",
            VendorName = "Tiger Drylac USA",
            VendorPartNumber = "TD-BLK-9005",
            QuantityInStock = 500,
            UnitOfMeasure = "lbs",
            UnitCost = 3.50m,
            LastPurchasePrice = 3.50m,
            ReorderPoint = 100,
            ReorderQuantity = 200,
            MinimumStock = 50,
            MaximumStock = 1000,
            CoverageSqFtPerLb = 30,
            TransferEfficiencyPct = 65,
            Location = "Shelf A-1",
            IsActive = true,
            Notes = "Glossy finish"
        });
        csv.NextRecord();

        csv.WriteRecord(new InventoryItemImportDto
        {
            SKU = "PWD-WHT-001",
            ItemName = "White Powder Coating",
            Description = "Bright white powder coat",
            CategoryName = "Powder",
            Manufacturer = "Tiger Drylac",
            ManufacturerPartNumber = "049/90010",
            ColorName = "White",
            ColorCode = "RAL 9010",
            Finish = "Gloss",
            VendorName = "Tiger Drylac USA",
            VendorPartNumber = "TD-WHT-9010",
            QuantityInStock = 350,
            UnitOfMeasure = "lbs",
            UnitCost = 3.75m,
            LastPurchasePrice = 3.75m,
            ReorderPoint = 75,
            ReorderQuantity = 150,
            MinimumStock = 25,
            MaximumStock = 500,
            CoverageSqFtPerLb = 30,
            TransferEfficiencyPct = 65,
            Location = "Shelf A-2",
            IsActive = true,
            Notes = "Bright white"
        });
        csv.NextRecord();

        writer.Flush();
        return memoryStream.ToArray();
    }

    /// <summary>
    /// Generates a downloadable CSV template with a single example quote row.
    /// The example includes both customer-linked and prospect-style columns so users can see
    /// that CustomerEmail is optional when importing prospect quotes.
    /// </summary>
    public byte[] GenerateQuoteTemplate()
    {
        using var memoryStream = new MemoryStream();
        using var writer = new StreamWriter(memoryStream);
        using var csv = new CsvWriter(writer, new CsvConfiguration(CultureInfo.InvariantCulture));

        csv.WriteHeader<QuoteImportDto>();
        csv.NextRecord();

        csv.WriteRecord(new QuoteImportDto
        {
            QuoteNumber = "QT-2601-0001",
            CustomerEmail = "customer@example.com",
            Status = "Draft",
            QuoteDate = DateTime.Today,
            ExpirationDate = DateTime.Today.AddDays(30),
            Subtotal = 500.00m,
            TaxAmount = 40.00m,
            Total = 540.00m,
            Notes = "Sample quote",
            TermsAndConditions = "Net 30"
        });
        csv.NextRecord();

        writer.Flush();
        return memoryStream.ToArray();
    }

    /// <summary>
    /// Generates a downloadable CSV template with a single example job row.
    /// CustomerEmail is optional — the importer falls back to CustomerName when email is blank.
    /// At least one of the two must be present and must match an existing customer record.
    /// </summary>
    public byte[] GenerateJobTemplate()
    {
        using var memoryStream = new MemoryStream();
        using var writer = new StreamWriter(memoryStream);
        using var csv = new CsvWriter(writer, new CsvConfiguration(CultureInfo.InvariantCulture));

        csv.WriteHeader<JobImportDto>();
        csv.NextRecord();

        csv.WriteRecord(new JobImportDto
        {
            JobNumber = "JOB-2601-0001",
            CustomerEmail = "customer@example.com",
            CustomerName = "Acme Corp (used if email is blank or not found)",
            Description = "Sample job description",
            Status = "Pending",
            Priority = "Normal",
            ScheduledDate = DateTime.Today.AddDays(7),
            DueDate = DateTime.Today.AddDays(14),
            FinalPrice = 750.00m,
            CustomerPO = "PO-12345",
            SpecialInstructions = "Handle with care",
            Notes = "Internal notes"
        });
        csv.NextRecord();

        writer.Flush();
        return memoryStream.ToArray();
    }

    /// <summary>
    /// Generates a downloadable CSV template with a single example appointment row.
    /// ScheduledStart and ScheduledEnd are shown with time components to make it clear
    /// that appointments carry datetime precision, not just dates.
    /// </summary>
    public byte[] GenerateAppointmentTemplate()
    {
        using var memoryStream = new MemoryStream();
        using var writer = new StreamWriter(memoryStream);
        using var csv = new CsvWriter(writer, new CsvConfiguration(CultureInfo.InvariantCulture));

        csv.WriteHeader<AppointmentImportDto>();
        csv.NextRecord();

        csv.WriteRecord(new AppointmentImportDto
        {
            AppointmentNumber = "APT-2601-0001",
            CustomerEmail = "customer@example.com",
            AppointmentType = "Consultation",
            Status = "Scheduled",
            ScheduledStart = DateTime.Today.AddDays(3).AddHours(10),
            ScheduledEnd = DateTime.Today.AddDays(3).AddHours(11),
            Title = "Initial Consultation",
            Description = "Discuss project requirements",
            Location = "Main Office",
            Notes = "Customer requested morning appointment"
        });
        csv.NextRecord();

        writer.Flush();
        return memoryStream.ToArray();
    }

    /// <summary>
    /// Generates a downloadable CSV template with a single example equipment row.
    /// The Status column is populated so users understand the allowed enum values
    /// (Operational, NeedsMaintenance, UnderMaintenance, OutOfService, Retired).
    /// </summary>
    public byte[] GenerateEquipmentTemplate()
    {
        using var memoryStream = new MemoryStream();
        using var writer = new StreamWriter(memoryStream);
        using var csv = new CsvWriter(writer, new CsvConfiguration(CultureInfo.InvariantCulture));

        csv.WriteHeader<EquipmentImportDto>();
        csv.NextRecord();

        csv.WriteRecord(new EquipmentImportDto
        {
            EquipmentName = "Powder Coating Oven #1",
            EquipmentNumber = "EQ-001",
            EquipmentType = "Oven",
            Manufacturer = "Coating Solutions Inc",
            Model = "CS-3000",
            SerialNumber = "SN-123456789",
            PurchaseDate = DateTime.Today.AddYears(-2),
            PurchasePrice = 25000.00m,
            WarrantyExpiration = DateTime.Today.AddYears(1),
            Location = "Production Floor - Bay 1",
            RecommendedMaintenanceIntervalDays = 90,
            Status = "Operational",
            IsActive = true,
            Notes = "Regular maintenance required quarterly"
        });
        csv.NextRecord();

        writer.Flush();
        return memoryStream.ToArray();
    }

    /// <summary>
    /// Generates a downloadable CSV template with a single example maintenance record row.
    /// EquipmentName must match an existing equipment record exactly because the importer
    /// resolves the FK by name lookup rather than requiring the user to know internal IDs.
    /// </summary>
    public byte[] GenerateMaintenanceTemplate()
    {
        using var memoryStream = new MemoryStream();
        using var writer = new StreamWriter(memoryStream);
        using var csv = new CsvWriter(writer, new CsvConfiguration(CultureInfo.InvariantCulture));

        csv.WriteHeader<MaintenanceImportDto>();
        csv.NextRecord();

        csv.WriteRecord(new MaintenanceImportDto
        {
            EquipmentName = "Powder Coating Oven #1",
            MaintenanceType = "Preventive",
            ScheduledDate = DateTime.Today.AddDays(30),
            Status = "Scheduled",
            Priority = "Normal",
            LaborCost = 150.00m,
            PartsCost = 75.00m,
            TotalCost = 225.00m,
            Description = "Quarterly maintenance check",
            Notes = "Check heating elements and seals"
        });
        csv.NextRecord();

        writer.Flush();
        return memoryStream.ToArray();
    }

    #endregion

    #region Import Methods

    /// <summary>
    /// Imports customers from a CSV stream and persists valid rows to the database for the given company.
    /// The import uses a two-phase approach: all rows are parsed and validated first, then each validated
    /// entity is saved individually so that a single bad row does not roll back the entire batch.
    /// Duplicate detection uses a three-tier strategy, each tier only engaged when the previous
    /// identifier is absent:
    ///   Tier 1 — email address (case-insensitive): if email is present and matches a DB record or
    ///             earlier batch row the row is skipped.
    ///   Tier 2 — name + normalised phone composite: used when email is absent. Combining name with
    ///             phone prevents false positives when two people share a number (e.g. a family).
    ///             Row is skipped on match.
    ///   Tier 3 — name + city/state/zip composite: used when both email and phone are absent.
    ///             Location data is imprecise so this emits a warning but still imports the row.
    /// Pricing tiers are resolved by tier name; an unrecognised name is demoted to a warning and the
    /// customer is imported without a tier rather than being skipped entirely.
    /// Contact names are split on the first space into FirstName / LastName because the CSV carries a
    /// single "ContactName" column — this matches how the customer form presents the data.
    /// </summary>
    /// <param name="csvStream">Readable stream of CSV data (header row required).</param>
    /// <param name="companyId">Tenant company that will own the imported records.</param>
    public async Task<CsvImportResultDto> ImportCustomersAsync(Stream csvStream, int companyId)
    {
        var result = new CsvImportResultDto();
        var rowNumber = 0;

        try
        {
            using var reader = new StreamReader(csvStream);
            using var csv = new CsvReader(reader, new CsvConfiguration(CultureInfo.InvariantCulture)
            {
                HeaderValidated = null,
                MissingFieldFound = null
            });

            var records = csv.GetRecords<CustomerImportDto>().ToList();
            result.TotalRows = records.Count;

            _logger.LogInformation("Starting import of {Count} customers for company {CompanyId}", records.Count, companyId);

            // Get all existing customers for duplicate detection
            var existingCustomers = await _unitOfWork.Customers.GetAllAsync();

            // Tier 1 lookup: email → existing customer
            var existingEmails = existingCustomers.Where(c => !string.IsNullOrEmpty(c.Email))
                .ToDictionary(c => c.Email!.ToLower(), c => c, StringComparer.OrdinalIgnoreCase);

            // Tier 2 lookup: (normalised phone + "|" + display name) → existing customer.
            // Combining name with phone avoids false positives when two people share a number.
            var existingByPhoneAndName = new Dictionary<string, Customer>(StringComparer.OrdinalIgnoreCase);
            foreach (var c in existingCustomers)
            {
                var phone = NormalizePhone(c.MobilePhone) ?? NormalizePhone(c.Phone);
                if (phone == null) continue;
                var name = string.IsNullOrWhiteSpace(c.CompanyName)
                    ? $"{c.ContactFirstName} {c.ContactLastName}".Trim()
                    : c.CompanyName;
                var key = $"{phone}|{name}";
                if (!existingByPhoneAndName.ContainsKey(key))
                    existingByPhoneAndName[key] = c;
            }

            // Tier 3 lookup: (display name + "|" + city + "|" + state + "|" + zip) → existing customer.
            // Only populated when a customer has at least one location field so the key isn't trivially blank.
            var existingByNameAndLocation = new Dictionary<string, Customer>(StringComparer.OrdinalIgnoreCase);
            foreach (var c in existingCustomers)
            {
                if (string.IsNullOrWhiteSpace(c.City) && string.IsNullOrWhiteSpace(c.State) && string.IsNullOrWhiteSpace(c.ZipCode))
                    continue;
                var name = string.IsNullOrWhiteSpace(c.CompanyName)
                    ? $"{c.ContactFirstName} {c.ContactLastName}".Trim()
                    : c.CompanyName;
                if (string.IsNullOrWhiteSpace(name)) continue;
                var key = $"{name}|{c.City}|{c.State}|{c.ZipCode}";
                if (!existingByNameAndLocation.ContainsKey(key))
                    existingByNameAndLocation[key] = c;
            }

            // Get pricing tiers for lookup
            var pricingTiers = await _unitOfWork.PricingTiers.GetAllAsync();
            var pricingTierDict = pricingTiers.ToDictionary(pt => pt.TierName.ToUpper(), pt => pt, StringComparer.OrdinalIgnoreCase);

            var customersToImport = new List<(int RowNumber, Customer Customer, string Email)>();

            // Within-batch tracking sets (prevent duplicate detection against rows already queued)
            var batchEmails           = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
            var batchPhoneAndName     = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
            var batchNameAndLocation  = new HashSet<string>(StringComparer.OrdinalIgnoreCase);

            foreach (var record in records)
            {
                rowNumber++;
                try
                {
                    // Strip any literal quote characters that QB/Excel may wrap around field values
                    var cleanCompanyName = StripQuotes(record.CompanyName);
                    // Normalise to null (not empty string) — the UNIQUE index on (CompanyId, Email)
                    // uses HasFilter("[Email] IS NOT NULL"), so NULL is allowed for multiple rows
                    // but "" (empty string) is not NULL and would cause a unique-constraint violation
                    // on the second blank-email customer saved.
                    var rawEmail   = StripQuotes(record.Email);
                    var cleanEmail = string.IsNullOrWhiteSpace(rawEmail) ? null : rawEmail;
                    var firstName = StripQuotes(record.ContactFirstName)?.Trim();
                    var lastName = StripQuotes(record.ContactLastName)?.Trim();

                    // Non-commercial (individual) customers may have no company name — use contact name silently
                    if (string.IsNullOrWhiteSpace(cleanCompanyName))
                    {
                        var derivedName = $"{firstName} {lastName}".Trim();
                        if (string.IsNullOrWhiteSpace(derivedName))
                        {
                            result.Errors.Add($"Row {rowNumber}: Either CompanyName or ContactFirstName/ContactLastName is required.");
                            result.ErrorCount++;
                            continue;
                        }
                        cleanCompanyName = derivedName;
                    }

                    // Canonical display name used as part of composite keys in Tiers 2 and 3
                    var displayName = string.IsNullOrWhiteSpace(cleanCompanyName)
                        ? $"{firstName} {lastName}".Trim()
                        : cleanCompanyName;

                    // --- Tier 1: email dedup ---
                    // Only engaged when the row has an email address.
                    if (!string.IsNullOrEmpty(cleanEmail))
                    {
                        if (existingEmails.ContainsKey(cleanEmail.ToLower()))
                        {
                            result.Warnings.Add($"Row {rowNumber}: Customer with email '{cleanEmail}' already exists in database. Skipping.");
                            result.SkippedCount++;
                            continue;
                        }
                        if (batchEmails.Contains(cleanEmail))
                        {
                            result.Warnings.Add($"Row {rowNumber}: Duplicate email '{cleanEmail}' found in import file. Skipping.");
                            result.SkippedCount++;
                            continue;
                        }
                    }
                    else
                    {
                        // --- Tier 2: name + phone composite dedup (email absent) ---
                        // Requiring both name and phone to match avoids false positives when two
                        // unrelated people happen to share a phone number (e.g. a shared office line).
                        var normalizedPhone = NormalizePhone(record.MobilePhone) ?? NormalizePhone(record.Phone);
                        if (normalizedPhone != null)
                        {
                            var phoneNameKey = $"{normalizedPhone}|{displayName}";
                            if (existingByPhoneAndName.TryGetValue(phoneNameKey, out var existingMatch))
                            {
                                result.Warnings.Add($"Row {rowNumber}: '{displayName}' has no email; name + phone matches existing customer '{existingMatch.CompanyName}'. Skipping.");
                                result.SkippedCount++;
                                continue;
                            }
                            if (batchPhoneAndName.Contains(phoneNameKey))
                            {
                                result.Warnings.Add($"Row {rowNumber}: '{displayName}' has no email; duplicate name + phone found in import file. Skipping.");
                                result.SkippedCount++;
                                continue;
                            }
                        }
                        else
                        {
                            // --- Tier 3: name + location composite warning (no email, no phone) ---
                            // Location data is imprecise so we warn but still import — a name + city
                            // collision across unrelated people is plausible enough not to hard-skip.
                            var city  = record.City?.Trim();
                            var state = record.State?.Trim();
                            var zip   = record.ZipCode?.Trim();
                            var hasLocation = !string.IsNullOrWhiteSpace(city) || !string.IsNullOrWhiteSpace(state) || !string.IsNullOrWhiteSpace(zip);
                            if (hasLocation && !string.IsNullOrWhiteSpace(displayName))
                            {
                                var locationKey = $"{displayName}|{city}|{state}|{zip}";
                                if (existingByNameAndLocation.ContainsKey(locationKey) || batchNameAndLocation.Contains(locationKey))
                                {
                                    result.Warnings.Add($"Row {rowNumber}: '{displayName}' has no email or phone; name + location matches an existing record. Imported anyway — verify manually.");
                                }
                            }
                        }
                    }

                    // Resolve pricing tier
                    int? pricingTierId = null;
                    if (!string.IsNullOrWhiteSpace(record.PricingTierCode))
                    {
                        if (pricingTierDict.TryGetValue(record.PricingTierCode.ToUpper(), out var tier))
                        {
                            pricingTierId = tier.Id;
                        }
                        else
                        {
                            result.Warnings.Add($"Row {rowNumber}: Pricing tier '{record.PricingTierCode}' not found. Customer will have no pricing tier.");
                        }
                    }

                    // Determine customer type
                    bool isCommercial = string.Equals(record.CustomerType, "Commercial", StringComparison.OrdinalIgnoreCase);

                    // Create customer entity
                    var customer = new Customer
                    {
                        CompanyId = companyId,
                        CompanyName = cleanCompanyName,
                        ContactFirstName = firstName,
                        ContactLastName = lastName,
                        Email = cleanEmail,
                        Phone = record.Phone?.Trim(),
                        MobilePhone = record.MobilePhone?.Trim(),
                        Address = record.Address?.Trim(),
                        City = record.City?.Trim(),
                        State = record.State?.Trim(),
                        ZipCode = record.ZipCode?.Trim(),
                        Country = record.Country?.Trim() ?? "USA",
                        IsCommercial = isCommercial,
                        TaxId = record.TaxId?.Trim(),
                        PricingTierId = pricingTierId,
                        CreditLimit = record.CreditLimit ?? 0,
                        PaymentTerms = record.PaymentTerms?.Trim() ?? "Net 30",
                        IsTaxExempt = record.TaxExempt ?? false,
                        GeneralNotes = record.Notes?.Trim(),
                        LeadSource = record.LeadSource?.Trim(),
                        ShipToAddress = record.ShipToAddress?.Trim(),
                        ShipToCity = record.ShipToCity?.Trim(),
                        ShipToState = record.ShipToState?.Trim(),
                        ShipToZipCode = record.ShipToZipCode?.Trim(),
                        ShipToCountry = record.ShipToCountry?.Trim(),
                        IsActive = record.IsActive ?? true,
                        CreatedAt = DateTime.UtcNow,
                        UpdatedAt = DateTime.UtcNow
                    };

                    customersToImport.Add((rowNumber, customer, cleanEmail ?? string.Empty));

                    // Register in batch tracking so later rows are checked against this one
                    if (!string.IsNullOrEmpty(cleanEmail))
                    {
                        batchEmails.Add(cleanEmail);
                    }
                    else
                    {
                        var normalizedPhone = NormalizePhone(record.MobilePhone) ?? NormalizePhone(record.Phone);
                        if (normalizedPhone != null)
                        {
                            batchPhoneAndName.Add($"{normalizedPhone}|{displayName}");
                        }
                        else
                        {
                            var city  = record.City?.Trim();
                            var state = record.State?.Trim();
                            var zip   = record.ZipCode?.Trim();
                            var hasLocation = !string.IsNullOrWhiteSpace(city) || !string.IsNullOrWhiteSpace(state) || !string.IsNullOrWhiteSpace(zip);
                            if (hasLocation && !string.IsNullOrWhiteSpace(displayName))
                                batchNameAndLocation.Add($"{displayName}|{city}|{state}|{zip}");
                        }
                    }
                }
                catch (Exception ex)
                {
                    result.Errors.Add($"Row {rowNumber}: {ex.Message}");
                    result.ErrorCount++;
                    _logger.LogWarning(ex, "Error processing customer at row {RowNumber}", rowNumber);
                }
            }

            // Save customers one-by-one to isolate database errors
            foreach (var (customerRowNumber, customer, email) in customersToImport)
            {
                try
                {
                    await _unitOfWork.Customers.AddAsync(customer);
                    await _unitOfWork.CompleteAsync();
                    result.SuccessCount++;
                }
                catch (Microsoft.EntityFrameworkCore.DbUpdateException dbEx) when (dbEx.InnerException?.Message.Contains("duplicate key") == true || dbEx.InnerException?.Message.Contains("UNIQUE") == true)
                {
                    result.Warnings.Add($"Row {customerRowNumber}: Customer with email '{email}' already exists in database (detected during save). Skipping.");
                    result.ErrorCount++;
                    _logger.LogWarning("Duplicate email '{Email}' detected during save at row {RowNumber}", email, customerRowNumber);

                    // Detach failed entity so it doesn't contaminate the next row's save
                    _unitOfWork.ClearChangeTracker();
                }
                catch (Exception ex)
                {
                    result.Errors.Add($"Row {customerRowNumber}: Database error - {ex.Message}");
                    result.ErrorCount++;
                    _logger.LogError(ex, "Error saving customer at row {RowNumber}", customerRowNumber);

                    // Detach failed entity so it doesn't contaminate the next row's save
                    _unitOfWork.ClearChangeTracker();
                }
            }

            _logger.LogInformation("Import completed: {SuccessCount} succeeded, {ErrorCount} failed", result.SuccessCount, result.ErrorCount);
            result.Success = result.SuccessCount > 0;
        }
        catch (Exception ex)
        {
            result.Errors.Add($"Fatal error: {ex.Message}");
            result.Success = false;
            _logger.LogError(ex, "Fatal error importing customers");
        }

        return result;
    }

    /// <summary>
    /// Imports catalog items from a CSV stream, auto-creating the category hierarchy as needed.
    /// Categories are expressed as slash-delimited paths (e.g. "Automotive/Wheels") and resolved
    /// via <see cref="ResolveOrCreateCategoryAsync"/>, which creates missing parent or leaf nodes
    /// on the fly and caches them for the duration of the import to avoid redundant DB calls.
    /// Duplicate detection is keyed on SKU; items with no SKU or an already-existing SKU are skipped.
    /// Accounting ledger accounts (<paramref name="revenueAccountId"/>, <paramref name="cogsAccountId"/>)
    /// are stamped on every item so that new catalog items can integrate with the AP/AR module without
    /// manual post-import account assignment.
    /// </summary>
    /// <param name="csvStream">Readable stream of CSV data (header row required).</param>
    /// <param name="companyId">Tenant company that will own the imported records.</param>
    /// <param name="revenueAccountId">Optional revenue GL account to assign to every imported item.</param>
    /// <param name="cogsAccountId">Optional COGS GL account to assign to every imported item.</param>
    public async Task<CsvImportResultDto> ImportCatalogItemsAsync(Stream csvStream, int companyId, int? revenueAccountId = null, int? cogsAccountId = null)
    {
        var result = new CsvImportResultDto();
        var rowNumber = 0;

        try
        {
            using var reader = new StreamReader(csvStream);
            using var csv = new CsvReader(reader, new CsvConfiguration(CultureInfo.InvariantCulture)
            {
                HeaderValidated = null,
                MissingFieldFound = null
            });

            var records = csv.GetRecords<CatalogItemImportDto>().ToList();
            result.TotalRows = records.Count;

            _logger.LogInformation("Starting import of {Count} catalog items for company {CompanyId}", records.Count, companyId);

            // Get all existing catalog items for duplicate detection
            var existingItems = await _unitOfWork.CatalogItems.GetAllAsync();
            var existingSkus = existingItems.Where(i => !string.IsNullOrEmpty(i.SKU))
                .ToDictionary(i => i.SKU!.ToUpper(), i => i, StringComparer.OrdinalIgnoreCase);

            // Category cache
            var categoryCache = new Dictionary<string, int>();

            var catalogItemsToImport = new List<(int RowNumber, CatalogItem Item, string SKU)>();

            foreach (var record in records)
            {
                rowNumber++;
                try
                {
                    // Validate required fields
                    if (string.IsNullOrWhiteSpace(record.ItemName))
                    {
                        result.Errors.Add($"Row {rowNumber}: ItemName is required.");
                        result.ErrorCount++;
                        continue;
                    }

                    if (string.IsNullOrWhiteSpace(record.CategoryPath))
                    {
                        result.Errors.Add($"Row {rowNumber}: CategoryPath is required.");
                        result.ErrorCount++;
                        continue;
                    }

                    // Check for duplicate SKU in existing data
                    if (!string.IsNullOrEmpty(record.SKU) && existingSkus.ContainsKey(record.SKU.ToUpper()))
                    {
                        result.Warnings.Add($"Row {rowNumber}: Catalog item with SKU '{record.SKU}' already exists in database. Skipping.");
                        result.SkippedCount++;
                        continue;
                    }

                    // Check for duplicate SKU within the import batch
                    if (!string.IsNullOrEmpty(record.SKU) && catalogItemsToImport.Any(x => x.SKU.Equals(record.SKU, StringComparison.OrdinalIgnoreCase)))
                    {
                        result.Warnings.Add($"Row {rowNumber}: Duplicate SKU '{record.SKU}' found in import file. Skipping.");
                        result.SkippedCount++;
                        continue;
                    }

                    // Resolve or create category
                    int categoryId = await ResolveOrCreateCategoryAsync(record.CategoryPath, companyId, categoryCache);

                    // Create catalog item entity
                    var catalogItem = new CatalogItem
                    {
                        CompanyId = companyId,
                        Name = record.ItemName.Trim(),
                        SKU = record.SKU?.Trim(),
                        Description = record.Description?.Trim(),
                        CategoryId = categoryId,
                        DefaultPrice = record.BasePrice ?? 0,
                        ApproximateArea = record.ApproximateArea,
                        DefaultEstimatedMinutes = record.EstimatedMinutes,
                        DefaultRequiresSandblasting = record.RequiresSandblasting ?? false,
                        DefaultRequiresMasking = record.RequiresMasking ?? false,
                        IsActive = record.IsActive ?? true,
                        DisplayOrder = 0,
                        RevenueAccountId = revenueAccountId,
                        CogsAccountId = cogsAccountId,
                        CreatedAt = DateTime.UtcNow,
                        UpdatedAt = DateTime.UtcNow
                    };

                    catalogItemsToImport.Add((rowNumber, catalogItem, record.SKU?.Trim() ?? string.Empty));
                }
                catch (Exception ex)
                {
                    result.Errors.Add($"Row {rowNumber}: {ex.Message}");
                    result.ErrorCount++;
                    _logger.LogWarning(ex, "Error processing catalog item at row {RowNumber}", rowNumber);
                }
            }

            // Save catalog items one-by-one to isolate database errors
            foreach (var (itemRowNumber, item, sku) in catalogItemsToImport)
            {
                try
                {
                    await _unitOfWork.CatalogItems.AddAsync(item);
                    await _unitOfWork.CompleteAsync();
                    result.SuccessCount++;
                }
                catch (Microsoft.EntityFrameworkCore.DbUpdateException dbEx) when (dbEx.InnerException?.Message.Contains("duplicate key") == true || dbEx.InnerException?.Message.Contains("UNIQUE") == true)
                {
                    result.Warnings.Add($"Row {itemRowNumber}: Catalog item with SKU '{sku}' already exists in database (detected during save). Skipping.");
                    result.ErrorCount++;
                    _logger.LogWarning("Duplicate SKU '{SKU}' detected during save at row {RowNumber}", sku, itemRowNumber);

                    // Detach failed entity so it doesn't contaminate the next row's save
                    _unitOfWork.ClearChangeTracker();
                }
                catch (Exception ex)
                {
                    result.Errors.Add($"Row {itemRowNumber}: Database error - {ex.Message}");
                    result.ErrorCount++;
                    _logger.LogError(ex, "Error saving catalog item at row {RowNumber}", itemRowNumber);

                    // Detach failed entity so it doesn't contaminate the next row's save
                    _unitOfWork.ClearChangeTracker();
                }
            }

            _logger.LogInformation("Import completed: {SuccessCount} succeeded, {ErrorCount} failed", result.SuccessCount, result.ErrorCount);
            result.Success = result.SuccessCount > 0;
        }
        catch (Exception ex)
        {
            result.Errors.Add($"Fatal error: {ex.Message}");
            result.Success = false;
            _logger.LogError(ex, "Fatal error importing catalog items");
        }

        return result;
    }

    /// <summary>
    /// Imports inventory items from a CSV stream with soft-delete awareness and vendor/category resolution.
    /// The method intentionally bypasses global query filters when loading existing items so that
    /// soft-deleted SKUs are visible — if an incoming SKU matches a deleted record the item is restored
    /// and updated in place rather than inserting a duplicate, which would violate the unique SKU index.
    /// Category matching uses a two-tier strategy: first try exact display name or category code,
    /// then fall back to a built-in alias table (e.g. "Powder Coatings" → POWDER) that covers common
    /// customer naming variations. Unresolved categories are warned, not blocked.
    /// Vendor lookup is name-based and scoped to the target company to avoid cross-tenant leakage.
    /// Items are saved individually (not in a batch) so a DB error on one row does not orphan others.
    /// </summary>
    /// <param name="csvStream">Readable stream of CSV data (header row required).</param>
    /// <param name="companyId">Tenant company that will own the imported records.</param>
    /// <param name="inventoryAccountId">Optional inventory asset GL account stamped on every new item.</param>
    /// <param name="cogsAccountId">Optional COGS GL account stamped on every new item.</param>
    public async Task<CsvImportResultDto> ImportInventoryItemsAsync(Stream csvStream, int companyId, int? inventoryAccountId = null, int? cogsAccountId = null)
    {
        var result = new CsvImportResultDto();
        var rowNumber = 0;

        try
        {
            using var reader = new StreamReader(csvStream);
            using var csv = new CsvReader(reader, new CsvConfiguration(CultureInfo.InvariantCulture)
            {
                HeaderValidated = null,
                MissingFieldFound = null
            });

            var records = csv.GetRecords<InventoryItemImportDto>().ToList();
            result.TotalRows = records.Count;

            _logger.LogInformation("Starting import of {Count} inventory items for company {CompanyId}", records.Count, companyId);

            // Load only THIS company's items (ignoreQueryFilters to include soft-deleted rows).
            // Using FindAsync with an explicit companyId predicate rather than GetAllAsync(ignoreQueryFilters:true)
            // prevents cross-tenant SKU collisions from causing a ToDictionary duplicate-key crash when
            // two different companies happen to share the same SKU.
            var companyItemsIncDeleted = await _unitOfWork.InventoryItems
                .FindAsync(i => i.CompanyId == companyId, ignoreQueryFilters: true);

            var existingSkus = companyItemsIncDeleted
                .Where(i => !i.IsDeleted && !string.IsNullOrWhiteSpace(i.SKU))
                .ToDictionary(i => i.SKU.Trim().ToUpper(), i => i, StringComparer.OrdinalIgnoreCase);
            var deletedSkus = companyItemsIncDeleted
                .Where(i => i.IsDeleted && !string.IsNullOrWhiteSpace(i.SKU))
                .ToDictionary(i => i.SKU.Trim().ToUpper(), i => i, StringComparer.OrdinalIgnoreCase);

            // Build vendor lookup by company name for this company
            var allVendors = await _unitOfWork.Vendors.GetAllAsync();
            var vendorDict = allVendors
                .Where(v => v.CompanyId == companyId)
                .GroupBy(v => v.CompanyName.ToUpper())
                .ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase);

            // Build category lookup for THIS company only — using another company's category Id
            // would be silently excluded by EF's multi-tenancy query filter at runtime.
            var allCategories = await _unitOfWork.InventoryCategoryLookups.GetAllAsync();
            var companyCategories = allCategories.Where(c => c.CompanyId == companyId).ToList();
            var categoryDict = new Dictionary<string, InventoryCategoryLookup>(StringComparer.OrdinalIgnoreCase);
            foreach (var cat in companyCategories)
            {
                categoryDict.TryAdd(cat.DisplayName, cat);
                categoryDict.TryAdd(cat.CategoryCode, cat);
            }
            // Common aliases customers might type in the CSV
            var categoryAliases = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
            {
                { "Powder Coatings", "POWDER" },
                { "Powder Coating", "POWDER" },
                { "Powders", "POWDER" },
                { "Primers", "PRIMER" },
                { "Cleaners", "CLEANER" },
                { "Masking", "MASKING" },
                { "Masking Tape", "MASKING" },
                { "Abrasive", "ABRASIVE" },
                { "Abrasives", "ABRASIVE" },
                { "Blast Media", "ABRASIVE" },
                { "Chemicals", "CHEMICAL" },
                { "Consumable", "CONSUMABLE" },
                { "Tools & Equipment", "TOOL" },
                { "Equipment", "TOOL" },
                { "General", "OTHER" },
            };

            var itemsToImport = new List<(int RowNumber, InventoryItem Item, string SKU)>();

            foreach (var record in records)
            {
                rowNumber++;
                try
                {
                    // Validate required fields
                    if (string.IsNullOrWhiteSpace(record.SKU))
                    {
                        result.Errors.Add($"Row {rowNumber}: SKU is required.");
                        result.ErrorCount++;
                        continue;
                    }

                    if (string.IsNullOrWhiteSpace(record.ItemName))
                    {
                        result.Errors.Add($"Row {rowNumber}: ItemName is required.");
                        result.ErrorCount++;
                        continue;
                    }

                    // Check for duplicate SKU in existing data
                    if (existingSkus.ContainsKey(record.SKU.Trim().ToUpper()))
                    {
                        result.Warnings.Add($"Row {rowNumber}: Inventory item with SKU '{record.SKU.Trim()}' already exists. Skipping.");
                        result.SkippedCount++;
                        continue;
                    }

                    // Check for duplicate SKU within the import batch
                    if (itemsToImport.Any(x => x.SKU.Equals(record.SKU, StringComparison.OrdinalIgnoreCase)))
                    {
                        result.Warnings.Add($"Row {rowNumber}: Duplicate SKU '{record.SKU}' found in import file. Skipping.");
                        result.SkippedCount++;
                        continue;
                    }

                    // Resolve category lookup — auto-create if not found
                    InventoryCategoryLookup? resolvedCategory = null;
                    if (!string.IsNullOrWhiteSpace(record.CategoryName))
                    {
                        var catKey = record.CategoryName.Trim();
                        if (!categoryDict.TryGetValue(catKey, out resolvedCategory) &&
                            categoryAliases.TryGetValue(catKey, out var aliasCode))
                        {
                            categoryDict.TryGetValue(aliasCode, out resolvedCategory);
                        }
                        if (resolvedCategory == null)
                        {
                            // Create on-the-fly so the import doesn't silently drop category data
                            var newCode = System.Text.RegularExpressions.Regex
                                .Replace(catKey.ToUpper(), @"[^A-Z0-9]", "_");
                            resolvedCategory = new InventoryCategoryLookup
                            {
                                CompanyId = companyId,
                                CategoryCode = newCode,
                                DisplayName = catKey,
                                DisplayOrder = 99,
                                IsActive = true,
                                IsSystemDefined = false,
                                IsCoating = false,
                                CreatedAt = DateTime.UtcNow,
                                UpdatedAt = DateTime.UtcNow
                            };
                            await _unitOfWork.InventoryCategoryLookups.AddAsync(resolvedCategory);
                            await _unitOfWork.CompleteAsync();
                            categoryDict[resolvedCategory.DisplayName] = resolvedCategory;
                            categoryDict[resolvedCategory.CategoryCode] = resolvedCategory;
                            companyCategories.Add(resolvedCategory);
                        }
                    }

                    // Resolve vendor by name
                    int? primaryVendorId = null;
                    if (!string.IsNullOrWhiteSpace(record.VendorName))
                    {
                        if (vendorDict.TryGetValue(record.VendorName.Trim().ToUpper(), out var vendor))
                        {
                            primaryVendorId = vendor.Id;
                        }
                        else
                        {
                            result.Warnings.Add($"Row {rowNumber}: Vendor '{record.VendorName}' not found. Item will have no primary vendor assigned.");
                        }
                    }

                    // If the SKU belongs to a previously soft-deleted item, restore and update it
                    // instead of inserting a new record (avoids unique constraint violation).
                    if (deletedSkus.TryGetValue(record.SKU.ToUpper(), out var deletedItem))
                    {
                        deletedItem.IsDeleted = false;
                        deletedItem.Name = record.ItemName.Trim();
                        deletedItem.Description = record.Description?.Trim();
                        deletedItem.InventoryCategoryId = resolvedCategory?.Id ?? deletedItem.InventoryCategoryId;
                        deletedItem.Category = resolvedCategory?.DisplayName ?? record.CategoryName?.Trim() ?? deletedItem.Category;
                        deletedItem.Manufacturer = record.Manufacturer?.Trim();
                        deletedItem.ManufacturerPartNumber = record.ManufacturerPartNumber?.Trim();
                        deletedItem.ColorName = record.ColorName?.Trim();
                        deletedItem.ColorCode = record.ColorCode?.Trim();
                        deletedItem.Finish = record.Finish?.Trim();
                        deletedItem.PrimaryVendorId = primaryVendorId ?? deletedItem.PrimaryVendorId;
                        deletedItem.VendorPartNumber = record.VendorPartNumber?.Trim();
                        deletedItem.QuantityOnHand = record.QuantityInStock ?? 0;
                        deletedItem.UnitOfMeasure = record.UnitOfMeasure?.Trim() ?? deletedItem.UnitOfMeasure;
                        deletedItem.UnitCost = record.UnitCost ?? deletedItem.UnitCost;
                        deletedItem.LastPurchasePrice = record.LastPurchasePrice ?? record.UnitCost ?? deletedItem.LastPurchasePrice;
                        deletedItem.ReorderPoint = record.ReorderPoint ?? deletedItem.ReorderPoint;
                        deletedItem.ReorderQuantity = record.ReorderQuantity ?? deletedItem.ReorderQuantity;
                        deletedItem.MinimumStock = record.MinimumStock ?? deletedItem.MinimumStock;
                        deletedItem.MaximumStock = record.MaximumStock ?? deletedItem.MaximumStock;
                        deletedItem.CoverageSqFtPerLb = record.CoverageSqFtPerLb ?? deletedItem.CoverageSqFtPerLb;
                        deletedItem.TransferEfficiency = record.TransferEfficiencyPct ?? deletedItem.TransferEfficiency;
                        deletedItem.Location = record.Location?.Trim();
                        deletedItem.Notes = record.Notes?.Trim();
                        deletedItem.IsActive = record.IsActive ?? true;
                        deletedItem.InventoryAccountId = inventoryAccountId ?? deletedItem.InventoryAccountId;
                        deletedItem.CogsAccountId = cogsAccountId ?? deletedItem.CogsAccountId;
                        deletedItem.UpdatedAt = DateTime.UtcNow;
                        result.Warnings.Add($"Row {rowNumber}: SKU '{record.SKU}' was previously deleted — item restored and updated.");
                        itemsToImport.Add((rowNumber, deletedItem, record.SKU.Trim()));
                        continue;
                    }

                    // Create new inventory item entity
                    var inventoryItem = new InventoryItem
                    {
                        CompanyId = companyId,
                        SKU = record.SKU.Trim(),
                        Name = record.ItemName.Trim(),
                        Description = record.Description?.Trim(),
                        InventoryCategoryId = resolvedCategory?.Id,
                        Category = resolvedCategory?.DisplayName ?? record.CategoryName?.Trim() ?? "General",
                        Manufacturer = record.Manufacturer?.Trim(),
                        ManufacturerPartNumber = record.ManufacturerPartNumber?.Trim(),
                        ColorName = record.ColorName?.Trim(),
                        ColorCode = record.ColorCode?.Trim(),
                        Finish = record.Finish?.Trim(),
                        PrimaryVendorId = primaryVendorId,
                        VendorPartNumber = record.VendorPartNumber?.Trim(),
                        QuantityOnHand = record.QuantityInStock ?? 0,
                        UnitOfMeasure = record.UnitOfMeasure?.Trim() ?? "units",
                        UnitCost = record.UnitCost ?? 0,
                        LastPurchasePrice = record.LastPurchasePrice ?? record.UnitCost ?? 0,
                        ReorderPoint = record.ReorderPoint ?? 0,
                        ReorderQuantity = record.ReorderQuantity ?? 0,
                        MinimumStock = record.MinimumStock ?? 0,
                        MaximumStock = record.MaximumStock ?? 0,
                        CoverageSqFtPerLb = record.CoverageSqFtPerLb,
                        TransferEfficiency = record.TransferEfficiencyPct,
                        Location = record.Location?.Trim(),
                        Notes = record.Notes?.Trim(),
                        IsActive = record.IsActive ?? true,
                        InventoryAccountId = inventoryAccountId,
                        CogsAccountId = cogsAccountId,
                        CreatedAt = DateTime.UtcNow,
                        UpdatedAt = DateTime.UtcNow
                    };

                    itemsToImport.Add((rowNumber, inventoryItem, record.SKU.Trim()));
                }
                catch (Exception ex)
                {
                    result.Errors.Add($"Row {rowNumber}: {ex.Message}");
                    result.ErrorCount++;
                    _logger.LogWarning(ex, "Error processing inventory item at row {RowNumber}", rowNumber);
                }
            }

            // Save items one-by-one to isolate database errors
            foreach (var (itemRowNumber, item, sku) in itemsToImport)
            {
                try
                {
                    // Restored items already exist in the DB; new items need to be inserted.
                    if (item.Id > 0)
                        await _unitOfWork.InventoryItems.UpdateAsync(item);
                    else
                        await _unitOfWork.InventoryItems.AddAsync(item);
                    await _unitOfWork.CompleteAsync();
                    result.SuccessCount++;
                }
                catch (Microsoft.EntityFrameworkCore.DbUpdateException dbEx) when (dbEx.InnerException?.Message.Contains("duplicate key") == true)
                {
                    result.Warnings.Add($"Row {itemRowNumber}: SKU '{sku}' already exists in database (detected during save). Skipping.");
                    result.ErrorCount++;
                    _logger.LogWarning("Duplicate SKU '{SKU}' detected during save at row {RowNumber}", sku, itemRowNumber);

                    // Detach failed entity so it doesn't contaminate the next row's save
                    _unitOfWork.ClearChangeTracker();
                }
                catch (Exception ex)
                {
                    result.Errors.Add($"Row {itemRowNumber}: Database error - {ex.Message}");
                    result.ErrorCount++;
                    _logger.LogError(ex, "Error saving inventory item at row {RowNumber}", itemRowNumber);

                    // Detach failed entity so it doesn't contaminate the next row's save
                    _unitOfWork.ClearChangeTracker();
                }
            }

            _logger.LogInformation("Import completed: {SuccessCount} succeeded, {ErrorCount} failed", result.SuccessCount, result.ErrorCount);
            result.Success = result.SuccessCount > 0;
        }
        catch (Exception ex)
        {
            result.Errors.Add($"Fatal error: {ex.Message}");
            result.Success = false;
            _logger.LogError(ex, "Fatal error importing inventory items");
        }

        return result;
    }

    /// <summary>
    /// Imports quote headers from a CSV stream, supporting both customer-linked and prospect quotes.
    /// Customer resolution is by email address. When a CustomerEmail is supplied but no matching
    /// customer is found the row is not rejected — instead it is stored as a prospect quote with
    /// the raw email preserved in ProspectEmail, because the quote data itself is still useful.
    /// Quote status is resolved against the QuoteStatusLookup table; unrecognised values fall back
    /// to "Draft" with a warning so that legacy exports using display labels still import cleanly.
    /// Duplicate detection uses QuoteNumber as the unique key, checking both the DB and the current
    /// import batch to guard against re-importing the same file twice.
    /// Note: this method imports quote headers only; line items are not part of the CSV format.
    /// </summary>
    /// <param name="csvStream">Readable stream of CSV data (header row required).</param>
    /// <param name="companyId">Tenant company that will own the imported records.</param>
    public async Task<CsvImportResultDto> ImportQuotesAsync(Stream csvStream, int companyId)
    {
        var result = new CsvImportResultDto();
        var rowNumber = 0;

        try
        {
            using var reader = new StreamReader(csvStream);
            using var csv = new CsvReader(reader, new CsvConfiguration(CultureInfo.InvariantCulture)
            {
                HeaderValidated = null,
                MissingFieldFound = null
            });

            var records = csv.GetRecords<QuoteImportDto>().ToList();
            result.TotalRows = records.Count;

            _logger.LogInformation("Starting import of {Count} quotes for company {CompanyId}", records.Count, companyId);

            // Get all existing quotes for duplicate detection
            var existingQuotes = await _unitOfWork.Quotes.GetAllAsync();
            var existingQuoteNumbers = existingQuotes.Where(q => !string.IsNullOrEmpty(q.QuoteNumber))
                .ToDictionary(q => q.QuoteNumber.ToUpper(), q => q, StringComparer.OrdinalIgnoreCase);

            // Get customers for lookup — email-first, name-fallback (mirrors ImportJobsAsync)
            var customers = await _unitOfWork.Customers.GetAllAsync();
            var customerByEmail = customers.Where(c => !string.IsNullOrEmpty(c.Email))
                .ToDictionary(c => c.Email!.Trim().ToLower(), c => c, StringComparer.OrdinalIgnoreCase);
            var customerByName = new Dictionary<string, Customer>(StringComparer.OrdinalIgnoreCase);
            foreach (var c in customers)
            {
                var displayName = !string.IsNullOrWhiteSpace(c.CompanyName)
                    ? c.CompanyName.Trim()
                    : $"{c.ContactFirstName} {c.ContactLastName}".Trim();
                if (!string.IsNullOrEmpty(displayName))
                    customerByName.TryAdd(displayName, c);
            }

            // Get quote statuses for lookup
            var quoteStatuses = await _unitOfWork.QuoteStatusLookups.GetAllAsync();
            var quoteStatusDict = quoteStatuses.ToDictionary(qs => qs.StatusCode.ToUpper(), qs => qs, StringComparer.OrdinalIgnoreCase);

            var quotesToImport = new List<(int RowNumber, Quote Quote, string QuoteNumber)>();

            foreach (var record in records)
            {
                rowNumber++;
                try
                {
                    // Validate required fields
                    if (string.IsNullOrWhiteSpace(record.QuoteNumber))
                    {
                        result.Errors.Add($"Row {rowNumber}: QuoteNumber is required.");
                        result.ErrorCount++;
                        continue;
                    }

                    // Check for duplicate quote number in existing data
                    if (existingQuoteNumbers.ContainsKey(record.QuoteNumber.ToUpper()))
                    {
                        result.Warnings.Add($"Row {rowNumber}: Quote with number '{record.QuoteNumber}' already exists in database. Skipping.");
                        result.ErrorCount++;
                        continue;
                    }

                    // Check for duplicate quote number within the import batch
                    if (quotesToImport.Any(x => x.QuoteNumber.Equals(record.QuoteNumber, StringComparison.OrdinalIgnoreCase)))
                    {
                        result.Warnings.Add($"Row {rowNumber}: Duplicate quote number '{record.QuoteNumber}' found in import file. Skipping.");
                        result.ErrorCount++;
                        continue;
                    }

                    // Resolve customer — try email first, then CustomerName fallback
                    int? customerId = null;
                    if (!string.IsNullOrEmpty(record.CustomerEmail))
                    {
                        if (customerByEmail.TryGetValue(record.CustomerEmail.Trim().ToLower(), out var byEmail))
                        {
                            customerId = byEmail.Id;
                        }
                        else
                        {
                            result.Warnings.Add($"Row {rowNumber}: Customer with email '{record.CustomerEmail}' not found. Trying CustomerName fallback.");
                        }
                    }

                    if (!customerId.HasValue && !string.IsNullOrWhiteSpace(record.CustomerName))
                    {
                        if (customerByName.TryGetValue(record.CustomerName.Trim(), out var byName))
                        {
                            customerId = byName.Id;
                            if (!string.IsNullOrEmpty(record.CustomerEmail))
                                result.Warnings.Add($"Row {rowNumber}: Matched customer by name '{record.CustomerName}'.");
                        }
                        else
                        {
                            result.Warnings.Add($"Row {rowNumber}: Customer '{record.CustomerName}' not found by name. Will treat as prospect quote.");
                        }
                    }

                    // Resolve quote status
                    int quoteStatusId;
                    if (!quoteStatusDict.TryGetValue(record.Status.ToUpper(), out var quoteStatus))
                    {
                        // Default to "Draft" if status not found
                        quoteStatus = quoteStatusDict.Values.FirstOrDefault(qs => qs.StatusCode.Equals("DRAFT", StringComparison.OrdinalIgnoreCase));
                        if (quoteStatus == null)
                        {
                            result.Errors.Add($"Row {rowNumber}: Quote status '{record.Status}' not found and no default 'Draft' status exists.");
                            result.ErrorCount++;
                            continue;
                        }
                        result.Warnings.Add($"Row {rowNumber}: Quote status '{record.Status}' not found. Using 'Draft' as default.");
                    }
                    quoteStatusId = quoteStatus.Id;

                    // Create quote entity
                    var quote = new Quote
                    {
                        CompanyId = companyId,
                        QuoteNumber = record.QuoteNumber.Trim(),
                        CustomerId = customerId,
                        QuoteStatusId = quoteStatusId,
                        QuoteDate = record.QuoteDate,
                        ExpirationDate = record.ExpirationDate,
                        SubTotal = record.Subtotal,
                        TaxAmount = record.TaxAmount,
                        Total = record.Total,
                        Notes = record.Notes?.Trim(),
                        Terms = record.TermsAndConditions?.Trim(),
                        ProjectName = record.ProjectName?.Trim(),
                        IsCommercial = customerId.HasValue,
                        CreatedAt = DateTime.UtcNow,
                        UpdatedAt = DateTime.UtcNow
                    };

                    // If this is a prospect quote, populate prospect fields.
                    // Fall back to CustomerName as the prospect company name when ProspectCompany
                    // is blank — handles the case where the user had a name in CustomerName that
                    // didn't match any existing customer record.
                    // Store null (not empty string) so the ?? display fallback chain works correctly;
                    // empty strings block the fallback to ProspectContactName in mapped DTOs.
                    if (!customerId.HasValue)
                    {
                        var rawCompany = !string.IsNullOrWhiteSpace(record.ProspectCompany)
                            ? record.ProspectCompany.Trim()
                            : record.CustomerName?.Trim();
                        quote.ProspectCompanyName = string.IsNullOrWhiteSpace(rawCompany) ? null : rawCompany;

                        var rawContact = record.ProspectContact?.Trim();
                        quote.ProspectContactName = string.IsNullOrWhiteSpace(rawContact) ? null : rawContact;

                        var rawEmail = record.ProspectEmail?.Trim();
                        quote.ProspectEmail = string.IsNullOrWhiteSpace(rawEmail) ? null : rawEmail;

                        var rawPhone = record.ProspectPhone?.Trim();
                        quote.ProspectPhone = string.IsNullOrWhiteSpace(rawPhone) ? null : rawPhone;
                    }

                    quotesToImport.Add((rowNumber, quote, record.QuoteNumber.Trim()));
                }
                catch (Exception ex)
                {
                    result.Errors.Add($"Row {rowNumber}: {ex.Message}");
                    result.ErrorCount++;
                    _logger.LogWarning(ex, "Error processing quote at row {RowNumber}", rowNumber);
                }
            }

            // Save quotes one-by-one to isolate database errors
            foreach (var (quoteRowNumber, quote, quoteNumber) in quotesToImport)
            {
                try
                {
                    await _unitOfWork.Quotes.AddAsync(quote);
                    await _unitOfWork.CompleteAsync();
                    result.SuccessCount++;
                }
                catch (Microsoft.EntityFrameworkCore.DbUpdateException dbEx) when (dbEx.InnerException?.Message.Contains("duplicate key") == true || dbEx.InnerException?.Message.Contains("UNIQUE") == true)
                {
                    result.Warnings.Add($"Row {quoteRowNumber}: Quote with number '{quoteNumber}' already exists in database (detected during save). Skipping.");
                    result.ErrorCount++;
                    _logger.LogWarning("Duplicate quote number '{QuoteNumber}' detected during save at row {RowNumber}", quoteNumber, quoteRowNumber);

                    // Detach failed entity so it doesn't contaminate the next row's save
                    _unitOfWork.ClearChangeTracker();
                }
                catch (Exception ex)
                {
                    result.Errors.Add($"Row {quoteRowNumber}: Database error - {ex.Message}");
                    result.ErrorCount++;
                    _logger.LogError(ex, "Error saving quote at row {RowNumber}", quoteRowNumber);

                    // Detach failed entity so it doesn't contaminate the next row's save
                    _unitOfWork.ClearChangeTracker();
                }
            }

            _logger.LogInformation("Import completed: {SuccessCount} succeeded, {ErrorCount} failed", result.SuccessCount, result.ErrorCount);
            result.Success = result.SuccessCount > 0;
        }
        catch (Exception ex)
        {
            result.Errors.Add($"Fatal error: {ex.Message}");
            result.Success = false;
            _logger.LogError(ex, "Fatal error importing quotes");
        }

        return result;
    }

    /// <summary>
    /// Imports job records from a CSV stream, resolving customer, status, and priority FKs by name.
    /// Unlike quote import, CustomerEmail is mandatory here — a job with no customer is not a valid
    /// business record and the row is rejected with an error (not downgraded to a prospect).
    /// Status and priority are resolved against their respective lookup tables; both default to
    /// "Pending" / "Normal" with a warning when the CSV value is unrecognised, keeping the import
    /// permissive for data migrated from systems that use different status labels.
    /// FinalPrice and QuotedPrice are both seeded from the same CSV FinalPrice column because
    /// historic imported jobs typically carry only one agreed price figure.
    /// </summary>
    /// <param name="csvStream">Readable stream of CSV data (header row required).</param>
    /// <param name="companyId">Tenant company that will own the imported records.</param>
    public async Task<CsvImportResultDto> ImportJobsAsync(Stream csvStream, int companyId)
    {
        var result = new CsvImportResultDto();
        var rowNumber = 0;

        try
        {
            using var reader = new StreamReader(csvStream);
            using var csv = new CsvReader(reader, new CsvConfiguration(CultureInfo.InvariantCulture)
            {
                HeaderValidated = null,
                MissingFieldFound = null
            });

            // Treat non-numeric values in decimal? fields (e.g. a spreadsheet "false" in FinalPrice)
            // as null rather than throwing a fatal TypeConverterException.
            csv.Context.TypeConverterCache.AddConverter<decimal?>(new LenientNullableDecimalConverter());

            // Read header row first so we know field count before iterating rows.
            await csv.ReadAsync();
            csv.ReadHeader();

            // Pre-load lookup data before streaming rows so async calls don't interleave with CSV reads.
            var existingJobs = await _unitOfWork.Jobs.GetAllAsync();
            var existingJobNumbers = existingJobs.Where(j => !string.IsNullOrEmpty(j.JobNumber))
                .ToDictionary(j => j.JobNumber.ToUpper(), j => j, StringComparer.OrdinalIgnoreCase);

            var customers = await _unitOfWork.Customers.GetAllAsync();
            var customerByEmail = customers.Where(c => !string.IsNullOrEmpty(c.Email))
                .ToDictionary(c => c.Email!.Trim().ToLower(), c => c, StringComparer.OrdinalIgnoreCase);
            var customerByName = new Dictionary<string, Customer>(StringComparer.OrdinalIgnoreCase);
            foreach (var c in customers)
            {
                var name = !string.IsNullOrWhiteSpace(c.CompanyName)
                    ? c.CompanyName.Trim()
                    : $"{c.ContactFirstName} {c.ContactLastName}".Trim();
                if (!string.IsNullOrEmpty(name))
                    customerByName.TryAdd(name, c);
            }

            var jobStatuses = await _unitOfWork.JobStatusLookups.GetAllAsync();
            var jobStatusDict = jobStatuses.ToDictionary(js => js.StatusCode.ToUpper(), js => js, StringComparer.OrdinalIgnoreCase);

            var jobPriorities = await _unitOfWork.JobPriorityLookups.GetAllAsync();
            var jobPriorityDict = jobPriorities.ToDictionary(jp => jp.PriorityCode.ToUpper(), jp => jp, StringComparer.OrdinalIgnoreCase);

            var jobsToImport = new List<(int RowNumber, Job Job, string JobNumber)>();

            // Stream rows one at a time so a bad type conversion on a single row (e.g. "false"
            // in a decimal field) is caught per-row rather than aborting the entire import.
            while (await csv.ReadAsync())
            {
                result.TotalRows++;
                JobImportDto record;
                try
                {
                    record = csv.GetRecord<JobImportDto>()
                        ?? throw new InvalidOperationException("Row returned null record.");
                }
                catch (Exception parseEx)
                {
                    result.Errors.Add($"Row {csv.Context.Parser?.Row}: Could not parse row - {parseEx.InnerException?.Message ?? parseEx.Message}");
                    result.ErrorCount++;
                    _logger.LogWarning(parseEx, "Parse error at CSV row {Row}", csv.Context.Parser?.Row);
                    continue;
                }

                rowNumber = csv.Context.Parser?.Row ?? rowNumber + 1;

                // Warn when FinalPrice was non-numeric (lenient converter returned null).
                var rawFinalPrice = csv.TryGetField<string>(7, out var fpStr) ? fpStr : null;
                if (!string.IsNullOrWhiteSpace(rawFinalPrice) && record.FinalPrice == null
                    && !decimal.TryParse(rawFinalPrice, System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, out _))
                {
                    result.Warnings.Add($"Row {rowNumber}: FinalPrice value '{rawFinalPrice}' could not be parsed as a number; defaulting to $0.");
                }
                try
                {
                    // Validate required fields
                    if (string.IsNullOrWhiteSpace(record.JobNumber))
                    {
                        result.Errors.Add($"Row {rowNumber}: JobNumber is required.");
                        result.ErrorCount++;
                        continue;
                    }

                    // Check for duplicate job number in existing data
                    if (existingJobNumbers.ContainsKey(record.JobNumber.ToUpper()))
                    {
                        result.Warnings.Add($"Row {rowNumber}: Job with number '{record.JobNumber}' already exists in database. Skipping.");
                        result.SkippedCount++;
                        continue;
                    }

                    // Check for duplicate job number within the import batch
                    if (jobsToImport.Any(x => x.JobNumber.Equals(record.JobNumber, StringComparison.OrdinalIgnoreCase)))
                    {
                        result.Warnings.Add($"Row {rowNumber}: Duplicate job number '{record.JobNumber}' found in import file. Skipping.");
                        result.SkippedCount++;
                        continue;
                    }

                    // Resolve customer: try email first, then fall back to company name.
                    // Both CustomerEmail and CustomerName may be absent; at least one is required.
                    Customer? customer = null;
                    var hasEmail = !string.IsNullOrWhiteSpace(record.CustomerEmail);
                    var hasName  = !string.IsNullOrWhiteSpace(record.CustomerName);

                    if (!hasEmail && !hasName)
                    {
                        result.Errors.Add($"Row {rowNumber}: Either CustomerEmail or CustomerName is required to identify the customer.");
                        result.ErrorCount++;
                        continue;
                    }

                    if (hasEmail)
                        customerByEmail.TryGetValue(record.CustomerEmail!.Trim().ToLower(), out customer);

                    if (customer == null && hasName)
                    {
                        // Name fallback — warn so the user knows which method resolved the record
                        customerByName.TryGetValue(record.CustomerName!.Trim(), out customer);
                        if (customer != null)
                            result.Warnings.Add($"Row {rowNumber}: Customer matched by name '{record.CustomerName}' because no email was provided or matched. Verify this is the correct customer.");
                    }

                    if (customer == null)
                    {
                        var lookupDetail = hasEmail ? $"email '{record.CustomerEmail}'" : $"name '{record.CustomerName}'";
                        result.Errors.Add($"Row {rowNumber}: Customer not found by {lookupDetail}.");
                        result.ErrorCount++;
                        continue;
                    }

                    // Resolve job status
                    int jobStatusId;
                    if (!jobStatusDict.TryGetValue(record.Status.ToUpper(), out var jobStatus))
                    {
                        // Default to "Pending" if status not found
                        jobStatus = jobStatusDict.Values.FirstOrDefault(js => js.StatusCode.Equals("PENDING", StringComparison.OrdinalIgnoreCase));
                        if (jobStatus == null)
                        {
                            result.Errors.Add($"Row {rowNumber}: Job status '{record.Status}' not found and no default 'Pending' status exists.");
                            result.ErrorCount++;
                            continue;
                        }
                        result.Warnings.Add($"Row {rowNumber}: Job status '{record.Status}' not found. Using 'Pending' as default.");
                    }
                    jobStatusId = jobStatus.Id;

                    // Resolve job priority
                    int jobPriorityId;
                    if (!jobPriorityDict.TryGetValue(record.Priority.ToUpper(), out var jobPriority))
                    {
                        // Default to "Normal" if priority not found
                        jobPriority = jobPriorityDict.Values.FirstOrDefault(jp => jp.PriorityCode.Equals("NORMAL", StringComparison.OrdinalIgnoreCase));
                        if (jobPriority == null)
                        {
                            result.Errors.Add($"Row {rowNumber}: Job priority '{record.Priority}' not found and no default 'Normal' priority exists.");
                            result.ErrorCount++;
                            continue;
                        }
                        result.Warnings.Add($"Row {rowNumber}: Job priority '{record.Priority}' not found. Using 'Normal' as default.");
                    }
                    jobPriorityId = jobPriority.Id;

                    // Create job entity
                    var job = new Job
                    {
                        CompanyId = companyId,
                        JobNumber = record.JobNumber.Trim(),
                        CustomerId = customer.Id,
                        JobStatusId = jobStatusId,
                        JobPriorityId = jobPriorityId,
                        ScheduledDate = record.ScheduledDate,
                        DueDate = record.DueDate,
                        FinalPrice = record.FinalPrice ?? 0,
                        QuotedPrice = record.FinalPrice ?? 0,
                        CustomerPO = record.CustomerPO?.Trim(),
                        SpecialInstructions = record.SpecialInstructions?.Trim(),
                        InternalNotes = record.Notes?.Trim(),
                        ProjectName = record.ProjectName?.Trim(),
                        Description = record.Description?.Trim()
                            ?? record.SpecialInstructions?.Trim()
                            ?? "Imported job",
                        CreatedAt = DateTime.UtcNow,
                        UpdatedAt = DateTime.UtcNow
                    };

                    jobsToImport.Add((rowNumber, job, record.JobNumber.Trim()));
                }
                catch (Exception ex)
                {
                    result.Errors.Add($"Row {rowNumber}: {ex.Message}");
                    result.ErrorCount++;
                    _logger.LogWarning(ex, "Error processing job at row {RowNumber}", rowNumber);
                }
            }

            // Save jobs one-by-one to isolate database errors
            foreach (var (jobRowNumber, job, jobNumber) in jobsToImport)
            {
                try
                {
                    await _unitOfWork.Jobs.AddAsync(job);
                    await _unitOfWork.CompleteAsync();
                    result.SuccessCount++;
                }
                catch (Microsoft.EntityFrameworkCore.DbUpdateException dbEx) when (dbEx.InnerException?.Message.Contains("duplicate key") == true || dbEx.InnerException?.Message.Contains("UNIQUE") == true)
                {
                    result.Warnings.Add($"Row {jobRowNumber}: Job with number '{jobNumber}' already exists in database (detected during save). Skipping.");
                    result.ErrorCount++;
                    _logger.LogWarning("Duplicate job number '{JobNumber}' detected during save at row {RowNumber}", jobNumber, jobRowNumber);

                    // Detach failed entity so it doesn't contaminate the next row's save
                    _unitOfWork.ClearChangeTracker();
                }
                catch (Exception ex)
                {
                    result.Errors.Add($"Row {jobRowNumber}: Database error - {ex.Message}");
                    result.ErrorCount++;
                    _logger.LogError(ex, "Error saving job at row {RowNumber}", jobRowNumber);

                    // Detach failed entity so it doesn't contaminate the next row's save
                    _unitOfWork.ClearChangeTracker();
                }
            }

            _logger.LogInformation("Import completed: {SuccessCount} succeeded, {ErrorCount} failed", result.SuccessCount, result.ErrorCount);
            result.Success = result.SuccessCount > 0;
        }
        catch (Exception ex)
        {
            result.Errors.Add($"Fatal error: {ex.Message}");
            result.Success = false;
            _logger.LogError(ex, "Fatal error importing jobs");
        }

        return result;
    }

    /// <summary>
    /// Imports appointment records from a CSV stream, resolving customer, type, and status FKs.
    /// Customer linking is optional — appointments without a matching customer email are saved
    /// without a CustomerId rather than rejected, because walk-in or phone appointments are common.
    /// Appointment type is resolved by TypeCode first, then falls back to DisplayName matching
    /// so that human-readable type labels exported from older versions still map correctly.
    /// Status defaults to "Scheduled" when the supplied value is unrecognised.
    /// AppointmentNumber is the duplicate-detection key; the same two-layer check (DB + batch) used
    /// by other importers prevents re-import of already-stored records.
    /// </summary>
    /// <param name="csvStream">Readable stream of CSV data (header row required).</param>
    /// <param name="companyId">Tenant company that will own the imported records.</param>
    public async Task<CsvImportResultDto> ImportAppointmentsAsync(Stream csvStream, int companyId)
    {
        var result = new CsvImportResultDto();
        var rowNumber = 0;

        try
        {
            using var reader = new StreamReader(csvStream);
            using var csv = new CsvReader(reader, new CsvConfiguration(CultureInfo.InvariantCulture)
            {
                HeaderValidated = null,
                MissingFieldFound = null
            });

            var records = csv.GetRecords<AppointmentImportDto>().ToList();
            result.TotalRows = records.Count;

            _logger.LogInformation("Starting import of {Count} appointments for company {CompanyId}", records.Count, companyId);

            // Get all existing appointments for duplicate detection
            var existingAppointments = await _unitOfWork.Appointments.GetAllAsync();
            var existingAppointmentNumbers = existingAppointments.Where(a => !string.IsNullOrEmpty(a.AppointmentNumber))
                .ToDictionary(a => a.AppointmentNumber.ToUpper(), a => a, StringComparer.OrdinalIgnoreCase);

            // Get customers for lookup (optional for appointments)
            var customers = await _unitOfWork.Customers.GetAllAsync();
            var customerDict = customers.Where(c => !string.IsNullOrEmpty(c.Email))
                .ToDictionary(c => c.Email!.ToLower(), c => c, StringComparer.OrdinalIgnoreCase);

            // Get appointment types for lookup
            var appointmentTypes = await _unitOfWork.AppointmentTypeLookups.GetAllAsync();
            var appointmentTypeDict = appointmentTypes.ToDictionary(at => at.TypeCode.ToUpper(), at => at, StringComparer.OrdinalIgnoreCase);

            // Get appointment statuses for lookup
            var appointmentStatuses = await _unitOfWork.AppointmentStatusLookups.GetAllAsync();
            var appointmentStatusDict = appointmentStatuses.ToDictionary(asl => asl.StatusCode.ToUpper(), asl => asl, StringComparer.OrdinalIgnoreCase);

            var appointmentsToImport = new List<(int RowNumber, Appointment Appointment, string AppointmentNumber)>();

            foreach (var record in records)
            {
                rowNumber++;
                try
                {
                    // Validate required fields
                    if (string.IsNullOrWhiteSpace(record.AppointmentNumber))
                    {
                        result.Errors.Add($"Row {rowNumber}: AppointmentNumber is required.");
                        result.ErrorCount++;
                        continue;
                    }

                    if (string.IsNullOrWhiteSpace(record.Title))
                    {
                        result.Errors.Add($"Row {rowNumber}: Title is required.");
                        result.ErrorCount++;
                        continue;
                    }

                    // Check for duplicate appointment number in existing data
                    if (existingAppointmentNumbers.ContainsKey(record.AppointmentNumber.ToUpper()))
                    {
                        result.Warnings.Add($"Row {rowNumber}: Appointment with number '{record.AppointmentNumber}' already exists in database. Skipping.");
                        result.ErrorCount++;
                        continue;
                    }

                    // Check for duplicate appointment number within the import batch
                    if (appointmentsToImport.Any(x => x.AppointmentNumber.Equals(record.AppointmentNumber, StringComparison.OrdinalIgnoreCase)))
                    {
                        result.Warnings.Add($"Row {rowNumber}: Duplicate appointment number '{record.AppointmentNumber}' found in import file. Skipping.");
                        result.ErrorCount++;
                        continue;
                    }

                    // Resolve customer by email (optional)
                    int? customerId = null;
                    if (!string.IsNullOrEmpty(record.CustomerEmail))
                    {
                        if (customerDict.TryGetValue(record.CustomerEmail.ToLower(), out var customer))
                        {
                            customerId = customer.Id;
                        }
                        else
                        {
                            result.Warnings.Add($"Row {rowNumber}: Customer with email '{record.CustomerEmail}' not found. Creating appointment without customer.");
                        }
                    }

                    // Resolve appointment type
                    int appointmentTypeId;
                    if (!appointmentTypeDict.TryGetValue(record.AppointmentType.ToUpper(), out var appointmentType))
                    {
                        // Try to find by display name as fallback
                        appointmentType = appointmentTypes.FirstOrDefault(at => at.DisplayName.Equals(record.AppointmentType, StringComparison.OrdinalIgnoreCase));
                        if (appointmentType == null)
                        {
                            result.Errors.Add($"Row {rowNumber}: Appointment type '{record.AppointmentType}' not found.");
                            result.ErrorCount++;
                            continue;
                        }
                    }
                    appointmentTypeId = appointmentType.Id;

                    // Resolve appointment status
                    int appointmentStatusId;
                    if (!appointmentStatusDict.TryGetValue(record.Status.ToUpper(), out var appointmentStatus))
                    {
                        // Default to "Scheduled" if status not found
                        appointmentStatus = appointmentStatusDict.Values.FirstOrDefault(asl => asl.StatusCode.Equals("SCHEDULED", StringComparison.OrdinalIgnoreCase));
                        if (appointmentStatus == null)
                        {
                            result.Errors.Add($"Row {rowNumber}: Appointment status '{record.Status}' not found and no default 'Scheduled' status exists.");
                            result.ErrorCount++;
                            continue;
                        }
                        result.Warnings.Add($"Row {rowNumber}: Appointment status '{record.Status}' not found. Using 'Scheduled' as default.");
                    }
                    appointmentStatusId = appointmentStatus.Id;

                    // Create appointment entity
                    var appointment = new Appointment
                    {
                        CompanyId = companyId,
                        AppointmentNumber = record.AppointmentNumber.Trim(),
                        CustomerId = customerId,
                        AppointmentTypeId = appointmentTypeId,
                        AppointmentStatusId = appointmentStatusId,
                        Title = record.Title.Trim(),
                        Description = record.Description?.Trim(),
                        ScheduledStartTime = record.ScheduledStart,
                        ScheduledEndTime = record.ScheduledEnd,
                        Location = record.Location?.Trim(),
                        Notes = record.Notes?.Trim(),
                        CreatedAt = DateTime.UtcNow,
                        UpdatedAt = DateTime.UtcNow
                    };

                    appointmentsToImport.Add((rowNumber, appointment, record.AppointmentNumber.Trim()));
                }
                catch (Exception ex)
                {
                    result.Errors.Add($"Row {rowNumber}: {ex.Message}");
                    result.ErrorCount++;
                    _logger.LogWarning(ex, "Error processing appointment at row {RowNumber}", rowNumber);
                }
            }

            // Save appointments one-by-one to isolate database errors
            foreach (var (appointmentRowNumber, appointment, appointmentNumber) in appointmentsToImport)
            {
                try
                {
                    await _unitOfWork.Appointments.AddAsync(appointment);
                    await _unitOfWork.CompleteAsync();
                    result.SuccessCount++;
                }
                catch (Microsoft.EntityFrameworkCore.DbUpdateException dbEx) when (dbEx.InnerException?.Message.Contains("duplicate key") == true || dbEx.InnerException?.Message.Contains("UNIQUE") == true)
                {
                    result.Warnings.Add($"Row {appointmentRowNumber}: Appointment with number '{appointmentNumber}' already exists in database (detected during save). Skipping.");
                    result.ErrorCount++;
                    _logger.LogWarning("Duplicate appointment number '{AppointmentNumber}' detected during save at row {RowNumber}", appointmentNumber, appointmentRowNumber);

                    // Detach failed entity so it doesn't contaminate the next row's save
                    _unitOfWork.ClearChangeTracker();
                }
                catch (Exception ex)
                {
                    result.Errors.Add($"Row {appointmentRowNumber}: Database error - {ex.Message}");
                    result.ErrorCount++;
                    _logger.LogError(ex, "Error saving appointment at row {RowNumber}", appointmentRowNumber);

                    // Detach failed entity so it doesn't contaminate the next row's save
                    _unitOfWork.ClearChangeTracker();
                }
            }

            _logger.LogInformation("Import completed: {SuccessCount} succeeded, {ErrorCount} failed", result.SuccessCount, result.ErrorCount);
            result.Success = result.SuccessCount > 0;
        }
        catch (Exception ex)
        {
            result.Errors.Add($"Fatal error: {ex.Message}");
            result.Success = false;
            _logger.LogError(ex, "Fatal error importing appointments");
        }

        return result;
    }

    /// <summary>
    /// Imports equipment records from a CSV stream, parsing the EquipmentStatus enum from the
    /// human-readable string in the CSV. Spaces are stripped from the status string before parsing
    /// so that values like "Needs Maintenance" (two words) are accepted alongside the enum name
    /// "NeedsMaintenance". Duplicate detection uses EquipmentName as the natural key because
    /// equipment records rarely have an external unique identifier that users would know.
    /// </summary>
    /// <param name="csvStream">Readable stream of CSV data (header row required).</param>
    /// <param name="companyId">Tenant company that will own the imported records.</param>
    public async Task<CsvImportResultDto> ImportEquipmentAsync(Stream csvStream, int companyId)
    {
        var result = new CsvImportResultDto();
        var rowNumber = 0;

        try
        {
            using var reader = new StreamReader(csvStream);
            using var csv = new CsvReader(reader, new CsvConfiguration(CultureInfo.InvariantCulture)
            {
                HeaderValidated = null,
                MissingFieldFound = null
            });

            var records = csv.GetRecords<EquipmentImportDto>().ToList();
            result.TotalRows = records.Count;

            _logger.LogInformation("Starting import of {Count} equipment records for company {CompanyId}", records.Count, companyId);

            // Get all existing equipment for duplicate detection
            var existingEquipment = await _unitOfWork.Equipment.GetAllAsync();
            var existingEquipmentNames = existingEquipment.Where(e => !string.IsNullOrEmpty(e.EquipmentName))
                .ToDictionary(e => e.EquipmentName.ToUpper(), e => e, StringComparer.OrdinalIgnoreCase);

            var equipmentToImport = new List<(int RowNumber, Equipment Equipment, string EquipmentName)>();

            foreach (var record in records)
            {
                rowNumber++;
                try
                {
                    // Validate required fields
                    if (string.IsNullOrWhiteSpace(record.EquipmentName))
                    {
                        result.Errors.Add($"Row {rowNumber}: EquipmentName is required.");
                        result.ErrorCount++;
                        continue;
                    }

                    if (string.IsNullOrWhiteSpace(record.EquipmentType))
                    {
                        result.Errors.Add($"Row {rowNumber}: EquipmentType is required.");
                        result.ErrorCount++;
                        continue;
                    }

                    // Check for duplicate equipment name in existing data
                    if (existingEquipmentNames.ContainsKey(record.EquipmentName.ToUpper()))
                    {
                        result.Warnings.Add($"Row {rowNumber}: Equipment with name '{record.EquipmentName}' already exists in database. Skipping.");
                        result.ErrorCount++;
                        continue;
                    }

                    // Check for duplicate equipment name within the import batch
                    if (equipmentToImport.Any(x => x.EquipmentName.Equals(record.EquipmentName, StringComparison.OrdinalIgnoreCase)))
                    {
                        result.Warnings.Add($"Row {rowNumber}: Duplicate equipment name '{record.EquipmentName}' found in import file. Skipping.");
                        result.ErrorCount++;
                        continue;
                    }

                    // Parse equipment status
                    EquipmentStatus status = EquipmentStatus.Operational;
                    if (!string.IsNullOrEmpty(record.Status))
                    {
                        if (!Enum.TryParse<EquipmentStatus>(record.Status.Replace(" ", ""), true, out status))
                        {
                            result.Warnings.Add($"Row {rowNumber}: Equipment status '{record.Status}' not recognized. Using 'Operational' as default.");
                            status = EquipmentStatus.Operational;
                        }
                    }

                    // Create equipment entity
                    var equipment = new Equipment
                    {
                        CompanyId = companyId,
                        EquipmentName = record.EquipmentName.Trim(),
                        EquipmentNumber = record.EquipmentNumber?.Trim(),
                        EquipmentType = record.EquipmentType.Trim(),
                        Manufacturer = record.Manufacturer?.Trim(),
                        Model = record.Model?.Trim(),
                        SerialNumber = record.SerialNumber?.Trim(),
                        PurchaseDate = record.PurchaseDate,
                        PurchasePrice = record.PurchasePrice ?? 0,
                        WarrantyExpiration = record.WarrantyExpiration,
                        Location = record.Location?.Trim(),
                        RecommendedMaintenanceIntervalDays = record.RecommendedMaintenanceIntervalDays ?? 0,
                        Status = status,
                        Notes = record.Notes?.Trim(),
                        IsActive = record.IsActive ?? true,
                        CreatedAt = DateTime.UtcNow,
                        UpdatedAt = DateTime.UtcNow
                    };

                    equipmentToImport.Add((rowNumber, equipment, record.EquipmentName.Trim()));
                }
                catch (Exception ex)
                {
                    result.Errors.Add($"Row {rowNumber}: {ex.Message}");
                    result.ErrorCount++;
                    _logger.LogWarning(ex, "Error processing equipment at row {RowNumber}", rowNumber);
                }
            }

            // Save equipment one-by-one to isolate database errors
            foreach (var (equipmentRowNumber, equipment, equipmentName) in equipmentToImport)
            {
                try
                {
                    await _unitOfWork.Equipment.AddAsync(equipment);
                    await _unitOfWork.CompleteAsync();
                    result.SuccessCount++;
                }
                catch (Microsoft.EntityFrameworkCore.DbUpdateException dbEx) when (dbEx.InnerException?.Message.Contains("duplicate key") == true || dbEx.InnerException?.Message.Contains("UNIQUE") == true)
                {
                    result.Warnings.Add($"Row {equipmentRowNumber}: Equipment with name '{equipmentName}' already exists in database (detected during save). Skipping.");
                    result.ErrorCount++;
                    _logger.LogWarning("Duplicate equipment name '{EquipmentName}' detected during save at row {RowNumber}", equipmentName, equipmentRowNumber);

                    // Detach failed entity so it doesn't contaminate the next row's save
                    _unitOfWork.ClearChangeTracker();
                }
                catch (Exception ex)
                {
                    result.Errors.Add($"Row {equipmentRowNumber}: Database error - {ex.Message}");
                    result.ErrorCount++;
                    _logger.LogError(ex, "Error saving equipment at row {RowNumber}", equipmentRowNumber);

                    // Detach failed entity so it doesn't contaminate the next row's save
                    _unitOfWork.ClearChangeTracker();
                }
            }

            _logger.LogInformation("Import completed: {SuccessCount} succeeded, {ErrorCount} failed", result.SuccessCount, result.ErrorCount);
            result.Success = result.SuccessCount > 0;
        }
        catch (Exception ex)
        {
            result.Errors.Add($"Fatal error: {ex.Message}");
            result.Success = false;
            _logger.LogError(ex, "Fatal error importing equipment");
        }

        return result;
    }

    /// <summary>
    /// Imports maintenance records from a CSV stream, resolving equipment by name and parsing
    /// <see cref="MaintenanceStatus"/> and <see cref="MaintenancePriority"/> from string values.
    /// Unlike most other importers, maintenance records have no natural unique key (a piece of
    /// equipment can have many scheduled records with the same type and date), so no duplicate
    /// detection is performed — callers should ensure they do not re-upload the same file twice.
    /// TotalCost defaults to LaborCost + PartsCost when not explicitly provided, mirroring what
    /// the UI does when an admin creates a record manually.
    /// </summary>
    /// <param name="csvStream">Readable stream of CSV data (header row required).</param>
    /// <param name="companyId">Tenant company that will own the imported records.</param>
    public async Task<CsvImportResultDto> ImportMaintenanceAsync(Stream csvStream, int companyId)
    {
        var result = new CsvImportResultDto();
        var rowNumber = 0;

        try
        {
            using var reader = new StreamReader(csvStream);
            using var csv = new CsvReader(reader, new CsvConfiguration(CultureInfo.InvariantCulture)
            {
                HeaderValidated = null,
                MissingFieldFound = null
            });

            var records = csv.GetRecords<MaintenanceImportDto>().ToList();
            result.TotalRows = records.Count;

            _logger.LogInformation("Starting import of {Count} maintenance records for company {CompanyId}", records.Count, companyId);

            // Get all equipment for lookup
            var equipment = await _unitOfWork.Equipment.GetAllAsync();
            var equipmentDict = equipment.Where(e => !string.IsNullOrEmpty(e.EquipmentName))
                .ToDictionary(e => e.EquipmentName.ToUpper(), e => e, StringComparer.OrdinalIgnoreCase);

            var maintenanceToImport = new List<(int RowNumber, MaintenanceRecord Maintenance)>();

            foreach (var record in records)
            {
                rowNumber++;
                try
                {
                    // Validate required fields
                    if (string.IsNullOrWhiteSpace(record.EquipmentName))
                    {
                        result.Errors.Add($"Row {rowNumber}: EquipmentName is required.");
                        result.ErrorCount++;
                        continue;
                    }

                    if (string.IsNullOrWhiteSpace(record.MaintenanceType))
                    {
                        result.Errors.Add($"Row {rowNumber}: MaintenanceType is required.");
                        result.ErrorCount++;
                        continue;
                    }

                    if (string.IsNullOrWhiteSpace(record.Description))
                    {
                        result.Errors.Add($"Row {rowNumber}: Description is required.");
                        result.ErrorCount++;
                        continue;
                    }

                    // Resolve equipment by name
                    if (!equipmentDict.TryGetValue(record.EquipmentName.ToUpper(), out var equipmentEntity))
                    {
                        result.Errors.Add($"Row {rowNumber}: Equipment with name '{record.EquipmentName}' not found.");
                        result.ErrorCount++;
                        continue;
                    }

                    // Parse maintenance status
                    MaintenanceStatus status = MaintenanceStatus.Scheduled;
                    if (!string.IsNullOrEmpty(record.Status))
                    {
                        if (!Enum.TryParse<MaintenanceStatus>(record.Status.Replace(" ", ""), true, out status))
                        {
                            result.Warnings.Add($"Row {rowNumber}: Maintenance status '{record.Status}' not recognized. Using 'Scheduled' as default.");
                            status = MaintenanceStatus.Scheduled;
                        }
                    }

                    // Parse maintenance priority
                    MaintenancePriority priority = MaintenancePriority.Normal;
                    if (!string.IsNullOrEmpty(record.Priority))
                    {
                        if (!Enum.TryParse<MaintenancePriority>(record.Priority.Replace(" ", ""), true, out priority))
                        {
                            result.Warnings.Add($"Row {rowNumber}: Maintenance priority '{record.Priority}' not recognized. Using 'Normal' as default.");
                            priority = MaintenancePriority.Normal;
                        }
                    }

                    // Create maintenance record entity
                    var maintenance = new MaintenanceRecord
                    {
                        CompanyId = companyId,
                        EquipmentId = equipmentEntity.Id,
                        MaintenanceType = record.MaintenanceType.Trim(),
                        Status = status,
                        Priority = priority,
                        ScheduledDate = record.ScheduledDate,
                        CompletedDate = record.CompletedDate,
                        Description = record.Description.Trim(),
                        WorkPerformed = record.WorkPerformed?.Trim(),
                        LaborCost = record.LaborCost ?? 0,
                        PartsCost = record.PartsCost ?? 0,
                        TotalCost = record.TotalCost ?? (record.LaborCost ?? 0) + (record.PartsCost ?? 0),
                        Notes = record.Notes?.Trim(),
                        CreatedAt = DateTime.UtcNow,
                        UpdatedAt = DateTime.UtcNow
                    };

                    maintenanceToImport.Add((rowNumber, maintenance));
                }
                catch (Exception ex)
                {
                    result.Errors.Add($"Row {rowNumber}: {ex.Message}");
                    result.ErrorCount++;
                    _logger.LogWarning(ex, "Error processing maintenance record at row {RowNumber}", rowNumber);
                }
            }

            // Save maintenance records one-by-one to isolate database errors
            foreach (var (maintenanceRowNumber, maintenance) in maintenanceToImport)
            {
                try
                {
                    await _unitOfWork.MaintenanceRecords.AddAsync(maintenance);
                    await _unitOfWork.CompleteAsync();
                    result.SuccessCount++;
                }
                catch (Exception ex)
                {
                    result.Errors.Add($"Row {maintenanceRowNumber}: Database error - {ex.Message}");
                    result.ErrorCount++;
                    _logger.LogError(ex, "Error saving maintenance record at row {RowNumber}", maintenanceRowNumber);

                    // Detach failed entity so it doesn't contaminate the next row's save
                    _unitOfWork.ClearChangeTracker();
                }
            }

            _logger.LogInformation("Import completed: {SuccessCount} succeeded, {ErrorCount} failed", result.SuccessCount, result.ErrorCount);
            result.Success = result.SuccessCount > 0;
        }
        catch (Exception ex)
        {
            result.Errors.Add($"Fatal error: {ex.Message}");
            result.Success = false;
            _logger.LogError(ex, "Fatal error importing maintenance records");
        }

        return result;
    }

    #endregion

    #region Vendor Import

    /// <summary>
    /// Generates a downloadable CSV template with a single example vendor row.
    /// The example includes all optional contact and financial fields so users understand
    /// the full breadth of data they can supply, including AccountNumber and CreditLimit
    /// which are needed for the Accounts Payable module to function correctly.
    /// </summary>
    public byte[] GenerateVendorTemplate()
    {
        using var memoryStream = new MemoryStream();
        using var writer = new StreamWriter(memoryStream);
        using var csv = new CsvWriter(writer, new CsvConfiguration(CultureInfo.InvariantCulture));

        csv.WriteHeader<VendorImportDto>();
        csv.NextRecord();

        csv.WriteRecord(new VendorImportDto
        {
            CompanyName = "Acme Powder Supply Co.",
            ContactName = "Jane Smith",
            Email = "jane@acmepowder.com",
            Phone = "555-9000",
            Address = "456 Industrial Blvd",
            City = "Chicago",
            State = "IL",
            ZipCode = "60601",
            Country = "USA",
            Website = "https://www.acmepowder.com",
            AccountNumber = "ACC-001",
            TaxId = "98-7654321",
            PaymentTerms = "Net 30",
            CreditLimit = 10000,
            IsPreferred = true,
            IsActive = true,
            Notes = "Primary powder supplier"
        });
        csv.NextRecord();

        writer.Flush();
        return memoryStream.ToArray();
    }

    /// <summary>
    /// Imports vendors from a CSV stream using an upsert strategy keyed on CompanyName.
    /// Unlike the customer and inventory importers which skip duplicates, vendor import
    /// intentionally updates existing records so that re-exporting from QuickBooks and
    /// re-importing is a safe, idempotent refresh operation. Only non-null CSV fields
    /// overwrite existing values — a blank CSV cell never clears a field that was previously set.
    /// CompanyName is passed through <see cref="StripQuotes"/> because accounting software
    /// frequently exports names wrapped in literal quote characters.
    /// </summary>
    /// <param name="csvStream">Readable stream of CSV data (header row required).</param>
    /// <param name="companyId">Tenant company that will own newly inserted vendor records.</param>
    public async Task<CsvImportResultDto> ImportVendorsAsync(Stream csvStream, int companyId)
    {
        var result = new CsvImportResultDto();
        var rowNumber = 0;

        try
        {
            using var reader = new StreamReader(csvStream);
            using var csv = new CsvReader(reader, new CsvConfiguration(CultureInfo.InvariantCulture)
            {
                HeaderValidated = null,
                MissingFieldFound = null
            });

            var records = csv.GetRecords<VendorImportDto>().ToList();
            result.TotalRows = records.Count;

            _logger.LogInformation("Starting import of {Count} vendors for company {CompanyId}", records.Count, companyId);

            // Load existing vendors for upsert matching
            var existingVendors = await _unitOfWork.Vendors.GetAllAsync();
            var vendorDict = existingVendors
                .Where(v => !string.IsNullOrEmpty(v.CompanyName))
                .GroupBy(v => v.CompanyName.Trim().ToUpperInvariant())
                .ToDictionary(g => g.Key, g => g.First());

            foreach (var record in records)
            {
                rowNumber++;
                try
                {
                    // Strip any literal quote characters that QB/Excel may wrap around field values
                    var cleanCompanyName = StripQuotes(record.CompanyName);

                    if (string.IsNullOrWhiteSpace(cleanCompanyName))
                    {
                        result.Errors.Add($"Row {rowNumber}: CompanyName is required.");
                        result.ErrorCount++;
                        continue;
                    }

                    var key = cleanCompanyName.ToUpperInvariant();
                    var now = DateTime.UtcNow;

                    if (vendorDict.TryGetValue(key, out var existing))
                    {
                        // Update
                        existing.ContactName = StripQuotes(record.ContactName) ?? existing.ContactName;
                        existing.Email = record.Email ?? existing.Email;
                        existing.Phone = record.Phone ?? existing.Phone;
                        existing.Address = record.Address ?? existing.Address;
                        existing.City = record.City ?? existing.City;
                        existing.State = record.State ?? existing.State;
                        existing.ZipCode = record.ZipCode ?? existing.ZipCode;
                        existing.Country = record.Country ?? existing.Country;
                        existing.Website = record.Website ?? existing.Website;
                        existing.AccountNumber = record.AccountNumber ?? existing.AccountNumber;
                        existing.TaxId = record.TaxId ?? existing.TaxId;
                        existing.PaymentTerms = record.PaymentTerms ?? existing.PaymentTerms;
                        if (record.CreditLimit.HasValue) existing.CreditLimit = record.CreditLimit;
                        if (record.IsPreferred.HasValue) existing.IsPreferred = record.IsPreferred.Value;
                        if (record.IsActive.HasValue) existing.IsActive = record.IsActive.Value;
                        existing.Notes = record.Notes ?? existing.Notes;
                        existing.UpdatedAt = now;

                        await _unitOfWork.CompleteAsync();
                        result.Warnings.Add($"Row {rowNumber}: Updated existing vendor '{cleanCompanyName}'.");
                        result.SuccessCount++;
                    }
                    else
                    {
                        var vendor = new Core.Entities.Vendor
                        {
                            CompanyId = companyId,
                            CompanyName = cleanCompanyName,
                            ContactName = StripQuotes(record.ContactName),
                            Email = record.Email,
                            Phone = record.Phone,
                            Address = record.Address,
                            City = record.City,
                            State = record.State,
                            ZipCode = record.ZipCode,
                            Country = record.Country ?? "USA",
                            Website = record.Website,
                            AccountNumber = record.AccountNumber,
                            TaxId = record.TaxId,
                            PaymentTerms = record.PaymentTerms,
                            CreditLimit = record.CreditLimit,
                            IsPreferred = record.IsPreferred ?? false,
                            IsActive = record.IsActive ?? true,
                            Notes = record.Notes,
                            CreatedAt = now,
                            UpdatedAt = now
                        };

                        await _unitOfWork.Vendors.AddAsync(vendor);
                        await _unitOfWork.CompleteAsync();
                        result.SuccessCount++;
                    }
                }
                catch (Exception ex)
                {
                    result.Errors.Add($"Row {rowNumber}: Database error - {ex.Message}");
                    result.ErrorCount++;
                    _logger.LogError(ex, "Error saving vendor at row {RowNumber}", rowNumber);
                }
            }

            _logger.LogInformation("Vendor import completed: {SuccessCount} succeeded, {ErrorCount} failed", result.SuccessCount, result.ErrorCount);
            result.Success = result.SuccessCount > 0;
        }
        catch (Exception ex)
        {
            result.Errors.Add($"Fatal error: {ex.Message}");
            result.Success = false;
            _logger.LogError(ex, "Fatal error importing vendors");
        }

        return result;
    }

    #endregion
    #region Prep Service Import

    /// <summary>
    /// Generates a downloadable CSV template with three example prep service rows covering the most
    /// common surface-preparation processes (Sandblasting, Chemical Stripping, Hand Sanding).
    /// DisplayOrder is shown explicitly so users understand they can control the order in which
    /// prep services appear in the quote/job wizard.
    /// </summary>
    public byte[] GeneratePrepServiceTemplate()
    {
        using var memoryStream = new MemoryStream();
        using var writer = new StreamWriter(memoryStream);
        using var csv = new CsvWriter(writer, new CsvConfiguration(CultureInfo.InvariantCulture));

        csv.WriteHeader<PrepServiceImportDto>();
        csv.NextRecord();

        csv.WriteRecord(new PrepServiceImportDto
        {
            ServiceName = "Sandblasting",
            Description = "Abrasive blasting to remove rust, paint, and scale",
            DisplayOrder = 10,
            IsActive = true
        });
        csv.NextRecord();

        csv.WriteRecord(new PrepServiceImportDto
        {
            ServiceName = "Chemical Stripping",
            Description = "Chemical process to remove existing coatings",
            DisplayOrder = 20,
            IsActive = true
        });
        csv.NextRecord();

        csv.WriteRecord(new PrepServiceImportDto
        {
            ServiceName = "Hand Sanding",
            Description = "Manual sanding for surface preparation",
            DisplayOrder = 30,
            IsActive = true
        });
        csv.NextRecord();

        writer.Flush();
        return memoryStream.ToArray();
    }

    /// <summary>
    /// Imports prep services from a CSV stream using an upsert strategy keyed on ServiceName.
    /// Upsert behaviour (update if exists, insert if new) was chosen because prep services are
    /// typically a small, stable list — being able to re-import to update descriptions and display
    /// order without manual deduplication is more valuable than strict insert-only protection.
    /// </summary>
    /// <param name="csvStream">Readable stream of CSV data (header row required).</param>
    /// <param name="companyId">Tenant company that will own newly inserted service records.</param>
    public async Task<CsvImportResultDto> ImportPrepServicesAsync(Stream csvStream, int companyId)
    {
        var result = new CsvImportResultDto();
        var rowNumber = 0;

        try
        {
            using var reader = new StreamReader(csvStream);
            using var csv = new CsvReader(reader, new CsvConfiguration(CultureInfo.InvariantCulture)
            {
                HeaderValidated = null,
                MissingFieldFound = null
            });

            var records = csv.GetRecords<PrepServiceImportDto>().ToList();
            result.TotalRows = records.Count;

            _logger.LogInformation("Starting import of {Count} prep services for company {CompanyId}", records.Count, companyId);

            // Load existing services for upsert matching by name
            var existingServices = await _unitOfWork.PrepServices.GetAllAsync();
            var serviceDict = existingServices
                .Where(s => !string.IsNullOrEmpty(s.ServiceName))
                .GroupBy(s => s.ServiceName.Trim().ToUpperInvariant())
                .ToDictionary(g => g.Key, g => g.First());

            foreach (var record in records)
            {
                rowNumber++;
                try
                {
                    if (string.IsNullOrWhiteSpace(record.ServiceName))
                    {
                        result.Errors.Add($"Row {rowNumber}: ServiceName is required.");
                        result.ErrorCount++;
                        continue;
                    }

                    var key = record.ServiceName.Trim().ToUpperInvariant();
                    var now = DateTime.UtcNow;

                    if (serviceDict.TryGetValue(key, out var existing))
                    {
                        // Update
                        existing.Description = record.Description ?? existing.Description;
                        if (record.DisplayOrder.HasValue) existing.DisplayOrder = record.DisplayOrder.Value;
                        if (record.IsActive.HasValue) existing.IsActive = record.IsActive.Value;
                        existing.UpdatedAt = now;

                        await _unitOfWork.CompleteAsync();
                        result.Warnings.Add($"Row {rowNumber}: Updated existing prep service '{record.ServiceName}'.");
                        result.SuccessCount++;
                    }
                    else
                    {
                        var service = new Core.Entities.PrepService
                        {
                            CompanyId = companyId,
                            ServiceName = record.ServiceName.Trim(),
                            Description = record.Description,
                            DisplayOrder = record.DisplayOrder ?? 0,
                            IsActive = record.IsActive ?? true,
                            CreatedAt = now,
                            UpdatedAt = now
                        };

                        await _unitOfWork.PrepServices.AddAsync(service);
                        await _unitOfWork.CompleteAsync();
                        result.SuccessCount++;
                    }
                }
                catch (Exception ex)
                {
                    result.Errors.Add($"Row {rowNumber}: Database error - {ex.Message}");
                    result.ErrorCount++;
                    _logger.LogError(ex, "Error saving prep service at row {RowNumber}", rowNumber);
                }
            }

            _logger.LogInformation("Prep service import completed: {SuccessCount} succeeded, {ErrorCount} failed", result.SuccessCount, result.ErrorCount);
            result.Success = result.SuccessCount > 0;
        }
        catch (Exception ex)
        {
            result.Errors.Add($"Fatal error: {ex.Message}");
            result.Success = false;
            _logger.LogError(ex, "Fatal error importing prep services");
        }

        return result;
    }

    #endregion

    #region Helper Methods

    /// <summary>
    /// Resolves a slash-delimited category path (e.g. "Automotive/Wheels") to a leaf category ID,
    /// creating any missing parent or child nodes in the process. Each path segment is resolved from
    /// left to right so that "Automotive" is created (or found) before "Wheels" is attached to it.
    /// A per-import dictionary cache prevents redundant DB round-trips when many rows share the same
    /// category path — the cache stores partial paths too (e.g. "Automotive") so sibling categories
    /// like "Automotive/Rims" reuse the already-resolved parent without hitting the database again.
    /// </summary>
    /// <param name="categoryPath">Slash-delimited path string, e.g. "Metal/Automotive/Wheels".</param>
    /// <param name="companyId">Tenant company used when creating new category nodes.</param>
    /// <param name="cache">Shared mutable dictionary mapping path strings to category IDs for this import session.</param>
    /// <returns>The database ID of the leaf (deepest) category in the path.</returns>
    private async Task<int> ResolveOrCreateCategoryAsync(string categoryPath, int companyId, Dictionary<string, int> cache)
    {
        // Check cache first
        if (cache.TryGetValue(categoryPath, out int cachedId))
        {
            return cachedId;
        }

        var parts = categoryPath.Split('/', StringSplitOptions.RemoveEmptyEntries);
        int? parentId = null;
        string currentPath = "";

        for (int i = 0; i < parts.Length; i++)
        {
            var categoryName = parts[i].Trim();
            currentPath = i == 0 ? categoryName : $"{currentPath}/{categoryName}";

            // Check cache for this level
            if (cache.TryGetValue(currentPath, out int levelId))
            {
                parentId = levelId;
                continue;
            }

            // Try to find existing category
            var existingCategories = await _unitOfWork.CatalogCategories.FindAsync(
                c => c.Name == categoryName && c.ParentCategoryId == parentId);

            var category = existingCategories.FirstOrDefault();

            if (category == null)
            {
                // Create new category
                category = new CatalogCategory
                {
                    CompanyId = companyId,
                    Name = categoryName,
                    ParentCategoryId = parentId,
                    IsActive = true,
                    DisplayOrder = 0,
                    CreatedAt = DateTime.UtcNow,
                    UpdatedAt = DateTime.UtcNow
                };

                await _unitOfWork.CatalogCategories.AddAsync(category);
                await _unitOfWork.CompleteAsync();

                _logger.LogInformation("Created new category: {CategoryName} (parent: {ParentId})", categoryName, parentId);
            }

            // Cache this category
            cache[currentPath] = category.Id;
            parentId = category.Id;
        }

        return parentId!.Value;
    }

    #endregion

    /// <summary>
    /// Generates a CSV template for expense imports with one sample row.
    /// </summary>
    public byte[] GenerateExpenseTemplate()
    {
        using var memoryStream = new MemoryStream();
        using var writer = new StreamWriter(memoryStream);
        using var csv = new CsvWriter(writer, new CsvConfiguration(CultureInfo.InvariantCulture));

        csv.WriteHeader<ExpenseImportDto>();
        csv.NextRecord();

        csv.WriteRecord(new ExpenseImportDto
        {
            ExpenseNumber = "",
            Date = DateTime.Today,
            VendorName = "Acme Powder Supply Co.",
            ExpenseAccountNumber = "6200",
            PaymentAccountNumber = "1000",
            JobNumber = "",
            PaymentMethod = "Check",
            Amount = 250.00m,
            Memo = "Monthly powder order"
        });
        csv.NextRecord();

        writer.Flush();
        return memoryStream.ToArray();
    }

    /// <summary>
    /// Imports expenses from a CSV stream for the given company.
    /// Resolves ExpenseAccountNumber and PaymentAccountNumber against Account.AccountNumber.
    /// VendorName (optional) is matched against Vendor.CompanyName.
    /// JobNumber (optional) is matched against Job.JobNumber.
    /// ExpenseNumber is auto-generated in EXP-YYMM-#### format when the column is blank.
    /// </summary>
    /// <param name="csvStream">Readable CSV stream with a header row.</param>
    /// <param name="companyId">Tenant company that will own the imported records.</param>
    public async Task<CsvImportResultDto> ImportExpensesAsync(Stream csvStream, int companyId)
    {
        var result = new CsvImportResultDto();
        var rowNumber = 0;

        try
        {
            using var reader = new StreamReader(csvStream);
            using var csv = new CsvReader(reader, new CsvConfiguration(CultureInfo.InvariantCulture)
            {
                HeaderValidated = null,
                MissingFieldFound = null
            });

            var records = csv.GetRecords<ExpenseImportDto>().ToList();
            result.TotalRows = records.Count;

            _logger.LogInformation("Starting import of {Count} expenses for company {CompanyId}", records.Count, companyId);

            // Build lookup dictionaries
            var accounts = await _unitOfWork.Accounts.GetAllAsync();
            var accountByNumber = accounts
                .Where(a => !string.IsNullOrEmpty(a.AccountNumber))
                .GroupBy(a => a.AccountNumber.Trim())
                .ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase);

            var vendors = await _unitOfWork.Vendors.GetAllAsync();
            var vendorByName = vendors
                .Where(v => !string.IsNullOrEmpty(v.CompanyName))
                .GroupBy(v => v.CompanyName.Trim())
                .ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase);

            var jobs = await _unitOfWork.Jobs.GetAllAsync();
            var jobByNumber = jobs
                .Where(j => !string.IsNullOrEmpty(j.JobNumber))
                .ToDictionary(j => j.JobNumber.Trim(), j => j, StringComparer.OrdinalIgnoreCase);

            // Pre-load existing expense numbers for duplicate detection and auto-numbering
            var existingExpenses = await _unitOfWork.Expenses.GetAllAsync();
            var existingExpenseNumbers = existingExpenses
                .Where(e => !string.IsNullOrEmpty(e.ExpenseNumber))
                .Select(e => e.ExpenseNumber.ToUpperInvariant())
                .ToHashSet(StringComparer.OrdinalIgnoreCase);

            // Track numbers generated within this batch to avoid same-batch collisions
            var batchNumbers = new HashSet<string>(StringComparer.OrdinalIgnoreCase);

            foreach (var record in records)
            {
                rowNumber++;
                try
                {
                    // Resolve expense account (required)
                    var cleanExpenseAccount = StripQuotes(record.ExpenseAccountNumber)?.Trim() ?? "";
                    if (string.IsNullOrWhiteSpace(cleanExpenseAccount))
                    {
                        result.Errors.Add($"Row {rowNumber}: ExpenseAccountNumber is required.");
                        result.ErrorCount++;
                        continue;
                    }
                    if (!accountByNumber.TryGetValue(cleanExpenseAccount, out var expenseAccount))
                    {
                        result.Errors.Add($"Row {rowNumber}: Account number '{cleanExpenseAccount}' not found in Chart of Accounts.");
                        result.ErrorCount++;
                        continue;
                    }

                    // Resolve payment account (required)
                    var cleanPaymentAccount = StripQuotes(record.PaymentAccountNumber)?.Trim() ?? "";
                    if (string.IsNullOrWhiteSpace(cleanPaymentAccount))
                    {
                        result.Errors.Add($"Row {rowNumber}: PaymentAccountNumber is required.");
                        result.ErrorCount++;
                        continue;
                    }
                    if (!accountByNumber.TryGetValue(cleanPaymentAccount, out var paymentAccount))
                    {
                        result.Errors.Add($"Row {rowNumber}: Payment account number '{cleanPaymentAccount}' not found in Chart of Accounts.");
                        result.ErrorCount++;
                        continue;
                    }

                    // Resolve vendor (optional)
                    int? vendorId = null;
                    var cleanVendorName = StripQuotes(record.VendorName)?.Trim();
                    if (!string.IsNullOrEmpty(cleanVendorName))
                    {
                        if (vendorByName.TryGetValue(cleanVendorName, out var vendor))
                            vendorId = vendor.Id;
                        else
                            result.Warnings.Add($"Row {rowNumber}: Vendor '{cleanVendorName}' not found — expense will be saved without a vendor link.");
                    }

                    // Resolve job (optional)
                    int? jobId = null;
                    var cleanJobNumber = StripQuotes(record.JobNumber)?.Trim();
                    if (!string.IsNullOrEmpty(cleanJobNumber))
                    {
                        if (jobByNumber.TryGetValue(cleanJobNumber, out var job))
                            jobId = job.Id;
                        else
                            result.Warnings.Add($"Row {rowNumber}: Job '{cleanJobNumber}' not found — expense will be saved without a job link.");
                    }

                    // Resolve PaymentMethod enum
                    if (!Enum.TryParse<Core.Enums.PaymentMethod>(record.PaymentMethod?.Trim(), ignoreCase: true, out var paymentMethod))
                    {
                        result.Warnings.Add($"Row {rowNumber}: Unknown PaymentMethod '{record.PaymentMethod}'. Defaulting to Cash.");
                        paymentMethod = Core.Enums.PaymentMethod.Cash;
                    }

                    // Determine expense number — use supplied value or auto-generate
                    string expenseNumber;
                    var cleanExpenseNumber = StripQuotes(record.ExpenseNumber)?.Trim();
                    if (!string.IsNullOrEmpty(cleanExpenseNumber))
                    {
                        if (existingExpenseNumbers.Contains(cleanExpenseNumber) || batchNumbers.Contains(cleanExpenseNumber))
                        {
                            result.Warnings.Add($"Row {rowNumber}: Expense number '{cleanExpenseNumber}' already exists. Skipping.");
                            result.SkippedCount++;
                            continue;
                        }
                        expenseNumber = cleanExpenseNumber;
                    }
                    else
                    {
                        // Auto-generate EXP-YYMM-#### using the current date
                        var prefix = $"EXP-{record.Date:yyMM}-";
                        var maxSeq = existingExpenseNumbers
                            .Where(n => n.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
                            .Select(n => int.TryParse(n[prefix.Length..], out var seq) ? seq : 0)
                            .Concat(batchNumbers
                                .Where(n => n.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
                                .Select(n => int.TryParse(n[prefix.Length..], out var seq) ? seq : 0))
                            .DefaultIfEmpty(0)
                            .Max();
                        expenseNumber = $"{prefix}{maxSeq + 1:D4}";
                    }
                    batchNumbers.Add(expenseNumber);
                    existingExpenseNumbers.Add(expenseNumber);

                    var expense = new Core.Entities.Expense
                    {
                        CompanyId = companyId,
                        ExpenseNumber = expenseNumber,
                        Date = record.Date,
                        VendorId = vendorId,
                        ExpenseAccountId = expenseAccount.Id,
                        PaymentAccountId = paymentAccount.Id,
                        JobId = jobId,
                        PaymentMethod = paymentMethod,
                        Amount = record.Amount,
                        Memo = StripQuotes(record.Memo),
                        CreatedAt = DateTime.UtcNow,
                        UpdatedAt = DateTime.UtcNow
                    };

                    await _unitOfWork.Expenses.AddAsync(expense);
                    await _unitOfWork.CompleteAsync();
                    result.SuccessCount++;
                }
                catch (Exception ex)
                {
                    result.Errors.Add($"Row {rowNumber}: {ex.Message}");
                    result.ErrorCount++;
                    _logger.LogWarning(ex, "Error processing expense at row {RowNumber}", rowNumber);
                    _unitOfWork.ClearChangeTracker();
                }
            }
        }
        catch (Exception ex)
        {
            result.Errors.Add($"Fatal error: {ex.Message}");
            result.ErrorCount++;
            _logger.LogError(ex, "Fatal error importing expenses for company {CompanyId}", companyId);
        }

        return result;
    }

    /// <summary>
    /// Generates a CSV template for Chart of Accounts imports with one sample row per common account type.
    /// </summary>
    public byte[] GenerateChartOfAccountsTemplate()
    {
        using var memoryStream = new MemoryStream();
        using var writer = new StreamWriter(memoryStream);
        using var csv = new CsvHelper.CsvWriter(writer, System.Globalization.CultureInfo.InvariantCulture);

        csv.WriteHeader<ChartOfAccountsImportDto>();
        csv.NextRecord();

        csv.WriteRecord(new ChartOfAccountsImportDto
        {
            AccountNumber = "1010",
            Name = "Checking Account",
            AccountType = "Asset",
            AccountSubType = "Checking",
            Description = "Primary operating checking account",
            OpeningBalance = 0,
            OpeningBalanceDate = null,
            IsActive = true
        });
        csv.NextRecord();

        csv.WriteRecord(new ChartOfAccountsImportDto
        {
            AccountNumber = "4010",
            Name = "Coating Revenue",
            AccountType = "Revenue",
            AccountSubType = "ServiceRevenue",
            Description = "Revenue from powder coating services",
            IsActive = true
        });
        csv.NextRecord();

        writer.Flush();
        return memoryStream.ToArray();
    }

    /// <summary>
    /// Imports Chart of Accounts entries from a CSV stream. Existing accounts matched by
    /// AccountNumber are updated; new ones are created. System accounts are never modified.
    /// AccountType and AccountSubType must be valid enum names.
    /// </summary>
    /// <param name="csvStream">Readable CSV stream with a header row.</param>
    /// <param name="companyId">Tenant company that will own the imported accounts.</param>
    public async Task<CsvImportResultDto> ImportChartOfAccountsAsync(Stream csvStream, int companyId)
    {
        var result = new CsvImportResultDto();

        try
        {
            // Load existing accounts keyed by AccountNumber (case-insensitive)
            var existing = await _unitOfWork.Accounts.GetAllAsync();
            var accountDict = existing
                .Where(a => !string.IsNullOrEmpty(a.AccountNumber))
                .GroupBy(a => a.AccountNumber.Trim().ToUpper())
                .ToDictionary(g => g.Key, g => g.First());

            using var reader = new StreamReader(csvStream);
            using var csv = new CsvReader(reader, new CsvConfiguration(CultureInfo.InvariantCulture)
            {
                HeaderValidated = null,
                MissingFieldFound = null
            });

            var records = csv.GetRecords<ChartOfAccountsImportDto>().ToList();
            result.TotalRows = records.Count;

            int rowNumber = 1;
            foreach (var record in records)
            {
                rowNumber++;
                try
                {
                    var cleanNumber = StripQuotes(record.AccountNumber)?.Trim();
                    var cleanName = StripQuotes(record.Name)?.Trim();

                    if (string.IsNullOrWhiteSpace(cleanNumber))
                    {
                        result.Errors.Add($"Row {rowNumber}: AccountNumber is required.");
                        result.ErrorCount++;
                        continue;
                    }
                    if (string.IsNullOrWhiteSpace(cleanName))
                    {
                        result.Errors.Add($"Row {rowNumber}: Name is required.");
                        result.ErrorCount++;
                        continue;
                    }

                    if (!Enum.TryParse<AccountType>(record.AccountType?.Trim(), true, out var accountType))
                    {
                        result.Errors.Add($"Row {rowNumber}: Unknown AccountType '{record.AccountType}'. Valid values: {string.Join(", ", Enum.GetNames<AccountType>())}");
                        result.ErrorCount++;
                        continue;
                    }

                    if (!Enum.TryParse<AccountSubType>(record.AccountSubType?.Trim(), true, out var accountSubType))
                    {
                        result.Errors.Add($"Row {rowNumber}: Unknown AccountSubType '{record.AccountSubType}'. Valid values: {string.Join(", ", Enum.GetNames<AccountSubType>())}");
                        result.ErrorCount++;
                        continue;
                    }

                    DateTime? openingBalanceDate = null;
                    if (!string.IsNullOrWhiteSpace(record.OpeningBalanceDate)
                        && DateTime.TryParse(record.OpeningBalanceDate, out var parsedDate))
                        openingBalanceDate = parsedDate;

                    var key = cleanNumber.ToUpper();

                    if (accountDict.TryGetValue(key, out var acct))
                    {
                        // Skip system accounts — never overwrite them
                        if (acct.IsSystem)
                        {
                            result.Warnings.Add($"Row {rowNumber}: Account '{cleanNumber}' is a system account and cannot be modified. Skipping.");
                            result.SkippedCount++;
                            continue;
                        }

                        // Update
                        acct.Name = cleanName;
                        acct.AccountType = accountType;
                        acct.AccountSubType = accountSubType;
                        acct.Description = StripQuotes(record.Description);
                        if (record.OpeningBalance.HasValue) acct.OpeningBalance = record.OpeningBalance.Value;
                        if (openingBalanceDate.HasValue) acct.OpeningBalanceDate = openingBalanceDate;
                        if (record.IsActive.HasValue) acct.IsActive = record.IsActive.Value;
                        acct.UpdatedAt = DateTime.UtcNow;

                        await _unitOfWork.Accounts.UpdateAsync(acct);
                        result.SuccessCount++;
                    }
                    else
                    {
                        // Create
                        var newAccount = new Account
                        {
                            CompanyId = companyId,
                            AccountNumber = cleanNumber,
                            Name = cleanName,
                            AccountType = accountType,
                            AccountSubType = accountSubType,
                            Description = StripQuotes(record.Description),
                            OpeningBalance = record.OpeningBalance ?? 0,
                            OpeningBalanceDate = openingBalanceDate,
                            IsActive = record.IsActive ?? true,
                            IsSystem = false,
                            CreatedAt = DateTime.UtcNow,
                            UpdatedAt = DateTime.UtcNow
                        };

                        await _unitOfWork.Accounts.AddAsync(newAccount);
                        result.SuccessCount++;
                    }
                }
                catch (Exception ex)
                {
                    result.Errors.Add($"Row {rowNumber}: {ex.Message}");
                    result.ErrorCount++;
                }
            }

            await _unitOfWork.CompleteAsync();

            _logger.LogInformation("Chart of accounts import completed: {SuccessCount} succeeded, {ErrorCount} failed", result.SuccessCount, result.ErrorCount);
            result.Success = result.SuccessCount > 0;
        }
        catch (Exception ex)
        {
            result.Errors.Add($"Fatal error: {ex.Message}");
            result.ErrorCount++;
            result.Success = false;
            _logger.LogError(ex, "Fatal error importing chart of accounts for company {CompanyId}", companyId);
        }

        return result;
    }

    /// <summary>
    /// Strips a single matching pair of outer quote characters (double or single) from a field value.
    /// QuickBooks and several other accounting tools export text fields wrapped in literal quote
    /// characters — e.g. the company name column contains the string <c>"Acme Corp"</c> including
    /// the quotes. CsvHelper passes these through as-is when the field itself is not CSV-quoted,
    /// which means a naive string comparison would fail to detect a duplicate against the stored
    /// value "Acme Corp". Stripping before comparison and before writing to the entity keeps the
    /// data clean without requiring the user to pre-process the file.
    /// </summary>
    /// <param name="value">Raw string value as returned by CsvHelper, or null.</param>
    /// <returns>The trimmed string with outer quote characters removed, or null if the input was null.</returns>
    private static string? StripQuotes(string? value)
    {
        if (value == null) return null;
        var trimmed = value.Trim();
        // Strip matching outer double-quotes
        if (trimmed.Length >= 2 && trimmed[0] == '"' && trimmed[^1] == '"')
            trimmed = trimmed[1..^1].Trim();
        // Strip matching outer single-quotes
        else if (trimmed.Length >= 2 && trimmed[0] == '\'' && trimmed[^1] == '\'')
            trimmed = trimmed[1..^1].Trim();
        return trimmed;
    }

    /// <summary>
    /// Normalises a phone string to its last 10 digits for duplicate-detection comparisons.
    /// Stripping to digits only means formatting differences such as (423)&nbsp;331-9834,
    /// 423-331-9834, and 4233319834 all produce the same key. Returns null when the input
    /// contains fewer than 7 digits — too short to be a real phone number and avoids false
    /// positive matches on placeholder values like "N/A" or extension-only strings.
    /// </summary>
    /// <param name="phone">Raw phone string as read from the CSV, or null.</param>
    /// <returns>Last 10 (or all, if fewer than 10) digits of the input; null if input is unusable.</returns>
    private static string? NormalizePhone(string? phone)
    {
        if (string.IsNullOrWhiteSpace(phone)) return null;
        var digits = new string(phone.Where(char.IsDigit).ToArray());
        if (digits.Length < 7) return null;
        return digits.Length >= 10 ? digits[^10..] : digits;
    }

    // ── Invoice Import ───────────────────────────────────────────────────────────

    /// <summary>
    /// Returns a CSV template whose column headers match the native invoice export so that
    /// exported files can be re-imported without modification.
    /// </summary>
    public byte[] GenerateInvoiceTemplate()
    {
        using var memoryStream = new MemoryStream();
        using var writer = new StreamWriter(memoryStream);
        using var csv = new CsvWriter(writer, new CsvConfiguration(CultureInfo.InvariantCulture));

        csv.WriteHeader<InvoiceImportDto>();
        csv.NextRecord();

        // Write a sample row so users know what values are expected
        csv.WriteRecord(new InvoiceImportDto
        {
            InvoiceNumber   = "INV-2601-0001",
            CustomerEmail   = "customer@example.com",
            CustomerName    = "Acme Corp",
            JobNumber       = "JOB-2601-0001",
            Status          = "Draft",
            InvoiceDate     = DateTime.Today,
            DueDate         = DateTime.Today.AddDays(30),
            SubTotal        = 500.00m,
            TaxPercent      = 8.0m,
            TaxAmount       = 40.00m,
            DiscountAmount  = 0m,
            Total           = 540.00m,
            AmountPaid      = 0m,
            CustomerPO      = "",
            Terms           = "Net 30",
            Notes           = ""
        });
        csv.NextRecord();

        writer.Flush();
        return memoryStream.ToArray();
    }

    /// <summary>
    /// Imports invoice headers from a CSV stream. Customers are resolved by CustomerEmail first
    /// then by CustomerName; rows without a matching customer are skipped with an error.
    /// InvoiceNumber is the unique key — existing invoices are updated; new ones are created.
    /// AmountPaid is stored directly (covers migrated invoices that already have payments).
    /// Line items are not part of the CSV format.
    /// </summary>
    /// <param name="csvStream">Readable stream of CSV data (header row required).</param>
    /// <param name="companyId">Tenant company that will own the imported records.</param>
    public async Task<CsvImportResultDto> ImportInvoicesAsync(Stream csvStream, int companyId)
    {
        var result  = new CsvImportResultDto();
        var rowNumber = 0;

        try
        {
            using var reader = new StreamReader(csvStream);
            using var csv = new CsvReader(reader, new CsvConfiguration(CultureInfo.InvariantCulture)
            {
                HeaderValidated  = null,
                MissingFieldFound = null
            });

            var records = csv.GetRecords<InvoiceImportDto>().ToList();
            result.TotalRows = records.Count;

            _logger.LogInformation("Starting import of {Count} invoices for company {CompanyId}", records.Count, companyId);

            // Build lookup dictionaries for customers
            var customers = await _unitOfWork.Customers.GetAllAsync();
            var customerByEmail = customers.Where(c => !string.IsNullOrEmpty(c.Email))
                .ToDictionary(c => c.Email!.Trim().ToLower(), c => c, StringComparer.OrdinalIgnoreCase);
            var customerByName = new Dictionary<string, Customer>(StringComparer.OrdinalIgnoreCase);
            foreach (var c in customers)
            {
                var displayName = !string.IsNullOrWhiteSpace(c.CompanyName)
                    ? c.CompanyName.Trim()
                    : $"{c.ContactFirstName} {c.ContactLastName}".Trim();
                if (!string.IsNullOrEmpty(displayName))
                    customerByName.TryAdd(displayName, c);
            }

            // Build job lookup by job number (optional link)
            var jobs = await _unitOfWork.Jobs.GetAllAsync();
            var jobByNumber = jobs.Where(j => !string.IsNullOrEmpty(j.JobNumber))
                .ToDictionary(j => j.JobNumber.Trim(), j => j, StringComparer.OrdinalIgnoreCase);

            // Existing invoices keyed by InvoiceNumber for upsert logic
            var existingInvoices = await _unitOfWork.Invoices.GetAllAsync(ignoreQueryFilters: false);
            var invoiceByNumber  = existingInvoices.Where(i => !string.IsNullOrEmpty(i.InvoiceNumber))
                .ToDictionary(i => i.InvoiceNumber.Trim(), i => i, StringComparer.OrdinalIgnoreCase);

            // Valid InvoiceStatus enum values for parsing
            var validStatuses = Enum.GetNames<InvoiceStatus>()
                .ToDictionary(n => n.ToLower(), n => Enum.Parse<InvoiceStatus>(n), StringComparer.OrdinalIgnoreCase);

            var inBatchNumbers = new HashSet<string>(StringComparer.OrdinalIgnoreCase);

            foreach (var record in records)
            {
                rowNumber++;
                try
                {
                    if (string.IsNullOrWhiteSpace(record.InvoiceNumber))
                    {
                        result.Errors.Add($"Row {rowNumber}: InvoiceNumber is required.");
                        result.ErrorCount++;
                        continue;
                    }

                    var invoiceNumber = record.InvoiceNumber.Trim();

                    if (!inBatchNumbers.Add(invoiceNumber))
                    {
                        result.Warnings.Add($"Row {rowNumber}: Duplicate InvoiceNumber '{invoiceNumber}' in file. Skipping.");
                        result.ErrorCount++;
                        continue;
                    }

                    // Resolve customer — required for invoices
                    Customer? customer = null;
                    if (!string.IsNullOrWhiteSpace(record.CustomerEmail))
                        customerByEmail.TryGetValue(record.CustomerEmail.Trim().ToLower(), out customer);

                    if (customer == null && !string.IsNullOrWhiteSpace(record.CustomerName))
                    {
                        customerByName.TryGetValue(record.CustomerName.Trim(), out customer);
                        if (customer != null && !string.IsNullOrWhiteSpace(record.CustomerEmail))
                            result.Warnings.Add($"Row {rowNumber}: Customer matched by name '{record.CustomerName}' (email not found).");
                    }

                    if (customer == null)
                    {
                        var identifier = record.CustomerEmail ?? record.CustomerName ?? "(none)";
                        result.Errors.Add($"Row {rowNumber}: Customer '{identifier}' not found. Invoice requires a linked customer.");
                        result.ErrorCount++;
                        continue;
                    }

                    // Optional job link
                    int? jobId = null;
                    if (!string.IsNullOrWhiteSpace(record.JobNumber))
                    {
                        if (jobByNumber.TryGetValue(record.JobNumber.Trim(), out var job))
                            jobId = job.Id;
                        else
                            result.Warnings.Add($"Row {rowNumber}: Job '{record.JobNumber}' not found — invoice will not be linked to a job.");
                    }

                    // Parse status
                    if (!validStatuses.TryGetValue(record.Status?.Trim() ?? "", out var status))
                    {
                        result.Warnings.Add($"Row {rowNumber}: Unknown status '{record.Status}' — defaulting to Draft.");
                        status = InvoiceStatus.Draft;
                    }

                    if (invoiceByNumber.TryGetValue(invoiceNumber, out var existing))
                    {
                        // Update existing invoice
                        existing.CustomerId      = customer.Id;
                        existing.JobId           = jobId;
                        existing.Status          = status;
                        existing.InvoiceDate     = record.InvoiceDate == default ? DateTime.UtcNow : record.InvoiceDate;
                        existing.DueDate         = record.DueDate;
                        existing.SubTotal        = record.SubTotal;
                        existing.TaxPercent      = record.TaxPercent;
                        existing.TaxAmount       = record.TaxAmount;
                        existing.DiscountAmount  = record.DiscountAmount;
                        existing.Total           = record.Total;
                        existing.AmountPaid      = record.AmountPaid;
                        existing.CustomerPO      = string.IsNullOrWhiteSpace(record.CustomerPO)   ? null : record.CustomerPO.Trim();
                        existing.Terms           = string.IsNullOrWhiteSpace(record.Terms)        ? null : record.Terms.Trim();
                        existing.Notes           = string.IsNullOrWhiteSpace(record.Notes)        ? null : record.Notes.Trim();
                        existing.ProjectName     = string.IsNullOrWhiteSpace(record.ProjectName)  ? null : record.ProjectName.Trim();
                        await _unitOfWork.CompleteAsync();
                        result.SuccessCount++;
                    }
                    else
                    {
                        // Create new invoice
                        var invoice = new Core.Entities.Invoice
                        {
                            InvoiceNumber  = invoiceNumber,
                            CompanyId      = companyId,
                            CustomerId     = customer.Id,
                            JobId          = jobId,
                            Status         = status,
                            InvoiceDate    = record.InvoiceDate == default ? DateTime.UtcNow : record.InvoiceDate,
                            DueDate        = record.DueDate,
                            SubTotal       = record.SubTotal,
                            TaxPercent     = record.TaxPercent,
                            TaxAmount      = record.TaxAmount,
                            DiscountAmount = record.DiscountAmount,
                            Total          = record.Total,
                            AmountPaid     = record.AmountPaid,
                            CustomerPO     = string.IsNullOrWhiteSpace(record.CustomerPO)  ? null : record.CustomerPO.Trim(),
                            Terms          = string.IsNullOrWhiteSpace(record.Terms)       ? null : record.Terms.Trim(),
                            Notes          = string.IsNullOrWhiteSpace(record.Notes)       ? null : record.Notes.Trim(),
                            ProjectName    = string.IsNullOrWhiteSpace(record.ProjectName) ? null : record.ProjectName.Trim()
                        };
                        await _unitOfWork.Invoices.AddAsync(invoice);
                        await _unitOfWork.CompleteAsync();
                        result.SuccessCount++;
                    }
                }
                catch (Exception ex)
                {
                    _logger.LogError(ex, "Error importing invoice row {RowNumber} for company {CompanyId}", rowNumber, companyId);
                    result.Errors.Add($"Row {rowNumber}: {ex.Message}");
                    result.ErrorCount++;
                }
            }

            result.Success = result.ErrorCount == 0 || result.SuccessCount > 0;
            return result;
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Fatal error during invoice CSV import for company {CompanyId}", companyId);
            result.Errors.Add($"Fatal error: {ex.Message}");
            return result;
        }
    }

    public byte[] GenerateInvoiceItemTemplate()
    {
        using var memoryStream = new MemoryStream();
        using var writer = new StreamWriter(memoryStream);
        using var csv = new CsvWriter(writer, new CsvConfiguration(CultureInfo.InvariantCulture));

        csv.WriteHeader<InvoiceItemImportDto>();
        csv.NextRecord();

        csv.WriteRecord(new InvoiceItemImportDto
        {
            InvoiceNumber        = "INV-2601-0001",
            Description          = "Powder coating - 4 wheels",
            Quantity             = 4,
            UnitPrice            = 75.00m,
            TotalPrice           = 300.00m,
            ColorName            = "Gloss Black",
            RevenueAccountNumber = "47905",
            DisplayOrder         = 0,
            Notes                = ""
        });
        csv.NextRecord();

        writer.Flush();
        return memoryStream.ToArray();
    }

    public byte[] GeneratePaymentTemplate()
    {
        using var memoryStream = new MemoryStream();
        using var writer = new StreamWriter(memoryStream);
        using var csv = new CsvWriter(writer, new CsvConfiguration(CultureInfo.InvariantCulture));

        csv.WriteHeader<PaymentImportDto>();
        csv.NextRecord();

        csv.WriteRecord(new PaymentImportDto
        {
            InvoiceNumber       = "INV-2601-0001",
            Amount              = 250.00m,
            PaymentDate         = DateTime.Today,
            PaymentMethod       = "Check",
            DepositAccountNumber = "10100",
            Reference           = "CHK-1234",
            Notes               = ""
        });
        csv.NextRecord();

        writer.Flush();
        return memoryStream.ToArray();
    }

    /// <summary>
    /// Imports invoice line items from CSV. Each row is matched to its parent invoice by InvoiceNumber;
    /// the revenue account is resolved (optional) from RevenueAccountNumber against the Chart of Accounts.
    /// Idempotent — an invoice line with the same description + total + display order is skipped, so the
    /// import can be safely re-run. Run AFTER invoices have been imported (the parents must exist).
    /// </summary>
    public async Task<CsvImportResultDto> ImportInvoiceItemsAsync(Stream csvStream, int companyId)
    {
        var result = new CsvImportResultDto();
        var rowNumber = 0;

        try
        {
            using var reader = new StreamReader(csvStream);
            using var csv = new CsvReader(reader, new CsvConfiguration(CultureInfo.InvariantCulture)
            {
                HeaderValidated = null,
                MissingFieldFound = null
            });

            var records = csv.GetRecords<InvoiceItemImportDto>().ToList();
            result.TotalRows = records.Count;

            _logger.LogInformation("Starting import of {Count} invoice line items for company {CompanyId}", records.Count, companyId);

            var invoices = await _unitOfWork.Invoices.GetAllAsync(false, i => i.InvoiceItems);
            var invoiceByNumber = invoices.Where(i => !string.IsNullOrEmpty(i.InvoiceNumber))
                .ToDictionary(i => i.InvoiceNumber.Trim(), i => i, StringComparer.OrdinalIgnoreCase);

            var accounts = await _unitOfWork.Accounts.GetAllAsync();
            var accountByNumber = accounts
                .Where(a => !string.IsNullOrEmpty(a.AccountNumber))
                .GroupBy(a => a.AccountNumber.Trim())
                .ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase);

            foreach (var record in records)
            {
                rowNumber++;
                try
                {
                    if (string.IsNullOrWhiteSpace(record.InvoiceNumber))
                    {
                        result.Errors.Add($"Row {rowNumber}: InvoiceNumber is required.");
                        result.ErrorCount++;
                        continue;
                    }

                    if (!invoiceByNumber.TryGetValue(record.InvoiceNumber.Trim(), out var invoice))
                    {
                        result.Errors.Add($"Row {rowNumber}: Invoice '{record.InvoiceNumber}' not found.");
                        result.ErrorCount++;
                        continue;
                    }

                    var description = StripQuotes(record.Description)?.Trim() ?? "";

                    // Idempotency: skip a line that already exists on this invoice.
                    var isDuplicate = invoice.InvoiceItems.Any(it =>
                        string.Equals(it.Description, description, StringComparison.OrdinalIgnoreCase)
                        && it.TotalPrice == record.TotalPrice
                        && it.DisplayOrder == record.DisplayOrder);
                    if (isDuplicate)
                    {
                        result.Warnings.Add($"Row {rowNumber}: Line '{description}' already exists on invoice '{record.InvoiceNumber}' — skipped.");
                        result.SkippedCount++;
                        continue;
                    }

                    // Resolve the optional revenue account by number so revenue attribution is preserved.
                    int? revenueAccountId = null;
                    var cleanRevenueAccount = StripQuotes(record.RevenueAccountNumber)?.Trim();
                    if (!string.IsNullOrWhiteSpace(cleanRevenueAccount))
                    {
                        if (accountByNumber.TryGetValue(cleanRevenueAccount, out var revenueAccount))
                            revenueAccountId = revenueAccount.Id;
                        else
                            result.Warnings.Add($"Row {rowNumber}: Revenue account '{cleanRevenueAccount}' not found in Chart of Accounts — line imported without a revenue account.");
                    }

                    var item = new Core.Entities.InvoiceItem
                    {
                        InvoiceId        = invoice.Id,
                        CompanyId        = companyId,
                        Description      = description,
                        Quantity         = record.Quantity,
                        UnitPrice        = record.UnitPrice,
                        TotalPrice       = record.TotalPrice,
                        ColorName        = string.IsNullOrWhiteSpace(record.ColorName) ? null : record.ColorName.Trim(),
                        RevenueAccountId = revenueAccountId,
                        DisplayOrder     = record.DisplayOrder,
                        Notes            = string.IsNullOrWhiteSpace(record.Notes) ? null : record.Notes.Trim()
                    };

                    await _unitOfWork.InvoiceItems.AddAsync(item);
                    await _unitOfWork.CompleteAsync();

                    // Keep the in-memory invoice current so later rows dedup against it correctly.
                    invoice.InvoiceItems.Add(item);

                    result.SuccessCount++;
                }
                catch (Exception ex)
                {
                    result.Errors.Add($"Row {rowNumber}: {ex.Message}");
                    result.ErrorCount++;
                }
            }

            result.Success = result.ErrorCount == 0 || result.SuccessCount > 0;
            return result;
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Fatal error during invoice item CSV import for company {CompanyId}", companyId);
            result.Errors.Add($"Fatal error: {ex.Message}");
            return result;
        }
    }

    /// <summary>
    /// Imports vendor bill headers from CSV. Vendor is resolved by name and the AP account by number.
    /// Dedup by BillNumber. Line items import separately via <see cref="ImportBillLineItemsAsync"/>.
    /// </summary>
    public async Task<CsvImportResultDto> ImportBillsAsync(Stream csvStream, int companyId)
    {
        var result = new CsvImportResultDto();
        var rowNumber = 0;

        try
        {
            using var reader = new StreamReader(csvStream);
            using var csv = new CsvReader(reader, new CsvConfiguration(CultureInfo.InvariantCulture)
            {
                HeaderValidated = null,
                MissingFieldFound = null
            });

            var records = csv.GetRecords<BillImportDto>().ToList();
            result.TotalRows = records.Count;

            var accounts = await _unitOfWork.Accounts.GetAllAsync();
            var accountByNumber = accounts.Where(a => !string.IsNullOrEmpty(a.AccountNumber))
                .GroupBy(a => a.AccountNumber.Trim())
                .ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase);

            var vendors = await _unitOfWork.Vendors.GetAllAsync();
            var vendorByName = vendors.Where(v => !string.IsNullOrEmpty(v.CompanyName))
                .GroupBy(v => v.CompanyName.Trim())
                .ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase);

            var existingBills = await _unitOfWork.Bills.GetAllAsync();
            var existingBillNumbers = existingBills.Where(b => !string.IsNullOrEmpty(b.BillNumber))
                .Select(b => b.BillNumber.Trim()).ToHashSet(StringComparer.OrdinalIgnoreCase);

            foreach (var record in records)
            {
                rowNumber++;
                try
                {
                    var billNumber = StripQuotes(record.BillNumber)?.Trim() ?? "";
                    if (string.IsNullOrWhiteSpace(billNumber))
                    {
                        result.Errors.Add($"Row {rowNumber}: BillNumber is required.");
                        result.ErrorCount++;
                        continue;
                    }
                    if (existingBillNumbers.Contains(billNumber))
                    {
                        result.Warnings.Add($"Row {rowNumber}: Bill '{billNumber}' already exists — skipped.");
                        result.SkippedCount++;
                        continue;
                    }

                    var cleanVendor = StripQuotes(record.VendorName)?.Trim() ?? "";
                    if (!vendorByName.TryGetValue(cleanVendor, out var vendor))
                    {
                        result.Errors.Add($"Row {rowNumber}: Vendor '{cleanVendor}' not found.");
                        result.ErrorCount++;
                        continue;
                    }

                    var cleanApAccount = StripQuotes(record.APAccountNumber)?.Trim() ?? "";
                    if (!accountByNumber.TryGetValue(cleanApAccount, out var apAccount))
                    {
                        result.Errors.Add($"Row {rowNumber}: AP account '{cleanApAccount}' not found in Chart of Accounts.");
                        result.ErrorCount++;
                        continue;
                    }

                    if (!Enum.TryParse<BillStatus>(record.Status?.Trim(), true, out var status))
                        status = BillStatus.Open;

                    var bill = new Core.Entities.Bill
                    {
                        CompanyId           = companyId,
                        BillNumber          = billNumber,
                        VendorInvoiceNumber = string.IsNullOrWhiteSpace(record.VendorInvoiceNumber) ? null : record.VendorInvoiceNumber.Trim(),
                        VendorId            = vendor.Id,
                        APAccountId         = apAccount.Id,
                        BillDate            = record.BillDate == default ? DateTime.UtcNow.Date : record.BillDate,
                        DueDate             = record.DueDate,
                        Status              = status,
                        Terms               = string.IsNullOrWhiteSpace(record.Terms) ? null : record.Terms.Trim(),
                        Memo                = string.IsNullOrWhiteSpace(record.Memo) ? null : record.Memo.Trim(),
                        SubTotal            = record.SubTotal,
                        TaxPercent          = record.TaxPercent,
                        TaxAmount           = record.TaxAmount,
                        Total               = record.Total,
                        AmountPaid          = record.AmountPaid
                    };

                    await _unitOfWork.Bills.AddAsync(bill);
                    await _unitOfWork.CompleteAsync();
                    existingBillNumbers.Add(billNumber);
                    result.SuccessCount++;
                }
                catch (Exception ex)
                {
                    result.Errors.Add($"Row {rowNumber}: {ex.Message}");
                    result.ErrorCount++;
                }
            }

            result.Success = result.ErrorCount == 0 || result.SuccessCount > 0;
            return result;
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Fatal error during bill CSV import for company {CompanyId}", companyId);
            result.Errors.Add($"Fatal error: {ex.Message}");
            return result;
        }
    }

    /// <summary>
    /// Imports vendor bill line items from CSV. Each line is matched to its parent bill by BillNumber;
    /// the expense/asset account (optional) and job (optional) are resolved by number. Idempotent by
    /// bill + description + amount + display order. Run after bills have been imported.
    /// </summary>
    public async Task<CsvImportResultDto> ImportBillLineItemsAsync(Stream csvStream, int companyId)
    {
        var result = new CsvImportResultDto();
        var rowNumber = 0;

        try
        {
            using var reader = new StreamReader(csvStream);
            using var csv = new CsvReader(reader, new CsvConfiguration(CultureInfo.InvariantCulture)
            {
                HeaderValidated = null,
                MissingFieldFound = null
            });

            var records = csv.GetRecords<BillLineItemImportDto>().ToList();
            result.TotalRows = records.Count;

            var bills = await _unitOfWork.Bills.GetAllAsync(false, b => b.LineItems);
            var billByNumber = bills.Where(b => !string.IsNullOrEmpty(b.BillNumber))
                .ToDictionary(b => b.BillNumber.Trim(), b => b, StringComparer.OrdinalIgnoreCase);

            var accounts = await _unitOfWork.Accounts.GetAllAsync();
            var accountByNumber = accounts.Where(a => !string.IsNullOrEmpty(a.AccountNumber))
                .GroupBy(a => a.AccountNumber.Trim())
                .ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase);

            var jobs = await _unitOfWork.Jobs.GetAllAsync();
            var jobByNumber = jobs.Where(j => !string.IsNullOrEmpty(j.JobNumber))
                .ToDictionary(j => j.JobNumber.Trim(), j => j, StringComparer.OrdinalIgnoreCase);

            foreach (var record in records)
            {
                rowNumber++;
                try
                {
                    var billNumber = StripQuotes(record.BillNumber)?.Trim() ?? "";
                    if (!billByNumber.TryGetValue(billNumber, out var bill))
                    {
                        result.Errors.Add($"Row {rowNumber}: Bill '{record.BillNumber}' not found.");
                        result.ErrorCount++;
                        continue;
                    }

                    var description = StripQuotes(record.Description)?.Trim() ?? "";
                    var isDuplicate = bill.LineItems.Any(li =>
                        string.Equals(li.Description, description, StringComparison.OrdinalIgnoreCase)
                        && li.Amount == record.Amount && li.DisplayOrder == record.DisplayOrder);
                    if (isDuplicate)
                    {
                        result.Warnings.Add($"Row {rowNumber}: Line '{description}' already exists on bill '{billNumber}' — skipped.");
                        result.SkippedCount++;
                        continue;
                    }

                    int? accountId = null;
                    var cleanAccount = StripQuotes(record.AccountNumber)?.Trim();
                    if (!string.IsNullOrWhiteSpace(cleanAccount))
                    {
                        if (accountByNumber.TryGetValue(cleanAccount, out var account))
                            accountId = account.Id;
                        else
                            result.Warnings.Add($"Row {rowNumber}: Account '{cleanAccount}' not found — line imported without an account.");
                    }

                    int? jobId = null;
                    var cleanJob = StripQuotes(record.JobNumber)?.Trim();
                    if (!string.IsNullOrWhiteSpace(cleanJob) && jobByNumber.TryGetValue(cleanJob, out var job))
                        jobId = job.Id;

                    var lineItem = new Core.Entities.BillLineItem
                    {
                        CompanyId    = companyId,
                        BillId       = bill.Id,
                        AccountId    = accountId,
                        JobId        = jobId,
                        Description  = description,
                        Quantity     = record.Quantity,
                        UnitPrice    = record.UnitPrice,
                        Amount       = record.Amount,
                        DisplayOrder = record.DisplayOrder
                    };

                    await _unitOfWork.BillLineItems.AddAsync(lineItem);
                    await _unitOfWork.CompleteAsync();
                    bill.LineItems.Add(lineItem);
                    result.SuccessCount++;
                }
                catch (Exception ex)
                {
                    result.Errors.Add($"Row {rowNumber}: {ex.Message}");
                    result.ErrorCount++;
                }
            }

            result.Success = result.ErrorCount == 0 || result.SuccessCount > 0;
            return result;
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Fatal error during bill line item CSV import for company {CompanyId}", companyId);
            result.Errors.Add($"Fatal error: {ex.Message}");
            return result;
        }
    }

    /// <summary>
    /// Imports customer deposits from CSV. Customer is resolved by name, the bank account by number,
    /// and the optional applied invoice by number. Dedup by ReceiptNumber.
    /// </summary>
    public async Task<CsvImportResultDto> ImportDepositsAsync(Stream csvStream, int companyId)
    {
        var result = new CsvImportResultDto();
        var rowNumber = 0;

        try
        {
            using var reader = new StreamReader(csvStream);
            using var csv = new CsvReader(reader, new CsvConfiguration(CultureInfo.InvariantCulture)
            {
                HeaderValidated = null,
                MissingFieldFound = null
            });

            var records = csv.GetRecords<DepositImportDto>().ToList();
            result.TotalRows = records.Count;

            var accounts = await _unitOfWork.Accounts.GetAllAsync();
            var accountByNumber = accounts.Where(a => !string.IsNullOrEmpty(a.AccountNumber))
                .GroupBy(a => a.AccountNumber.Trim())
                .ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase);

            var customers = await _unitOfWork.Customers.GetAllAsync();
            var customerByName = new Dictionary<string, Core.Entities.Customer>(StringComparer.OrdinalIgnoreCase);
            foreach (var c in customers)
            {
                var name = !string.IsNullOrWhiteSpace(c.CompanyName)
                    ? c.CompanyName.Trim()
                    : $"{c.ContactFirstName} {c.ContactLastName}".Trim();
                if (!string.IsNullOrWhiteSpace(name)) customerByName[name] = c;
            }

            var invoices = await _unitOfWork.Invoices.GetAllAsync();
            var invoiceByNumber = invoices.Where(i => !string.IsNullOrEmpty(i.InvoiceNumber))
                .ToDictionary(i => i.InvoiceNumber.Trim(), i => i, StringComparer.OrdinalIgnoreCase);

            var validMethods = Enum.GetNames<PaymentMethod>()
                .ToDictionary(n => n.ToLower(), n => Enum.Parse<PaymentMethod>(n), StringComparer.OrdinalIgnoreCase);

            var existingReceipts = (await _unitOfWork.Deposits.GetAllAsync())
                .Where(d => !string.IsNullOrEmpty(d.ReceiptNumber))
                .Select(d => d.ReceiptNumber.Trim()).ToHashSet(StringComparer.OrdinalIgnoreCase);

            foreach (var record in records)
            {
                rowNumber++;
                try
                {
                    var receiptNumber = StripQuotes(record.ReceiptNumber)?.Trim() ?? "";
                    if (!string.IsNullOrWhiteSpace(receiptNumber) && existingReceipts.Contains(receiptNumber))
                    {
                        result.Warnings.Add($"Row {rowNumber}: Deposit '{receiptNumber}' already exists — skipped.");
                        result.SkippedCount++;
                        continue;
                    }

                    var cleanCustomer = StripQuotes(record.CustomerName)?.Trim() ?? "";
                    if (!customerByName.TryGetValue(cleanCustomer, out var customer))
                    {
                        result.Errors.Add($"Row {rowNumber}: Customer '{cleanCustomer}' not found.");
                        result.ErrorCount++;
                        continue;
                    }

                    if (!validMethods.TryGetValue(record.PaymentMethod?.Trim() ?? "", out var method))
                        method = PaymentMethod.Cash;

                    int? depositAccountId = null;
                    var cleanDepositAccount = StripQuotes(record.DepositAccountNumber)?.Trim();
                    if (!string.IsNullOrWhiteSpace(cleanDepositAccount))
                    {
                        if (accountByNumber.TryGetValue(cleanDepositAccount, out var depositAccount))
                            depositAccountId = depositAccount.Id;
                        else
                            result.Warnings.Add($"Row {rowNumber}: Deposit account '{cleanDepositAccount}' not found — deposit imported without a bank account.");
                    }

                    int? appliedInvoiceId = null;
                    var cleanInvoice = StripQuotes(record.AppliedToInvoiceNumber)?.Trim();
                    if (!string.IsNullOrWhiteSpace(cleanInvoice) && invoiceByNumber.TryGetValue(cleanInvoice, out var appliedInvoice))
                        appliedInvoiceId = appliedInvoice.Id;

                    var deposit = new Core.Entities.Deposit
                    {
                        CompanyId          = companyId,
                        ReceiptNumber      = receiptNumber,
                        CustomerId         = customer.Id,
                        Amount             = record.Amount,
                        PaymentMethod      = method,
                        ReceivedDate       = record.ReceivedDate == default ? DateTime.UtcNow.Date : record.ReceivedDate,
                        DepositAccountId   = depositAccountId,
                        AppliedToInvoiceId = appliedInvoiceId,
                        AppliedDate        = record.AppliedDate,
                        Reference          = string.IsNullOrWhiteSpace(record.Reference) ? null : record.Reference.Trim(),
                        Notes              = string.IsNullOrWhiteSpace(record.Notes) ? null : record.Notes.Trim()
                    };

                    await _unitOfWork.Deposits.AddAsync(deposit);
                    await _unitOfWork.CompleteAsync();
                    if (!string.IsNullOrWhiteSpace(receiptNumber)) existingReceipts.Add(receiptNumber);
                    result.SuccessCount++;
                }
                catch (Exception ex)
                {
                    result.Errors.Add($"Row {rowNumber}: {ex.Message}");
                    result.ErrorCount++;
                }
            }

            result.Success = result.ErrorCount == 0 || result.SuccessCount > 0;
            return result;
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Fatal error during deposit CSV import for company {CompanyId}", companyId);
            result.Errors.Add($"Fatal error: {ex.Message}");
            return result;
        }
    }

    /// <summary>
    /// Imports journal entry headers from CSV. Dedup by EntryNumber. The debit/credit lines import
    /// separately via <see cref="ImportJournalEntryLinesAsync"/>.
    /// </summary>
    public async Task<CsvImportResultDto> ImportJournalEntriesAsync(Stream csvStream, int companyId)
    {
        var result = new CsvImportResultDto();
        var rowNumber = 0;

        try
        {
            using var reader = new StreamReader(csvStream);
            using var csv = new CsvReader(reader, new CsvConfiguration(CultureInfo.InvariantCulture)
            {
                HeaderValidated = null,
                MissingFieldFound = null
            });

            var records = csv.GetRecords<JournalEntryImportDto>().ToList();
            result.TotalRows = records.Count;

            var existingNumbers = (await _unitOfWork.JournalEntries.GetAllAsync())
                .Where(j => !string.IsNullOrEmpty(j.EntryNumber))
                .Select(j => j.EntryNumber.Trim()).ToHashSet(StringComparer.OrdinalIgnoreCase);

            foreach (var record in records)
            {
                rowNumber++;
                try
                {
                    var entryNumber = StripQuotes(record.EntryNumber)?.Trim() ?? "";
                    if (string.IsNullOrWhiteSpace(entryNumber))
                    {
                        result.Errors.Add($"Row {rowNumber}: EntryNumber is required.");
                        result.ErrorCount++;
                        continue;
                    }
                    if (existingNumbers.Contains(entryNumber))
                    {
                        result.Warnings.Add($"Row {rowNumber}: Journal entry '{entryNumber}' already exists — skipped.");
                        result.SkippedCount++;
                        continue;
                    }

                    if (!Enum.TryParse<JournalEntryStatus>(record.Status?.Trim(), true, out var status))
                        status = JournalEntryStatus.Draft;

                    var entry = new Core.Entities.JournalEntry
                    {
                        CompanyId   = companyId,
                        EntryNumber = entryNumber,
                        EntryDate   = record.EntryDate == default ? DateTime.UtcNow.Date : record.EntryDate,
                        Reference   = string.IsNullOrWhiteSpace(record.Reference) ? null : record.Reference.Trim(),
                        Description = string.IsNullOrWhiteSpace(record.Description) ? null : record.Description.Trim(),
                        Status      = status,
                        PostedAt    = status == JournalEntryStatus.Posted ? DateTime.UtcNow : null
                    };

                    await _unitOfWork.JournalEntries.AddAsync(entry);
                    await _unitOfWork.CompleteAsync();
                    existingNumbers.Add(entryNumber);
                    result.SuccessCount++;
                }
                catch (Exception ex)
                {
                    result.Errors.Add($"Row {rowNumber}: {ex.Message}");
                    result.ErrorCount++;
                }
            }

            result.Success = result.ErrorCount == 0 || result.SuccessCount > 0;
            return result;
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Fatal error during journal entry CSV import for company {CompanyId}", companyId);
            result.Errors.Add($"Fatal error: {ex.Message}");
            return result;
        }
    }

    /// <summary>
    /// Imports journal entry lines from CSV. Each line is matched to its parent entry by EntryNumber
    /// and the account resolved (required) from AccountNumber. Idempotent by entry + account + amounts
    /// + line order. Run after journal entry headers have been imported.
    /// </summary>
    public async Task<CsvImportResultDto> ImportJournalEntryLinesAsync(Stream csvStream, int companyId)
    {
        var result = new CsvImportResultDto();
        var rowNumber = 0;

        try
        {
            using var reader = new StreamReader(csvStream);
            using var csv = new CsvReader(reader, new CsvConfiguration(CultureInfo.InvariantCulture)
            {
                HeaderValidated = null,
                MissingFieldFound = null
            });

            var records = csv.GetRecords<JournalEntryLineImportDto>().ToList();
            result.TotalRows = records.Count;

            var entries = await _unitOfWork.JournalEntries.GetAllAsync(false, j => j.Lines);
            var entryByNumber = entries.Where(j => !string.IsNullOrEmpty(j.EntryNumber))
                .ToDictionary(j => j.EntryNumber.Trim(), j => j, StringComparer.OrdinalIgnoreCase);

            var accounts = await _unitOfWork.Accounts.GetAllAsync();
            var accountByNumber = accounts.Where(a => !string.IsNullOrEmpty(a.AccountNumber))
                .GroupBy(a => a.AccountNumber.Trim())
                .ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase);

            foreach (var record in records)
            {
                rowNumber++;
                try
                {
                    var entryNumber = StripQuotes(record.EntryNumber)?.Trim() ?? "";
                    if (!entryByNumber.TryGetValue(entryNumber, out var entry))
                    {
                        result.Errors.Add($"Row {rowNumber}: Journal entry '{record.EntryNumber}' not found.");
                        result.ErrorCount++;
                        continue;
                    }

                    var cleanAccount = StripQuotes(record.AccountNumber)?.Trim() ?? "";
                    if (!accountByNumber.TryGetValue(cleanAccount, out var account))
                    {
                        result.Errors.Add($"Row {rowNumber}: Account '{cleanAccount}' not found in Chart of Accounts.");
                        result.ErrorCount++;
                        continue;
                    }

                    var isDuplicate = entry.Lines.Any(l =>
                        l.AccountId == account.Id && l.DebitAmount == record.DebitAmount
                        && l.CreditAmount == record.CreditAmount && l.LineOrder == record.LineOrder);
                    if (isDuplicate)
                    {
                        result.Warnings.Add($"Row {rowNumber}: Line for account '{cleanAccount}' already exists on entry '{entryNumber}' — skipped.");
                        result.SkippedCount++;
                        continue;
                    }

                    var line = new Core.Entities.JournalEntryLine
                    {
                        CompanyId      = companyId,
                        JournalEntryId = entry.Id,
                        AccountId      = account.Id,
                        DebitAmount    = record.DebitAmount,
                        CreditAmount   = record.CreditAmount,
                        Description    = string.IsNullOrWhiteSpace(record.Description) ? null : record.Description.Trim(),
                        LineOrder      = record.LineOrder
                    };

                    await _unitOfWork.JournalEntryLines.AddAsync(line);
                    await _unitOfWork.CompleteAsync();
                    entry.Lines.Add(line);
                    result.SuccessCount++;
                }
                catch (Exception ex)
                {
                    result.Errors.Add($"Row {rowNumber}: {ex.Message}");
                    result.ErrorCount++;
                }
            }

            result.Success = result.ErrorCount == 0 || result.SuccessCount > 0;
            return result;
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Fatal error during journal entry line CSV import for company {CompanyId}", companyId);
            result.Errors.Add($"Fatal error: {ex.Message}");
            return result;
        }
    }

    public async Task<CsvImportResultDto> ImportPaymentsAsync(Stream csvStream, int companyId)
    {
        var result = new CsvImportResultDto();
        var rowNumber = 0;

        try
        {
            using var reader = new StreamReader(csvStream);
            using var csv = new CsvReader(reader, new CsvConfiguration(CultureInfo.InvariantCulture)
            {
                HeaderValidated  = null,
                MissingFieldFound = null
            });

            var records = csv.GetRecords<PaymentImportDto>().ToList();
            result.TotalRows = records.Count;

            _logger.LogInformation("Starting import of {Count} payments for company {CompanyId}", records.Count, companyId);

            // Build invoice lookup by number scoped to this company, including existing payments for dedup
            var invoices = await _unitOfWork.Invoices.GetAllAsync(false, i => i.Payments);
            var invoiceByNumber = invoices.Where(i => !string.IsNullOrEmpty(i.InvoiceNumber))
                .ToDictionary(i => i.InvoiceNumber.Trim(), i => i, StringComparer.OrdinalIgnoreCase);

            // Account lookup for resolving the deposit (bank) account by number — optional per row.
            // Mirrors the expense import so payments round-trip with their bank-account linkage intact.
            var accounts = await _unitOfWork.Accounts.GetAllAsync();
            var accountByNumber = accounts
                .Where(a => !string.IsNullOrEmpty(a.AccountNumber))
                .GroupBy(a => a.AccountNumber.Trim())
                .ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase);

            var validMethods = Enum.GetNames<PaymentMethod>()
                .ToDictionary(n => n.ToLower(), n => Enum.Parse<PaymentMethod>(n), StringComparer.OrdinalIgnoreCase);

            // Track how much this import is adding per invoice so we can recalculate AmountPaid correctly.
            // Using a running sum rather than += avoids double-counting when invoices were previously
            // imported with AmountPaid already set directly (no actual Payment records).
            var addedPerInvoice = new Dictionary<int, decimal>();

            foreach (var record in records)
            {
                rowNumber++;
                try
                {
                    if (string.IsNullOrWhiteSpace(record.InvoiceNumber))
                    {
                        result.Errors.Add($"Row {rowNumber}: InvoiceNumber is required.");
                        result.ErrorCount++;
                        continue;
                    }

                    if (!invoiceByNumber.TryGetValue(record.InvoiceNumber.Trim(), out var invoice))
                    {
                        result.Errors.Add($"Row {rowNumber}: Invoice '{record.InvoiceNumber}' not found.");
                        result.ErrorCount++;
                        continue;
                    }

                    // Dedup: skip if the same invoice already has a payment matching date + amount
                    var paymentDate = record.PaymentDate == default ? DateTime.UtcNow.Date : record.PaymentDate.Date;
                    var isDuplicate = invoice.Payments.Any(p =>
                        p.PaymentDate.Date == paymentDate && p.Amount == record.Amount);

                    if (isDuplicate)
                    {
                        result.Warnings.Add($"Row {rowNumber}: Payment of {record.Amount:C} on {paymentDate:yyyy-MM-dd} already exists for invoice '{record.InvoiceNumber}' — skipped.");
                        result.SkippedCount++;
                        continue;
                    }

                    if (!validMethods.TryGetValue(record.PaymentMethod?.Trim() ?? "", out var method))
                    {
                        result.Warnings.Add($"Row {rowNumber}: Unknown PaymentMethod '{record.PaymentMethod}' — defaulting to Cash.");
                        method = PaymentMethod.Cash;
                    }

                    // Resolve the optional deposit (bank) account by number so the balance recalc can
                    // post this payment. A blank value is fine; an unknown number warns but still imports.
                    int? depositAccountId = null;
                    var cleanDepositAccount = StripQuotes(record.DepositAccountNumber)?.Trim();
                    if (!string.IsNullOrWhiteSpace(cleanDepositAccount))
                    {
                        if (accountByNumber.TryGetValue(cleanDepositAccount, out var depositAccount))
                            depositAccountId = depositAccount.Id;
                        else
                            result.Warnings.Add($"Row {rowNumber}: Deposit account '{cleanDepositAccount}' not found in Chart of Accounts — payment imported without a deposit account.");
                    }

                    var payment = new Core.Entities.Payment
                    {
                        InvoiceId        = invoice.Id,
                        CompanyId        = companyId,
                        Amount           = record.Amount,
                        PaymentDate      = new DateTime(paymentDate.Year, paymentDate.Month, paymentDate.Day, 0, 0, 0, DateTimeKind.Utc),
                        PaymentMethod    = method,
                        DepositAccountId = depositAccountId,
                        Reference        = string.IsNullOrWhiteSpace(record.Reference) ? null : record.Reference.Trim(),
                        Notes            = string.IsNullOrWhiteSpace(record.Notes)     ? null : record.Notes.Trim()
                    };

                    await _unitOfWork.Payments.AddAsync(payment);
                    await _unitOfWork.CompleteAsync();

                    addedPerInvoice.TryGetValue(invoice.Id, out var soFar);
                    addedPerInvoice[invoice.Id] = soFar + record.Amount;

                    result.SuccessCount++;
                }
                catch (Exception ex)
                {
                    _logger.LogError(ex, "Error importing payment row {RowNumber} for company {CompanyId}", rowNumber, companyId);
                    result.Errors.Add($"Row {rowNumber}: {ex.Message}");
                    result.ErrorCount++;
                }
            }

            // Recalculate AmountPaid for affected invoices from the actual sum of all their payments.
            // This prevents double-counting when invoices were previously imported with AmountPaid
            // already set directly (without corresponding Payment records).
            foreach (var (invoiceId, _) in addedPerInvoice)
            {
                var inv = invoices.First(i => i.Id == invoiceId);
                var allPayments = await _unitOfWork.Payments.FindAsync(p => p.InvoiceId == invoiceId && !p.IsDeleted);
                inv.AmountPaid = allPayments.Sum(p => p.Amount);
                if (inv.AmountPaid >= inv.Total)
                    inv.Status = InvoiceStatus.Paid;
                else if (inv.AmountPaid > 0)
                    inv.Status = InvoiceStatus.PartiallyPaid;
                else
                    inv.Status = InvoiceStatus.Sent;
                await _unitOfWork.CompleteAsync();
            }

            result.Success = result.ErrorCount == 0 || result.SuccessCount > 0;
            return result;
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Fatal error during payment CSV import for company {CompanyId}", companyId);
            result.Errors.Add($"Fatal error: {ex.Message}");
            return result;
        }
    }

    public byte[] GeneratePurchaseOrderTemplate()
    {
        using var memoryStream = new MemoryStream();
        using var writer = new StreamWriter(memoryStream);
        using var csv = new CsvWriter(writer, new CsvConfiguration(CultureInfo.InvariantCulture));

        csv.WriteHeader<PurchaseOrderImportDto>();
        csv.NextRecord();

        csv.WriteRecord(new PurchaseOrderImportDto
        {
            PoNumber             = "PO-2601-0001",
            Vendor               = "Acme Powder Supply Co.",
            Status               = "Submitted",
            OrderDate            = DateTime.Today,
            ExpectedDeliveryDate = DateTime.Today.AddDays(7),
            ReceivedDate         = null,
            SubTotal             = 500.00m,
            ShippingCost         = 25.00m,
            TotalAmount          = 525.00m,
            Notes                = ""
        });
        csv.NextRecord();

        writer.Flush();
        return memoryStream.ToArray();
    }

    public async Task<CsvImportResultDto> ImportPurchaseOrdersAsync(Stream csvStream, int companyId)
    {
        var result = new CsvImportResultDto();
        var rowNumber = 0;

        try
        {
            using var reader = new StreamReader(csvStream);
            using var csv = new CsvReader(reader, new CsvConfiguration(CultureInfo.InvariantCulture)
            {
                HeaderValidated  = null,
                MissingFieldFound = null
            });

            var records = csv.GetRecords<PurchaseOrderImportDto>().ToList();
            result.TotalRows = records.Count;

            _logger.LogInformation("Starting import of {Count} purchase orders for company {CompanyId}", records.Count, companyId);

            // Build vendor lookup by company name
            var vendors = await _unitOfWork.Vendors.GetAllAsync();
            var vendorByName = vendors.Where(v => !string.IsNullOrEmpty(v.CompanyName))
                .ToDictionary(v => v.CompanyName.Trim(), v => v, StringComparer.OrdinalIgnoreCase);

            // Existing POs for upsert
            var existingPos = await _unitOfWork.PurchaseOrders.GetAllAsync();
            var poByNumber = existingPos.Where(p => !string.IsNullOrEmpty(p.PoNumber))
                .ToDictionary(p => p.PoNumber.Trim(), p => p, StringComparer.OrdinalIgnoreCase);

            var validStatuses = Enum.GetNames<PurchaseOrderStatus>()
                .ToDictionary(n => n.ToLower(), n => Enum.Parse<PurchaseOrderStatus>(n), StringComparer.OrdinalIgnoreCase);

            var inBatchNumbers = new HashSet<string>(StringComparer.OrdinalIgnoreCase);

            foreach (var record in records)
            {
                rowNumber++;
                try
                {
                    if (string.IsNullOrWhiteSpace(record.PoNumber))
                    {
                        result.Errors.Add($"Row {rowNumber}: PoNumber is required.");
                        result.ErrorCount++;
                        continue;
                    }

                    var poNumber = record.PoNumber.Trim();

                    if (!inBatchNumbers.Add(poNumber))
                    {
                        result.Warnings.Add($"Row {rowNumber}: Duplicate PoNumber '{poNumber}' in file — skipped.");
                        result.SkippedCount++;
                        continue;
                    }

                    if (string.IsNullOrWhiteSpace(record.Vendor))
                    {
                        result.Errors.Add($"Row {rowNumber}: Vendor is required.");
                        result.ErrorCount++;
                        continue;
                    }

                    if (!vendorByName.TryGetValue(record.Vendor.Trim(), out var vendor))
                    {
                        result.Errors.Add($"Row {rowNumber}: Vendor '{record.Vendor}' not found.");
                        result.ErrorCount++;
                        continue;
                    }

                    if (!validStatuses.TryGetValue(record.Status?.Trim() ?? "", out var status))
                    {
                        result.Warnings.Add($"Row {rowNumber}: Unknown status '{record.Status}' — defaulting to Draft.");
                        status = PurchaseOrderStatus.Draft;
                    }

                    if (poByNumber.TryGetValue(poNumber, out var existing))
                    {
                        existing.VendorId            = vendor.Id;
                        existing.Status              = status;
                        existing.OrderDate           = record.OrderDate == default ? DateTime.UtcNow : record.OrderDate;
                        existing.ExpectedDeliveryDate = record.ExpectedDeliveryDate;
                        existing.ReceivedDate        = record.ReceivedDate;
                        existing.SubTotal            = record.SubTotal;
                        existing.ShippingCost        = record.ShippingCost;
                        existing.TotalAmount         = record.TotalAmount;
                        existing.Notes               = string.IsNullOrWhiteSpace(record.Notes) ? null : record.Notes.Trim();
                        await _unitOfWork.CompleteAsync();
                        result.SuccessCount++;
                    }
                    else
                    {
                        var po = new Core.Entities.PurchaseOrder
                        {
                            PoNumber             = poNumber,
                            CompanyId            = companyId,
                            VendorId             = vendor.Id,
                            Status               = status,
                            OrderDate            = record.OrderDate == default ? DateTime.UtcNow : record.OrderDate,
                            ExpectedDeliveryDate = record.ExpectedDeliveryDate,
                            ReceivedDate         = record.ReceivedDate,
                            SubTotal             = record.SubTotal,
                            ShippingCost         = record.ShippingCost,
                            TotalAmount          = record.TotalAmount,
                            Notes                = string.IsNullOrWhiteSpace(record.Notes) ? null : record.Notes.Trim()
                        };
                        await _unitOfWork.PurchaseOrders.AddAsync(po);
                        await _unitOfWork.CompleteAsync();
                        result.SuccessCount++;
                    }
                }
                catch (Exception ex)
                {
                    _logger.LogError(ex, "Error importing purchase order row {RowNumber} for company {CompanyId}", rowNumber, companyId);
                    result.Errors.Add($"Row {rowNumber}: {ex.Message}");
                    result.ErrorCount++;
                }
            }

            result.Success = result.ErrorCount == 0 || result.SuccessCount > 0;
            return result;
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Fatal error during purchase order CSV import for company {CompanyId}", companyId);
            result.Errors.Add($"Fatal error: {ex.Message}");
            return result;
        }
    }

    /// <summary>
    /// Returns null for any value that cannot be parsed as a decimal, instead of throwing a
    /// TypeConverterException. Registered globally on the job CSV reader so that spreadsheet
    /// artefacts like "false" in a price column are treated as $0 with a warning.
    /// </summary>
    private sealed class LenientNullableDecimalConverter : CsvHelper.TypeConversion.ITypeConverter
    {
        public object? ConvertFromString(string? text, CsvHelper.IReaderRow row, CsvHelper.Configuration.MemberMapData memberMapData)
        {
            if (string.IsNullOrWhiteSpace(text)) return null;
            return decimal.TryParse(text, System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, out var v)
                ? (object?)v
                : null;
        }

        public string? ConvertToString(object? value, CsvHelper.IWriterRow row, CsvHelper.Configuration.MemberMapData memberMapData)
            => value?.ToString();
    }
}
