using AutoMapper;
using PowderCoating.Shared.Constants;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using PowderCoating.Application.DTOs.Common;
using PowderCoating.Application.DTOs.Customer;
using PowderCoating.Application.Interfaces;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Enums;
using PowderCoating.Core.Interfaces;
using PowderCoating.Infrastructure.Data;
using PowderCoating.Web.Helpers;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.EntityFrameworkCore;

namespace PowderCoating.Web.Controllers;

[Authorize(Policy = AppConstants.Policies.CanManageCustomers)]
public class CustomersController : Controller
{
    private readonly IUnitOfWork _unitOfWork;
    private readonly IMapper _mapper;
    private readonly ILogger<CustomersController> _logger;
    private readonly INotificationService _notificationService;
    private readonly ISubscriptionService _subscriptionService;
    private readonly ITenantContext _tenantContext;
    private readonly UserManager<ApplicationUser> _userManager;
    private readonly IFinancialReportService _financialReports;

    public CustomersController(
        IUnitOfWork unitOfWork,
        IMapper mapper,
        ILogger<CustomersController> logger,
        INotificationService notificationService,
        ISubscriptionService subscriptionService,
        ITenantContext tenantContext,
        UserManager<ApplicationUser> userManager,
        IFinancialReportService financialReports)
    {
        _unitOfWork = unitOfWork;
        _mapper = mapper;
        _logger = logger;
        _notificationService = notificationService;
        _subscriptionService = subscriptionService;
        _tenantContext = tenantContext;
        _userManager = userManager;
        _financialReports = financialReports;
    }

    /// <summary>
    /// Displays the paginated, searchable customer list. Sanitizes the search term before
    /// building the EF filter expression to guard against injection; sorting is resolved
    /// server-side via a switch so the column name never reaches raw SQL.
    /// </summary>
    public async Task<IActionResult> Index(
        string? searchTerm,
        string? sortColumn,
        string sortDirection = "asc",
        int pageNumber = 1,
        int pageSize = 25)
    {
        try
        {
            // SECURITY: Sanitize search input to prevent injection attacks
            searchTerm = SecurityHelper.SanitizeSearchTerm(searchTerm);

            // Create and validate grid request
            var gridRequest = new GridRequest
            {
                PageNumber = pageNumber,
                PageSize = pageSize,
                SortColumn = sortColumn ?? "CompanyName",
                SortDirection = sortDirection,
                SearchTerm = searchTerm
            };
            gridRequest.Validate();

            // Build search filter
            System.Linq.Expressions.Expression<Func<Customer, bool>>? filter = null;
            if (!string.IsNullOrWhiteSpace(searchTerm))
            {
                var search = searchTerm.ToLower();
                filter = c => c.CompanyName.ToLower().Contains(search)
                    || (c.ContactFirstName != null && c.ContactFirstName.ToLower().Contains(search))
                    || (c.ContactLastName != null && c.ContactLastName.ToLower().Contains(search))
                    || (c.Email != null && c.Email.ToLower().Contains(search))
                    || (c.Phone != null && c.Phone.ToLower().Contains(search));
            }

            // Build orderBy function
            Func<IQueryable<Customer>, IOrderedQueryable<Customer>> orderBy = gridRequest.SortColumn switch
            {
                "CompanyName" => q => gridRequest.SortDirection == "asc"
                    ? q.OrderBy(c => c.CompanyName ?? c.ContactLastName)
                    : q.OrderByDescending(c => c.CompanyName ?? c.ContactLastName),
                "ContactName" => q => gridRequest.SortDirection == "asc"
                    ? q.OrderBy(c => c.ContactFirstName).ThenBy(c => c.ContactLastName)
                    : q.OrderByDescending(c => c.ContactFirstName).ThenByDescending(c => c.ContactLastName),
                "Email" => q => gridRequest.SortDirection == "asc" ? q.OrderBy(c => c.Email) : q.OrderByDescending(c => c.Email),
                "Phone" => q => gridRequest.SortDirection == "asc" ? q.OrderBy(c => c.Phone) : q.OrderByDescending(c => c.Phone),
                "CurrentBalance" => q => gridRequest.SortDirection == "asc" ? q.OrderBy(c => c.CurrentBalance) : q.OrderByDescending(c => c.CurrentBalance),
                "IsActive" => q => gridRequest.SortDirection == "asc" ? q.OrderBy(c => c.IsActive) : q.OrderByDescending(c => c.IsActive),
                "LastContactDate" => q => gridRequest.SortDirection == "asc" ? q.OrderBy(c => c.LastContactDate) : q.OrderByDescending(c => c.LastContactDate),
                _ => q => q.OrderBy(c => c.CompanyName ?? c.ContactLastName)
            };

            // Get paged data
            var (items, totalCount) = await _unitOfWork.Customers.GetPagedAsync(
                gridRequest.PageNumber,
                gridRequest.PageSize,
                filter,
                orderBy);

            // Map to DTOs
            var customerDtos = items.Select(c => new CustomerListDto
            {
                Id = c.Id,
                CompanyName = c.CompanyName,
                ContactName = !string.IsNullOrEmpty(c.ContactFirstName) || !string.IsNullOrEmpty(c.ContactLastName)
                    ? $"{c.ContactFirstName} {c.ContactLastName}".Trim()
                    : string.Empty,
                Phone = c.Phone,
                Email = c.Email,
                IsCommercial = c.IsCommercial,
                CurrentBalance = c.CurrentBalance,
                IsActive = c.IsActive,
                LastContactDate = c.LastContactDate
            }).ToList();

            var pagedResult = PagedResult<CustomerListDto>.From(gridRequest, customerDtos, totalCount);

            // Set ViewBag for sorting
            ViewBag.SearchTerm = searchTerm;
            ViewBag.SortColumn = gridRequest.SortColumn;
            ViewBag.SortDirection = gridRequest.SortDirection;

            return View(pagedResult);
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Error retrieving customers");
            this.ToastError("An error occurred while loading customers.");
            return View(new PagedResult<CustomerListDto>());
        }
    }

