using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.EntityFrameworkCore;
using PowderCoating.Application.DTOs.Accounting;
using PowderCoating.Application.DTOs.AI;
using PowderCoating.Application.Interfaces;
using PowderCoating.Core.Enums;
using PowderCoating.Core.Interfaces;
using PowderCoating.Core.Entities;
using PowderCoating.Shared.Constants;
using PowderCoating.Web.ViewModels.Reports;
using System.Security.Claims;

namespace PowderCoating.Web.Controllers;

[Authorize(Policy = AppConstants.Policies.CanViewReports)]
public class ReportsController : Controller
{
    private readonly IUnitOfWork _unitOfWork;
    private readonly ILogger<ReportsController> _logger;
    private readonly IFinancialReportService _financialReports;
    private readonly IOperationalReportService _operationalReports;
    private readonly IPdfService _pdfService;
    private readonly UserManager<ApplicationUser> _userManager;
    private readonly IAccountingAiService _accountingAi;
    private readonly IAiUsageLogger _usageLogger;
    private readonly ITenantContext _tenantContext;

    public ReportsController(IUnitOfWork unitOfWork, ILogger<ReportsController> logger, IFinancialReportService financialReports, IOperationalReportService operationalReports, IPdfService pdfService, UserManager<ApplicationUser> userManager, IAccountingAiService accountingAi, IAiUsageLogger usageLogger, ITenantContext tenantContext)
    {
        _unitOfWork = unitOfWork;
        _logger = logger;
        _financialReports = financialReports;
        _operationalReports = operationalReports;
        _pdfService = pdfService;
        _userManager = userManager;
        _accountingAi = accountingAi;
        _usageLogger = usageLogger;
        _tenantContext = tenantContext;
    }

    /// <summary>
    /// The report catalog / landing page. All report links live here rather than in the nav so
    /// accounting-gated reports can be shown or hidden without duplicating the policy check on
    /// every individual action.
    /// </summary>
    public IActionResult Landing() => View();

    /// <summary>
    /// Guard helper checked at the start of every accounting-sensitive action (P&amp;L, Balance Sheet,
    /// AR Aging, etc.). The AllowAccounting flag is set by a middleware or filter reading the
    /// company's subscription plan — non-accounting plans return false and those actions redirect to
    /// the landing page instead of showing financial data. Reads from HttpContext.Items so the value
    /// is computed once per request and cached for the lifetime of the HTTP pipeline.
    /// </summary>
    private bool AllowAccounting() => HttpContext.Items["AllowAccounting"] as bool? ?? false;

    // Catalog is the new index — redirect legacy bookmarks
    public IActionResult Index() => RedirectToAction(nameof(Landing));

