using AutoMapper;
using Microsoft.AspNetCore.Authorization;
using PowderCoating.Shared.Constants;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using PowderCoating.Application.Configuration;
using PowderCoating.Application.Services;
using PowderCoating.Application.DTOs.Accounting;
using PowderCoating.Application.DTOs.AI;
using PowderCoating.Application.Interfaces;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Enums;
using PowderCoating.Core.Interfaces;
using PowderCoating.Application.DTOs.PurchaseOrder;
using PowderCoating.Web.Helpers;

namespace PowderCoating.Web.Controllers;

[Authorize(Policy = AppConstants.Policies.CanViewData)]
public class BillsController : Controller
{
    private static readonly string[] AllowedReceiptTypes = { ".jpg", ".jpeg", ".png", ".gif", ".webp", ".pdf" };
    private const long MaxReceiptBytes = 10 * 1024 * 1024;

    private readonly IUnitOfWork _unitOfWork;
    private readonly IMapper _mapper;
    private readonly UserManager<ApplicationUser> _userManager;
    private readonly ILogger<BillsController> _logger;
    private readonly IAccountBalanceService _accountBalanceService;
    private readonly IAccountingAiService _accountingAi;
    private readonly IAzureBlobStorageService _blobStorage;
    private readonly StorageSettings _storageSettings;
    private readonly IAiUsageLogger _usageLogger;
    private readonly ITenantContext _tenantContext;

    public BillsController(
        IUnitOfWork unitOfWork,
        IMapper mapper,
        UserManager<ApplicationUser> userManager,
        ILogger<BillsController> logger,
        IAccountBalanceService accountBalanceService,
        IAccountingAiService accountingAi,
        IAzureBlobStorageService blobStorage,
        IOptions<StorageSettings> storageSettings,
        IAiUsageLogger usageLogger,
        ITenantContext tenantContext)
    {
        _unitOfWork = unitOfWork;
        _mapper = mapper;
        _userManager = userManager;
        _logger = logger;
        _accountBalanceService = accountBalanceService;
        _accountingAi = accountingAi;
        _blobStorage = blobStorage;
        _storageSettings = storageSettings.Value;
        _usageLogger = usageLogger;
        _tenantContext = tenantContext;
    }

    // -- Index ----------------------------------------------------------------

    /// <summary>
    /// Lists bills and direct expenses in a unified AP ledger view. The <paramref name="type"/>
    /// parameter lets the caller pin the list to Bills only, Expenses only, or both (null).
    /// Expenses are inherently fully paid so they are always excluded when the caller filters to
    /// "Unpaid" or "Overdue" � preventing them from inflating the "amount owed" summary.
    /// Amount-based search strips leading $ and commas before comparing so "$1,234" works naturally.
    /// </summary>
    public async Task<IActionResult> Index(string? type, string? search, string? status, int page = 1, int pageSize = 25)
    {
        var entries = new List<BillExpenseListDto>();

        // Pre-parse search as amount once so both queries can use it
        decimal? searchAmount = null;
        if (!string.IsNullOrEmpty(search))
        {
            var normalized = search.TrimStart('$').Replace(",", "");
            if (decimal.TryParse(normalized, System.Globalization.NumberStyles.Any,
                    System.Globalization.CultureInfo.InvariantCulture, out var parsed))
                searchAmount = parsed;
        }

        var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;

        // Bills
        if (type == null || type == "Bill")
        {
            var bills = await _unitOfWork.Bills.GetForIndexAsync(companyId, status, search, searchAmount);

            entries.AddRange(bills.Select(b => new BillExpenseListDto
            {
                EntryType   = "Bill",
                Id          = b.Id,
                Number      = b.BillNumber,
                Date        = b.BillDate,
                VendorName  = b.Vendor?.CompanyName,
                Memo        = b.Memo,
                Total       = b.Total,
                BalanceDue  = b.BalanceDue,
                DueDate     = b.DueDate,
                IsOverdue   = b.Status != BillStatus.Paid && b.Status != BillStatus.Voided
                              && b.DueDate.HasValue && b.DueDate.Value.Date < DateTime.Today,
                HasReceipt  = !string.IsNullOrEmpty(b.ReceiptFilePath),
                StatusLabel = b.Status.ToString(),
                StatusColor = b.Status switch
                {
                    BillStatus.Open          => "primary",
                    BillStatus.PartiallyPaid => "warning",
                    BillStatus.Paid          => "success",
                    BillStatus.Voided        => "danger",
                    _                        => "secondary"
                }
            }));
        }

        // Expenses are always fully paid � exclude when filtering to unpaid/overdue bills only
        if ((type == null || type == "Expense") && status != "Unpaid" && status != "Overdue")
        {
            var expSearch = search;
            var expAmount = searchAmount;
            var expenseList = await _unitOfWork.Expenses.FindAsync(
                e => string.IsNullOrEmpty(expSearch) ||
                     e.ExpenseNumber.Contains(expSearch) ||
                     (e.Vendor != null && e.Vendor.CompanyName.Contains(expSearch)) ||
                     (e.Memo != null && e.Memo.Contains(expSearch)) ||
                     (expAmount.HasValue && e.Amount == expAmount.Value),
                false,
                e => e.Vendor!, e => e.ExpenseAccount!);
            var expenses = expenseList.OrderByDescending(e => e.Date).ToList();

            entries.AddRange(expenses.Select(e => new BillExpenseListDto
            {
                EntryType   = "Expense",
                Id          = e.Id,
                Number      = e.ExpenseNumber,
                Date        = e.Date,
                VendorName  = e.Vendor?.CompanyName,
                Memo        = e.Memo,
                AccountName = e.ExpenseAccount?.Name,
                Total       = e.Amount,
                BalanceDue  = 0,
                HasReceipt  = !string.IsNullOrEmpty(e.ReceiptFilePath),
                StatusLabel = "Paid",
                StatusColor = "success"
            }));
        }

        entries = entries.OrderByDescending(e => e.Date).ToList();

        ViewBag.TypeFilter    = type;
        ViewBag.Search        = search;
        ViewBag.StatusFilter  = status;
        ViewBag.TotalOwed   = entries.Where(e => e.EntryType == "Bill" && e.BalanceDue > 0)
                                     .Sum(e => e.BalanceDue);
        ViewBag.TotalCount  = entries.Count;
        ViewBag.Page        = page;
        ViewBag.PageSize    = pageSize;
        ViewBag.TotalPages  = (int)Math.Ceiling(entries.Count / (double)pageSize);

        var pagedEntries = entries.Skip((page - 1) * pageSize).Take(pageSize).ToList();
        return View(pagedEntries);
    }