    /// <summary>
    /// Renders the customer detail page. In addition to basic info and credit memos, runs
    /// four sequential queries (jobs, quotes, invoices, deposits) to build:
    /// (1) <see cref="CustomerLifetimeStatsDto"/> — aggregate KPIs for the stats card
    /// (2) <see cref="CustomerTimelineEventDto"/> list — last 15 events for the activity feed
    /// Credit memos are loaded separately because the Customer aggregate does not navigate to them.
    /// </summary>
    public async Task<IActionResult> Details(int? id)
    {
        if (id == null)
        {
            return NotFound();
        }

        try
        {
            var customer = await _unitOfWork.Customers.GetByIdAsync(id.Value);
            if (customer == null)
            {
                return NotFound();
            }

            var creditMemos = await _unitOfWork.CreditMemos.FindAsync(
                cm => cm.CustomerId == id.Value && cm.Status != CreditMemoStatus.Voided);
            ViewBag.CreditMemos = creditMemos
                .OrderByDescending(cm => cm.IssueDate)
                .Take(10)
                .ToList();

            // CRM queries — must be sequential; EF Core's DbContext is not thread-safe
            var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
            var jobs     = (await _unitOfWork.Jobs.FindAsync(j => j.CustomerId == id.Value && j.CompanyId == companyId, false, j => j.JobStatus)).ToList();
            var quotes   = (await _unitOfWork.Quotes.FindAsync(q => q.CustomerId == id.Value && q.CompanyId == companyId, false, q => q.QuoteStatus)).ToList();
            var invoices = (await _unitOfWork.Invoices.FindAsync(i => i.CustomerId == id.Value && i.CompanyId == companyId)).ToList();
            var deposits = (await _unitOfWork.Deposits.FindAsync(d => d.CustomerId == id.Value && d.CompanyId == companyId)).ToList();

            var pendingPickups = (await _unitOfWork.Jobs.FindAsync(
                    j => j.CustomerId == id.Value && j.CompanyId == companyId
                         && j.JobStatus.StatusCode == AppConstants.StatusCodes.Job.ReadyForPickup,
                    false, j => j.JobStatus))
                .OrderBy(j => j.UpdatedAt)
                .ToList();
            ViewBag.PendingPickups = pendingPickups;

            var customerNotes = (await _unitOfWork.CustomerNotes.FindAsync(n => n.CustomerId == id.Value))
                .OrderByDescending(n => n.CreatedAt)
                .ToList();
            ViewBag.CustomerNotes = customerNotes;

            var preferredPowders = (await _unitOfWork.CustomerPreferredPowders.FindAsync(
                    p => p.CustomerId == id.Value, false, p => p.InventoryItem))
                .ToList();
            ViewBag.PreferredPowders = preferredPowders;

            var customerContacts = (await _unitOfWork.CustomerContacts.FindAsync(n => n.CustomerId == id.Value))
                .OrderBy(c => c.FirstName)
                .ToList();
            ViewBag.CustomerContacts = customerContacts;

            // Stats
            var nonVoided = invoices.Where(i => i.Status != InvoiceStatus.Voided).ToList();
            var stats = new CustomerLifetimeStatsDto
            {
                TotalJobs       = jobs.Count,
                ActiveJobs      = jobs.Count(j => j.JobStatus != null && !j.JobStatus.IsTerminalStatus),
                TotalRevenue    = nonVoided.Sum(i => i.Total),
                TotalCollected  = nonVoided.Sum(i => i.AmountPaid),
                AverageJobValue = jobs.Count > 0 ? jobs.Average(j => j.FinalPrice) : 0,
                LastJobDate     = jobs.Count > 0 ? jobs.Max(j => (DateTime?)j.CreatedAt) : null,
                LastJobId       = jobs.Count > 0 ? jobs.OrderByDescending(j => j.CreatedAt).First().Id : (int?)null,
                TotalQuotes     = quotes.Count,
                TotalInvoices   = invoices.Count,
                OpenBalance     = customer.CurrentBalance
            };
            stats.DaysSinceLastJob = stats.LastJobDate.HasValue
                ? (int)(DateTime.UtcNow - stats.LastJobDate.Value).TotalDays
                : null;

            // Timeline: merge all event types, sort descending, cap at 15
            var events = new List<CustomerTimelineEventDto>();

            foreach (var j in jobs)
                events.Add(new CustomerTimelineEventDto
                {
                    Date           = j.CreatedAt,
                    Icon           = "bi-briefcase",
                    BadgeColor     = "primary",
                    Title          = $"Job {j.JobNumber}",
                    Subtitle       = j.Description,
                    Amount         = j.FinalPrice > 0 ? j.FinalPrice : null,
                    EntityId       = j.Id,
                    LinkController = "Jobs",
                    LinkAction     = "Details"
                });

            foreach (var q in quotes)
                events.Add(new CustomerTimelineEventDto
                {
                    Date           = q.QuoteDate,
                    Icon           = "bi-file-text",
                    BadgeColor     = "info",
                    Title          = $"Quote {q.QuoteNumber}",
                    Subtitle       = q.QuoteStatus?.DisplayName,
                    Amount         = q.Total > 0 ? q.Total : null,
                    EntityId       = q.Id,
                    LinkController = "Quotes",
                    LinkAction     = "Details"
                });

            foreach (var inv in invoices)
                events.Add(new CustomerTimelineEventDto
                {
                    Date           = inv.InvoiceDate,
                    Icon           = inv.Status == InvoiceStatus.Paid ? "bi-receipt-cutoff" : "bi-receipt",
                    BadgeColor     = inv.Status == InvoiceStatus.Paid ? "success" : "warning",
                    Title          = $"Invoice {inv.InvoiceNumber}",
                    Subtitle       = inv.Status.ToString(),
                    Amount         = inv.Total,
                    EntityId       = inv.Id,
                    LinkController = "Invoices",
                    LinkAction     = "Details"
                });

            foreach (var dep in deposits)
                events.Add(new CustomerTimelineEventDto
                {
                    Date           = dep.ReceivedDate,
                    Icon           = "bi-cash-coin",
                    BadgeColor     = "success",
                    Title          = "Deposit received",
                    Subtitle       = dep.ReceiptNumber,
                    Amount         = dep.Amount,
                    EntityId       = dep.JobId,
                    LinkController = dep.JobId.HasValue ? "Jobs" : null,
                    LinkAction     = dep.JobId.HasValue ? "Details" : null
                });

            ViewBag.CrmStats = stats;
            ViewBag.Timeline = events
                .OrderByDescending(e => e.Date)
                .Take(15)
                .ToList();

            var customerDto = _mapper.Map<CustomerDto>(customer);
            return View(customerDto);
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Error retrieving customer {CustomerId}", id);
            this.ToastError("An error occurred while loading the customer.");
            return RedirectToAction(nameof(Index));
        }
    }