    /// <summary>
    /// Legacy full-analytics dashboard — the original single-page report before individual report
    /// pages were introduced. Kept for reference during transition; the individual report pages
    /// (KpiDashboard, RevenueTrends, etc.) are the current canonical destinations.
    /// Loads multiple entity collections in-memory because they are needed for cross-cutting
    /// aggregations (e.g. revenue by priority requires both job AND priority lookup tables).
    /// Status codes are compared as string constants ("COMPLETED", "IN_PREPARATION", etc.) rather
    /// than enum values because JobStatus is now a lookup table entity, not an enum.
    /// </summary>
    public async Task<IActionResult> Analytics(int months = 6)
    {
        try
        {
            var now = DateTime.UtcNow;
            var today = DateTime.Today;
            var startOfMonth = new DateTime(now.Year, now.Month, 1);
            var startOfLastMonth = startOfMonth.AddMonths(-1);
            var startDate = now.AddMonths(-months);

            var completedStatusCodes = new[] { "COMPLETED", "READY_FOR_PICKUP", "DELIVERED" };
            var activeStatusCodes = new[] { "PENDING", "QUOTED", "APPROVED", "IN_PREPARATION", "SANDBLASTING",
                "MASKING_TAPING", "CLEANING", "IN_OVEN", "COATING", "CURING", "QUALITY_CHECK", "ON_HOLD" };
            var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;

            // Load only necessary data — all explicitly scoped to this company
            var jobs = (await _unitOfWork.Jobs.FindAsync(j => j.CompanyId == companyId, false, j => j.Customer, j => j.JobStatus, j => j.JobPriority, j => j.AssignedUser)).ToList();

            // Quotes: Load all quotes (needed for quote status distribution and conversion funnel)
            var quotes = (await _unitOfWork.Quotes.FindAsync(q => q.CompanyId == companyId, false, q => q.Customer, q => q.QuoteStatus)).ToList();

            // Customers: Load all (needed for active count and customer creation trend across all months)
            var customers = (await _unitOfWork.Customers.FindAsync(c => c.CompanyId == companyId)).ToList();

            // Equipment: Load all for status distribution
            var equipment = (await _unitOfWork.Equipment.FindAsync(e => e.CompanyId == companyId)).ToList();

            // Inventory: Load all for low stock analysis
            var inventory = (await _unitOfWork.InventoryItems.FindAsync(i => i.CompanyId == companyId)).ToList();

            // Appointments: Filter to relevant date range at DB level
            var appointments = (await _unitOfWork.Appointments.FindAsync(
                a => a.CompanyId == companyId && a.ScheduledStartTime >= startDate,
                false,
                a => a.Customer,
                a => a.AppointmentType,
                a => a.AppointmentStatus)).ToList();

            // Users with assigned jobs/appointments will be loaded below when building worker stats

            // CatalogItems: Load all for category distribution
            var catalogItems = (await _unitOfWork.CatalogItems.FindAsync(ci => ci.CompanyId == companyId, false, c => c.Category)).ToList();

            // === OVERVIEW METRICS ===
            var completedJobs = jobs.Where(j => completedStatusCodes.Contains(j.JobStatus.StatusCode)).ToList();
            var totalRevenue = completedJobs.Sum(j => j.FinalPrice);

            var activeJobs = jobs.Where(j => activeStatusCodes.Contains(j.JobStatus.StatusCode)).ToList();

            var completedThisMonth = jobs.Count(j =>
                completedStatusCodes.Contains(j.JobStatus.StatusCode) && j.UpdatedAt >= startOfMonth);

            var completedLastMonth = jobs.Count(j =>
                completedStatusCodes.Contains(j.JobStatus.StatusCode)
                && j.UpdatedAt >= startOfLastMonth
                && j.UpdatedAt < startOfMonth);

            var approvedQuotes = quotes.Where(q => q.QuoteStatus.StatusCode == "APPROVED" || q.QuoteStatus.StatusCode == "CONVERTED").ToList();
            var winRate = quotes.Any() ? Math.Round((decimal)approvedQuotes.Count / quotes.Count * 100, 1) : 0m;

            var avgJobValue = completedJobs.Any() ? totalRevenue / completedJobs.Count : 0m;

            var appointmentsThisMonth = appointments.Count(a => a.ScheduledStartTime >= startOfMonth);

            // Calculate average job duration (days from created to completed)
            var completedJobsWithDates = completedJobs.Where(j => j.CompletedDate.HasValue).ToList();
            var avgJobDuration = completedJobsWithDates.Any()
                ? completedJobsWithDates.Average(j => (j.CompletedDate.GetValueOrDefault() - j.CreatedAt).TotalDays)
                : 0;

            // Month-over-month growth
            var revenueThisMonth = jobs.Where(j => completedStatusCodes.Contains(j.JobStatus.StatusCode) && j.UpdatedAt >= startOfMonth).Sum(j => j.FinalPrice);
            var revenueLastMonth = jobs.Where(j => completedStatusCodes.Contains(j.JobStatus.StatusCode)
                && j.UpdatedAt >= startOfLastMonth && j.UpdatedAt < startOfMonth).Sum(j => j.FinalPrice);
            var momGrowth = revenueLastMonth > 0 ? Math.Round((revenueThisMonth - revenueLastMonth) / revenueLastMonth * 100, 1) : 0m;

            // === REVENUE ANALYTICS ===
            // CompletedDate is the authoritative "when the job finished" date.
            // UpdatedAt is set by the EF interceptor only on Modified saves, so seeded/imported
            // jobs may have UpdatedAt = null. Fall back to CreatedAt as a last resort.
            static DateTime JobMonthDate(Job j) =>
                j.CompletedDate ?? j.UpdatedAt ?? j.CreatedAt;

            var completedJobsInRange = completedJobs
                .Where(j => JobMonthDate(j) >= startDate)
                .ToList();

            // Group by month for efficient monthly aggregations
            var jobsByMonth = completedJobsInRange
                .GroupBy(j => { var d = JobMonthDate(j); return new DateTime(d.Year, d.Month, 1); })
                .ToDictionary(g => g.Key, g => g.ToList());

            // Monthly revenue trend
            var monthLabels = new List<string>();
            var monthlyRevenue = new List<decimal>();
            var monthlyJobCount = new List<int>();

            for (var i = months - 1; i >= 0; i--)
            {
                var month = now.AddMonths(-i);
                var monthStart = new DateTime(month.Year, month.Month, 1);

                monthLabels.Add(month.ToString("MMM yyyy"));

                if (jobsByMonth.TryGetValue(monthStart, out var monthJobs))
                {
                    monthlyRevenue.Add(monthJobs.Sum(j => j.FinalPrice));
                    monthlyJobCount.Add(monthJobs.Count);
                }
                else
                {
                    monthlyRevenue.Add(0);
                    monthlyJobCount.Add(0);
                }
            }

            // Revenue by customer type
            var revenueByCustomerType = completedJobs
                .Where(j => j.Customer != null)
                .GroupBy(j => j.Customer!.IsCommercial ? "Commercial" : "Individual")
                .ToDictionary(g => g.Key, g => g.Sum(j => j.FinalPrice));

            // Revenue by priority — build order lookup once to avoid O(n²) per group
            var priorityOrder = completedJobs
                .GroupBy(j => j.JobPriority.DisplayName)
                .ToDictionary(g => g.Key, g => g.First().JobPriority.DisplayOrder);
            var revenueByPriority = completedJobs
                .GroupBy(j => j.JobPriority.DisplayName)
                .OrderBy(g => priorityOrder.TryGetValue(g.Key, out var o) ? o : 999)
                .ToDictionary(g => g.Key, g => g.Sum(j => j.FinalPrice));

            // Average order value trend - reuse jobsByMonth
            var avgOrderValueTrend = new List<decimal>();
            for (var i = months - 1; i >= 0; i--)
            {
                var month = now.AddMonths(-i);
                var monthStart = new DateTime(month.Year, month.Month, 1);

                if (jobsByMonth.TryGetValue(monthStart, out var monthJobs) && monthJobs.Any())
                {
                    avgOrderValueTrend.Add(monthJobs.Average(j => j.FinalPrice));
                }
                else
                {
                    avgOrderValueTrend.Add(0);
                }
            }

            // Top customers by revenue
            var topCustomers = completedJobs
                .Where(j => j.Customer != null)
                .GroupBy(j => j.Customer!.Id)
                .Select(g => new TopCustomerItem
                {
                    Id      = g.Key,
                    Name    = g.First().Customer!.CompanyName
                              ?? $"{g.First().Customer!.ContactFirstName} {g.First().Customer!.ContactLastName}".Trim(),
                    Revenue  = g.Sum(j => j.FinalPrice),
                    JobCount = g.Count()
                })
                .OrderByDescending(x => x.Revenue)
                .Take(10)
                .ToList();

            // === OPERATIONS ANALYTICS ===
            // Job status distribution — build order lookups once to avoid O(n²) per group
            var statusOrder = jobs
                .GroupBy(j => j.JobStatus.DisplayName)
                .ToDictionary(g => g.Key, g => g.First().JobStatus.DisplayOrder);
            var jobsByStatus = jobs
                .GroupBy(j => j.JobStatus.DisplayName)
                .OrderBy(g => statusOrder.TryGetValue(g.Key, out var o) ? o : 999)
                .ToDictionary(g => g.Key, g => g.Count());

            // Active jobs by priority — same pattern
            var activePriorityOrder = activeJobs
                .GroupBy(j => j.JobPriority.DisplayName)
                .ToDictionary(g => g.Key, g => g.First().JobPriority.DisplayOrder);
            var jobsByPriority = activeJobs
                .GroupBy(j => j.JobPriority.DisplayName)
                .OrderBy(g => activePriorityOrder.TryGetValue(g.Key, out var o) ? o : 999)
                .ToDictionary(g => g.Key, g => g.Count());

            // Appointment analytics
            var appointmentsByType = appointments
                .Where(a => a.AppointmentType != null)
                .GroupBy(a => a.AppointmentType!.DisplayName)
                .ToDictionary(g => g.Key, g => g.Count());

            var appointmentsByStatus = appointments
                .Where(a => a.AppointmentStatus != null)
                .GroupBy(a => a.AppointmentStatus!.DisplayName)
                .ToDictionary(g => g.Key, g => g.Count());

            var completedAppointments = appointments.Count(a => a.AppointmentStatus?.StatusCode == "COMPLETED");
            var appointmentCompletionRate = appointments.Any()
                ? Math.Round((decimal)completedAppointments / appointments.Count * 100, 1)
                : 0m;

            // Appointments by day of week
            var appointmentsByDayOfWeek = appointments
                .GroupBy(a => a.ScheduledStartTime.DayOfWeek)
                .OrderBy(g => (int)g.Key)
                .ToDictionary(g => g.Key.ToString(), g => g.Count());

            // Worker performance
            var assignedUserIds = jobs.Where(j => j.AssignedUserId != null).Select(j => j.AssignedUserId!)
                .Concat(appointments.Where(a => a.AssignedUserId != null).Select(a => a.AssignedUserId!))
                .Distinct().ToList();
            var assignedUsers = assignedUserIds.Any()
                ? await _userManager.Users.Where(u => assignedUserIds.Contains(u.Id)).ToListAsync()
                : new List<ApplicationUser>();

            var workerStats = assignedUsers.Select(u => new WorkerStatsItem
            {
                Name = u.FullName,
                Role = u.Position ?? u.CompanyRole ?? "User",
                JobsAssigned = jobs.Count(j => j.AssignedUserId == u.Id),
                JobsCompleted = jobs.Count(j => j.AssignedUserId == u.Id && completedStatusCodes.Contains(j.JobStatus.StatusCode)),
                AppointmentsAssigned = appointments.Count(a => a.AssignedUserId == u.Id)
            })
            .Where(w => w.JobsAssigned > 0 || w.AppointmentsAssigned > 0)
            .OrderByDescending(w => w.JobsCompleted)
            .Take(10)
            .ToList();

            // Equipment health
            var equipmentByStatus = equipment
                .GroupBy(e => e.Status)
                .ToDictionary(g => FormatEnum(g.Key.ToString()), g => g.Count());

            // Low stock items
            var lowStock = inventory
                .Where(i => i.IsActive && i.QuantityOnHand <= i.ReorderPoint)
                .OrderBy(i => i.QuantityOnHand)
                .Take(10)
                .Select(i => new LowStockItem
                {
                    Id = i.Id,
                    Name = i.Name,
                    ColorName = i.ColorName,
                    QuantityOnHand = i.QuantityOnHand,
                    ReorderPoint = i.ReorderPoint,
                    UnitOfMeasure = i.UnitOfMeasure
                })
                .ToList();

            // === CUSTOMER ANALYTICS ===
            // Pre-filter customers by date range once for monthly calculations
            var customersByMonth = customers
                .Where(c => c.CreatedAt >= startDate)
                .GroupBy(c => new DateTime(c.CreatedAt.Year, c.CreatedAt.Month, 1))
                .ToDictionary(g => g.Key, g => g.Count());

            // New customers per month
            var newCustomersPerMonth = new List<int>();
            for (var i = months - 1; i >= 0; i--)
            {
                var month = now.AddMonths(-i);
                var monthStart = new DateTime(month.Year, month.Month, 1);
                newCustomersPerMonth.Add(customersByMonth.TryGetValue(monthStart, out var count) ? count : 0);
            }

            // Customer retention (customers with multiple jobs)
            var customersWithMultipleJobs = completedJobs
                .Where(j => j.Customer != null)
                .GroupBy(j => j.Customer!.Id)
                .Count(g => g.Count() > 1);
            var totalCustomersWithJobs = completedJobs
                .Where(j => j.Customer != null)
                .Select(j => j.Customer!.Id)
                .Distinct()
                .Count();
            var customerRetentionRate = totalCustomersWithJobs > 0
                ? Math.Round((decimal)customersWithMultipleJobs / totalCustomersWithJobs * 100, 1)
                : 0m;

            // Customer lifetime value (top 10)
            var customerLifetimeValue = completedJobs
                .Where(j => j.Customer != null)
                .GroupBy(j => new { j.Customer!.Id, j.Customer.CompanyName })
                .Select(g => new CustomerLifetimeValueItem
                {
                    CustomerName = g.Key.CompanyName,
                    TotalRevenue = g.Sum(j => j.FinalPrice),
                    JobCount = g.Count(),
                    AvgOrderValue = g.Average(j => j.FinalPrice),
                    FirstJobDate = g.Min(j => j.CreatedAt),
                    LastJobDate = g.Max(j => j.CompletedDate ?? j.UpdatedAt ?? j.CreatedAt)
                })
                .OrderByDescending(c => c.TotalRevenue)
                .Take(10)
                .ToList();

            // Quote conversion funnel — build order lookup once to avoid O(n²) per group
            var quoteStatusOrder = quotes
                .GroupBy(q => q.QuoteStatus.DisplayName)
                .ToDictionary(g => g.Key, g => g.First().QuoteStatus.DisplayOrder);
            var quotesByStatus = quotes
                .GroupBy(q => q.QuoteStatus.DisplayName)
                .OrderBy(g => quoteStatusOrder.TryGetValue(g.Key, out var o) ? o : 999)
                .ToDictionary(g => g.Key, g => g.Count());

            var quoteFunnel = new QuoteConversionFunnel
            {
                Draft = quotes.Count(q => q.QuoteStatus.StatusCode == "DRAFT"),
                Sent = quotes.Count(q => q.QuoteStatus.StatusCode == "SENT"),
                Approved = quotes.Count(q => q.QuoteStatus.StatusCode == "APPROVED"),
                Converted = quotes.Count(q => q.QuoteStatus.StatusCode == "CONVERTED"),
                Rejected = quotes.Count(q => q.QuoteStatus.StatusCode == "REJECTED"),
                Expired = quotes.Count(q => q.QuoteStatus.StatusCode == "EXPIRED")
            };

            // Most popular catalog items (from jobs - would need JobItem relationship)
            // For now, just show catalog stats
            var catalogByCategory = catalogItems
                .Where(c => c.Category != null)
                .GroupBy(c => c.Category!.Name)
                .OrderByDescending(g => g.Count())
                .Take(8)
                .ToDictionary(g => g.Key, g => g.Count());

            // === FINANCIAL ANALYTICS ===
            var allInvoices = (await _unitOfWork.Invoices.FindAsync(i => i.CompanyId == companyId, false, i => i.Customer, i => i.Payments)).ToList();
            var activeInvoices = allInvoices.Where(i => i.Status != InvoiceStatus.Voided && i.Status != InvoiceStatus.WrittenOff).ToList();

            var totalInvoiced = activeInvoices.Sum(i => i.Total);
            var totalCollected = activeInvoices.Sum(i => i.AmountPaid);
            var totalOutstanding = activeInvoices.Where(i => i.Status != InvoiceStatus.Paid).Sum(i => i.BalanceDue);
            var overdueInvoices = activeInvoices.Where(i => i.Status != InvoiceStatus.Paid && i.DueDate.HasValue && i.DueDate.Value < today).ToList();
            var totalOverdue = overdueInvoices.Sum(i => i.BalanceDue);

            var invoicesOverdueCount = overdueInvoices.Count;
            var invoicesDraftCount = allInvoices.Count(i => i.Status == InvoiceStatus.Draft);
            var invoicesPaidCount = allInvoices.Count(i => i.Status == InvoiceStatus.Paid);

            // Monthly invoiced vs collected (same month labels as existing revenue chart)
            var invoicesByMonth = activeInvoices
                .GroupBy(i => new DateTime(i.InvoiceDate.Year, i.InvoiceDate.Month, 1))
                .ToDictionary(g => g.Key, g => g.ToList());

            var paymentsByMonth = activeInvoices
                .SelectMany(i => i.Payments.Where(p => !p.IsDeleted))
                .GroupBy(p => new DateTime(p.PaymentDate.Year, p.PaymentDate.Month, 1))
                .ToDictionary(g => g.Key, g => g.ToList());

            var monthlyInvoiced = new List<decimal>();
            var monthlyCollected = new List<decimal>();
            for (var i = months - 1; i >= 0; i--)
            {
                var month = now.AddMonths(-i);
                var monthStart = new DateTime(month.Year, month.Month, 1);
                monthlyInvoiced.Add(invoicesByMonth.TryGetValue(monthStart, out var mInv) ? mInv.Sum(x => x.Total) : 0m);
                monthlyCollected.Add(paymentsByMonth.TryGetValue(monthStart, out var mPay) ? mPay.Sum(x => x.Amount) : 0m);
            }

            // AR Aging buckets (outstanding invoices only)
            var outstandingInvoices = activeInvoices.Where(i => i.Status != InvoiceStatus.Paid && i.Total > i.AmountPaid).ToList();
            var agingBuckets = new List<AgingBucketItem>
            {
                new AgingBucketItem { Label = "Current (0–30 days)", Amount = 0, Count = 0 },
                new AgingBucketItem { Label = "31–60 days", Amount = 0, Count = 0 },
                new AgingBucketItem { Label = "61–90 days", Amount = 0, Count = 0 },
                new AgingBucketItem { Label = "Over 90 days", Amount = 0, Count = 0 }
            };
            foreach (var inv in outstandingInvoices)
            {
                var daysOverdue = inv.DueDate.HasValue ? (int)(today - inv.DueDate.Value).TotalDays : 0;
                var balance = inv.BalanceDue;
                var bucket = daysOverdue <= 30 ? 0 : daysOverdue <= 60 ? 1 : daysOverdue <= 90 ? 2 : 3;
                agingBuckets[bucket].Amount += balance;
                agingBuckets[bucket].Count++;
            }

            // Recent payments (last 10, with invoice number + customer name)
            var recentPayments = activeInvoices
                .SelectMany(i => i.Payments.Where(p => !p.IsDeleted).Select(p => new RecentPaymentItem
                {
                    InvoiceNumber = i.InvoiceNumber,
                    CustomerName = i.Customer?.CompanyName
                                  ?? $"{i.Customer?.ContactFirstName} {i.Customer?.ContactLastName}".Trim()
                                  ?? string.Empty,
                    Amount = p.Amount,
                    PaymentMethod = p.PaymentMethod.ToString(),
                    PaymentDate = p.PaymentDate
                }))
                .OrderByDescending(p => p.PaymentDate)
                .Take(10)
                .ToList();

            // Top outstanding customers
            var topOutstandingCustomers = outstandingInvoices
                .Where(i => i.Customer != null)
                .GroupBy(i => new { i.CustomerId, Name = i.Customer!.CompanyName ?? $"{i.Customer.ContactFirstName} {i.Customer.ContactLastName}".Trim() })
                .Select(g => new OutstandingCustomerItem
                {
                    CustomerName = g.Key.Name,
                    OutstandingBalance = g.Sum(i => i.BalanceDue),
                    OpenInvoiceCount = g.Count()
                })
                .OrderByDescending(x => x.OutstandingBalance)
                .Take(5)
                .ToList();

            // Average days to payment
            var paidInvoicesWithDates = allInvoices
                .Where(i => i.Status == InvoiceStatus.Paid && i.SentDate.HasValue && i.PaidDate.HasValue)
                .ToList();
            var avgDaysToPayment = paidInvoicesWithDates.Any()
                ? Math.Round(paidInvoicesWithDates.Average(i => (i.PaidDate!.Value - i.SentDate!.Value).TotalDays), 1)
                : 0.0;

            // === SALES BY CUSTOMER ===
            var salesByCustomer = activeInvoices
                .Where(i => i.Customer != null)
                .GroupBy(i => new
                {
                    i.CustomerId,
                    Name = i.Customer!.IsCommercial
                        ? i.Customer.CompanyName
                        : $"{i.Customer.ContactFirstName} {i.Customer.ContactLastName}".Trim(),
                    i.Customer.IsCommercial
                })
                .Select(g => new SalesByCustomerItem
                {
                    CustomerId      = g.Key.CustomerId,
                    CustomerName    = g.Key.Name ?? "Unknown",
                    IsCommercial    = g.Key.IsCommercial,
                    InvoiceCount    = g.Count(),
                    TotalInvoiced   = g.Sum(i => i.Total),
                    TotalPaid       = g.Sum(i => i.AmountPaid),
                    LastInvoiceDate = g.Max(i => (DateTime?)i.InvoiceDate)
                })
                .OrderByDescending(x => x.TotalInvoiced)
                .ToList();

            // === EXPENSE / AP ANALYTICS ===
            var allBills = await _operationalReports.GetActiveBillsAsync();

            var totalBilled = allBills.Sum(b => b.Total);
            var totalBillsPaid = allBills.Sum(b => b.AmountPaid);
            var totalApOutstanding = allBills.Sum(b => b.BalanceDue);

            // AP Aging
            var outstandingBills = allBills.Where(b => b.BalanceDue > 0).ToList();
            var apAgingBuckets = new List<AgingBucketItem>
            {
                new() { Label = "Current (0–30 days)", Amount = 0, Count = 0 },
                new() { Label = "31–60 days",           Amount = 0, Count = 0 },
                new() { Label = "61–90 days",           Amount = 0, Count = 0 },
                new() { Label = "Over 90 days",         Amount = 0, Count = 0 }
            };
            foreach (var bill in outstandingBills)
            {
                var daysOverdue = bill.DueDate.HasValue ? (int)(today - bill.DueDate.Value).TotalDays : 0;
                var bucket = daysOverdue <= 30 ? 0 : daysOverdue <= 60 ? 1 : daysOverdue <= 90 ? 2 : 3;
                apAgingBuckets[bucket].Amount += bill.BalanceDue;
                apAgingBuckets[bucket].Count++;
            }

            // Vendor spend (top vendors)
            var vendorSpend = allBills
                .Where(b => b.Vendor != null)
                .GroupBy(b => new { b.VendorId, Name = b.Vendor!.CompanyName })
                .Select(g => new VendorSpendItem
                {
                    VendorName = g.Key.Name,
                    TotalBilled = g.Sum(b => b.Total),
                    TotalPaid = g.Sum(b => b.AmountPaid),
                    BillCount = g.Count()
                })
                .OrderByDescending(v => v.TotalBilled)
                .Take(8)
                .ToList();

            // Expenses by account
            var allExpenses = await _operationalReports.GetAllExpensesAsync();

            var expensesByAccount = allExpenses
                .Where(e => e.ExpenseAccount != null)
                .GroupBy(e => new { e.ExpenseAccountId, Name = $"{e.ExpenseAccount!.AccountNumber} – {e.ExpenseAccount.Name}" })
                .Select(g => new ExpenseByAccountItem
                {
                    AccountName = g.Key.Name,
                    Amount = g.Sum(e => e.Amount),
                    Count = g.Count()
                })
                .OrderByDescending(x => x.Amount)
                .ToList();

            var totalDirectExpenses = allExpenses.Sum(e => e.Amount);

            // Monthly expenses (bills paid + direct expenses) for P&L
            var monthlyBillsPaid = new List<decimal>();
            var monthlyDirectExpenses = new List<decimal>();
            for (var i = months - 1; i >= 0; i--)
            {
                var month = now.AddMonths(-i);
                var monthStart = new DateTime(month.Year, month.Month, 1);
                var monthEnd = monthStart.AddMonths(1);

                var billsThisMonth = allBills
                    .SelectMany(b => b.Payments)
                    .Where(p => p.PaymentDate >= monthStart && p.PaymentDate < monthEnd)
                    .Sum(p => p.Amount);

                var expensesThisMonth = allExpenses
                    .Where(e => e.Date >= monthStart && e.Date < monthEnd)
                    .Sum(e => e.Amount);

                monthlyBillsPaid.Add(billsThisMonth);
                monthlyDirectExpenses.Add(expensesThisMonth);
            }

            // P&L: Revenue (collected) vs Total Expenses (bills paid + direct)
            var plRevenue = monthlyCollected;
            var plExpenses = monthlyBillsPaid.Zip(monthlyDirectExpenses, (a, b) => a + b).ToList();
            var plNetIncome = plRevenue.Zip(plExpenses, (r, e) => r - e).ToList();

            // === POWDER USAGE ANALYTICS ===
            var powderTransactions = (await _unitOfWork.InventoryTransactions
                .FindAsync(t => t.TransactionType == InventoryTransactionType.JobUsage
                             && t.TransactionDate >= startDate,
                           false,
                           t => t.InventoryItem))
                .ToList();

            var totalPowderUsedLbs = powderTransactions.Sum(t => Math.Abs(t.Quantity));
            var totalPowderCost = powderTransactions.Sum(t => Math.Abs(t.TotalCost));
            var totalJobsWithPowderUsage = powderTransactions
                .Where(t => !string.IsNullOrEmpty(t.Reference))
                .Select(t => t.Reference)
                .Distinct()
                .Count();

            var topColorsByUsage = powderTransactions
                .Where(t => t.InventoryItem != null)
                .GroupBy(t => t.InventoryItemId)
                .Select(g => new PowderUsageByColorItem
                {
                    InventoryItemId = g.Key,
                    ColorName = g.First().InventoryItem!.ColorName ?? g.First().InventoryItem.Name,
                    ColorCode = g.First().InventoryItem!.ColorCode,
                    SKU = g.First().InventoryItem!.SKU,
                    Manufacturer = g.First().InventoryItem!.Manufacturer,
                    TotalLbsUsed = g.Sum(t => Math.Abs(t.Quantity)),
                    TotalCost = g.Sum(t => Math.Abs(t.TotalCost)),
                    JobCount = g.Where(t => !string.IsNullOrEmpty(t.Reference))
                                .Select(t => t.Reference).Distinct().Count()
                })
                .OrderByDescending(x => x.TotalLbsUsed)
                .Take(15)
                .ToList();

            var monthlyPowderUsageLbs = new List<decimal>();
            var monthlyPowderUsageCost = new List<decimal>();
            for (var i = months - 1; i >= 0; i--)
            {
                var month = now.AddMonths(-i);
                var monthStart = new DateTime(month.Year, month.Month, 1);
                var monthEnd = monthStart.AddMonths(1);
                var monthTx = powderTransactions.Where(t => t.TransactionDate >= monthStart && t.TransactionDate < monthEnd).ToList();
                monthlyPowderUsageLbs.Add(monthTx.Sum(t => Math.Abs(t.Quantity)));
                monthlyPowderUsageCost.Add(monthTx.Sum(t => Math.Abs(t.TotalCost)));
            }

            // === CUSTOMER RETENTION ===
            var customerRetention = customers
                .Where(c => c.IsActive)
                .Select(c =>
                {
                    var cJobs = completedJobs.Where(j => j.CustomerId == c.Id).ToList();
                    var lastJob = cJobs.Any() ? cJobs.Max(j => j.CompletedDate ?? j.UpdatedAt ?? j.CreatedAt) : (DateTime?)null;
                    var daysSince = lastJob.HasValue ? (int)(now - lastJob.Value).TotalDays : -1;
                    var status = cJobs.Count == 0 ? "Never Ordered"
                                 : daysSince <= 30 ? "Active"
                                 : daysSince <= 60 ? "At Risk"
                                 : daysSince <= 90 ? "Lapsing"
                                 : "Churned";
                    return new CustomerRetentionItem
                    {
                        CustomerId      = c.Id,
                        CustomerName    = c.IsCommercial ? c.CompanyName ?? "Unknown" : $"{c.ContactFirstName} {c.ContactLastName}".Trim(),
                        Email           = c.Email,
                        Phone           = c.Phone ?? c.MobilePhone,
                        TotalJobs       = cJobs.Count,
                        LifetimeRevenue = cJobs.Sum(j => j.FinalPrice),
                        LastJobDate     = lastJob,
                        DaysSinceLastJob = daysSince,
                        RetentionStatus = status
                    };
                })
                .OrderBy(c =>  c.RetentionStatus == "Churned"       ? 0
                             : c.RetentionStatus == "Lapsing"       ? 1
                             : c.RetentionStatus == "At Risk"       ? 2
                             : c.RetentionStatus == "Active"        ? 3
                             : 4)
                .ThenByDescending(c => c.LifetimeRevenue)
                .ToList();

            // === JOB CYCLE TIME ===
            var allStatusHistory = await _operationalReports.GetAllJobStatusHistoryAsync();

            var historyByJob = allStatusHistory
                .GroupBy(h => h.JobId)
                .ToDictionary(g => g.Key, g => g.OrderBy(h => h.ChangedDate).ToList());

            var statusDisplayOrder = new[] { "PENDING", "QUOTED", "APPROVED", "IN_PREPARATION", "SANDBLASTING",
                "MASKING_TAPING", "CLEANING", "IN_OVEN", "COATING", "CURING", "QUALITY_CHECK" };

            var statusTimings = new Dictionary<string, (string DisplayName, List<double> Days)>();
            foreach (var job in completedJobs.Where(j => j.CompletedDate.HasValue))
            {
                if (!historyByJob.TryGetValue(job.Id, out var history) || !history.Any()) continue;
                var prevDate = job.CreatedAt;
                foreach (var entry in history)
                {
                    var fromCode = entry.FromStatus?.StatusCode;
                    var fromName = entry.FromStatus?.DisplayName;
                    if (fromCode == null) { prevDate = entry.ChangedDate; continue; }
                    var days = (entry.ChangedDate - prevDate).TotalDays;
                    if (days >= 0 && days <= 365)
                    {
                        if (!statusTimings.ContainsKey(fromCode))
                            statusTimings[fromCode] = (fromName ?? fromCode, new List<double>());
                        statusTimings[fromCode].Days.Add(days);
                    }
                    prevDate = entry.ChangedDate;
                }
                // Time in final status before completion
                var last = history.Last();
                var toCode = last.ToStatus?.StatusCode;
                var toName = last.ToStatus?.DisplayName;
                if (toCode != null)
                {
                    var days = (job.CompletedDate!.Value - last.ChangedDate).TotalDays;
                    if (days >= 0 && days <= 365)
                    {
                        if (!statusTimings.ContainsKey(toCode))
                            statusTimings[toCode] = (toName ?? toCode, new List<double>());
                        statusTimings[toCode].Days.Add(days);
                    }
                }
            }

            var jobCycleTime = statusTimings
                .Where(kv => kv.Value.Days.Any())
                .Select(kv => new JobCycleTimeItem
                {
                    StatusCode   = kv.Key,
                    StatusName   = kv.Value.DisplayName,
                    DisplayOrder = Array.IndexOf(statusDisplayOrder, kv.Key),
                    AvgDays      = Math.Round(kv.Value.Days.Average(), 1),
                    MinDays      = Math.Round(kv.Value.Days.Min(), 1),
                    MaxDays      = Math.Round(kv.Value.Days.Max(), 1),
                    JobCount     = kv.Value.Days.Count
                })
                .OrderBy(x => x.DisplayOrder < 0 ? 99 : x.DisplayOrder)
                .ToList();

            var overallAvgCycleDays = completedJobs
                .Where(j => j.CompletedDate.HasValue)
                .Select(j => (j.CompletedDate!.Value - j.CreatedAt).TotalDays)
                .Where(d => d >= 0 && d <= 365)
                .DefaultIfEmpty(0)
                .Average();

            // === JOB STATUS AGING ===
            var jobStatusAging = activeJobs
                .Select(j => new JobStatusAgingItem
                {
                    JobId               = j.Id,
                    JobNumber           = j.JobNumber,
                    CustomerName        = j.Customer?.IsCommercial == true
                                          ? j.Customer.CompanyName ?? "Unknown"
                                          : $"{j.Customer?.ContactFirstName} {j.Customer?.ContactLastName}".Trim(),
                    StatusName          = j.JobStatus.DisplayName,
                    StatusCode          = j.JobStatus.StatusCode,
                    PriorityName        = j.JobPriority?.DisplayName ?? "Normal",
                    PriorityCode        = j.JobPriority?.PriorityCode ?? "NORMAL",
                    DaysInCurrentStatus = (int)(now - (j.UpdatedAt ?? j.CreatedAt)).TotalDays,
                    DueDate             = j.DueDate,
                    IsOverdue           = j.DueDate.HasValue && j.DueDate.Value < today
                })
                .OrderByDescending(x => x.DaysInCurrentStatus)
                .ToList();

            // === INVOICE AGING DETAIL ===
            var invoiceAgingDetail = allInvoices
                .Where(i => i.Customer != null
                         && i.Status != InvoiceStatus.Voided
                         && i.Status != InvoiceStatus.WrittenOff
                         && i.Status != InvoiceStatus.Paid)
                .Select(i =>
                {
                    var daysOverdue = i.DueDate.HasValue ? (int)(today - i.DueDate.Value.Date).TotalDays : 0;
                    return new InvoiceAgingDetailItem
                    {
                        InvoiceId     = i.Id,
                        InvoiceNumber = i.InvoiceNumber,
                        CustomerName  = i.Customer!.IsCommercial
                                        ? i.Customer.CompanyName ?? "Unknown"
                                        : $"{i.Customer.ContactFirstName} {i.Customer.ContactLastName}".Trim(),
                        CustomerEmail = i.Customer.Email,
                        CustomerPhone = i.Customer.Phone ?? i.Customer.MobilePhone,
                        InvoiceDate   = i.InvoiceDate,
                        DueDate       = i.DueDate,
                        Total         = i.Total,
                        AmountPaid    = i.AmountPaid,
                        BalanceDue    = i.BalanceDue,
                        DaysOverdue   = Math.Max(0, daysOverdue),
                        AgingBucket   = daysOverdue <= 0  ? "Current"
                                      : daysOverdue <= 30 ? "1–30 Days"
                                      : daysOverdue <= 60 ? "31–60 Days"
                                      : daysOverdue <= 90 ? "61–90 Days"
                                      : "90+ Days",
                        StatusDisplay = i.Status.ToString()
                    };
                })
                .OrderByDescending(i => i.DaysOverdue)
                .ToList();

            // === POWDER CONSUMPTION VS PURCHASE ===
            var allInventoryTransactions = (await _unitOfWork.InventoryTransactions
                .FindAsync(t => t.CompanyId == companyId, false, t => t.InventoryItem))
                .ToList();

            var powderConsumptionItems = allInventoryTransactions
                .Where(t => t.InventoryItem != null)
                .GroupBy(t => new
                {
                    t.InventoryItemId,
                    t.InventoryItem!.Name,
                    t.InventoryItem.SKU,
                    t.InventoryItem.ColorName,
                    t.InventoryItem.ColorCode,
                    t.InventoryItem.Manufacturer
                })
                .Select(g => new PowderConsumptionItem
                {
                    InventoryItemId   = g.Key.InventoryItemId,
                    ItemName          = g.Key.Name,
                    SKU               = g.Key.SKU,
                    ColorName         = g.Key.ColorName,
                    ColorCode         = g.Key.ColorCode,
                    Manufacturer      = g.Key.Manufacturer,
                    TotalPurchasedLbs = g.Where(t => t.TransactionType == InventoryTransactionType.Purchase
                                                  || t.TransactionType == InventoryTransactionType.Initial)
                                         .Sum(t => t.Quantity),
                    TotalConsumedLbs  = g.Where(t => t.TransactionType == InventoryTransactionType.JobUsage
                                                  || t.TransactionType == InventoryTransactionType.Waste)
                                         .Sum(t => Math.Abs(t.Quantity)),
                    PurchaseCount     = g.Count(t => t.TransactionType == InventoryTransactionType.Purchase),
                    UsageJobCount     = g.Where(t => t.TransactionType == InventoryTransactionType.JobUsage
                                                  && !string.IsNullOrEmpty(t.Reference))
                                         .Select(t => t.Reference).Distinct().Count()
                })
                .Where(x => x.TotalPurchasedLbs > 0 || x.TotalConsumedLbs > 0)
                .OrderByDescending(x => x.TotalConsumedLbs)
                .ToList();

            // === INVENTORY TURNOVER ===
            var daysInPeriod = months * 30.0;
            var inventoryTurnover = inventory
                .Where(i => i.IsActive)
                .Select(i =>
                {
                    var iTx      = allInventoryTransactions.Where(t => t.InventoryItemId == i.Id).ToList();
                    var consumed = (double)iTx.Where(t => t.TransactionType == InventoryTransactionType.JobUsage
                                                       || t.TransactionType == InventoryTransactionType.Waste)
                                              .Sum(t => Math.Abs(t.Quantity));
                    var purchased = (double)iTx.Where(t => t.TransactionType == InventoryTransactionType.Purchase)
                                               .Sum(t => t.Quantity);
                    var dailyUse = daysInPeriod > 0 ? consumed / daysInPeriod : 0;
                    var dsOut    = dailyUse > 0 ? (double)i.QuantityOnHand / dailyUse : 9999;
                    return new InventoryTurnoverItem
                    {
                        InventoryItemId     = i.Id,
                        ItemName            = i.Name,
                        SKU                 = i.SKU,
                        ColorName           = i.ColorName,
                        CurrentStockLbs     = i.QuantityOnHand,
                        TotalConsumedLbs    = (decimal)consumed,
                        TotalPurchasedLbs   = (decimal)purchased,
                        DailyConsumptionLbs = Math.Round((decimal)dailyUse, 3),
                        DaysToStockout      = dsOut >= 9999 ? 9999 : Math.Round(dsOut, 0),
                        TurnoverRate        = consumed > 0 && (double)i.QuantityOnHand > 0
                                              ? Math.Round(consumed / (double)i.QuantityOnHand, 2) : 0,
                        StockStatus         = dsOut <= 7    ? "Critical"
                                            : dsOut <= 30   ? "Low"
                                            : dsOut <= 90   ? "Normal"
                                            : dsOut < 9999  ? "Overstocked"
                                            : "No Usage"
                    };
                })
                .Where(x => x.TotalConsumedLbs > 0 || x.CurrentStockLbs > 0)
                .OrderBy(x => x.DaysToStockout)
                .ToList();

            var vm = new AnalyticsDashboardViewModel
            {
                // Overview KPIs
                TotalRevenue = totalRevenue,
                ActiveJobsCount = activeJobs.Count,
                ActiveCustomersCount = customers.Count(c => c.IsActive),
                QuoteWinRate = winRate,
                TotalQuotes = quotes.Count,
                CompletedJobsThisMonth = completedThisMonth,
                AverageJobValue = avgJobValue,
                AppointmentsThisMonth = appointmentsThisMonth,
                AverageJobDuration = avgJobDuration,
                MonthOverMonthGrowth = momGrowth,

                // Revenue
                MonthLabels = monthLabels,
                MonthlyRevenue = monthlyRevenue,
                MonthlyJobCount = monthlyJobCount,
                RevenueByCustomerType = revenueByCustomerType,
                RevenueByPriority = revenueByPriority,
                AverageOrderValueTrend = avgOrderValueTrend,
                TopCustomers = topCustomers,

                // Operations
                JobsByStatus = jobsByStatus,
                ActiveJobsByPriority = jobsByPriority,
                AppointmentsByType = appointmentsByType,
                AppointmentsByStatus = appointmentsByStatus,
                AppointmentCompletionRate = appointmentCompletionRate,
                AppointmentsByDayOfWeek = appointmentsByDayOfWeek,
                WorkerStats = workerStats,
                EquipmentByStatus = equipmentByStatus,
                LowStockItems = lowStock,

                // Customers
                NewCustomersPerMonth = newCustomersPerMonth,
                CustomerRetentionRate = customerRetentionRate,
                CustomerLifetimeValue = customerLifetimeValue,
                QuotesByStatus = quotesByStatus,
                QuoteFunnel = quoteFunnel,
                CatalogByCategory = catalogByCategory,

                // Financial
                TotalInvoiced = totalInvoiced,
                TotalCollected = totalCollected,
                TotalOutstanding = totalOutstanding,
                TotalOverdue = totalOverdue,
                InvoicesOverdueCount = invoicesOverdueCount,
                InvoicesDraftCount = invoicesDraftCount,
                InvoicesPaidCount = invoicesPaidCount,
                MonthlyInvoiced = monthlyInvoiced,
                MonthlyCollected = monthlyCollected,
                AgingBuckets = agingBuckets,
                RecentPayments = recentPayments,
                TopOutstandingCustomers = topOutstandingCustomers,
                AvgDaysToPayment = avgDaysToPayment,

                // Expense / AP
                TotalBilled = totalBilled,
                TotalBillsPaid = totalBillsPaid,
                TotalApOutstanding = totalApOutstanding,
                TotalDirectExpenses = totalDirectExpenses,
                ApAgingBuckets = apAgingBuckets,
                VendorSpend = vendorSpend,
                ExpensesByAccount = expensesByAccount,
                MonthlyBillsPaid = monthlyBillsPaid,
                MonthlyDirectExpenses = monthlyDirectExpenses,
                PlRevenue = plRevenue,
                PlExpenses = plExpenses,
                PlNetIncome = plNetIncome,

                // Powder Usage
                TotalPowderUsedLbs = totalPowderUsedLbs,
                TotalPowderCost = totalPowderCost,
                TotalJobsWithPowderUsage = totalJobsWithPowderUsage,
                ColorsUsedCount = topColorsByUsage.Count,
                TopColorsByUsage = topColorsByUsage,
                MonthlyPowderUsageLbs = monthlyPowderUsageLbs,
                MonthlyPowderUsageCost = monthlyPowderUsageCost,

                // Sales by Customer
                SalesByCustomer              = salesByCustomer,
                SalesByCustomerTotalInvoiced = salesByCustomer.Sum(x => x.TotalInvoiced),
                SalesByCustomerTotalPaid     = salesByCustomer.Sum(x => x.TotalPaid),

                // Customer Retention
                CustomerRetention = customerRetention,

                // Job Cycle Time
                JobCycleTime       = jobCycleTime,
                OverallAvgCycleDays = overallAvgCycleDays,

                // Job Status Aging
                JobStatusAging = jobStatusAging,

                // Invoice Aging Detail
                InvoiceAgingDetail = invoiceAgingDetail,

                // Powder Consumption vs Purchase
                PowderConsumption = powderConsumptionItems,

                // Inventory Turnover
                InventoryTurnover = inventoryTurnover,

                // Filters
                SelectedMonths = months
            };

            return View("Index", vm);
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Error loading analytics");
            TempData["Error"] = "An error occurred while loading analytics.";
            return View("Index", new AnalyticsDashboardViewModel());
        }
    }