    // -- Create ---------------------------------------------------------------

    // -- Create from Purchase Order --------------------------------------------

    /// <summary>
    /// Scaffolds a new bill pre-filled from a received purchase order. Only POs in
    /// <c>Received</c> or <c>PartiallyReceived</c> status can be billed � earlier states mean
    /// goods have not yet arrived and no liability has been incurred. If a bill already exists for
    /// the PO the user is redirected to the existing bill to prevent duplicate AP entries.
    /// Line items are copied from PO items (using inventory item names where available), and
    /// shipping cost is appended as an additional line item if non-zero. The vendor's
    /// <c>DefaultExpenseAccountId</c> is used to pre-categorise all lines, falling back to the
    /// first active Expense/COGS account when the vendor has no default configured.
    /// </summary>
    [Authorize(Policy = AppConstants.Policies.CanManageBills)]
    public async Task<IActionResult> CreateFromPurchaseOrder(int purchaseOrderId)
    {
        var currentUser = await _userManager.GetUserAsync(User);
        if (currentUser == null) return Unauthorized();

        var po = await _unitOfWork.PurchaseOrders.LoadForViewAsync(purchaseOrderId, currentUser.CompanyId);

        if (po == null) return NotFound();

        if (po.Status != PurchaseOrderStatus.Received && po.Status != PurchaseOrderStatus.PartiallyReceived)
        {
            TempData["Error"] = "Bills can only be created for Received or Partially Received purchase orders.";
            return RedirectToAction("Details", "PurchaseOrders", new { id = purchaseOrderId });
        }

        if (po.BillId.HasValue)
        {
            TempData["Info"] = "A bill already exists for this purchase order.";
            return RedirectToAction(nameof(Details), new { id = po.BillId });
        }

        var apAccount = await _unitOfWork.Accounts.FirstOrDefaultAsync(
            a => a.CompanyId == po.CompanyId && a.AccountSubType == AccountSubType.AccountsPayable);

        // Vendor default expense account, fall back to first expense/COGS account
        int? defaultExpenseAccountId = po.Vendor?.DefaultExpenseAccountId;
        if (!defaultExpenseAccountId.HasValue)
        {
            var fallbackAccount = await _unitOfWork.Accounts.FirstOrDefaultAsync(
                a => a.CompanyId == po.CompanyId && a.IsActive && (a.AccountType == AccountType.Expense || a.AccountType == AccountType.CostOfGoods));
            defaultExpenseAccountId = fallbackAccount?.Id;
        }

        var lineItems = po.Items
            .OrderBy(i => i.Id)
            .Select((item, idx) => new CreateBillLineItemDto
            {
                AccountId    = defaultExpenseAccountId ?? 0,
                Description  = item.InventoryItem?.Name ?? item.Description ?? string.Empty,
                Quantity     = item.QuantityOrdered,
                UnitPrice    = item.UnitCost,
                DisplayOrder = idx
            }).ToList();

        if (po.ShippingCost > 0)
        {
            lineItems.Add(new CreateBillLineItemDto
            {
                AccountId    = defaultExpenseAccountId ?? 0,
                Description  = "Shipping",
                Quantity     = 1,
                UnitPrice    = po.ShippingCost,
                DisplayOrder = lineItems.Count
            });
        }

        var dto = new CreateBillDto
        {
            VendorId         = po.VendorId,
            APAccountId      = apAccount?.Id ?? 0,
            BillDate         = DateTime.Today,
            Memo             = $"From PO: {po.PoNumber}",
            PurchaseOrderId  = purchaseOrderId,
            LineItems        = lineItems
        };

        ViewBag.FromPoNumber = po.PoNumber;
        ViewBag.FromPoId     = purchaseOrderId;

        await PopulateDropdownsAsync();
        return View("Create", dto);
    }

    // -- Create ---------------------------------------------------------------

    /// <summary>
    /// Returns the blank bill creation form. When <paramref name="vendorId"/> is supplied the
    /// vendor is pre-selected and, if that vendor has a <c>DefaultExpenseAccountId</c>, a single
    /// pre-categorised line item is added automatically so the user only needs to fill in the
    /// amount. The AP account is pre-filled with the first active AccountsPayable sub-type account
    /// so the double-entry pair is ready without manual lookup.
    /// </summary>
    [Authorize(Policy = AppConstants.Policies.CanManageBills)]
    public async Task<IActionResult> Create(int? vendorId)
    {
        var dto = new CreateBillDto
        {
            BillDate = DateTime.Today,
            VendorId = vendorId ?? 0
        };

        // Pre-fill AP account
        var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
        var apAccount = await _unitOfWork.Accounts.FirstOrDefaultAsync(
            a => a.CompanyId == companyId && a.AccountSubType == AccountSubType.AccountsPayable);
        dto.APAccountId = apAccount?.Id ?? 0;

        // Pre-fill default expense account for vendor
        if (vendorId.HasValue)
        {
            var vendor = await _unitOfWork.Vendors.GetByIdAsync(vendorId.Value);
            if (vendor?.DefaultExpenseAccountId != null)
                dto.LineItems.Add(new CreateBillLineItemDto { AccountId = vendor.DefaultExpenseAccountId.Value, Quantity = 1 });
        }

        if (!dto.LineItems.Any())
            dto.LineItems.Add(new CreateBillLineItemDto { Quantity = 1 });

        await PopulateDropdownsAsync();
        return View(dto);
    }