    /// <summary>
    /// Shows the full paginated job history for a single customer. Defaults to descending
    /// by creation date so the newest jobs appear first. JobStatus and JobPriority are
    /// eagerly loaded so their display names and sort orders are available without N+1 queries.
    /// </summary>
    public async Task<IActionResult> JobHistory(
        int? id,
        string? searchTerm,
        string? sortColumn,
        string sortDirection = "desc",
        int pageNumber = 1,
        int pageSize = 25)
    {
        if (id == null)
        {
            return NotFound();
        }

        try
        {
            var customer = await _unitOfWork.Customers.GetByIdAsync(id.Value);
            if (customer == null)
            {
                return NotFound();
            }

            var gridRequest = new GridRequest
            {
                PageNumber = pageNumber,
                PageSize = pageSize,
                SortColumn = sortColumn ?? "CreatedAt",
                SortDirection = sortDirection,
                SearchTerm = searchTerm
            };
            gridRequest.Validate();

            System.Linq.Expressions.Expression<Func<Core.Entities.Job, bool>> filter;
            if (!string.IsNullOrWhiteSpace(searchTerm))
            {
                var search = searchTerm.ToLower();
                filter = j => j.CustomerId == id.Value
                    && (j.JobNumber.ToLower().Contains(search)
                        || j.Description.ToLower().Contains(search)
                        || (j.CustomerPO != null && j.CustomerPO.ToLower().Contains(search))
                        || j.JobStatus.DisplayName.ToLower().Contains(search)
                        || j.JobPriority.DisplayName.ToLower().Contains(search));
            }
            else
            {
                filter = j => j.CustomerId == id.Value;
            }

            Func<IQueryable<Core.Entities.Job>, IOrderedQueryable<Core.Entities.Job>> orderBy = gridRequest.SortColumn switch
            {
                "JobNumber" => q => gridRequest.SortDirection == "asc" ? q.OrderBy(j => j.JobNumber) : q.OrderByDescending(j => j.JobNumber),
                "Status" => q => gridRequest.SortDirection == "asc" ? q.OrderBy(j => j.JobStatus.DisplayOrder) : q.OrderByDescending(j => j.JobStatus.DisplayOrder),
                "Priority" => q => gridRequest.SortDirection == "asc" ? q.OrderBy(j => j.JobPriority.DisplayOrder) : q.OrderByDescending(j => j.JobPriority.DisplayOrder),
                "ScheduledDate" => q => gridRequest.SortDirection == "asc" ? q.OrderBy(j => j.ScheduledDate) : q.OrderByDescending(j => j.ScheduledDate),
                "DueDate" => q => gridRequest.SortDirection == "asc" ? q.OrderBy(j => j.DueDate) : q.OrderByDescending(j => j.DueDate),
                "FinalPrice" => q => gridRequest.SortDirection == "asc" ? q.OrderBy(j => j.FinalPrice) : q.OrderByDescending(j => j.FinalPrice),
                _ => q => q.OrderByDescending(j => j.CreatedAt)
            };

            var (items, totalCount) = await _unitOfWork.Jobs.GetPagedAsync(
                gridRequest.PageNumber,
                gridRequest.PageSize,
                filter,
                orderBy,
                j => j.JobStatus,
                j => j.JobPriority);

            // Use AutoMapper to map jobs to DTOs
            var jobDtos = _mapper.Map<List<Application.DTOs.Job.JobListDto>>(items);

            var pagedResult = new Application.DTOs.Common.PagedResult<Application.DTOs.Job.JobListDto>
            {
                Items = jobDtos,
                PageNumber = gridRequest.PageNumber,
                PageSize = gridRequest.PageSize,
                TotalCount = totalCount
            };

            ViewBag.CustomerId = id.Value;
            ViewBag.CustomerName = !string.IsNullOrWhiteSpace(customer.CompanyName)
                ? customer.CompanyName
                : $"{customer.ContactFirstName} {customer.ContactLastName}".Trim();
            ViewBag.SearchTerm = searchTerm;
            ViewBag.SortColumn = gridRequest.SortColumn;
            ViewBag.SortDirection = gridRequest.SortDirection;

            return View(pagedResult);
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Error retrieving job history for customer {CustomerId}", id);
            this.ToastError("An error occurred while loading the job history.");
            return RedirectToAction(nameof(Details), new { id });
        }
    }

    /// <summary>
    /// Renders the customer activity tab-panel, showing jobs and quotes in independent
    /// paginated grids. Each grid carries its own sort/page state in the query string so
    /// both can be navigated without resetting the other. Page sizes are clamped to
    /// [10, 100] server-side to prevent accidental full-table loads.
    /// </summary>
    public async Task<IActionResult> Activity(
        int? id,
        string activeTab = "jobs",
        string jobSort = "CreatedAt",
        string jobDir = "desc",
        int jobPage = 1,
        int jobSize = 25,
        string quoteSort = "QuoteDate",
        string quoteDir = "desc",
        int quotePage = 1,
        int quoteSize = 25)
    {
        if (id == null)
        {
            return NotFound();
        }

        try
        {
            var customer = await _unitOfWork.Customers.GetByIdAsync(id.Value);
            if (customer == null)
            {
                return NotFound();
            }

            var customerName = !string.IsNullOrWhiteSpace(customer.CompanyName)
                ? customer.CompanyName
                : $"{customer.ContactFirstName} {customer.ContactLastName}".Trim();

            // Clamp page sizes
            jobSize = Math.Clamp(jobSize, 10, 100);
            quoteSize = Math.Clamp(quoteSize, 10, 100);
            if (jobPage < 1) jobPage = 1;
            if (quotePage < 1) quotePage = 1;

            // --- Jobs ---
            Func<IQueryable<Core.Entities.Job>, IOrderedQueryable<Core.Entities.Job>> jobOrderBy = jobSort switch
            {
                "JobNumber" => q => jobDir == "asc" ? q.OrderBy(j => j.JobNumber) : q.OrderByDescending(j => j.JobNumber),
                "Status"    => q => jobDir == "asc" ? q.OrderBy(j => j.JobStatus.DisplayOrder) : q.OrderByDescending(j => j.JobStatus.DisplayOrder),
                "Priority"  => q => jobDir == "asc" ? q.OrderBy(j => j.JobPriority.DisplayOrder) : q.OrderByDescending(j => j.JobPriority.DisplayOrder),
                "DueDate"   => q => jobDir == "asc" ? q.OrderBy(j => j.DueDate) : q.OrderByDescending(j => j.DueDate),
                "FinalPrice"=> q => jobDir == "asc" ? q.OrderBy(j => j.FinalPrice) : q.OrderByDescending(j => j.FinalPrice),
                _           => q => jobDir == "asc" ? q.OrderBy(j => j.CreatedAt) : q.OrderByDescending(j => j.CreatedAt)
            };

            var (jobItems, jobTotal) = await _unitOfWork.Jobs.GetPagedAsync(
                jobPage, jobSize,
                j => j.CustomerId == id.Value,
                jobOrderBy,
                j => j.JobStatus,
                j => j.JobPriority);

            var jobDtos = _mapper.Map<List<Application.DTOs.Job.JobListDto>>(jobItems);
            var pagedJobs = new Application.DTOs.Common.PagedResult<Application.DTOs.Job.JobListDto>
            {
                Items = jobDtos,
                PageNumber = jobPage,
                PageSize = jobSize,
                TotalCount = jobTotal
            };

            // --- Quotes ---
            Func<IQueryable<Core.Entities.Quote>, IOrderedQueryable<Core.Entities.Quote>> quoteOrderBy = quoteSort switch
            {
                "QuoteNumber" => q => quoteDir == "asc" ? q.OrderBy(x => x.QuoteNumber) : q.OrderByDescending(x => x.QuoteNumber),
                "Status"      => q => quoteDir == "asc" ? q.OrderBy(x => x.QuoteStatus.DisplayOrder) : q.OrderByDescending(x => x.QuoteStatus.DisplayOrder),
                "Total"       => q => quoteDir == "asc" ? q.OrderBy(x => x.Total) : q.OrderByDescending(x => x.Total),
                "Expiration"  => q => quoteDir == "asc" ? q.OrderBy(x => x.ExpirationDate) : q.OrderByDescending(x => x.ExpirationDate),
                _             => q => quoteDir == "asc" ? q.OrderBy(x => x.QuoteDate) : q.OrderByDescending(x => x.QuoteDate)
            };

            var (quoteItems, quoteTotal) = await _unitOfWork.Quotes.GetPagedAsync(
                quotePage, quoteSize,
                q => q.CustomerId == id.Value,
                quoteOrderBy,
                q => q.QuoteStatus);

            var quoteDtos = _mapper.Map<List<Application.DTOs.Quote.QuoteListDto>>(quoteItems);
            var pagedQuotes = new Application.DTOs.Common.PagedResult<Application.DTOs.Quote.QuoteListDto>
            {
                Items = quoteDtos,
                PageNumber = quotePage,
                PageSize = quoteSize,
                TotalCount = quoteTotal
            };

            ViewBag.CustomerId = id.Value;
            ViewBag.CustomerName = customerName;
            ViewBag.ActiveTab = activeTab;
            ViewBag.Jobs = pagedJobs;
            ViewBag.JobSort = jobSort;
            ViewBag.JobDir = jobDir;
            ViewBag.Quotes = pagedQuotes;
            ViewBag.QuoteSort = quoteSort;
            ViewBag.QuoteDir = quoteDir;

            return View();
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Error retrieving activity for customer {CustomerId}", id);
            this.ToastError("An error occurred while loading customer activity.");
            return RedirectToAction(nameof(Details), new { id });
        }
    }