    /// <summary>Converts PascalCase enum names to spaced display strings (e.g. "NeedsMaintenance" → "Needs Maintenance").</summary>
    private static string FormatEnum(string value) =>
        System.Text.RegularExpressions.Regex.Replace(value, "([A-Z])", " $1").Trim();

    // ── Financial Reports ─────────────────────────────────────────────────────

    /// <summary>
    /// Profit &amp; Loss report for a date range. Revenue is summed from InvoiceItems posted to
    /// revenue-type accounts (excludes Draft and Voided invoices). Expenses come from two sources:
    /// direct Expenses (one-off spend) and BillLineItems (vendor purchase order costs) — both are
    /// merged by account before building the report lines so each account appears once.
    /// Items without a revenue account fall into an "Other Sales (unclassified)" bucket rather than
    /// being silently dropped, so the total always reconciles to total invoice revenue.
    /// Gated behind <see cref="AllowAccounting"/>; non-accounting plans redirect to Landing.
    /// </summary>
    // GET: /Reports/ProfitAndLoss
    public async Task<IActionResult> ProfitAndLoss(DateTime? from, DateTime? to)
    {
        if (!AllowAccounting()) return RedirectToAction(nameof(Landing));
        var fromDate  = (from ?? new DateTime(DateTime.Today.Year, 1, 1)).Date;
        var toDate    = (to   ?? DateTime.Today).Date;
        var companyId = int.TryParse(User.FindFirst("CompanyId")?.Value, out var cid) ? cid : 0;
        var dto = await _financialReports.GetProfitAndLossAsync(companyId, fromDate, toDate);
        return View(dto);
    }

    /// <summary>
    /// Balance Sheet as of a point-in-time date. All transaction amounts are pre-computed per
    /// account via batch GROUP BY queries (not per-row loops) to avoid N+1 queries across potentially
    /// large transaction tables. The local <c>ComputeBalance</c> function applies standard
    /// double-entry rules: assets have a normal debit balance; liabilities and equity have a normal
    /// credit balance. AR and AP accounts are handled specially because their balances come from
    /// Invoice/Bill totals rather than generic deposit/expense entries. Retained earnings is computed
    /// as lifetime revenue − COGS − bills from inception through asOf — there is no explicit
    /// retained-earnings transaction table; it is always derived.
    /// Gated behind <see cref="AllowAccounting"/>.
    /// </summary>
    // GET: /Reports/BalanceSheet
    public async Task<IActionResult> BalanceSheet(DateTime? asOf)
    {
        if (!AllowAccounting()) return RedirectToAction(nameof(Landing));
        var asOfDate  = (asOf ?? DateTime.Today).Date;
        var companyId = int.TryParse(User.FindFirst("CompanyId")?.Value, out var cid) ? cid : 0;
        var dto = await _financialReports.GetBalanceSheetAsync(companyId, asOfDate);
        return View(dto);
    }

    /// <summary>
    /// AR Aging report as of a point-in-time date. Shows open invoice balances bucketed into
    /// Current / 1–30 / 31–60 / 61–90 / 90+ day ranges based on days past the invoice DueDate.
    /// Invoices without a DueDate are treated as current (0 days overdue) to avoid erroneous
    /// classification. Results are grouped by customer and sorted by total outstanding balance
    /// descending so the biggest exposures appear at the top.
    /// Gated behind <see cref="AllowAccounting"/>.
    /// </summary>
    // GET: /Reports/ArAging
    public async Task<IActionResult> ArAging(DateTime? asOf)
    {
        if (!AllowAccounting()) return RedirectToAction(nameof(Landing));
        var asOfDate  = (asOf ?? DateTime.Today).Date;
        var companyId = int.TryParse(User.FindFirst("CompanyId")?.Value, out var cid) ? cid : 0;
        var dto = await _financialReports.GetArAgingAsync(companyId, asOfDate);
        return View(dto);
    }

    /// <summary>
    /// Sales &amp; Income report for a date range. Distinguishes between invoiced amounts (invoice
    /// date within range) and collected amounts (payment date within range) — these can differ
    /// when a customer pays in a different month than the invoice was issued. The "collectedInPeriod"
    /// total uses payment dates so cash-basis bookkeeping is possible from this report even though
    /// the invoice list uses accrual-basis invoice dates.
    /// Not gated by <see cref="AllowAccounting"/> — all roles with CanViewReports can see sales totals.
    /// </summary>
    // GET: /Reports/SalesAndIncome
    public async Task<IActionResult> SalesAndIncome(DateTime? from, DateTime? to)
    {
        var fromDate  = (from ?? new DateTime(DateTime.Today.Year, 1, 1)).Date;
        var toDate    = (to   ?? DateTime.Today).Date;
        var companyId = int.TryParse(User.FindFirst("CompanyId")?.Value, out var cid) ? cid : 0;
        var dto = await _financialReports.GetSalesAndIncomeAsync(companyId, fromDate, toDate);
        return View(dto);
    }

    // ── PDF Download Actions ──────────────────────────────────────────────────

    /// <summary>
    /// PDF export of the Profit &amp; Loss report. Duplicates the data-loading logic from
    /// <see cref="ProfitAndLoss"/> because the two actions need identical data but different
    /// return types (View vs File). The <paramref name="inline"/> flag controls Content-Disposition:
    /// true = inline (browser renders in tab), false = attachment (browser prompts download).
    /// The inline=true path is used when embedding the PDF in an iframe within the view.
    /// Gated behind <see cref="AllowAccounting"/>.
    /// </summary>
    // GET: /Reports/ProfitAndLossPdf
    public async Task<IActionResult> ProfitAndLossPdf(DateTime? from, DateTime? to, bool inline = false)
    {
        if (!AllowAccounting()) return RedirectToAction(nameof(Landing));
        var fromDate  = (from ?? new DateTime(DateTime.Today.Year, 1, 1)).Date;
        var toDate    = (to   ?? DateTime.Today).Date;
        var companyId = int.TryParse(User.FindFirst("CompanyId")?.Value, out var cid) ? cid : 0;
        var dto = await _financialReports.GetProfitAndLossAsync(companyId, fromDate, toDate);
        var pdfBytes = await _pdfService.GenerateProfitAndLossPdfAsync(dto);
        return inline ? File(pdfBytes, "application/pdf") : File(pdfBytes, "application/pdf", $"ProfitAndLoss-{fromDate:yyyyMMdd}-{toDate:yyyyMMdd}.pdf");
    }

    /// <summary>
    /// PDF export of the Balance Sheet. Same inline/attachment pattern as
    /// <see cref="ProfitAndLossPdf"/>. Gated behind <see cref="AllowAccounting"/>.
    /// </summary>
    // GET: /Reports/BalanceSheetPdf
    public async Task<IActionResult> BalanceSheetPdf(DateTime? asOf, bool inline = false)
    {
        if (!AllowAccounting()) return RedirectToAction(nameof(Landing));
        var asOfDate  = (asOf ?? DateTime.Today).Date;
        var companyId = int.TryParse(User.FindFirst("CompanyId")?.Value, out var cid) ? cid : 0;
        var dto = await _financialReports.GetBalanceSheetAsync(companyId, asOfDate);
        var pdfBytes = await _pdfService.GenerateBalanceSheetPdfAsync(dto);
        return inline ? File(pdfBytes, "application/pdf") : File(pdfBytes, "application/pdf", $"BalanceSheet-{asOfDate:yyyyMMdd}.pdf");
    }

    /// <summary>
    /// PDF export of the AR Aging report. Same inline/attachment pattern as
    /// <see cref="ProfitAndLossPdf"/>. Gated behind <see cref="AllowAccounting"/>.
    /// </summary>
    // GET: /Reports/ArAgingPdf
    public async Task<IActionResult> ArAgingPdf(DateTime? asOf, bool inline = false)
    {
        if (!AllowAccounting()) return RedirectToAction(nameof(Landing));
        var asOfDate  = (asOf ?? DateTime.Today).Date;
        var companyId = int.TryParse(User.FindFirst("CompanyId")?.Value, out var cid) ? cid : 0;
        var dto = await _financialReports.GetArAgingAsync(companyId, asOfDate);
        var pdfBytes = await _pdfService.GenerateArAgingPdfAsync(dto);
        return inline ? File(pdfBytes, "application/pdf") : File(pdfBytes, "application/pdf", $"AR-Aging-{asOfDate:yyyyMMdd}.pdf");
    }

    /// <summary>
    /// PDF export of the Sales &amp; Income report. Not gated by accounting (mirrors
    /// <see cref="SalesAndIncome"/>). Same inline/attachment pattern as other PDF actions.
    /// </summary>
    // GET: /Reports/SalesAndIncomePdf
    public async Task<IActionResult> SalesAndIncomePdf(DateTime? from, DateTime? to, bool inline = false)
    {
        var fromDate  = (from ?? new DateTime(DateTime.Today.Year, 1, 1)).Date;
        var toDate    = (to   ?? DateTime.Today).Date;
        var companyId = int.TryParse(User.FindFirst("CompanyId")?.Value, out var cid) ? cid : 0;
        var dto = await _financialReports.GetSalesAndIncomeAsync(companyId, fromDate, toDate);
        var pdfBytes = await _pdfService.GenerateSalesAndIncomePdfAsync(dto);
        return inline ? File(pdfBytes, "application/pdf") : File(pdfBytes, "application/pdf", $"SalesAndIncome-{fromDate:yyyyMMdd}-{toDate:yyyyMMdd}.pdf");
    }

    /// <summary>
    /// Sales Tax Liability report (invoice basis). Shows taxable vs non-taxable sales,
    /// total tax billed, breakdown by tax account and by month, and a full invoice detail grid.
    /// Gated behind <see cref="AllowAccounting"/>.
    /// </summary>
    // GET: /Reports/SalesTax
    public async Task<IActionResult> SalesTax(DateTime? from, DateTime? to)
    {
        if (!AllowAccounting()) return RedirectToAction(nameof(Landing));
        var fromDate  = (from ?? new DateTime(DateTime.Today.Year, 1, 1)).Date;
        var toDate    = (to   ?? DateTime.Today).Date;
        var companyId = int.TryParse(User.FindFirst("CompanyId")?.Value, out var cid) ? cid : 0;
        var dto = await _financialReports.GetSalesTaxReportAsync(companyId, fromDate, toDate);
        return View(dto);
    }

    /// <summary>
    /// PDF export of the Sales Tax Liability report. Same inline/attachment pattern as other PDF actions.
    /// Gated behind <see cref="AllowAccounting"/>.
    /// </summary>
    // GET: /Reports/SalesTaxPdf
    public async Task<IActionResult> SalesTaxPdf(DateTime? from, DateTime? to, bool inline = false)
    {
        if (!AllowAccounting()) return RedirectToAction(nameof(Landing));
        var fromDate  = (from ?? new DateTime(DateTime.Today.Year, 1, 1)).Date;
        var toDate    = (to   ?? DateTime.Today).Date;
        var companyId = int.TryParse(User.FindFirst("CompanyId")?.Value, out var cid) ? cid : 0;
        var dto = await _financialReports.GetSalesTaxReportAsync(companyId, fromDate, toDate);
        var pdfBytes = await _pdfService.GenerateSalesTaxReportPdfAsync(dto);
        return inline ? File(pdfBytes, "application/pdf") : File(pdfBytes, "application/pdf", $"SalesTaxReport-{fromDate:yyyyMMdd}-{toDate:yyyyMMdd}.pdf");
    }