    /// <summary>
    /// Persists a new vendor bill. Bills are created in <c>Draft</c> status so the user can
    /// review before committing to AP. Empty line items (zero account or zero price) are stripped
    /// before validation to avoid spurious errors when the browser submits blank rows.
    /// If <paramref name="payNow"/> is true a <see cref="BillPayment"/> record is inserted
    /// immediately and the bill status is advanced to <c>Paid</c> or <c>PartiallyPaid</c> �
    /// useful for entering historical bills that were already settled. Account balance side
    /// effects are deliberately deferred to <see cref="MarkOpen"/> so that Draft bills do not
    /// affect the AP ledger until they are approved. If the bill was created from a PO the
    /// back-reference <c>PurchaseOrder.BillId</c> is set to establish the 1:1 linkage.
    /// </summary>
    [HttpPost, ValidateAntiForgeryToken]
    [Authorize(Policy = AppConstants.Policies.CanManageBills)]
    public async Task<IActionResult> Create(CreateBillDto dto, IFormFile? receiptFile,
        bool payNow = false,
        DateTime? paymentDate = null,
        int? paymentMethod = null,
        int? bankAccountId = null,
        string? checkNumber = null,
        string? paymentMemo = null)
    {
        // Remove empty line items before validation
        dto.LineItems = dto.LineItems.Where(li => li.AccountId > 0 && li.UnitPrice > 0).ToList();
        if (!dto.LineItems.Any())
            ModelState.AddModelError("LineItems", "At least one line item is required.");

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

        try
        {
            var currentUser = await _userManager.GetUserAsync(User);

            // Period lock check � block if the bill date is in a locked period
            if (currentUser != null)
            {
                var co = await _unitOfWork.Companies.GetByIdAsync(currentUser.CompanyId);
                if (Web.Helpers.AccountingPeriodValidator.IsLocked(dto.BillDate, co?.BookLockedThrough))
                {
                    ModelState.AddModelError("BillDate", Web.Helpers.AccountingPeriodValidator.LockedMessage(co!.BookLockedThrough));
                    await PopulateDropdownsAsync();
                    return View(dto);
                }
            }

            // Validate the pay-from account before entering the transaction so an invalid
            // selection rejects the whole request rather than saving a bill with no payment.
            if (payNow && paymentMethod.HasValue && bankAccountId.HasValue && currentUser != null
                && !await AccountGuard.IsValidMoneyAccountAsync(_unitOfWork, bankAccountId, currentUser.CompanyId))
            {
                ModelState.AddModelError(string.Empty, "Choose a valid bank or credit account to record the payment.");
                await PopulateDropdownsAsync();
                return View(dto);
            }

            Bill? bill = null;

            // Bill entity, PO back-reference, and optional immediate payment all commit
            // atomically so a payNow failure cannot leave a bill with no payment record.
            await _unitOfWork.ExecuteInTransactionAsync(async () =>
            {
                bill = _mapper.Map<Bill>(dto);
                bill.BillNumber = await GenerateBillNumberAsync();
                bill.Status = BillStatus.Open;
                bill.CompanyId = currentUser!.CompanyId;
                bill.CreatedBy = currentUser.Email;

                // Calculate financials
                int order = 0;
                foreach (var li in bill.LineItems)
                {
                    li.Amount = Math.Round(li.Quantity * li.UnitPrice, 2);
                    li.DisplayOrder = order++;
                    li.CompanyId = currentUser.CompanyId;
                }

                bill.SubTotal = bill.LineItems.Sum(li => li.Amount);
                bill.TaxAmount = Math.Round(bill.SubTotal * (dto.TaxPercent / 100m), 2);
                bill.Total = bill.SubTotal + bill.TaxAmount;

                await _unitOfWork.Bills.AddAsync(bill);
                await _unitOfWork.CompleteAsync();  // flush to get bill.Id

                // Link bill back to source PO
                if (dto.PurchaseOrderId > 0)
                {
                    var po = await _unitOfWork.PurchaseOrders.GetByIdAsync(dto.PurchaseOrderId!.Value);
                    if (po != null)
                    {
                        po.BillId    = bill.Id;
                        po.UpdatedAt = DateTime.UtcNow;
                    }
                }

                // Record payment immediately if "already paid" was checked
                if (payNow && paymentMethod.HasValue && bankAccountId.HasValue)
                {
                    var payment = new BillPayment
                    {
                        BillId        = bill.Id,
                        VendorId      = bill.VendorId,
                        PaymentNumber = await GeneratePaymentNumberAsync(),
                        PaymentDate   = paymentDate ?? DateTime.Today,
                        Amount        = bill.Total,
                        PaymentMethod = (PaymentMethod)paymentMethod.Value,
                        BankAccountId = bankAccountId.Value,
                        CheckNumber   = checkNumber,
                        Memo          = paymentMemo,
                        CompanyId     = bill.CompanyId,
                        CreatedBy     = currentUser.Email
                    };

                    bill.AmountPaid = payment.Amount;
                    bill.Status     = bill.AmountPaid >= bill.Total ? BillStatus.Paid : BillStatus.PartiallyPaid;
                    await _unitOfWork.BillPayments.AddAsync(payment);
                }

                await _unitOfWork.CompleteAsync();
            });

            // Receipt upload after the transaction commits � bill.Id is set and core data
            // is secure. A blob failure here leaves the bill intact without an attachment.
            if (receiptFile != null && receiptFile.Length > 0)
            {
                var (receiptValid, _, receiptError) = BlobFileHelper.ValidateUpload(receiptFile, AllowedReceiptTypes, MaxReceiptBytes);
                if (receiptValid)
                {
                    bill!.ReceiptFilePath = await UploadReceiptAsync(receiptFile, bill.Id, currentUser.CompanyId);
                    await _unitOfWork.Bills.UpdateAsync(bill);
                    await _unitOfWork.CompleteAsync();
                }
                else
                    TempData["Warning"] = $"Bill saved but receipt not uploaded: {receiptError}";
            }

            TempData["Success"] = payNow && paymentMethod.HasValue && bankAccountId.HasValue
                ? $"Bill {bill!.BillNumber} saved and marked as paid."
                : $"Bill {bill!.BillNumber} created.";
            return RedirectToAction(nameof(Details), new { id = bill!.Id });
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Error creating bill");
            ModelState.AddModelError(string.Empty, "An error occurred while saving.");
            await PopulateDropdownsAsync();
            return View(dto);
        }
    }

    // -- Details --------------------------------------------------------------

    /// <summary>
    /// Displays full bill detail including line items, payments, and the payment entry form.
    /// Bank account and payment method dropdowns are populated here (not in a separate action)
    /// because the partial payment modal lives inline on the details page.
    /// </summary>
    public async Task<IActionResult> Details(int? id)
    {
        if (id == null) return NotFound();

        var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
        var bill = await _unitOfWork.Bills.LoadForViewAsync(id.Value, companyId);
        if (bill == null) return NotFound();

        var dto = _mapper.Map<BillDto>(bill);

        // Payment form defaults
        // Payment sources: filter by parent AccountType (Asset or Liability), not sub-type,
        // so accounts a company classified under a different sub-type still appear.
        var bankAccounts = (await _unitOfWork.Accounts.FindAsync(
            a => a.CompanyId == bill.CompanyId &&
                (a.AccountType == AccountType.Asset ||
                 a.AccountType == AccountType.Liability)))
            .OrderBy(a => a.AccountNumber)
            .ToList();

        ViewBag.BankAccounts = bankAccounts
            .Select(a => new SelectListItem($"{a.AccountNumber} � {a.Name}", a.Id.ToString()))
            .ToList();

        ViewBag.PaymentMethods = Enum.GetValues<PaymentMethod>()
            .Select(m => new SelectListItem(m.ToString(), ((int)m).ToString()))
            .ToList();

        return View(dto);
    }