    /// <summary>
    /// Displays the paginated invoice list scoped to a single customer. Defaults to
    /// descending invoice date so the most recent invoice is shown first, matching the
    /// typical accounts-receivable workflow of reviewing the latest outstanding balance.
    /// </summary>
    public async Task<IActionResult> Invoices(
        int? id,
        string? sortColumn,
        string sortDirection = "desc",
        int pageNumber = 1,
        int pageSize = 25)
    {
        if (id == null)
        {
            return NotFound();
        }

        try
        {
            var customer = await _unitOfWork.Customers.GetByIdAsync(id.Value);
            if (customer == null)
            {
                return NotFound();
            }

            var gridRequest = new GridRequest
            {
                PageNumber = pageNumber,
                PageSize = pageSize,
                SortColumn = sortColumn ?? "InvoiceDate",
                SortDirection = sortDirection
            };
            gridRequest.Validate();

            Func<IQueryable<Core.Entities.Invoice>, IOrderedQueryable<Core.Entities.Invoice>> orderBy = gridRequest.SortColumn switch
            {
                "InvoiceNumber" => q => gridRequest.SortDirection == "asc" ? q.OrderBy(i => i.InvoiceNumber) : q.OrderByDescending(i => i.InvoiceNumber),
                "Status"        => q => gridRequest.SortDirection == "asc" ? q.OrderBy(i => i.Status) : q.OrderByDescending(i => i.Status),
                "DueDate"       => q => gridRequest.SortDirection == "asc" ? q.OrderBy(i => i.DueDate) : q.OrderByDescending(i => i.DueDate),
                "Total"         => q => gridRequest.SortDirection == "asc" ? q.OrderBy(i => i.Total) : q.OrderByDescending(i => i.Total),
                "BalanceDue"    => q => gridRequest.SortDirection == "asc" ? q.OrderBy(i => i.BalanceDue) : q.OrderByDescending(i => i.BalanceDue),
                _               => q => gridRequest.SortDirection == "asc" ? q.OrderBy(i => i.InvoiceDate) : q.OrderByDescending(i => i.InvoiceDate)
            };

            var (items, totalCount) = await _unitOfWork.Invoices.GetPagedAsync(
                gridRequest.PageNumber,
                gridRequest.PageSize,
                i => i.CustomerId == id.Value,
                orderBy);

            var invoiceDtos = _mapper.Map<List<Application.DTOs.Invoice.InvoiceListDto>>(items);

            var pagedResult = new Application.DTOs.Common.PagedResult<Application.DTOs.Invoice.InvoiceListDto>
            {
                Items = invoiceDtos,
                PageNumber = gridRequest.PageNumber,
                PageSize = gridRequest.PageSize,
                TotalCount = totalCount
            };

            ViewBag.CustomerId = id.Value;
            ViewBag.CustomerName = !string.IsNullOrWhiteSpace(customer.CompanyName)
                ? customer.CompanyName
                : $"{customer.ContactFirstName} {customer.ContactLastName}".Trim();
            ViewBag.SortColumn = gridRequest.SortColumn;
            ViewBag.SortDirection = gridRequest.SortDirection;

            return View(pagedResult);
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Error retrieving invoices for customer {CustomerId}", id);
            this.ToastError("An error occurred while loading the invoices.");
            return RedirectToAction(nameof(Details), new { id });
        }
    }

    /// <summary>
    /// Renders the customer creation form. Checks the subscription plan limit before
    /// allowing access; redirects with an error if the company has reached its customer cap.
    /// Pricing tiers are pre-loaded via <see cref="PopulatePricingTiersAsync"/> so the
    /// dropdown is available immediately without a round-trip.
    /// </summary>
    public async Task<IActionResult> Create()
    {
        var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
        if (!await _subscriptionService.CanAddCustomerAsync(companyId))
        {
            var (used, max) = await _subscriptionService.GetCustomerCountAsync(companyId);
            TempData["Error"] = $"You have reached your plan limit of {max} customers. " +
                "Please upgrade your plan to add more customers.";
            return RedirectToAction(nameof(Index));
        }

        await PopulatePricingTiersAsync();
        return View();
    }

    /// <summary>
    /// Persists a new customer record. Re-validates the subscription plan limit on POST
    /// to guard against concurrent session exploitation. If the staff member records
    /// verbal SMS consent, sets NotifyBySms, stamps SmsConsentedAt, and immediately
    /// sends a confirmation SMS via <see cref="INotificationService"/>; the resulting
    /// notification log entry is then surfaced as a toast so staff can confirm delivery.
    /// </summary>
    [HttpPost]
    [ValidateAntiForgeryToken]
    public async Task<IActionResult> Create(CreateCustomerDto dto)
    {
        if (!ModelState.IsValid)
        {
            await PopulatePricingTiersAsync();
            return View(dto);
        }

        // Subscription limit check
        var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
        if (!await _subscriptionService.CanAddCustomerAsync(companyId))
        {
            var (used, max) = await _subscriptionService.GetCustomerCountAsync(companyId);
            ModelState.AddModelError(string.Empty,
                $"You have reached your plan limit of {max} customers. " +
                "Please upgrade your plan to add more customers.");
            await PopulatePricingTiersAsync();
            return View(dto);
        }

        try
        {
            var customer = _mapper.Map<Customer>(dto);
            customer.CreatedAt = DateTime.UtcNow;
            customer.IsActive = true;

            // SMS consent: only enable if staff has checked the consent box
            if (dto.SmsConsentGranted && !string.IsNullOrWhiteSpace(dto.MobilePhone ?? dto.Phone))
            {
                customer.NotifyBySms = true;
                customer.SmsConsentedAt = DateTime.UtcNow;
                customer.SmsConsentMethod = "Staff-recorded verbal consent";
            }
            else
            {
                customer.NotifyBySms = false;
            }

            await _unitOfWork.Customers.AddAsync(customer);
            await _unitOfWork.SaveChangesAsync();

            // Send welcome/confirmation SMS after the customer record is saved
            if (customer.NotifyBySms)
            {
                try { await _notificationService.NotifySmsConsentGrantedAsync(customer); }
                catch (Exception ex) { _logger.LogWarning(ex, "SMS consent confirmation failed for customer {Id}", customer.Id); }

                var logs = await _unitOfWork.NotificationLogs.FindAsync(
                    n => n.CustomerId == customer.Id, ignoreQueryFilters: true);
                var smsLog = logs.OrderByDescending(n => n.SentAt).FirstOrDefault();
                this.SetNotificationResultToast(smsLog);
            }

            this.ToastSuccess("Customer created successfully.");
            return RedirectToAction(nameof(Index));
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Error creating customer");
            this.ToastError("An error occurred while creating the customer.");
            return View(dto);
        }
    }