    /// <summary>
    /// CSV export of the Sales Tax Liability report. Returns one row per invoice, suitable
    /// for handing to an accountant or importing into tax filing software.
    /// Gated behind <see cref="AllowAccounting"/>.
    /// </summary>
    // GET: /Reports/SalesTaxCsv
    public async Task<IActionResult> SalesTaxCsv(DateTime? from, DateTime? to)
    {
        if (!AllowAccounting()) return RedirectToAction(nameof(Landing));
        var fromDate  = (from ?? new DateTime(DateTime.Today.Year, 1, 1)).Date;
        var toDate    = (to   ?? DateTime.Today).Date;
        var companyId = int.TryParse(User.FindFirst("CompanyId")?.Value, out var cid) ? cid : 0;
        var dto = await _financialReports.GetSalesTaxReportAsync(companyId, fromDate, toDate);

        var sb = new System.Text.StringBuilder();
        sb.AppendLine("Invoice #,Customer,Date,Status,Subtotal,Tax %,Tax Amount,Total,Amount Paid,Balance Due,Tax Account");
        foreach (var inv in dto.Invoices)
        {
            sb.AppendLine(string.Join(",",
                $"\"{inv.InvoiceNumber}\"",
                $"\"{inv.CustomerName.Replace("\"", "\"\"")}\"",
                inv.InvoiceDate.ToString("yyyy-MM-dd"),
                $"\"{inv.Status}\"",
                inv.SubTotal.ToString("F2"),
                inv.TaxPercent.ToString("F4"),
                inv.TaxAmount.ToString("F2"),
                inv.Total.ToString("F2"),
                inv.AmountPaid.ToString("F2"),
                inv.BalanceDue.ToString("F2"),
                $"\"{inv.TaxAccountName.Replace("\"", "\"\"")}\""));
        }

        var bytes = System.Text.Encoding.UTF8.GetPreamble().Concat(System.Text.Encoding.UTF8.GetBytes(sb.ToString())).ToArray();
        return File(bytes, "text/csv", $"SalesTaxReport-{fromDate:yyyyMMdd}-{toDate:yyyyMMdd}.csv");
    }

    /// <summary>
    /// Accounts Payable Aging report — mirrors the AR Aging but groups open bills by vendor
    /// and buckets them by days past due date. Gated behind <see cref="AllowAccounting"/>.
    /// </summary>
    // GET: /Reports/ApAging
    public async Task<IActionResult> ApAging(DateTime? asOf)
    {
        if (!AllowAccounting()) return RedirectToAction(nameof(Landing));
        var asOfDate  = (asOf ?? DateTime.Today).Date;
        var companyId = int.TryParse(User.FindFirst("CompanyId")?.Value, out var cid) ? cid : 0;
        var dto = await _financialReports.GetApAgingAsync(companyId, asOfDate);
        return View(dto);
    }

    /// <summary>
    /// PDF export of the AP Aging report. Same inline/attachment pattern as other PDF actions.
    /// Gated behind <see cref="AllowAccounting"/>.
    /// </summary>
    // GET: /Reports/ApAgingPdf
    public async Task<IActionResult> ApAgingPdf(DateTime? asOf, bool inline = false)
    {
        if (!AllowAccounting()) return RedirectToAction(nameof(Landing));
        var asOfDate  = (asOf ?? DateTime.Today).Date;
        var companyId = int.TryParse(User.FindFirst("CompanyId")?.Value, out var cid) ? cid : 0;
        var dto = await _financialReports.GetApAgingAsync(companyId, asOfDate);
        var pdfBytes = await _pdfService.GenerateApAgingPdfAsync(dto);
        return inline
            ? File(pdfBytes, "application/pdf")
            : File(pdfBytes, "application/pdf", $"AP-Aging-{asOfDate:yyyyMMdd}.pdf");
    }

    /// <summary>
    /// Trial Balance report — lists all active accounts with debit and credit balances using
    /// <c>Account.CurrentBalance</c> (live, not point-in-time). Validates that debits equal
    /// credits. Gated behind <see cref="AllowAccounting"/>.
    /// </summary>
    // GET: /Reports/TrialBalance
    public async Task<IActionResult> TrialBalance(DateTime? asOf)
    {
        if (!AllowAccounting()) return RedirectToAction(nameof(Landing));
        var asOfDate  = (asOf ?? DateTime.Today).Date;
        var companyId = int.TryParse(User.FindFirst("CompanyId")?.Value, out var cid) ? cid : 0;
        var dto = await _financialReports.GetTrialBalanceAsync(companyId, asOfDate);
        return View(dto);
    }

    /// <summary>
    /// Balance reconciliation diagnostic: each account's stored CurrentBalance vs its recomputed ledger
    /// balance, plus AR/AP subledger totals vs their control accounts. Read-only; surfaces drift in the
    /// denormalized balances without changing any posting. Gated behind <see cref="AllowAccounting"/>.
    /// </summary>
    // GET: /Reports/Reconciliation
    public async Task<IActionResult> Reconciliation()
    {
        if (!AllowAccounting()) return RedirectToAction(nameof(Landing));
        var companyId = int.TryParse(User.FindFirst("CompanyId")?.Value, out var cid) ? cid : 0;
        var dto = await _financialReports.GetBalanceReconciliationAsync(companyId);
        return View(dto);
    }

    /// <summary>
    /// PDF export of the Trial Balance report. Same inline/attachment pattern as other PDF actions.
    /// Gated behind <see cref="AllowAccounting"/>.
    /// </summary>
    // GET: /Reports/TrialBalancePdf
    public async Task<IActionResult> TrialBalancePdf(DateTime? asOf, bool inline = false)
    {
        if (!AllowAccounting()) return RedirectToAction(nameof(Landing));
        var asOfDate  = (asOf ?? DateTime.Today).Date;
        var companyId = int.TryParse(User.FindFirst("CompanyId")?.Value, out var cid) ? cid : 0;
        var dto = await _financialReports.GetTrialBalanceAsync(companyId, asOfDate);
        var pdfBytes = await _pdfService.GenerateTrialBalancePdfAsync(dto);
        return inline
            ? File(pdfBytes, "application/pdf")
            : File(pdfBytes, "application/pdf", $"TrialBalance-{asOfDate:yyyyMMdd}.pdf");
    }

    /// <summary>
    /// Cash Flow Statement — shows cash receipts from customers, cash payments to vendors and
    /// for direct expenses, and a summary of beginning/ending cash position. Uses the direct
    /// (cash-basis) method for operating activities so the numbers reflect actual cash movement
    /// regardless of the company's accrual vs cash accounting preference.
    /// Gated behind <see cref="AllowAccounting"/>.
    /// </summary>
    // GET: /Reports/CashFlowStatement
    public async Task<IActionResult> CashFlowStatement(DateTime? from, DateTime? to)
    {
        if (!AllowAccounting()) return RedirectToAction(nameof(Landing));
        var fromDate  = (from ?? new DateTime(DateTime.Today.Year, 1, 1)).Date;
        var toDate    = (to ?? DateTime.Today).Date;
        var companyId = int.TryParse(User.FindFirst("CompanyId")?.Value, out var cid) ? cid : 0;
        var dto = await _financialReports.GetCashFlowStatementAsync(companyId, fromDate, toDate);
        return View(dto);
    }

    /// <summary>
    /// PDF export of the Cash Flow Statement. Same inline/attachment pattern as other PDF actions.
    /// Gated behind <see cref="AllowAccounting"/>.
    /// </summary>
    // GET: /Reports/CashFlowStatementPdf
    public async Task<IActionResult> CashFlowStatementPdf(DateTime? from, DateTime? to, bool inline = false)
    {
        if (!AllowAccounting()) return RedirectToAction(nameof(Landing));
        var fromDate  = (from ?? new DateTime(DateTime.Today.Year, 1, 1)).Date;
        var toDate    = (to ?? DateTime.Today).Date;
        var companyId = int.TryParse(User.FindFirst("CompanyId")?.Value, out var cid) ? cid : 0;
        var dto       = await _financialReports.GetCashFlowStatementAsync(companyId, fromDate, toDate);
        var pdfBytes  = await _pdfService.GenerateCashFlowStatementPdfAsync(dto);
        return inline
            ? File(pdfBytes, "application/pdf")
            : File(pdfBytes, "application/pdf", $"CashFlow-{fromDate:yyyyMMdd}-{toDate:yyyyMMdd}.pdf");
    }

    // ── INDIVIDUAL REPORT PAGES ──────────────────────────────────────────────

    /// <summary>
    /// KPI Dashboard — a focused summary of the most important business metrics: revenue, job
    /// volume, quote win rate, customer retention, average job value, and equipment health.
    /// Uses string-based status code comparisons ("COMPLETED", "PENDING", etc.) rather than enum
    /// values because JobStatus and JobPriority are lookup-table entities, not enums.
    /// </summary>
    public async Task<IActionResult> KpiDashboard(int months = 6)
    {
        var now = DateTime.UtcNow;
        var today = DateTime.Today;
        var startOfMonth = new DateTime(now.Year, now.Month, 1);
        var startOfLastMonth = startOfMonth.AddMonths(-1);
        var startDate = now.AddMonths(-months);
        var completedStatusCodes = new[] { "COMPLETED", "READY_FOR_PICKUP", "DELIVERED" };
        var activeStatusCodes = new[] { "PENDING", "QUOTED", "APPROVED", "IN_PREPARATION", "SANDBLASTING",
            "MASKING_TAPING", "CLEANING", "IN_OVEN", "COATING", "CURING", "QUALITY_CHECK", "ON_HOLD" };
        var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;

        var jobs = (await _unitOfWork.Jobs.FindAsync(j => j.CompanyId == companyId, false, j => j.Customer, j => j.JobStatus, j => j.JobPriority)).ToList();
        var customers = (await _unitOfWork.Customers.FindAsync(c => c.CompanyId == companyId)).ToList();
        var quotes = (await _unitOfWork.Quotes.FindAsync(q => q.CompanyId == companyId, false, q => q.QuoteStatus)).ToList();
        var equipment = (await _unitOfWork.Equipment.FindAsync(e => e.CompanyId == companyId)).ToList();
        var inventory = (await _unitOfWork.InventoryItems.FindAsync(i => i.CompanyId == companyId)).ToList();
        var allAppointments = await _unitOfWork.Appointments.FindAsync(a => a.CompanyId == companyId && a.ScheduledStartTime >= startDate, false, a => a.AppointmentStatus);
        var appointments = allAppointments.ToList();

        var completedJobs = jobs.Where(j => completedStatusCodes.Contains(j.JobStatus.StatusCode)).ToList();
        var activeJobs = jobs.Where(j => activeStatusCodes.Contains(j.JobStatus.StatusCode)).ToList();
        var totalRevenue = completedJobs.Sum(j => j.FinalPrice);
        var completedThisMonth = jobs.Count(j => completedStatusCodes.Contains(j.JobStatus.StatusCode) && j.UpdatedAt >= startOfMonth);
        var approvedQuotes = quotes.Where(q => q.QuoteStatus.StatusCode == "APPROVED" || q.QuoteStatus.StatusCode == "CONVERTED").ToList();
        var winRate = quotes.Any() ? Math.Round((decimal)approvedQuotes.Count / quotes.Count * 100, 1) : 0m;
        var avgJobValue = completedJobs.Any() ? totalRevenue / completedJobs.Count : 0m;
        var avgJobDuration = completedJobs.Where(j => j.CompletedDate.HasValue)
            .Select(j => (j.CompletedDate!.Value - j.CreatedAt).TotalDays).DefaultIfEmpty(0).Average();
        var revenueThisMonth = completedJobs.Where(j => j.UpdatedAt >= startOfMonth).Sum(j => j.FinalPrice);
        var revenueLastMonth = completedJobs.Where(j => j.UpdatedAt >= startOfLastMonth && j.UpdatedAt < startOfMonth).Sum(j => j.FinalPrice);
        var momGrowth = revenueLastMonth > 0 ? Math.Round((revenueThisMonth - revenueLastMonth) / revenueLastMonth * 100, 1) : 0m;
        var completedAppointments = appointments.Count(a => a.AppointmentStatus?.StatusCode == "COMPLETED");
        var appointmentCompletionRate = appointments.Any() ? Math.Round((decimal)completedAppointments / appointments.Count * 100, 1) : 0m;
        var customersWithMultipleJobs = completedJobs.Where(j => j.Customer != null).GroupBy(j => j.Customer!.Id).Count(g => g.Count() > 1);
        var totalCustomersWithJobs = completedJobs.Where(j => j.Customer != null).Select(j => j.Customer!.Id).Distinct().Count();
        var customerRetentionRate = totalCustomersWithJobs > 0 ? Math.Round((decimal)customersWithMultipleJobs / totalCustomersWithJobs * 100, 1) : 0m;
        var lowStockCount = inventory.Count(i => i.IsActive && i.QuantityOnHand <= i.ReorderPoint);

        var jobsByMonth = completedJobs.Where(j => j.UpdatedAt >= startDate)
            .GroupBy(j => new DateTime(j.UpdatedAt!.Value.Year, j.UpdatedAt.Value.Month, 1))
            .ToDictionary(g => g.Key, g => g.ToList());
        var monthLabels = new List<string>();
        var monthlyRevenue = new List<decimal>();
        for (var i = months - 1; i >= 0; i--)
        {
            var month = now.AddMonths(-i);
            var ms = new DateTime(month.Year, month.Month, 1);
            monthLabels.Add(month.ToString("MMM yyyy"));
            monthlyRevenue.Add(jobsByMonth.TryGetValue(ms, out var mj) ? mj.Sum(j => j.FinalPrice) : 0m);
        }
        var topCustomers = completedJobs.Where(j => j.Customer != null)
            .GroupBy(j => j.Customer!.Id)
            .Select(g => new TopCustomerItem
            {
                Id       = g.Key,
                Name     = g.First().Customer!.CompanyName
                           ?? $"{g.First().Customer!.ContactFirstName} {g.First().Customer!.ContactLastName}".Trim(),
                Revenue  = g.Sum(j => j.FinalPrice),
                JobCount = g.Count()
            })
            .OrderByDescending(x => x.Revenue).Take(5).ToList();
        var jobsByStatus = jobs.GroupBy(j => j.JobStatus.DisplayName)
            .OrderBy(g => jobs.First(j => j.JobStatus.DisplayName == g.Key).JobStatus.DisplayOrder)
            .ToDictionary(g => g.Key, g => g.Count());
        var equipmentByStatus = equipment.GroupBy(e => e.Status)
            .ToDictionary(g => FormatEnum(g.Key.ToString()), g => g.Count());

        var vm = new KpiDashboardViewModel
        {
            ReportTitle = "KPI Dashboard", ReportDescription = "Key performance indicators across all business areas",
            SelectedMonths = months,
            TotalRevenue = totalRevenue, ActiveJobsCount = activeJobs.Count, ActiveCustomersCount = customers.Count(c => c.IsActive),
            QuoteWinRate = winRate, TotalQuotes = quotes.Count, CompletedJobsThisMonth = completedThisMonth,
            AverageJobValue = avgJobValue, AppointmentsThisMonth = appointments.Count(a => a.ScheduledStartTime >= startOfMonth),
            AverageJobDuration = avgJobDuration, MonthOverMonthGrowth = momGrowth, CustomerRetentionRate = customerRetentionRate,
            AppointmentCompletionRate = appointmentCompletionRate, LowStockCount = lowStockCount,
            MonthLabels = monthLabels, MonthlyRevenue = monthlyRevenue, JobsByStatus = jobsByStatus,
            TopCustomers = topCustomers, EquipmentByStatus = equipmentByStatus
        };
        return View(vm);
    }

    /// <summary>
    /// Revenue Trends report — monthly revenue/job-count trend, top 10 customers by revenue,
    /// and revenue broken down by customer type and job priority. All data is in-memory after a
    /// single jobs query; month labels and series values are built in a single forward-iteration loop.
    /// </summary>
    public async Task<IActionResult> RevenueTrends(int months = 6)
    {
        var now = DateTime.UtcNow;
        var startDate = now.AddMonths(-months);
        var completedStatusCodes = new[] { "COMPLETED", "READY_FOR_PICKUP", "DELIVERED" };
        var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
        var jobs = (await _unitOfWork.Jobs.FindAsync(j => j.CompanyId == companyId, false, j => j.Customer, j => j.JobStatus, j => j.JobPriority)).ToList();
        var completedJobs = jobs.Where(j => completedStatusCodes.Contains(j.JobStatus.StatusCode)).ToList();
        var inRange = completedJobs.Where(j => j.UpdatedAt >= startDate).ToList();
        var byMonth = inRange.GroupBy(j => new DateTime(j.UpdatedAt!.Value.Year, j.UpdatedAt.Value.Month, 1)).ToDictionary(g => g.Key, g => g.ToList());
        var monthLabels = new List<string>(); var monthlyRevenue = new List<decimal>(); var monthlyJobCount = new List<int>(); var avgOv = new List<decimal>();
        for (var i = months - 1; i >= 0; i--)
        {
            var m = now.AddMonths(-i); var ms = new DateTime(m.Year, m.Month, 1);
            monthLabels.Add(m.ToString("MMM yyyy"));
            if (byMonth.TryGetValue(ms, out var mj)) { monthlyRevenue.Add(mj.Sum(j => j.FinalPrice)); monthlyJobCount.Add(mj.Count); avgOv.Add(mj.Any() ? mj.Average(j => j.FinalPrice) : 0m); }
            else { monthlyRevenue.Add(0); monthlyJobCount.Add(0); avgOv.Add(0); }
        }
        var revenueByCustomerType = completedJobs.Where(j => j.Customer != null)
            .GroupBy(j => j.Customer!.IsCommercial ? "Commercial" : "Individual")
            .ToDictionary(g => g.Key, g => g.Sum(j => j.FinalPrice));
        var revenueByPriority = completedJobs
            .GroupBy(j => j.JobPriority.DisplayName)
            .OrderBy(g => completedJobs.First(j => j.JobPriority.DisplayName == g.Key).JobPriority.DisplayOrder)
            .ToDictionary(g => g.Key, g => g.Sum(j => j.FinalPrice));
        var topCustomers = completedJobs.Where(j => j.Customer != null)
            .GroupBy(j => j.Customer!.Id)
            .Select(g => new TopCustomerItem
            {
                Id       = g.Key,
                Name     = g.First().Customer!.CompanyName
                           ?? $"{g.First().Customer!.ContactFirstName} {g.First().Customer!.ContactLastName}".Trim(),
                Revenue  = g.Sum(j => j.FinalPrice),
                JobCount = g.Count()
            })
            .OrderByDescending(x => x.Revenue).Take(10).ToList();
        return View(new RevenueTrendsViewModel
        {
            ReportTitle = "Revenue Trends", ReportDescription = "Monthly revenue, job count, and customer breakdown",
            SelectedMonths = months, MonthLabels = monthLabels, MonthlyRevenue = monthlyRevenue, MonthlyJobCount = monthlyJobCount,
            AverageOrderValueTrend = avgOv, RevenueByCustomerType = revenueByCustomerType, RevenueByPriority = revenueByPriority,
            TopCustomers = topCustomers, TotalRevenue = completedJobs.Sum(j => j.FinalPrice),
            TotalCompletedJobs = completedJobs.Count, AverageJobValue = completedJobs.Any() ? completedJobs.Average(j => j.FinalPrice) : 0m
        });
    }

    /// <summary>
    /// Operations Report — job-status distribution, appointment analytics, worker performance,
    /// equipment health, and low-stock alerts. Worker stats require loading ApplicationUser records
    /// separately (via UserManager) because assigned users are Identity users, not domain entities,
    /// so they can't be eagerly loaded via the generic repository.
    /// </summary>
    public async Task<IActionResult> OperationsReport(int months = 6)
    {
        var now = DateTime.UtcNow;
        var startDate = now.AddMonths(-months);
        var completedStatusCodes = new[] { "COMPLETED", "READY_FOR_PICKUP", "DELIVERED" };
        var activeStatusCodes = new[] { "PENDING", "QUOTED", "APPROVED", "IN_PREPARATION", "SANDBLASTING",
            "MASKING_TAPING", "CLEANING", "IN_OVEN", "COATING", "CURING", "QUALITY_CHECK", "ON_HOLD" };
        var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;

        var jobs = (await _unitOfWork.Jobs.FindAsync(j => j.CompanyId == companyId, false, j => j.JobStatus, j => j.JobPriority, j => j.AssignedUser)).ToList();
        var equipment = (await _unitOfWork.Equipment.FindAsync(e => e.CompanyId == companyId)).ToList();
        var inventory = (await _unitOfWork.InventoryItems.FindAsync(i => i.CompanyId == companyId)).ToList();
        var allAppts = await _unitOfWork.Appointments.FindAsync(a => a.CompanyId == companyId && a.ScheduledStartTime >= startDate, false, a => a.AppointmentType, a => a.AppointmentStatus);
        var appointments = allAppts.ToList();

        var activeJobs = jobs.Where(j => activeStatusCodes.Contains(j.JobStatus.StatusCode)).ToList();
        var completedJobs = jobs.Where(j => completedStatusCodes.Contains(j.JobStatus.StatusCode)).ToList();
        var jobsByStatus = jobs.GroupBy(j => j.JobStatus.DisplayName)
            .OrderBy(g => jobs.First(j => j.JobStatus.DisplayName == g.Key).JobStatus.DisplayOrder)
            .ToDictionary(g => g.Key, g => g.Count());
        var jobsByPriority = activeJobs.GroupBy(j => j.JobPriority.DisplayName)
            .OrderBy(g => activeJobs.First(j => j.JobPriority.DisplayName == g.Key).JobPriority.DisplayOrder)
            .ToDictionary(g => g.Key, g => g.Count());
        var appointmentsByType = appointments.Where(a => a.AppointmentType != null).GroupBy(a => a.AppointmentType!.DisplayName).ToDictionary(g => g.Key, g => g.Count());
        var appointmentsByStatus = appointments.Where(a => a.AppointmentStatus != null).GroupBy(a => a.AppointmentStatus!.DisplayName).ToDictionary(g => g.Key, g => g.Count());
        var completedAppts = appointments.Count(a => a.AppointmentStatus?.StatusCode == "COMPLETED");
        var appointmentCompletionRate = appointments.Any() ? Math.Round((decimal)completedAppts / appointments.Count * 100, 1) : 0m;
        var appointmentsByDay = appointments.GroupBy(a => a.ScheduledStartTime.DayOfWeek).OrderBy(g => (int)g.Key).ToDictionary(g => g.Key.ToString(), g => g.Count());
        var assignedUserIds = jobs.Where(j => j.AssignedUserId != null).Select(j => j.AssignedUserId!).Concat(appointments.Where(a => a.AssignedUserId != null).Select(a => a.AssignedUserId!)).Distinct().ToList();
        var assignedUsers = assignedUserIds.Any() ? await _userManager.Users.Where(u => assignedUserIds.Contains(u.Id)).ToListAsync() : new List<ApplicationUser>();
        var workerStats = assignedUsers.Select(u => new WorkerStatsItem
        {
            Name = u.FullName, Role = u.Position ?? u.CompanyRole ?? "User",
            JobsAssigned = jobs.Count(j => j.AssignedUserId == u.Id),
            JobsCompleted = completedJobs.Count(j => j.AssignedUserId == u.Id),
            AppointmentsAssigned = appointments.Count(a => a.AssignedUserId == u.Id)
        }).Where(w => w.JobsAssigned > 0 || w.AppointmentsAssigned > 0).OrderByDescending(w => w.JobsCompleted).Take(10).ToList();
        var equipmentByStatus = equipment.GroupBy(e => e.Status).ToDictionary(g => FormatEnum(g.Key.ToString()), g => g.Count());
        var lowStock = inventory.Where(i => i.IsActive && i.QuantityOnHand <= i.ReorderPoint).OrderBy(i => i.QuantityOnHand).Take(10)
            .Select(i => new LowStockItem { Id = i.Id, Name = i.Name, ColorName = i.ColorName, QuantityOnHand = i.QuantityOnHand, ReorderPoint = i.ReorderPoint, UnitOfMeasure = i.UnitOfMeasure }).ToList();

        return View(new OperationsReportViewModel
        {
            ReportTitle = "Operations Report", ReportDescription = "Job status, appointments, worker performance, and equipment health",
            SelectedMonths = months, ActiveJobsCount = activeJobs.Count, TotalAppointments = appointments.Count,
            JobsByStatus = jobsByStatus, ActiveJobsByPriority = jobsByPriority, AppointmentsByType = appointmentsByType,
            AppointmentsByStatus = appointmentsByStatus, AppointmentCompletionRate = appointmentCompletionRate,
            AppointmentsByDayOfWeek = appointmentsByDay, WorkerStats = workerStats, EquipmentByStatus = equipmentByStatus, LowStockItems = lowStock
        });
    }

    /// <summary>
    /// Customer Overview report — acquisition trend (new customers per month), retention rate,
    /// lifetime value top-10, and quote conversion funnel. Retention is defined as customers
    /// who have more than one completed job — a simple but actionable proxy for repeat business.
    /// </summary>
    public async Task<IActionResult> CustomerOverview(int months = 6)
    {
        var now = DateTime.UtcNow;
        var startDate = now.AddMonths(-months);
        var completedStatusCodes = new[] { "COMPLETED", "READY_FOR_PICKUP", "DELIVERED" };
        var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
        var customers = (await _unitOfWork.Customers.FindAsync(c => c.CompanyId == companyId)).ToList();
        var quotes = (await _unitOfWork.Quotes.FindAsync(q => q.CompanyId == companyId, false, q => q.QuoteStatus)).ToList();
        var catalogItems = (await _unitOfWork.CatalogItems.FindAsync(ci => ci.CompanyId == companyId, false, c => c.Category)).ToList();
        var completedJobs = (await _unitOfWork.Jobs.FindAsync(j => j.CompanyId == companyId, false, j => j.Customer, j => j.JobStatus, j => j.JobPriority))
            .Where(j => completedStatusCodes.Contains(j.JobStatus.StatusCode)).ToList();

        var customersByMonth = customers.Where(c => c.CreatedAt >= startDate).GroupBy(c => new DateTime(c.CreatedAt.Year, c.CreatedAt.Month, 1)).ToDictionary(g => g.Key, g => g.Count());
        var monthLabels = new List<string>(); var newCustomersPerMonth = new List<int>();
        for (var i = months - 1; i >= 0; i--) { var m = now.AddMonths(-i); var ms = new DateTime(m.Year, m.Month, 1); monthLabels.Add(m.ToString("MMM yyyy")); newCustomersPerMonth.Add(customersByMonth.TryGetValue(ms, out var c) ? c : 0); }
        var customersWithMultiple = completedJobs.Where(j => j.Customer != null).GroupBy(j => j.Customer!.Id).Count(g => g.Count() > 1);
        var totalWithJobs = completedJobs.Where(j => j.Customer != null).Select(j => j.Customer!.Id).Distinct().Count();
        var retentionRate = totalWithJobs > 0 ? Math.Round((decimal)customersWithMultiple / totalWithJobs * 100, 1) : 0m;
        var clv = completedJobs.Where(j => j.Customer != null).GroupBy(j => j.Customer!.Id)
            .Select(g => new CustomerLifetimeValueItem
            {
                CustomerName  = g.First().Customer!.CompanyName
                                ?? $"{g.First().Customer!.ContactFirstName} {g.First().Customer!.ContactLastName}".Trim(),
                TotalRevenue  = g.Sum(j => j.FinalPrice),
                JobCount      = g.Count(),
                AvgOrderValue = g.Average(j => j.FinalPrice),
                FirstJobDate  = g.Min(j => j.CreatedAt),
                LastJobDate   = g.Max(j => j.CompletedDate ?? j.UpdatedAt ?? j.CreatedAt)
            })
            .OrderByDescending(c => c.TotalRevenue).Take(10).ToList();
        var quotesByStatus = quotes.GroupBy(q => q.QuoteStatus.DisplayName).OrderBy(g => quotes.First(q => q.QuoteStatus.DisplayName == g.Key).QuoteStatus.DisplayOrder).ToDictionary(g => g.Key, g => g.Count());
        var quoteFunnel = new QuoteConversionFunnel { Draft = quotes.Count(q => q.QuoteStatus.StatusCode == "DRAFT"), Sent = quotes.Count(q => q.QuoteStatus.StatusCode == "SENT"), Approved = quotes.Count(q => q.QuoteStatus.StatusCode == "APPROVED"), Converted = quotes.Count(q => q.QuoteStatus.StatusCode == "CONVERTED"), Rejected = quotes.Count(q => q.QuoteStatus.StatusCode == "REJECTED"), Expired = quotes.Count(q => q.QuoteStatus.StatusCode == "EXPIRED") };
        var catalogByCategory = catalogItems.Where(c => c.Category != null).GroupBy(c => c.Category!.Name).OrderByDescending(g => g.Count()).Take(8).ToDictionary(g => g.Key, g => g.Count());

        return View(new CustomerOverviewViewModel
        {
            ReportTitle = "Customer Overview", ReportDescription = "Acquisition trends, lifetime value, and quote conversion",
            SelectedMonths = months, MonthLabels = monthLabels, NewCustomersPerMonth = newCustomersPerMonth,
            CustomerRetentionRate = retentionRate, ActiveCustomersCount = customers.Count(c => c.IsActive),
            CustomerLifetimeValue = clv, QuotesByStatus = quotesByStatus, QuoteFunnel = quoteFunnel, CatalogByCategory = catalogByCategory
        });
    }

    /// <summary>
    /// Financial Summary report — AR overview with aging buckets, monthly invoiced-vs-collected
    /// chart, top 5 outstanding customers, recent payments, and average days-to-payment metric.
    /// AvgDaysToPayment is calculated only on invoices with both SentDate and PaidDate set, so it
    /// reflects actual turnaround time from sending to collection rather than creation-to-payment.
    /// Gated behind <see cref="AllowAccounting"/>.
    /// </summary>
    public async Task<IActionResult> FinancialSummary(int months = 6)
    {
        if (!AllowAccounting()) return RedirectToAction(nameof(Landing));
        var now = DateTime.UtcNow;
        var today = DateTime.Today;
        var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
        var allInvoices = (await _unitOfWork.Invoices.FindAsync(i => i.CompanyId == companyId, false, i => i.Customer, i => i.Payments)).ToList();
        var activeInvoices = allInvoices.Where(i => i.Status != InvoiceStatus.Voided && i.Status != InvoiceStatus.WrittenOff).ToList();
        var outstandingInvoices = activeInvoices.Where(i => i.Status != InvoiceStatus.Paid && i.Total > i.AmountPaid).ToList();
        var overdueInvoices = activeInvoices.Where(i => i.Status != InvoiceStatus.Paid && i.DueDate.HasValue && i.DueDate.Value < today).ToList();

        var invoicesByMonth = activeInvoices.GroupBy(i => new DateTime(i.InvoiceDate.Year, i.InvoiceDate.Month, 1)).ToDictionary(g => g.Key, g => g.ToList());
        var paymentsByMonth = activeInvoices.SelectMany(i => i.Payments.Where(p => !p.IsDeleted)).GroupBy(p => new DateTime(p.PaymentDate.Year, p.PaymentDate.Month, 1)).ToDictionary(g => g.Key, g => g.ToList());
        var monthLabels = new List<string>(); var monthlyInvoiced = new List<decimal>(); var monthlyCollected = new List<decimal>();
        for (var i = months - 1; i >= 0; i--) { var m = now.AddMonths(-i); var ms = new DateTime(m.Year, m.Month, 1); monthLabels.Add(m.ToString("MMM yyyy")); monthlyInvoiced.Add(invoicesByMonth.TryGetValue(ms, out var mi) ? mi.Sum(x => x.Total) : 0m); monthlyCollected.Add(paymentsByMonth.TryGetValue(ms, out var mp) ? mp.Sum(x => x.Amount) : 0m); }

        var agingBuckets = new List<AgingBucketItem> { new() { Label = "Current (0–30 days)" }, new() { Label = "31–60 days" }, new() { Label = "61–90 days" }, new() { Label = "Over 90 days" } };
        foreach (var inv in outstandingInvoices) { var days = inv.DueDate.HasValue ? (int)(today - inv.DueDate.Value).TotalDays : 0; var balance = inv.BalanceDue; var b = days <= 30 ? 0 : days <= 60 ? 1 : days <= 90 ? 2 : 3; agingBuckets[b].Amount += balance; agingBuckets[b].Count++; }
        var recentPayments = activeInvoices.SelectMany(i => i.Payments.Where(p => !p.IsDeleted).Select(p => new RecentPaymentItem { InvoiceNumber = i.InvoiceNumber, CustomerName = i.Customer?.CompanyName ?? $"{i.Customer?.ContactFirstName} {i.Customer?.ContactLastName}".Trim(), Amount = p.Amount, PaymentMethod = p.PaymentMethod.ToString(), PaymentDate = p.PaymentDate })).OrderByDescending(p => p.PaymentDate).Take(10).ToList();
        var topOutstanding = outstandingInvoices.Where(i => i.Customer != null).GroupBy(i => new { i.CustomerId, Name = i.Customer!.CompanyName ?? $"{i.Customer.ContactFirstName} {i.Customer.ContactLastName}".Trim() }).Select(g => new OutstandingCustomerItem { CustomerName = g.Key.Name, OutstandingBalance = g.Sum(i => i.BalanceDue), OpenInvoiceCount = g.Count() }).OrderByDescending(x => x.OutstandingBalance).Take(5).ToList();
        var paidWithDates = allInvoices.Where(i => i.Status == InvoiceStatus.Paid && i.SentDate.HasValue && i.PaidDate.HasValue).ToList();
        var avgDays = paidWithDates.Any() ? paidWithDates.Average(i => (i.PaidDate!.Value - i.SentDate!.Value).TotalDays) : 0.0;

        return View(new FinancialSummaryViewModel
        {
            ReportTitle = "Financial Summary", ReportDescription = "Invoicing, collections, and accounts receivable overview",
            SelectedMonths = months, MonthLabels = monthLabels,
            TotalInvoiced = activeInvoices.Sum(i => i.Total), TotalCollected = activeInvoices.Sum(i => i.AmountPaid),
            TotalOutstanding = activeInvoices.Sum(i => i.BalanceDue), TotalOverdue = overdueInvoices.Sum(i => i.BalanceDue),
            InvoicesOverdueCount = overdueInvoices.Count, InvoicesDraftCount = allInvoices.Count(i => i.Status == InvoiceStatus.Draft),
            InvoicesPaidCount = allInvoices.Count(i => i.Status == InvoiceStatus.Paid), AvgDaysToPayment = avgDays,
            MonthlyInvoiced = monthlyInvoiced, MonthlyCollected = monthlyCollected, AgingBuckets = agingBuckets,
            RecentPayments = recentPayments, TopOutstandingCustomers = topOutstanding
        });
    }

    /// <summary>
    /// Expenses &amp; AP report — outstanding vendor bills bucketed by aging, vendor spend ranking,
    /// and direct expenses by account. Mirrors the AR Aging structure on the payable side.
    /// Gated behind <see cref="AllowAccounting"/>.
    /// </summary>
    public async Task<IActionResult> ExpensesAp(int months = 6)
    {
        if (!AllowAccounting()) return RedirectToAction(nameof(Landing));
        var now = DateTime.UtcNow;
        var today = DateTime.Today;
        var allBills = await _operationalReports.GetActiveBillsAsync();
        var allExpenses = await _operationalReports.GetAllExpensesAsync();

        var outstandingBills = allBills.Where(b => b.BalanceDue > 0).ToList();
        var apAgingBuckets = new List<AgingBucketItem> { new() { Label = "Current (0–30 days)" }, new() { Label = "31–60 days" }, new() { Label = "61–90 days" }, new() { Label = "Over 90 days" } };
        foreach (var bill in outstandingBills) { var days = bill.DueDate.HasValue ? (int)(today - bill.DueDate.Value).TotalDays : 0; var b = days <= 30 ? 0 : days <= 60 ? 1 : days <= 90 ? 2 : 3; apAgingBuckets[b].Amount += bill.BalanceDue; apAgingBuckets[b].Count++; }
        var vendorSpend = allBills.Where(b => b.Vendor != null).GroupBy(b => new { b.VendorId, Name = b.Vendor!.CompanyName }).Select(g => new VendorSpendItem { VendorName = g.Key.Name, TotalBilled = g.Sum(b => b.Total), TotalPaid = g.Sum(b => b.AmountPaid), BillCount = g.Count() }).OrderByDescending(v => v.TotalBilled).Take(8).ToList();
        var expensesByAccount = allExpenses.Where(e => e.ExpenseAccount != null).GroupBy(e => new { e.ExpenseAccountId, Name = $"{e.ExpenseAccount!.AccountNumber} – {e.ExpenseAccount.Name}" }).Select(g => new ExpenseByAccountItem { AccountName = g.Key.Name, Amount = g.Sum(e => e.Amount), Count = g.Count() }).OrderByDescending(x => x.Amount).ToList();

        var monthLabels = new List<string>(); var monthlyBillsPaid = new List<decimal>(); var monthlyDirectExpenses = new List<decimal>();
        // Also load collected payments for P&L comparison
        var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
        var allInvoices = (await _unitOfWork.Invoices.FindAsync(i => i.CompanyId == companyId, false, i => i.Payments)).ToList();
        var paymentsByMonth = allInvoices.SelectMany(i => i.Payments.Where(p => !p.IsDeleted)).GroupBy(p => new DateTime(p.PaymentDate.Year, p.PaymentDate.Month, 1)).ToDictionary(g => g.Key, g => g.ToList());
        var plRevenue = new List<decimal>(); var plExpenses = new List<decimal>(); var plNet = new List<decimal>();
        for (var i = months - 1; i >= 0; i--)
        {
            var m = now.AddMonths(-i); var ms = new DateTime(m.Year, m.Month, 1); var me = ms.AddMonths(1);
            monthLabels.Add(m.ToString("MMM yyyy"));
            var billsPaid = allBills.SelectMany(b => b.Payments).Where(p => p.PaymentDate >= ms && p.PaymentDate < me).Sum(p => p.Amount);
            var expenses = allExpenses.Where(e => e.Date >= ms && e.Date < me).Sum(e => e.Amount);
            var revenue = paymentsByMonth.TryGetValue(ms, out var mp) ? mp.Sum(x => x.Amount) : 0m;
            monthlyBillsPaid.Add(billsPaid); monthlyDirectExpenses.Add(expenses);
            plRevenue.Add(revenue); plExpenses.Add(billsPaid + expenses); plNet.Add(revenue - billsPaid - expenses);
        }

        return View(new ExpensesApViewModel
        {
            ReportTitle = "Expenses & AP", ReportDescription = "Vendor spend, accounts payable aging, and expense breakdown",
            SelectedMonths = months, MonthLabels = monthLabels,
            TotalBilled = allBills.Sum(b => b.Total), TotalBillsPaid = allBills.Sum(b => b.AmountPaid),
            TotalApOutstanding = allBills.Sum(b => b.BalanceDue), TotalDirectExpenses = allExpenses.Sum(e => e.Amount),
            ApAgingBuckets = apAgingBuckets, VendorSpend = vendorSpend, ExpensesByAccount = expensesByAccount,
            MonthlyBillsPaid = monthlyBillsPaid, MonthlyDirectExpenses = monthlyDirectExpenses,
            PlRevenue = plRevenue, PlExpenses = plExpenses, PlNetIncome = plNet
        });
    }

    /// <summary>
    /// Powder Usage report — top 15 powder colors by lbs consumed (via JobUsage inventory
    /// transactions) and a monthly trend of total lbs used and cost. Job count per color is
    /// approximated using distinct transaction Reference values (which store the job number).
    /// </summary>
    public async Task<IActionResult> PowderUsage(int months = 6)
    {
        var now = DateTime.UtcNow;
        var startDate = now.AddMonths(-months);
        var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
        var powderTransactions = (await _unitOfWork.InventoryTransactions.FindAsync(
                t => t.CompanyId == companyId && t.TransactionType == InventoryTransactionType.JobUsage && t.TransactionDate >= startDate, false, t => t.InventoryItem))
            .ToList();

        var topColors = powderTransactions.Where(t => t.InventoryItem != null).GroupBy(t => t.InventoryItemId)
            .Select(g => new PowderUsageByColorItem { InventoryItemId = g.Key, ColorName = g.First().InventoryItem!.ColorName ?? g.First().InventoryItem.Name, ColorCode = g.First().InventoryItem!.ColorCode, SKU = g.First().InventoryItem!.SKU, Manufacturer = g.First().InventoryItem!.Manufacturer, TotalLbsUsed = g.Sum(t => Math.Abs(t.Quantity)), TotalCost = g.Sum(t => Math.Abs(t.TotalCost)), JobCount = g.Where(t => !string.IsNullOrEmpty(t.Reference)).Select(t => t.Reference).Distinct().Count() })
            .OrderByDescending(x => x.TotalLbsUsed).Take(15).ToList();
        var monthLabels = new List<string>(); var monthlyLbs = new List<decimal>(); var monthlyCost = new List<decimal>();
        for (var i = months - 1; i >= 0; i--) { var m = now.AddMonths(-i); var ms = new DateTime(m.Year, m.Month, 1); var me = ms.AddMonths(1); var mTx = powderTransactions.Where(t => t.TransactionDate >= ms && t.TransactionDate < me).ToList(); monthLabels.Add(m.ToString("MMM yyyy")); monthlyLbs.Add(mTx.Sum(t => Math.Abs(t.Quantity))); monthlyCost.Add(mTx.Sum(t => Math.Abs(t.TotalCost))); }

        return View(new PowderUsageViewModel
        {
            ReportTitle = "Powder Usage", ReportDescription = "Powder consumption by color and monthly trend",
            SelectedMonths = months, MonthLabels = monthLabels,
            TotalPowderUsedLbs = powderTransactions.Sum(t => Math.Abs(t.Quantity)), TotalPowderCost = powderTransactions.Sum(t => Math.Abs(t.TotalCost)),
            TotalJobsWithPowderUsage = powderTransactions.Where(t => !string.IsNullOrEmpty(t.Reference)).Select(t => t.Reference).Distinct().Count(),
            ColorsUsedCount = topColors.Count, TopColorsByUsage = topColors, MonthlyPowderUsageLbs = monthlyLbs, MonthlyPowderUsageCost = monthlyCost
        });
    }

    /// <summary>Sales by Customer report — all active (non-voided) invoices grouped by customer, sorted by total invoiced.</summary>
    public async Task<IActionResult> SalesByCustomer(int months = 6)
    {
        var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
        var allInvoices = (await _unitOfWork.Invoices.FindAsync(i => i.CompanyId == companyId, false, i => i.Customer, i => i.Payments)).ToList();
        var activeInvoices = allInvoices.Where(i => i.Status != InvoiceStatus.Voided && i.Status != InvoiceStatus.WrittenOff).ToList();
        var items = activeInvoices.Where(i => i.Customer != null)
            .GroupBy(i => new { i.CustomerId, Name = i.Customer!.IsCommercial ? i.Customer.CompanyName : $"{i.Customer.ContactFirstName} {i.Customer.ContactLastName}".Trim(), i.Customer.IsCommercial })
            .Select(g => new SalesByCustomerItem { CustomerId = g.Key.CustomerId, CustomerName = g.Key.Name ?? "Unknown", IsCommercial = g.Key.IsCommercial, InvoiceCount = g.Count(), TotalInvoiced = g.Sum(i => i.Total), TotalPaid = g.Sum(i => i.AmountPaid), LastInvoiceDate = g.Max(i => (DateTime?)i.InvoiceDate) })
            .OrderByDescending(x => x.TotalInvoiced).ToList();
        return View(new SalesByCustomerViewModel { ReportTitle = "Sales by Customer", ReportDescription = "Revenue and collection totals per customer", SelectedMonths = months, Items = items, TotalInvoiced = items.Sum(x => x.TotalInvoiced), TotalPaid = items.Sum(x => x.TotalPaid) });
    }

    /// <summary>
    /// Customer Retention report — classifies every active customer into Active / At Risk / Lapsing /
    /// Churned / Never Ordered based on days since their last completed job. Thresholds:
    /// ≤30 days = Active, ≤60 = At Risk, ≤90 = Lapsing, &gt;90 = Churned.
    /// Results are sorted worst-to-best by status so churned customers appear first.
    /// </summary>
    public async Task<IActionResult> CustomerRetention(int months = 6)
    {
        var now = DateTime.UtcNow;
        var completedStatusCodes = new[] { "COMPLETED", "READY_FOR_PICKUP", "DELIVERED" };
        var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
        var customers = (await _unitOfWork.Customers.FindAsync(c => c.CompanyId == companyId)).ToList();
        var completedJobs = (await _unitOfWork.Jobs.FindAsync(j => j.CompanyId == companyId, false, j => j.JobStatus)).Where(j => completedStatusCodes.Contains(j.JobStatus.StatusCode)).ToList();
        var items = customers.Where(c => c.IsActive).Select(c =>
        {
            var cJobs = completedJobs.Where(j => j.CustomerId == c.Id).ToList();
            var lastJob = cJobs.Any() ? cJobs.Max(j => j.CompletedDate ?? j.UpdatedAt ?? j.CreatedAt) : (DateTime?)null;
            var daysSince = lastJob.HasValue ? (int)(now - lastJob.Value).TotalDays : -1;
            var status = cJobs.Count == 0 ? "Never Ordered" : daysSince <= 30 ? "Active" : daysSince <= 60 ? "At Risk" : daysSince <= 90 ? "Lapsing" : "Churned";
            return new CustomerRetentionItem { CustomerId = c.Id, CustomerName = c.IsCommercial ? c.CompanyName ?? "Unknown" : $"{c.ContactFirstName} {c.ContactLastName}".Trim(), Email = c.Email, Phone = c.Phone ?? c.MobilePhone, TotalJobs = cJobs.Count, LifetimeRevenue = cJobs.Sum(j => j.FinalPrice), LastJobDate = lastJob, DaysSinceLastJob = daysSince, RetentionStatus = status };
        }).OrderBy(c => c.RetentionStatus == "Churned" ? 0 : c.RetentionStatus == "Lapsing" ? 1 : c.RetentionStatus == "At Risk" ? 2 : c.RetentionStatus == "Active" ? 3 : 4).ThenByDescending(c => c.LifetimeRevenue).ToList();
        return View(new CustomerRetentionViewModel
        {
            ReportTitle = "Customer Retention", ReportDescription = "Identify at-risk and churned customers by recency of activity",
            SelectedMonths = months, Items = items,
            ActiveCount = items.Count(r => r.RetentionStatus == "Active"), AtRiskCount = items.Count(r => r.RetentionStatus == "At Risk"),
            LapsingCount = items.Count(r => r.RetentionStatus == "Lapsing"), ChurnedCount = items.Count(r => r.RetentionStatus == "Churned"),
            NeverOrderedCount = items.Count(r => r.RetentionStatus == "Never Ordered")
        });
    }

    /// <summary>
    /// Job Cycle Time report — shows average, min, and max days spent in each workflow stage
    /// for all completed jobs with a CompletedDate. Reads from JobStatusHistory (the change-log
    /// table) to calculate time in each status; the final status's time is measured from the
    /// last recorded change to CompletedDate. Jobs with cycle times &gt;365 days are excluded as
    /// likely data-entry errors. Only applies to completed jobs; active jobs are excluded because
    /// their current-status time is still accumulating.
    /// </summary>
    public async Task<IActionResult> JobCycleTime(int months = 6)
    {
        var now = DateTime.UtcNow;
        var completedStatusCodes = new[] { "COMPLETED", "READY_FOR_PICKUP", "DELIVERED" };
        var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
        var completedJobs = (await _unitOfWork.Jobs.FindAsync(j => j.CompanyId == companyId, false, j => j.JobStatus)).Where(j => completedStatusCodes.Contains(j.JobStatus.StatusCode) && j.CompletedDate.HasValue).ToList();
        var allStatusHistory = await _operationalReports.GetAllJobStatusHistoryAsync();
        var historyByJob = allStatusHistory.GroupBy(h => h.JobId).ToDictionary(g => g.Key, g => g.OrderBy(h => h.ChangedDate).ToList());
        var statusDisplayOrder = new[] { "PENDING", "QUOTED", "APPROVED", "IN_PREPARATION", "SANDBLASTING", "MASKING_TAPING", "CLEANING", "IN_OVEN", "COATING", "CURING", "QUALITY_CHECK" };
        var statusTimings = new Dictionary<string, (string DisplayName, List<double> Days)>();
        foreach (var job in completedJobs)
        {
            if (!historyByJob.TryGetValue(job.Id, out var history) || !history.Any()) continue;
            var prevDate = job.CreatedAt;
            foreach (var entry in history)
            {
                var fc = entry.FromStatus?.StatusCode; var fn = entry.FromStatus?.DisplayName;
                if (fc == null) { prevDate = entry.ChangedDate; continue; }
                var d = (entry.ChangedDate - prevDate).TotalDays;
                if (d >= 0 && d <= 365) { if (!statusTimings.ContainsKey(fc)) statusTimings[fc] = (fn ?? fc, new List<double>()); statusTimings[fc].Days.Add(d); }
                prevDate = entry.ChangedDate;
            }
            var last = history.Last(); var tc = last.ToStatus?.StatusCode; var tn = last.ToStatus?.DisplayName;
            if (tc != null) { var d = (job.CompletedDate!.Value - last.ChangedDate).TotalDays; if (d >= 0 && d <= 365) { if (!statusTimings.ContainsKey(tc)) statusTimings[tc] = (tn ?? tc, new List<double>()); statusTimings[tc].Days.Add(d); } }
        }
        var items = statusTimings.Where(kv => kv.Value.Days.Any())
            .Select(kv => new JobCycleTimeItem { StatusCode = kv.Key, StatusName = kv.Value.DisplayName, DisplayOrder = Array.IndexOf(statusDisplayOrder, kv.Key), AvgDays = Math.Round(kv.Value.Days.Average(), 1), MinDays = Math.Round(kv.Value.Days.Min(), 1), MaxDays = Math.Round(kv.Value.Days.Max(), 1), JobCount = kv.Value.Days.Count })
            .OrderBy(x => x.DisplayOrder < 0 ? 99 : x.DisplayOrder).ToList();
        var overallAvg = completedJobs.Select(j => (j.CompletedDate!.Value - j.CreatedAt).TotalDays).Where(d => d >= 0 && d <= 365).DefaultIfEmpty(0).Average();
        return View(new JobCycleTimeViewModel { ReportTitle = "Job Cycle Time", ReportDescription = "Average time spent in each workflow stage", SelectedMonths = months, Items = items, OverallAvgCycleDays = overallAvg });
    }

    /// <summary>
    /// Job Profitability report — compares each job's final price and collected amount against
    /// actual labor cost (hours × StandardLaborRate) and estimated powder cost (PowderToOrder ×
    /// PowderCostPerLb, or ActualPowderUsedLbs when recorded). Only jobs with at least one
    /// JobTimeEntry contribute meaningful labor cost figures; the report flags rows without time
    /// tracking so the user knows which margin estimates are incomplete.
    /// </summary>
    public async Task<IActionResult> JobProfitability(int months = 6, bool timeTrackedOnly = false)
    {
        var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
        var cutoff    = DateTime.UtcNow.AddMonths(-months);

        // ── Load base data ────────────────────────────────────────────────

        var jobs = (await _unitOfWork.Jobs.FindAsync(
                j => j.CompanyId == companyId && j.CreatedAt >= cutoff,
                false,
                j => j.Customer,
                j => j.JobStatus))
            .ToList();

        var jobIds = jobs.Select(j => j.Id).ToList();

        // Time entries grouped by job
        var timeEntries = (await _unitOfWork.JobTimeEntries.FindAsync(
                te => jobIds.Contains(te.JobId) && !te.IsDeleted))
            .GroupBy(te => te.JobId)
            .ToDictionary(g => g.Key, g => g.Sum(te => te.HoursWorked));

        // Job items, then coats grouped by job
        var jobItems = (await _unitOfWork.JobItems.FindAsync(
                ji => jobIds.Contains(ji.JobId) && !ji.IsDeleted))
            .ToList();
        var jobItemIds = jobItems.Select(ji => ji.Id).ToList();

        var coats = jobItemIds.Any()
            ? (await _unitOfWork.JobItemCoats.FindAsync(
                    c => jobItemIds.Contains(c.JobItemId) && !c.IsDeleted))
                .ToList()
            : new List<JobItemCoat>();

        // Map coat cost totals back to JobId
        var jobItemToJob = jobItems.ToDictionary(ji => ji.Id, ji => ji.JobId);
        var powderCostByJob = coats
            .Where(c => c.PowderCostPerLb.HasValue)
            .GroupBy(c => jobItemToJob.TryGetValue(c.JobItemId, out var jid) ? jid : 0)
            .Where(g => g.Key > 0)
            .ToDictionary(
                g => g.Key,
                g => g.Sum(c =>
                {
                    var lbs = c.ActualPowderUsedLbs ?? c.PowderToOrder ?? 0m;
                    return lbs * (c.PowderCostPerLb ?? 0m);
                }));

        // Invoices grouped by job
        var invoices = (await _unitOfWork.Invoices.FindAsync(
                inv => inv.CompanyId == companyId && inv.JobId.HasValue
                       && jobIds.Contains(inv.JobId!.Value)
                       && !inv.IsDeleted))
            .GroupBy(inv => inv.JobId!.Value)
            .ToDictionary(g => g.Key, g => g.Sum(inv => inv.AmountPaid));

        // Labor rate
        var opCosts = await _unitOfWork.CompanyOperatingCosts.FirstOrDefaultAsync(
            c => c.CompanyId == companyId && !c.IsDeleted);
        var laborRate = opCosts?.StandardLaborRate ?? 0m;

        // ── Build report rows ─────────────────────────────────────────────

        var items = jobs
            .Select(j =>
            {
                var hours       = timeEntries.TryGetValue(j.Id, out var h) ? h : 0m;
                var powderCost  = powderCostByJob.TryGetValue(j.Id, out var pc) ? pc : 0m;
                var collected   = invoices.TryGetValue(j.Id, out var paid) ? paid : 0m;

                return new JobProfitabilityItem
                {
                    JobId           = j.Id,
                    JobNumber       = j.JobNumber,
                    CustomerName    = !string.IsNullOrWhiteSpace(j.Customer?.CompanyName)
                        ? j.Customer.CompanyName
                        : $"{j.Customer?.ContactFirstName} {j.Customer?.ContactLastName}".Trim()
                          is { Length: > 0 } n ? n : "Unknown",
                    StatusName      = j.JobStatus?.DisplayName ?? "Unknown",
                    StatusColorClass = j.JobStatus?.ColorClass ?? "bg-secondary",
                    JobDate         = j.CreatedAt,
                    FinalPrice      = j.FinalPrice,
                    AmountCollected = collected,
                    ActualHours     = hours,
                    ActualLaborCost = Math.Round(hours * laborRate, 2),
                    ActualPowderCost = Math.Round(powderCost, 2),
                    HasTimeEntries  = timeEntries.ContainsKey(j.Id),
                };
            })
            .Where(r => !timeTrackedOnly || r.HasTimeEntries)
            .OrderByDescending(r => r.JobDate)
            .ToList();

        // ── Summary KPIs ──────────────────────────────────────────────────

        var itemsWithCost = items.Where(r => r.EstimatedTotalCost > 0).ToList();

        return View(new JobProfitabilityViewModel
        {
            ReportTitle         = "Job Profitability",
            ReportDescription   = "Actual labor and powder cost vs. billed price per job",
            SelectedMonths      = months,
            TimeTrackedOnly     = timeTrackedOnly,
            TotalJobs           = items.Count,
            JobsWithTimeEntries = items.Count(r => r.HasTimeEntries),
            TotalRevenue        = items.Sum(r => r.FinalPrice),
            TotalCollected      = items.Sum(r => r.AmountCollected),
            TotalEstimatedCost  = itemsWithCost.Sum(r => r.EstimatedTotalCost),
            TotalActualHours    = items.Sum(r => r.ActualHours),
            AvgMarginPercent    = itemsWithCost.Any()
                ? Math.Round(itemsWithCost.Average(r => r.MarginPercent), 1)
                : 0m,
            Items = items,
        });
    }

    /// <summary>
    /// Job Status Aging report — all active (non-terminal) jobs sorted by days in their current
    /// status descending. Uses UpdatedAt as the proxy for "when did this job enter its current status"
    /// because there's no per-status entry-timestamp on the Job entity itself. Jobs that haven't
    /// moved in a long time bubble to the top, making it easy to spot stalled work.
    /// </summary>
    public async Task<IActionResult> JobStatusAging(int months = 6)
    {
        var now = DateTime.UtcNow;
        var today = DateTime.Today;
        var activeStatusCodes = new[] { "PENDING", "QUOTED", "APPROVED", "IN_PREPARATION", "SANDBLASTING", "MASKING_TAPING", "CLEANING", "IN_OVEN", "COATING", "CURING", "QUALITY_CHECK", "ON_HOLD" };
        var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
        var activeJobs = (await _unitOfWork.Jobs.FindAsync(j => j.CompanyId == companyId, false, j => j.Customer, j => j.JobStatus, j => j.JobPriority)).Where(j => activeStatusCodes.Contains(j.JobStatus.StatusCode)).ToList();
        var items = activeJobs.Select(j => new JobStatusAgingItem
        {
            JobId = j.Id, JobNumber = j.JobNumber, CustomerName = j.Customer?.IsCommercial == true ? j.Customer.CompanyName ?? "Unknown" : $"{j.Customer?.ContactFirstName} {j.Customer?.ContactLastName}".Trim(),
            StatusName = j.JobStatus.DisplayName, StatusCode = j.JobStatus.StatusCode, PriorityName = j.JobPriority?.DisplayName ?? "Normal", PriorityCode = j.JobPriority?.PriorityCode ?? "NORMAL",
            DaysInCurrentStatus = (int)(now - (j.UpdatedAt ?? j.CreatedAt)).TotalDays, DueDate = j.DueDate, IsOverdue = j.DueDate.HasValue && j.DueDate.Value < today
        }).OrderByDescending(j => j.DaysInCurrentStatus).ToList();
        return View(new JobStatusAgingViewModel { ReportTitle = "Job Status Aging", ReportDescription = "Active jobs ranked by days in current status", SelectedMonths = months, Items = items });
    }

    /// <summary>
    /// Invoice Aging Detail — line-by-line view of every open (unpaid, non-voided) invoice with
    /// aging bucket and days overdue. Sorted by days overdue descending. Includes customer contact
    /// info so staff can use this report for collections outreach without switching screens.
    /// Gated behind <see cref="AllowAccounting"/>.
    /// </summary>
    public async Task<IActionResult> InvoiceAgingDetail(int months = 6)
    {
        if (!AllowAccounting()) return RedirectToAction(nameof(Landing));
        var today = DateTime.Today;
        var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
        var allInvoices = (await _unitOfWork.Invoices.FindAsync(i => i.CompanyId == companyId, false, i => i.Customer, i => i.Payments)).ToList();
        var items = allInvoices.Where(i => i.Customer != null && i.Status != InvoiceStatus.Voided && i.Status != InvoiceStatus.WrittenOff && i.Status != InvoiceStatus.Paid)
            .Select(i =>
            {
                var daysOverdue = i.DueDate.HasValue ? (int)(today - i.DueDate.Value.Date).TotalDays : 0;
                return new InvoiceAgingDetailItem { InvoiceId = i.Id, InvoiceNumber = i.InvoiceNumber, CustomerName = i.Customer!.IsCommercial ? i.Customer.CompanyName ?? "Unknown" : $"{i.Customer.ContactFirstName} {i.Customer.ContactLastName}".Trim(), CustomerEmail = i.Customer.Email, CustomerPhone = i.Customer.Phone ?? i.Customer.MobilePhone, InvoiceDate = i.InvoiceDate, DueDate = i.DueDate, Total = i.Total, AmountPaid = i.AmountPaid, BalanceDue = i.BalanceDue, DaysOverdue = Math.Max(0, daysOverdue), AgingBucket = daysOverdue <= 0 ? "Current" : daysOverdue <= 30 ? "1–30 Days" : daysOverdue <= 60 ? "31–60 Days" : daysOverdue <= 90 ? "61–90 Days" : "90+ Days", StatusDisplay = i.Status.ToString() };
            }).OrderByDescending(i => i.DaysOverdue).ToList();
        return View(new InvoiceAgingDetailViewModel { ReportTitle = "Invoice Aging Detail", ReportDescription = "Outstanding invoices with aging analysis", SelectedMonths = months, Items = items, TotalBalance = items.Sum(i => i.BalanceDue) });
    }

    /// <summary>
    /// Powder Consumption vs Purchase report — per-SKU comparison of total pounds purchased vs
    /// consumed (JobUsage + Waste transactions). Items with no purchases and no consumption are
    /// excluded. Helps identify powder that was purchased but never used, or SKUs running down
    /// faster than they're being restocked.
    /// </summary>
    public async Task<IActionResult> PowderConsumption(int months = 6)
    {
        var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
        var allTx = (await _unitOfWork.InventoryTransactions.FindAsync(t => t.CompanyId == companyId, false, t => t.InventoryItem)).ToList();
        var items = allTx.Where(t => t.InventoryItem != null)
            .GroupBy(t => new { t.InventoryItemId, t.InventoryItem!.Name, t.InventoryItem.SKU, t.InventoryItem.ColorName, t.InventoryItem.ColorCode, t.InventoryItem.Manufacturer })
            .Select(g => new PowderConsumptionItem { InventoryItemId = g.Key.InventoryItemId, ItemName = g.Key.Name, SKU = g.Key.SKU, ColorName = g.Key.ColorName, ColorCode = g.Key.ColorCode, Manufacturer = g.Key.Manufacturer, TotalPurchasedLbs = g.Where(t => t.TransactionType == InventoryTransactionType.Purchase || t.TransactionType == InventoryTransactionType.Initial).Sum(t => t.Quantity), TotalConsumedLbs = g.Where(t => t.TransactionType == InventoryTransactionType.JobUsage || t.TransactionType == InventoryTransactionType.Waste).Sum(t => Math.Abs(t.Quantity)), PurchaseCount = g.Count(t => t.TransactionType == InventoryTransactionType.Purchase), UsageJobCount = g.Where(t => t.TransactionType == InventoryTransactionType.JobUsage && !string.IsNullOrEmpty(t.Reference)).Select(t => t.Reference).Distinct().Count() })
            .Where(x => x.TotalPurchasedLbs > 0 || x.TotalConsumedLbs > 0).OrderByDescending(x => x.TotalConsumedLbs).ToList();
        return View(new PowderConsumptionViewModel { ReportTitle = "Powder Consumption vs Purchase", ReportDescription = "Compare purchased vs consumed powder per SKU", SelectedMonths = months, Items = items, TotalPurchasedLbs = items.Sum(x => x.TotalPurchasedLbs), TotalConsumedLbs = items.Sum(x => x.TotalConsumedLbs) });
    }

    /// <summary>
    /// Inventory Turnover report — days-to-stockout and turnover rate per active inventory item.
    /// DailyConsumptionLbs = total consumed ÷ (months × 30) to normalize across reporting periods.
    /// DaysToStockout = current stock ÷ daily consumption; items with zero usage get DaysToStockout = 9999
    /// (sentinel for "no usage data") which the view renders as "N/A". StockStatus thresholds:
    /// ≤7 days = Critical, ≤30 = Low, ≤90 = Normal, &lt;9999 = Overstocked, 9999 = No Usage.
    /// </summary>
    public async Task<IActionResult> InventoryTurnover(int months = 6)
    {
        var daysInPeriod = months * 30.0;
        var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
        var inventory = (await _unitOfWork.InventoryItems.FindAsync(i => i.CompanyId == companyId)).ToList();
        var allTx = (await _unitOfWork.InventoryTransactions.FindAsync(t => t.CompanyId == companyId, false, t => t.InventoryItem)).ToList();
        var items = inventory.Where(i => i.IsActive).Select(i =>
        {
            var iTx = allTx.Where(t => t.InventoryItemId == i.Id).ToList();
            var consumed = (double)iTx.Where(t => t.TransactionType == InventoryTransactionType.JobUsage || t.TransactionType == InventoryTransactionType.Waste).Sum(t => Math.Abs(t.Quantity));
            var purchased = (double)iTx.Where(t => t.TransactionType == InventoryTransactionType.Purchase).Sum(t => t.Quantity);
            var dailyUse = daysInPeriod > 0 ? consumed / daysInPeriod : 0;
            var dsOut = dailyUse > 0 ? (double)i.QuantityOnHand / dailyUse : 9999;
            return new InventoryTurnoverItem { InventoryItemId = i.Id, ItemName = i.Name, SKU = i.SKU, ColorName = i.ColorName, CurrentStockLbs = i.QuantityOnHand, TotalConsumedLbs = (decimal)consumed, TotalPurchasedLbs = (decimal)purchased, DailyConsumptionLbs = Math.Round((decimal)dailyUse, 3), DaysToStockout = dsOut >= 9999 ? 9999 : Math.Round(dsOut, 0), TurnoverRate = consumed > 0 && (double)i.QuantityOnHand > 0 ? Math.Round(consumed / (double)i.QuantityOnHand, 2) : 0, StockStatus = dsOut <= 7 ? "Critical" : dsOut <= 30 ? "Low" : dsOut <= 90 ? "Normal" : dsOut < 9999 ? "Overstocked" : "No Usage" };
        }).Where(x => x.TotalConsumedLbs > 0 || x.CurrentStockLbs > 0).OrderBy(x => x.DaysToStockout).ToList();
        return View(new InventoryTurnoverViewModel { ReportTitle = "Inventory Turnover", ReportDescription = "Stock consumption rates and days-to-stockout by item", SelectedMonths = months, Items = items });
    }

    // ── AI: AR Follow-up Email Draft ─────────────────────────────────────────

    /// <summary>
    /// AJAX POST — uses Anthropic Claude to draft a collections follow-up email for a specific
    /// overdue invoice. The request body supplies the invoice details (amount, days overdue,
    /// customer name); the AI adjusts tone based on severity. Company name is injected server-side
    /// so the client can't spoof it. Rate-limited by the AI rate-limit policy.
    /// Gated behind <see cref="AllowAccounting"/>.
    /// </summary>
    [HttpPost]
    [EnableRateLimiting(AppConstants.RateLimitPolicies.Ai)]
    public async Task<IActionResult> DraftArFollowUp([FromBody] ArFollowUpRequest? request)
    {
        if (!AllowAccounting()) return Json(new { success = false, error = "Accounting module is not enabled." });
        if (request == null)
            return Json(new { success = false, error = "Invalid request." });

        request.CompanyName = await GetCompanyNameAsync();
        var result = await _accountingAi.DraftFollowUpEmailAsync(request);
        var _arCid = int.TryParse(User.FindFirst("CompanyId")?.Value, out var _arC) ? _arC : 0;
        await _usageLogger.LogAsync(_arCid, User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value ?? "", AppConstants.AiFeatures.ArFollowUp);
        return Json(result);
    }

    // ── AI: Financial Summary ─────────────────────────────────────────────────

    /// <summary>
    /// AJAX POST — assembles current-period financial data (AR totals, expense breakdown,
    /// month-over-month trends, overdue invoice count) and sends it to Claude for a plain-English
    /// narrative summary. Designed to be embedded in the Full Analytics view as a one-click insight.
    /// Bill payments are added to total expenses alongside direct expenses so the AI sees the
    /// complete cost picture. Rate-limited by the AI rate-limit policy.
    /// Gated behind <see cref="AllowAccounting"/>.
    /// </summary>
    [HttpPost]
    [EnableRateLimiting(AppConstants.RateLimitPolicies.Ai)]
    public async Task<IActionResult> GenerateFinancialSummary()
    {
        if (!AllowAccounting()) return Json(new { success = false, error = "Accounting module is not enabled." });
        try
        {
            var companyName = await GetCompanyNameAsync();
            var now = DateTime.UtcNow;
            var today = DateTime.Today;

            var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
            // Load invoices for AR data
            var allInvoices = (await _unitOfWork.Invoices.FindAsync(i => i.CompanyId == companyId, false, i => i.Customer, i => i.Payments)).ToList();
            var activeInvoices = allInvoices.Where(i => i.Status != InvoiceStatus.Voided && i.Status != InvoiceStatus.WrittenOff).ToList();
            var outstandingInvoices = activeInvoices.Where(i => i.BalanceDue > 0 && i.Status != InvoiceStatus.Paid).ToList();

            var totalRevenue = activeInvoices.Sum(i => i.Total);
            var totalCollected = activeInvoices.SelectMany(i => i.Payments.Where(p => !p.IsDeleted)).Sum(p => p.Amount);

            // Prior month revenue
            var startOfThisMonth = new DateTime(now.Year, now.Month, 1);
            var startOfLastMonth = startOfThisMonth.AddMonths(-1);
            var priorMonthInvoices = activeInvoices.Where(i => i.InvoiceDate >= startOfLastMonth && i.InvoiceDate < startOfThisMonth).ToList();
            var thisMonthInvoices = activeInvoices.Where(i => i.InvoiceDate >= startOfThisMonth).ToList();
            var priorMonthRevenue = priorMonthInvoices.Sum(i => i.Total);
            var thisMonthRevenue = thisMonthInvoices.Sum(i => i.Total);

            // AR overdue
            var totalArOutstanding = outstandingInvoices.Sum(i => i.BalanceDue);
            var arOverdue30 = outstandingInvoices
                .Where(i => i.DueDate.HasValue && (today - i.DueDate.Value).TotalDays > 30)
                .Sum(i => i.BalanceDue);
            var overdueCount = outstandingInvoices
                .Count(i => i.DueDate.HasValue && (today - i.DueDate.Value).TotalDays > 30);

            // Expenses by account
            var allExpenses = await _operationalReports.GetAllExpensesAsync();
            var expensesByCategory = allExpenses
                .Where(e => e.ExpenseAccount != null)
                .GroupBy(e => e.ExpenseAccount!.Name)
                .Select(g => new ExpenseByCategory { Category = g.Key, Amount = g.Sum(e => e.Amount) })
                .OrderByDescending(x => x.Amount)
                .ToList();
            var totalExpenses = allExpenses.Sum(e => e.Amount);

            // Also include bills paid as expenses
            var allBills = await _operationalReports.GetActiveBillsAsync();
            var billsPaid = allBills.Sum(b => b.AmountPaid);
            totalExpenses += billsPaid;

            var priorMonthExpenses = allExpenses
                .Where(e => e.Date >= startOfLastMonth && e.Date < startOfThisMonth)
                .Sum(e => e.Amount);

            var summaryRequest = new FinancialSummaryRequest
            {
                CompanyName = companyName,
                Period = "Last 6 months",
                TotalRevenue = thisMonthRevenue > 0 ? thisMonthRevenue : totalRevenue,
                TotalExpenses = totalExpenses,
                NetIncome = totalRevenue - totalExpenses,
                PriorMonthRevenue = priorMonthRevenue,
                PriorMonthExpenses = priorMonthExpenses,
                TotalArOutstanding = totalArOutstanding,
                ArOverdue30Days = arOverdue30,
                OverdueInvoiceCount = overdueCount,
                ExpensesByCategory = expensesByCategory
            };

            var result = await _accountingAi.GenerateFinancialSummaryAsync(summaryRequest);
            var _fsCid = int.TryParse(User.FindFirst("CompanyId")?.Value, out var _fsC) ? _fsC : 0;
            await _usageLogger.LogAsync(_fsCid, User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value ?? "", AppConstants.AiFeatures.FinancialSummary, result.Success);
            return Json(result);
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Error generating financial summary");
            return Json(new FinancialSummaryResult { Success = false, ErrorMessage = "An error occurred while generating the summary." });
        }
    }

    // ── AI: Cash Flow Forecast ───────────────────────────────────────────────

    /// <summary>Entry point for the Cash Flow Forecast page. Gated behind <see cref="AllowAccounting"/>.</summary>
    public IActionResult CashFlowForecast() => AllowAccounting() ? View() : RedirectToAction(nameof(Landing));

    /// <summary>
    /// AJAX POST — builds a 30/60/90-day cash flow forecast using Claude. Passes three data streams:
    /// — Open AR invoices with customer-specific avg-days-to-pay (derived from historical paid invoices).
    ///   Customers with no payment history default to 30 days.
    /// — Open AP bills with their due dates so expected outflows are included.
    /// — Top 30 active (non-terminal) jobs by value as a pipeline of expected future invoices.
    /// The AI synthesises these into an outlook badge (Strong/Moderate/Tight/Concerning) with
    /// narrative projections. Rate-limited by the AI rate-limit policy.
    /// Gated behind <see cref="AllowAccounting"/>.
    /// </summary>
    [HttpPost]
    [EnableRateLimiting(AppConstants.RateLimitPolicies.Ai)]
    public async Task<IActionResult> GenerateCashFlowForecast()
    {
        if (!AllowAccounting()) return Json(new { success = false, error = "Accounting module is not enabled." });
        try
        {
            var companyName = await GetCompanyNameAsync();
            var today = DateTime.Today;

            var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
            // Open AR invoices
            var openInvoices = (await _unitOfWork.Invoices.FindAsync(i => i.CompanyId == companyId, false, i => i.Customer, i => i.Payments))
                .Where(i => i.BalanceDue > 0 && i.Status != InvoiceStatus.Voided && i.Status != InvoiceStatus.WrittenOff && i.Status != InvoiceStatus.Paid)
                .ToList();

            // Compute avg days to pay per customer from paid invoices
            var paidInvoices = (await _unitOfWork.Invoices.FindAsync(i => i.CompanyId == companyId, false, i => i.Payments))
                .Where(i => i.Status == InvoiceStatus.Paid && i.InvoiceDate != default)
                .ToList();
            var avgDaysByCustomer = paidInvoices
                .Where(i => i.Payments.Any(p => !p.IsDeleted))
                .GroupBy(i => i.CustomerId)
                .ToDictionary(
                    g => g.Key,
                    g => (int)g.Select(i =>
                    {
                        var firstPay = i.Payments.Where(p => !p.IsDeleted).OrderBy(p => p.PaymentDate).FirstOrDefault();
                        return firstPay == null ? 30.0 : (firstPay.PaymentDate - i.InvoiceDate).TotalDays;
                    }).DefaultIfEmpty(30).Average());

            var arItems = openInvoices.Select(i => new CashFlowArItem
            {
                CustomerName = i.Customer != null
                    ? (i.Customer.CompanyName ?? $"{i.Customer.ContactFirstName} {i.Customer.ContactLastName}".Trim())
                    : "Unknown",
                InvoiceNumber = i.InvoiceNumber,
                BalanceDue = i.BalanceDue,
                DueDateIso = i.DueDate?.ToString("yyyy-MM-dd"),
                DaysOverdue = i.DueDate.HasValue && today > i.DueDate.Value
                    ? (today - i.DueDate.Value).Days : 0,
                AvgDaysToPay = avgDaysByCustomer.TryGetValue(i.CustomerId, out var avg) ? avg : 30
            }).ToList();

            // Open AP bills
            var openBills = (await _operationalReports.GetActiveBillsAsync())
                .Where(b => b.AmountPaid < b.Total)
                .ToList();

            var apItems = openBills.Select(b => new CashFlowApItem
            {
                VendorName = b.Vendor?.CompanyName ?? "Unknown",
                BillNumber = b.BillNumber,
                BalanceDue = b.BalanceDue,
                DueDateIso = b.DueDate?.ToString("yyyy-MM-dd")
            }).ToList();

            // Active job pipeline (non-terminal jobs not yet invoiced)
            var activeJobs = (await _unitOfWork.Jobs.GetBoardJobsAsync())
                .Where(j => j.JobStatus != null && !j.JobStatus.IsTerminalStatus)
                .OrderByDescending(j => j.FinalPrice > 0 ? j.FinalPrice : j.QuotedPrice)
                .Take(30)
                .ToList();

            var jobItems = activeJobs.Select(j => new CashFlowJobItem
            {
                JobNumber = j.JobNumber,
                CustomerName = j.Customer != null
                    ? (j.Customer.CompanyName ?? $"{j.Customer.ContactFirstName} {j.Customer.ContactLastName}".Trim())
                    : "Unknown",
                Status = j.JobStatus?.DisplayName ?? "Active",
                EstimatedValue = j.FinalPrice > 0 ? j.FinalPrice : j.QuotedPrice
            }).ToList();

            var forecastRequest = new CashFlowForecastRequest
            {
                CompanyName = companyName,
                AsOfDate = today.ToString("yyyy-MM-dd"),
                OpenInvoices = arItems,
                OpenBills = apItems,
                ActiveJobs = jobItems
            };

            var result = await _accountingAi.GenerateCashFlowForecastAsync(forecastRequest);
            var _cfCid = int.TryParse(User.FindFirst("CompanyId")?.Value, out var _cfC) ? _cfC : 0;
            await _usageLogger.LogAsync(_cfCid, User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value ?? "", AppConstants.AiFeatures.CashFlowForecast, result.Success);
            return Json(result);
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Error generating cash flow forecast");
            return Json(new CashFlowForecastResult { Success = false, ErrorMessage = "An error occurred while generating the forecast." });
        }
    }

    // ── AI: Anomaly / Duplicate Detection ────────────────────────────────────

    /// <summary>Entry point for the Anomaly Detection page. Gated behind <see cref="AllowAccounting"/>.</summary>
    public IActionResult AnomalyDetection() => AllowAccounting() ? View() : RedirectToAction(nameof(Landing));

    /// <summary>
    /// AJAX POST — scans the last 90 days of vendor bills for anomalies using Claude. Passes three
    /// data streams to the AI:
    /// — Recent bills (last 90 days) as candidates for analysis.
    /// — Vendor history (all time) with per-vendor average invoice amount and monthly spend, so the
    ///   AI can flag amounts that deviate significantly from the vendor's normal range.
    /// — Account spend trends (this month vs last month vs historical average by account) so the AI
    ///   can flag accounts with unusual spikes.
    /// Claude returns a severity-sorted list of flag cards (duplicate invoice numbers, amount spikes,
    /// unknown vendors, account overruns). Rate-limited by the AI rate-limit policy.
    /// Gated behind <see cref="AllowAccounting"/>.
    /// </summary>
    [HttpPost]
    [EnableRateLimiting(AppConstants.RateLimitPolicies.Ai)]
    public async Task<IActionResult> RunAnomalyDetection()
    {
        if (!AllowAccounting()) return Json(new { success = false, error = "Accounting module is not enabled." });
        try
        {
            var companyName = await GetCompanyNameAsync();
            var today = DateTime.Today;
            var ninetyDaysAgo = today.AddDays(-90);
            var startOfThisMonth = new DateTime(today.Year, today.Month, 1);
            var startOfLastMonth = startOfThisMonth.AddMonths(-1);

            // All active bills — used for both recent-bill candidates and all-time vendor history
            var allBills = await _operationalReports.GetActiveBillsAsync();
            var recentBills = allBills.Where(b => b.BillDate >= ninetyDaysAgo).OrderByDescending(b => b.BillDate).ToList();

            var billSummaries = recentBills.Select(b => new AnomalyBillSummary
            {
                Id = b.Id,
                BillNumber = b.BillNumber,
                VendorName = b.Vendor?.CompanyName ?? "Unknown",
                Total = b.Total,
                BillDateIso = b.BillDate.ToString("yyyy-MM-dd"),
                VendorInvoiceNumber = b.VendorInvoiceNumber
            }).ToList();

            var vendorHistory = allBills
                .Where(b => b.Vendor != null)
                .GroupBy(b => b.Vendor!.CompanyName)
                .Select(g =>
                {
                    var amounts = g.Select(b => b.Total).ToList();
                    var months = g.GroupBy(b => new { b.BillDate.Year, b.BillDate.Month })
                        .Select(m => m.Sum(b => b.Total)).ToList();
                    return new AnomalyVendorHistory
                    {
                        VendorName = g.Key,
                        AverageInvoiceAmount = amounts.Count > 0 ? amounts.Average() : 0,
                        AverageMonthlySpend = months.Count > 0 ? months.Average() : 0,
                        InvoiceCount = amounts.Count
                    };
                }).ToList();

            // Account spend trends (bills + expenses by account this month vs historical avg)
            var allExpenses = await _operationalReports.GetAllExpensesAsync();

            var accountTrends = allExpenses
                .Where(e => e.ExpenseAccount != null)
                .GroupBy(e => e.ExpenseAccount!.Name)
                .Select(g =>
                {
                    var thisMonth = g.Where(e => e.Date >= startOfThisMonth).Sum(e => e.Amount);
                    var lastMonth = g.Where(e => e.Date >= startOfLastMonth && e.Date < startOfThisMonth).Sum(e => e.Amount);
                    var allMonths = g.GroupBy(e => new { e.Date.Year, e.Date.Month })
                        .Select(m => m.Sum(e => e.Amount)).ToList();
                    return new AnomalyAccountTrend
                    {
                        AccountName = g.Key,
                        ThisMonthAmount = thisMonth,
                        LastMonthAmount = lastMonth,
                        AverageMonthlyAmount = allMonths.Count > 0 ? allMonths.Average() : 0
                    };
                })
                .Where(t => t.AverageMonthlyAmount > 0 || t.ThisMonthAmount > 0)
                .ToList();

            var detectionRequest = new AnomalyDetectionRequest
            {
                CompanyName = companyName,
                RecentBills = billSummaries,
                VendorHistory = vendorHistory,
                AccountTrends = accountTrends
            };

            var result = await _accountingAi.DetectAnomaliesAsync(detectionRequest);
            var _adCid = int.TryParse(User.FindFirst("CompanyId")?.Value, out var _adC) ? _adC : 0;
            await _usageLogger.LogAsync(_adCid, User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value ?? "", AppConstants.AiFeatures.AnomalyDetection, result.Success);
            return Json(result);
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Error running anomaly detection");
            return Json(new AnomalyDetectionResult { Success = false, ErrorMessage = "An error occurred while running the analysis." });
        }
    }

    // ── AI: Late Payment Prediction ───────────────────────────────────────────

    /// <summary>
    /// AJAX POST — loads all open AR invoices with customer payment history, then asks Claude
    /// to score each customer's payment risk. Avg days to pay and late rate are pre-computed
    /// from the full invoice history rather than open invoices only, so customers with only
    /// one open invoice still get meaningful risk scoring based on prior behavior.
    /// Gated behind <see cref="AllowAccounting"/>.
    /// </summary>
    [HttpPost]
    [EnableRateLimiting(AppConstants.RateLimitPolicies.Ai)]
    public async Task<IActionResult> PredictLatePayments()
    {
        if (!AllowAccounting()) return Json(new { success = false, error = "Accounting module is not enabled." });
        try
        {
            var companyName = await GetCompanyNameAsync();
            var today = DateTime.Today;

            var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
            var allInvoices = (await _unitOfWork.Invoices.FindAsync(i => i.CompanyId == companyId, false, i => i.Customer, i => i.Payments)).ToList();
            var activeInvoices = allInvoices.Where(i =>
                i.Status != InvoiceStatus.Voided &&
                i.Status != InvoiceStatus.WrittenOff).ToList();

            static string CustomerDisplayName(Invoice i) =>
                i.Customer?.CompanyName ?? (i.Customer != null
                    ? $"{i.Customer.ContactFirstName} {i.Customer.ContactLastName}".Trim()
                    : $"Customer #{i.CustomerId}");

            var outstandingByCustomer = activeInvoices
                .Where(i => i.BalanceDue > 0 && i.Status != InvoiceStatus.Paid)
                .GroupBy(i => CustomerDisplayName(i))
                .ToList();

            // Pre-compute per-customer historical behavior from all paid invoices
            var historyByCustomer = activeInvoices
                .Where(i => i.Status == InvoiceStatus.Paid && i.PaidDate.HasValue && i.SentDate.HasValue)
                .GroupBy(i => CustomerDisplayName(i))
                .ToDictionary(
                    g => g.Key,
                    g => new
                    {
                        AvgDaysToPay    = g.Average(i => (i.PaidDate!.Value - i.SentDate!.Value).TotalDays),
                        TotalInvoices   = g.Count(),
                        LateInvoices    = g.Count(i => i.DueDate.HasValue && i.PaidDate!.Value > i.DueDate.Value)
                    });

            var customerData = outstandingByCustomer.Select(g =>
            {
                var history = historyByCustomer.GetValueOrDefault(g.Key);
                return new LatePaymentCustomerData
                {
                    CustomerName      = g.Key,
                    TotalOwed         = g.Sum(i => i.BalanceDue),
                    AvgDaysToPay      = history?.AvgDaysToPay ?? 30,
                    TotalInvoicesAllTime = history?.TotalInvoices ?? 0,
                    LateInvoicesAllTime  = history?.LateInvoices  ?? 0,
                    OpenInvoices      = g.Select(i => new OpenInvoiceSummary
                    {
                        InvoiceNumber = i.InvoiceNumber,
                        BalanceDue    = i.BalanceDue,
                        DueDateIso    = i.DueDate?.ToString("yyyy-MM-dd"),
                        DaysOverdue   = i.DueDate.HasValue && i.DueDate.Value < today
                            ? (today - i.DueDate.Value).Days : 0
                    }).ToList()
                };
            }).ToList();

            if (!customerData.Any())
                return Json(new LatePaymentPredictionResult { Success = true, Insights = new() { "No outstanding invoices to analyze." } });

            var result = await _accountingAi.PredictLatePaymentsAsync(new LatePaymentPredictionRequest
            {
                CompanyName = companyName,
                Customers   = customerData
            });

            var lpCid = int.TryParse(User.FindFirst("CompanyId")?.Value, out var _lpC) ? _lpC : 0;
            await _usageLogger.LogAsync(lpCid, User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value ?? "", AppConstants.AiFeatures.LatePaymentPrediction, result.Success);
            return Json(result);
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Error predicting late payments");
            return Json(new LatePaymentPredictionResult { Success = false, ErrorMessage = "An error occurred while analyzing payment risk." });
        }
    }

    // ── AI: Natural Language Financial Queries ────────────────────────────────

    /// <summary>
    /// GET page for the natural language financial query tool. Pre-loads the financial context
    /// snapshot so the first query does not have a visible data-fetch delay — the context is
    /// serialized into a hidden field and passed back on the POST.
    /// </summary>
    public async Task<IActionResult> FinancialQuery()
    {
        if (!AllowAccounting()) return RedirectToAction(nameof(Landing));
        ViewBag.Context = await BuildFinancialQueryContextAsync();
        return View();
    }

    /// <summary>
    /// AJAX POST — receives the user's plain-English question and the pre-built context object,
    /// then sends both to Claude. The context is passed from client to server (rather than
    /// re-fetched on every request) so that rapid follow-up questions do not trigger additional
    /// database round-trips. The context JSON is validated server-side before passing to the AI
    /// service so a corrupted hidden field cannot cause a crash.
    /// </summary>
    [HttpPost]
    [EnableRateLimiting(AppConstants.RateLimitPolicies.Ai)]
    public async Task<IActionResult> RunFinancialQuery([FromBody] FinancialQueryRequest? request)
    {
        if (!AllowAccounting()) return Json(new { success = false, error = "Accounting module is not enabled." });
        if (request == null || string.IsNullOrWhiteSpace(request.Question))
            return Json(new FinancialQueryResult { Success = false, ErrorMessage = "Please enter a question." });

        // If context is empty (e.g. client didn't pass it), rebuild from DB
        if (request.Context == null || string.IsNullOrWhiteSpace(request.Context.CompanyName))
            request.Context = await BuildFinancialQueryContextAsync();

        var result = await _accountingAi.AnswerFinancialQueryAsync(request);
        var fqCid = int.TryParse(User.FindFirst("CompanyId")?.Value, out var _fqC) ? _fqC : 0;
        await _usageLogger.LogAsync(fqCid, User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value ?? "", AppConstants.AiFeatures.FinancialQuery, result.Success);
        return Json(result);
    }

    /// <summary>
    /// Builds a <see cref="FinancialQueryContext"/> snapshot from live DB data covering
    /// YTD totals, last 12 months of monthly revenue/expense summaries, and current
    /// AR/AP outstanding. This is factored out so both the GET page load and the fallback
    /// POST path share identical context-building logic.
    /// </summary>
    private async Task<FinancialQueryContext> BuildFinancialQueryContextAsync()
    {
        var companyName = await GetCompanyNameAsync();
        var now = DateTime.UtcNow;
        var startOfYear = new DateTime(now.Year, 1, 1);
        var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;

        var allInvoices = (await _unitOfWork.Invoices.FindAsync(i => i.CompanyId == companyId, false, i => i.Customer, i => i.Payments))
            .Where(i => i.Status != InvoiceStatus.Voided && i.Status != InvoiceStatus.WrittenOff)
            .ToList();

        var ytdRevenue  = allInvoices.Where(i => i.InvoiceDate >= startOfYear).Sum(i => i.Total);
        var arOutstanding = allInvoices.Where(i => i.BalanceDue > 0 && i.Status != InvoiceStatus.Paid).Sum(i => i.BalanceDue);

        var allBills = await _operationalReports.GetActiveBillsAsync();
        var ytdExpenses = allBills.Where(b => b.BillDate >= startOfYear).Sum(b => b.Total);
        var apOutstanding = allBills.Where(b => b.BalanceDue > 0).Sum(b => b.BalanceDue);

        // Monthly summaries for last 12 months
        var monthly = new List<MonthlyFinancialSummary>();
        for (var i = 11; i >= 0; i--)
        {
            var monthStart = new DateTime(now.Year, now.Month, 1).AddMonths(-i);
            var monthEnd   = monthStart.AddMonths(1);
            var rev = allInvoices.Where(inv => inv.InvoiceDate >= monthStart && inv.InvoiceDate < monthEnd).Sum(inv => inv.Total);
            var exp = allBills.Where(b => b.BillDate >= monthStart && b.BillDate < monthEnd).Sum(b => b.Total);
            monthly.Add(new MonthlyFinancialSummary
            {
                Month      = monthStart.ToString("yyyy-MM"),
                Revenue    = rev,
                Expenses   = exp,
                NetIncome  = rev - exp
            });
        }

        // Expense breakdown from bills by account
        var expensesByAccount = allBills
            .GroupBy(b => b.Memo ?? "Uncategorized")
            .Select(g => new ExpenseByCategory { Category = g.Key, Amount = g.Sum(b => b.Total) })
            .OrderByDescending(e => e.Amount)
            .Take(10)
            .ToList();

        return new FinancialQueryContext
        {
            CompanyName       = companyName,
            AsOfDate          = now.ToString("yyyy-MM-dd"),
            TotalRevenueYtd   = ytdRevenue,
            TotalExpensesYtd  = ytdExpenses,
            NetIncomeYtd      = ytdRevenue - ytdExpenses,
            ArOutstanding     = arOutstanding,
            ApOutstanding     = apOutstanding,
            Last12Months      = monthly,
            ExpensesByCategory = expensesByAccount
        };
    }

    // GET: /Reports/BudgetVsActual
    /// <summary>
    /// Budget vs. Actual report: compares a budget's monthly line amounts against real P&amp;L activity
    /// for the same period. Actual figures come from the same GL queries used by the P&amp;L report.
    /// If no budgetId is specified, the default budget for the selected year is used automatically.
    /// </summary>
    public async Task<IActionResult> BudgetVsActual(int? budgetId, int? year)
    {
        if (!AllowAccounting()) return RedirectToAction(nameof(Landing));

        var companyId  = int.TryParse(User.FindFirst("CompanyId")?.Value, out var _bvaid) ? _bvaid : 0;
        var reportYear = year ?? DateTime.Now.Year;

        // Load all budgets for the year for the selector
        var allBudgets = (await _unitOfWork.Budgets.FindAsync(b => b.CompanyId == companyId && b.FiscalYear == reportYear))
            .OrderBy(b => b.Name).ToList();

        Core.Entities.Budget? budget = null;
        if (budgetId.HasValue)
            budget = await _unitOfWork.Budgets.GetByIdAsync(budgetId.Value, false, b => b.Lines);

        budget ??= (await _unitOfWork.Budgets.FindAsync(
            b => b.CompanyId == companyId && b.FiscalYear == reportYear && b.IsDefault, false, b => b.Lines)).FirstOrDefault();

        budget ??= (await _unitOfWork.Budgets.FindAsync(
            b => b.CompanyId == companyId && b.FiscalYear == reportYear, false, b => b.Lines)).FirstOrDefault();

        ViewBag.ReportYear    = reportYear;
        ViewBag.Budget        = budget;
        ViewBag.AllBudgets    = allBudgets;
        ViewBag.AvailableYears = Enumerable.Range(DateTime.Now.Year - 4, 6).OrderDescending().ToList();

        if (budget == null)
        {
            ViewBag.NoBudget = true;
            return View(new List<BudgetVsActualRow>());
        }

        // Fetch one P&L per month (12 calls); build a flat dict: accountId → decimal[12]
        var actualByAccount = new Dictionary<int, decimal[]>();
        for (int m = 1; m <= 12; m++)
        {
            var mFrom = new DateTime(reportYear, m, 1);
            var mTo   = new DateTime(reportYear, m, DateTime.DaysInMonth(reportYear, m));
            var mpl   = await _financialReports.GetProfitAndLossAsync(companyId, mFrom, mTo);

            var allPLLines = mpl.RevenueLines
                .Concat(mpl.CogsLines)
                .Concat(mpl.ExpenseLines);

            foreach (var pll in allPLLines)
            {
                if (!actualByAccount.ContainsKey(pll.AccountId))
                    actualByAccount[pll.AccountId] = new decimal[12];
                actualByAccount[pll.AccountId][m - 1] = pll.Amount;
            }
        }

        // Load account metadata for budget lines
        var accountIds = budget.Lines.Select(l => l.AccountId).Distinct().ToList();
        var accounts   = (await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == budget.CompanyId && accountIds.Contains(a.Id)))
            .ToDictionary(a => a.Id);

        var rows = new List<BudgetVsActualRow>();
        foreach (var line in budget.Lines)
        {
            if (!accounts.TryGetValue(line.AccountId, out var acct)) continue;

            actualByAccount.TryGetValue(line.AccountId, out var monthlyActuals);
            monthlyActuals ??= new decimal[12];

            rows.Add(new BudgetVsActualRow
            {
                AccountId     = acct.Id,
                AccountNumber = acct.AccountNumber,
                AccountName   = acct.Name,
                AccountType   = acct.AccountType,
                BudgetMonths  = new[] { line.Jan, line.Feb, line.Mar, line.Apr, line.May, line.Jun, line.Jul, line.Aug, line.Sep, line.Oct, line.Nov, line.Dec },
                ActualMonths  = monthlyActuals
            });
        }

        return View(rows.OrderBy(r => r.AccountNumber).ToList());
    }

    // GET: /Reports/TaxReporting1099
    /// <summary>
    /// 1099-NEC report: sums all bill payments + expenses paid to vendors marked Is1099Vendor=true
    /// for the selected calendar year. Flags vendors that exceed the $600 reporting threshold.
    /// </summary>
    public async Task<IActionResult> TaxReporting1099(int? year)
    {
        if (!AllowAccounting()) return RedirectToAction(nameof(Landing));

        var companyId = int.TryParse(User.FindFirst("CompanyId")?.Value, out var _rcid) ? _rcid : 0;
        var reportYear = year ?? DateTime.Now.Year;

        var periodStart = new DateTime(reportYear, 1, 1, 0, 0, 0, DateTimeKind.Utc);
        var periodEnd   = new DateTime(reportYear, 12, 31, 23, 59, 59, DateTimeKind.Utc);

        // Load 1099-eligible vendors
        var vendors = (await _unitOfWork.Vendors.FindAsync(v => v.CompanyId == companyId && v.Is1099Vendor)).ToList();

        var rows = new List<Vendor1099Row>();

        foreach (var vendor in vendors)
        {
            // Sum bills paid (using bill payment records) within the year
            var bills = await _unitOfWork.Bills.FindAsync(
                b => b.VendorId == vendor.Id,
                false,
                b => b.Payments);

            decimal billPaid = bills
                .SelectMany(b => b.Payments)
                .Where(p => p.PaymentDate >= periodStart && p.PaymentDate <= periodEnd)
                .Sum(p => p.Amount);

            // Sum direct expenses for this vendor within the year
            var expenses = await _unitOfWork.Expenses.FindAsync(
                e => e.VendorId == vendor.Id && e.Date >= periodStart && e.Date <= periodEnd);

            decimal expensePaid = expenses.Sum(e => e.Amount);

            var total = billPaid + expensePaid;

            rows.Add(new Vendor1099Row
            {
                VendorId    = vendor.Id,
                VendorName  = vendor.CompanyName,
                TaxId       = vendor.TaxId,
                Address     = string.Join(", ", new[] { vendor.Address, vendor.City, vendor.State, vendor.ZipCode }
                                               .Where(s => !string.IsNullOrWhiteSpace(s))),
                BillsPaid   = billPaid,
                ExpensesPaid = expensePaid,
                TotalPaid   = total,
                NeedsForm   = total >= 600m
            });
        }

        ViewBag.ReportYear = reportYear;
        ViewBag.AvailableYears = Enumerable.Range(DateTime.Now.Year - 5, 6).OrderDescending().ToList();
        ViewBag.VendorsOver600 = rows.Count(r => r.NeedsForm);

        return View(rows.OrderByDescending(r => r.TotalPaid).ToList());
    }

    // =========================================================================
    // Timeclock / Attendance
    // =========================================================================

    /// <summary>
    /// Attendance report: daily punch detail and weekly summaries per employee.
    /// Managers can see all employees; Workers see their own history only.
    /// </summary>
    [HttpGet]
    public async Task<IActionResult> Attendance(string? mode, string? week, string? month)
    {
        var currentUser = await _userManager.GetUserAsync(User);
        if (currentUser == null) return Forbid();

        var companyId = currentUser.CompanyId;
        var isManager = currentUser.CompanyRole is "CompanyAdmin" or "Manager";

        var period = ResolveAttendancePeriod(mode, week, month);

        var entries = await _unitOfWork.EmployeeClockEntries.FindAsync(
            e => e.CompanyId == companyId
              && e.ClockInTime >= period.Start
              && e.ClockInTime < period.End
              && (isManager || e.UserId == currentUser.Id),
            false,
            e => e.User);

        var grouped = entries
            .GroupBy(e => e.UserId)
            .Select(g => new AttendanceEmployeeRow
            {
                UserId      = g.Key,
                DisplayName = g.First().User?.FullName ?? "Unknown",
                // Only Work segments count toward paid hours
                TotalHours  = Math.Round(g.Where(e => e.EntryType == PowderCoating.Core.Enums.ClockEntryType.Work).Sum(e => e.HoursWorked ?? 0), 2),
                Days        = g.GroupBy(e => e.ClockInTime.Date)
                               .OrderByDescending(d => d.Key)
                               .Select(d => new AttendanceDayRow
                               {
                                   Date     = d.Key,
                                   DayTotal = Math.Round(d.Where(e => e.EntryType == PowderCoating.Core.Enums.ClockEntryType.Work).Sum(e => e.HoursWorked ?? 0), 2),
                                   Segments = d.OrderBy(e => e.ClockInTime).ToList()
                               }).ToList()
            })
            .OrderBy(r => r.DisplayName)
            .ToList();

        ViewBag.Period    = period;
        ViewBag.IsManager = isManager;
        return View(grouped);
    }

    /// <summary>
    /// Exports the attendance data for the selected date range as a CSV file suitable for
    /// import into payroll software. Each row is one clock segment (punch pair); employee name,
    /// date, clock in/out times, segment hours, day total, and week total are all repeated on
    /// every row so the file is fully self-contained in flat-file format.
    /// </summary>
    [HttpGet]
    public async Task<IActionResult> AttendanceCsv(string? mode, string? week, string? month)
    {
        var currentUser = await _userManager.GetUserAsync(User);
        if (currentUser == null) return Forbid();

        var companyId = currentUser.CompanyId;
        var isManager = currentUser.CompanyRole is "CompanyAdmin" or "Manager";

        var period = ResolveAttendancePeriod(mode, week, month);
        var start  = period.Start;
        var end    = period.End;

        var entries = await _unitOfWork.EmployeeClockEntries.FindAsync(
            e => e.CompanyId == companyId
              && e.ClockInTime >= start
              && e.ClockInTime < end
              && (isManager || e.UserId == currentUser.Id),
            false,
            e => e.User);

        // Build flat CSV rows sorted by employee name → date → clock-in time
        var rows = entries
            .OrderBy(e => e.User?.FullName ?? "")
            .ThenBy(e => e.ClockInTime)
            .ToList();

        // Day/week totals count only Work segments (Break/Lunch are unpaid)
        var workEntries = entries.Where(e => e.EntryType == PowderCoating.Core.Enums.ClockEntryType.Work).ToList();

        var dayTotals = workEntries
            .GroupBy(e => (e.UserId, e.ClockInTime.Date))
            .ToDictionary(g => g.Key, g => Math.Round(g.Sum(e => e.HoursWorked ?? 0), 2));

        // ISO week: group by (UserId, year, week number) where week starts Monday
        static int IsoWeek(DateTime d) =>
            System.Globalization.ISOWeek.GetWeekOfYear(d);

        var weekTotals = workEntries
            .GroupBy(e => (e.UserId, e.ClockInTime.Year, IsoWeek(e.ClockInTime)))
            .ToDictionary(g => g.Key, g => Math.Round(g.Sum(e => e.HoursWorked ?? 0), 2));

        var sb = new System.Text.StringBuilder();
        sb.AppendLine("Employee Name,Date,Day of Week,Type,Clock In,Clock Out,Segment Hours,Day Total Hours,Week Total Hours,Notes");

        foreach (var entry in rows)
        {
            var name      = CsvEscape(entry.User?.FullName ?? "Unknown");
            var date      = entry.ClockInTime.ToLocalTime().ToString("yyyy-MM-dd");
            var dow       = entry.ClockInTime.ToLocalTime().DayOfWeek.ToString();
            var entryType = entry.EntryType.ToString();  // Work, Break, or Lunch
            var clockIn   = entry.ClockInTime.ToLocalTime().ToString("h:mm tt");
            var clockOut  = entry.ClockOutTime.HasValue
                ? entry.ClockOutTime.Value.ToLocalTime().ToString("h:mm tt")
                : "In Progress";
            var segHrs    = entry.HoursWorked.HasValue
                ? entry.HoursWorked.Value.ToString("F2")
                : "";
            // Day/week totals are blank for Break/Lunch rows — they only appear on Work rows
            var isWork   = entry.EntryType == PowderCoating.Core.Enums.ClockEntryType.Work;
            var dayKey   = (entry.UserId, entry.ClockInTime.Date);
            var weekKey  = (entry.UserId, entry.ClockInTime.Year, IsoWeek(entry.ClockInTime));
            var dayTotal = isWork && dayTotals.TryGetValue(dayKey,   out var d) ? d.ToString("F2") : "";
            var wkTotal  = isWork && weekTotals.TryGetValue(weekKey, out var w) ? w.ToString("F2") : "";
            var notes    = CsvEscape(entry.Notes ?? "");

            sb.AppendLine($"{name},{date},{dow},{entryType},{clockIn},{clockOut},{segHrs},{dayTotal},{wkTotal},{notes}");
        }

        var company   = await _unitOfWork.Companies.GetByIdAsync(companyId);
        var safeName  = (company?.CompanyName ?? "Company").Replace(" ", "_");
        var safePeriod = period.PeriodLabel.Replace(" ", "_").Replace("–", "-").Replace(",", "");
        var filename  = $"{safeName}_Attendance_{safePeriod}.csv";

        return File(System.Text.Encoding.UTF8.GetBytes(sb.ToString()), "text/csv", filename);
    }

    private static string CsvEscape(string value)
    {
        if (value.Contains(',') || value.Contains('"') || value.Contains('\n'))
            return $"\"{value.Replace("\"", "\"\"")}\"";
        return value;
    }

    // =========================================================================
    // Attendance period helpers
    // =========================================================================

    /// <summary>
    /// Resolves query start/end dates and picker display state from the attendance
    /// report's mode/week/month URL params. Defaults to the current ISO week.
    /// Shared by <see cref="Attendance"/> and <see cref="AttendanceCsv"/> so both
    /// actions always query exactly the same date range for a given URL.
    /// </summary>
    private static AttendancePeriod ResolveAttendancePeriod(string? mode, string? week, string? month)
    {
        if (mode?.ToLower() == "month")
        {
            var monthDate = month != null
                && DateTime.TryParseExact(month + "-01", "yyyy-MM-dd",
                   System.Globalization.CultureInfo.InvariantCulture,
                   System.Globalization.DateTimeStyles.None, out var md)
                ? md
                : new DateTime(DateTime.UtcNow.Year, DateTime.UtcNow.Month, 1);

            return new AttendancePeriod
            {
                Mode        = "month",
                Start       = monthDate,
                End         = monthDate.AddMonths(1),
                WeekValue   = IsoWeekValue(DateTime.UtcNow),
                MonthValue  = monthDate.ToString("yyyy-MM"),
                PeriodLabel = monthDate.ToString("MMMM yyyy")
            };
        }
        else
        {
            var weekStart = TryParseIsoWeek(week) ?? CurrentIsoWeekStart();
            var weekEnd   = weekStart.AddDays(6);

            var label = weekStart.Year != weekEnd.Year
                ? $"{weekStart:MMM d, yyyy} – {weekEnd:MMM d, yyyy}"
                : weekStart.Month != weekEnd.Month
                    ? $"Week of {weekStart:MMM d} – {weekEnd:MMM d, yyyy}"
                    : $"Week of {weekStart:MMMM d} – {weekEnd:d, yyyy}";

            return new AttendancePeriod
            {
                Mode        = "week",
                Start       = weekStart,
                End         = weekStart.AddDays(7),
                WeekValue   = IsoWeekValue(weekStart),
                MonthValue  = new DateTime(DateTime.UtcNow.Year, DateTime.UtcNow.Month, 1).ToString("yyyy-MM"),
                PeriodLabel = label
            };
        }
    }

    /// <summary>Returns the Monday of the current ISO week in UTC.</summary>
    private static DateTime CurrentIsoWeekStart()
    {
        var today = DateTime.UtcNow.Date;
        return System.Globalization.ISOWeek.ToDateTime(
            System.Globalization.ISOWeek.GetYear(today),
            System.Globalization.ISOWeek.GetWeekOfYear(today),
            DayOfWeek.Monday);
    }

    /// <summary>Formats a date as the ISO week string used by the HTML week picker (e.g. "2026-W22").</summary>
    private static string IsoWeekValue(DateTime d) =>
        $"{System.Globalization.ISOWeek.GetYear(d):D4}-W{System.Globalization.ISOWeek.GetWeekOfYear(d):D2}";

    /// <summary>Parses an ISO week string (e.g. "2026-W22") to the Monday of that week. Returns null on failure.</summary>
    private static DateTime? TryParseIsoWeek(string? value)
    {
        if (value == null) return null;
        var parts = value.Split("-W");
        if (parts.Length == 2
            && int.TryParse(parts[0], out int yr) && yr is >= 2020 and <= 2100
            && int.TryParse(parts[1], out int wk) && wk is >= 1 and <= 53)
        {
            try { return System.Globalization.ISOWeek.ToDateTime(yr, wk, DayOfWeek.Monday); }
            catch { /* invalid week number for that year */ }
        }
        return null;
    }

    /// <summary>
    /// Looks up the current tenant's company name from the CompanyId claim. Used to inject the
    /// company name into AI prompts so the generated text refers to the actual business, not a
    /// generic placeholder. Falls back to "Your Company" if the claim is missing or the company
    /// record is not found (prevents blank company names in AI output).
    /// </summary>
    private async Task<string> GetCompanyNameAsync()
    {
        var companyIdClaim = User.FindFirst("CompanyId")?.Value;
        if (companyIdClaim != null && int.TryParse(companyIdClaim, out int companyId))
        {
            var company = await _unitOfWork.Companies.GetByIdAsync(companyId);
            return company?.CompanyName ?? "Your Company";
        }
        return "Your Company";
    }
}

