using Microsoft.AspNetCore.Http;
using Microsoft.EntityFrameworkCore;
using OfficeOpenXml;
using PowderCoating.Application.DTOs.QuickBooks;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Enums;
using PowderCoating.Core.Interfaces;
using PowderCoating.Infrastructure.Data;

namespace PowderCoating.Web.Services;

/// <summary>
/// Imports data exported from QuickBooks Online (.xlsx or .csv files).
/// Handles the flexible column/header structure QBO produces across its various report exports.
/// The service lives in the Web project (not Infrastructure) because it depends on
/// <see cref="IFormFile"/>, which is an ASP.NET Core abstraction not suitable for
/// a pure infrastructure library.
/// </summary>
public class QuickBooksOnlineService
{
    private readonly ApplicationDbContext _db;
    private readonly ITenantContext _tenantContext;
    private readonly ILogger<QuickBooksOnlineService> _logger;

    public QuickBooksOnlineService(
        ApplicationDbContext db,
        ITenantContext tenantContext,
        ILogger<QuickBooksOnlineService> logger)
    {
        _db            = db;
        _tenantContext = tenantContext;
        _logger        = logger;
        ExcelPackage.LicenseContext = LicenseContext.NonCommercial;
    }

    // ── Public entry points ───────────────────────────────────────────────────

    /// <summary>
    /// Imports customers from a QBO Customer Contact List export (.xlsx).
    /// Matches existing customers by normalized display name; updates contact fields for
    /// existing records and inserts new ones.  Balance is imported read-only and not used
    /// to update <see cref="Customer.CurrentBalance"/> to avoid overwriting live AR data.
    /// </summary>
    public Task<ImportResultDto> ImportCustomersAsync(IFormFile file, int companyId, string userId)
        => RunImport(file, companyId, userId, ProcessCustomers);

    /// <summary>
    /// Imports vendors from a QBO Vendor Contact List export (.xlsx).
    /// Matches by normalized company display name; updates contact fields on existing records.
    /// </summary>
    public Task<ImportResultDto> ImportVendorsAsync(IFormFile file, int companyId, string userId)
        => RunImport(file, companyId, userId, ProcessVendors);

    /// <summary>
    /// Imports products and services from a QBO Products and Services export (.xlsx).
    /// QBO "Inventory" type items are mapped to <see cref="InventoryItem"/>; all other
    /// service/non-inventory types are mapped to <see cref="CatalogItem"/>.  Uses a
    /// two-pass save to ensure category rows receive real database IDs before item rows
    /// reference them via foreign key.
    /// </summary>
    public Task<ImportResultDto> ImportCatalogItemsAsync(IFormFile file, int companyId, string userId)
        => RunImport(file, companyId, userId, ProcessCatalogItems);

    /// <summary>
    /// Imports the chart of accounts from a QBO Chart of Accounts export (.xlsx).
    /// Uses a two-pass save strategy: pass 1 upserts all accounts without parent links so
    /// every row gets a database ID, then pass 2 wires up <c>ParentAccountId</c> relationships.
    /// This avoids self-referential FK violations when both a parent and its sub-account are new.
    /// </summary>
    public Task<ImportResultDto> ImportChartOfAccountsAsync(IFormFile file, int companyId, string userId)
        => RunImport(file, companyId, userId, ProcessChartOfAccounts);

    /// <summary>
    /// Imports invoices from a QBO Invoice List export (.xlsx).
    /// De-duplicates by <c>ExternalReference</c> (the original QBO invoice number) so the same
    /// file can be re-uploaded safely.  Because the QBO Invoice List is summary-only (no line
    /// items), a single catch-all <see cref="InvoiceItem"/> is synthesized for each invoice.
    /// Existing payments implied by a non-zero "Open Balance" are also imported as
    /// <see cref="Payment"/> records using <see cref="PaymentMethod.BankTransferACH"/> as a
    /// neutral placeholder — the actual method is unknown from QBO exports.
    /// </summary>
    public Task<ImportResultDto> ImportInvoicesAsync(IFormFile file, int companyId, string userId)
        => RunImportAsync(file, companyId, userId, ProcessInvoicesAsync);

    /// <summary>
    /// Imports payment transactions from a QBO Transaction List by Date export (.xlsx).
    /// Filters rows to Payment/Receipt types only, then attempts to match each payment to
    /// an existing invoice using two strategies: (1) exact <c>ExternalReference</c> match on
    /// the transaction number, then (2) customer name + outstanding balance/total amount.
    /// Duplicate payments (same date + amount already recorded) are silently skipped.
    /// Unmatched payments are recorded as warnings in <see cref="ImportResultDto.Errors"/>.
    /// </summary>
    public Task<ImportResultDto> ImportTransactionsAsync(IFormFile file, int companyId, string userId)
        => RunImport(file, companyId, userId, ProcessTransactions);

    // ── Generic runner ────────────────────────────────────────────────────────