    /// <summary>
    /// Renders the customer edit form, pre-populated via AutoMapper from the stored entity.
    /// Pricing tiers are loaded so the dropdown reflects current active tiers even if
    /// the tier list has changed since the customer was created.
    /// </summary>
    public async Task<IActionResult> Edit(int? id)
    {
        if (id == null)
        {
            return NotFound();
        }

        try
        {
            var customer = await _unitOfWork.Customers.GetByIdAsync(id.Value);
            if (customer == null)
            {
                return NotFound();
            }

            var dto = _mapper.Map<UpdateCustomerDto>(customer);
            await PopulatePricingTiersAsync();
            return View(dto);
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Error retrieving customer {CustomerId} for edit", id);
            this.ToastError("An error occurred while loading the customer.");
            return RedirectToAction(nameof(Index));
        }
    }

    /// <summary>
    /// Persists edits to an existing customer. Special handling covers two sensitive fields:
    /// (1) Tax-exempt certificate bytes are captured before AutoMapper runs and restored after,
    ///     preventing the Edit form from inadvertently clearing an already-uploaded file.
    /// (2) SMS consent is append-only: newly granted consent stamps SmsConsentedAt and sends
    ///     a confirmation SMS; existing consent is not re-granted, but NotifyBySms can be
    ///     toggled on/off after the first grant to pause or resume messages.
    /// </summary>
    [HttpPost]
    [ValidateAntiForgeryToken]
    public async Task<IActionResult> Edit(int id, UpdateCustomerDto dto)
    {
        if (id != dto.Id)
        {
            return NotFound();
        }

        if (!ModelState.IsValid)
        {
            await PopulatePricingTiersAsync();
            return View(dto);
        }

        try
        {
            var customer = await _unitOfWork.Customers.GetByIdAsync(id);
            if (customer == null)
            {
                return NotFound();
            }

            // Capture consent state before mapping (mapping ignores SmsConsentedAt/Method)
            var hadConsentBefore = customer.SmsConsentedAt.HasValue;

            // Preserve cert data before mapping (mapper ignores these, but save explicitly for safety)
            var certData = customer.TaxExemptCertificateData;
            var certContentType = customer.TaxExemptCertificateContentType;
            var certFileName = customer.TaxExemptCertificateFileName;

            _mapper.Map(dto, customer);
            customer.UpdatedAt = DateTime.UtcNow;

            // Restore cert data (defensive: ensure Edit form never clears uploaded certificates)
            customer.TaxExemptCertificateData = certData;
            customer.TaxExemptCertificateContentType = certContentType;
            customer.TaxExemptCertificateFileName = certFileName;

            // If consent was not previously granted and staff has now checked the consent box
            var newlyGranted = !hadConsentBefore && dto.SmsConsentGranted
                && !string.IsNullOrWhiteSpace(customer.MobilePhone ?? customer.Phone);

            if (newlyGranted)
            {
                customer.NotifyBySms = true;
                customer.SmsConsentedAt = DateTime.UtcNow;
                customer.SmsConsentMethod = "Staff-recorded verbal consent";
            }
            else if (!hadConsentBefore)
            {
                // No consent exists and none newly granted — keep SMS off
                customer.NotifyBySms = false;
            }
            // If hadConsentBefore: NotifyBySms is mapped from the toggle as-is (allow pause/resume)

            await _unitOfWork.Customers.UpdateAsync(customer);
            await _unitOfWork.SaveChangesAsync();

            // Send welcome SMS if consent was just granted
            if (newlyGranted)
            {
                try { await _notificationService.NotifySmsConsentGrantedAsync(customer); }
                catch (Exception ex) { _logger.LogWarning(ex, "SMS consent confirmation failed for customer {Id}", customer.Id); }

                var logs = await _unitOfWork.NotificationLogs.FindAsync(
                    n => n.CustomerId == customer.Id, ignoreQueryFilters: true);
                var smsLog = logs.OrderByDescending(n => n.SentAt).FirstOrDefault();
                this.SetNotificationResultToast(smsLog);
            }

            this.ToastSuccess("Customer updated successfully.");
            return RedirectToAction(nameof(Index));
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Error updating customer {CustomerId}", id);
            this.ToastError("An error occurred while updating the customer.");
            return View(dto);
        }
    }

    /// <summary>
    /// Renders the delete confirmation page for a customer. The view shows a full
    /// summary so the user can verify they are about to remove the correct record
    /// before submitting the confirmation POST.
    /// </summary>
    public async Task<IActionResult> Delete(int? id)
    {
        if (id == null)
        {
            return NotFound();
        }

        try
        {
            var customer = await _unitOfWork.Customers.GetByIdAsync(id.Value);
            if (customer == null)
            {
                return NotFound();
            }

            var customerDto = _mapper.Map<CustomerDto>(customer);
            return View(customerDto);
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Error retrieving customer {CustomerId} for delete", id);
            this.ToastError("An error occurred while loading the customer.");
            return RedirectToAction(nameof(Index));
        }
    }

    /// <summary>
    /// Performs a soft delete on the customer record. A soft delete sets IsDeleted = true
    /// rather than removing the row, preserving referential integrity with historical jobs,
    /// quotes, and invoices. The record is filtered from all normal queries by the global
    /// EF query filter, and can only be seen by SuperAdmins with ignoreQueryFilters.
    /// </summary>
    [HttpPost, ActionName("Delete")]
    [ValidateAntiForgeryToken]
    public async Task<IActionResult> DeleteConfirmed(int id)
    {
        try
        {
            var customer = await _unitOfWork.Customers.GetByIdAsync(id);
            if (customer == null)
            {
                return NotFound();
            }

            await _unitOfWork.Customers.SoftDeleteAsync(customer);
            await _unitOfWork.SaveChangesAsync();

            this.ToastSuccess("Customer deleted successfully.");
            return RedirectToAction(nameof(Index));
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Error deleting customer {CustomerId}", id);
            this.ToastError("An error occurred while deleting the customer.");
            return RedirectToAction(nameof(Index));
        }
    }

    /// <summary>
    /// Streams the tax-exempt certificate file stored as binary in the Customer entity back
    /// to the browser using the original content-type and filename. Certificates are stored
    /// in-database (not on disk) to keep them tenant-isolated without requiring blob storage
    /// configuration in every deployment environment.
    /// </summary>
    [HttpGet]
    public async Task<IActionResult> TaxExemptCertificate(int id)
    {
        try
        {
            var customer = await _unitOfWork.Customers.GetByIdAsync(id);
            if (customer == null || customer.TaxExemptCertificateData == null || customer.TaxExemptCertificateData.Length == 0)
            {
                return NotFound();
            }

            var contentType = customer.TaxExemptCertificateContentType ?? "application/pdf";
            var fileName = customer.TaxExemptCertificateFileName ?? "tax-exempt-certificate.pdf";

            return File(customer.TaxExemptCertificateData, contentType, fileName);
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Error retrieving tax exempt certificate for customer {CustomerId}", id);
            return NotFound();
        }
    }