// View Models
public class AnalyticsDashboardViewModel
{
    // Overview KPIs
    public decimal TotalRevenue { get; set; }
    public int ActiveJobsCount { get; set; }
    public int ActiveCustomersCount { get; set; }
    public decimal QuoteWinRate { get; set; }
    public int TotalQuotes { get; set; }
    public int CompletedJobsThisMonth { get; set; }
    public decimal AverageJobValue { get; set; }
    public int AppointmentsThisMonth { get; set; }
    public double AverageJobDuration { get; set; }
    public decimal MonthOverMonthGrowth { get; set; }

    // Revenue Analytics
    public List<string> MonthLabels { get; set; } = new();
    public List<decimal> MonthlyRevenue { get; set; } = new();
    public List<int> MonthlyJobCount { get; set; } = new();
    public Dictionary<string, decimal> RevenueByCustomerType { get; set; } = new();
    public Dictionary<string, decimal> RevenueByPriority { get; set; } = new();
    public List<decimal> AverageOrderValueTrend { get; set; } = new();
    public List<TopCustomerItem> TopCustomers { get; set; } = new();

    // Operations Analytics
    public Dictionary<string, int> JobsByStatus { get; set; } = new();
    public Dictionary<string, int> ActiveJobsByPriority { get; set; } = new();
    public Dictionary<string, int> AppointmentsByType { get; set; } = new();
    public Dictionary<string, int> AppointmentsByStatus { get; set; } = new();
    public decimal AppointmentCompletionRate { get; set; }
    public Dictionary<string, int> AppointmentsByDayOfWeek { get; set; } = new();
    public List<WorkerStatsItem> WorkerStats { get; set; } = new();
    public Dictionary<string, int> EquipmentByStatus { get; set; } = new();
    public List<LowStockItem> LowStockItems { get; set; } = new();