    // -- Edit -----------------------------------------------------------------

    /// <summary>
    /// Returns the edit form for a bill. Only <c>Draft</c> bills are editable; once a bill is
    /// <c>Open</c> its account-balance side effects have been applied and edits would create
    /// unreconciled ledger entries. Paid and Voided bills are also blocked to preserve the
    /// audit trail.
    /// </summary>
    [Authorize(Policy = AppConstants.Policies.CanManageBills)]
    public async Task<IActionResult> Edit(int? id)
    {
        if (id == null) return NotFound();

        var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
        var bill = await _unitOfWork.Bills.LoadForEditAsync(id.Value, companyId);
        if (bill == null) return NotFound();

        if (bill.Status == BillStatus.Paid || bill.Status == BillStatus.Voided)
        {
            TempData["Error"] = "Paid and voided bills cannot be edited.";
            return RedirectToAction(nameof(Details), new { id });
        }

        var dto = new EditBillDto
        {
            Id = bill.Id,
            VendorId = bill.VendorId,
            APAccountId = bill.APAccountId,
            VendorInvoiceNumber = bill.VendorInvoiceNumber,
            BillDate = bill.BillDate,
            DueDate = bill.DueDate,
            Terms = bill.Terms,
            Memo = bill.Memo,
            TaxPercent = bill.TaxPercent,
            ReceiptFilePath = bill.ReceiptFilePath,
            LineItems = bill.LineItems
                .OrderBy(li => li.DisplayOrder)
                .Select(li => new CreateBillLineItemDto
                {
                    AccountId = li.AccountId,
                    JobId = li.JobId,
                    Description = li.Description,
                    Quantity = li.Quantity,
                    UnitPrice = li.UnitPrice,
                    DisplayOrder = li.DisplayOrder
                }).ToList()
        };

        await PopulateDropdownsAsync();
        return View(dto);
    }

    /// <summary>
    /// Saves bill edits. Line items are replaced using the soft-delete + re-insert pattern so
    /// that historical <c>BillLineItem</c> records are preserved (required for audit log
    /// integrity). Financials (SubTotal, TaxAmount, Total) are recalculated from the new line
    /// items on every save. An optional receipt file replaces the previous attachment in blob
    /// storage; the old blob is deleted before the new one is written to avoid orphaned files.
    /// </summary>
    [HttpPost, ValidateAntiForgeryToken]
    [Authorize(Policy = AppConstants.Policies.CanManageBills)]
    public async Task<IActionResult> Edit(int id, EditBillDto dto, IFormFile? receiptFile)
    {
        if (id != dto.Id) return NotFound();

        dto.LineItems = dto.LineItems.Where(li => li.AccountId > 0 && li.UnitPrice > 0).ToList();
        if (!dto.LineItems.Any())
            ModelState.AddModelError("LineItems", "At least one line item is required.");

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

        try
        {
            var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
            var bill = await _unitOfWork.Bills.LoadForEditAsync(id, companyId);

            if (bill == null) return NotFound();

            if (bill.Status != BillStatus.Draft)
            {
                TempData["Error"] = "Only Draft bills can be edited.";
                return RedirectToAction(nameof(Details), new { id });
            }

            var currentUser = await _userManager.GetUserAsync(User);

            // Soft-delete old line items
            foreach (var li in bill.LineItems)
            {
                li.IsDeleted = true;
                li.UpdatedAt = DateTime.UtcNow;
            }

            bill.VendorId = dto.VendorId;
            bill.APAccountId = dto.APAccountId;
            bill.VendorInvoiceNumber = dto.VendorInvoiceNumber;
            bill.BillDate = dto.BillDate;
            bill.DueDate = dto.DueDate;
            bill.Terms = dto.Terms;
            bill.Memo = dto.Memo;
            bill.TaxPercent = dto.TaxPercent;
            bill.UpdatedAt = DateTime.UtcNow;
            bill.UpdatedBy = currentUser?.Email;

            int order = 0;
            var newLineItems = dto.LineItems.Select(li => new BillLineItem
            {
                BillId = bill.Id,
                AccountId = li.AccountId,
                JobId = li.JobId,
                Description = li.Description,
                Quantity = li.Quantity,
                UnitPrice = li.UnitPrice,
                Amount = Math.Round(li.Quantity * li.UnitPrice, 2),
                DisplayOrder = order++,
                CompanyId = bill.CompanyId
            }).ToList();

            bill.SubTotal = newLineItems.Sum(li => li.Amount);
            bill.TaxAmount = Math.Round(bill.SubTotal * (dto.TaxPercent / 100m), 2);
            bill.Total = bill.SubTotal + bill.TaxAmount;

            await _unitOfWork.BillLineItems.AddRangeAsync(newLineItems);

            // Handle receipt file replacement
            if (receiptFile != null && receiptFile.Length > 0)
            {
                var (receiptValid, _, receiptError) = BlobFileHelper.ValidateUpload(receiptFile, AllowedReceiptTypes, MaxReceiptBytes);
                if (receiptValid)
                {
                    if (!string.IsNullOrEmpty(bill.ReceiptFilePath))
                        await _blobStorage.DeleteAsync(_storageSettings.Containers.ReceiptImages, bill.ReceiptFilePath);
                    bill.ReceiptFilePath = await UploadReceiptAsync(receiptFile, bill.Id, bill.CompanyId);
                }
                else
                {
                    TempData["Warning"] = $"Bill saved but receipt not uploaded: {receiptError}";
                }
            }

            await _unitOfWork.CompleteAsync();

            TempData["Success"] = $"Bill {bill.BillNumber} updated.";
            return RedirectToAction(nameof(Details), new { id });
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Error updating bill {Id}", id);
            ModelState.AddModelError(string.Empty, "An error occurred while saving.");
            await PopulateDropdownsAsync();
            return View(dto);
        }
    }

    // -- Mark Open (Draft ? Open) ---------------------------------------------