    /// <summary>
    /// Accepts a tax-exempt certificate upload (PDF, JPG, or PNG, max 10 MB) and stores the
    /// raw bytes directly on the Customer entity. Storing in-database avoids the need for a
    /// shared file system or Azure Blob container in single-tenant or on-premise deployments.
    /// Content-type and original filename are stored alongside the bytes so
    /// <see cref="TaxExemptCertificate"/> can return them faithfully.
    /// </summary>
    [HttpPost]
    [ValidateAntiForgeryToken]
    public async Task<IActionResult> UploadTaxExemptCertificate(int id, IFormFile certificateFile)
    {
        try
        {
            if (certificateFile == null || certificateFile.Length == 0)
            {
                this.ToastError("Please select a file to upload.");
                return RedirectToAction(nameof(Edit), new { id });
            }

            // Validate file type
            var allowedContentTypes = new[] { "application/pdf", "image/jpeg", "image/jpg", "image/png" };
            if (!allowedContentTypes.Contains(certificateFile.ContentType, StringComparer.OrdinalIgnoreCase))
            {
                this.ToastError("Invalid file type. Only PDF and image files (JPG, PNG) are allowed.");
                return RedirectToAction(nameof(Edit), new { id });
            }

            // Validate file size (10 MB max)
            const long maxSize = 10 * 1024 * 1024;
            if (certificateFile.Length > maxSize)
            {
                this.ToastError("File size exceeds the 10 MB limit.");
                return RedirectToAction(nameof(Edit), new { id });
            }

            var customer = await _unitOfWork.Customers.GetByIdAsync(id);
            if (customer == null)
            {
                return NotFound();
            }

            // Read file data
            using var ms = new MemoryStream();
            await certificateFile.CopyToAsync(ms);
            customer.TaxExemptCertificateData = ms.ToArray();
            customer.TaxExemptCertificateContentType = certificateFile.ContentType;
            customer.TaxExemptCertificateFileName = certificateFile.FileName;

            await _unitOfWork.Customers.UpdateAsync(customer);
            await _unitOfWork.SaveChangesAsync();

            this.ToastSuccess("Tax exempt certificate uploaded successfully.");
            return RedirectToAction(nameof(Edit), new { id });
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Error uploading tax exempt certificate for customer {CustomerId}", id);
            this.ToastError("An error occurred while uploading the certificate.");
            return RedirectToAction(nameof(Edit), new { id });
        }
    }

    /// <summary>
    /// Clears the tax-exempt certificate from the customer record by nulling all three
    /// certificate fields (Data, ContentType, FileName). A separate action (rather than
    /// embedding the delete in Edit) avoids race conditions where the Edit form could
    /// clear the certificate bytes if the user saves without re-uploading.
    /// </summary>
    [HttpPost]
    [ValidateAntiForgeryToken]
    public async Task<IActionResult> DeleteTaxExemptCertificate(int id)
    {
        try
        {
            var customer = await _unitOfWork.Customers.GetByIdAsync(id);
            if (customer == null)
            {
                return NotFound();
            }

            customer.TaxExemptCertificateData = null;
            customer.TaxExemptCertificateContentType = null;
            customer.TaxExemptCertificateFileName = null;

            await _unitOfWork.Customers.UpdateAsync(customer);
            await _unitOfWork.SaveChangesAsync();

            this.ToastSuccess("Tax exempt certificate deleted successfully.");
            return RedirectToAction(nameof(Edit), new { id });
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Error deleting tax exempt certificate for customer {CustomerId}", id);
            this.ToastError("An error occurred while deleting the certificate.");
            return RedirectToAction(nameof(Edit), new { id });
        }
    }

    /// <summary>
    /// Issues a standalone credit memo and increments the customer's CreditBalance.
    /// Restricted to CompanyAdmin because credits affect the financial ledger. The memo
    /// number follows the CM-YYMM-#### format generated by <see cref="GenerateCreditMemoNumberAsync"/>.
    /// The memo is not tied to any invoice at creation; it will be applied automatically
    /// when the next invoice is created for this customer.
    /// </summary>
    [HttpPost, ValidateAntiForgeryToken]
    [Authorize(Policy = AppConstants.Policies.CompanyAdminOnly)]
    public async Task<IActionResult> AddCredit(int id, AddCreditDto dto)
    {
        if (!ModelState.IsValid)
        {
            TempData["Error"] = "Invalid credit amount or missing reason.";
            return RedirectToAction(nameof(Details), new { id });
        }

        try
        {
            var customer = await _unitOfWork.Customers.GetByIdAsync(id);
            if (customer == null) return NotFound();

            var currentUser = await _userManager.GetUserAsync(User);
            var memoNumber = await GenerateCreditMemoNumberAsync();

            var memo = new CreditMemo
            {
                MemoNumber = memoNumber,
                CustomerId = id,
                OriginalInvoiceId = null,   // not tied to an invoice
                Amount = dto.Amount,
                AmountApplied = 0,
                IssueDate = DateTime.UtcNow,
                ExpiryDate = dto.ExpiryDate,
                Reason = dto.Reason,
                Notes = dto.Notes,
                Status = CreditMemoStatus.Active,
                IssuedById = currentUser?.Id,
                CompanyId = customer.CompanyId,
                CreatedAt = DateTime.UtcNow,
                CreatedBy = currentUser?.Email
            };

            await _unitOfWork.CreditMemos.AddAsync(memo);

            customer.CreditBalance += dto.Amount;
            await _unitOfWork.Customers.UpdateAsync(customer);

            await _unitOfWork.CompleteAsync();

            TempData["Success"] = $"Credit of {dto.Amount:C} added to {customer.CompanyName ?? customer.ContactFirstName}'s account (Memo {memoNumber}).";
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Error adding credit to customer {CustomerId}", id);
            TempData["Error"] = "An error occurred while adding the credit.";
        }

        return RedirectToAction(nameof(Details), new { id });
    }

    /// <summary>
    /// Adds a quick internal note to the customer record. Returns the rendered note HTML so
    /// the caller can prepend it to the notes list without a full page reload.
    /// </summary>
    [HttpPost, ValidateAntiForgeryToken]
    public async Task<IActionResult> AddCustomerNote(int id, string note, bool isImportant = false)
    {
        if (string.IsNullOrWhiteSpace(note))
            return Json(new { success = false, message = "Note cannot be empty." });

        try
        {
            var customer = await _unitOfWork.Customers.GetByIdAsync(id);
            if (customer == null) return Json(new { success = false, message = "Customer not found." });

            var currentUser = await _userManager.GetUserAsync(User);
            var entity = new PowderCoating.Core.Entities.CustomerNote
            {
                CustomerId  = id,
                Note        = note.Trim(),
                IsImportant = isImportant,
                CreatedBy   = currentUser?.Email
            };

            await _unitOfWork.CustomerNotes.AddAsync(entity);
            await _unitOfWork.CompleteAsync();

            var displayDate = entity.CreatedAt.ToLocalTime().ToString("MMM dd, yyyy h:mm tt");
            var author      = currentUser?.Email ?? "Staff";
            var noteHtml = $@"<div class=""customer-note-item d-flex gap-2 py-2 border-bottom"" data-note-id=""{entity.Id}"">
  <div class=""flex-grow-1"">
    {(isImportant ? @"<span class=""text-warning me-1"" title=""Important"">&#9733;</span>" : "")}
    <span class=""note-text"">{System.Web.HttpUtility.HtmlEncode(entity.Note)}</span>
    <div class=""text-muted"" style=""font-size:0.75rem;"">{System.Web.HttpUtility.HtmlEncode(author)} &mdash; {displayDate}</div>
  </div>
  <button type=""button"" class=""btn btn-sm btn-link text-danger p-0 flex-shrink-0""
          onclick=""deleteCustomerNote({id}, {entity.Id})"" title=""Delete note"">
    <i class=""bi bi-trash""></i>
  </button>
</div>";

            return Json(new { success = true, noteHtml });
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Error adding note to customer {CustomerId}", id);
            return Json(new { success = false, message = "An error occurred." });
        }
    }