    // Customer Analytics
    public List<int> NewCustomersPerMonth { get; set; } = new();
    public decimal CustomerRetentionRate { get; set; }
    public List<CustomerLifetimeValueItem> CustomerLifetimeValue { get; set; } = new();
    public Dictionary<string, int> QuotesByStatus { get; set; } = new();
    public QuoteConversionFunnel QuoteFunnel { get; set; } = new();
    public Dictionary<string, int> CatalogByCategory { get; set; } = new();

    // Financial Analytics
    public decimal TotalInvoiced { get; set; }
    public decimal TotalCollected { get; set; }
    public decimal TotalOutstanding { get; set; }
    public decimal TotalOverdue { get; set; }
    public int InvoicesOverdueCount { get; set; }
    public int InvoicesDraftCount { get; set; }
    public int InvoicesPaidCount { get; set; }
    public List<decimal> MonthlyInvoiced { get; set; } = new();
    public List<decimal> MonthlyCollected { get; set; } = new();
    public List<AgingBucketItem> AgingBuckets { get; set; } = new();
    public List<RecentPaymentItem> RecentPayments { get; set; } = new();
    public List<OutstandingCustomerItem> TopOutstandingCustomers { get; set; } = new();
    public double AvgDaysToPayment { get; set; }

    // Expense / AP Analytics
    public decimal TotalBilled { get; set; }
    public decimal TotalBillsPaid { get; set; }
    public decimal TotalApOutstanding { get; set; }
    public decimal TotalDirectExpenses { get; set; }
    public List<AgingBucketItem> ApAgingBuckets { get; set; } = new();
    public List<VendorSpendItem> VendorSpend { get; set; } = new();
    public List<ExpenseByAccountItem> ExpensesByAccount { get; set; } = new();
    public List<decimal> MonthlyBillsPaid { get; set; } = new();
    public List<decimal> MonthlyDirectExpenses { get; set; } = new();
    public List<decimal> PlRevenue { get; set; } = new();
    public List<decimal> PlExpenses { get; set; } = new();
    public List<decimal> PlNetIncome { get; set; } = new();