    /// <summary>
    /// Transitions a bill from <c>Draft</c> to <c>Open</c> (the AP approval step). This is
    /// the commit point for double-entry accounting: the AP account is credited for the full
    /// bill total (increasing the liability), and each expense line item's account is debited
    /// (recognising the cost). The vendor's <c>CurrentBalance</c> is also incremented so the
    /// vendor aging report reflects the new obligation. These side effects are intentionally
    /// deferred from bill creation to give users a review window without polluting the ledger.
    /// </summary>
    [HttpPost, ValidateAntiForgeryToken]
    [Authorize(Policy = AppConstants.Policies.CanManageBills)]
    public async Task<IActionResult> MarkOpen(int id)
    {
        var bill = await _unitOfWork.Bills.GetByIdAsync(id, false, b => b.LineItems);
        if (bill == null) return NotFound();

        if (bill.Status != BillStatus.Draft)
        {
            TempData["Error"] = "Only Draft bills can be opened.";
            return RedirectToAction(nameof(Details), new { id });
        }

        var currentUser = await _userManager.GetUserAsync(User);
        bill.Status = BillStatus.Open;
        bill.UpdatedAt = DateTime.UtcNow;
        bill.UpdatedBy = currentUser?.Email;

        // Add to vendor's balance
        var vendor = await _unitOfWork.Vendors.GetByIdAsync(bill.VendorId);
        if (vendor != null)
        {
            vendor.CurrentBalance += bill.Total;
            await _unitOfWork.Vendors.UpdateAsync(vendor);
        }

        await _unitOfWork.Bills.UpdateAsync(bill);

        // Update account balances: credit AP, debit each expense account
        await _accountBalanceService.CreditAsync(bill.APAccountId, bill.Total);
        foreach (var li in bill.LineItems.Where(l => !l.IsDeleted && l.AccountId.HasValue))
            await _accountBalanceService.DebitAsync(li.AccountId!.Value, li.Amount);

        await _unitOfWork.CompleteAsync();

        TempData["Success"] = $"Bill {bill.BillNumber} is now Open.";
        return RedirectToAction(nameof(Details), new { id });
    }

    // -- Record Payment -------------------------------------------------------

    /// <summary>
    /// Records a full or partial payment against an open bill. Overpayment is blocked because
    /// it would produce a negative balance due and make the AP aging report unreliable.
    /// Double-entry effect: the bank account (asset) is credited, and the AP account (liability)
    /// is debited, reducing the outstanding obligation. The vendor's <c>CurrentBalance</c> is
    /// decremented by the payment amount (floored at zero to absorb any floating-point drift).
    /// Bill status advances to <c>Paid</c> only when <c>BalanceDue</c> drops to zero or below;
    /// any positive remainder leaves the bill in <c>PartiallyPaid</c>.
    /// </summary>
    [HttpPost, ValidateAntiForgeryToken]
    [Authorize(Policy = AppConstants.Policies.CanManageBills)]
    public async Task<IActionResult> RecordPayment(RecordBillPaymentDto dto)
    {
        if (!ModelState.IsValid)
        {
            TempData["Error"] = string.Join(" ", ModelState.Values
                .SelectMany(v => v.Errors).Select(e => e.ErrorMessage));
            return RedirectToAction(nameof(Details), new { id = dto.BillId });
        }

        try
        {
            var bill = await _unitOfWork.Bills.GetByIdAsync(dto.BillId);
            if (bill == null) return NotFound();

            if (bill.Status == BillStatus.Paid || bill.Status == BillStatus.Voided)
            {
                TempData["Error"] = "Cannot record payment on a Paid or Voided bill.";
                return RedirectToAction(nameof(Details), new { id = dto.BillId });
            }

            if (dto.Amount > bill.BalanceDue)
            {
                TempData["Error"] = $"Payment amount ({dto.Amount:C}) exceeds balance due ({bill.BalanceDue:C}).";
                return RedirectToAction(nameof(Details), new { id = dto.BillId });
            }

            // The pay-from account must be a real money account (Asset/Liability) — defense in depth
            // against a tampered or stale selection before we post to it.
            if (!await AccountGuard.IsValidMoneyAccountAsync(_unitOfWork, dto.BankAccountId, bill.CompanyId))
            {
                TempData["Error"] = "Select a valid bank or credit account to pay from.";
                return RedirectToAction(nameof(Details), new { id = dto.BillId });
            }

            var currentUser = await _userManager.GetUserAsync(User);

            var payment = _mapper.Map<BillPayment>(dto);
            payment.PaymentNumber = await GeneratePaymentNumberAsync();
            payment.VendorId = bill.VendorId;
            payment.CompanyId = bill.CompanyId;
            payment.CreatedBy = currentUser?.Email;

            await _unitOfWork.BillPayments.AddAsync(payment);

            // Update bill financials
            bill.AmountPaid += dto.Amount;
            bill.Status = bill.BalanceDue <= 0 ? BillStatus.Paid : BillStatus.PartiallyPaid;
            bill.UpdatedAt = DateTime.UtcNow;
            bill.UpdatedBy = currentUser?.Email;

            // Update vendor balance
            var vendor = await _unitOfWork.Vendors.GetByIdAsync(bill.VendorId);
            if (vendor != null)
            {
                vendor.CurrentBalance -= dto.Amount;
                if (vendor.CurrentBalance < 0) vendor.CurrentBalance = 0;
                await _unitOfWork.Vendors.UpdateAsync(vendor);
            }

            await _unitOfWork.Bills.UpdateAsync(bill);

            // Update account balances: credit bank account, debit AP
            await _accountBalanceService.CreditAsync(payment.BankAccountId, dto.Amount);
            await _accountBalanceService.DebitAsync(bill.APAccountId, dto.Amount);

            await _unitOfWork.CompleteAsync();

            TempData["Success"] = $"Payment of {dto.Amount:C} recorded.";
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Error recording payment for bill {BillId}", dto.BillId);
            TempData["Error"] = "An error occurred while recording the payment.";
        }

        return RedirectToAction(nameof(Details), new { id = dto.BillId });
    }

    // -- Delete Payment -------------------------------------------------------

