using System.Globalization;
using System.Text;
using PowderCoating.Core.Enums;
using CsvHelper;
using CsvHelper.Configuration;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using PowderCoating.Application.DTOs.QuickBooks;
using PowderCoating.Application.Interfaces;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Interfaces;
using ExcelDataReader;

namespace PowderCoating.Application.Services;

/// <summary>
/// Implements QuickBooks IIF (Intuit Interchange Format) export and import for QuickBooks Desktop,
/// plus CSV import support for QuickBooks Online exports.
/// <para>
/// IIF format is tab-delimited text where header definition rows start with <c>!</c> (e.g. <c>!CUST</c>)
/// and corresponding data rows use the same prefix without the <c>!</c> (e.g. <c>CUST</c>).
/// Sign conventions must be observed: QuickBooks expects credit-normal accounts (Revenue, AP, Equity)
/// with opposite-sign values to debit-normal accounts.
/// </para>
/// <para>
/// Import flow: Customers → Vendors → Chart of Accounts → Invoices → Transactions → Bills → Vendor Payments.
/// Each step requires reference data from prior steps to be present.
/// </para>
/// </summary>
public class QuickBooksIifService : IQuickBooksIifService
{
    private readonly IUnitOfWork _unitOfWork;
    private readonly ILogger<QuickBooksIifService> _logger;

    private const char TabDelimiter = '\t';
    private const long MaxFileSize = 50 * 1024 * 1024; // 50 MB
    private const int MaxRecords = 10000;
    private static readonly string[] AllowedExtensions = { ".iif", ".txt", ".xls", ".xlsx", ".csv" };

    /// <summary>
    /// Initializes the service with required infrastructure dependencies.
    /// </summary>
    public QuickBooksIifService(IUnitOfWork unitOfWork, ILogger<QuickBooksIifService> logger)
    {
        _unitOfWork = unitOfWork;
        _logger = logger;
    }

    #region Export Methods

    /// <summary>
    /// Exports all active customers for a company as a QuickBooks Desktop IIF file (<c>!CUST</c> / <c>CUST</c> records).
    /// <para>
    /// The <c>TAXABLE</c> field uses an inverted boolean: QuickBooks stores whether the customer IS taxable,
    /// while the application stores <c>IsTaxExempt</c>. Therefore <c>TAXABLE = N</c> when <c>IsTaxExempt = true</c>.
    /// </para>
    /// <para>
    /// Returns an empty result (success=false) with an error message when no active customers exist,
    /// so the caller can show a meaningful UI message rather than offering a 0-byte download.
    /// </para>
    /// </summary>
    public async Task<(bool Success, byte[] FileContent, string FileName, string ErrorMessage)> ExportCustomersAsync(int companyId)
    {
        try
        {
            _logger.LogInformation("Exporting customers for company {CompanyId}", companyId);

            // Fetch active customers with pricing tier
            var customers = await _unitOfWork.Customers.FindAsync(
                c => c.CompanyId == companyId && c.IsActive && !c.IsDeleted);

            var customerList = customers.ToList();
            _logger.LogInformation("Found {Count} active customers to export", customerList.Count);

            if (customerList.Count == 0)
            {
                return (false, Array.Empty<byte>(), string.Empty, "No active customers found to export.");
            }

            // Build IIF content
            var sb = new StringBuilder();

            // Header row
            sb.AppendLine(string.Join(TabDelimiter, new[]
            {
                "!CUST", "NAME", "BADDR1", "CITY", "STATE", "ZIP", "COUNTRY",
                "PHONE1", "PHONE2", "EMAIL", "CONT1", "CONT2", "TERMS",
                "TAXABLE", "LIMIT", "NOTE"
            }));

            // Data rows
            foreach (var customer in customerList)
            {
                sb.AppendLine(string.Join(TabDelimiter, new[]
                {
                    "CUST",
                    EscapeIifField(customer.CompanyName),
                    EscapeIifField(customer.Address),
                    EscapeIifField(customer.City),
                    EscapeIifField(customer.State),
                    EscapeIifField(customer.ZipCode),
                    EscapeIifField(customer.Country ?? "USA"),
                    EscapeIifField(customer.Phone),
                    EscapeIifField(customer.MobilePhone),
                    EscapeIifField(customer.Email),
                    EscapeIifField(customer.ContactFirstName),
                    EscapeIifField(customer.ContactLastName),
                    EscapeIifField(customer.PaymentTerms ?? "Net 30"),
                    customer.IsTaxExempt ? "N" : "Y", // Inverted: QuickBooks TAXABLE = not exempt
                    customer.CreditLimit.ToString("F2"),
                    EscapeIifField(customer.GeneralNotes)
                }));
            }

            var fileContent = Encoding.UTF8.GetBytes(sb.ToString());
            var fileName = $"customers_{companyId}_{DateTime.UtcNow:yyyyMMddHHmmss}.iif";

            _logger.LogInformation("Successfully exported {Count} customers to {FileName}", customerList.Count, fileName);

            return (true, fileContent, fileName, string.Empty);
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Error exporting customers for company {CompanyId}", companyId);
            return (false, Array.Empty<byte>(), string.Empty, $"Error exporting customers: {ex.Message}");
        }
    }

    /// <summary>
    /// Exports active catalog items for a company as a QuickBooks Desktop IIF file using the <c>SERV</c>
    /// (Service Item) record type, since powder coating line items are service-based, not physical inventory.
    /// <para>
    /// The <c>HIDDEN</c> field is the inverse of <c>IsActive</c>: <c>HIDDEN=Y</c> means the item is suppressed
    /// in QuickBooks, so only inactive items get <c>HIDDEN=Y</c>.
    /// </para>
    /// <para>
    /// Category names are included in the <c>EXTRA</c> column as a reference comment; QuickBooks Desktop
    /// does not natively support service-item categories in IIF, so this is informational only.
    /// </para>
    /// </summary>
    public async Task<(bool Success, byte[] FileContent, string FileName, string ErrorMessage)> ExportCatalogItemsAsync(int companyId)
    {
        try
        {
            _logger.LogInformation("Exporting catalog items for company {CompanyId}", companyId);

            // Fetch active catalog items with category
            var items = await _unitOfWork.CatalogItems.FindAsync(
                c => c.CompanyId == companyId && c.IsActive && !c.IsDeleted);

            var itemList = items.ToList();
            _logger.LogInformation("Found {Count} active catalog items to export", itemList.Count);

            if (itemList.Count == 0)
            {
                return (false, Array.Empty<byte>(), string.Empty, "No active catalog items found to export.");
            }

            // Load categories for all items
            var categoryIds = itemList.Select(i => i.CategoryId).Distinct().ToList();
            var categories = await _unitOfWork.CatalogCategories.FindAsync(c => categoryIds.Contains(c.Id));
            var categoryDict = categories.ToDictionary(c => c.Id, c => c.Name);

            // Build IIF content
            var sb = new StringBuilder();

            // Header row (SERV = Service item in QuickBooks)
            sb.AppendLine(string.Join(TabDelimiter, new[]
            {
                "!SERV", "NAME", "SALESDESC", "PRICE", "HIDDEN", "EXTRA"
            }));

            // Data rows
            foreach (var item in itemList)
            {
                var categoryName = categoryDict.TryGetValue(item.CategoryId, out var catName) ? catName : "Uncategorized";
                var salesDesc = string.IsNullOrEmpty(item.Description)
                    ? item.Name
                    : $"{item.Name} - {item.Description}";

                sb.AppendLine(string.Join(TabDelimiter, new[]
                {
                    "SERV",
                    EscapeIifField(item.SKU ?? $"ITEM-{item.Id}"),
                    EscapeIifField(salesDesc),
                    item.DefaultPrice.ToString("F2"),
                    item.IsActive ? "N" : "Y", // Inverted: QuickBooks HIDDEN = not active
                    EscapeIifField(categoryName) // Category as reference
                }));
            }

            var fileContent = Encoding.UTF8.GetBytes(sb.ToString());
            var fileName = $"catalog_items_{companyId}_{DateTime.UtcNow:yyyyMMddHHmmss}.iif";

            _logger.LogInformation("Successfully exported {Count} catalog items to {FileName}", itemList.Count, fileName);

            return (true, fileContent, fileName, string.Empty);
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Error exporting catalog items for company {CompanyId}", companyId);
            return (false, Array.Empty<byte>(), string.Empty, $"Error exporting catalog items: {ex.Message}");
        }
    }

    /// <summary>
    /// Exports active customers as a QuickBooks Online (QBO) CSV file.
    /// <para>
    /// QBO uses a different import format from QuickBooks Desktop IIF: comma-delimited with a
    /// <c>*Customer</c> marker column. Billing address is duplicated into shipping address columns
    /// because QBO requires both; powder coating customers typically pick up in person, so the
    /// shipping address is the same as billing.
    /// </para>
    /// <para>
    /// Customer display name priority: <c>CompanyName</c> (if set) → <c>ContactFirstName ContactLastName</c>.
    /// </para>
    /// </summary>
    public async Task<(bool Success, byte[] FileContent, string FileName, string ErrorMessage)> ExportCustomersOnlineAsync(int companyId)
    {
        try
        {
            _logger.LogInformation("Exporting customers for QuickBooks Online (CSV) for company {CompanyId}", companyId);

            var customers = await _unitOfWork.Customers
                .FindAsync(c => c.CompanyId == companyId && c.IsActive);

            if (!customers.Any())
            {
                return (false, Array.Empty<byte>(), string.Empty, "No active customers found to export.");
            }

            // Build CSV content for QuickBooks Online
            var csv = new System.Text.StringBuilder();

            // Header row (QuickBooks Online format)
            csv.AppendLine("*Customer,Name,Company Name,Email,Phone,Mobile,Fax,Website,Billing Address,Billing City,Billing State,Billing ZIP,Billing Country,Shipping Address,Shipping City,Shipping State,Shipping ZIP,Shipping Country,Notes");

            // Data rows
            foreach (var customer in customers.OrderBy(c => c.CompanyName ?? c.ContactLastName))
            {
                var displayName = !string.IsNullOrWhiteSpace(customer.CompanyName)
                    ? customer.CompanyName
                    : $"{customer.ContactFirstName} {customer.ContactLastName}".Trim();

                var companyName = !string.IsNullOrWhiteSpace(customer.CompanyName) ? customer.CompanyName : "";
                var email = customer.Email ?? "";
                var phone = customer.Phone ?? "";
                var mobile = customer.MobilePhone ?? "";
                var address = customer.Address ?? "";
                var city = customer.City ?? "";
                var state = customer.State ?? "";
                var zipCode = customer.ZipCode ?? "";
                var notes = $"Credit Limit: {customer.CreditLimit:C}";

                // Escape quotes and commas in CSV
                displayName = EscapeCsv(displayName);
                companyName = EscapeCsv(companyName);
                email = EscapeCsv(email);
                mobile = EscapeCsv(mobile);
                address = EscapeCsv(address);
                notes = EscapeCsv(notes);

                // Columns: *Customer,Name,Company Name,Email,Phone,Mobile,Fax,Website,Billing Address,Billing City,Billing State,Billing ZIP,Billing Country,Shipping Address,Shipping City,Shipping State,Shipping ZIP,Shipping Country,Notes
                csv.AppendLine($"Customer,{displayName},{companyName},{email},{phone},{mobile},,,{address},{city},{state},{zipCode},USA,{address},{city},{state},{zipCode},USA,{notes}");
            }

            var fileName = $"Customers_QBO_{DateTime.Now:yyyyMMdd_HHmmss}.csv";
            var fileContent = System.Text.Encoding.UTF8.GetBytes(csv.ToString());

            _logger.LogInformation("Successfully exported {Count} customers to QuickBooks Online CSV", customers.Count());
            return (true, fileContent, fileName, string.Empty);
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Error exporting customers for QuickBooks Online");
            return (false, Array.Empty<byte>(), string.Empty, $"Error exporting customers: {ex.Message}");
        }
    }

    /// <summary>
    /// Exports active catalog items as a QuickBooks Online (QBO) CSV file using the <c>ServiceItem</c> row marker.
    /// <para>
    /// All items default to non-taxable (<c>No</c>) because powder coating services are subject to
    /// location-specific sales tax rules that QBO manages through its own tax settings; presetting
    /// taxable=Yes would cause double-taxation for customers who are already tax-exempt.
    /// </para>
    /// <para>
    /// Eager-loads <c>Category</c> and <c>Category.ParentCategory</c> to support QBO's hierarchical
    /// category display without additional per-item round-trips.
    /// </para>
    /// </summary>
    public async Task<(bool Success, byte[] FileContent, string FileName, string ErrorMessage)> ExportCatalogItemsOnlineAsync(int companyId)
    {
        try
        {
            _logger.LogInformation("Exporting catalog items for QuickBooks Online (CSV) for company {CompanyId}", companyId);

            var catalogItems = await _unitOfWork.CatalogItems
                .GetAllAsync(false,
                    ci => ci.Category,
                    ci => ci.Category.ParentCategory);

            var activeItems = catalogItems
                .Where(ci => ci.CompanyId == companyId && ci.IsActive)
                .ToList();

            if (!activeItems.Any())
            {
                return (false, Array.Empty<byte>(), string.Empty, "No active catalog items found to export.");
            }

            // Build CSV content for QuickBooks Online
            var csv = new System.Text.StringBuilder();

            // Header row (QuickBooks Online format for Service items)
            csv.AppendLine("*ServiceItem,Name,Description,Price,SKU,Category,Taxable,Income Account");

            // Data rows
            foreach (var item in activeItems.OrderBy(i => i.SKU))
            {
                var name = EscapeCsv(item.Name);
                var description = EscapeCsv(item.Description ?? "");
                var price = item.DefaultPrice.ToString("F2");
                var sku = EscapeCsv(item.SKU ?? "");
                var category = item.Category != null ? EscapeCsv(item.Category.Name) : "";
                var taxable = "No"; // Default to non-taxable

                csv.AppendLine($"ServiceItem,{name},{description},{price},{sku},{category},{taxable},Sales");
            }

            var fileName = $"CatalogItems_QBO_{DateTime.Now:yyyyMMdd_HHmmss}.csv";
            var fileContent = System.Text.Encoding.UTF8.GetBytes(csv.ToString());

            _logger.LogInformation("Successfully exported {Count} catalog items to QuickBooks Online CSV", activeItems.Count);
            return (true, fileContent, fileName, string.Empty);
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Error exporting catalog items for QuickBooks Online");
            return (false, Array.Empty<byte>(), string.Empty, $"Error exporting catalog items: {ex.Message}");
        }
    }