    // Customer Retention
    public List<CustomerRetentionItem> CustomerRetention { get; set; } = new();

    // Job Cycle Time
    public List<JobCycleTimeItem> JobCycleTime { get; set; } = new();
    public double OverallAvgCycleDays { get; set; }

    // Job Status Aging
    public List<JobStatusAgingItem> JobStatusAging { get; set; } = new();

    // Invoice Aging Detail
    public List<InvoiceAgingDetailItem> InvoiceAgingDetail { get; set; } = new();

    // Powder Consumption vs Purchase
    public List<PowderConsumptionItem> PowderConsumption { get; set; } = new();

    // Inventory Turnover
    public List<InventoryTurnoverItem> InventoryTurnover { get; set; } = new();

    // Sales by Customer
    public List<SalesByCustomerItem> SalesByCustomer { get; set; } = new();
    public decimal SalesByCustomerTotalInvoiced { get; set; }
    public decimal SalesByCustomerTotalPaid { get; set; }

    // Powder Usage Analytics
    public decimal TotalPowderUsedLbs { get; set; }
    public decimal TotalPowderCost { get; set; }
    public int TotalJobsWithPowderUsage { get; set; }
    public int ColorsUsedCount { get; set; }
    public List<PowderUsageByColorItem> TopColorsByUsage { get; set; } = new();
    public List<decimal> MonthlyPowderUsageLbs { get; set; } = new();
    public List<decimal> MonthlyPowderUsageCost { get; set; } = new();