    /// <summary>
    /// Reads the uploaded file into memory, opens it with EPPlus, and delegates processing
    /// to the supplied synchronous <paramref name="processor"/> delegate.
    /// All exceptions are caught and returned as a failed <see cref="ImportResultDto"/> so
    /// the controller never receives an unhandled 500 during an import operation.
    /// </summary>
    private async Task<ImportResultDto> RunImport(
        IFormFile file, int companyId, string userId,
        Func<ExcelWorksheet, int, string, ImportResultDto> processor)
    {
        try
        {
            using var stream  = new MemoryStream();
            await file.CopyToAsync(stream);
            stream.Position = 0;

            using var package = new ExcelPackage(stream);
            var ws = package.Workbook.Worksheets.FirstOrDefault();
            if (ws == null)
                return Fail("The file appears to be empty or could not be parsed.");

            return processor(ws, companyId, userId);
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "QBO import error for company {CompanyId}", companyId);
            var msg = ex.InnerException?.Message ?? ex.Message;
            return Fail($"Import failed: {msg}");
        }
    }

    /// <summary>
    /// Async variant of <see cref="RunImport"/> for processors that require awaitable
    /// operations inside their row loops (e.g., invoice number generation via DB query).
    /// </summary>
    private async Task<ImportResultDto> RunImportAsync(
        IFormFile file, int companyId, string userId,
        Func<ExcelWorksheet, int, string, Task<ImportResultDto>> processor)
    {
        try
        {
            using var stream  = new MemoryStream();
            await file.CopyToAsync(stream);
            stream.Position = 0;

            using var package = new ExcelPackage(stream);
            var ws = package.Workbook.Worksheets.FirstOrDefault();
            if (ws == null)
                return Fail("The file appears to be empty or could not be parsed.");

            return await processor(ws, companyId, userId);
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "QBO import error for company {CompanyId}", companyId);
            var msg = ex.InnerException?.Message ?? ex.Message;
            return Fail($"Import failed: {msg}");
        }
    }

    // ── Processors ────────────────────────────────────────────────────────────

    /// <summary>
    /// Parses a QBO Customer Contact List worksheet and upserts <see cref="Customer"/> records.
    /// Uses <c>IgnoreQueryFilters()</c> so that soft-deleted customers from a previous import
    /// are included in the duplicate check — this prevents ghost re-inserts when a record was
    /// soft-deleted and the same customer appears in a fresh export.
    /// The "Customer" column in QBO contains the display name (which may be a company name or
    /// a personal name), while separate "First Name"/"Last Name" columns carry the contact name.
    /// </summary>
    private ImportResultDto ProcessCustomers(ExcelWorksheet ws, int companyId, string userId)
    {
        var result = new ImportResultDto();
        var (headerRow, colMap) = FindHeaderRow(ws, new[]
        {
            "customer", "name", "company", "first name", "last name", "email", "phone",
            "billing street", "city", "state", "zip", "balance", "terms"
        });
        if (headerRow < 0) return Fail("Could not find column headers. Please export using Reports → Customer Contact List → Export to Excel.");

        var existing = _db.Customers
            .IgnoreQueryFilters()
            .Where(c => c.CompanyId == companyId)
            .ToList()
            .ToDictionary(c => Normalize(c.CompanyName ?? $"{c.ContactFirstName} {c.ContactLastName}"), c => c);

        int lastRow = ws.Dimension?.End.Row ?? 0;
        for (int row = headerRow + 1; row <= lastRow; row++)
        {
            var rawName    = Cell(ws, row, colMap, "customer", "name");
            var company    = Cell(ws, row, colMap, "company", "company name");
            var firstName  = Cell(ws, row, colMap, "first name");
            var lastName   = Cell(ws, row, colMap, "last name");
            var email      = StripLabel(Cell(ws, row, colMap, "email", "email address"));
            var phone      = StripLabel(Cell(ws, row, colMap, "phone", "phone number", "mobile"));
            var street     = Cell(ws, row, colMap, "billing street", "address");
            var city       = Cell(ws, row, colMap, "city", "billing city");
            var state      = Cell(ws, row, colMap, "state", "billing state");
            var zip        = Cell(ws, row, colMap, "zip", "postal code", "billing zip", "billing postal code");
            var country    = Cell(ws, row, colMap, "country", "billing country");
            var terms      = Cell(ws, row, colMap, "terms", "payment terms");
            var balanceRaw = Cell(ws, row, colMap, "balance", "open balance");

            // Display name — QBO uses "Customer" column for the full display name
            var displayName = string.IsNullOrWhiteSpace(rawName) ? company : rawName;
            if (string.IsNullOrWhiteSpace(displayName))
            {
                displayName = $"{firstName} {lastName}".Trim();
            }
            if (string.IsNullOrWhiteSpace(displayName) || IsMetadataRow(displayName)) { result.SkippedCount++; continue; }

            // For company field: if no dedicated company column, use display name
            if (string.IsNullOrWhiteSpace(company)) company = displayName;

            result.TotalRecords++;
            decimal.TryParse(balanceRaw?.Replace("$", "").Replace(",", ""), out var balance);

            var key = Normalize(displayName);
            if (existing.TryGetValue(key, out var cust))
            {
                // Update
                if (!string.IsNullOrWhiteSpace(email))     cust.Email         = email;
                if (!string.IsNullOrWhiteSpace(phone))     cust.Phone         = phone;
                if (!string.IsNullOrWhiteSpace(street))    cust.Address       = street;
                if (!string.IsNullOrWhiteSpace(city))      cust.City          = city;
                if (!string.IsNullOrWhiteSpace(state))     cust.State         = state;
                if (!string.IsNullOrWhiteSpace(zip))       cust.ZipCode       = zip;
                if (!string.IsNullOrWhiteSpace(country))   cust.Country       = country;
                if (!string.IsNullOrWhiteSpace(terms))     cust.PaymentTerms  = terms;
                cust.UpdatedAt = DateTime.UtcNow;
                result.UpdatedCount++;
            }
            else
            {
                // Insert
                var newCust = new Customer
                {
                    CompanyId          = companyId,
                    CompanyName        = company,
                    ContactFirstName   = firstName ?? "",
                    ContactLastName    = lastName ?? "",
                    Email              = email ?? "",
                    Phone              = phone ?? "",
                    Address            = street ?? "",
                    City               = city ?? "",
                    State              = state ?? "",
                    ZipCode            = zip ?? "",
                    Country            = country ?? "",
                    PaymentTerms       = terms ?? "",
                    IsCommercial       = !string.IsNullOrWhiteSpace(company) && company != displayName,
                    IsActive           = true,
                    CurrentBalance     = balance,
                    CreatedAt          = DateTime.UtcNow,
                    UpdatedAt          = DateTime.UtcNow,
                    CreatedBy          = userId,
                    UpdatedBy          = userId,
                };
                _db.Customers.Add(newCust);
                existing[key] = newCust;
                result.ImportedCount++;
            }
        }

        _db.SaveChanges();
        result.Success = result.TotalRecords > 0;
        return result;
    }

    /// <summary>
    /// Parses a QBO Vendor Contact List worksheet and upserts <see cref="Vendor"/> records.
    /// Deduplication key is the normalized company display name.  Only non-blank contact fields
    /// from the import overwrite existing values, preserving any enrichment done inside the app.
    /// </summary>
    private ImportResultDto ProcessVendors(ExcelWorksheet ws, int companyId, string userId)
    {
        var result = new ImportResultDto();
        var (headerRow, colMap) = FindHeaderRow(ws, new[]
        {
            "company display name", "vendor", "name", "company", "email", "phone", "city", "state", "zip", "terms"
        });
        if (headerRow < 0) return Fail("Could not find column headers. Please export using Reports → Vendor Contact List → Export to Excel.");

        var existing = _db.Vendors
            .IgnoreQueryFilters()
            .Where(v => v.CompanyId == companyId)
            .ToList()
            .ToDictionary(v => Normalize(v.CompanyName ?? ""), v => v);

        int lastRow = ws.Dimension?.End.Row ?? 0;
        for (int row = headerRow + 1; row <= lastRow; row++)
        {
            var company = Cell(ws, row, colMap, "company display name", "vendor", "company", "name");
            if (string.IsNullOrWhiteSpace(company) || IsMetadataRow(company)) { result.SkippedCount++; continue; }

            var contact = Cell(ws, row, colMap, "contact", "contact name");
            var email   = Cell(ws, row, colMap, "email", "email address");
            var phone   = Cell(ws, row, colMap, "phone", "phone number");
            var street  = Cell(ws, row, colMap, "billing street", "address");
            var city    = Cell(ws, row, colMap, "city", "billing city");
            var state   = Cell(ws, row, colMap, "state", "billing state");
            var zip     = Cell(ws, row, colMap, "zip", "postal code", "billing zip");
            var country = Cell(ws, row, colMap, "country", "billing country");
            var terms   = Cell(ws, row, colMap, "terms", "payment terms");

            result.TotalRecords++;
            var key = Normalize(company);

            if (existing.TryGetValue(key, out var vendor))
            {
                if (!string.IsNullOrWhiteSpace(email))   vendor.Email        = email;
                if (!string.IsNullOrWhiteSpace(phone))   vendor.Phone        = phone;
                if (!string.IsNullOrWhiteSpace(street))  vendor.Address      = street;
                if (!string.IsNullOrWhiteSpace(city))    vendor.City         = city;
                if (!string.IsNullOrWhiteSpace(state))   vendor.State        = state;
                if (!string.IsNullOrWhiteSpace(zip))     vendor.ZipCode      = zip;
                if (!string.IsNullOrWhiteSpace(country)) vendor.Country      = country;
                if (!string.IsNullOrWhiteSpace(terms))   vendor.PaymentTerms = terms;
                vendor.UpdatedAt = DateTime.UtcNow;
                result.UpdatedCount++;
            }
            else
            {
                _db.Vendors.Add(new Vendor
                {
                    CompanyId    = companyId,
                    CompanyName  = company,
                    ContactName  = contact ?? "",
                    Email        = email ?? "",
                    Phone        = phone ?? "",
                    Address      = street ?? "",
                    City         = city ?? "",
                    State        = state ?? "",
                    ZipCode      = zip ?? "",
                    Country      = country ?? "",
                    PaymentTerms = terms ?? "",
                    IsActive     = true,
                    CreatedAt    = DateTime.UtcNow,
                    UpdatedAt    = DateTime.UtcNow,
                    CreatedBy    = userId,
                    UpdatedBy    = userId,
                });
                result.ImportedCount++;
            }
        }

        _db.SaveChanges();
        result.Success = result.TotalRecords > 0;
        return result;
    }

    /// <summary>
    /// Parses a QBO Products and Services worksheet and upserts catalog and inventory items.
    /// QBO exports one row per category header (type = "Category") and one row per item, both
    /// in the same sheet.  Category rows are identified by either the "Category" type column
    /// or by the absence of a colon-delimited sub-item suffix and skipped from item processing.
    /// A two-pass <c>SaveChanges()</c> is required: pass 1 persists any new <see cref="CatalogCategory"/>
    /// rows so they have valid database IDs, and pass 2 uses those IDs when inserting
    /// <see cref="CatalogItem"/> records.  QBO "Inventory" type items are routed to
    /// <see cref="InventoryItem"/> instead of <see cref="CatalogItem"/>.
    /// </summary>
    private ImportResultDto ProcessCatalogItems(ExcelWorksheet ws, int companyId, string userId)
    {
        var result = new ImportResultDto();
        var (headerRow, colMap) = FindHeaderRow(ws, new[]
        {
            "name", "type", "sku", "sales price", "rate", "price", "cost", "qty on hand", "quantity on hand", "income account"
        });
        if (headerRow < 0) return Fail("Could not find column headers. Please export from Sales → Products and Services → Export to Excel.");

        // Load existing categories so we can get-or-create them.
        var existingCategories = _db.CatalogCategories
            .IgnoreQueryFilters()
            .Where(c => c.CompanyId == companyId)
            .ToList()
            .ToDictionary(c => Normalize(c.Name), c => c);

        var existingCatalog = _db.CatalogItems
            .IgnoreQueryFilters()
            .Where(c => c.CompanyId == companyId)
            .ToList()
            .ToDictionary(c => Normalize(c.Name), c => c);

        var existingInventory = _db.InventoryItems
            .IgnoreQueryFilters()
            .Where(i => i.CompanyId == companyId)
            .ToList()
            .ToDictionary(i => Normalize(i.Name), i => i);

        // Helper: get existing category or create a new one (without saving yet).
        CatalogCategory GetOrCreateCategory(string categoryName)
        {
            var key = Normalize(categoryName);
            if (existingCategories.TryGetValue(key, out var existing))
                return existing;

            var newCat = new CatalogCategory
            {
                CompanyId  = companyId,
                Name       = categoryName.Trim(),
                IsActive   = true,
                CreatedAt  = DateTime.UtcNow,
                UpdatedAt  = DateTime.UtcNow,
                CreatedBy  = userId,
                UpdatedBy  = userId,
            };
            _db.CatalogCategories.Add(newCat);
            existingCategories[key] = newCat;
            return newCat;
        }

        // Ensure there is always a fallback "Imported" category for items with no prefix.
        var defaultCategory = GetOrCreateCategory("Imported");

        // Collect rows for processing.
        var rows = new List<(string name, string? categoryName, string type, string? sku, decimal price, decimal cost, decimal qty, string? desc)>();

        int lastRow = ws.Dimension?.End.Row ?? 0;
        for (int row = headerRow + 1; row <= lastRow; row++)
        {
            var rawName = Cell(ws, row, colMap, "name");
            if (string.IsNullOrWhiteSpace(rawName) || IsMetadataRow(rawName)) { result.SkippedCount++; continue; }

            // Extract category prefix: "Category:Item Name"
            var name         = rawName.Contains(':') ? rawName.Substring(rawName.LastIndexOf(':') + 1).Trim() : rawName.Trim();
            var categoryName = rawName.Contains(':') ? rawName.Substring(0, rawName.LastIndexOf(':')).Trim() : null;

            var type     = Cell(ws, row, colMap, "type") ?? "Service";
            var sku      = Cell(ws, row, colMap, "sku");
            var priceRaw = Cell(ws, row, colMap, "sales price/rate", "sales price", "price/rate", "rate", "price");
            var costRaw  = Cell(ws, row, colMap, "cost", "purchase cost");
            var qtyRaw   = Cell(ws, row, colMap, "qty on hand", "quantity on hand", "qty");
            var desc     = Cell(ws, row, colMap, "description", "sales description");

            decimal.TryParse(priceRaw?.Replace("$", "").Replace(",", ""), out var price);
            decimal.TryParse(costRaw?.Replace("$", "").Replace(",", ""), out var cost);
            decimal.TryParse(qtyRaw?.Replace(",", ""), out var qty);

            // Ensure the category entity exists in the change tracker (but not saved yet).
            if (categoryName != null) GetOrCreateCategory(categoryName);

            result.TotalRecords++;
            rows.Add((name, categoryName, type, sku, price, cost, qty, desc));
        }

        // Pass 1: save any new categories so they get real IDs before items reference them.
        _db.SaveChanges();

        // Pass 2: upsert items now that every category has a valid Id.
        foreach (var (name, categoryName, type, sku, price, cost, qty, desc) in rows)
        {
            // Skip rows that ARE a category definition (QBO exports one row per category
            // with just the category name and no sub-item suffix).
            if (categoryName == null && existingCategories.ContainsKey(Normalize(name)))
            { result.SkippedCount++; result.TotalRecords--; continue; }

            // Also skip rows explicitly typed as "Category".
            if (type.Equals("Category", StringComparison.OrdinalIgnoreCase))
            { result.SkippedCount++; result.TotalRecords--; continue; }

            if (type.Equals("Inventory", StringComparison.OrdinalIgnoreCase))
            {
                var key = Normalize(name);
                if (existingInventory.TryGetValue(key, out var inv))
                {
                    inv.UnitCost       = cost > 0 ? cost : inv.UnitCost;
                    inv.QuantityOnHand = qty;
                    inv.UpdatedAt      = DateTime.UtcNow;
                    result.UpdatedCount++;
                }
                else
                {
                    _db.InventoryItems.Add(new InventoryItem
                    {
                        CompanyId      = companyId,
                        Name           = name,
                        SKU            = sku ?? "",
                        Description    = desc ?? "",
                        UnitCost       = cost,
                        QuantityOnHand = qty,
                        IsActive       = true,
                        CreatedAt      = DateTime.UtcNow,
                        UpdatedAt      = DateTime.UtcNow,
                        CreatedBy      = userId,
                        UpdatedBy      = userId,
                    });
                    result.ImportedCount++;
                }
            }
            else
            {
                var key      = Normalize(name);
                var catKey   = categoryName != null ? Normalize(categoryName) : Normalize("Imported");
                var category = existingCategories.TryGetValue(catKey, out var c) ? c : defaultCategory;

                if (existingCatalog.TryGetValue(key, out var cat))
                {
                    cat.DefaultPrice = price > 0 ? price : cat.DefaultPrice;
                    cat.UpdatedAt    = DateTime.UtcNow;
                    result.UpdatedCount++;
                }
                else
                {
                    _db.CatalogItems.Add(new CatalogItem
                    {
                        CompanyId    = companyId,
                        Name         = name,
                        SKU          = sku ?? "",
                        Description  = desc ?? "",
                        DefaultPrice = price,
                        CategoryId   = category.Id,
                        IsActive     = true,
                        CreatedAt    = DateTime.UtcNow,
                        UpdatedAt    = DateTime.UtcNow,
                        CreatedBy    = userId,
                        UpdatedBy    = userId,
                    });
                    result.ImportedCount++;
                }
            }
        }

        _db.SaveChanges();
        result.Success = result.TotalRecords > 0;
        return result;
    }

    /// <summary>
    /// Parses a QBO Chart of Accounts worksheet and upserts <see cref="Account"/> records.
    /// Sub-accounts are expressed in QBO as colon-delimited names (e.g., "Expenses:Utilities").
    /// Pass 1 saves all accounts without parent links so every row has a real ID.
    /// Pass 2 resolves <c>ParentAccountId</c> links by looking up the parent segment by name.
    /// QBO account type/detail-type strings are mapped to <see cref="AccountType"/> and
    /// <see cref="AccountSubType"/> enums via <see cref="MapQboAccountType"/> and
    /// <see cref="MapQboDetailType"/>; unrecognized types default to <c>Expense</c>/<c>Other</c>.
    /// </summary>
    private ImportResultDto ProcessChartOfAccounts(ExcelWorksheet ws, int companyId, string userId)
    {
        var result = new ImportResultDto();
        var (headerRow, colMap) = FindHeaderRow(ws, new[]
        {
            "account name", "name", "type", "detail type", "number", "description"
        });
        if (headerRow < 0) return Fail("Could not find column headers. Please export from Accounting → Chart of Accounts → Export.");

        var existing = _db.Accounts
            .IgnoreQueryFilters()
            .Where(a => a.CompanyId == companyId)
            .ToList()
            .ToDictionary(a => Normalize(a.Name), a => a);

        // Collect rows so we can do a two-pass save: parents first, then sub-accounts.
        // This avoids a self-referential FK violation when both parent and child are new.
        var rows = new List<(string displayName, string? parentName, string? number, string? desc, AccountType accountType, AccountSubType accountSubType)>();

        int lastRow = ws.Dimension?.End.Row ?? 0;
        for (int row = headerRow + 1; row <= lastRow; row++)
        {
            var name       = Cell(ws, row, colMap, "account name", "name");
            var typeStr    = Cell(ws, row, colMap, "type", "account type");
            var detailType = Cell(ws, row, colMap, "detail type", "detail account type");
            var number     = Cell(ws, row, colMap, "number", "account number", "#");
            var desc       = Cell(ws, row, colMap, "description");

            if (string.IsNullOrWhiteSpace(name) || IsMetadataRow(name)) { result.SkippedCount++; continue; }

            // Strip sub-account prefix if present (e.g., "Sales:Powder Coating")
            var displayName = name.Contains(':') ? name.Substring(name.LastIndexOf(':') + 1).Trim() : name.Trim();
            var parentName  = name.Contains(':') ? name.Substring(0, name.LastIndexOf(':')).Trim() : null;

            result.TotalRecords++;
            rows.Add((displayName, parentName, number, desc, MapQboAccountType(typeStr), MapQboDetailType(detailType)));
        }

        // Pass 1: upsert every account WITHOUT parent links so they all get IDs.
        foreach (var (displayName, _, number, desc, accountType, accountSubType) in rows)
        {
            var key = Normalize(displayName);
            if (existing.TryGetValue(key, out var acct))
            {
                if (!string.IsNullOrWhiteSpace(number)) acct.AccountNumber = number;
                if (!string.IsNullOrWhiteSpace(desc))   acct.Description   = desc;
                acct.AccountType    = accountType;
                acct.AccountSubType = accountSubType;
                acct.UpdatedAt      = DateTime.UtcNow;
                result.UpdatedCount++;
            }
            else
            {
                var newAcct = new Account
                {
                    CompanyId       = companyId,
                    Name            = displayName,
                    AccountNumber   = number ?? "",
                    Description     = desc ?? "",
                    AccountType     = accountType,
                    AccountSubType  = accountSubType,
                    IsActive        = true,
                    CreatedAt       = DateTime.UtcNow,
                    UpdatedAt       = DateTime.UtcNow,
                    CreatedBy       = userId,
                    UpdatedBy       = userId,
                };
                _db.Accounts.Add(newAcct);
                existing[key] = newAcct;
                result.ImportedCount++;
            }
        }

        // Save so all new accounts have real IDs before we link parents.
        _db.SaveChanges();

        // Pass 2: wire up ParentAccountId now that every account has an Id.
        foreach (var (displayName, parentName, _, _, _, _) in rows)
        {
            if (parentName == null) continue;
            var childKey  = Normalize(displayName);
            var parentKey = Normalize(parentName.Contains(':') ? parentName.Substring(parentName.LastIndexOf(':') + 1) : parentName);
            if (existing.TryGetValue(childKey, out var child) &&
                existing.TryGetValue(parentKey, out var parent) &&
                child.ParentAccountId != parent.Id)
            {
                child.ParentAccountId = parent.Id;
            }
        }

        _db.SaveChanges();
        result.Success = result.TotalRecords > 0;
        return result;
    }

    /// <summary>
    /// Parses a QBO Invoice List worksheet and inserts new <see cref="Invoice"/> records.
    /// De-duplication is based on <c>ExternalReference</c> (stored as the original QBO invoice
    /// number) so the same file can be re-uploaded without creating duplicates.
    /// Because the QBO Invoice List export is summary-level only (no individual line items),
    /// a single synthetic <see cref="InvoiceItem"/> covering the full invoice total is created.
    /// If "Open Balance" is less than the invoice total, the difference is recorded as a
    /// <see cref="Payment"/> using <see cref="PaymentMethod.BankTransferACH"/> as a neutral
    /// placeholder — the actual payment method is not available in the export.
    /// Invoice numbers use the app's own <c>INV-YYMM-####</c> scheme; the QBO number is
    /// preserved in <c>ExternalReference</c> for cross-reference.
    /// </summary>
    private async Task<ImportResultDto> ProcessInvoicesAsync(ExcelWorksheet ws, int companyId, string userId)
    {
        var result = new ImportResultDto();
        var (headerRow, colMap) = FindHeaderRow(ws, new[]
        {
            "date", "invoice no", "num", "customer", "name", "amount", "total", "open balance", "due date"
        });
        if (headerRow < 0) return Fail("Could not find column headers. Please export using Reports → Invoice List → Export to Excel.");

        var customers = _db.Customers
            .IgnoreQueryFilters()
            .Where(c => c.CompanyId == companyId && !c.IsDeleted)
            .ToList()
            .ToDictionary(c => Normalize(c.CompanyName ?? $"{c.ContactFirstName} {c.ContactLastName}"), c => c);

        var existingInvoices = _db.Invoices
            .IgnoreQueryFilters()
            .Where(i => i.CompanyId == companyId)
            .Select(i => i.ExternalReference)
            .Where(r => r != null)
            .ToHashSet();

        // Load status lookups
        var paidStatus     = GetOrCreateInvoiceStatus(InvoiceStatus.Paid);
        var unpaidStatus   = GetOrCreateInvoiceStatus(InvoiceStatus.Sent);
        var partialStatus  = GetOrCreateInvoiceStatus(InvoiceStatus.PartiallyPaid);
        var overdueStatus  = GetOrCreateInvoiceStatus(InvoiceStatus.Overdue);

        int lastRow = ws.Dimension?.End.Row ?? 0;
        for (int row = headerRow + 1; row <= lastRow; row++)
        {
            var invoiceNoRaw = Cell(ws, row, colMap, "invoice no", "num", "invoice number", "#");
            var customerName = Cell(ws, row, colMap, "customer", "name");
            var dateRaw      = Cell(ws, row, colMap, "date", "invoice date");
            var dueDateRaw   = Cell(ws, row, colMap, "due date");
            var totalRaw     = Cell(ws, row, colMap, "amount", "total");
            var balanceRaw   = Cell(ws, row, colMap, "open balance", "balance");
            var statusRaw    = Cell(ws, row, colMap, "status");

            if (string.IsNullOrWhiteSpace(invoiceNoRaw) || string.IsNullOrWhiteSpace(customerName))
            { result.SkippedCount++; continue; }

            if (existingInvoices.Contains(invoiceNoRaw))
            { result.SkippedCount++; continue; }

            if (!DateTime.TryParse(dateRaw, out var invoiceDate)) invoiceDate = DateTime.UtcNow;
            DateTime.TryParse(dueDateRaw, out var dueDate);
            decimal.TryParse(totalRaw?.Replace("$", "").Replace(",", ""), out var total);
            decimal.TryParse(balanceRaw?.Replace("$", "").Replace(",", ""), out var balanceDue);
            var amountPaid = total - balanceDue;

            var custKey  = Normalize(customerName);
            customers.TryGetValue(custKey, out var customer);

            result.TotalRecords++;

            var status = (statusRaw?.ToLower()) switch
            {
                "paid"     => paidStatus,
                "partial"  => partialStatus,
                "overdue"  => overdueStatus,
                _          => unpaidStatus
            };
            if (amountPaid >= total && total > 0) status = paidStatus;
            else if (amountPaid > 0)              status = partialStatus;

            var invoice = new Invoice
            {
                CompanyId         = companyId,
                CustomerId        = customer?.Id ?? 0,
                InvoiceNumber     = await GenerateInvoiceNumber(companyId),
                ExternalReference = invoiceNoRaw,
                InvoiceDate       = invoiceDate,
                DueDate           = dueDate == default ? (DateTime?)null : dueDate,
                SubTotal          = total,
                TaxAmount         = 0,
                Total             = total,
                AmountPaid        = amountPaid,
                Status            = status,
                CreatedAt         = DateTime.UtcNow,
                UpdatedAt         = DateTime.UtcNow,
                CreatedBy         = userId,
                UpdatedBy         = userId,
            };
            _db.Invoices.Add(invoice);

            // QBO Invoice List is summary-only — create one catch-all line item for the total.
            var lineDesc = Cell(ws, row, colMap, "memo", "description", "product/service") ?? $"QB Invoice #{invoiceNoRaw}";
            _db.InvoiceItems.Add(new InvoiceItem
            {
                Invoice      = invoice,   // navigation property — EF resolves Id after save
                CompanyId    = companyId,
                Description  = lineDesc,
                Quantity     = 1,
                UnitPrice    = total,
                TotalPrice   = total,
                DisplayOrder = 1,
                CreatedAt    = DateTime.UtcNow,
                UpdatedAt    = DateTime.UtcNow,
                CreatedBy    = userId,
                UpdatedBy    = userId,
            });

            existingInvoices.Add(invoiceNoRaw);
            result.ImportedCount++;

            if (amountPaid > 0)
            {
                _db.Payments.Add(new Payment
                {
                    Invoice       = invoice,
                    CompanyId     = companyId,
                    Amount        = amountPaid,
                    PaymentDate   = invoiceDate,
                    PaymentMethod = PaymentMethod.BankTransferACH,
                    Notes         = "Imported from QuickBooks Online",
                    CreatedAt     = DateTime.UtcNow,
                    UpdatedAt     = DateTime.UtcNow,
                    CreatedBy     = userId,
                    UpdatedBy     = userId,
                });
            }
        }

        _db.SaveChanges();
        result.Success = result.TotalRecords > 0;
        return result;
    }

    /// <summary>
    /// Parses a QBO Transaction List by Date worksheet and posts payment records against
    /// previously-imported invoices.
    /// Only rows whose Transaction Type contains "Payment" or "Receipt" are processed.
    /// Two invoice-matching strategies are attempted in order:
    ///   1. Exact match on <c>ExternalReference</c> using the Num column (works when the QBO
    ///      transaction number equals the invoice number — common for invoice payments).
    ///   2. Customer-name + amount match against open invoices (fallback for payment rows where
    ///      the Num column holds a check/ACH reference number rather than an invoice number).
    /// Payments already recorded for the same date and amount are silently skipped to prevent
    /// duplicates when the same transaction file is uploaded more than once.
    /// Unmatched payments are recorded as warnings rather than errors so the rest of the batch
    /// still completes successfully.
    /// </summary>
    private ImportResultDto ProcessTransactions(ExcelWorksheet ws, int companyId, string userId)
    {
        var result = new ImportResultDto();
        var (headerRow, colMap) = FindHeaderRow(ws, new[]
        {
            "date", "transaction type", "type", "num", "name", "customer", "amount", "memo", "description"
        });
        if (headerRow < 0) return Fail("Could not find column headers. Please export using Reports → Transaction List by Date → Export to Excel.");

        var allInvoices = _db.Invoices
            .IgnoreQueryFilters()
            .Where(i => i.CompanyId == companyId)
            .Include(i => i.Payments)
            .Include(i => i.Customer)
            .ToList();

        // Primary lookup: by ExternalReference (invoice number as exported from QBO).
        var invoicesByRef = allInvoices
            .Where(i => i.ExternalReference != null)
            .ToDictionary(i => i.ExternalReference!, i => i);

        // Fallback lookup: by customer display name → list of their open invoices.
        // QBO payment rows carry the customer name in the "Name" column but NOT the invoice number.
        var invoicesByCustomer = allInvoices
            .GroupBy(i =>
            {
                var c = i.Customer;
                return Normalize(c?.CompanyName ?? $"{c?.ContactFirstName} {c?.ContactLastName}".Trim());
            })
            .ToDictionary(g => g.Key, g => g.ToList());

        var paidStatus    = GetOrCreateInvoiceStatus(InvoiceStatus.Paid);
        var partialStatus = GetOrCreateInvoiceStatus(InvoiceStatus.PartiallyPaid);

        int lastRow = ws.Dimension?.End.Row ?? 0;
        for (int row = headerRow + 1; row <= lastRow; row++)
        {
            var txType      = Cell(ws, row, colMap, "transaction type", "type") ?? "";
            var dateRaw     = Cell(ws, row, colMap, "date");
            var num         = Cell(ws, row, colMap, "num", "#");
            var customerRaw = Cell(ws, row, colMap, "name", "customer");
            var amtRaw      = Cell(ws, row, colMap, "amount");
            var memo        = Cell(ws, row, colMap, "memo", "description", "memo/description");

            // Only process payments/receipts
            if (!txType.Contains("Payment", StringComparison.OrdinalIgnoreCase) &&
                !txType.Contains("Receipt", StringComparison.OrdinalIgnoreCase))
            { result.SkippedCount++; continue; }

            if (!DateTime.TryParse(dateRaw, out var payDate)) payDate = DateTime.UtcNow;
            decimal.TryParse(amtRaw?.Replace("$", "").Replace(",", "").Replace("(", "-").Replace(")", ""), out var amount);
            if (amount < 0) amount = Math.Abs(amount);
            if (amount == 0) { result.SkippedCount++; continue; }

            result.TotalRecords++;

            // Match strategy 1: num matches an invoice's ExternalReference directly.
            Invoice? invoice = null;
            if (!string.IsNullOrWhiteSpace(num)) invoicesByRef.TryGetValue(num, out invoice);

            // Match strategy 2: QBO payment rows use the payment/check number in Num, not the
            // invoice number. Fall back to customer name + exact amount against open invoices.
            if (invoice == null && !string.IsNullOrWhiteSpace(customerRaw))
            {
                var custKey = Normalize(customerRaw);
                if (invoicesByCustomer.TryGetValue(custKey, out var custInvoices))
                {
                    // Prefer an invoice whose outstanding balance equals the payment amount.
                    invoice = custInvoices.FirstOrDefault(i =>
                        i.BalanceDue > 0 && i.BalanceDue == amount &&
                        !i.Payments.Any(p => p.PaymentDate.Date == payDate.Date && p.Amount == amount));

                    // Broader fallback: any open invoice for this customer with a matching total.
                    invoice ??= custInvoices.FirstOrDefault(i =>
                        i.BalanceDue > 0 && i.Total == amount &&
                        !i.Payments.Any(p => p.PaymentDate.Date == payDate.Date && p.Amount == amount));
                }
            }

            if (invoice != null)
            {
                // Avoid duplicate payments
                if (invoice.Payments.Any(p => p.PaymentDate.Date == payDate.Date && p.Amount == amount))
                { result.SkippedCount++; result.TotalRecords--; continue; }

                invoice.AmountPaid += amount;
                invoice.Status      = invoice.BalanceDue <= 0 ? paidStatus : partialStatus;
                invoice.UpdatedAt   = DateTime.UtcNow;

                _db.Payments.Add(new Payment
                {
                    CompanyId     = companyId,
                    InvoiceId     = invoice.Id,
                    Amount        = amount,
                    PaymentDate   = payDate,
                    PaymentMethod = PaymentMethod.BankTransferACH,
                    Notes         = memo ?? "Imported from QuickBooks Online",
                    Reference     = num ?? "",
                    CreatedAt     = DateTime.UtcNow,
                    UpdatedAt     = DateTime.UtcNow,
                    CreatedBy     = userId,
                    UpdatedBy     = userId,
                });
                result.ImportedCount++;
            }
            else
            {
                result.Errors.Add(new ImportErrorDto
                {
                    Severity      = "Warning",
                    LineNumber    = row,
                    RecordName    = num,
                    ErrorMessage  = $"No matching invoice found for payment '{num}' (customer: {customerRaw ?? "unknown"}, amount: {amount:C}). Payment skipped."
                });
                result.SkippedCount++;
            }
        }

        _db.SaveChanges();
        result.Success = result.TotalRecords > 0;
        return result;
    }

    // ── Header detection ──────────────────────────────────────────────────────

    /// <summary>
    /// Scans the first 10 rows looking for the row that contains the most known column keywords.
    /// Returns (headerRowIndex, columnMap) where columnMap maps lowercase header name → column index.
    /// </summary>
    private static (int headerRow, Dictionary<string, int> colMap) FindHeaderRow(
        ExcelWorksheet ws, string[] knownHeaders)
    {
        int lastCol = ws.Dimension?.End.Column ?? 0;
        int bestRow = -1;
        int bestScore = 0;
        var bestMap = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);

        for (int row = 1; row <= Math.Min(10, ws.Dimension?.End.Row ?? 0); row++)
        {
            var map = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
            int score = 0;

            for (int col = 1; col <= lastCol; col++)
            {
                var val = ws.Cells[row, col].Text?.Trim();
                if (string.IsNullOrEmpty(val)) continue;
                var lower = val.ToLowerInvariant();
                map[lower] = col;
                if (knownHeaders.Any(h => lower.Contains(h) || h.Contains(lower))) score++;
            }

            if (score > bestScore)
            {
                bestScore = score;
                bestRow   = row;
                bestMap   = map;
            }
        }

        return bestScore >= 2 ? (bestRow, bestMap) : (-1, new Dictionary<string, int>());
    }

    /// <summary>
    /// Reads a cell value by trying multiple possible column header names.
    /// </summary>
    private static string? Cell(ExcelWorksheet ws, int row, Dictionary<string, int> colMap, params string[] names)
    {
        foreach (var name in names)
        {
            // Exact match
            if (colMap.TryGetValue(name, out var col))
            {
                var val = ws.Cells[row, col].Text?.Trim();
                if (!string.IsNullOrEmpty(val)) return val;
            }
            // Partial match
            var partial = colMap.Keys.FirstOrDefault(k => k.Contains(name) || name.Contains(k));
            if (partial != null)
            {
                var val = ws.Cells[row, colMap[partial]].Text?.Trim();
                if (!string.IsNullOrEmpty(val)) return val;
            }
        }
        return null;
    }

    // ── Metadata-row guard ───────────────────────────────────────────────────

    private static readonly string[] _daysOfWeek =
        ["monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"];

    /// <summary>
    /// Returns true if the value looks like a QBO report footer/header line rather than real data.
    /// QBO appends a timestamp footer such as "Saturday, April 04, 2026 02:06 PM GMTZ" and sometimes
    /// a company-name header before the column headers.
    /// </summary>
    private static bool IsMetadataRow(string? value)
    {
        if (string.IsNullOrWhiteSpace(value)) return false;
        var lower = value.Trim().ToLowerInvariant();

        // Starts with a day-of-week name (timestamp footer)
        if (_daysOfWeek.Any(d => lower.StartsWith(d + ","))) return true;

        // Contains AM/PM + a timezone abbreviation (another timestamp pattern)
        if ((lower.Contains(" am ") || lower.Contains(" pm ")) &&
            (lower.EndsWith("gmt") || lower.EndsWith("gmtz") || lower.EndsWith("utc") ||
             lower.EndsWith("est") || lower.EndsWith("cst") || lower.EndsWith("mst") || lower.EndsWith("pst")))
            return true;

        // "Total" / "Grand Total" summary rows
        if (lower.StartsWith("total") || lower.StartsWith("grand total")) return true;

        return false;
    }

    // ── Enum mapping ──────────────────────────────────────────────────────────

    /// <summary>
    /// Translates a QBO account type string (as it appears in the export) to the app's
    /// <see cref="AccountType"/> enum.  QBO uses long, human-readable labels like
    /// "Accounts Receivable (A/R)" which must be lowercased and matched literally.
    /// Unrecognized types default to <see cref="AccountType.Expense"/> to avoid import failures.
    /// </summary>
    private static AccountType MapQboAccountType(string? type) => (type?.ToLower().Trim()) switch
    {
        "bank"                       => AccountType.Asset,
        "accounts receivable (a/r)"  => AccountType.Asset,
        "accounts receivable"        => AccountType.Asset,
        "other current assets"       => AccountType.Asset,
        "fixed assets"               => AccountType.Asset,
        "other assets"               => AccountType.Asset,
        "accounts payable (a/p)"     => AccountType.Liability,
        "accounts payable"           => AccountType.Liability,
        "credit card"                => AccountType.Liability,
        "other current liabilities"  => AccountType.Liability,
        "long-term liabilities"      => AccountType.Liability,
        "equity"                     => AccountType.Equity,
        "income"                     => AccountType.Revenue,
        "other income"               => AccountType.Revenue,
        "cost of goods sold"         => AccountType.CostOfGoods,
        "expenses"                   => AccountType.Expense,
        "other expense"              => AccountType.Expense,
        _                            => AccountType.Expense
    };

    /// <summary>
    /// Translates a QBO "Detail Type" string to the app's <see cref="AccountSubType"/> enum.
    /// Only the most common subtypes used in powder-coating businesses are mapped; everything
    /// else collapses to <see cref="AccountSubType.Other"/> so the import does not fail on
    /// exotic or custom QBO account detail types.
    /// </summary>
    private static AccountSubType MapQboDetailType(string? detail) => (detail?.ToLower().Trim()) switch
    {
        "checking"                   => AccountSubType.Checking,
        "savings"                    => AccountSubType.Savings,
        "inventory"                  => AccountSubType.Inventory,
        "accounts receivable"        => AccountSubType.AccountsReceivable,
        "accounts payable"           => AccountSubType.AccountsPayable,
        _                            => AccountSubType.Other
    };

    /// <summary>
    /// Pass-through helper retained for symmetry with other entity processors.
    /// <see cref="InvoiceStatus"/> is a plain enum (not a lookup table entity), so no
    /// database query is needed — the value is returned as-is.
    /// </summary>
    private InvoiceStatus GetOrCreateInvoiceStatus(InvoiceStatus status) => status;

    /// <summary>
    /// Strips a leading "Label: " prefix that QBO sometimes embeds in cell values
    /// (e.g. "Phone: (555) 123-4567" → "(555) 123-4567").
    /// </summary>
    private static string? StripLabel(string? value)
    {
        if (string.IsNullOrWhiteSpace(value)) return value;
        var idx = value.IndexOf(':');
        if (idx > 0 && idx < 20) // colon within the first 20 chars → likely a label
        {
            var candidate = value.Substring(idx + 1).Trim();
            if (!string.IsNullOrWhiteSpace(candidate)) return candidate;
        }
        return value;
    }

    // ── Helpers ───────────────────────────────────────────────────────────────

    /// <summary>
    /// Generates the next sequential invoice number in the format <c>INV-YYMM-####</c>.
    /// Uses <c>IgnoreQueryFilters()</c> to scan across all tenants' invoices with the same
    /// month prefix, which guarantees uniqueness even if the same company previously had
    /// soft-deleted invoices from the same month.
    /// Called once per invoice row during the import loop, so it performs one DB round-trip
    /// per invoice — acceptable because invoice imports are typically small batches.
    /// </summary>
    private async Task<string> GenerateInvoiceNumber(int companyId)
    {
        var prefix = $"INV-{DateTime.UtcNow:yyMM}-";
        var last   = await _db.Invoices
            .IgnoreQueryFilters()
            .Where(i => i.InvoiceNumber.StartsWith(prefix))
            .OrderByDescending(i => i.InvoiceNumber)
            .Select(i => i.InvoiceNumber)
            .FirstOrDefaultAsync();

        int seq = 1;
        if (last != null && int.TryParse(last.Substring(prefix.Length), out var n)) seq = n + 1;
        return $"{prefix}{seq:D4}";
    }

    /// <summary>
    /// Normalizes a string for case- and whitespace-insensitive deduplication matching
    /// (trim, lowercase, collapse double spaces).  Used as the dictionary key for all
    /// upsert lookups so that "ABC Corp" and "abc corp " are treated as the same entity.
    /// </summary>
    private static string Normalize(string? s) =>
        (s ?? "").Trim().ToLowerInvariant().Replace("  ", " ");

    /// <summary>
    /// Constructs a failed <see cref="ImportResultDto"/> with a single descriptive error.
    /// Used for early-exit conditions such as an unreadable file or missing header row.
    /// </summary>
    private static ImportResultDto Fail(string message) =>
        new ImportResultDto { Success = false, Errors = { new ImportErrorDto { ErrorMessage = message } } };
}