    /// <summary>
    /// Reverses a previously recorded payment. All double-entry effects of
    /// <see cref="RecordPayment"/> are unwound: the bank account is debited and the AP account
    /// is credited to restore the outstanding balance. The vendor's <c>CurrentBalance</c> is
    /// incremented by the reversed amount, and the bill status reverts to <c>Open</c> or
    /// <c>PartiallyPaid</c> depending on the remaining <c>AmountPaid</c> after reversal.
    /// </summary>
    [HttpPost, ValidateAntiForgeryToken]
    [Authorize(Policy = AppConstants.Policies.CanManageBills)]
    public async Task<IActionResult> DeletePayment(int paymentId, int billId)
    {
        try
        {
            var payment = await _unitOfWork.BillPayments.GetByIdAsync(paymentId);
            if (payment == null) return NotFound();

            var bill = await _unitOfWork.Bills.GetByIdAsync(billId);
            if (bill == null) return NotFound();

            var currentUser = await _userManager.GetUserAsync(User);

            // Reverse bill financials
            bill.AmountPaid -= payment.Amount;
            bill.Status = bill.AmountPaid <= 0 ? BillStatus.Open : BillStatus.PartiallyPaid;
            bill.UpdatedAt = DateTime.UtcNow;
            bill.UpdatedBy = currentUser?.Email;

            // Reverse vendor balance
            var vendor = await _unitOfWork.Vendors.GetByIdAsync(bill.VendorId);
            if (vendor != null)
            {
                vendor.CurrentBalance += payment.Amount;
                await _unitOfWork.Vendors.UpdateAsync(vendor);
            }

            await _unitOfWork.Bills.UpdateAsync(bill);

            // Reverse account balances: debit bank account, credit AP
            await _accountBalanceService.DebitAsync(payment.BankAccountId, payment.Amount);
            await _accountBalanceService.CreditAsync(bill.APAccountId, payment.Amount);

            await _unitOfWork.BillPayments.SoftDeleteAsync(paymentId);
            await _unitOfWork.CompleteAsync();

            TempData["Success"] = "Payment deleted and bill balance restored.";
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Error deleting payment {PaymentId}", paymentId);
            TempData["Error"] = "An error occurred while deleting the payment.";
        }

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

    // -- Edit Payment ---------------------------------------------------------

    /// <summary>
    /// Updates non-financial attributes of a payment (date, method, check number, memo) and,
    /// if the bank account changed, swaps the account-balance entries atomically: the old bank
    /// account is debited (reversing the credit) and the new bank account is credited. The bill
    /// amount on the AP side does not change so no AP balance adjustment is needed.
    /// </summary>
    [HttpPost, ValidateAntiForgeryToken]
    [Authorize(Policy = AppConstants.Policies.CanManageBills)]
    public async Task<IActionResult> EditPayment(EditBillPaymentDto dto)
    {
        if (!ModelState.IsValid)
        {
            TempData["Error"] = string.Join(" ", ModelState.Values
                .SelectMany(v => v.Errors).Select(e => e.ErrorMessage));
            return RedirectToAction(nameof(Details), new { id = dto.BillId });
        }

        try
        {
            var payment = await _unitOfWork.BillPayments.GetByIdAsync(dto.PaymentId);
            if (payment == null) return NotFound();

            // Reject an invalid new pay-from account before we move the balance to it.
            if (!await AccountGuard.IsValidMoneyAccountAsync(_unitOfWork, dto.BankAccountId, payment.CompanyId))
            {
                TempData["Error"] = "Select a valid bank or credit account.";
                return RedirectToAction(nameof(Details), new { id = dto.BillId });
            }

            var currentUser = await _userManager.GetUserAsync(User);

            // If the bank account changed, reverse the old balance entry and apply the new one
            if (payment.BankAccountId != dto.BankAccountId)
            {
                await _accountBalanceService.DebitAsync(payment.BankAccountId, payment.Amount);
                await _accountBalanceService.CreditAsync(dto.BankAccountId, payment.Amount);
            }

            payment.PaymentDate   = dto.PaymentDate;
            payment.PaymentMethod = dto.PaymentMethod;
            payment.BankAccountId = dto.BankAccountId;
            payment.CheckNumber   = dto.CheckNumber;
            payment.Memo          = dto.Memo;
            payment.UpdatedAt     = DateTime.UtcNow;
            payment.UpdatedBy     = currentUser?.Email;
            await _unitOfWork.BillPayments.UpdateAsync(payment);
            await _unitOfWork.CompleteAsync();

            TempData["Success"] = "Payment updated successfully.";
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Error editing bill payment {PaymentId}", dto.PaymentId);
            TempData["Error"] = "An error occurred while updating the payment.";
        }

        return RedirectToAction(nameof(Details), new { id = dto.BillId });
    }

    // -- Void -----------------------------------------------------------------

    /// <summary>
    /// Voids an open or partially-paid bill, removing the remaining AP liability from the ledger.
    /// Only the unpaid portion (<c>BalanceDue</c>) is reversed on the AP account � any payments
    /// already recorded remain as historical cash transactions. The vendor balance is likewise
    /// reduced only by the outstanding balance, not the total. To signal "fully settled" without
    /// leaving a positive <c>BalanceDue</c>, <c>AmountPaid</c> is set equal to <c>Total</c>
    /// (making the computed <c>BalanceDue</c> zero) rather than deleting payment records.
    /// Restricted to <c>CompanyAdminOnly</c> because voiding affects the AP aging and P&amp;L.
    /// </summary>
    [HttpPost, ValidateAntiForgeryToken]
    [Authorize(Policy = AppConstants.Policies.CompanyAdminOnly)]
    public async Task<IActionResult> Void(int id)
    {
        try
        {
            var bill = await _unitOfWork.Bills.GetByIdAsync(id);
            if (bill == null) return NotFound();

            if (bill.Status == BillStatus.Voided)
            {
                TempData["Error"] = "Bill is already voided.";
                return RedirectToAction(nameof(Details), new { id });
            }

            var currentUser = await _userManager.GetUserAsync(User);

            // Reverse vendor balance (only for the unpaid portion)
            var vendor = await _unitOfWork.Vendors.GetByIdAsync(bill.VendorId);
            if (vendor != null)
            {
                vendor.CurrentBalance -= bill.BalanceDue;
                if (vendor.CurrentBalance < 0) vendor.CurrentBalance = 0;
                await _unitOfWork.Vendors.UpdateAsync(vendor);
            }

            // Reverse AP account balance for the remaining (unpaid) balance
            await _accountBalanceService.DebitAsync(bill.APAccountId, bill.BalanceDue);

            bill.Status = BillStatus.Voided;
            bill.AmountPaid = bill.Total; // zero out BalanceDue (computed: Total - AmountPaid)
            bill.UpdatedAt = DateTime.UtcNow;
            bill.UpdatedBy = currentUser?.Email;

            await _unitOfWork.Bills.UpdateAsync(bill);
            await _unitOfWork.CompleteAsync();

            TempData["Success"] = $"Bill {bill.BillNumber} has been voided.";
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Error voiding bill {Id}", id);
            TempData["Error"] = "An error occurred while voiding the bill.";
        }

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

    // -- AJAX: Vendor default expense account --------------------------------

    /// <summary>
    /// AJAX endpoint that returns a vendor's default expense account and payment terms. Called by
    /// the Create/Edit bill form when the user changes the vendor dropdown so the UI can
    /// pre-populate line items and the Due Date without a full page reload.
    /// </summary>
    [HttpGet]
    public async Task<IActionResult> GetVendorDefaults(int vendorId)
    {
        var vendor = await _unitOfWork.Vendors.GetByIdAsync(vendorId);
        return Json(new
        {
            defaultExpenseAccountId = vendor?.DefaultExpenseAccountId,
            terms = vendor?.PaymentTerms
        });
    }

    // -- Helpers --------------------------------------------------------------

    /// <summary>
    /// Loads all dropdown lists needed by the Create and Edit views into <c>ViewBag</c>: vendors,
    /// AP accounts, expense/COGS/asset accounts for line items, bank accounts for payment, payment
    /// methods, and open jobs (for cost allocation). All account queries use a single
    /// <c>FindAsync</c> call and then filter in memory to minimise round trips.
    /// </summary>
    private async Task PopulateDropdownsAsync()
    {
        var dd = await AccountingDropdownHelper.LoadAsync(_unitOfWork);
        ViewBag.Vendors = dd.Vendors;
        ViewBag.APAccounts = dd.ApAccounts;
        ViewBag.ExpenseAccounts = dd.ExpenseAndAssetAccounts;
        ViewBag.BankAccounts = dd.BankAccounts;
        ViewBag.PaymentMethods = dd.PaymentMethods;
        ViewBag.Jobs = dd.ActiveJobs;
    }

    /// <summary>
    /// Generates a sequential bill number in the format <c>BILL-YYMM-####</c>. Uses
    /// <c>IgnoreQueryFilters()</c> so that soft-deleted bills are included in the sequence scan,
    /// preventing number reuse after a bill is deleted. The month-scoped prefix means sequence
    /// counters reset each month (e.g. BILL-2604-0001 in April 2026).
    /// </summary>
    private async Task<string> GenerateBillNumberAsync()
    {
        var prefix = $"BILL-{DateTime.Now:yyMM}-";
        var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
        var last = await _unitOfWork.Bills.GetLastBillNumberAsync(companyId, prefix);

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

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

    /// <summary>
    /// Generates a sequential payment reference number in the format <c>BPMT-YYMM-####</c>.
    /// Same monotonic sequence logic as <see cref="GenerateBillNumberAsync"/> � soft-deleted
    /// records are included in the scan so payment numbers are never reused.
    /// </summary>
    private async Task<string> GeneratePaymentNumberAsync()
    {
        var prefix = $"BPMT-{DateTime.Now:yyMM}-";
        var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
        var last = await _unitOfWork.Bills.GetLastPaymentNumberAsync(companyId, prefix);

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

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

    // -- Receipt File: Download / Remove -------------------------------------

    /// <summary>
    /// Downloads the receipt attachment for a bill as a file-download response. Unlike expense
    /// receipts (which are displayed inline for images), all bill receipt downloads are served
    /// as attachments to ensure the browser prompts for save rather than opening in a tab.
    /// </summary>
    [HttpGet]
    public async Task<IActionResult> DownloadReceipt(int id)
    {
        var bill = await _unitOfWork.Bills.GetByIdAsync(id);
        if (bill == null || string.IsNullOrEmpty(bill.ReceiptFilePath)) return NotFound();

        var result = await _blobStorage.DownloadAsync(_storageSettings.Containers.ReceiptImages, bill.ReceiptFilePath);
        if (!result.Success) return NotFound();

        var ext = Path.GetExtension(bill.ReceiptFilePath).ToLowerInvariant();
        var contentType = BlobFileHelper.GetContentType(ext);
        var fileName = $"receipt-{bill.BillNumber}{ext}";
        return File(result.Content, contentType, fileName);
    }

    /// <summary>
    /// Detaches and permanently deletes the blob-stored receipt file from a bill. The blob is
    /// deleted from Azure Blob Storage before the database reference is cleared, so there is no
    /// window where the UI shows a broken attachment link.
    /// </summary>
    [HttpPost, ValidateAntiForgeryToken]
    [Authorize(Policy = AppConstants.Policies.CanManageBills)]
    public async Task<IActionResult> RemoveReceipt(int id)
    {
        var bill = await _unitOfWork.Bills.GetByIdAsync(id);
        if (bill == null) return NotFound();

        if (!string.IsNullOrEmpty(bill.ReceiptFilePath))
        {
            await _blobStorage.DeleteAsync(_storageSettings.Containers.ReceiptImages, bill.ReceiptFilePath);
            bill.ReceiptFilePath = null;
            await _unitOfWork.CompleteAsync();
        }

        TempData["Success"] = "Receipt attachment removed.";
        return RedirectToAction(nameof(Details), new { id });
    }

    // -- AI: Receipt Scanning -------------------------------------------------

    /// <summary>
    /// AI-powered receipt scanning endpoint. Accepts an image or PDF of a vendor receipt, passes
    /// the raw bytes to <see cref="IAccountingAiService.ScanReceiptAsync"/> (Anthropic Claude
    /// Sonnet 4.6), and returns structured JSON containing extracted fields: vendor name, date,
    /// amount, and suggested line-item account mappings. The caller's JS wizard uses the response
    /// to pre-fill the bill Create form. Rate-limited to the <c>Ai</c> policy to control
    /// Anthropic API costs. The available expense account list is passed with the request so the
    /// model can match categories to the company's specific chart of accounts.
    /// </summary>
    [HttpPost]
    [Authorize(Policy = AppConstants.Policies.CanManageBills)]
    [EnableRateLimiting(AppConstants.RateLimitPolicies.Ai)]
    public async Task<IActionResult> ScanReceipt(IFormFile? receiptImage)
    {
        if (receiptImage == null || receiptImage.Length == 0)
            return Json(new { success = false, error = "No file uploaded." });

        var allowedTypes = new[] { "image/jpeg", "image/jpg", "image/png", "image/gif", "image/webp", "application/pdf" };
        if (!allowedTypes.Contains(receiptImage.ContentType.ToLowerInvariant()))
            return Json(new { success = false, error = "Supported formats: JPG, PNG, GIF, WebP, or PDF." });

        if (receiptImage.Length > 10 * 1024 * 1024)
            return Json(new { success = false, error = "File must be under 10 MB." });

        // Load expense accounts for matching
        var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
        var allAccounts = await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == companyId && a.IsActive);
        var expenseAccounts = allAccounts
            .Where(a => a.AccountType == AccountType.Expense ||
                        a.AccountType == AccountType.CostOfGoods ||
                        a.AccountType == AccountType.Asset)
            .Select(a => new AccountSummary
            {
                Id = a.Id,
                AccountNumber = a.AccountNumber,
                Name = a.Name,
                AccountType = a.AccountType.ToString(),
                AccountSubType = a.AccountSubType.ToString()
            })
            .ToList();

        using var ms = new MemoryStream();
        await receiptImage.CopyToAsync(ms);
        var imageBytes = ms.ToArray();

        var result = await _accountingAi.ScanReceiptAsync(imageBytes, receiptImage.ContentType, expenseAccounts);
        var userId = User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value ?? "";
        await _usageLogger.LogAsync(companyId, userId, AppConstants.AiFeatures.ReceiptScan, inputLength: (int)receiptImage.Length);
        return Json(result);
    }

    // -- AI: Account Suggestion ------------------------------------------------

    /// <summary>
    /// AI-powered account categorisation for a single bill line item. When the caller does not
    /// supply <c>AvailableAccounts</c> (e.g. on first load) the controller fetches the active
    /// expense/COGS/asset accounts from the database and adds them to the request before
    /// forwarding to <see cref="IAccountingAiService.SuggestAccountAsync"/>. This lazy-load
    /// approach lets the JS call this endpoint on blur without requiring the view to embed the
    /// full account list in the DOM. Rate-limited to the <c>Ai</c> policy.
    /// </summary>
    [HttpPost]
    [Authorize(Policy = AppConstants.Policies.CanManageBills)]
    [EnableRateLimiting(AppConstants.RateLimitPolicies.Ai)]
    public async Task<IActionResult> SuggestAccount([FromBody] AccountSuggestionRequest request)
    {
        if (request == null)
            return Json(new { success = false, error = "Invalid request." });

        // Load expense accounts if not supplied
        if (!request.AvailableAccounts.Any())
        {
            var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
            var allAccounts = await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == companyId && a.IsActive);
            request.AvailableAccounts = allAccounts
                .Where(a => a.AccountType == AccountType.Expense ||
                            a.AccountType == AccountType.CostOfGoods ||
                            a.AccountType == AccountType.Asset)
                .Select(a => new AccountSummary
                {
                    Id = a.Id,
                    AccountNumber = a.AccountNumber,
                    Name = a.Name,
                    AccountType = a.AccountType.ToString(),
                    AccountSubType = a.AccountSubType.ToString()
                })
                .ToList();
        }

        var result = await _accountingAi.SuggestAccountAsync(request);
        var companyId2 = int.TryParse(User.FindFirst("CompanyId")?.Value, out var cid2) ? cid2 : 0;
        var userId2 = User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value ?? "";
        await _usageLogger.LogAsync(companyId2, userId2, AppConstants.AiFeatures.AccountSuggest, inputLength: (request.Description?.Length ?? 0));
        return Json(result);
    }

    // -- AI: Recurring Bill Detection ------------------------------------------

    /// <summary>
    /// GET page � displays the recurring bill detection tool. No data is pre-fetched here;
    /// the user triggers the scan by clicking a button which calls <see cref="RunRecurringDetection"/>.
    /// </summary>
    public IActionResult RecurringDetection() => View();

    /// <summary>
    /// AJAX POST � loads up to 12 months of bill history for the company and passes it to
    /// Claude for recurring pattern analysis. Only posted bills (Draft/Open/Partial/Paid) are
    /// included; Voided bills are excluded so cancelled payments do not distort the pattern.
    /// Results are returned as JSON for client-side rendering in the view.
    /// </summary>
    [HttpPost]
    [EnableRateLimiting(AppConstants.RateLimitPolicies.Ai)]
    [ValidateAntiForgeryToken]
    public async Task<IActionResult> RunRecurringDetection()
    {
        try
        {
            var companyId = int.TryParse(User.FindFirst("CompanyId")?.Value, out var cid) ? cid : 0;
            var cutoff = DateTime.Today.AddMonths(-12);

            var bills = (await _unitOfWork.Bills.FindAsync(b => b.CompanyId == companyId, false, b => b.Vendor))
                .Where(b => b.Status != BillStatus.Voided && b.BillDate >= cutoff)
                .ToList();

            if (!bills.Any())
                return Json(new RecurringBillDetectionResult
                {
                    Success  = true,
                    Insights = new List<string> { "No bill history found in the last 12 months." }
                });

            var companyName = (await _unitOfWork.Companies.GetByIdAsync(companyId))?.CompanyName ?? "Your Company";

            var request = new RecurringBillDetectionRequest
            {
                CompanyName = companyName,
                Bills = bills.Select(b => new RecurringBillHistoryItem
                {
                    VendorName  = b.Vendor?.CompanyName ?? $"Vendor #{b.VendorId}",
                    BillNumber  = b.BillNumber,
                    Amount      = b.Total,
                    DateIso     = b.BillDate.ToString("yyyy-MM-dd"),
                    Memo        = b.Memo
                }).ToList()
            };

            var result = await _accountingAi.DetectRecurringBillsAsync(request);
            var userId = User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value ?? "";
            await _usageLogger.LogAsync(companyId, userId, AppConstants.AiFeatures.RecurringBillDetection, result.Success);
            return Json(result);
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Error running recurring bill detection");
            return Json(new RecurringBillDetectionResult { Success = false, ErrorMessage = "An error occurred while analyzing bill patterns." });
        }
    }

    // -- Receipt File Helpers --------------------------------------------------

    /// <summary>
    /// Uploads a receipt file to Azure Blob Storage under the path
    /// <c>{companyId}/bill-receipts/{billId}/{guid}{ext}</c>. The GUID component prevents
    /// name collisions when multiple receipts are uploaded during a single edit session. Returns
    /// the blob name (relative path) to be stored on the <c>Bill.ReceiptFilePath</c> field, or
    /// <c>null</c> when the upload fails so the caller can surface a non-fatal warning.
    /// </summary>
    private async Task<string?> UploadReceiptAsync(IFormFile file, int billId, int companyId)
    {
        var ext = Path.GetExtension(file.FileName).ToLowerInvariant();
        var blobName = $"{companyId}/bill-receipts/{billId}/{Guid.NewGuid()}{ext}";
        using var stream = file.OpenReadStream();
        var result = await _blobStorage.UploadAsync(_storageSettings.Containers.ReceiptImages, blobName, stream, BlobFileHelper.GetContentType(ext));
        return result.Success ? blobName : null;
    }
}