    // Filters
    public int SelectedMonths { get; set; } = 6;
}

public class BudgetVsActualRow
{
    public int         AccountId     { get; set; }
    public string      AccountNumber { get; set; } = string.Empty;
    public string      AccountName   { get; set; } = string.Empty;
    public AccountType AccountType   { get; set; }
    public decimal[]   BudgetMonths  { get; set; } = new decimal[12];
    public decimal[]   ActualMonths  { get; set; } = new decimal[12];

    public decimal BudgetAnnual  => BudgetMonths.Sum();
    public decimal ActualAnnual  => ActualMonths.Sum();
    public decimal VarianceAnnual => ActualAnnual - BudgetAnnual;
}

public class Vendor1099Row
{
    public int     VendorId     { get; set; }
    public string  VendorName   { get; set; } = string.Empty;
    public string? TaxId        { get; set; }
    public string? Address      { get; set; }
    public decimal BillsPaid    { get; set; }
    public decimal ExpensesPaid { get; set; }
    public decimal TotalPaid    { get; set; }
    public bool    NeedsForm    { get; set; }
}

public class AttendanceEmployeeRow
{
    public string UserId      { get; set; } = string.Empty;
    public string DisplayName { get; set; } = string.Empty;
    public decimal TotalHours { get; set; }
    public List<AttendanceDayRow> Days { get; set; } = new();
}

public class AttendanceDayRow
{
    public DateTime Date     { get; set; }
    public decimal DayTotal  { get; set; }
    public List<PowderCoating.Core.Entities.EmployeeClockEntry> Segments { get; set; } = new();
}

/// <summary>Resolved date range and picker state for the attendance report.</summary>
public record AttendancePeriod
{
    public string Mode        { get; init; } = "week";
    public DateTime Start     { get; init; }
    /// <summary>Exclusive upper bound for EF queries (Start + 7 days or +1 month).</summary>
    public DateTime End       { get; init; }
    public string WeekValue   { get; init; } = "";   // "2026-W22"
    public string MonthValue  { get; init; } = "";   // "2026-05"
    public string PeriodLabel { get; init; } = "";
}