    /// <summary>
    /// Soft-deletes a single customer note. Only the owning company can delete their own notes
    /// (enforced via CompanyId on the entity + global query filter).
    /// </summary>
    [HttpPost, ValidateAntiForgeryToken]
    public async Task<IActionResult> DeleteCustomerNote(int id, int noteId)
    {
        try
        {
            var note = await _unitOfWork.CustomerNotes.GetByIdAsync(noteId);
            if (note == null || note.CustomerId != id)
                return Json(new { success = false, message = "Note not found." });

            await _unitOfWork.CustomerNotes.SoftDeleteAsync(note);
            await _unitOfWork.CompleteAsync();
            return Json(new { success = true });
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Error deleting note {NoteId} for customer {CustomerId}", noteId, id);
            return Json(new { success = false, message = "An error occurred." });
        }
    }

    /// <summary>
    /// Returns up to 10 inventory items matching the search term for the preferred-powder typeahead.
    /// Results are scoped to the current company and only include active items.
    /// </summary>
    [HttpGet]
    public async Task<IActionResult> SearchInventoryItems(string term)
    {
        if (string.IsNullOrWhiteSpace(term) || term.Length < 2)
            return Json(Array.Empty<object>());

        var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
        var lower = term.ToLower();
        var items = (await _unitOfWork.InventoryItems.FindAsync(
                i => i.CompanyId == companyId && i.IsActive
                     && (i.Name.ToLower().Contains(lower) || (i.SKU != null && i.SKU.ToLower().Contains(lower)))))
            .OrderBy(i => i.Name)
            .Take(10)
            .Select(i => new { i.Id, i.Name, i.ColorName, sku = i.SKU })
            .ToList();

        return Json(items);
    }

    /// <summary>
    /// Associates an inventory item as a preferred powder for a customer.
    /// Silently succeeds if the association already exists (idempotent).
    /// </summary>
    [HttpPost, ValidateAntiForgeryToken]
    public async Task<IActionResult> AddPreferredPowder(int id, int inventoryItemId, string? notes = null)
    {
        try
        {
            var customer = await _unitOfWork.Customers.GetByIdAsync(id);
            if (customer == null) return Json(new { success = false, message = "Customer not found." });

            var item = await _unitOfWork.InventoryItems.GetByIdAsync(inventoryItemId);
            if (item == null) return Json(new { success = false, message = "Inventory item not found." });

            var existing = (await _unitOfWork.CustomerPreferredPowders.FindAsync(
                p => p.CustomerId == id && p.InventoryItemId == inventoryItemId)).FirstOrDefault();
            if (existing != null)
                return Json(new { success = false, message = $"{item.Name} is already in preferred powders." });

            var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
            await _unitOfWork.CustomerPreferredPowders.AddAsync(new PowderCoating.Core.Entities.CustomerPreferredPowder
            {
                CustomerId      = id,
                InventoryItemId = inventoryItemId,
                Notes           = notes?.Trim(),
                CompanyId       = companyId
            });
            await _unitOfWork.CompleteAsync();

            return Json(new { success = true, itemId = inventoryItemId, itemName = item.Name, notes = notes?.Trim() });
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Error adding preferred powder for customer {CustomerId}", id);
            return Json(new { success = false, message = "An error occurred." });
        }
    }

    /// <summary>
    /// Removes a preferred-powder association by inventory item ID. Soft-deletes the record
    /// so the history is preserved but it no longer appears on the customer page.
    /// </summary>
    [HttpPost, ValidateAntiForgeryToken]
    public async Task<IActionResult> RemovePreferredPowder(int id, int itemId)
    {
        try
        {
            var record = (await _unitOfWork.CustomerPreferredPowders.FindAsync(
                p => p.CustomerId == id && p.InventoryItemId == itemId)).FirstOrDefault();
            if (record == null) return Json(new { success = false, message = "Record not found." });

            await _unitOfWork.CustomerPreferredPowders.SoftDeleteAsync(record);
            await _unitOfWork.CompleteAsync();
            return Json(new { success = true });
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Error removing preferred powder {ItemId} for customer {CustomerId}", itemId, id);
            return Json(new { success = false, message = "An error occurred." });
        }
    }

    // ── Customer Contacts ──────────────────────────────────────────────────

    /// <summary>
    /// Returns the JSON representation of a single contact for pre-populating the edit modal.
    /// </summary>
    [HttpGet]
    public async Task<IActionResult> GetContact(int id, int contactId)
    {
        var contact = await _unitOfWork.CustomerContacts.GetByIdAsync(contactId);
        if (contact == null || contact.CustomerId != id)
            return Json(new { success = false });

        var dto = _mapper.Map<PowderCoating.Application.DTOs.Customer.UpdateCustomerContactDto>(contact);
        return Json(new { success = true, contact = dto });
    }

    /// <summary>
    /// Adds a new contact to the customer record. Returns rendered row HTML so the
    /// caller can append it to the contacts table without a full reload.
    /// </summary>
    [HttpPost, ValidateAntiForgeryToken]
    public async Task<IActionResult> AddContact(int id, PowderCoating.Application.DTOs.Customer.CreateCustomerContactDto dto)
    {
        if (!ModelState.IsValid)
            return Json(new { success = false, message = ModelState.Values.SelectMany(v => v.Errors).FirstOrDefault()?.ErrorMessage ?? "Invalid data." });

        try
        {
            var customer = await _unitOfWork.Customers.GetByIdAsync(id);
            if (customer == null) return Json(new { success = false, message = "Customer not found." });

            var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
            var entity = _mapper.Map<PowderCoating.Core.Entities.CustomerContact>(dto);
            entity.CustomerId = id;
            entity.CompanyId = companyId;

            await _unitOfWork.CustomerContacts.AddAsync(entity);
            await _unitOfWork.CompleteAsync();

            var rowHtml = BuildContactRowHtml(id, entity);
            return Json(new { success = true, contactId = entity.Id, rowHtml });
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Error adding contact to customer {CustomerId}", id);
            return Json(new { success = false, message = "An error occurred." });
        }
    }