    /// <summary>
    /// Escapes a field value for RFC 4180-compliant CSV output.
    /// Wraps the value in double-quotes and doubles any embedded double-quotes when the value
    /// contains a comma, double-quote, or newline — the three characters that would otherwise
    /// break CSV parsing.
    /// </summary>
    private string EscapeCsv(string value)
    {
        if (string.IsNullOrEmpty(value))
            return string.Empty;

        // If value contains comma, quote, or newline, wrap in quotes and escape quotes
        if (value.Contains(",") || value.Contains("\"") || value.Contains("\n"))
        {
            return $"\"{value.Replace("\"", "\"\"")}\"";
        }

        return value;
    }

    #endregion

    #region Import Methods

    /// <summary>
    /// Dispatches a customer import file to the appropriate parser based on file extension.
    /// <para>
    /// Supported formats:
    /// <list type="bullet">
    ///   <item><description><c>.xls</c> / <c>.xlsx</c> — QuickBooks Online customer export (Excel)</description></item>
    ///   <item><description><c>.iif</c> / <c>.txt</c> — QuickBooks Desktop IIF export</description></item>
    /// </list>
    /// Returns an error (not an exception) for unsupported extensions so the UI can render
    /// a user-friendly message without a 500 response.
    /// </para>
    /// </summary>
    public async Task<ImportResultDto> ImportCustomersAsync(IFormFile file, int companyId, string userId)
    {
        var result = new ImportResultDto();

        try
        {
            _logger.LogInformation("Importing customers from file {FileName} for company {CompanyId}", file.FileName, companyId);

            // Detect format based on file extension
            var extension = Path.GetExtension(file.FileName).ToLowerInvariant();

            if (extension == ".xls" || extension == ".xlsx")
            {
                return await ImportCustomersFromExcelAsync(file, companyId, userId);
            }
            else if (extension == ".iif" || extension == ".txt")
            {
                return await ImportCustomersFromIifAsync(file, companyId, userId);
            }
            else
            {
                result.Success = false;
                result.Errors.Add(new ImportErrorDto
                {
                    LineNumber = 0,
                    ErrorMessage = $"Unsupported file format: {extension}. Supported formats: .iif, .txt (QuickBooks Desktop), .xls, .xlsx (QuickBooks Online)"
                });
                return result;
            }
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Error importing customers from file {FileName}", file.FileName);
            result.Success = false;
            result.Errors.Add(new ImportErrorDto
            {
                LineNumber = 0,
                ErrorMessage = $"Import failed: {ex.Message}"
            });
        }

        return result;
    }

    /// <summary>
    /// Parses a QuickBooks Desktop IIF file and upserts customers for the given company.
    /// <para>
    /// IIF files may contain multiple sections (e.g. <c>!HDR</c>, <c>!CUSTNAMEDICT</c>) before the
    /// <c>!CUST</c> section. The parser scans for the first line that starts with exactly
    /// <c>"!CUST\t"</c> (tab after CUST) to avoid false-matching <c>!CUSTNAMEDICT</c>.
    /// </para>
    /// <para>
    /// Customer identity is matched by <c>CompanyName</c> within the tenant. On a match the
    /// existing record is updated; otherwise a new one is created. Both paths run inside a
    /// single database transaction via <c>ExecuteInTransactionAsync</c> so a mid-import failure
    /// rolls back all rows rather than leaving partial data.
    /// </para>
    /// <para>
    /// Company name is resolved with a three-tier fallback:
    /// <c>COMPANYNAME</c> → <c>NAME</c> → <c>FIRSTNAME + LASTNAME</c>.
    /// </para>
    /// </summary>
    private async Task<ImportResultDto> ImportCustomersFromIifAsync(IFormFile file, int companyId, string userId)
    {
        var result = new ImportResultDto();

        try
        {
            // Validate file
            var validation = await ValidateCustomerFileAsync(file);
            if (!validation.IsValid)
            {
                result.Success = false;
                result.Errors = validation.Errors;
                return result;
            }

            // Read file content
            using var reader = new StreamReader(file.OpenReadStream(), Encoding.UTF8);
            var allLines = new List<string>();
            while (!reader.EndOfStream)
            {
                var line = await reader.ReadLineAsync();
                if (!string.IsNullOrWhiteSpace(line))
                    allLines.Add(line);
            }

            if (allLines.Count < 2)
            {
                result.Success = false;
                result.Errors.Add(new ImportErrorDto
                {
                    LineNumber = 0,
                    ErrorMessage = "File must contain at least a header row and one data row"
                });
                return result;
            }

            // Find the !CUST header line (skip QuickBooks metadata sections like !HDR, !CUSTNAMEDICT, etc.)
            int headerIndex = -1;
            for (int i = 0; i < allLines.Count; i++)
            {
                var line = allLines[i].Trim();

                // Remove UTF-8 BOM if present
                if (line.Length > 0 && line[0] == '\uFEFF')
                {
                    line = line.Substring(1);
                }

                // Match exactly "!CUST" followed by tab (not !CUSTNAMEDICT or other variants)
                if (line.StartsWith("!CUST" + TabDelimiter, StringComparison.OrdinalIgnoreCase))
                {
                    headerIndex = i;
                    break;
                }
            }

            if (headerIndex == -1)
            {
                result.Success = false;
                result.Errors.Add(new ImportErrorDto
                {
                    LineNumber = 0,
                    ErrorMessage = "Could not find customer data section (!CUST) in file. This may not be a QuickBooks customer export."
                });
                return result;
            }

            // Extract only the customer section (from !CUST onwards)
            var lines = allLines.Skip(headerIndex).ToList();
            var headerLine = lines[0].Trim();

            // Remove UTF-8 BOM if present
            if (headerLine.Length > 0 && headerLine[0] == '\uFEFF')
            {
                headerLine = headerLine.Substring(1);
            }

            var headers = headerLine.Split(TabDelimiter);
            result.TotalRecords = lines.Count - 1; // Exclude header

            _logger.LogInformation("Found customer header at line {HeaderIndex} with {FieldCount} fields",
                headerIndex + 1, headers.Length);

            await _unitOfWork.ExecuteInTransactionAsync(async () =>
            {
                result.ImportedCount = 0;
                result.UpdatedCount = 0;
                result.SkippedCount = 0;
                result.Errors.Clear();

                // Process data rows
                for (int i = 1; i < lines.Count; i++)
                {
                    var lineNumber = i + 1;
                    var line = lines[i];

                    try
                    {
                        var fields = line.Split(TabDelimiter);

                        // Skip non-CUST records
                        if (fields.Length > 0 && fields[0] != "CUST")
                        {
                            result.SkippedCount++;
                            continue;
                        }

                        // Build company name using same logic as CreateCustomerFromIif
                        var companyName = GetField(fields, headers, "COMPANYNAME");
                        if (string.IsNullOrWhiteSpace(companyName))
                        {
                            companyName = GetField(fields, headers, "NAME");
                            if (string.IsNullOrWhiteSpace(companyName))
                            {
                                var firstName = GetField(fields, headers, "FIRSTNAME");
                                var lastName = GetField(fields, headers, "LASTNAME");
                                companyName = $"{firstName} {lastName}".Trim();
                            }
                        }

                        if (string.IsNullOrWhiteSpace(companyName))
                        {
                            result.Errors.Add(new ImportErrorDto
                            {
                                LineNumber = lineNumber,
                                RecordName = GetField(fields, headers, "NAME"),
                                FieldName = "COMPANYNAME/NAME",
                                ErrorMessage = "Company name is required (COMPANYNAME, NAME, or FIRSTNAME+LASTNAME)"
                            });
                            result.SkippedCount++;
                            continue;
                        }

                        // Check if customer exists
                        var existingCustomer = await _unitOfWork.Customers.FirstOrDefaultAsync(
                            c => c.CompanyId == companyId && c.CompanyName == companyName);

                        if (existingCustomer != null)
                        {
                            // Update existing customer
                            UpdateCustomerFromIif(existingCustomer, fields, headers, userId);
                            await _unitOfWork.Customers.UpdateAsync(existingCustomer);
                            result.UpdatedCount++;
                            _logger.LogDebug("Updated customer: {CompanyName}", companyName);
                        }
                        else
                        {
                            // Create new customer
                            var newCustomer = CreateCustomerFromIif(fields, headers, companyId, userId);
                            await _unitOfWork.Customers.AddAsync(newCustomer);
                            result.ImportedCount++;
                            _logger.LogDebug("Created new customer: {CompanyName}", companyName);
                        }
                    }
                    catch (Exception ex)
                    {
                        _logger.LogWarning(ex, "Error processing line {LineNumber}", lineNumber);
                        result.Errors.Add(new ImportErrorDto
                        {
                            LineNumber = lineNumber,
                            ErrorMessage = ex.Message
                        });
                        result.SkippedCount++;
                    }
                }

                // Save changes
                await _unitOfWork.CompleteAsync();

                result.Success = true;
                _logger.LogInformation("Successfully imported customers: {Summary}", result.Summary);
            });
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Error importing customers from file {FileName}", file.FileName);
            result.Success = false;
            result.Errors.Add(new ImportErrorDto
            {
                LineNumber = 0,
                ErrorMessage = $"Import failed: {ex.Message}"
            });
        }

        return result;
    }

    /// <summary>
    /// Parses a QuickBooks Online Excel export and upserts customers for the given company.
    /// <para>
    /// QBO exports column headers like "Company name", "Street Address", "Open balance" — these differ
    /// from the Desktop IIF field names (<c>COMPANYNAME</c>, <c>BADDR1</c>, <c>LIMIT</c>).
    /// <see cref="GetColumnValue"/> handles missing columns gracefully.
    /// </para>
    /// <para>
    /// Duplicate email detection is done in-memory (via a <c>HashSet</c>) before hitting the database
    /// so that two rows with the same email in the same file are caught without needing a DB round-trip
    /// for each row. The set is declared inside the transaction lambda so retries start clean.
    /// </para>
    /// <para>
    /// <c>IsCommercial</c> is inferred: customers with a non-empty <c>Company name</c> column are
    /// treated as commercial (B2B); individual-name-only rows are treated as non-commercial.
    /// </para>
    /// </summary>
    private async Task<ImportResultDto> ImportCustomersFromExcelAsync(IFormFile file, int companyId, string userId)
    {
        var result = new ImportResultDto();

        try
        {
            System.Text.Encoding.RegisterProvider(System.Text.CodePagesEncodingProvider.Instance);

            using var stream = file.OpenReadStream();
            using var reader = ExcelReaderFactory.CreateReader(stream);

            // Read as DataSet
            var dataSet = reader.AsDataSet(new ExcelDataSetConfiguration()
            {
                ConfigureDataTable = (_) => new ExcelDataTableConfiguration()
                {
                    UseHeaderRow = true
                }
            });

            if (dataSet.Tables.Count == 0)
            {
                result.Success = false;
                result.Errors.Add(new ImportErrorDto
                {
                    LineNumber = 0,
                    ErrorMessage = "Excel file contains no worksheets"
                });
                return result;
            }

            var table = dataSet.Tables[0];
            result.TotalRecords = table.Rows.Count;

            _logger.LogInformation("Found {RowCount} customer records in Excel file", table.Rows.Count);

            await _unitOfWork.ExecuteInTransactionAsync(async () =>
            {
                // Track emails to detect duplicates within the file — declared inside so retries start clean
                var processedEmails = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
                result.ImportedCount = 0;
                result.UpdatedCount = 0;
                result.SkippedCount = 0;
                result.Errors.Clear();

                for (int i = 0; i < table.Rows.Count; i++)
                {
                    var row = table.Rows[i];
                    var lineNumber = i + 2; // +2 because row 1 is header, and we're 0-indexed

                    try
                    {
                        // Extract fields from Excel columns (QuickBooks Online format)
                        var name = GetColumnValue(row, "Name");
                        var companyName = GetColumnValue(row, "Company name");
                        var streetAddress = GetColumnValue(row, "Street Address");
                        var city = GetColumnValue(row, "City");
                        var state = GetColumnValue(row, "State");
                        var country = GetColumnValue(row, "Country");
                        var zip = GetColumnValue(row, "Zip");
                        var phone = GetColumnValue(row, "Phone");
                        var email = GetColumnValue(row, "Email");
                        var openBalance = GetColumnValue(row, "Open balance");

                        // Use Company name if provided, otherwise use Name
                        var finalCompanyName = string.IsNullOrWhiteSpace(companyName) ? name : companyName;

                        if (string.IsNullOrWhiteSpace(finalCompanyName))
                        {
                            result.Errors.Add(new ImportErrorDto
                            {
                                LineNumber = lineNumber,
                                RecordName = name,
                                FieldName = "Name/Company name",
                                ErrorMessage = "Name or Company name is required"
                            });
                            result.SkippedCount++;
                            continue;
                        }

                        // Convert empty email to null
                        if (string.IsNullOrWhiteSpace(email))
                            email = null;

                        // Check for duplicate email within this import
                        if (!string.IsNullOrEmpty(email))
                        {
                            if (processedEmails.Contains(email))
                            {
                                result.Errors.Add(new ImportErrorDto
                                {
                                    LineNumber = lineNumber,
                                    RecordName = finalCompanyName,
                                    FieldName = "Email",
                                    ErrorMessage = $"Duplicate email address '{email}' in import file. Only the first occurrence will be imported."
                                });
                                result.SkippedCount++;
                                continue;
                            }
                            processedEmails.Add(email);
                        }

                        // Check if customer already exists (by company name)
                        var existing = await _unitOfWork.Customers.FindAsync(
                            c => c.CompanyName == finalCompanyName && c.CompanyId == companyId
                        );
                        var existingCustomer = existing.FirstOrDefault();

                        if (existingCustomer != null)
                        {
                            // Update existing customer
                            existingCustomer.Address = streetAddress;
                            existingCustomer.City = city;
                            existingCustomer.State = state;
                            existingCustomer.ZipCode = zip;
                            existingCustomer.Country = string.IsNullOrWhiteSpace(country) ? "USA" : country;
                            existingCustomer.Phone = phone;
                            existingCustomer.Email = email;
                            existingCustomer.CurrentBalance = ParseDecimal(openBalance);
                            existingCustomer.UpdatedBy = userId;
                            existingCustomer.UpdatedAt = DateTime.UtcNow;

                            await _unitOfWork.Customers.UpdateAsync(existingCustomer);
                            result.UpdatedCount++;
                        }
                        else
                        {
                            // Create new customer
                            var newCustomer = new Customer
                            {
                                CompanyId = companyId,
                                CompanyName = finalCompanyName,
                                Address = streetAddress,
                                City = city,
                                State = state,
                                ZipCode = zip,
                                Country = string.IsNullOrWhiteSpace(country) ? "USA" : country,
                                Phone = phone,
                                Email = email,
                                ContactFirstName = string.Empty,
                                ContactLastName = string.Empty,
                                PaymentTerms = "Net 30",
                                CurrentBalance = ParseDecimal(openBalance),
                                CreditLimit = 0,
                                IsCommercial = !string.IsNullOrWhiteSpace(companyName),
                                IsActive = true,
                                CreatedBy = userId,
                                CreatedAt = DateTime.UtcNow
                            };

                            await _unitOfWork.Customers.AddAsync(newCustomer);
                            result.ImportedCount++;
                        }
                    }
                    catch (Exception ex)
                    {
                        _logger.LogError(ex, "Error processing row {LineNumber}", lineNumber);
                        result.Errors.Add(new ImportErrorDto
                        {
                            LineNumber = lineNumber,
                            ErrorMessage = $"Error processing record: {ex.Message}"
                        });
                        result.SkippedCount++;
                    }
                }

                // Save all changes
                await _unitOfWork.CompleteAsync();

                result.Success = true;

                _logger.LogInformation("Successfully imported customers from Excel: {Summary}", result.Summary);
            });
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Error importing customers from Excel file {FileName}", file.FileName);
            result.Success = false;
            result.Errors.Add(new ImportErrorDto
            {
                LineNumber = 0,
                ErrorMessage = $"Import failed: {ex.Message}"
            });
        }

        return result;
    }

    /// <summary>
    /// Safely reads a named column from an Excel <see cref="System.Data.DataRow"/>.
    /// Returns <see cref="string.Empty"/> rather than throwing when the column does not exist
    /// (QBO export layouts vary by account plan and settings), so callers can treat optional
    /// columns uniformly without per-column null checks.
    /// </summary>
    private string GetColumnValue(System.Data.DataRow row, string columnName)
    {
        try
        {
            if (row.Table.Columns.Contains(columnName) && row[columnName] != DBNull.Value)
            {
                return row[columnName]?.ToString() ?? string.Empty;
            }
        }
        catch
        {
            // Column doesn't exist or error reading
        }
        return string.Empty;
    }

    /// <summary>
    /// Dispatches a catalog-item import file to the appropriate parser based on file extension.
    /// <para>
    /// Supported formats:
    /// <list type="bullet">
    ///   <item><description><c>.xls</c> / <c>.xlsx</c> — QuickBooks Online "Products and Services" Excel export</description></item>
    ///   <item><description><c>.csv</c> — QuickBooks Online CSV export</description></item>
    ///   <item><description><c>.iif</c> / <c>.txt</c> — QuickBooks Desktop IIF export (INVITEM records)</description></item>
    /// </list>
    /// Validation (file size, extension) is run before routing so all three parsers can assume
    /// a valid, non-empty file.
    /// </para>
    /// </summary>
    public async Task<ImportResultDto> ImportCatalogItemsAsync(IFormFile file, int companyId, string userId)
    {
        _logger.LogInformation("Importing catalog items from file {FileName} for company {CompanyId}", file.FileName, companyId);

        // Validate file
        var validation = await ValidateCatalogItemFileAsync(file);
        if (!validation.IsValid)
        {
            return new ImportResultDto
            {
                Success = false,
                Errors = validation.Errors
            };
        }

        // Route to appropriate import method based on file extension
        var extension = Path.GetExtension(file.FileName).ToLowerInvariant();
        if (extension == ".xls" || extension == ".xlsx")
        {
            return await ImportCatalogItemsFromExcelAsync(file, companyId, userId);
        }
        else if (extension == ".csv")
        {
            return await ImportCatalogItemsFromQboCsvAsync(file, companyId, userId);
        }
        else
        {
            return await ImportCatalogItemsFromIifAsync(file, companyId, userId);
        }
    }

    /// <summary>
    /// Parses a QuickBooks Desktop IIF file and upserts catalog items for the given company.
    /// <para>
    /// QuickBooks exports all item types as <c>INVITEM</c> records with an <c>INVITEMTYPE</c> discriminator.
    /// Only <c>SERV</c> (service) items are imported because powder coating line items are services,
    /// not physical inventory. Types like <c>DISC</c>, <c>PMT</c>, <c>GRP</c>, and <c>OTHC</c>
    /// are QuickBooks-internal and have no equivalent in this system.
    /// </para>
    /// <para>
    /// QuickBooks uses a colon-delimited hierarchy in the <c>NAME</c> field (e.g. <c>"Cerakote:Auto:Item"</c>)
    /// to represent item groups. The last segment is the item name; all preceding segments become the
    /// category path via <see cref="GetOrCreateCategoryFromSkuHierarchy"/>.
    /// </para>
    /// <para>
    /// Items without a description (<c>DESC</c> empty) are silently skipped because they are
    /// QuickBooks group/header markers, not actual billable items.
    /// </para>
    /// </summary>
    private async Task<ImportResultDto> ImportCatalogItemsFromIifAsync(IFormFile file, int companyId, string userId)
    {
        var result = new ImportResultDto();

        try
        {
            // Read file content
            using var reader = new StreamReader(file.OpenReadStream(), Encoding.UTF8);
            var allLines = new List<string>();
            while (!reader.EndOfStream)
            {
                var line = await reader.ReadLineAsync();
                if (!string.IsNullOrWhiteSpace(line))
                    allLines.Add(line);
            }

            if (allLines.Count < 2)
            {
                result.Success = false;
                result.Errors.Add(new ImportErrorDto
                {
                    LineNumber = 0,
                    ErrorMessage = "File must contain at least a header row and one data row"
                });
                return result;
            }

            // Find the !INVITEM header line (QuickBooks exports service items as INVITEM with INVITEMTYPE=SERV)
            int headerIndex = -1;
            for (int i = 0; i < allLines.Count; i++)
            {
                var line = allLines[i].Trim();

                // Remove UTF-8 BOM if present
                if (line.Length > 0 && line[0] == '\uFEFF')
                {
                    line = line.Substring(1);
                }

                // Match exactly "!INVITEM" followed by tab
                if (line.StartsWith("!INVITEM" + TabDelimiter, StringComparison.OrdinalIgnoreCase))
                {
                    headerIndex = i;
                    break;
                }
            }

            if (headerIndex == -1)
            {
                result.Success = false;
                result.Errors.Add(new ImportErrorDto
                {
                    LineNumber = 0,
                    ErrorMessage = "Could not find items data section (!INVITEM) in file. This may not be a QuickBooks items export."
                });
                return result;
            }

            // Extract only the items section (from !INVITEM onwards)
            var lines = allLines.Skip(headerIndex).ToList();
            var headerLine = lines[0].Trim();

            // Remove UTF-8 BOM if present (again, just in case)
            if (headerLine.Length > 0 && headerLine[0] == '\uFEFF')
            {
                headerLine = headerLine.Substring(1);
            }

            var headers = headerLine.Split(TabDelimiter);
            result.TotalRecords = lines.Count - 1; // Exclude header

            await _unitOfWork.ExecuteInTransactionAsync(async () =>
            {
                result.ImportedCount = 0;
                result.UpdatedCount = 0;
                result.SkippedCount = 0;
                result.Errors.Clear();

                // Process data rows
                for (int i = 1; i < lines.Count; i++)
                {
                    var lineNumber = i + 1;
                    var line = lines[i];

                    try
                    {
                        var fields = line.Split(TabDelimiter);

                        // Skip non-INVITEM records (ENDGRP, second header rows, etc.) — QB structural, not items
                        if (fields.Length > 0 && fields[0] != "INVITEM")
                        {
                            result.TotalRecords--;
                            continue;
                        }

                        // Only import service items (INVITEMTYPE=SERV) — DISC, OTHC, GRP, PMT, etc. are QB-only types
                        var itemType = GetField(fields, headers, "INVITEMTYPE");
                        if (!string.Equals(itemType, "SERV", StringComparison.OrdinalIgnoreCase))
                        {
                            result.TotalRecords--;
                            continue;
                        }

                        var sku = GetField(fields, headers, "NAME");
                        if (string.IsNullOrWhiteSpace(sku))
                        {
                            result.Errors.Add(new ImportErrorDto
                            {
                                LineNumber   = lineNumber,
                                FieldName    = "NAME",
                                ErrorMessage = "SKU is required"
                            });
                            result.SkippedCount++;
                            continue;
                        }

                        // Category/group markers have a NAME but no DESC — silently exclude
                        var desc = GetField(fields, headers, "DESC");
                        if (string.IsNullOrWhiteSpace(desc))
                        {
                            result.TotalRecords--;
                            continue;
                        }

                        // Parse SKU hierarchy and get/create the appropriate category
                        var categoryId = await GetOrCreateCategoryFromSkuHierarchy(sku, companyId, userId);

                        // Get the item name from DESC to check for duplicates
                        var itemName = desc;
                        if (!string.IsNullOrWhiteSpace(desc) && desc.Contains(" - "))
                        {
                            var parts = desc.Split(new[] { " - " }, 2, StringSplitOptions.None);
                            itemName = parts[0];
                        }

                        // Check if item exists by Name + CategoryId (SKU is not used for QuickBooks imports)
                        var existingItem = await _unitOfWork.CatalogItems.FirstOrDefaultAsync(
                            c => c.CompanyId == companyId && c.Name == itemName && c.CategoryId == categoryId);

                        if (existingItem != null)
                        {
                            // Update existing item (update category too in case hierarchy changed)
                            existingItem.CategoryId = categoryId;
                            UpdateCatalogItemFromIif(existingItem, fields, headers, userId);
                            await _unitOfWork.CatalogItems.UpdateAsync(existingItem);
                            result.UpdatedCount++;
                            _logger.LogDebug("Updated catalog item: {SKU}", sku);
                        }
                        else
                        {
                            // Create new item with parsed category
                            var newItem = CreateCatalogItemFromIif(fields, headers, companyId, categoryId, userId);
                            await _unitOfWork.CatalogItems.AddAsync(newItem);
                            result.ImportedCount++;
                            _logger.LogDebug("Created new catalog item: {SKU}", sku);
                        }
                    }
                    catch (Exception ex)
                    {
                        _logger.LogWarning(ex, "Error processing line {LineNumber}", lineNumber);
                        result.Errors.Add(new ImportErrorDto
                        {
                            LineNumber = lineNumber,
                            ErrorMessage = ex.Message
                        });
                        result.SkippedCount++;
                    }
                }

                // Save changes
                await _unitOfWork.CompleteAsync();

                result.Success = true;
                _logger.LogInformation("Successfully imported catalog items: {Summary}", result.Summary);
            });
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Error importing catalog items from file {FileName}", file.FileName);
            result.Success = false;
            result.Errors.Add(new ImportErrorDto
            {
                LineNumber = 0,
                ErrorMessage = $"Import failed: {ex.Message}"
            });
        }

        return result;
    }

    /// <summary>
    /// Parses a QuickBooks Online "Products and Services" Excel export and upserts catalog items.
    /// <para>
    /// QBO exports variant product hierarchies with a <c>"Single,parent or variant?"</c> column.
    /// Only <c>Single</c> and <c>Parent</c> rows are imported; child <c>variant</c> rows are skipped
    /// because they share a name with the parent but differ only in attributes that have no direct
    /// equivalent in the catalog item model.
    /// </para>
    /// <para>
    /// Supported item types: Service, Inventory, Non-inventory. Bundle and Group types are skipped
    /// because their composite pricing cannot be represented as a single catalog item price.
    /// </para>
    /// <para>
    /// In-file duplicate detection uses a <c>HashSet</c> keyed on <c>itemName|categoryId</c>
    /// (declared inside the transaction lambda so retries reset it).
    /// </para>
    /// </summary>
    private async Task<ImportResultDto> ImportCatalogItemsFromExcelAsync(IFormFile file, int companyId, string userId)
    {
        var result = new ImportResultDto();

        try
        {
            System.Text.Encoding.RegisterProvider(System.Text.CodePagesEncodingProvider.Instance);

            using var stream = file.OpenReadStream();
            using var reader = ExcelReaderFactory.CreateReader(stream);

            var dataSet = reader.AsDataSet(new ExcelDataSetConfiguration()
            {
                ConfigureDataTable = (_) => new ExcelDataTableConfiguration()
                {
                    UseHeaderRow = true
                }
            });

            if (dataSet.Tables.Count == 0)
            {
                result.Success = false;
                result.Errors.Add(new ImportErrorDto { LineNumber = 0, ErrorMessage = "Excel file contains no worksheets" });
                return result;
            }

            var table = dataSet.Tables[0];
            result.TotalRecords = table.Rows.Count;
            _logger.LogInformation("Found {RowCount} catalog item records in Excel file", table.Rows.Count);

            await _unitOfWork.ExecuteInTransactionAsync(async () =>
            {
                // Declared inside so retries start clean
                var processedItems = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
                result.ImportedCount = 0;
                result.UpdatedCount = 0;
                result.SkippedCount = 0;
                result.Errors.Clear();

                for (int i = 0; i < table.Rows.Count; i++)
                {
                    var row = table.Rows[i];
                    var lineNumber = i + 2;

                    try
                    {
                        // QuickBooks Online column names (QBO Excel export)
                        var qbName = GetColumnValue(row, "Product/Service Name");
                        var itemType = GetColumnValue(row, "Item type");
                        var categoryName = GetColumnValue(row, "Category");
                        var salesDescription = GetColumnValue(row, "Sales Description");
                        var price = GetColumnValue(row, "Price");
                        var sku = GetColumnValue(row, "SKU");
                        var variantType = GetColumnValue(row, "Single,parent or variant?");

                        // Skip child variants — only import Singles and Parents
                        if (string.Equals(variantType, "variant", StringComparison.OrdinalIgnoreCase))
                        {
                            result.SkippedCount++;
                            continue;
                        }

                        // Skip unsupported item types (bundles, groups, etc.)
                        if (!string.IsNullOrWhiteSpace(itemType) &&
                            !string.Equals(itemType, "Service", StringComparison.OrdinalIgnoreCase) &&
                            !string.Equals(itemType, "Inventory", StringComparison.OrdinalIgnoreCase) &&
                            !string.Equals(itemType, "Non-inventory", StringComparison.OrdinalIgnoreCase))
                        {
                            result.SkippedCount++;
                            continue;
                        }

                        if (string.IsNullOrWhiteSpace(qbName))
                        {
                            result.Errors.Add(new ImportErrorDto { LineNumber = lineNumber, RecordName = qbName, FieldName = "Product/Service Name", ErrorMessage = "Product/Service Name is required" });
                            result.SkippedCount++;
                            continue;
                        }

                        // Get/create flat category from the Category column
                        var categoryId = string.IsNullOrWhiteSpace(categoryName)
                            ? await GetOrCreateDefaultCategory(companyId, userId)
                            : await GetOrCreateCategoryByName(categoryName, companyId, userId);

                        // Use Product/Service Name as the item name; Sales Description as description
                        var itemName = qbName;
                        var itemDescription = salesDescription;

                        // Check for duplicates by name + category
                        var duplicateKey = $"{itemName}|{categoryId}";
                        if (processedItems.Contains(duplicateKey))
                        {
                            result.Errors.Add(new ImportErrorDto { LineNumber = lineNumber, RecordName = itemName, FieldName = "Product/Service Name", ErrorMessage = $"Duplicate item '{itemName}' in category" });
                            result.SkippedCount++;
                            continue;
                        }
                        processedItems.Add(duplicateKey);

                        var existingItem = await _unitOfWork.CatalogItems.FirstOrDefaultAsync(c => c.CompanyId == companyId && c.Name == itemName && c.CategoryId == categoryId);

                        if (existingItem != null)
                        {
                            existingItem.CategoryId = categoryId;
                            existingItem.Description = itemDescription;
                            existingItem.DefaultPrice = ParseDecimal(price);
                            if (!string.IsNullOrWhiteSpace(sku)) existingItem.SKU = sku;
                            existingItem.UpdatedBy = userId;
                            existingItem.UpdatedAt = DateTime.UtcNow;
                            result.UpdatedCount++;
                        }
                        else
                        {
                            var newItem = new CatalogItem { CompanyId = companyId, CategoryId = categoryId, SKU = string.IsNullOrWhiteSpace(sku) ? null : sku, Name = itemName, Description = itemDescription, DefaultPrice = ParseDecimal(price), IsActive = true, DisplayOrder = 0, CreatedBy = userId, CreatedAt = DateTime.UtcNow };
                            await _unitOfWork.CatalogItems.AddAsync(newItem);
                            result.ImportedCount++;
                        }
                    }
                    catch (Exception ex)
                    {
                        _logger.LogWarning(ex, "Error processing Excel row {LineNumber}", lineNumber);
                        result.Errors.Add(new ImportErrorDto { LineNumber = lineNumber, ErrorMessage = ex.Message });
                        result.SkippedCount++;
                    }
                }

                await _unitOfWork.CompleteAsync();
                result.Success = true;
                _logger.LogInformation("Successfully imported catalog items from Excel: {Summary}", result.Summary);
            });
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Error importing catalog items from Excel file {FileName}", file.FileName);
            result.Success = false;
            result.Errors.Add(new ImportErrorDto { LineNumber = 0, ErrorMessage = $"Import failed: {ex.Message}" });
        }

        return result;
    }

    /// <summary>
    /// Parses a QuickBooks Online "Products and Services" CSV export and upserts catalog items.
    /// <para>
    /// CSV rows are read entirely into memory before entering the transaction lambda so that the
    /// <see cref="CsvReader"/> stream is not held open across async EF operations (EF async and
    /// CsvHelper are not async-safe to interleave). The in-memory list also allows clean retry
    /// semantics if the transaction is rolled back and re-attempted.
    /// </para>
    /// <para>
    /// Applies the same variant/type filtering as <c>ImportCatalogItemsFromExcelAsync</c>.
    /// </para>
    /// </summary>
    private async Task<ImportResultDto> ImportCatalogItemsFromQboCsvAsync(IFormFile file, int companyId, string userId)
    {
        var result = new ImportResultDto();

        try
        {
            using var stream = file.OpenReadStream();
            using var reader = new System.IO.StreamReader(stream);
            using var csv = new CsvReader(reader, new CsvConfiguration(CultureInfo.InvariantCulture)
            {
                HeaderValidated = null,
                MissingFieldFound = null
            });

            csv.Read();
            csv.ReadHeader();

            // Read all rows into memory before the lambda so retries can reprocess from the beginning
            var csvRows = new List<Dictionary<string, string>>();
            while (csv.Read())
            {
                var csvRow = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
                foreach (var header in csv.HeaderRecord ?? [])
                    csvRow[header] = csv.GetField(header) ?? string.Empty;
                csvRows.Add(csvRow);
            }
            result.TotalRecords = csvRows.Count;

            await _unitOfWork.ExecuteInTransactionAsync(async () =>
            {
                // Declared inside so retries start clean
                var processedItems = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
                result.ImportedCount = 0;
                result.UpdatedCount = 0;
                result.SkippedCount = 0;
                result.TotalRecords = csvRows.Count;
                result.Errors.Clear();

                int lineNumber = 1;
                foreach (var csvRow in csvRows)
                {
                    lineNumber++;
                    try
                    {
                        var itemName    = csvRow.GetValueOrDefault("Product/Service Name", string.Empty);
                        var itemType    = csvRow.GetValueOrDefault("Item type", string.Empty);
                        var categoryName = csvRow.GetValueOrDefault("Category", string.Empty);
                        var sku         = csvRow.GetValueOrDefault("SKU", string.Empty);
                        var price       = csvRow.GetValueOrDefault("Price", string.Empty);
                        var description = csvRow.GetValueOrDefault("Sales Description", string.Empty);
                        var variantType = csvRow.GetValueOrDefault("Single,parent or variant?", string.Empty);

                        // Skip child variants
                        if (string.Equals(variantType, "variant", StringComparison.OrdinalIgnoreCase))
                        {
                            result.SkippedCount++;
                            continue;
                        }

                        // Skip unsupported types
                        if (!string.IsNullOrWhiteSpace(itemType) &&
                            !string.Equals(itemType, "Service", StringComparison.OrdinalIgnoreCase) &&
                            !string.Equals(itemType, "Inventory", StringComparison.OrdinalIgnoreCase) &&
                            !string.Equals(itemType, "Non-inventory", StringComparison.OrdinalIgnoreCase))
                        {
                            result.SkippedCount++;
                            continue;
                        }

                        if (string.IsNullOrWhiteSpace(itemName))
                        {
                            result.Errors.Add(new ImportErrorDto { LineNumber = lineNumber, FieldName = "Product/Service Name", ErrorMessage = "Product/Service Name is required" });
                            result.SkippedCount++;
                            continue;
                        }

                        var categoryId = string.IsNullOrWhiteSpace(categoryName)
                            ? await GetOrCreateDefaultCategory(companyId, userId)
                            : await GetOrCreateCategoryByName(categoryName, companyId, userId);

                        var duplicateKey = $"{itemName}|{categoryId}";
                        if (processedItems.Contains(duplicateKey))
                        {
                            result.Errors.Add(new ImportErrorDto { LineNumber = lineNumber, RecordName = itemName, FieldName = "Product/Service Name", ErrorMessage = $"Duplicate item '{itemName}' in this import file" });
                            result.SkippedCount++;
                            continue;
                        }
                        processedItems.Add(duplicateKey);

                        result.TotalRecords++;

                        var existingItem = await _unitOfWork.CatalogItems.FirstOrDefaultAsync(
                            c => c.CompanyId == companyId && c.Name == itemName && c.CategoryId == categoryId);

                        if (existingItem != null)
                        {
                            existingItem.Description = description;
                            existingItem.DefaultPrice = ParseDecimal(price);
                            if (!string.IsNullOrWhiteSpace(sku)) existingItem.SKU = sku;
                            existingItem.UpdatedBy = userId;
                            existingItem.UpdatedAt = DateTime.UtcNow;
                            result.UpdatedCount++;
                        }
                        else
                        {
                            var newItem = new CatalogItem
                            {
                                CompanyId = companyId,
                                CategoryId = categoryId,
                                SKU = string.IsNullOrWhiteSpace(sku) ? null : sku,
                                Name = itemName,
                                Description = description,
                                DefaultPrice = ParseDecimal(price),
                                IsActive = true,
                                DisplayOrder = 0,
                                CreatedBy = userId,
                                CreatedAt = DateTime.UtcNow
                            };
                            await _unitOfWork.CatalogItems.AddAsync(newItem);
                            result.ImportedCount++;
                        }
                    }
                    catch (Exception ex)
                    {
                        _logger.LogWarning(ex, "Error processing QBO CSV row {LineNumber}", lineNumber);
                        result.Errors.Add(new ImportErrorDto { LineNumber = lineNumber, ErrorMessage = ex.Message });
                        result.SkippedCount++;
                    }
                }

                await _unitOfWork.CompleteAsync();
                result.Success = true;
                _logger.LogInformation("Successfully imported catalog items from QBO CSV: {Summary}", result.Summary);
            });
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Error importing catalog items from QBO CSV file {FileName}", file.FileName);
            result.Success = false;
            result.Errors.Add(new ImportErrorDto { LineNumber = 0, ErrorMessage = $"Import failed: {ex.Message}" });
        }

        return result;
    }

    /// <summary>
    /// Gets or creates a flat (top-level) category by name.
    /// Used for QBO exports where category is a simple name, not a hierarchy.
    /// </summary>
    private async Task<int> GetOrCreateCategoryByName(string name, int companyId, string userId)
    {
        var existing = await _unitOfWork.CatalogCategories.FirstOrDefaultAsync(
            c => c.CompanyId == companyId && c.Name == name && c.ParentCategoryId == null);

        if (existing != null)
            return existing.Id;

        var newCategory = new CatalogCategory
        {
            CompanyId = companyId,
            ParentCategoryId = null,
            Name = name,
            Description = $"Imported from QuickBooks Online",
            DisplayOrder = 0,
            IsActive = true,
            CreatedBy = userId,
            CreatedAt = DateTime.UtcNow
        };

        await _unitOfWork.CatalogCategories.AddAsync(newCategory);
        await _unitOfWork.CompleteAsync();
        _logger.LogInformation("Created category: {Name}", name);
        return newCategory.Id;
    }

    #endregion

    #region Validation Methods

    /// <summary>
    /// Validates a customer import file before any data is written to the database.
    /// <para>
    /// Checks performed (in order):
    /// <list type="number">
    ///   <item><description>File is non-null and non-empty.</description></item>
    ///   <item><description>Extension is in the allowed list (<c>.iif</c>, <c>.txt</c>, <c>.xls</c>, <c>.xlsx</c>, <c>.csv</c>).</description></item>
    ///   <item><description>File size does not exceed 50 MB.</description></item>
    ///   <item><description>For IIF/TXT files only: the first 100 lines contain a <c>!CUST\t</c> header row.</description></item>
    /// </list>
    /// The 100-line scan limit avoids loading the entire file into memory just for format detection
    /// while still handling real QuickBooks exports that include multi-section preambles.
    /// </para>
    /// </summary>
    public async Task<ValidationResultDto> ValidateCustomerFileAsync(IFormFile file)
    {
        var result = new ValidationResultDto { IsValid = true };

        // Check file exists
        if (file == null || file.Length == 0)
        {
            result.IsValid = false;
            result.Errors.Add(new ImportErrorDto
            {
                LineNumber = 0,
                ErrorMessage = "No file provided or file is empty"
            });
            return result;
        }

        // Check file extension
        var extension = Path.GetExtension(file.FileName).ToLowerInvariant();
        if (!AllowedExtensions.Contains(extension))
        {
            result.IsValid = false;
            result.Errors.Add(new ImportErrorDto
            {
                LineNumber = 0,
                ErrorMessage = $"Invalid file extension. Allowed: {string.Join(", ", AllowedExtensions)}"
            });
            return result;
        }

        // Check file size
        if (file.Length > MaxFileSize)
        {
            result.IsValid = false;
            result.Errors.Add(new ImportErrorDto
            {
                LineNumber = 0,
                ErrorMessage = $"File size exceeds maximum allowed size of {MaxFileSize / 1024 / 1024} MB"
            });
            return result;
        }

        // Basic format check - look for !CUST section (may be after QuickBooks headers)
        try
        {
            using var reader = new StreamReader(file.OpenReadStream(), Encoding.UTF8);
            bool foundCustomerSection = false;
            string line;
            int lineNumber = 0;

            while ((line = await reader.ReadLineAsync()) != null && lineNumber < 100) // Check first 100 lines
            {
                lineNumber++;
                line = line.Trim();

                // Remove UTF-8 BOM if present
                if (line.Length > 0 && line[0] == '\uFEFF')
                {
                    line = line.Substring(1);
                }

                // Match exactly "!CUST" followed by tab (not !CUSTNAMEDICT or other variants)
                if (line.StartsWith("!CUST" + TabDelimiter, StringComparison.OrdinalIgnoreCase))
                {
                    foundCustomerSection = true;
                    break;
                }
            }

            if (!foundCustomerSection)
            {
                result.IsValid = false;
                result.Errors.Add(new ImportErrorDto
                {
                    LineNumber = 0,
                    ErrorMessage = "Could not find customer data section (!CUST) in file. This may not be a QuickBooks customer export."
                });
            }
        }
        catch (Exception ex)
        {
            result.IsValid = false;
            result.Errors.Add(new ImportErrorDto
            {
                LineNumber = 0,
                ErrorMessage = $"Error reading file: {ex.Message}"
            });
        }

        return result;
    }

    /// <summary>
    /// Validates a catalog-item import file before any data is written to the database.
    /// <para>
    /// Applies the same size/extension checks as <see cref="ValidateCustomerFileAsync"/>.
    /// Excel (<c>.xls</c>, <c>.xlsx</c>) and CSV (<c>.csv</c>) files skip the content-format scan
    /// because their structure is validated during import (the header row lookup is format-specific).
    /// For IIF/TXT files, the first 100 lines are scanned for a <c>!INVITEM\t</c> header row.
    /// </para>
    /// </summary>
    public async Task<ValidationResultDto> ValidateCatalogItemFileAsync(IFormFile file)
    {
        var result = new ValidationResultDto { IsValid = true };

        // Check file exists
        if (file == null || file.Length == 0)
        {
            result.IsValid = false;
            result.Errors.Add(new ImportErrorDto
            {
                LineNumber = 0,
                ErrorMessage = "No file provided or file is empty"
            });
            return result;
        }

        // Check file extension
        var extension = Path.GetExtension(file.FileName).ToLowerInvariant();
        if (!AllowedExtensions.Contains(extension))
        {
            result.IsValid = false;
            result.Errors.Add(new ImportErrorDto
            {
                LineNumber = 0,
                ErrorMessage = $"Invalid file extension. Allowed: {string.Join(", ", AllowedExtensions)}"
            });
            return result;
        }

        // Check file size
        if (file.Length > MaxFileSize)
        {
            result.IsValid = false;
            result.Errors.Add(new ImportErrorDto
            {
                LineNumber = 0,
                ErrorMessage = $"File size exceeds maximum allowed size of {MaxFileSize / 1024 / 1024} MB"
            });
            return result;
        }

        // Skip format validation for Excel and CSV files (will be validated during import)
        if (extension == ".xls" || extension == ".xlsx" || extension == ".csv")
        {
            return result;
        }

        // Basic format check for IIF files - look for !INVITEM section (QuickBooks exports service items as INVITEM)
        try
        {
            using var reader = new StreamReader(file.OpenReadStream(), Encoding.UTF8);
            bool foundItemSection = false;
            string line;
            int lineNumber = 0;

            while ((line = await reader.ReadLineAsync()) != null && lineNumber < 100) // Check first 100 lines
            {
                lineNumber++;
                line = line.Trim();

                // Remove UTF-8 BOM if present
                if (line.Length > 0 && line[0] == '\uFEFF')
                {
                    line = line.Substring(1);
                }

                // Match exactly "!INVITEM" followed by tab
                if (line.StartsWith("!INVITEM" + TabDelimiter, StringComparison.OrdinalIgnoreCase))
                {
                    foundItemSection = true;
                    break;
                }
            }

            if (!foundItemSection)
            {
                result.IsValid = false;
                result.Errors.Add(new ImportErrorDto
                {
                    LineNumber = 0,
                    ErrorMessage = "Could not find items data section (!INVITEM) in file. This may not be a QuickBooks items export."
                });
            }
        }
        catch (Exception ex)
        {
            result.IsValid = false;
            result.Errors.Add(new ImportErrorDto
            {
                LineNumber = 0,
                ErrorMessage = $"Error reading file: {ex.Message}"
            });
        }

        return result;
    }

    #endregion

    #region Helper Methods

    #region Vendor Export

    /// <summary>
    /// Exports all active vendors for a company as a QuickBooks Desktop IIF file (<c>!VEND</c> / <c>VEND</c> records).
    /// <para>
    /// QuickBooks Desktop IIF uses <c>ADDR1</c> for street address and <c>ADDR2</c> for the
    /// city/state/zip line (formatted as "City, ST Zip"), which differs from the application's
    /// separate <c>City</c>, <c>State</c>, <c>ZipCode</c> fields. The export combines the three
    /// fields into the QB <c>ADDR2</c> format.
    /// </para>
    /// </summary>
    public async Task<(bool Success, byte[] FileContent, string FileName, string ErrorMessage)> ExportVendorsAsync(int companyId)
    {
        try
        {
            _logger.LogInformation("Exporting vendors for company {CompanyId}", companyId);

            var vendors = await _unitOfWork.Vendors.FindAsync(
                v => v.CompanyId == companyId && v.IsActive && !v.IsDeleted);

            var list = vendors.OrderBy(v => v.CompanyName).ToList();

            if (list.Count == 0)
                return (false, Array.Empty<byte>(), string.Empty, "No active vendors found to export.");

            var sb = new StringBuilder();

            sb.AppendLine(string.Join(TabDelimiter, new[]
            {
                "!VEND", "NAME", "PRINTAS", "ADDR1", "ADDR2",
                "PHONE1", "EMAIL", "CONT1", "TERMS", "LIMIT", "TAXID", "NOTE"
            }));

            foreach (var vendor in list)
            {
                // Combine city/state/zip into ADDR2 matching QB format
                var addr2Parts = new List<string>();
                if (!string.IsNullOrWhiteSpace(vendor.City)) addr2Parts.Add(vendor.City);
                var stateZip = $"{vendor.State} {vendor.ZipCode}".Trim();
                if (!string.IsNullOrWhiteSpace(stateZip)) addr2Parts.Add(stateZip);
                var addr2 = addr2Parts.Count > 0 ? string.Join(", ", addr2Parts) : string.Empty;

                sb.AppendLine(string.Join(TabDelimiter, new[]
                {
                    "VEND",
                    EscapeIifField(vendor.CompanyName),
                    EscapeIifField(vendor.CompanyName),
                    EscapeIifField(vendor.Address),
                    EscapeIifField(addr2),
                    EscapeIifField(vendor.Phone),
                    EscapeIifField(vendor.Email),
                    EscapeIifField(vendor.ContactName),
                    EscapeIifField(vendor.PaymentTerms),
                    vendor.CreditLimit.HasValue ? vendor.CreditLimit.Value.ToString("F2") : string.Empty,
                    EscapeIifField(vendor.TaxId),
                    EscapeIifField(vendor.Notes)
                }));
            }

            var fileContent = Encoding.UTF8.GetBytes(sb.ToString());
            var fileName = $"vendors_{companyId}_{DateTime.UtcNow:yyyyMMddHHmmss}.iif";

            _logger.LogInformation("Successfully exported {Count} vendors to {FileName}", list.Count, fileName);
            return (true, fileContent, fileName, string.Empty);
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Error exporting vendors for company {CompanyId}", companyId);
            return (false, Array.Empty<byte>(), string.Empty, $"Error exporting vendors: {ex.Message}");
        }
    }

    #endregion

    #region Vendor Import

    /// <summary>
    /// Parses a QuickBooks Desktop IIF file and upserts vendors for the given company.
    /// <para>
    /// Vendor name is resolved with a four-tier fallback:
    /// <c>COMPANYNAME</c> → <c>PRINTAS</c> → <c>NAME</c> → <c>FIRSTNAME + LASTNAME</c>.
    /// This mirrors how QuickBooks assigns vendor display names depending on whether the vendor is
    /// a business or an individual.
    /// </para>
    /// <para>
    /// Vendors whose <c>HIDDEN</c> field is <c>Y</c> (inactive in QuickBooks) are silently skipped
    /// rather than imported as inactive, because hidden QB vendors are typically merged/deleted
    /// duplicates that should not be brought into the new system.
    /// </para>
    /// </summary>
    public async Task<ImportResultDto> ImportVendorsAsync(IFormFile file, int companyId, string userId)
    {
        var result = new ImportResultDto();

        try
        {
            _logger.LogInformation("Importing vendors from file {FileName} for company {CompanyId}", file.FileName, companyId);

            using var reader = new StreamReader(file.OpenReadStream(), Encoding.UTF8);
            var allLines = new List<string>();
            while (!reader.EndOfStream)
            {
                var line = await reader.ReadLineAsync();
                if (!string.IsNullOrWhiteSpace(line))
                    allLines.Add(line);
            }

            // Find the !VEND header line
            int headerIndex = -1;
            for (int i = 0; i < allLines.Count; i++)
            {
                var line = allLines[i].Trim();
                if (line.Length > 0 && line[0] == '\uFEFF')
                    line = line.Substring(1);

                if (line.StartsWith("!VEND" + TabDelimiter, StringComparison.OrdinalIgnoreCase))
                {
                    headerIndex = i;
                    break;
                }
            }

            if (headerIndex == -1)
            {
                result.Success = false;
                result.Errors.Add(new ImportErrorDto
                {
                    LineNumber = 0,
                    ErrorMessage = "Could not find vendor data section (!VEND) in file. This may not be a QuickBooks vendor export."
                });
                return result;
            }

            var lines = allLines.Skip(headerIndex).ToList();
            var headerLine = lines[0].Trim();
            if (headerLine.Length > 0 && headerLine[0] == '\uFEFF')
                headerLine = headerLine.Substring(1);

            var headers = headerLine.Split(TabDelimiter);
            result.TotalRecords = lines.Count - 1;

            await _unitOfWork.ExecuteInTransactionAsync(async () =>
            {
                result.ImportedCount = 0;
                result.UpdatedCount = 0;
                result.SkippedCount = 0;
                result.Errors.Clear();

                for (int i = 1; i < lines.Count; i++)
                {
                    var lineNumber = i + 1;
                    var line = lines[i];

                    try
                    {
                        var fields = line.Split(TabDelimiter);

                        // Skip non-VEND records
                        if (fields.Length == 0 || fields[0] != "VEND")
                        {
                            result.SkippedCount++;
                            continue;
                        }

                        // Skip hidden/deleted vendors
                        var hidden = GetField(fields, headers, "HIDDEN");
                        if (hidden == "Y")
                        {
                            result.SkippedCount++;
                            continue;
                        }

                        // Resolve company name: COMPANYNAME → NAME → FIRSTNAME + LASTNAME
                        var companyName = GetField(fields, headers, "COMPANYNAME");
                        if (string.IsNullOrWhiteSpace(companyName))
                            companyName = GetField(fields, headers, "PRINTAS");
                        if (string.IsNullOrWhiteSpace(companyName))
                            companyName = GetField(fields, headers, "NAME");
                        if (string.IsNullOrWhiteSpace(companyName))
                        {
                            var fn = GetField(fields, headers, "FIRSTNAME");
                            var ln = GetField(fields, headers, "LASTNAME");
                            companyName = $"{fn} {ln}".Trim();
                        }

                        if (string.IsNullOrWhiteSpace(companyName))
                        {
                            result.Errors.Add(new ImportErrorDto
                            {
                                LineNumber = lineNumber,
                                RecordName = GetField(fields, headers, "NAME"),
                                FieldName = "COMPANYNAME/NAME",
                                ErrorMessage = "Vendor name is required"
                            });
                            result.SkippedCount++;
                            continue;
                        }

                        var existing = await _unitOfWork.Vendors.FirstOrDefaultAsync(
                            v => v.CompanyId == companyId && v.CompanyName == companyName);

                        if (existing != null)
                        {
                            UpdateVendorFromIif(existing, fields, headers, userId);
                            await _unitOfWork.Vendors.UpdateAsync(existing);
                            result.UpdatedCount++;
                        }
                        else
                        {
                            var vendor = CreateVendorFromIif(fields, headers, companyId, userId, companyName);
                            await _unitOfWork.Vendors.AddAsync(vendor);
                            result.ImportedCount++;
                        }
                    }
                    catch (Exception ex)
                    {
                        _logger.LogWarning(ex, "Error processing vendor line {LineNumber}", lineNumber);
                        result.Errors.Add(new ImportErrorDto
                        {
                            LineNumber = lineNumber,
                            ErrorMessage = ex.Message
                        });
                        result.SkippedCount++;
                    }
                }

                await _unitOfWork.CompleteAsync();
                result.Success = true;
                _logger.LogInformation("Vendor import complete: {Summary}", result.Summary);
            });
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Error importing vendors from file {FileName}", file.FileName);
            result.Success = false;
            result.Errors.Add(new ImportErrorDto { LineNumber = 0, ErrorMessage = $"Import failed: {ex.Message}" });
        }

        return result;
    }

    /// <summary>
    /// Constructs a new <see cref="Vendor"/> entity from a parsed IIF data row.
    /// <para>
    /// QuickBooks Desktop packs city/state/zip into <c>ADDR2</c> as a single string in the format
    /// <c>"City, ST Zip"</c>. This method splits on the first comma to extract city, then splits the
    /// remainder on whitespace to extract state (first token) and zip (last token), which handles
    /// both two-character state abbreviations and full state names.
    /// </para>
    /// <para>
    /// Empty email is stored as <c>null</c> rather than an empty string to avoid triggering the
    /// database's unique-email constraint when multiple vendors have no email address on file.
    /// </para>
    /// <para>
    /// Credit limit of 0 is stored as <c>null</c> because QB exports a <c>0</c> when no credit
    /// limit is configured, and the application distinguishes between "no limit" (null) and "limit
    /// is explicitly zero" which would block all purchases.
    /// </para>
    /// </summary>
    private Vendor CreateVendorFromIif(string[] fields, string[] headers, int companyId, string userId, string companyName)
    {
        // Parse contact name: CONT1 is primary contact name in vendor exports
        var contactName = GetField(fields, headers, "CONT1");
        if (string.IsNullOrWhiteSpace(contactName))
        {
            var fn = GetField(fields, headers, "FIRSTNAME");
            var ln = GetField(fields, headers, "LASTNAME");
            contactName = $"{fn} {ln}".Trim();
        }

        // Parse address: ADDR1=street, ADDR2 often "City, State Zip"
        var addr1 = GetField(fields, headers, "ADDR1");
        var addr2 = GetField(fields, headers, "ADDR2");
        var city = string.Empty;
        var state = string.Empty;
        var zip = string.Empty;

        if (!string.IsNullOrWhiteSpace(addr2))
        {
            // Try "City, ST Zip" format
            var commaIdx = addr2.IndexOf(',');
            if (commaIdx > 0)
            {
                city = addr2.Substring(0, commaIdx).Trim();
                var rest = addr2.Substring(commaIdx + 1).Trim();
                // rest = "ST Zip" or "State Zip"
                var parts = rest.Split(' ', StringSplitOptions.RemoveEmptyEntries);
                if (parts.Length >= 2)
                {
                    state = parts[0];
                    zip = parts[parts.Length - 1];
                }
                else if (parts.Length == 1)
                {
                    state = parts[0];
                }
            }
            else
            {
                city = addr2.Trim();
            }
        }

        var limitRaw = GetField(fields, headers, "LIMIT").Replace(",", "").Replace("\"", "").Trim();
        var notes = GetField(fields, headers, "NOTEPAD");
        if (string.IsNullOrWhiteSpace(notes))
            notes = GetField(fields, headers, "NOTE");

        var email = GetField(fields, headers, "EMAIL");
        if (string.IsNullOrWhiteSpace(email)) email = null;

        return new Vendor
        {
            CompanyId = companyId,
            CompanyName = companyName,
            ContactName = string.IsNullOrWhiteSpace(contactName) ? null : contactName,
            Email = email,
            Phone = GetField(fields, headers, "PHONE1"),
            Address = string.IsNullOrWhiteSpace(addr1) ? null : addr1,
            City = string.IsNullOrWhiteSpace(city) ? null : city,
            State = string.IsNullOrWhiteSpace(state) ? null : state,
            ZipCode = string.IsNullOrWhiteSpace(zip) ? null : zip,
            Country = "USA",
            TaxId = GetField(fields, headers, "TAXID") is { Length: > 0 } tid ? tid : null,
            PaymentTerms = GetField(fields, headers, "TERMS") is { Length: > 0 } t ? t : null,
            CreditLimit = string.IsNullOrWhiteSpace(limitRaw) ? null : ParseDecimal(limitRaw) == 0 ? null : ParseDecimal(limitRaw),
            Notes = string.IsNullOrWhiteSpace(notes) ? null : notes,
            IsActive = true,
            IsPreferred = false,
            CreatedBy = userId,
            CreatedAt = DateTime.UtcNow
        };
    }

    /// <summary>
    /// Updates an existing <see cref="Vendor"/> entity in-place from a parsed IIF data row.
    /// <para>
    /// Only non-empty values overwrite existing data, preserving any local edits made after the
    /// last QuickBooks export. This is intentional: IIF imports are not authoritative overwrites —
    /// they are merges that fill gaps without destroying local data.
    /// </para>
    /// <para>
    /// Uses the same ADDR2 city/state/zip parsing logic as <see cref="CreateVendorFromIif"/>.
    /// </para>
    /// </summary>
    private void UpdateVendorFromIif(Vendor vendor, string[] fields, string[] headers, string userId)
    {
        var contactName = GetField(fields, headers, "CONT1");
        if (string.IsNullOrWhiteSpace(contactName))
        {
            var fn = GetField(fields, headers, "FIRSTNAME");
            var ln = GetField(fields, headers, "LASTNAME");
            contactName = $"{fn} {ln}".Trim();
        }

        var addr1 = GetField(fields, headers, "ADDR1");
        var addr2 = GetField(fields, headers, "ADDR2");
        var city = string.Empty;
        var state = string.Empty;
        var zip = string.Empty;

        if (!string.IsNullOrWhiteSpace(addr2))
        {
            var commaIdx = addr2.IndexOf(',');
            if (commaIdx > 0)
            {
                city = addr2.Substring(0, commaIdx).Trim();
                var rest = addr2.Substring(commaIdx + 1).Trim();
                var parts = rest.Split(' ', StringSplitOptions.RemoveEmptyEntries);
                if (parts.Length >= 2) { state = parts[0]; zip = parts[parts.Length - 1]; }
                else if (parts.Length == 1) state = parts[0];
            }
            else city = addr2.Trim();
        }

        var limitRaw = GetField(fields, headers, "LIMIT").Replace(",", "").Replace("\"", "").Trim();
        var notes = GetField(fields, headers, "NOTEPAD");
        if (string.IsNullOrWhiteSpace(notes)) notes = GetField(fields, headers, "NOTE");

        var email = GetField(fields, headers, "EMAIL");
        if (string.IsNullOrWhiteSpace(email)) email = null;

        if (!string.IsNullOrWhiteSpace(contactName)) vendor.ContactName = contactName;
        vendor.Email = email;
        if (!string.IsNullOrWhiteSpace(GetField(fields, headers, "PHONE1"))) vendor.Phone = GetField(fields, headers, "PHONE1");
        if (!string.IsNullOrWhiteSpace(addr1)) vendor.Address = addr1;
        if (!string.IsNullOrWhiteSpace(city)) vendor.City = city;
        if (!string.IsNullOrWhiteSpace(state)) vendor.State = state;
        if (!string.IsNullOrWhiteSpace(zip)) vendor.ZipCode = zip;
        if (GetField(fields, headers, "TAXID") is { Length: > 0 } tid) vendor.TaxId = tid;
        if (GetField(fields, headers, "TERMS") is { Length: > 0 } terms) vendor.PaymentTerms = terms;
        if (!string.IsNullOrWhiteSpace(limitRaw) && ParseDecimal(limitRaw) != 0) vendor.CreditLimit = ParseDecimal(limitRaw);
        if (!string.IsNullOrWhiteSpace(notes)) vendor.Notes = notes;
        vendor.UpdatedBy = userId;
        vendor.UpdatedAt = DateTime.UtcNow;
    }

    #endregion

    /// <summary>
    /// Sanitises a string value for safe inclusion in a tab-delimited IIF field.
    /// <para>
    /// IIF is strictly tab-delimited: embedded tabs would break column alignment and cause
    /// QuickBooks to misparse the record. Embedded carriage returns and newlines would
    /// create phantom extra rows. All three are replaced with a single space.
    /// </para>
    /// <para>
    /// QuickBooks Desktop has a hard limit of ~500 characters per IIF field; values exceeding
    /// this are silently truncated to avoid import errors on the QuickBooks side.
    /// </para>
    /// </summary>
    private string EscapeIifField(string? value)
    {
        if (string.IsNullOrEmpty(value))
            return string.Empty;

        // Remove tabs and newlines, truncate to 500 chars
        var escaped = value
            .Replace("\t", " ")
            .Replace("\r", " ")
            .Replace("\n", " ");

        return escaped.Length > 500 ? escaped.Substring(0, 500) : escaped;
    }

    /// <summary>
    /// Retrieves a named field from a parsed IIF data row by performing a positional lookup
    /// against the corresponding header row.
    /// <para>
    /// The lookup is case-sensitive because QuickBooks IIF field names are always upper-case.
    /// Returns <see cref="string.Empty"/> (not null) when the field is not present so callers
    /// can use string comparisons without null checks.
    /// </para>
    /// <para>
    /// Applies <see cref="UnquoteIifField"/> so callers receive clean values even when QuickBooks
    /// wrapped the field in double-quotes (e.g. addresses containing commas).
    /// </para>
    /// </summary>
    private string GetField(string[] fields, string[] headers, string fieldName)
    {
        for (int i = 0; i < headers.Length && i < fields.Length; i++)
        {
            if (headers[i] == fieldName)
                return UnquoteIifField(fields[i]);
        }
        return string.Empty;
    }

    /// <summary>
    /// IIF format wraps fields that contain commas in double quotes (e.g. "D-Tech, Inc").
    /// Strip the surrounding quotes so the value is stored cleanly.
    /// </summary>
    private static string UnquoteIifField(string value)
    {
        if (value.Length >= 2 && value[0] == '"' && value[^1] == '"')
            return value[1..^1];
        return value;
    }

    /// <summary>
    /// Attempts to parse a decimal value from a string, returning <c>0</c> on failure.
    /// Used for optional numeric fields (prices, credit limits, balances) where a missing
    /// or non-numeric value should default to zero rather than cause a parse exception.
    /// </summary>
    private decimal ParseDecimal(string value)
    {
        if (string.IsNullOrWhiteSpace(value))
            return 0;

        if (decimal.TryParse(value, out var result))
            return result;

        return 0;
    }

    /// <summary>
    /// Constructs a new <see cref="Customer"/> entity from a parsed IIF <c>CUST</c> data row.
    /// <para>
    /// Company name resolution (three-tier fallback): <c>COMPANYNAME</c> → <c>NAME</c> → <c>FIRSTNAME + LASTNAME</c>.
    /// </para>
    /// <para>
    /// Contact name is read from <c>CONT1</c>/<c>CONT2</c> when available (QB business contacts);
    /// falling back to <c>FIRSTNAME</c>/<c>LASTNAME</c> for individual customers where QB stores
    /// the person's name in those fields.
    /// </para>
    /// <para>
    /// <c>IsTaxExempt</c> is the logical inverse of the IIF <c>TAXABLE</c> field: <c>TAXABLE=N</c>
    /// means the customer is not taxable → is tax exempt.
    /// </para>
    /// <para>
    /// <c>IsCommercial</c> is inferred: customers with a non-empty <c>COMPANYNAME</c> field are
    /// commercial (B2B); individuals without a company name are non-commercial.
    /// </para>
    /// <para>
    /// Notes are read from <c>NOTEPAD</c> first (longer notes field), falling back to <c>NOTE</c>.
    /// </para>
    /// </summary>
    private Customer CreateCustomerFromIif(string[] fields, string[] headers, int companyId, string userId)
    {
        var taxable = GetField(fields, headers, "TAXABLE");

        // QuickBooks uses COMPANYNAME for businesses, otherwise uses FIRSTNAME + LASTNAME
        var companyName = GetField(fields, headers, "COMPANYNAME");
        if (string.IsNullOrWhiteSpace(companyName))
        {
            // Use NAME field, or combine FIRSTNAME + LASTNAME
            companyName = GetField(fields, headers, "NAME");
            if (string.IsNullOrWhiteSpace(companyName))
            {
                var firstName = GetField(fields, headers, "FIRSTNAME");
                var lastName = GetField(fields, headers, "LASTNAME");
                companyName = $"{firstName} {lastName}".Trim();
            }
        }

        // Get contact name from CONT1/CONT2 if available, otherwise from FIRSTNAME/LASTNAME
        var contactFirstName = GetField(fields, headers, "CONT1");
        var contactLastName = GetField(fields, headers, "CONT2");

        if (string.IsNullOrWhiteSpace(contactFirstName))
        {
            contactFirstName = GetField(fields, headers, "FIRSTNAME");
            contactLastName = GetField(fields, headers, "LASTNAME");
        }

        // Notes - try NOTEPAD first, then NOTE
        var notes = GetField(fields, headers, "NOTEPAD");
        if (string.IsNullOrWhiteSpace(notes))
        {
            notes = GetField(fields, headers, "NOTE");
        }

        // Get email and convert empty to null to avoid unique constraint issues
        var email = GetField(fields, headers, "EMAIL");
        if (string.IsNullOrWhiteSpace(email))
            email = null;

        return new Customer
        {
            CompanyId = companyId,
            CompanyName = companyName,
            Address = GetField(fields, headers, "BADDR1"),
            City = GetField(fields, headers, "CITY"),
            State = GetField(fields, headers, "STATE"),
            ZipCode = GetField(fields, headers, "ZIP"),
            Country = GetField(fields, headers, "COUNTRY") is { Length: > 0 } c ? c : "USA",
            Phone = GetField(fields, headers, "PHONE1"),
            MobilePhone = GetField(fields, headers, "PHONE2"),
            Email = email,
            ContactFirstName = contactFirstName,
            ContactLastName = contactLastName,
            PaymentTerms = GetField(fields, headers, "TERMS"),
            IsTaxExempt = taxable == "N", // Inverted: TAXABLE=N means tax exempt
            CreditLimit = ParseDecimal(GetField(fields, headers, "LIMIT")),
            GeneralNotes = notes,
            IsCommercial = !string.IsNullOrWhiteSpace(GetField(fields, headers, "COMPANYNAME")), // Has company name = commercial
            IsActive = true,
            CreatedBy = userId,
            CreatedAt = DateTime.UtcNow
        };
    }

    /// <summary>
    /// Updates an existing <see cref="Customer"/> entity in-place from a parsed IIF <c>CUST</c> data row.
    /// <para>
    /// All fields are unconditionally overwritten on update (unlike the vendor update path), because
    /// customers are matched by company name and a full refresh is the expected behaviour for a
    /// re-import scenario (e.g. after correcting data in QuickBooks and re-exporting).
    /// </para>
    /// <para>
    /// Applies the same <c>TAXABLE</c> inversion and <c>NOTEPAD → NOTE</c> fallback as
    /// <see cref="CreateCustomerFromIif"/>.
    /// </para>
    /// </summary>
    private void UpdateCustomerFromIif(Customer customer, string[] fields, string[] headers, string userId)
    {
        // Get contact name from CONT1/CONT2 if available, otherwise from FIRSTNAME/LASTNAME
        var contactFirstName = GetField(fields, headers, "CONT1");
        var contactLastName = GetField(fields, headers, "CONT2");

        if (string.IsNullOrWhiteSpace(contactFirstName))
        {
            contactFirstName = GetField(fields, headers, "FIRSTNAME");
            contactLastName = GetField(fields, headers, "LASTNAME");
        }

        // Notes - try NOTEPAD first, then NOTE
        var notes = GetField(fields, headers, "NOTEPAD");
        if (string.IsNullOrWhiteSpace(notes))
        {
            notes = GetField(fields, headers, "NOTE");
        }

        var taxable = GetField(fields, headers, "TAXABLE");

        // Get email and convert empty to null to avoid unique constraint issues
        var email = GetField(fields, headers, "EMAIL");
        if (string.IsNullOrWhiteSpace(email))
            email = null;

        customer.Address = GetField(fields, headers, "BADDR1");
        customer.City = GetField(fields, headers, "CITY");
        customer.State = GetField(fields, headers, "STATE");
        customer.ZipCode = GetField(fields, headers, "ZIP");
        var country = GetField(fields, headers, "COUNTRY");
        if (!string.IsNullOrWhiteSpace(country)) customer.Country = country;
        customer.Phone = GetField(fields, headers, "PHONE1");
        customer.MobilePhone = GetField(fields, headers, "PHONE2");
        customer.Email = email;
        customer.ContactFirstName = contactFirstName;
        customer.ContactLastName = contactLastName;
        customer.PaymentTerms = GetField(fields, headers, "TERMS");
        customer.IsTaxExempt = taxable == "N"; // Inverted: TAXABLE=N means tax exempt
        customer.CreditLimit = ParseDecimal(GetField(fields, headers, "LIMIT"));
        customer.GeneralNotes = notes;
        customer.UpdatedBy = userId;
        customer.UpdatedAt = DateTime.UtcNow;
    }

    /// <summary>
    /// Constructs a new <see cref="CatalogItem"/> entity from a parsed IIF <c>INVITEM</c> data row.
    /// <para>
    /// The <c>NAME</c> field in QuickBooks represents the colon-delimited hierarchy path (e.g.
    /// <c>"Cerakote:Auto:Exhaust"</c>), not a human-readable item name. The human-readable name
    /// is stored in <c>DESC</c>. When DESC contains <c>" - "</c>, it is split into name and
    /// description (e.g. <c>"Sandblast - Small parts sandblasting service"</c> → name="Sandblast",
    /// description="Small parts sandblasting service").
    /// </para>
    /// <para>
    /// SKU is intentionally set to <c>null</c>: the QB <c>NAME</c> field (hierarchy path) is
    /// used for category resolution only, not as a catalog SKU.
    /// </para>
    /// <para>
    /// <c>IsActive</c> is the inverse of <c>HIDDEN</c>: <c>HIDDEN=Y</c> → inactive in this system.
    /// </para>
    /// </summary>
    private CatalogItem CreateCatalogItemFromIif(string[] fields, string[] headers, int companyId, int categoryId, string userId)
    {
        var desc = GetField(fields, headers, "DESC");
        var hidden = GetField(fields, headers, "HIDDEN");

        // Use DESC as the name (category markers are already filtered out)
        var name = desc;
        var description = string.Empty;

        // If the description contains " - ", split it into name and description
        if (!string.IsNullOrWhiteSpace(desc) && desc.Contains(" - "))
        {
            var parts = desc.Split(new[] { " - " }, 2, StringSplitOptions.None);
            name = parts[0];
            description = parts.Length > 1 ? parts[1] : string.Empty;
        }

        return new CatalogItem
        {
            CompanyId = companyId,
            CategoryId = categoryId,
            SKU = null, // SKU not used - QuickBooks NAME field is only for category hierarchy
            Name = name,
            Description = description,
            DefaultPrice = ParseDecimal(GetField(fields, headers, "PRICE")),
            IsActive = hidden != "Y", // Inverted: Y means hidden
            DisplayOrder = 0,
            CreatedBy = userId,
            CreatedAt = DateTime.UtcNow
        };
    }

    /// <summary>
    /// Updates an existing <see cref="CatalogItem"/> entity in-place from a parsed IIF <c>INVITEM</c> data row.
    /// <para>
    /// Applies the same DESC splitting logic as <see cref="CreateCatalogItemFromIif"/> and the same
    /// <c>HIDDEN</c> → <c>IsActive</c> inversion. Price and active state are always overwritten;
    /// category is updated by the caller (not this method) before it is invoked.
    /// </para>
    /// </summary>
    private void UpdateCatalogItemFromIif(CatalogItem item, string[] fields, string[] headers, string userId)
    {
        var desc = GetField(fields, headers, "DESC");
        var hidden = GetField(fields, headers, "HIDDEN");

        // Use DESC as the name (category markers are already filtered out)
        var name = desc;
        var description = string.Empty;

        // If the description contains " - ", split it into name and description
        if (!string.IsNullOrWhiteSpace(desc) && desc.Contains(" - "))
        {
            var parts = desc.Split(new[] { " - " }, 2, StringSplitOptions.None);
            name = parts[0];
            description = parts.Length > 1 ? parts[1] : string.Empty;
        }

        item.Name = name;
        item.Description = description;
        item.DefaultPrice = ParseDecimal(GetField(fields, headers, "PRICE"));
        item.IsActive = hidden != "Y"; // Inverted: Y means hidden
        item.UpdatedBy = userId;
        item.UpdatedAt = DateTime.UtcNow;
    }

    /// <summary>
    /// Parses a QuickBooks colon-delimited item hierarchy (e.g. <c>"Cerakote:Automotive:Exhaust:Item Name"</c>)
    /// and ensures the full category path exists in the database, creating any missing levels.
    /// Returns the ID of the deepest (leaf) category node.
    /// <para>
    /// The last colon-segment is the item name, not a category: <c>["Cerakote", "Automotive", "Exhaust"]</c>
    /// become categories; <c>"Item Name"</c> is handled by the caller.
    /// </para>
    /// <para>
    /// Each level is flushed immediately (<c>CompleteAsync</c>) after creation so that its database-assigned
    /// <c>Id</c> is available as the <c>ParentCategoryId</c> for the next level.
    /// </para>
    /// <para>
    /// Items with no colon hierarchy (flat names) fall back to the default "Imported Items" category
    /// via <see cref="GetOrCreateDefaultCategory"/> rather than creating spurious top-level categories.
    /// </para>
    /// </summary>
    private async Task<int> GetOrCreateCategoryFromSkuHierarchy(string sku, int companyId, string userId)
    {
        // Split SKU by colon to get hierarchy levels
        var parts = sku.Split(':');

        // If no hierarchy (no colons), create/use a default "Imported Items" category
        if (parts.Length == 1)
        {
            return await GetOrCreateDefaultCategory(companyId, userId);
        }

        // The last part is the item name, everything before is the category path
        // e.g., "Cerakote:Automotive:Exhaust:Item" -> ["Cerakote", "Automotive", "Exhaust"] = category path
        var categoryPath = parts.Take(parts.Length - 1).ToArray();

        int? parentId = null;
        int categoryId = 0;

        // Build/find category hierarchy from root to leaf
        for (int i = 0; i < categoryPath.Length; i++)
        {
            var categoryName = categoryPath[i].Trim();

            if (string.IsNullOrWhiteSpace(categoryName))
                continue;

            // Try to find existing category at this level
            var existingCategory = await _unitOfWork.CatalogCategories.FirstOrDefaultAsync(
                c => c.CompanyId == companyId &&
                     c.Name == categoryName &&
                     c.ParentCategoryId == parentId);

            if (existingCategory != null)
            {
                categoryId = existingCategory.Id;
            }
            else
            {
                // Create new category
                var newCategory = new CatalogCategory
                {
                    CompanyId = companyId,
                    ParentCategoryId = parentId,
                    Name = categoryName,
                    Description = $"Imported from QuickBooks: {string.Join(" > ", categoryPath.Take(i + 1))}",
                    DisplayOrder = i,
                    IsActive = true,
                    CreatedBy = userId,
                    CreatedAt = DateTime.UtcNow
                };

                await _unitOfWork.CatalogCategories.AddAsync(newCategory);
                await _unitOfWork.CompleteAsync();
                categoryId = newCategory.Id;

                _logger.LogInformation("Created category: {Name} (Parent: {ParentId})", categoryName, parentId);
            }

            // This category becomes the parent for the next level
            parentId = categoryId;
        }

        return categoryId;
    }

    /// <summary>
    /// Gets or creates the catch-all top-level "Imported Items" category used for QuickBooks items
    /// that have no colon-delimited hierarchy in their <c>NAME</c> field.
    /// <para>
    /// The category is lazily created on first use and reused for all subsequent flat-name items
    /// within the same import, so only one DB round-trip is needed per import (not per item).
    /// It is assigned a high <c>DisplayOrder</c> (999) to appear at the bottom of category lists.
    /// </para>
    /// </summary>
    private async Task<int> GetOrCreateDefaultCategory(int companyId, string userId)
    {
        var defaultCategory = await _unitOfWork.CatalogCategories.FirstOrDefaultAsync(
            c => c.CompanyId == companyId && c.Name == "Imported Items");

        if (defaultCategory != null)
        {
            return defaultCategory.Id;
        }

        // Create default category
        var newCategory = new CatalogCategory
        {
            CompanyId = companyId,
            ParentCategoryId = null,
            Name = "Imported Items",
            Description = "Items imported from QuickBooks without category hierarchy",
            DisplayOrder = 999,
            IsActive = true,
            CreatedBy = userId,
            CreatedAt = DateTime.UtcNow
        };

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

        _logger.LogInformation("Created default 'Imported Items' category");
        return newCategory.Id;
    }

    #endregion

    #region QB Invoice CSV Import

    // ── Private types ────────────────────────────────────────────────────────

    private sealed class QbInvoiceGroup
    {
        public string CustomerName { get; }
        public string QbInvoiceNum { get; }
        public DateTime InvoiceDate { get; }
        public List<QbInvoiceLine> Lines { get; } = new();
        /// <summary>Amount already paid as-of the QB export. 0 if unknown (detail format didn't track payments).</summary>
        public decimal AmountPaid { get; set; }
        /// <summary>Individual payment applications for Payment record creation on import.</summary>
        public List<QbPaymentRecord> PaymentRecords { get; } = new();

        public QbInvoiceGroup(string customerName, string qbNum, DateTime date)
        {
            CustomerName = customerName;
            QbInvoiceNum = qbNum;
            InvoiceDate = date;
        }
    }

    private sealed record QbPaymentRecord(DateTime PaymentDate, decimal Amount, string? CheckNum, string? BankAccountName);

    private sealed class QbInvoiceLine
    {
        public string Memo { get; init; } = "";
        public string ItemPath { get; init; } = "";
        public string QtyRaw { get; init; } = "";
        public string SalesPriceRaw { get; init; } = "";
        public decimal Amount { get; init; }
    }

    // ── Public method ────────────────────────────────────────────────────────

    /// <summary>
    /// Imports historical QuickBooks invoices from a QB Desktop CSV export and creates corresponding
    /// <see cref="Invoice"/> records in the application database.
    /// <para>
    /// <b>Pre-flight requirement:</b> Customers must be imported first; the importer matches invoices
    /// to customers by display name and fails with a descriptive error if no customers are found.
    /// Customer name matching uses exact lookup first, then a "starts-with" fallback to handle cases
    /// where the QuickBooks invoice name is shorter than the full company name stored in the DB
    /// (e.g. "Novo Nordisk" matching "Novo Nordisk Pharmaceutical Industries").
    /// </para>
    /// <para>
    /// <b>Duplicate prevention:</b> QuickBooks invoice numbers are stored in <see cref="Invoice.ExternalReference"/>.
    /// Re-running the import skips any invoice whose QB number is already present in the DB.
    /// </para>
    /// <para>
    /// <b>Invoice numbering:</b> Imported invoices receive sequential internal numbers (INV-YYMM-####)
    /// that are computed in a pre-import block to avoid per-invoice DB round-trips. The QB number is
    /// preserved separately in <c>ExternalReference</c>.
    /// </para>
    /// <para>
    /// <b>Payment records:</b> Payment history is intentionally NOT created here. The importer sets
    /// <c>AmountPaid</c> and <c>Status</c> on the invoice but leaves Payment record creation to
    /// <see cref="ImportQbTransactionsFromCsvAsync"/>, which has richer data (check numbers, exact
    /// dates, bank account names).
    /// </para>
    /// <para>
    /// <b>Format auto-detection:</b> Supports both the "Customer Balance Detail" format and the
    /// "Sales by Customer / Invoice Detail" format, detected by scanning the CSV header row for
    /// columns like "Item", "Qty", or "SalesPrice".
    /// </para>
    /// </summary>
    public async Task<ImportResultDto> ImportQbInvoicesFromCsvAsync(IFormFile file, int companyId, string userId)
    {
        var result = new ImportResultDto();

        try
        {
            // Pre-flight: customers must exist before invoices can be linked
            var customers = (await _unitOfWork.Customers.FindAsync(c => c.CompanyId == companyId && !c.IsDeleted)).ToList();
            if (!customers.Any())
            {
                result.Errors.Add(new ImportErrorDto
                {
                    RecordName = "Pre-flight",
                    ErrorMessage = "No customers found. Please import customers from QuickBooks first, then re-run the invoice import."
                });
                return result;
            }

            // Customer lookup: display name → Customer
            // Strip surrounding quotes that may have been stored during IIF import,
            // and register both the full name and any extra-word variants.
            var customerLookup = new Dictionary<string, Customer>(StringComparer.OrdinalIgnoreCase);
            foreach (var c in customers)
            {
                var raw = (!string.IsNullOrWhiteSpace(c.CompanyName)
                    ? c.CompanyName
                    : $"{c.ContactFirstName} {c.ContactLastName}").Trim();

                // Strip surrounding double-quotes stored by the IIF importer
                var name = raw.Trim('"');
                customerLookup.TryAdd(name, c);

                // Also index by the raw form in case it wasn't quoted
                customerLookup.TryAdd(raw, c);
            }

            // Build a list for fallback "starts-with" matching (handles cases where the
            // QB invoice has a shorter name than the full company name, e.g.
            // invoice says "Novo Nordisk" but customer is "Novo Nordisk Pharmaceutical Industries")
            var customerList2 = customerLookup.ToList();

            // Catalog item lookup: name → CatalogItem (best-effort match)
            var catalogItems = (await _unitOfWork.CatalogItems.FindAsync(ci => ci.CompanyId == companyId && !ci.IsDeleted)).ToList();
            var catalogLookup = new Dictionary<string, CatalogItem>(StringComparer.OrdinalIgnoreCase);
            foreach (var ci in catalogItems)
                catalogLookup.TryAdd(ci.Name.Trim(), ci);

            // Already-imported QB invoice numbers (ExternalReference) — skip duplicates
            var existingInvoices = await _unitOfWork.Invoices.FindAsync(i => i.CompanyId == companyId && i.ExternalReference != null);
            var existingRefs = existingInvoices.Select(i => i.ExternalReference!).ToHashSet(StringComparer.OrdinalIgnoreCase);

            // Read CSV
            string content;
            using (var reader = new StreamReader(file.OpenReadStream(), Encoding.UTF8, detectEncodingFromByteOrderMarks: true))
                content = await reader.ReadToEndAsync();

            // Parse into invoice groups
            List<QbInvoiceGroup> groups;
            try
            {
                groups = ParseQbInvoicesCsv(content);
            }
            catch (Exception ex)
            {
                _logger.LogError(ex, "Failed to parse QB invoices CSV");
                result.Errors.Add(new ImportErrorDto { ErrorMessage = $"Failed to parse CSV: {ex.Message}" });
                return result;
            }

            result.TotalRecords = groups.Count;
            _logger.LogInformation("Parsed {Count} invoice groups from QB CSV for company {CompanyId}", groups.Count, companyId);

            // Pre-compute a block of sequential invoice numbers (today's prefix)
            var prefix = $"INV-{DateTime.UtcNow:yy}{DateTime.UtcNow.Month:D2}-";
            var sameMonthInvoices = await _unitOfWork.Invoices.FindAsync(i => i.CompanyId == companyId && i.InvoiceNumber.StartsWith(prefix));
            var maxNum = sameMonthInvoices
                .Select(i => i.InvoiceNumber.Length >= prefix.Length + 4 ? i.InvoiceNumber[prefix.Length..] : "")
                .Where(s => int.TryParse(s, out _))
                .Select(s => int.Parse(s))
                .DefaultIfEmpty(0)
                .Max();
            var invoiceCounter = maxNum + 1;

            // Import each invoice group
            foreach (var group in groups)
            {
                // Skip already-imported QB invoices
                if (existingRefs.Contains(group.QbInvoiceNum))
                {
                    result.SkippedCount++;
                    continue;
                }

                // Require customer match — exact first, then starts-with fallback
                var invoiceCustomerName = group.CustomerName.Trim().Trim('"');
                if (!customerLookup.TryGetValue(invoiceCustomerName, out var customer))
                {
                    // Fallback: customer name in DB starts with the invoice name
                    // e.g. invoice = "Novo Nordisk", DB = "Novo Nordisk Pharmaceutical Industries"
                    var fallback = customerList2.FirstOrDefault(kvp =>
                        kvp.Key.StartsWith(invoiceCustomerName, StringComparison.OrdinalIgnoreCase));
                    customer = fallback.Value;
                }

                if (customer == null)
                {
                    result.Errors.Add(new ImportErrorDto
                    {
                        RecordName   = $"QB #{group.QbInvoiceNum}",
                        ErrorMessage = $"Customer '{group.CustomerName}' not found in this company. Skipping invoice #{group.QbInvoiceNum}."
                    });
                    result.SkippedCount++;
                    continue;
                }

                var total = group.Lines.Sum(l => l.Amount);
                var invoiceNumber = $"{prefix}{invoiceCounter++:D4}";

                var amountPaid = Math.Min(group.AmountPaid, total);
                var invoiceStatus = amountPaid >= total && total > 0
                    ? InvoiceStatus.Paid
                    : amountPaid > 0
                        ? InvoiceStatus.PartiallyPaid
                        : InvoiceStatus.Sent;

                var invoice = new Invoice
                {
                    CompanyId = companyId,
                    CustomerId = customer.Id,
                    InvoiceNumber = invoiceNumber,
                    ExternalReference = group.QbInvoiceNum,
                    InvoiceDate = group.InvoiceDate,
                    Status = invoiceStatus,
                    SubTotal = total,
                    TaxAmount = 0,
                    TaxPercent = 0,
                    DiscountAmount = 0,
                    Total = total,
                    AmountPaid = amountPaid,
                    CreatedBy = userId,
                    CreatedAt = DateTime.UtcNow
                };

                int displayOrder = 1;
                foreach (var line in group.Lines)
                {
                    var shortName = ExtractQbItemShortName(line.ItemPath);
                    catalogLookup.TryGetValue(shortName, out var catalogItem);

                    bool isPercent = line.SalesPriceRaw.Contains('%');
                    decimal qty = !isPercent && decimal.TryParse(line.QtyRaw, NumberStyles.Any, CultureInfo.InvariantCulture, out var q) ? q : 1m;
                    decimal unitPrice = isPercent
                        ? line.Amount
                        : (decimal.TryParse(line.SalesPriceRaw, NumberStyles.Any, CultureInfo.InvariantCulture, out var sp) ? sp : line.Amount);

                    invoice.InvoiceItems.Add(new InvoiceItem
                    {
                        CompanyId = companyId,
                        Description = line.Memo,
                        Quantity = qty,
                        UnitPrice = unitPrice,
                        TotalPrice = line.Amount,
                        CatalogItemId = catalogItem?.Id,
                        DisplayOrder = displayOrder++,
                        CreatedBy = userId,
                        CreatedAt = DateTime.UtcNow
                    });
                }

                await _unitOfWork.Invoices.AddAsync(invoice);

                // NOTE: We intentionally do NOT create Payment records here.
                // AmountPaid and Status are set correctly from the balance detail file.
                // Step 7 (ImportQbTransactionsFromCsvAsync) creates the actual Payment
                // records with richer data: check numbers, bank account names, exact dates.

                // Track within-file to prevent duplicates if QB number appears twice in the file
                existingRefs.Add(group.QbInvoiceNum);
                result.ImportedCount++;
            }

            if (result.ImportedCount > 0)
                await _unitOfWork.CompleteAsync();

            result.Success = true;
            _logger.LogInformation("QB invoice import complete: {Imported} imported, {Skipped} skipped, {Errors} errors",
                result.ImportedCount, result.SkippedCount, result.Errors.Count);
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Unexpected error during QB invoice import for company {CompanyId}", companyId);
            result.Errors.Add(new ImportErrorDto { ErrorMessage = $"Unexpected error: {ex.Message}" });
        }

        return result;
    }

    // ── Parsing ──────────────────────────────────────────────────────────────

    /// <summary>
    /// Detects which QuickBooks Desktop CSV export format the content represents and dispatches to
    /// the appropriate parser.
    /// <para>
    /// QB Desktop can export invoice data in at least two different layouts:
    /// <list type="bullet">
    ///   <item><description><b>Sales by Customer / Invoice Detail</b> (10+ columns): includes individual line-item rows
    ///   with Item, Qty, SalesPrice, and Amount columns.</description></item>
    ///   <item><description><b>Customer Balance Detail</b> (7 columns): summarises open invoices and payment rows
    ///   without line-item detail.</description></item>
    /// </list>
    /// Detection scans the header row for the presence of "item", "qty", "quantity", or "salesprice"
    /// keywords (case-insensitive). The detail format is preferred when any of these are found.
    /// </para>
    /// </summary>
    private static List<QbInvoiceGroup> ParseQbInvoicesCsv(string content)
    {
        // Auto-detect which QB Desktop CSV export format this is:
        //
        // (A) Sales by Customer / Invoice Detail — 10+ columns:
        //     [0] Customer label or blank, [1] Type, [2] Date, [3] Num, [4] Memo,
        //     [5] Name, [6] Item, [7] Qty, [8] SalesPrice, [9] Amount, [10] Balance
        //
        // (B) Customer Balance Detail — 7 columns:
        //     [0] Customer name (header rows) or blank, [1] Type, [2] Date,
        //     [3] Num, [4] Account, [5] Amount, [6] Balance
        //
        // We detect by scanning the header row: if it contains "item", "qty",
        // or "salesprice" → format A; otherwise assume format B.

        var lines = content.Split('\n');
        if (lines.Length < 2) return new List<QbInvoiceGroup>();

        var headerCols = ParseCsvLine(lines[0].TrimEnd('\r'));
        bool isDetailFormat = headerCols.Any(h =>
        {
            var lower = h.Trim().ToLowerInvariant();
            return lower == "item" || lower == "qty" || lower == "quantity"
                || lower.Contains("salesprice") || lower.Contains("sales price");
        });

        return isDetailFormat
            ? ParseQbInvoiceDetailCsv(lines)
            : ParseQbBalanceDetailCsv(lines);
    }

    /// <summary>
    /// Parser for QB Desktop "Customer Balance Detail" CSV export.
    /// Columns: [0] Customer name (on header rows) | blank, [1] Type, [2] Date,
    /// [3] Num, [4] Account, [5] Amount, [6] Balance
    /// Payments are matched to invoices FIFO per customer to set AmountPaid.
    /// </summary>
    private static List<QbInvoiceGroup> ParseQbBalanceDetailCsv(string[] lines)
    {
        var groups = new Dictionary<string, QbInvoiceGroup>(StringComparer.OrdinalIgnoreCase);
        var orderedKeys = new List<string>();

        string currentCustomer = "";
        // Open invoices for the current customer in order (for FIFO payment matching)
        var openInvoiceNums = new List<string>();
        // Payments that arrived before any invoice for this customer (prepayments / deposits)
        var pendingPayments = new List<QbPaymentRecord>();

        for (int i = 1; i < lines.Length; i++)
        {
            var rawLine = lines[i].TrimEnd('\r');
            if (string.IsNullOrWhiteSpace(rawLine)) continue;

            var cols = ParseCsvLine(rawLine);
            if (cols.Count < 2) continue;

            var col0 = cols[0].Trim().Trim('"');

            // Customer header row: col[0] is non-empty and not a "Total" row
            if (!string.IsNullOrEmpty(col0) && !col0.StartsWith("Total ", StringComparison.OrdinalIgnoreCase))
            {
                currentCustomer = col0;
                openInvoiceNums.Clear();
                pendingPayments.Clear();
                continue;
            }

            // Total row or blank first column — transaction rows have blank col[0]
            if (cols.Count < 6) continue;

            var txType    = cols.Count > 1 ? cols[1].Trim() : "";
            var dateStr   = cols.Count > 2 ? cols[2].Trim() : "";
            var num       = cols.Count > 3 ? cols[3].Trim() : "";
            var acctName  = cols.Count > 4 ? cols[4].Trim().Trim('"') : "";  // bank account name on payment rows
            var amountStr = cols.Count > 5 ? cols[5].Trim() : "";

            if (!decimal.TryParse(amountStr, NumberStyles.Any, CultureInfo.InvariantCulture, out var amount)) continue;
            if (!DateTime.TryParseExact(dateStr, "MM/dd/yyyy", CultureInfo.InvariantCulture, DateTimeStyles.None, out var txDate)
                && !DateTime.TryParse(dateStr, CultureInfo.InvariantCulture, DateTimeStyles.None, out txDate))
                continue;

            if (string.Equals(txType, "Invoice", StringComparison.OrdinalIgnoreCase) && !string.IsNullOrEmpty(num))
            {
                if (!groups.ContainsKey(num))
                {
                    var group = new QbInvoiceGroup(currentCustomer, num, txDate);
                    group.Lines.Add(new QbInvoiceLine
                    {
                        Memo          = $"QB Invoice #{num}",
                        Amount        = amount,
                        QtyRaw        = "1",
                        SalesPriceRaw = amount.ToString(CultureInfo.InvariantCulture),
                    });
                    groups[num] = group;
                    orderedKeys.Add(num);

                    // Drain any pending prepayments against this invoice first
                    if (pendingPayments.Count > 0)
                    {
                        decimal invoiceTotal = amount;
                        foreach (var pp in pendingPayments.ToList())
                        {
                            if (group.AmountPaid >= invoiceTotal) break;
                            var outstanding = invoiceTotal - group.AmountPaid;
                            var apply = Math.Min(pp.Amount, outstanding);
                            group.AmountPaid += apply;
                            group.PaymentRecords.Add(pp with { Amount = apply });
                            if (apply < pp.Amount)
                            {
                                // Partial use of a pending payment — keep the remainder
                                var idx = pendingPayments.IndexOf(pp);
                                pendingPayments[idx] = pp with { Amount = pp.Amount - apply };
                            }
                            else
                            {
                                pendingPayments.Remove(pp);
                            }
                        }
                    }

                    openInvoiceNums.Add(num);
                }
            }
            else if (string.Equals(txType, "Payment", StringComparison.OrdinalIgnoreCase) && amount < 0)
            {
                decimal remaining = Math.Abs(amount);

                if (openInvoiceNums.Count == 0)
                {
                    // No open invoices yet — this is a prepayment; hold it for the next invoice
                    pendingPayments.Add(new QbPaymentRecord(txDate, remaining,
                        string.IsNullOrEmpty(num) ? null : num,
                        string.IsNullOrEmpty(acctName) ? null : acctName));
                }
                else
                {
                    // Apply FIFO to open invoices
                    foreach (var invNum in openInvoiceNums.ToList())
                    {
                        if (remaining <= 0) break;
                        if (!groups.TryGetValue(invNum, out var g)) continue;

                        var invoiceTotal = g.Lines.Sum(l => l.Amount);
                        var alreadyPaid = g.AmountPaid;
                        var outstanding = invoiceTotal - alreadyPaid;
                        if (outstanding <= 0)
                        {
                            openInvoiceNums.Remove(invNum);
                            continue;
                        }

                        var apply = Math.Min(remaining, outstanding);
                        g.AmountPaid += apply;
                        g.PaymentRecords.Add(new QbPaymentRecord(txDate, apply,
                            string.IsNullOrEmpty(num) ? null : num,
                            string.IsNullOrEmpty(acctName) ? null : acctName));
                        remaining -= apply;

                        if (g.AmountPaid >= invoiceTotal)
                            openInvoiceNums.Remove(invNum);
                    }
                }
            }
        }

        return orderedKeys.Select(k => groups[k]).ToList();
    }

    /// <summary>
    /// Parser for QB Desktop "Sales by Customer" / Invoice Detail CSV export.
    /// Columns: [0] Customer label or blank, [1] Type, [2] Date, [3] Num,
    /// [4] Memo, [5] Name, [6] Item, [7] Qty, [8] SalesPrice, [9] Amount, [10] Balance
    /// </summary>
    /// <summary>
    /// Parses the "Sales by Customer" / Invoice Detail CSV export from QuickBooks Desktop.
    /// Groups individual line rows into <see cref="QbInvoiceGroup"/> objects keyed by QB invoice number.
    /// <para>
    /// The format has one row per line item; the first Invoice row for a given QB number creates
    /// the group. Subsequent rows for the same QB number append additional <see cref="QbInvoiceLine"/>
    /// entries. <c>AmountPaid</c> is left at 0 — this format does not include payment rows;
    /// payment reconciliation happens in <see cref="ParseQbBalanceDetailCsv"/> or during the
    /// transaction import step.
    /// </para>
    /// </summary>
    private static List<QbInvoiceGroup> ParseQbInvoiceDetailCsv(string[] lines)
    {
        var groups = new Dictionary<string, QbInvoiceGroup>(StringComparer.OrdinalIgnoreCase);
        var orderedKeys = new List<string>();

        for (int i = 1; i < lines.Length; i++)
        {
            var rawLine = lines[i].TrimEnd('\r');
            if (string.IsNullOrWhiteSpace(rawLine)) continue;

            var cols = ParseCsvLine(rawLine);
            if (cols.Count < 10) continue;

            if (!string.Equals(cols[1].Trim(), "Invoice", StringComparison.OrdinalIgnoreCase)) continue;

            var customerName = cols[5].Trim();
            var dateStr      = cols[2].Trim();
            var qbNum        = cols[3].Trim();
            var memo         = cols[4].Trim();
            var itemPath     = cols[6].Trim();
            var qtyRaw       = cols[7].Trim();
            var priceRaw     = cols[8].Trim();
            var amountStr    = cols[9].Trim();

            if (string.IsNullOrEmpty(qbNum) || string.IsNullOrEmpty(customerName)) continue;

            if (!DateTime.TryParseExact(dateStr, "MM/dd/yyyy", CultureInfo.InvariantCulture, DateTimeStyles.None, out var invoiceDate)
                && !DateTime.TryParse(dateStr, CultureInfo.InvariantCulture, DateTimeStyles.None, out invoiceDate))
                continue;

            if (!decimal.TryParse(amountStr, NumberStyles.Any, CultureInfo.InvariantCulture, out var amount))
                continue;

            if (!groups.ContainsKey(qbNum))
            {
                groups[qbNum] = new QbInvoiceGroup(customerName, qbNum, invoiceDate);
                orderedKeys.Add(qbNum);
            }

            groups[qbNum].Lines.Add(new QbInvoiceLine
            {
                Memo          = memo,
                ItemPath      = itemPath,
                QtyRaw        = qtyRaw,
                SalesPriceRaw = priceRaw,
                Amount        = amount
            });
        }

        return orderedKeys.Select(k => groups[k]).ToList();
    }

    /// <summary>
    /// Extracts the short item name from a QB item path.
    /// QB format: "Category:SubCat:Item Name (Short Name)" — returns "Short Name".
    /// Handles truncated paths ending with "...".
    /// </summary>
    private static string ExtractQbItemShortName(string itemPath)
    {
        if (string.IsNullOrWhiteSpace(itemPath)) return itemPath;

        var clean = itemPath.TrimEnd();
        // Drop trailing ellipsis from truncated paths
        if (clean.EndsWith("...", StringComparison.Ordinal))
            clean = clean[..^3];

        var lastClose = clean.LastIndexOf(')');
        if (lastClose < 0) return clean.Split(':').Last().Trim();

        var lastOpen = clean.LastIndexOf('(', lastClose);
        if (lastOpen < 0) return clean.Split(':').Last().Trim();

        return clean.Substring(lastOpen + 1, lastClose - lastOpen - 1).Trim();
    }

    /// <summary>
    /// Parses a single RFC 4180-compliant CSV line into a list of field values.
    /// Delegates to <see cref="ParseDelimitedLine"/> with <c>','</c> as the delimiter.
    /// </summary>
    private static List<string> ParseCsvLine(string line) => ParseDelimitedLine(line, ',');

    /// <summary>
    /// Tokenises a delimited line respecting RFC 4180 quoting rules:
    /// fields wrapped in double-quotes may contain the delimiter character and embedded newlines;
    /// a doubled double-quote (<c>""</c>) inside a quoted field represents a literal double-quote.
    /// <para>
    /// Used for both comma-delimited CSV and tab-delimited IIF lines (via the <paramref name="delimiter"/>
    /// parameter) so that the same robust parsing logic covers all input formats handled by this service.
    /// </para>
    /// </summary>
    private static List<string> ParseDelimitedLine(string line, char delimiter)
    {
        var fields = new List<string>();
        var current = new StringBuilder();
        bool inQuotes = false;

        for (int i = 0; i < line.Length; i++)
        {
            char c = line[i];
            if (inQuotes)
            {
                if (c == '"')
                {
                    if (i + 1 < line.Length && line[i + 1] == '"')
                    {
                        current.Append('"');
                        i++; // skip escaped quote
                    }
                    else
                    {
                        inQuotes = false;
                    }
                }
                else
                {
                    current.Append(c);
                }
            }
            else
            {
                if (c == '"')
                    inQuotes = true;
                else if (c == delimiter)
                {
                    fields.Add(current.ToString());
                    current.Clear();
                }
                else
                    current.Append(c);
            }
        }

        fields.Add(current.ToString());
        return fields;
    }

    #endregion

    #region QB Transactions CSV Import

    // -----------------------------------------------------------------------
    // Private helpers
    // -----------------------------------------------------------------------

    private sealed class QbTransactionRow
    {
        public string CustomerName { get; set; } = string.Empty;
        public string Type { get; set; } = string.Empty;   // "Invoice", "Payment", "Estimate"
        public DateTime Date { get; set; }
        public string Num { get; set; } = string.Empty;    // QB invoice # (on Invoice rows) or check # (on Payment rows)
        public string Memo { get; set; } = string.Empty;
        public string Account { get; set; } = string.Empty; // e.g. "10200 · Wells Fargo Checking"
        public string Split { get; set; } = string.Empty;
        public decimal Amount { get; set; }
    }

    // -----------------------------------------------------------------------
    // Public implementation
    // -----------------------------------------------------------------------

    /// <summary>
    /// Imports QuickBooks payment transactions from a "Customer Transaction Detail" CSV export
    /// and creates <see cref="Payment"/> records linked to previously-imported invoices.
    /// <para>
    /// <b>Pre-flight requirement:</b> QB-sourced invoices (those with a non-null
    /// <see cref="Invoice.ExternalReference"/>) must exist before this step can run.
    /// If none are found, the method returns an error directing the user to run the invoice
    /// import first.
    /// </para>
    /// <para>
    /// <b>Matching strategy:</b> Rows are grouped by customer name. Within each group the
    /// importer processes rows in file order: each Invoice row pushes the QB invoice number
    /// onto a pending queue; each Payment row pops the oldest invoice (FIFO) and applies the
    /// payment amount. Payments that arrive before their invoice row (prepayments) are held in
    /// a separate list and drained when the matching invoice is later encountered.
    /// </para>
    /// <para>
    /// <b>Already-settled invoices:</b> When an invoice was imported with <c>AmountPaid</c>
    /// pre-set from the balance-detail file, its balance is already zero. The importer still
    /// creates Payment records for historical audit trail (check numbers, exact dates, payment
    /// method) without changing <c>AmountPaid</c> or <c>Status</c>.
    /// </para>
    /// <para>
    /// <b>Session payment tracking:</b> Because EF does not immediately reflect newly-added
    /// Payment records in <c>invoice.Payments</c> within the same request, the importer
    /// maintains a <c>sessionPaymentsAdded</c> dictionary to track the running total of payments
    /// added during this import run, enabling correct duplicate detection.
    /// </para>
    /// <para>
    /// <b>Item description enrichment:</b> When an Invoice row has a non-generic <c>Memo</c>
    /// value, the first invoice line item's description is updated from the generic
    /// "QB Invoice #NNN" placeholder to the actual memo text.
    /// </para>
    /// </summary>
    public async Task<ImportResultDto> ImportQbTransactionsFromCsvAsync(
        IFormFile file, int companyId, string userId)
    {
        var result = new ImportResultDto();

        try
        {
            // ── 0. Pre-flight: make sure there are imported invoices to match against ──
            var existingInvoices = (await _unitOfWork.Invoices.FindAsync(
                    i => i.CompanyId == companyId && i.ExternalReference != null,
                    false,
                    i => i.Payments,
                    i => i.InvoiceItems))
                .ToList();

            if (existingInvoices.Count == 0)
            {
                result.Success = false;
                result.Errors.Add(new ImportErrorDto
                {
                    LineNumber = 0,
                    ErrorMessage = "No QB-imported invoices found. Import QB invoices first, then import transactions."
                });
                return result;
            }

            // Build lookup by ExternalReference (QB invoice #) — case-insensitive
            var invoiceByRef = existingInvoices
                .Where(i => !string.IsNullOrWhiteSpace(i.ExternalReference))
                .ToDictionary(i => i.ExternalReference!.Trim(), i => i, StringComparer.OrdinalIgnoreCase);

            _logger.LogInformation("QB Txn Import: {DbCount} invoices loaded into lookup (ExternalReference != null), {TotalCount} total imported invoices",
                invoiceByRef.Count, existingInvoices.Count);

            // ── Load bank/deposit accounts for DepositAccountId resolution ──
            var allAccounts = await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == companyId && !a.IsDeleted);
            var acctByNumber = allAccounts
                .Where(a => !string.IsNullOrEmpty(a.AccountNumber))
                .ToDictionary(a => a.AccountNumber.Trim(), a => a, StringComparer.OrdinalIgnoreCase);
            var acctByName = allAccounts
                .GroupBy(a => a.Name.Trim(), StringComparer.OrdinalIgnoreCase)
                .ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase);

            // ── 1. Read file ──
            string content;
            using (var reader = new StreamReader(file.OpenReadStream(), Encoding.UTF8, detectEncodingFromByteOrderMarks: true))
                content = await reader.ReadToEndAsync();

            // ── 2. Parse rows ──
            var rows = ParseQbTransactionsCsv(content);

            // ── 3. Group into (customer → ordered list of rows) then process ──
            //     For each customer group, iterate rows in order.
            //     Each "Invoice" row pushes its QB Num onto a pending queue.
            //     Each "Payment" row pops the oldest pending invoice and records a Payment.

            var grouped = rows
                .GroupBy(r => r.CustomerName, StringComparer.OrdinalIgnoreCase)
                .ToList();

            int lineNum = 0;
            int totalPaymentRows = rows.Count(r => r.Type.Equals("Payment", StringComparison.OrdinalIgnoreCase));
            result.TotalRecords = totalPaymentRows;

            _logger.LogInformation("QB Txn Import: CSV has {InvoiceRows} Invoice rows, {PaymentRows} Payment rows, {CustomerGroups} customer groups",
                rows.Count(r => r.Type.Equals("Invoice", StringComparison.OrdinalIgnoreCase)),
                totalPaymentRows,
                grouped.Count);

            // Skip-reason counters for diagnostics
            int skipNotInDb = 0;
            int skipAlreadyRecorded = 0;
            var firstNotFoundNums = new List<string>(); // sample of invoice nums not found in DB

            // Tracks how much payment history has been added (in this session) for invoices
            // that were already settled when imported. Keyed by QB invoice num.
            // Needed because invoice.Payments is a DB snapshot and won't include records
            // added earlier in the same import run.
            var sessionPaymentsAdded = new Dictionary<string, decimal>(StringComparer.OrdinalIgnoreCase);

            foreach (var customerGroup in grouped)
            {
                // Queue entries: (QB invoice num, found-in-DB flag).
                // We track FoundInDb so that multiple payments for the same un-imported
                // invoice are all silently skipped rather than erroring after the first.
                var pendingInvoiceQueue = new Queue<(string Num, bool FoundInDb)>();

                // Payments that arrive before their invoice (prepayments / deposits).
                // Drained FIFO when the matching invoice row is later encountered.
                var prepaymentRows = new List<QbTransactionRow>();

                foreach (var row in customerGroup)
                {
                    lineNum++;

                    if (row.Type.Equals("Invoice", StringComparison.OrdinalIgnoreCase))
                    {
                        if (string.IsNullOrWhiteSpace(row.Num))
                            continue;

                        var num = row.Num.Trim();
                        bool foundInDb = invoiceByRef.ContainsKey(num);
                        pendingInvoiceQueue.Enqueue((num, foundInDb));

                        // Update invoice item description from transactions CSV memo
                        if (foundInDb && !string.IsNullOrWhiteSpace(row.Memo))
                        {
                            var inv = invoiceByRef[num];
                            var item = inv.InvoiceItems.FirstOrDefault();
                            if (item != null &&
                                (item.Description.StartsWith("QB Invoice #", StringComparison.OrdinalIgnoreCase) ||
                                 item.Description.Equals("Imported from QuickBooks", StringComparison.OrdinalIgnoreCase)))
                            {
                                item.Description = row.Memo.Trim();
                                item.UpdatedAt = DateTime.UtcNow;
                                item.UpdatedBy = userId;
                                await _unitOfWork.InvoiceItems.UpdateAsync(item);
                            }
                        }

                        // Drain any prepayments against this newly-seen invoice
                        if (foundInDb && prepaymentRows.Count > 0)
                        {
                            var inv = invoiceByRef[num];
                            foreach (var pp in prepaymentRows.ToList())
                            {
                                var bd = inv.Total - inv.AmountPaid - inv.CreditApplied - inv.GiftCertificateRedeemed;
                                if (bd <= 0m) break;

                                var apply = Math.Min(pp.Amount, bd);
                                var ppDepositAcct = ResolveAccountFromQbString(pp.Account, acctByNumber, acctByName);
                                await _unitOfWork.Payments.AddAsync(new Payment
                                {
                                    InvoiceId = inv.Id,
                                    CompanyId = companyId,
                                    Amount = apply,
                                    PaymentDate = pp.Date,
                                    PaymentMethod = DetectPaymentMethod(pp.Account),
                                    DepositAccountId = ppDepositAcct?.Id,
                                    Reference = string.IsNullOrWhiteSpace(pp.Num) ? null : pp.Num.Trim(),
                                    Notes = string.IsNullOrWhiteSpace(pp.Memo) ? "Imported from QuickBooks (prepayment)" : pp.Memo.Trim(),
                                    RecordedById = userId,
                                    CreatedAt = DateTime.UtcNow,
                                    CreatedBy = userId
                                });

                                inv.AmountPaid += apply;
                                if (inv.Total - inv.AmountPaid - inv.CreditApplied - inv.GiftCertificateRedeemed <= 0m)
                                {
                                    inv.Status = InvoiceStatus.Paid;
                                    inv.PaidDate = pp.Date;
                                    pendingInvoiceQueue.Dequeue(); // fully paid by prepayment(s)
                                }
                                else
                                {
                                    inv.Status = InvoiceStatus.PartiallyPaid;
                                }
                                inv.UpdatedAt = DateTime.UtcNow;
                                inv.UpdatedBy = userId;
                                await _unitOfWork.Invoices.UpdateAsync(inv);

                                prepaymentRows.Remove(pp);
                                result.ImportedCount++;
                            }
                        }
                        continue;
                    }

                    if (!row.Type.Equals("Payment", StringComparison.OrdinalIgnoreCase))
                        continue; // Skip Estimates and anything else

                    // ── Match payment to oldest pending invoice ──

                    // Empty queue → prepayment (payment before invoice row in CSV)
                    if (pendingInvoiceQueue.Count == 0)
                    {
                        prepaymentRows.Add(row);
                        continue;
                    }

                    var (qbInvoiceNum, qbFoundInDb) = pendingInvoiceQueue.Peek();

                    if (!qbFoundInDb)
                    {
                        // Invoice wasn't imported — silently skip all its payments.
                        // Leave it in the queue so the next payment also skips; the
                        // invoice is dequeued when the next Invoice row arrives.
                        skipNotInDb++;
                        result.SkippedCount++;
                        if (firstNotFoundNums.Count < 10 && !firstNotFoundNums.Contains(qbInvoiceNum))
                            firstNotFoundNums.Add(qbInvoiceNum);
                        continue;
                    }

                    var invoice = invoiceByRef[qbInvoiceNum];

                    var balanceDue = invoice.Total - invoice.AmountPaid - invoice.CreditApplied - invoice.GiftCertificateRedeemed;
                    var alreadySettled = balanceDue <= 0m;

                    if (alreadySettled)
                    {
                        // Invoice was imported with AmountPaid pre-set from the balance detail
                        // file (Invoices.CSV). We still need to create Payment records for
                        // history (check numbers, dates, payment methods). Don't change AmountPaid.

                        // Guard: if enough payment history already exists (DB + this session),
                        // treat as duplicate and move on.
                        sessionPaymentsAdded.TryGetValue(qbInvoiceNum, out var sessionAdded);
                        var totalRecorded = invoice.Payments.Sum(p => p.Amount) + sessionAdded;
                        if (totalRecorded >= invoice.AmountPaid - 0.01m)
                        {
                            skipAlreadyRecorded++;
                            result.AlreadyRecordedCount++;
                            pendingInvoiceQueue.Dequeue();
                            continue;
                        }

                        var histDepositAcct = ResolveAccountFromQbString(row.Account, acctByNumber, acctByName);
                        var histPayment = new Payment
                        {
                            InvoiceId = invoice.Id,
                            CompanyId = companyId,
                            Amount = row.Amount,
                            PaymentDate = row.Date,
                            PaymentMethod = DetectPaymentMethod(row.Account),
                            DepositAccountId = histDepositAcct?.Id,
                            Reference = string.IsNullOrWhiteSpace(row.Num) ? null : row.Num.Trim(),
                            Notes = string.IsNullOrWhiteSpace(row.Memo) ? "Imported from QuickBooks" : row.Memo.Trim(),
                            RecordedById = userId,
                            CreatedAt = DateTime.UtcNow,
                            CreatedBy = userId
                        };
                        await _unitOfWork.Payments.AddAsync(histPayment);

                        var newSessionAdded = sessionAdded + row.Amount;
                        sessionPaymentsAdded[qbInvoiceNum] = newSessionAdded;

                        // Dequeue once total recorded history matches the invoice AmountPaid
                        if (invoice.Payments.Sum(p => p.Amount) + newSessionAdded >= invoice.AmountPaid - 0.01m)
                            pendingInvoiceQueue.Dequeue();

                        invoice.UpdatedAt = DateTime.UtcNow;
                        invoice.UpdatedBy = userId;
                        await _unitOfWork.Invoices.UpdateAsync(invoice);
                        result.ImportedCount++;
                        continue;
                    }

                    // ── Normal case: invoice has an outstanding balance ──

                    // Determine payment method and deposit account from Account name
                    var paymentMethod = DetectPaymentMethod(row.Account);
                    var depositAccount = ResolveAccountFromQbString(row.Account, acctByNumber, acctByName);

                    // Apply payment (cap at balance due)
                    var applyAmount = Math.Min(row.Amount, balanceDue);

                    var payment = new Payment
                    {
                        InvoiceId = invoice.Id,
                        CompanyId = companyId,
                        Amount = applyAmount,
                        PaymentDate = row.Date,
                        PaymentMethod = paymentMethod,
                        DepositAccountId = depositAccount?.Id,
                        Reference = string.IsNullOrWhiteSpace(row.Num) ? null : row.Num.Trim(),
                        Notes = string.IsNullOrWhiteSpace(row.Memo) ? "Imported from QuickBooks" : row.Memo.Trim(),
                        RecordedById = userId,
                        CreatedAt = DateTime.UtcNow,
                        CreatedBy = userId
                    };

                    await _unitOfWork.Payments.AddAsync(payment);

                    invoice.AmountPaid += applyAmount;
                    var newBalance = invoice.Total - invoice.AmountPaid - invoice.CreditApplied - invoice.GiftCertificateRedeemed;

                    if (newBalance <= 0m)
                    {
                        invoice.Status = InvoiceStatus.Paid;
                        invoice.PaidDate = row.Date;
                        // Fully paid — dequeue so next payment (if any) hits the next invoice
                        pendingInvoiceQueue.Dequeue();
                    }
                    else
                    {
                        invoice.Status = InvoiceStatus.PartiallyPaid;
                        // Keep invoice in queue; remaining payments may continue to apply
                    }

                    invoice.UpdatedAt = DateTime.UtcNow;
                    invoice.UpdatedBy = userId;
                    await _unitOfWork.Invoices.UpdateAsync(invoice);

                    result.ImportedCount++;
                }

                // Drain stale "not found" invoice from the front before next customer.
                // (Any remaining prepaymentRows for this customer have no invoice — drop them.)
            }

            _logger.LogInformation(
                "QB Txn Import complete: {Imported} imported, {SkipNotInDb} skipped-not-in-db, {SkipDupe} skipped-already-recorded. " +
                "First not-found invoice nums: [{Nums}]",
                result.ImportedCount, skipNotInDb, skipAlreadyRecorded,
                string.Join(", ", firstNotFoundNums));

            await _unitOfWork.CompleteAsync();

            result.Success = true;
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Error importing QB transactions CSV for company {CompanyId}", companyId);
            result.Success = false;
            result.Errors.Add(new ImportErrorDto { LineNumber = 0, ErrorMessage = $"Import failed: {ex.Message}" });
        }

        return result;
    }

    // -----------------------------------------------------------------------
    // ParseQbTransactionsCsv
    // Format: ,"Type","Date","Num","Memo","Account","Clr","Split","Amount"
    // Customer header rows: col[0] = customer name, all other cols empty
    // -----------------------------------------------------------------------
    /// <summary>
    /// Parses the QuickBooks Desktop "Customer Transaction Detail" CSV report into a flat list
    /// of <see cref="QbTransactionRow"/> objects.
    /// <para>
    /// The QB report format has customer header rows (col[0] = customer name, col[1] empty)
    /// that establish the current customer for subsequent transaction rows (col[0] empty, col[1] = type).
    /// Column header rows (col[1] = "Type") are skipped.
    /// </para>
    /// <para>
    /// Only Invoice, Payment, and Estimate row types are emitted; all other types (Credit Memo,
    /// Journal Entry, etc.) are silently dropped since they have no direct equivalent in the
    /// import flow.
    /// </para>
    /// <para>
    /// The report columns are: blank, Type, Date, Num, Memo, Account, Clr, Split, Amount.
    /// </para>
    /// </summary>
    private static List<QbTransactionRow> ParseQbTransactionsCsv(string content)
    {
        var rows = new List<QbTransactionRow>();
        var lines = content.Split('\n');
        string currentCustomer = string.Empty;

        foreach (var rawLine in lines)
        {
            var line = rawLine.TrimEnd('\r');
            if (string.IsNullOrWhiteSpace(line)) continue;

            var cols = ParseCsvLine(line);
            while (cols.Count < 9) cols.Add(string.Empty);

            // Customer header: col[0] has a name, col[1] is empty
            if (!string.IsNullOrWhiteSpace(cols[0]) && string.IsNullOrWhiteSpace(cols[1]))
            {
                currentCustomer = cols[0].Trim();
                continue;
            }

            // Column header row
            if (cols[1].Equals("Type", StringComparison.OrdinalIgnoreCase)) continue;

            // Transaction row: col[0] empty, col[1] = type
            var type = cols[1].Trim();
            if (string.IsNullOrWhiteSpace(type)) continue;
            if (!type.Equals("Invoice", StringComparison.OrdinalIgnoreCase)
                && !type.Equals("Payment", StringComparison.OrdinalIgnoreCase)
                && !type.Equals("Estimate", StringComparison.OrdinalIgnoreCase))
                continue;

            if (!DateTime.TryParseExact(cols[2].Trim(), "MM/dd/yyyy", CultureInfo.InvariantCulture, DateTimeStyles.None, out var date))
                continue;

            _ = decimal.TryParse(cols[8].Trim(), NumberStyles.Any, CultureInfo.InvariantCulture, out var amount);

            rows.Add(new QbTransactionRow
            {
                CustomerName = currentCustomer,
                Type = type,
                Date = date,
                Num = cols[3].Trim(),
                Memo = cols[4].Trim(),
                Account = cols[5].Trim(),
                Split = cols[7].Trim(),
                Amount = amount
            });
        }

        return rows;
    }

    /// <summary>
    /// Infers the <see cref="PaymentMethod"/> enum value from a QuickBooks account name string.
    /// <para>
    /// QuickBooks records the bank/clearing account name on payment rows rather than a payment type.
    /// This method performs a keyword scan to map account names to payment methods:
    /// <list type="bullet">
    ///   <item><description>"Undeposited Funds" → <see cref="PaymentMethod.Cash"/> (QB uses this for un-deposited cash/checks)</description></item>
    ///   <item><description>PayPal / Venmo / Zelle / CashApp / Square → <see cref="PaymentMethod.DigitalPayment"/></description></item>
    ///   <item><description>Credit / Debit / Visa / Mastercard / Amex → <see cref="PaymentMethod.CreditDebitCard"/></description></item>
    ///   <item><description>All other bank/checking accounts → <see cref="PaymentMethod.Check"/> (default for bank-routed payments)</description></item>
    /// </list>
    /// Returns <see cref="PaymentMethod.Cash"/> for null/empty account names as a safe fallback.
    /// </para>
    /// </summary>
    private static PaymentMethod DetectPaymentMethod(string accountName)
    {
        if (string.IsNullOrWhiteSpace(accountName))
            return PaymentMethod.Cash;

        var lower = accountName.ToLowerInvariant();

        if (lower.Contains("undeposited"))
            return PaymentMethod.Cash;

        if (lower.Contains("paypal") || lower.Contains("venmo") || lower.Contains("zelle")
            || lower.Contains("cashapp") || lower.Contains("square"))
            return PaymentMethod.DigitalPayment;

        if (lower.Contains("credit") || lower.Contains("debit") || lower.Contains("visa")
            || lower.Contains("mastercard") || lower.Contains("amex"))
            return PaymentMethod.CreditDebitCard;

        // Bank/checking accounts → Check or ACH; default to Check
        return PaymentMethod.Check;
    }

    #endregion

    #region QB Chart of Accounts Import

    /// <summary>
    /// Imports a QuickBooks Desktop IIF Chart of Accounts export and upserts <see cref="Account"/> records.
    /// <para>
    /// <b>Two-pass processing:</b> Top-level accounts (no colon in <c>NAME</c>) are processed first,
    /// then sub-accounts. This ensures parent accounts always exist before their children attempt
    /// to set <c>ParentAccountId</c>, avoiding foreign-key violations even though QuickBooks exports
    /// them in a single flat list.
    /// </para>
    /// <para>
    /// <b>Opening balance sign convention:</b> QuickBooks exports credit-normal accounts (Revenue,
    /// Liability, Equity) with negative <c>OBAMOUNT</c> values (debits are positive in QB's
    /// general ledger view). This system stores <c>OpeningBalance</c> as a positive magnitude for
    /// all account types (sign direction is implied by <c>AccountType</c>), so the import takes
    /// <c>Math.Abs</c> for those account types.
    /// </para>
    /// <para>
    /// <b>Upsert matching:</b> Accounts are matched by account number first (most reliable), then
    /// by display name within the same account type. The type guard on name-matching prevents a
    /// sub-account (e.g. <c>"Sales:Shop Supplies"</c>) from overwriting a top-level account with
    /// the same display name but a different type.
    /// </para>
    /// <para>
    /// <b>Auto-numbering:</b> Accounts that QB exported without an account number receive
    /// auto-assigned numbers from standard chart-of-accounts ranges (Assets 1000–1999,
    /// Liabilities 2000–2999, etc.) in increments of 10, avoiding collisions with any
    /// numbers already present in the DB or the import file.
    /// </para>
    /// <para>
    /// <b>Non-posting accounts</b> (type <c>NONPOSTING</c>) are skipped — these are QB internal
    /// ledgers for Estimates and Purchase Orders with no accounting significance.
    /// </para>
    /// </summary>
    public async Task<ImportResultDto> ImportChartOfAccountsAsync(IFormFile file, int companyId, string userId)
    {
        var result = new ImportResultDto();

        try
        {
            if (file == null || file.Length == 0)
            {
                result.Errors.Add(new ImportErrorDto { ErrorMessage = "No file provided." });
                return result;
            }

            // Read all lines (IIF is tab-delimited)
            using var reader = new StreamReader(file.OpenReadStream(), Encoding.UTF8, detectEncodingFromByteOrderMarks: true);
            var allLines = new List<string>();
            while (!reader.EndOfStream)
            {
                var line = await reader.ReadLineAsync();
                if (!string.IsNullOrWhiteSpace(line))
                    allLines.Add(line);
            }

            // Find the !ACCNT header line to get column positions
            string[]? headers = null;
            var dataRows = new List<string[]>();

            foreach (var line in allLines)
            {
                var clean = line.TrimStart('\uFEFF');
                var fields = clean.Split(TabDelimiter);

                if (fields[0].Equals("!ACCNT", StringComparison.OrdinalIgnoreCase))
                {
                    headers = fields;
                }
                else if (fields[0].Equals("ACCNT", StringComparison.OrdinalIgnoreCase) && headers != null)
                {
                    dataRows.Add(fields);
                }
            }

            if (headers == null || dataRows.Count == 0)
            {
                result.Errors.Add(new ImportErrorDto { ErrorMessage = "No ACCNT records found. Make sure this is a QuickBooks IIF file exported from Lists → Chart of Accounts." });
                return result;
            }

            int idxName     = Array.FindIndex(headers, h => h.Equals("NAME",      StringComparison.OrdinalIgnoreCase));
            int idxType     = Array.FindIndex(headers, h => h.Equals("ACCNTTYPE", StringComparison.OrdinalIgnoreCase));
            int idxBalance  = Array.FindIndex(headers, h => h.Equals("OBAMOUNT",  StringComparison.OrdinalIgnoreCase));
            int idxDesc     = Array.FindIndex(headers, h => h.Equals("DESC",      StringComparison.OrdinalIgnoreCase));
            int idxAccNum   = Array.FindIndex(headers, h => h.Equals("ACCNUM",    StringComparison.OrdinalIgnoreCase));
            int idxHidden   = Array.FindIndex(headers, h => h.Equals("HIDDEN",    StringComparison.OrdinalIgnoreCase));

            if (idxName < 0 || idxType < 0)
            {
                result.Errors.Add(new ImportErrorDto { ErrorMessage = "Could not find required NAME or ACCNTTYPE columns in the IIF file." });
                return result;
            }

            // Load existing accounts for upsert
            var existing = await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == companyId && !a.IsDeleted);
            var byNumber = existing
                .Where(a => !string.IsNullOrEmpty(a.AccountNumber))
                .ToDictionary(a => a.AccountNumber.Trim(), a => a, StringComparer.OrdinalIgnoreCase);
            var byQbName = existing
                .ToDictionary(a => a.Name.Trim(), a => a, StringComparer.OrdinalIgnoreCase);

            // Declared outside so they're accessible after the lambda (assigned at end of lambda)
            var qbNameToAccount = new Dictionary<string, Account>(StringComparer.OrdinalIgnoreCase);
            int imported = 0, updated = 0, skipped = 0;

            await _unitOfWork.ExecuteInTransactionAsync(async () =>
            {
                // Reset all state so retries start clean
                qbNameToAccount.Clear();
                imported = 0;
                updated = 0;
                skipped = 0;
                result.TotalRecords = 0;
                result.Errors.Clear();

                // ── Auto-numbering for accounts that QB exported without a number ──────────
                // Build a set of every account number already in use (DB + explicit imports)
                // so generated numbers never collide with either source.
                var usedNumbers = existing
                    .Where(a => !string.IsNullOrEmpty(a.AccountNumber))
                    .Select(a => a.AccountNumber!.Trim())
                    .Where(n => int.TryParse(n, out _))
                    .Select(int.Parse)
                    .ToHashSet();

                if (idxAccNum >= 0)
                {
                    foreach (var r in dataRows)
                    {
                        var n = GetField(r, idxAccNum);
                        if (!string.IsNullOrEmpty(n) && int.TryParse(n, out var parsed))
                            usedNumbers.Add(parsed);
                    }
                }

                // Standard chart-of-accounts ranges, incremented by 10
                var typeRangeStart = new Dictionary<AccountType, int>
                {
                    [AccountType.Asset]       = 1000,
                    [AccountType.Liability]   = 2000,
                    [AccountType.Equity]      = 3000,
                    [AccountType.Revenue]     = 4000,
                    [AccountType.CostOfGoods] = 5000,
                    [AccountType.Expense]     = 6000,
                };
                var nextNumberByType = new Dictionary<AccountType, int>();

                string AssignAccountNumber(AccountType t)
                {
                    if (!typeRangeStart.TryGetValue(t, out var start)) return string.Empty;
                    var end = start + 999;

                    if (!nextNumberByType.ContainsKey(t))
                    {
                        var maxUsed = usedNumbers.Where(n => n >= start && n <= end)
                                                 .DefaultIfEmpty(start - 10).Max();
                        var seed = (maxUsed / 10) * 10 + 10;
                        nextNumberByType[t] = seed < start ? start : seed;
                    }

                    while (usedNumbers.Contains(nextNumberByType[t]) && nextNumberByType[t] <= end)
                        nextNumberByType[t] += 10;

                    if (nextNumberByType[t] > end) return string.Empty; // range exhausted (extremely unlikely)

                    usedNumbers.Add(nextNumberByType[t]);
                    var num = nextNumberByType[t].ToString();
                    nextNumberByType[t] += 10;
                    return num;
                }
                // ─────────────────────────────────────────────────────────────────────────────

                // Two passes: first top-level accounts, then sub-accounts
                var topLevel = dataRows.Where(r => !GetField(r, idxName).Contains(':')).ToList();
                var subLevel = dataRows.Where(r =>  GetField(r, idxName).Contains(':')).ToList();

                foreach (var pass in new[] { topLevel, subLevel })
                {
                    foreach (var row in pass)
                    {
                        result.TotalRecords++;

                        var qbFullName  = GetField(row, idxName);
                        var accntType   = GetField(row, idxType);
                        var accNum      = idxAccNum >= 0 ? GetField(row, idxAccNum) : string.Empty;
                        var desc        = idxDesc   >= 0 ? GetField(row, idxDesc)   : string.Empty;
                        var balanceStr  = idxBalance >= 0 ? GetField(row, idxBalance) : string.Empty;
                        var hiddenStr   = idxHidden  >= 0 ? GetField(row, idxHidden)  : "N";

                        // Skip non-posting accounts (Estimates, Purchase Orders, etc.) — QB internal use only
                        if (accntType.Equals("NONPOSTING", StringComparison.OrdinalIgnoreCase))
                        {
                            result.TotalRecords--;
                            continue;
                        }

                        // Parse opening balance (QB wraps comma-formatted numbers in quotes)
                        balanceStr = balanceStr.Trim('"').Replace(",", "");
                        decimal.TryParse(balanceStr, NumberStyles.Any, CultureInfo.InvariantCulture, out var openingBalance);

                        // Determine display name and parent
                        string displayName;
                        int? parentId = null;

                        if (qbFullName.Contains(':'))
                        {
                            var lastColon = qbFullName.LastIndexOf(':');
                            var parentQbName = qbFullName[..lastColon];
                            displayName = qbFullName[(lastColon + 1)..];

                            if (qbNameToAccount.TryGetValue(parentQbName, out var parentAccount))
                                parentId = parentAccount.Id;
                        }
                        else
                        {
                            displayName = qbFullName;
                        }

                        var (accountType, subType) = MapQbAccountType(accntType, displayName);
                        var isActive = !hiddenStr.Equals("Y", StringComparison.OrdinalIgnoreCase);

                        // Auto-assign account number if QB didn't provide one
                        if (string.IsNullOrEmpty(accNum))
                            accNum = AssignAccountNumber(accountType);

                        // QB IIF exports credit-normal accounts (Revenue, Liability, Equity) with
                        // negative balance values. Our system stores OpeningBalance as a positive
                        // number for all account types (the sign direction is implied by account type).
                        if (accountType is AccountType.Revenue or AccountType.Liability or AccountType.Equity)
                            openingBalance = Math.Abs(openingBalance);

                        // Upsert: match by account number first, then by display name.
                        // Name match is only used when account types are compatible — prevents a
                        // sub-account (e.g. "Sales:Shop Supplies") from overwriting a top-level
                        // account with the same display name but a different type (e.g. "Shop Supplies" EXP).
                        Account? account = null;
                        if (!string.IsNullOrEmpty(accNum) && byNumber.TryGetValue(accNum, out var byNum))
                            account = byNum;
                        else if (byQbName.TryGetValue(displayName, out var byName)
                                 && byName.AccountType == accountType)
                            account = byName;

                        if (account != null)
                        {
                            account.Name            = displayName;
                            account.AccountNumber   = accNum;
                            account.AccountType     = accountType;
                            account.AccountSubType  = subType;
                            account.Description     = string.IsNullOrEmpty(desc) ? account.Description : desc;
                            account.OpeningBalance  = openingBalance;
                            account.ParentAccountId = parentId ?? account.ParentAccountId;
                            account.IsActive        = isActive;
                            account.UpdatedBy       = userId;
                            account.UpdatedAt       = DateTime.UtcNow;
                            await _unitOfWork.Accounts.UpdateAsync(account);
                            qbNameToAccount[qbFullName] = account;
                            updated++;
                        }
                        else
                        {
                            account = new Account
                            {
                                CompanyId       = companyId,
                                Name            = displayName,
                                AccountNumber   = accNum,
                                AccountType     = accountType,
                                AccountSubType  = subType,
                                Description     = string.IsNullOrEmpty(desc) ? null : desc,
                                OpeningBalance  = openingBalance,
                                ParentAccountId = parentId,
                                IsActive        = isActive,
                                IsSystem        = false,
                                CreatedBy       = userId,
                                CreatedAt       = DateTime.UtcNow,
                            };
                            await _unitOfWork.Accounts.AddAsync(account);
                            await _unitOfWork.CompleteAsync(); // flush to get Id for sub-account parent linking
                            qbNameToAccount[qbFullName] = account;
                            byNumber[accNum] = account;
                            byQbName[displayName] = account;
                            imported++;
                        }
                    }
                }

                await _unitOfWork.CompleteAsync();

                result.ImportedCount = imported;
                result.UpdatedCount  = updated;
                result.SkippedCount  = skipped;
                result.Success       = true;

                _logger.LogInformation(
                    "Chart of Accounts import complete for company {CompanyId}: {Created} created, {Updated} updated, {Skipped} skipped",
                    companyId, imported, updated, skipped);
            });
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Error importing Chart of Accounts for company {CompanyId}", companyId);
            result.Success = false;
            result.Errors.Add(new ImportErrorDto { ErrorMessage = $"Import failed: {ex.Message}" });
        }

        return result;
    }

    /// <summary>
    /// Retrieves a field by positional index from an IIF data row array.
    /// Strips surrounding double-quotes (QuickBooks wraps fields containing commas) and trims whitespace.
    /// Returns <see cref="string.Empty"/> when the index is out of bounds, allowing callers to
    /// use the result directly without bounds checking.
    /// </summary>
    private static string GetField(string[] row, int index)
        => index >= 0 && index < row.Length ? row[index].Trim('"').Trim() : string.Empty;

    /// <summary>
    /// Maps a QuickBooks IIF <c>ACCNTTYPE</c> string to the application's <see cref="AccountType"/>
    /// and <see cref="AccountSubType"/> enum pair.
    /// <para>
    /// QuickBooks account type codes are fixed strings (e.g. <c>BANK</c>, <c>AR</c>, <c>COGS</c>,
    /// <c>EXP</c>). The mapping is exhaustive for all types documented in the QuickBooks IIF
    /// specification; unrecognised types default to <c>Expense / Other</c> as a safe fallback.
    /// </para>
    /// <para>
    /// For <c>EXP</c> and <c>EXEXP</c> (other expense) types, the account name is used to
    /// infer a more specific <see cref="AccountSubType"/> via <see cref="MapExpenseSubType"/>.
    /// </para>
    /// </summary>
    private static (AccountType type, AccountSubType subType) MapQbAccountType(string qbType, string name)
    {
        var n = name.ToLowerInvariant();

        return qbType.ToUpperInvariant() switch
        {
            "BANK"       => (AccountType.Asset,     AccountSubType.Checking),
            "AR"         => (AccountType.Asset,     AccountSubType.AccountsReceivable),
            "OCASSET"    => (AccountType.Asset,     AccountSubType.OtherCurrentAsset),
            "FIXASSET"   => (AccountType.Asset,     AccountSubType.FixedAsset),
            "OASSET"     => (AccountType.Asset,     AccountSubType.OtherAsset),
            "AP"         => (AccountType.Liability, AccountSubType.AccountsPayable),
            "CCARD"      => (AccountType.Liability, AccountSubType.CreditCard),
            "OCLIAB"     => (AccountType.Liability, AccountSubType.OtherCurrentLiability),
            "LTLIAB"     => (AccountType.Liability, AccountSubType.LongTermLiability),
            "EQUITY"     => (AccountType.Equity,    AccountSubType.OwnersEquity),
            "INC"        => (AccountType.Revenue,   AccountSubType.Sales),
            "OTHERINC"   => (AccountType.Revenue,   AccountSubType.OtherIncome),
            "COGS"       => (AccountType.CostOfGoods, AccountSubType.CostOfGoodsSold),
            "EXP" or "EXEXP" => (AccountType.Expense, MapExpenseSubType(n)),
            _            => (AccountType.Expense,   AccountSubType.Other),
        };
    }

    /// <summary>
    /// Infers an expense <see cref="AccountSubType"/> from an account name using keyword matching.
    /// <para>
    /// QuickBooks Desktop exports all expense accounts with type <c>EXP</c>, losing the sub-type
    /// distinction that the application maintains. This heuristic restores a reasonable sub-type
    /// by scanning common keywords (rent, utilities, insurance, payroll, etc.) in the lowercased
    /// account name. Accounts that match no keyword default to <see cref="AccountSubType.Other"/>.
    /// </para>
    /// </summary>
    private static AccountSubType MapExpenseSubType(string nameLower)
    {
        if (nameLower.Contains("rent"))                                             return AccountSubType.Rent;
        if (nameLower.Contains("util"))                                             return AccountSubType.Utilities;
        if (nameLower.Contains("insur"))                                            return AccountSubType.Insurance;
        if (nameLower.Contains("advertis") || nameLower.Contains("promot"))        return AccountSubType.Advertising;
        if (nameLower.Contains("payroll"))                                          return AccountSubType.Payroll;
        if (nameLower.Contains("professional") || nameLower.Contains("legal"))     return AccountSubType.ProfessionalFees;
        if (nameLower.Contains("office"))                                           return AccountSubType.OfficeSupplies;
        if (nameLower.Contains("depreci"))                                          return AccountSubType.Depreciation;
        if (nameLower.Contains("bank") || nameLower.Contains("service charge"))    return AccountSubType.BankCharges;
        if (nameLower.Contains("travel"))                                           return AccountSubType.Travel;
        if (nameLower.Contains("meal") || nameLower.Contains("entertainment"))     return AccountSubType.Meals;
        if (nameLower.Contains("vehicle") || nameLower.Contains("auto"))           return AccountSubType.Vehicle;
        if (nameLower.Contains("shop") || nameLower.Contains("supplies")
            || nameLower.Contains("material") || nameLower.Contains("powder"))     return AccountSubType.SuppliesMaterials;
        if (nameLower.Contains("equipment"))                                        return AccountSubType.Equipment;
        return AccountSubType.Other;
    }

    #endregion

    #region QB Inventory Valuation Import

    /// <summary>
    /// Imports inventory stock levels and average costs from a QuickBooks Desktop
    /// "Inventory Valuation Summary" report exported as CSV.
    /// <para>
    /// <b>Format detection:</b> QB exports this report as either tab-delimited (Desktop) or
    /// comma-delimited (other paths). The first 10 non-empty lines are probed for tabs;
    /// if any contain a tab, tab-delimited parsing is used for the entire file.
    /// </para>
    /// <para>
    /// <b>Header-row discovery:</b> QB Desktop reports include several metadata rows at the top
    /// (company name, report title, date range). The importer scans up to the first 20 rows
    /// looking for a row containing "On Hand" (or "On-Hand") to identify the actual column headers
    /// and dynamically maps column positions for <c>On Hand</c>, <c>Avg Cost</c>, and
    /// <c>Pref Vendor</c>. This handles layout variation between QB versions.
    /// </para>
    /// <para>
    /// <b>Upsert logic:</b> Existing items are updated (quantity, average cost, preferred vendor);
    /// new items are created with category "Powder" and unit of measure "lbs" as defaults
    /// appropriate for powder coating inventory. An <see cref="InventoryTransaction"/> is written
    /// for each item with a non-zero quantity (<c>Initial</c> type for new items,
    /// <c>Adjustment</c> for existing) to maintain a complete audit trail.
    /// </para>
    /// <para>
    /// <b>Total rows:</b> Lines whose name starts with "Total" or equals "TOTAL" are silently
    /// skipped; they are QuickBooks subtotal/grand-total structural rows, not inventory items.
    /// </para>
    /// </summary>
    public async Task<ImportResultDto> ImportQbInventoryValuationAsync(IFormFile file, int companyId, string userId)
    {
        var result = new ImportResultDto();

        try
        {
            if (file == null || file.Length == 0)
            {
                result.Errors.Add(new ImportErrorDto { ErrorMessage = "No file provided." });
                return result;
            }

            var ext = Path.GetExtension(file.FileName).ToLowerInvariant();
            if (ext != ".csv")
            {
                result.Errors.Add(new ImportErrorDto { ErrorMessage = "File must be a .csv export from QuickBooks." });
                return result;
            }

            // Load vendors for name-based lookup
            var vendors = await _unitOfWork.Vendors.FindAsync(v => v.CompanyId == companyId && !v.IsDeleted);
            var vendorByName = vendors.ToDictionary(
                v => v.CompanyName.Trim().ToLowerInvariant(),
                v => v,
                StringComparer.OrdinalIgnoreCase);

            // Load existing inventory items for upsert
            var existingItems = await _unitOfWork.InventoryItems.FindAsync(i => i.CompanyId == companyId && !i.IsDeleted);
            var itemByName = existingItems.ToDictionary(
                i => i.Name.Trim().ToLowerInvariant(),
                i => i,
                StringComparer.OrdinalIgnoreCase);

            // Read all lines — QB reports have several metadata rows before the actual column headers
            using var reader = new StreamReader(file.OpenReadStream(), Encoding.UTF8, detectEncodingFromByteOrderMarks: true);
            var allLines = new List<string>();
            string? rawLine;
            while ((rawLine = await reader.ReadLineAsync()) != null)
                allLines.Add(rawLine);

            // QB Desktop exports Inventory Valuation Summary as tab-delimited; other exports are comma-delimited.
            // Auto-detect by checking the first non-empty lines for tabs.
            char delim = ',';
            foreach (var probe in allLines.Take(10).Where(l => !string.IsNullOrWhiteSpace(l)))
            {
                if (probe.Contains('\t')) { delim = '\t'; break; }
                if (probe.Contains(',')) break;
            }

            // Dynamically find the column-header row by scanning for "On Hand" within the first 20 rows.
            // This handles QB exports regardless of how many metadata rows precede the data.
            int colName = 0, colPrefVendor = -1, colOnHand = -1, colAvgCost = -1;
            int dataStartRow = 1; // fallback: start after row 0

            for (int scanIdx = 0; scanIdx < Math.Min(allLines.Count, 20); scanIdx++)
            {
                var scanFields = ParseDelimitedLine(allLines[scanIdx], delim);
                int onHandCol = scanFields.FindIndex(
                    f => f.Trim().Equals("On Hand", StringComparison.OrdinalIgnoreCase) ||
                         f.Trim().Equals("On-Hand", StringComparison.OrdinalIgnoreCase));
                if (onHandCol < 0) continue;

                // Found the header row — map all relevant columns
                colOnHand = onHandCol;
                for (int j = 0; j < scanFields.Count; j++)
                {
                    var h = scanFields[j].Trim();
                    if (h.Equals("Pref Vendor", StringComparison.OrdinalIgnoreCase) ||
                        h.Equals("Preferred Vendor", StringComparison.OrdinalIgnoreCase))
                        colPrefVendor = j;
                    else if (h.Equals("Avg Cost", StringComparison.OrdinalIgnoreCase) ||
                             h.Equals("Average Cost", StringComparison.OrdinalIgnoreCase))
                        colAvgCost = j;
                }
                dataStartRow = scanIdx + 1;
                break;
            }

            // Fallback column layout when the header row is not found:
            // QB report without Pref Vendor → [0]=name, [1]=OnHand, [2]=AvgCost, [3]=AssetValue
            // QB report with Pref Vendor    → [0]=name, [1]=PrefVendor, [2]=OnHand, [3]=AvgCost
            // We can't tell which layout is used without a header, so assume no Pref Vendor.
            if (colOnHand < 0)
            {
                colOnHand = 1;
                colAvgCost = 2;
            }
            // colAvgCost may still be -1 if the header had "On Hand" but no "Avg Cost" label
            if (colAvgCost < 0) colAvgCost = colOnHand + 1;

            int lineNumber = dataStartRow;
            int importedCount = 0;
            int updatedCount = 0;
            int skippedCount = 0;

            await _unitOfWork.ExecuteInTransactionAsync(async () =>
            {
                // Reset counters so retries don't double-count
                importedCount = 0;
                updatedCount = 0;
                skippedCount = 0;
                result.Errors.Clear();

                for (int lineIdx = dataStartRow; lineIdx < allLines.Count; lineIdx++)
                {
                    lineNumber = lineIdx + 1;
                    var fields = ParseDelimitedLine(allLines[lineIdx], delim);

                    // Helper: safely read a field by index
                    string F(int idx) => (idx >= 0 && idx < fields.Count) ? (fields[idx].Trim()) : string.Empty;

                    var itemName = F(colName);

                    if (string.IsNullOrWhiteSpace(itemName))
                        continue;

                    // Skip subtotal / grand total rows — QB structural, not inventory items
                    if (itemName.StartsWith("Total", StringComparison.OrdinalIgnoreCase) ||
                        itemName.Equals("TOTAL", StringComparison.OrdinalIgnoreCase))
                        continue;

                    var prefVendor = F(colPrefVendor);
                    var onHandStr  = F(colOnHand);
                    var avgCostStr = F(colAvgCost);

                    // Skip category/group header rows — name has text but all numeric columns are empty
                    if (string.IsNullOrEmpty(onHandStr) && string.IsNullOrEmpty(avgCostStr))
                        continue;

                    result.TotalRecords++;

                    if (!decimal.TryParse(onHandStr,  NumberStyles.Any, CultureInfo.InvariantCulture, out var onHand))
                        onHand = 0;
                    if (!decimal.TryParse(avgCostStr, NumberStyles.Any, CultureInfo.InvariantCulture, out var avgCost))
                        avgCost = 0;

                    // Vendor lookup by company name
                    int? vendorId = null;
                    if (!string.IsNullOrEmpty(prefVendor) &&
                        vendorByName.TryGetValue(prefVendor.ToLowerInvariant(), out var matchedVendor))
                    {
                        vendorId = matchedVendor.Id;
                    }

                    var nameKey = itemName.ToLowerInvariant();

                    if (itemByName.TryGetValue(nameKey, out var existing))
                    {
                        // Update existing item's stock levels and cost
                        existing.QuantityOnHand = onHand;
                        existing.AverageCost    = avgCost;
                        existing.UnitCost       = avgCost;
                        if (avgCost > 0)
                            existing.LastPurchasePrice = avgCost;
                        if (!string.IsNullOrEmpty(prefVendor))
                            existing.Manufacturer = prefVendor;
                        if (vendorId.HasValue)
                            existing.PrimaryVendorId = vendorId;
                        existing.UpdatedBy = userId;
                        existing.UpdatedAt = DateTime.UtcNow;

                        await _unitOfWork.InventoryItems.UpdateAsync(existing);

                        if (onHand != 0)
                        {
                            var tx = new InventoryTransaction
                            {
                                CompanyId         = companyId,
                                InventoryItemId   = existing.Id,
                                TransactionType   = InventoryTransactionType.Adjustment,
                                Quantity          = onHand,
                                UnitCost          = avgCost,
                                TotalCost         = onHand * avgCost,
                                BalanceAfter      = onHand,
                                TransactionDate   = DateTime.UtcNow,
                                Reference         = "QB Import",
                                Notes             = "Stock balance updated from QuickBooks Inventory Valuation Summary",
                                CreatedBy         = userId,
                                CreatedAt         = DateTime.UtcNow,
                            };
                            await _unitOfWork.InventoryTransactions.AddAsync(tx);
                        }

                        updatedCount++;
                    }
                    else
                    {
                        // Create new inventory item
                        var newItem = new InventoryItem
                        {
                            CompanyId          = companyId,
                            Name               = itemName,
                            ColorName          = itemName,
                            SKU                = BuildInventorySku(itemName),
                            Description        = null,
                            Category           = "Powder",
                            Manufacturer       = string.IsNullOrEmpty(prefVendor) ? null : prefVendor,
                            PrimaryVendorId    = vendorId,
                            QuantityOnHand     = onHand,
                            AverageCost        = avgCost,
                            UnitCost           = avgCost,
                            LastPurchasePrice  = avgCost > 0 ? avgCost : 0,
                            UnitOfMeasure      = "lbs",
                            ReorderPoint       = 0,
                            ReorderQuantity    = 0,
                            MinimumStock       = 0,
                            MaximumStock       = 0,
                            IsActive           = true,
                            CreatedBy          = userId,
                            CreatedAt          = DateTime.UtcNow,
                        };

                        await _unitOfWork.InventoryItems.AddAsync(newItem);
                        await _unitOfWork.CompleteAsync(); // flush to get the new item's Id

                        if (onHand != 0)
                        {
                            var tx = new InventoryTransaction
                            {
                                CompanyId         = companyId,
                                InventoryItemId   = newItem.Id,
                                TransactionType   = InventoryTransactionType.Initial,
                                Quantity          = onHand,
                                UnitCost          = avgCost,
                                TotalCost         = onHand * avgCost,
                                BalanceAfter      = onHand,
                                TransactionDate   = DateTime.UtcNow,
                                Reference         = "QB Import",
                                Notes             = "Opening balance imported from QuickBooks Inventory Valuation Summary",
                                CreatedBy         = userId,
                                CreatedAt         = DateTime.UtcNow,
                            };
                            await _unitOfWork.InventoryTransactions.AddAsync(tx);
                        }

                        itemByName[nameKey] = newItem; // prevent duplicate on re-upload
                        importedCount++;
                    }
                }

                await _unitOfWork.CompleteAsync();

                result.ImportedCount = importedCount;
                result.UpdatedCount  = updatedCount;
                result.SkippedCount  = skippedCount;
                result.Success       = true;

                _logger.LogInformation(
                    "QB inventory valuation import complete for company {CompanyId}: {Created} created, {Updated} updated, {Skipped} skipped",
                    companyId, importedCount, updatedCount, skippedCount);
            });
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Error importing QB inventory valuation for company {CompanyId}", companyId);
            result.Success = false;
            result.Errors.Add(new ImportErrorDto
            {
                LineNumber    = 0,
                ErrorMessage  = $"Import failed: {ex.Message}"
            });
        }

        return result;
    }

    /// <summary>
    /// Generates a simple URL-safe SKU from an inventory item name for QuickBooks-imported items.
    /// <para>
    /// QuickBooks inventory items do not always have SKUs. This method produces a deterministic
    /// prefix-based SKU (e.g. <c>"Wet Black"</c> → <c>"INV-WET-BLACK"</c>) so the new record
    /// has a non-null SKU while being recognisably QB-origin. Non-alphanumeric characters (except
    /// hyphens) are stripped to ensure the SKU is safe for use in URLs, barcodes, and labels.
    /// </para>
    /// </summary>
    private static string BuildInventorySku(string name)
    {
        // e.g. "Wet Black" → "INV-WET-BLACK"
        var slug = name.ToUpperInvariant()
            .Replace(" - ", "-")
            .Replace(" ", "-");
        // Strip any characters that aren't letters, digits or hyphens
        slug = new string(slug.Where(c => char.IsLetterOrDigit(c) || c == '-').ToArray());
        return $"INV-{slug}";
    }

    #endregion

    #region QB Bills Import

    /// <summary>
    /// Imports vendor bills from a QuickBooks Desktop "Vendor Balance Detail" or
    /// "Expense by Vendor Detail" CSV export and creates <see cref="Core.Entities.Bill"/> records.
    /// <para>
    /// <b>Pre-flight requirements:</b> Vendors and Chart of Accounts must be imported first.
    /// Bills are linked to vendors by name (with a starts-with fallback for partial name matches)
    /// and to AP/expense accounts by account number or name via <see cref="ResolveAccountFromQbString"/>.
    /// </para>
    /// <para>
    /// <b>Format auto-detection:</b> Three CSV layouts are supported, detected from the header row:
    /// <list type="bullet">
    ///   <item><description><b>VBD no memo</b> (≤7 columns): blank, Type, Date, Num, AP Account, Amount, Balance</description></item>
    ///   <item><description><b>VBD with memo</b> (8 columns, has "balance" and "memo" but no "split"): blank, Type, Date, Num, Memo, AP Account, Amount, Balance</description></item>
    ///   <item><description><b>Expense by Vendor Detail</b> (9+ columns): blank, Type, Date, Num, Memo, Expense Account, ?, Split/AP, Amount, ...</description></item>
    /// </list>
    /// </para>
    /// <para>
    /// <b>Row filtering:</b> Only rows with type "Bill" are processed; Bill Pmt, Item Receipt,
    /// Credit, and other QB row types are silently skipped (handled by other importers or irrelevant).
    /// Zero-amount bill rows are also skipped as they are QB artifacts.
    /// </para>
    /// <para>
    /// <b>Bill numbering:</b> Internal bill numbers are generated sequentially with the format
    /// <c>BILL-YYMM-####</c>. The QB vendor invoice number is preserved in <c>VendorInvoiceNumber</c>.
    /// </para>
    /// </summary>
    public async Task<ImportResultDto> ImportQbBillsAsync(IFormFile file, int companyId, string userId)
    {
        var result = new ImportResultDto();

        try
        {
            if (file == null || file.Length == 0)
            {
                result.Errors.Add(new ImportErrorDto { ErrorMessage = "No file provided." });
                return result;
            }

            // Read all lines
            using var reader = new StreamReader(file.OpenReadStream(), Encoding.UTF8, detectEncodingFromByteOrderMarks: true);
            var lines = new List<string>();
            while (!reader.EndOfStream)
            {
                var line = await reader.ReadLineAsync();
                if (!string.IsNullOrWhiteSpace(line))
                    lines.Add(line);
            }

            if (lines.Count < 2)
            {
                result.Errors.Add(new ImportErrorDto { ErrorMessage = "File is empty or has no data rows." });
                return result;
            }

            // Load reference data
            var vendors  = await _unitOfWork.Vendors.FindAsync(v => v.CompanyId == companyId && !v.IsDeleted);
            var accounts = await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == companyId && !a.IsDeleted);

            var vendorByName = vendors.ToDictionary(v => v.CompanyName.Trim(), v => v, StringComparer.OrdinalIgnoreCase);

            // Account lookups: by number and by name
            var accountByNumber = accounts
                .Where(a => !string.IsNullOrEmpty(a.AccountNumber))
                .ToDictionary(a => a.AccountNumber.Trim(), a => a, StringComparer.OrdinalIgnoreCase);
            var accountByName = accounts
                .GroupBy(a => a.Name.Trim(), StringComparer.OrdinalIgnoreCase)
                .ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase);

            // Default AP account fallback
            var defaultApAccount = accounts.FirstOrDefault(a =>
                a.AccountSubType == Core.Enums.AccountSubType.AccountsPayable && a.IsActive);

            // Bill number generation counter
            var billPrefix = $"BILL-{DateTime.Now:yyMM}-";
            var lastBill = await _unitOfWork.Bills.FindAsync(
                b => b.CompanyId == companyId && b.BillNumber.StartsWith(billPrefix),
                ignoreQueryFilters: true);
            int nextBillSeq = 1;
            if (lastBill.Any())
            {
                var maxNum = lastBill
                    .Select(b => b.BillNumber[billPrefix.Length..])
                    .Where(s => int.TryParse(s, out _))
                    .Select(int.Parse)
                    .DefaultIfEmpty(0)
                    .Max();
                nextBillSeq = maxNum + 1;
            }

            string NextBillNumber() => $"{billPrefix}{nextBillSeq++:D4}";

            await _unitOfWork.ExecuteInTransactionAsync(async () =>
            {

            // Auto-detect format from header row
            // VBD no memo (7 cols):  blank, Type, Date, Num, Account(AP), Amount, Balance
            // VBD + memo  (8 cols):  blank, Type, Date, Num, Memo, Account(AP), Amount, Balance
            // EVD         (9+ cols): blank, Type, Date, Num, Memo, Expense Account, ?, Split/AP, Amount, ...
            var headerCols = ParseCsvLine(lines[0]).ToArray();
            var headerLower = lines[0].ToLowerInvariant();
            bool isVbdWithMemo        = headerLower.Contains("balance") && headerLower.Contains("memo") && !headerLower.Contains("split");
            bool isVendorBalanceDetail = !isVbdWithMemo && (headerCols.Length <= 8 ||
                (headerLower.Contains("balance") && !headerLower.Contains("split")));

            string? currentVendorName = null;
            int lineNum = 0;
            int imported = 0, skipped = 0;

            // Skip header row (line 0)
            foreach (var rawLine in lines.Skip(1))
            {
                lineNum++;
                var colsList = ParseCsvLine(rawLine);
                var cols = colsList.ToArray();

                int minCols = isVendorBalanceDetail ? 6 : isVbdWithMemo ? 7 : 9;
                if (cols.Length < minCols) continue;

                var col0    = cols[0].Trim().Trim('"');
                var type    = cols.Length > 1 ? cols[1].Trim().Trim('"') : string.Empty;
                var dateStr = cols.Length > 2 ? cols[2].Trim().Trim('"') : string.Empty;
                var numStr  = cols.Length > 3 ? cols[3].Trim().Trim('"') : string.Empty;

                // Column mapping differs by format
                // VBD no memo: col[4]=AP Account, col[5]=Amount
                // VBD + memo:  col[4]=Memo, col[5]=AP Account, col[6]=Amount
                // EVD:         col[4]=Memo, col[5]=Expense Account, col[7]=Split/AP Account, col[8]=Amount
                string memo     = isVendorBalanceDetail ? string.Empty
                                  : (cols.Length > 4 ? cols[4].Trim().Trim('"') : string.Empty);
                string acctRaw  = (isVendorBalanceDetail || isVbdWithMemo) ? string.Empty
                                  : (cols.Length > 5 ? cols[5].Trim().Trim('"') : string.Empty);
                string splitRaw = isVendorBalanceDetail ? (cols.Length > 4 ? cols[4].Trim().Trim('"') : string.Empty)
                                  : isVbdWithMemo        ? (cols.Length > 5 ? cols[5].Trim().Trim('"') : string.Empty)
                                  :                        (cols.Length > 7 ? cols[7].Trim().Trim('"') : string.Empty);
                string amtStr   = isVendorBalanceDetail ? (cols.Length > 5 ? cols[5].Trim().Trim('"') : string.Empty)
                                  : isVbdWithMemo        ? (cols.Length > 6 ? cols[6].Trim().Trim('"') : string.Empty)
                                  :                        (cols.Length > 8 ? cols[8].Trim().Trim('"') : string.Empty);

                // Vendor header row: col 0 has content, col 1 is empty
                if (!string.IsNullOrEmpty(col0) && string.IsNullOrEmpty(type))
                {
                    // Skip Total rows
                    if (col0.StartsWith("Total ", StringComparison.OrdinalIgnoreCase))
                        continue;

                    currentVendorName = col0;
                    continue;
                }

                // Only process Bill rows — everything else (Bill Pmt, Item Receipt, Credit, etc.)
                // is either handled by another importer or QB-internal; skip all silently.
                if (!type.Equals("Bill", StringComparison.OrdinalIgnoreCase))
                    continue;

                result.TotalRecords++;

                // Parse date
                if (!DateTime.TryParseExact(dateStr, "MM/dd/yyyy", CultureInfo.InvariantCulture,
                        DateTimeStyles.None, out var billDate))
                {
                    result.Errors.Add(new ImportErrorDto
                    {
                        Severity     = "Error",
                        LineNumber   = lineNum,
                        RecordName   = currentVendorName,
                        ErrorMessage = $"Could not parse date '{dateStr}'."
                    });
                    continue;
                }

                // Parse amount
                decimal.TryParse(amtStr.Replace(",", ""), NumberStyles.Any,
                    CultureInfo.InvariantCulture, out var amount);
                if (amount == 0)
                    continue; // Zero-amount bill rows are QB artifacts — silently ignore

                // Resolve vendor
                if (currentVendorName == null ||
                    !vendorByName.TryGetValue(currentVendorName, out var vendor))
                {
                    // Try starts-with fallback
                    vendor = currentVendorName != null
                        ? vendorByName.Values.FirstOrDefault(v =>
                            v.CompanyName.StartsWith(currentVendorName, StringComparison.OrdinalIgnoreCase)
                            || currentVendorName.StartsWith(v.CompanyName, StringComparison.OrdinalIgnoreCase))
                        : null;

                    if (vendor == null)
                    {
                        result.Errors.Add(new ImportErrorDto
                        {
                            Severity     = "Error",
                            LineNumber   = lineNum,
                            RecordName   = currentVendorName ?? "(unknown)",
                            ErrorMessage = $"Vendor '{currentVendorName}' not found. Import vendors first."
                        });
                        continue;
                    }
                }

                // Resolve expense account from Account column (e.g. "67100 · Rent Expense")
                // Not available in Vendor Balance Detail format — that's OK, AccountId will be null on the line item.
                var expenseAccount = string.IsNullOrEmpty(acctRaw) ? null
                    : ResolveAccountFromQbString(acctRaw, accountByNumber, accountByName);
                if (!isVendorBalanceDetail && expenseAccount == null && !string.IsNullOrEmpty(acctRaw))
                {
                    result.Errors.Add(new ImportErrorDto
                    {
                        Severity     = "Error",
                        LineNumber   = lineNum,
                        RecordName   = currentVendorName,
                        FieldName    = "Account",
                        ErrorMessage = $"Could not find account for '{acctRaw}'."
                    });
                    continue;
                }

                // Resolve AP account from Split/Account column; fall back to default AP account
                var apAccount = ResolveAccountFromQbString(splitRaw, accountByNumber, accountByName)
                                ?? defaultApAccount;
                if (apAccount == null)
                {
                    result.Errors.Add(new ImportErrorDto
                    {
                        Severity     = "Error",
                        LineNumber   = lineNum,
                        RecordName   = currentVendorName,
                        FieldName    = "Split (AP Account)",
                        ErrorMessage = "No Accounts Payable account found. Import Chart of Accounts first."
                    });
                    continue;
                }

                // Create Bill + BillLineItem in one shot (no mid-loop flush needed)
                var bill = new Core.Entities.Bill
                {
                    CompanyId           = companyId,
                    BillNumber          = NextBillNumber(),
                    VendorInvoiceNumber = string.IsNullOrEmpty(numStr) ? null : numStr,
                    VendorId            = vendor.Id,
                    APAccountId         = apAccount.Id,
                    BillDate            = billDate,
                    Status              = Core.Enums.BillStatus.Open,
                    Memo                = string.IsNullOrEmpty(memo) ? null : memo,
                    SubTotal            = amount,
                    TaxPercent          = 0,
                    TaxAmount           = 0,
                    Total               = amount,
                    AmountPaid          = 0,
                    CreatedBy           = userId,
                    CreatedAt           = DateTime.UtcNow,
                };

                bill.LineItems.Add(new Core.Entities.BillLineItem
                {
                    CompanyId    = companyId,
                    AccountId    = expenseAccount?.Id,
                    Description  = string.IsNullOrEmpty(memo)
                                    ? (expenseAccount?.Name ?? "Imported from QuickBooks")
                                    : memo,
                    Quantity     = 1,
                    UnitPrice    = amount,
                    Amount       = amount,
                    DisplayOrder = 0,
                    CreatedBy    = userId,
                    CreatedAt    = DateTime.UtcNow,
                });

                await _unitOfWork.Bills.AddAsync(bill);
                imported++;
            }

            await _unitOfWork.CompleteAsync();

            result.ImportedCount = imported;
            result.SkippedCount  = skipped;
            result.Success       = true;

            }); // end ExecuteInTransactionAsync
        }
        catch (Exception ex)
        {
            result.Success = false;
            result.Errors.Add(new ImportErrorDto
            {
                ErrorMessage = $"Import failed: {ex.Message}"
            });
        }

        return result;
    }

    /// <summary>
    /// Resolves an Account from a QB-style string.
    /// Handles formats:
    ///   "67100 · Rent Expense"  (number + middle-dot separator + name)
    ///   "67100 - Rent Expense"  (number + dash + name)
    ///   "Rent Expense"          (name only)
    ///   "Expenses:Rent Expense" (QB sub-account path — tries last segment as name)
    ///   "67100"                 (number only)
    /// </summary>
    private static Core.Entities.Account? ResolveAccountFromQbString(
        string raw,
        Dictionary<string, Core.Entities.Account> byNumber,
        Dictionary<string, Core.Entities.Account> byName)
    {
        if (string.IsNullOrWhiteSpace(raw)) return null;

        var trimmed = raw.Trim();

        // Try account number: take the first token if it's all digits.
        // Format: "67100 · Rent Expense" or "67100 - Rent Expense" or "67100 Rent Expense"
        var firstSpace = trimmed.IndexOf(' ');
        if (firstSpace > 0)
        {
            var potentialNumber = trimmed[..firstSpace].Trim();
            if (potentialNumber.All(char.IsDigit) && byNumber.TryGetValue(potentialNumber, out var byNum))
                return byNum;
        }

        // Try the whole string as a number (e.g., "67100" with no name part)
        if (trimmed.All(char.IsDigit) && byNumber.TryGetValue(trimmed, out var numOnly))
            return numOnly;

        // Strip leading "NNNNN<any non-letter chars>" to isolate the account name.
        // \W+ handles any separator regardless of encoding (·, -, –, replacement chars, etc.)
        var namePart = System.Text.RegularExpressions.Regex.Replace(trimmed, @"^\d+\W+", "").Trim();
        if (!string.IsNullOrEmpty(namePart) && namePart != trimmed)
        {
            if (byName.TryGetValue(namePart, out var byNm))
                return byNm;
        }

        // Handle QB sub-account paths like "Expenses:Rent" or "Cost of Goods:Materials".
        // Try the last colon-segment as the account name, then try progressively shorter paths.
        if (trimmed.Contains(':'))
        {
            var segments = trimmed.Split(':');
            // Try last segment first (most specific), then last two, etc.
            for (int i = segments.Length - 1; i >= 0; i--)
            {
                var candidate = string.Join(":", segments.Skip(i)).Trim();
                // Strip any leading number prefix from the candidate
                var candidateName = System.Text.RegularExpressions.Regex.Replace(candidate, @"^\d+\W+", "").Trim();
                if (!string.IsNullOrEmpty(candidateName) && byName.TryGetValue(candidateName, out var byPath))
                    return byPath;
                if (byName.TryGetValue(candidate, out var byPathRaw))
                    return byPathRaw;
            }
        }

        // Last resort: try the raw value as a name
        return byName.TryGetValue(trimmed, out var direct) ? direct : null;
    }

    #endregion

    #region QB Vendor Payments Import

    /// <summary>
    /// Imports vendor payment records from a QuickBooks Desktop "Vendor Balance Detail" CSV export
    /// and creates <see cref="Core.Entities.BillPayment"/> records linked to previously-imported bills.
    /// <para>
    /// <b>Pre-flight requirements:</b> Vendors, Chart of Accounts, and Bills must be imported first.
    /// Payments are applied FIFO to open/partially-paid bills for each vendor.
    /// </para>
    /// <para>
    /// <b>Smart bill matching:</b> Rather than pure FIFO, the importer first checks for an exact
    /// balance-match within a 180-day date proximity window (most likely a 1:1 payment to a specific
    /// bill). If no exact match is found, it falls back to FIFO within the proximity window, then
    /// to global FIFO across all open bills. This heuristic significantly reduces mismatched payments
    /// on real-world data where QB payment rows lack an explicit bill reference number.
    /// </para>
    /// <para>
    /// <b>Format auto-detection:</b> Supports the same three VBD/EVD layouts as
    /// <see cref="ImportQbBillsAsync"/>. VBD formats have no bank account column; the importer
    /// falls back to the company's primary checking account (sorted by account number for
    /// deterministic selection when multiple checking accounts exist).
    /// </para>
    /// <para>
    /// <b>Payment amounts:</b> QuickBooks exports payment amounts as negative values (debits to cash).
    /// The importer takes the absolute value for the <see cref="Core.Entities.BillPayment.Amount"/> field.
    /// Zero-amount rows and amounts that exceed remaining open bills are logged as warnings but
    /// do not fail the import.
    /// </para>
    /// <para>
    /// <b>Payment number sequence:</b> Internal payment numbers use the format <c>BPMT-YYMM-####</c>.
    /// </para>
    /// </summary>
    public async Task<ImportResultDto> ImportQbVendorPaymentsAsync(IFormFile file, int companyId, string userId)
    {
        var result = new ImportResultDto();

        try
        {
            if (file == null || file.Length == 0)
            {
                result.Errors.Add(new ImportErrorDto { ErrorMessage = "No file provided." });
                return result;
            }

            using var reader = new StreamReader(file.OpenReadStream(), Encoding.UTF8, detectEncodingFromByteOrderMarks: true);
            var lines = new List<string>();
            while (!reader.EndOfStream)
            {
                var line = await reader.ReadLineAsync();
                if (!string.IsNullOrWhiteSpace(line))
                    lines.Add(line);
            }

            if (lines.Count < 2)
            {
                result.Errors.Add(new ImportErrorDto { ErrorMessage = "File is empty or has no data rows." });
                return result;
            }

            // Load reference data
            var vendors  = await _unitOfWork.Vendors.FindAsync(v => v.CompanyId == companyId && !v.IsDeleted);
            var accounts = await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == companyId && !a.IsDeleted);

            var vendorByName = vendors.ToDictionary(v => v.CompanyName.Trim(), v => v, StringComparer.OrdinalIgnoreCase);

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

            // Fallback bank account: Vendor Balance Detail doesn't include the payment bank account column.
            // Prefer accounts that have an account number (established accounts) over unnumbered ones
            // to avoid non-deterministic FirstOrDefault results when multiple checking accounts exist.
            var defaultBankAccount = accounts
                .Where(a => a.IsActive && a.AccountSubType == Core.Enums.AccountSubType.Checking)
                .OrderBy(a => string.IsNullOrEmpty(a.AccountNumber) ? 1 : 0)
                .ThenBy(a => a.AccountNumber)
                .FirstOrDefault()
                ?? accounts
                .Where(a => a.IsActive && a.AccountSubType == Core.Enums.AccountSubType.Savings)
                .OrderBy(a => string.IsNullOrEmpty(a.AccountNumber) ? 1 : 0)
                .ThenBy(a => a.AccountNumber)
                .FirstOrDefault();

            // Payment number sequence
            var pmtPrefix = $"BPMT-{DateTime.Now:yyMM}-";
            var lastPmt = await _unitOfWork.BillPayments.FindAsync(
                p => p.CompanyId == companyId && p.PaymentNumber.StartsWith(pmtPrefix),
                ignoreQueryFilters: true);
            int nextPmtSeq = 1;
            if (lastPmt.Any())
            {
                var maxNum = lastPmt
                    .Select(p => p.PaymentNumber[pmtPrefix.Length..])
                    .Where(s => int.TryParse(s, out _))
                    .Select(int.Parse)
                    .DefaultIfEmpty(0)
                    .Max();
                nextPmtSeq = maxNum + 1;
            }

            string NextPaymentNumber() => $"{pmtPrefix}{nextPmtSeq++:D4}";

            // Auto-detect payment format from header
            // VBD no memo (7 cols):  blank, Type, Date, Num, Account(AP), Amount, Balance
            // VBD + memo  (8 cols):  blank, Type, Date, Num, Memo, Account(AP), Amount, Balance
            // Old format  (8+ cols): blank, Type, Date, Num, Memo, Account(bank), ..., Amount
            var pmtHeaderCols  = ParseCsvLine(lines[0]).ToArray();
            var pmtHeaderLower = lines[0].ToLowerInvariant();
            bool pmtIsVbdWithMemo = pmtHeaderCols.Length == 8 &&
                pmtHeaderLower.Contains("balance") && pmtHeaderLower.Contains("memo") && !pmtHeaderLower.Contains("split");
            bool pmtIsVbdNoMemo   = !pmtIsVbdWithMemo && (pmtHeaderCols.Length <= 7 ||
                (pmtHeaderLower.Contains("balance") && !pmtHeaderLower.Contains("memo") && !pmtHeaderLower.Contains("split")));

            await _unitOfWork.ExecuteInTransactionAsync(async () =>
            {

            string? currentVendorName = null;
            Vendor? currentVendor = null;
            // Open bills for the current vendor, sorted FIFO
            List<Core.Entities.Bill>? vendorBills = null;

            int lineNum = 0;
            int imported = 0, skipped = 0;

            foreach (var rawLine in lines.Skip(1))
            {
                lineNum++;
                var colsList = ParseCsvLine(rawLine);
                var cols = colsList.ToArray();

                if (cols.Length < 6) continue;

                var col0    = cols[0].Trim().Trim('"');
                var type    = cols.Length > 1 ? cols[1].Trim().Trim('"') : string.Empty;
                var dateStr = cols.Length > 2 ? cols[2].Trim().Trim('"') : string.Empty;
                var numStr  = cols.Length > 3 ? cols[3].Trim().Trim('"') : string.Empty;
                // VBD no memo: col[4]=Account (AP acct), col[5]=Amount — no memo, no bank account column
                // VBD + memo:  col[4]=Memo, col[5]=Account(AP), col[6]=Amount — no bank account column
                // Old format:  col[4]=Memo, col[5]=Account (bank acct), col[8]=Amount
                var memo    = pmtIsVbdNoMemo ? string.Empty
                                            : (cols.Length > 4 ? cols[4].Trim().Trim('"') : string.Empty);
                var acctRaw = (pmtIsVbdNoMemo || pmtIsVbdWithMemo) ? string.Empty
                                                                    : (cols.Length > 5 ? cols[5].Trim().Trim('"') : string.Empty);
                var amtStr  = pmtIsVbdNoMemo   ? (cols.Length > 5 ? cols[5].Trim().Trim('"') : string.Empty)
                            : pmtIsVbdWithMemo ? (cols.Length > 6 ? cols[6].Trim().Trim('"') : string.Empty)
                            :                    (cols.Length > 8 ? cols[8].Trim().Trim('"') :
                                                  cols.Length > 7 ? cols[7].Trim().Trim('"') : string.Empty);

                // Vendor header row
                if (!string.IsNullOrEmpty(col0) && string.IsNullOrEmpty(type))
                {
                    if (col0.StartsWith("Total ", StringComparison.OrdinalIgnoreCase))
                        continue;

                    currentVendorName = col0;
                    vendorByName.TryGetValue(currentVendorName, out currentVendor);

                    if (currentVendor == null)
                    {
                        // Try starts-with fallback
                        currentVendor = vendorByName.Values.FirstOrDefault(v =>
                            v.CompanyName.StartsWith(currentVendorName, StringComparison.OrdinalIgnoreCase)
                            || currentVendorName.StartsWith(v.CompanyName, StringComparison.OrdinalIgnoreCase));
                    }

                    // Load open/partially-paid bills for this vendor, oldest first
                    if (currentVendor != null)
                    {
                        var bills = await _unitOfWork.Bills.FindAsync(
                            b => b.CompanyId == companyId
                              && b.VendorId == currentVendor.Id
                              && (b.Status == Core.Enums.BillStatus.Open || b.Status == Core.Enums.BillStatus.PartiallyPaid));
                        vendorBills = bills.OrderBy(b => b.BillDate).ThenBy(b => b.Id).ToList();
                    }
                    else
                    {
                        vendorBills = null;
                    }

                    continue;
                }

                // Only process Bill Pmt rows
                if (!type.StartsWith("Bill Pmt", StringComparison.OrdinalIgnoreCase))
                {
                    // Only Bill Pmt rows are relevant here; everything else (Bill, Item Receipt, Credit, etc.)
                    // is handled by another importer or QB-internal — skip all silently.
                    continue;
                }

                result.TotalRecords++;

                // Validate vendor
                if (currentVendor == null)
                {
                    result.Errors.Add(new ImportErrorDto
                    {
                        Severity     = "Error",
                        LineNumber   = lineNum,
                        RecordName   = currentVendorName ?? "(unknown)",
                        ErrorMessage = $"Vendor '{currentVendorName}' not found — payment skipped."
                    });
                    continue;
                }

                // Parse date
                if (!DateTime.TryParseExact(dateStr, "MM/dd/yyyy", CultureInfo.InvariantCulture,
                        DateTimeStyles.None, out var pmtDate))
                {
                    result.Errors.Add(new ImportErrorDto
                    {
                        Severity     = "Error",
                        LineNumber   = lineNum,
                        RecordName   = currentVendorName,
                        ErrorMessage = $"Could not parse date '{dateStr}'."
                    });
                    continue;
                }

                // Amount is negative in QB export — take absolute value for payment amount
                decimal.TryParse(amtStr.Replace(",", ""), NumberStyles.Any,
                    CultureInfo.InvariantCulture, out var pmtAmountRaw);
                var pmtAmount = Math.Abs(pmtAmountRaw);

                if (pmtAmount == 0)
                    continue; // Zero-amount payments are QB credit memo artifacts — silently ignore

                // Resolve bank account; VBD format has no bank account column — fall back to default checking account
                var bankAccount = string.IsNullOrEmpty(acctRaw)
                    ? defaultBankAccount
                    : (ResolveAccountFromQbString(acctRaw, accountByNumber, accountByName) ?? defaultBankAccount);
                if (bankAccount == null)
                {
                    result.Errors.Add(new ImportErrorDto
                    {
                        Severity     = "Error",
                        LineNumber   = lineNum,
                        RecordName   = currentVendorName,
                        FieldName    = "Account",
                        ErrorMessage = "No checking/bank account found. Please import Chart of Accounts first."
                    });
                    continue;
                }

                // Determine payment method from type string
                var paymentMethod = type.Contains("CCard", StringComparison.OrdinalIgnoreCase)
                    ? Core.Enums.PaymentMethod.CreditDebitCard
                    : Core.Enums.PaymentMethod.Check;

                // Apply FIFO across open bills for this vendor
                if (vendorBills == null || vendorBills.Count == 0)
                {
                    skipped++;
                    result.Errors.Add(new ImportErrorDto
                    {
                        Severity     = "Skipped",
                        LineNumber   = lineNum,
                        RecordName   = currentVendorName,
                        ErrorMessage = $"No open bills found for vendor '{currentVendorName}' — payment of {pmtAmount:C} skipped."
                    });
                    continue;
                }

                decimal remaining = pmtAmount;

                // Smart payment-to-bill matching:
                //   1. Exact remaining-balance match within date proximity window (payment date
                //      >= bill date and <= bill date + 180 days) — most likely a 1:1 payment
                //   2. FIFO within the date window (covers partial / multi-bill payments)
                //   3. Global FIFO fallback for very old or pre-dated payments
                const int proximityDays = 180;

                var exactWindowMatch = vendorBills.FirstOrDefault(b =>
                {
                    var bal      = b.Total - b.AmountPaid;
                    var daysDiff = (pmtDate.Date - b.BillDate.Date).TotalDays;
                    return bal > 0 &&
                           Math.Abs(bal - remaining) < 0.01m &&
                           daysDiff >= 0 &&
                           daysDiff <= proximityDays;
                });

                IEnumerable<Core.Entities.Bill> matchQueue;
                if (exactWindowMatch != null)
                {
                    // Single exact match — apply the whole payment here
                    matchQueue = new[] { exactWindowMatch };
                }
                else
                {
                    // Bills whose date is within the proximity window, oldest first
                    var windowedBills = vendorBills
                        .Where(b =>
                        {
                            var bal      = b.Total - b.AmountPaid;
                            var daysDiff = (pmtDate.Date - b.BillDate.Date).TotalDays;
                            return bal > 0 && daysDiff >= 0 && daysDiff <= proximityDays;
                        })
                        .OrderBy(b => b.BillDate)
                        .ToList();

                    // Fall back to global FIFO only when no bills fall in the window
                    matchQueue = windowedBills.Count > 0
                        ? (IEnumerable<Core.Entities.Bill>)windowedBills
                        : vendorBills.Where(b => b.Total - b.AmountPaid > 0);
                }

                foreach (var bill in matchQueue.ToList())
                {
                    if (remaining <= 0) break;

                    var billBalance = bill.Total - bill.AmountPaid;
                    if (billBalance <= 0)
                    {
                        vendorBills.Remove(bill);
                        continue;
                    }

                    var applyAmount = Math.Min(remaining, billBalance);

                    var payment = new Core.Entities.BillPayment
                    {
                        CompanyId     = companyId,
                        PaymentNumber = NextPaymentNumber(),
                        BillId        = bill.Id,
                        VendorId      = currentVendor.Id,
                        BankAccountId = bankAccount.Id,
                        PaymentDate   = pmtDate,
                        Amount        = applyAmount,
                        PaymentMethod = paymentMethod,
                        CheckNumber   = string.IsNullOrEmpty(numStr) ? null : numStr,
                        Memo          = string.IsNullOrEmpty(memo) ? null : memo,
                        CreatedBy     = userId,
                        CreatedAt     = DateTime.UtcNow,
                    };

                    await _unitOfWork.BillPayments.AddAsync(payment);

                    bill.AmountPaid += applyAmount;
                    bill.Status = bill.AmountPaid >= bill.Total
                        ? Core.Enums.BillStatus.Paid
                        : Core.Enums.BillStatus.PartiallyPaid;
                    await _unitOfWork.Bills.UpdateAsync(bill);

                    remaining -= applyAmount;
                    imported++;

                    if (bill.Status == Core.Enums.BillStatus.Paid)
                        vendorBills.Remove(bill);
                }

                if (remaining > 0.01m)
                {
                    result.Errors.Add(new ImportErrorDto
                    {
                        Severity     = "Warning",
                        LineNumber   = lineNum,
                        RecordName   = currentVendorName,
                        ErrorMessage = $"Payment of {pmtAmount:C} on {pmtDate:MM/dd/yyyy} — {remaining:C} could not be applied (no more open bills). Excess ignored."
                    });
                }
            }

            await _unitOfWork.CompleteAsync();

            result.ImportedCount = imported;
            result.SkippedCount  = skipped;
            result.Success       = true;

            }); // end ExecuteInTransactionAsync
        }
        catch (Exception ex)
        {
            result.Success = false;
            result.Errors.Add(new ImportErrorDto { ErrorMessage = $"Import failed: {ex.Message}" });
        }

        return result;
    }

    #endregion
}