    /// <summary>
    /// Updates an existing contact record in place. Returns the updated row HTML.
    /// </summary>
    [HttpPost, ValidateAntiForgeryToken]
    public async Task<IActionResult> UpdateContact(int id, PowderCoating.Application.DTOs.Customer.UpdateCustomerContactDto dto)
    {
        if (!ModelState.IsValid)
            return Json(new { success = false, message = ModelState.Values.SelectMany(v => v.Errors).FirstOrDefault()?.ErrorMessage ?? "Invalid data." });

        try
        {
            var contact = await _unitOfWork.CustomerContacts.GetByIdAsync(dto.Id);
            if (contact == null || contact.CustomerId != id)
                return Json(new { success = false, message = "Contact not found." });

            _mapper.Map(dto, contact);
            contact.UpdatedAt = DateTime.UtcNow;

            await _unitOfWork.CustomerContacts.UpdateAsync(contact);
            await _unitOfWork.CompleteAsync();

            var rowHtml = BuildContactRowHtml(id, contact);
            return Json(new { success = true, contactId = contact.Id, rowHtml });
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Error updating contact {ContactId} for customer {CustomerId}", dto.Id, id);
            return Json(new { success = false, message = "An error occurred." });
        }
    }

    /// <summary>
    /// Soft-deletes a contact. Only the owning company can delete their contacts
    /// (enforced via CompanyId + global query filter).
    /// </summary>
    [HttpPost, ValidateAntiForgeryToken]
    public async Task<IActionResult> DeleteContact(int id, int contactId)
    {
        try
        {
            var contact = await _unitOfWork.CustomerContacts.GetByIdAsync(contactId);
            if (contact == null || contact.CustomerId != id)
                return Json(new { success = false, message = "Contact not found." });

            await _unitOfWork.CustomerContacts.SoftDeleteAsync(contact);
            await _unitOfWork.CompleteAsync();
            return Json(new { success = true });
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Error deleting contact {ContactId} for customer {CustomerId}", contactId, id);
            return Json(new { success = false, message = "An error occurred." });
        }
    }

    /// <summary>
    /// Builds the table-row HTML for a contact. Kept server-side so the same markup is
    /// used for both the initial page render and the AJAX insert/replace path.
    /// </summary>
    private static string BuildContactRowHtml(int customerId, PowderCoating.Core.Entities.CustomerContact c)
    {
        var displayName = string.IsNullOrWhiteSpace(c.LastName) ? c.FirstName : $"{c.FirstName} {c.LastName}";
        var titlePart   = !string.IsNullOrWhiteSpace(c.Title) ? System.Web.HttpUtility.HtmlEncode(c.Title) : "";
        var roleBadge   = !string.IsNullOrWhiteSpace(c.ContactRole)
            ? $"<span class=\"badge bg-secondary bg-opacity-10 text-secondary ms-1\">{System.Web.HttpUtility.HtmlEncode(c.ContactRole)}</span>"
            : "";
        var email = !string.IsNullOrWhiteSpace(c.Email)
            ? $"<a href=\"mailto:{System.Web.HttpUtility.HtmlEncode(c.Email)}\" class=\"text-decoration-none small\">{System.Web.HttpUtility.HtmlEncode(c.Email)}</a>"
            : "<span class=\"text-muted small\">&mdash;</span>";
        var phone = !string.IsNullOrWhiteSpace(c.Phone ?? c.MobilePhone)
            ? $"<span class=\"small\">{System.Web.HttpUtility.HtmlEncode(c.Phone ?? c.MobilePhone)}</span>"
            : "<span class=\"text-muted small\">&mdash;</span>";

        return $@"<tr data-contact-id=""{c.Id}"">
  <td>
    <div class=""fw-semibold"">{System.Web.HttpUtility.HtmlEncode(displayName)}{roleBadge}</div>
    {(string.IsNullOrWhiteSpace(titlePart) ? "" : $"<div class=\"text-muted\" style=\"font-size:0.75rem;\">{titlePart}</div>")}
  </td>
  <td>{email}</td>
  <td>{phone}</td>
  <td class=""text-end"">
    <button type=""button"" class=""btn btn-sm btn-outline-secondary me-1""
            onclick=""editContact({customerId}, {c.Id})"" title=""Edit"">
      <i class=""bi bi-pencil""></i>
    </button>
    <button type=""button"" class=""btn btn-sm btn-outline-danger""
            onclick=""deleteContact({customerId}, {c.Id})"" title=""Delete"">
      <i class=""bi bi-trash""></i>
    </button>
  </td>
</tr>";
    }

    /// <summary>
    /// Displays or downloads a dated activity statement for a customer.
    /// Pass <c>pdf=true</c> to download the QuestPDF version; otherwise renders the HTML view.
    /// </summary>
    [HttpGet]
    public async Task<IActionResult> Statement(int id, DateTime? from, DateTime? to, bool pdf = false)
    {
        var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
        var fromDate  = from ?? new DateTime(DateTime.Today.Year, DateTime.Today.Month, 1);
        var toDate    = to   ?? DateTime.Today;

        var dto = await _financialReports.GetCustomerStatementAsync(companyId, id, fromDate, toDate);

        if (pdf)
        {
            var bytes = StatementPdfHelper.Generate(
                dto.CustomerName, dto.CompanyName, dto.CustomerAddress,
                dto.From, dto.To, dto.OpeningBalance, dto.Lines, dto.ClosingBalance, isVendor: false);
            return File(bytes, "application/pdf", $"Statement-{dto.CustomerName}-{toDate:yyyyMMdd}.pdf");
        }

        return View(dto);
    }

    /// <summary>
    /// Generates the next sequential credit memo number in CM-YYMM-#### format.
    /// Uses <c>ignoreQueryFilters: true</c> when scanning all existing memos so that
    /// soft-deleted records are included in the sequence check and their numbers are
    /// never reused, maintaining an audit-friendly gap-free numbering history.
    /// </summary>
    private async Task<string> GenerateCreditMemoNumberAsync()
    {
        var prefix = $"CM-{DateTime.Now:yyMM}-";
        var last = (await _unitOfWork.CreditMemos.FindAsync(
                m => m.MemoNumber.StartsWith(prefix), ignoreQueryFilters: true))
            .OrderByDescending(m => m.MemoNumber)
            .Select(m => m.MemoNumber)
            .FirstOrDefault();

        int next = 1;
        if (last != null && int.TryParse(last[prefix.Length..], out int num))
            next = num + 1;

        return $"{prefix}{next:D4}";
    }

    /// <summary>
    /// Loads all active pricing tiers into ViewBag.PricingTiers for the Create and Edit
    /// dropdowns. Tiers are sorted alphabetically and include the discount percentage in
    /// the label text so staff can see the benefit without opening the tier detail page.
    /// </summary>
    private async Task PopulatePricingTiersAsync()
    {
        var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
        var tiers = await _unitOfWork.PricingTiers.FindAsync(t => t.CompanyId == companyId && t.IsActive);
        ViewBag.PricingTiers = tiers
            .OrderBy(t => t.TierName)
            .Select(t => new SelectListItem
            {
                Value = t.Id.ToString(),
                Text = t.DiscountPercent > 0
                    ? $"{t.TierName} ({t.DiscountPercent:0.##}% discount)"
                    : t.TierName
            })
            .ToList();
    }
}
