using AutoMapper;
using PowderCoating.Shared.Constants;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Rendering;
using PowderCoating.Application.DTOs.Common;
using PowderCoating.Application.DTOs.Inventory;
using PowderCoating.Application.Interfaces;
using PowderCoating.Application.Services;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Enums;
using PowderCoating.Core.Interfaces;
using Microsoft.AspNetCore.Identity;
using QRCoder;
using System.Drawing;
using System.Drawing.Imaging;

namespace PowderCoating.Web.Controllers;

[Authorize(Policy = AppConstants.Policies.CanManageInventory)]
public class InventoryController : Controller
{
    private const decimal DefaultTransferEfficiency = 65m;

    private readonly IUnitOfWork _unitOfWork;
    private readonly IMapper _mapper;
    private readonly ILogger<InventoryController> _logger;
    private readonly ITenantContext _tenantContext;
    private readonly IMeasurementConversionService _measurementService;
    private readonly IInventoryAiLookupService _aiLookupService;
    private readonly ISubscriptionService _subscriptionService;
    private readonly UserManager<ApplicationUser> _userManager;
    private readonly IAccountBalanceService _accountBalanceService;

    public InventoryController(
        IUnitOfWork unitOfWork,
        IMapper mapper,
        ILogger<InventoryController> logger,
        ITenantContext tenantContext,
        IMeasurementConversionService measurementService,
        IInventoryAiLookupService aiLookupService,
        ISubscriptionService subscriptionService,
        UserManager<ApplicationUser> userManager,
        IAccountBalanceService accountBalanceService)
    {
        _unitOfWork = unitOfWork;
        _mapper = mapper;
        _logger = logger;
        _tenantContext = tenantContext;
        _measurementService = measurementService;
        _aiLookupService = aiLookupService;
        _subscriptionService = subscriptionService;
        _userManager = userManager;
        _accountBalanceService = accountBalanceService;
    }

    /// <summary>
    /// Displays the paginated inventory list with optional keyword search, category filter,
    /// color family filter, and a low-stock quick-filter. When lowStockOnly is active the
    /// default sort switches to QuantityOnHand ascending so the most depleted items surface
    /// immediately. Stats (total value, active count, low-stock count) are computed directly
    /// on the DbSet using aggregate SQL to avoid loading all rows into memory.
    /// </summary>
    public async Task<IActionResult> Index(
        string? searchTerm,
        string? category,
        string? location,
        string? colorFamily,
        string? sortColumn,
        string sortDirection = "asc",
        bool lowStockOnly = false,
        int pageNumber = 1,
        int pageSize = 25)
    {
        try
        {
            // Default sort to QuantityOnHand asc when showing low-stock filter
            var defaultSort = lowStockOnly ? "QuantityOnHand" : "Name";
            var defaultDir  = lowStockOnly ? "asc" : sortDirection;

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

            var hasSearch      = !string.IsNullOrWhiteSpace(searchTerm);
            var hasCategory    = !string.IsNullOrWhiteSpace(category);
            var hasLocation    = !string.IsNullOrWhiteSpace(location);
            var hasColorFamily = !string.IsNullOrWhiteSpace(colorFamily);

            var search   = searchTerm?.ToLower() ?? "";
            var cat      = category ?? "";
            var loc      = location ?? "";
            var colorFam = colorFamily ?? "";

            // Single composable predicate — EF Core evaluates the captured booleans as constants
            // so inactive conditions fold to true and are omitted from the generated SQL WHERE clause.
            System.Linq.Expressions.Expression<Func<InventoryItem, bool>>? filter = null;
            if (hasSearch || hasCategory || hasLocation || hasColorFamily || lowStockOnly)
            {
                filter = i =>
                    (!lowStockOnly || (i.IsActive && i.QuantityOnHand <= i.ReorderPoint)) &&
                    (!hasSearch || (i.SKU.ToLower().Contains(search) || i.Name.ToLower().Contains(search)
                        || (i.Description != null && i.Description.ToLower().Contains(search))
                        || (i.ColorName != null && i.ColorName.ToLower().Contains(search))
                        || (i.Manufacturer != null && i.Manufacturer.ToLower().Contains(search)))) &&
                    (!hasCategory || i.Category.ToLower() == cat.ToLower()) &&
                    (!hasLocation || (i.Location != null && i.Location.ToLower() == loc.ToLower())) &&
                    (!hasColorFamily || (i.ColorFamilies != null && (
                        i.ColorFamilies == colorFam ||
                        i.ColorFamilies.StartsWith(colorFam + ",") ||
                        i.ColorFamilies.EndsWith("," + colorFam) ||
                        i.ColorFamilies.Contains("," + colorFam + ","))));
            }

            // Build orderBy function
            Func<IQueryable<InventoryItem>, IOrderedQueryable<InventoryItem>> orderBy = gridRequest.SortColumn switch
            {
                "SKU" => q => gridRequest.SortDirection == "asc" ? q.OrderBy(i => i.SKU) : q.OrderByDescending(i => i.SKU),
                "Name" => q => gridRequest.SortDirection == "asc" ? q.OrderBy(i => i.Name) : q.OrderByDescending(i => i.Name),
                "Category" => q => gridRequest.SortDirection == "asc" ? q.OrderBy(i => i.Category) : q.OrderByDescending(i => i.Category),
                "ColorName" => q => gridRequest.SortDirection == "asc" ? q.OrderBy(i => i.ColorName) : q.OrderByDescending(i => i.ColorName),
                "QuantityOnHand" => q => gridRequest.SortDirection == "asc" ? q.OrderBy(i => i.QuantityOnHand) : q.OrderByDescending(i => i.QuantityOnHand),
                "UnitCost" => q => gridRequest.SortDirection == "asc" ? q.OrderBy(i => i.UnitCost) : q.OrderByDescending(i => i.UnitCost),
                "IsActive" => q => gridRequest.SortDirection == "asc" ? q.OrderBy(i => i.IsActive) : q.OrderByDescending(i => i.IsActive),
                _ => q => q.OrderBy(i => i.Name)
            };

            // Get paged data with PrimaryVendor and InventoryCategory eager loading
            var (items, totalCount) = await _unitOfWork.InventoryItems.GetPagedAsync(
                gridRequest.PageNumber,
                gridRequest.PageSize,
                filter,
                orderBy,
                i => i.PrimaryVendor,
                i => i.InventoryCategory);

            // Map to DTOs using AutoMapper
            var itemDtos = _mapper.Map<List<InventoryListDto>>(items);

            var pagedResult = PagedResult<InventoryListDto>.From(gridRequest, itemDtos, totalCount);

            // Load all items once to compute sidebar stats and dropdown option lists in memory
            var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
            var allItems = (await _unitOfWork.InventoryItems.FindAsync(i => i.CompanyId == companyId)).ToList();
            ViewBag.Categories         = allItems.Select(i => i.Category).Where(c => c != null).Distinct().OrderBy(c => c).ToList();
            ViewBag.Locations          = allItems.Select(i => i.Location).Where(l => !string.IsNullOrWhiteSpace(l)).Distinct().OrderBy(l => l).ToList();
            ViewBag.ColorFamilies      = allItems
                .Where(i => !string.IsNullOrEmpty(i.ColorFamilies))
                .SelectMany(i => i.ColorFamilies!.Split(',', StringSplitOptions.RemoveEmptyEntries))
                .Select(f => f.Trim())
                .Where(f => f.Length > 0)
                .Distinct()
                .OrderBy(f => f)
                .ToList();
            ViewBag.StatsLowStockCount = allItems.Count(i => i.IsActive && i.QuantityOnHand <= i.ReorderPoint);
            ViewBag.StatsActiveCount   = allItems.Count(i => i.IsActive);
            ViewBag.StatsTotalValue    = allItems.Sum(i => (decimal?)i.QuantityOnHand * i.UnitCost) ?? 0m;

            // Set ViewBag for sorting and filters
            ViewBag.SearchTerm   = searchTerm;
            ViewBag.Category     = category;
            ViewBag.Location     = location;
            ViewBag.ColorFamily  = colorFamily;
            ViewBag.LowStockOnly = lowStockOnly;
            ViewBag.SortColumn   = gridRequest.SortColumn;
            ViewBag.SortDirection = gridRequest.SortDirection;

            return View(pagedResult);
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Error retrieving inventory items");
            TempData["Error"] = "An error occurred while loading inventory items.";
            return View(new PagedResult<InventoryListDto>());
        }
    }

    /// <summary>
    /// Returns a print-optimised list of all active inventory items in a given bin/location.
    /// Renders without the site chrome (no layout) so the browser print dialog produces a
    /// clean page. Items are sorted by name within the bin.
    /// </summary>
    public async Task<IActionResult> PrintBin(string location)
    {
        if (string.IsNullOrWhiteSpace(location))
            return RedirectToAction(nameof(Index));

        var loc = location.Trim();
        var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
        var items = await _unitOfWork.InventoryItems.FindAsync(
            i => i.CompanyId == companyId && i.Location != null && i.Location.ToLower() == loc.ToLower());

        var dtos = _mapper.Map<List<InventoryListDto>>(items.OrderBy(i => i.Name).ToList());
        ViewBag.Location = loc;
        ViewBag.PrintedAt = DateTime.Now;
        return View(dtos);
    }

    /// <summary>
    /// Renders the inventory item detail page. The primary vendor name is looked up
    /// separately because the repository does not eager-load Vendor by default, avoiding
    /// the join on list queries where the vendor name is not displayed. The measurement
    /// unit label (sqft vs m²) is resolved from the company's metric preference so the
    /// coverage field is always displayed in the unit the shop recognizes.
    /// </summary>
    public async Task<IActionResult> Details(int? id)
    {
        if (id == null)
        {
            return NotFound();
        }

        try
        {
            var item = await _unitOfWork.InventoryItems.GetByIdAsync(id.Value, false, i => i.InventoryCategory);
            if (item == null)
            {
                return NotFound();
            }

            var itemDto = _mapper.Map<InventoryItemDto>(item);

            // Get vendor name if exists
            if (item.PrimaryVendorId.HasValue)
            {
                var vendor = await _unitOfWork.Vendors.GetByIdAsync(item.PrimaryVendorId.Value);
                itemDto.PrimaryVendorName = vendor?.CompanyName;
            }

            // Derive IsCoating from the category (not stored on the item)
            ViewBag.IsCoating = item.InventoryCategory?.IsCoating ?? false;

            // Set measurement units for view
            var useMetric = await _tenantContext.UseMetricSystemAsync();
            ViewBag.CoverageUnit = _measurementService.GetCoverageUnitLabel(useMetric);

            // Manufacturer-level catalog status: prefer the linked catalog row, fall back to an
            // identity match for items added before they were linked. Drives the "discontinued by
            // manufacturer — cannot reorder" warning. This is distinct from the shop's own
            // IsActive/DiscontinuedDate (whether the shop still stocks it).
            var catalogItem = item.PowderCatalogItemId.HasValue
                ? await _unitOfWork.PowderCatalog.GetByIdAsync(item.PowderCatalogItemId.Value)
                : await FindCatalogMatchAsync(item.Manufacturer, item.ManufacturerPartNumber);
            ViewBag.CatalogDiscontinued = catalogItem?.IsDiscontinued ?? false;
            ViewBag.CatalogVendorName = catalogItem?.VendorName;
            ViewBag.CatalogProductUrl = catalogItem?.ProductUrl;

            return View(itemDto);
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Error retrieving inventory item {ItemId}", id);
            TempData["Error"] = "An error occurred while loading the inventory item.";
            return RedirectToAction(nameof(Index));
        }
    }

    /// <summary>
    /// Renders the inventory item creation form. Calls <see cref="PopulateDropdowns"/> to
    /// load vendor, category, unit-of-measure, and chart-of-accounts options, and exposes
    /// the company's metric preference so the view can label coverage fields correctly.
    /// </summary>
    public async Task<IActionResult> Create()
    {
        await PopulateDropdowns();

        // Set measurement unit labels based on company preference
        var useMetric = await _tenantContext.UseMetricSystemAsync();
        ViewBag.UseMetric = useMetric;
        ViewBag.CoverageUnit = _measurementService.GetCoverageUnitLabel(useMetric);

        // Pre-fill the GL account dropdowns from the company's configured defaults so new items
        // inherit them (the user can still change or clear them on the form).
        var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
        var prefs = await _unitOfWork.CompanyPreferences
            .FirstOrDefaultAsync(p => p.CompanyId == companyId && !p.IsDeleted);

        return View(new CreateInventoryItemDto
        {
            CoverageSqFtPerLb = 30,
            TransferEfficiency = 65,
            InventoryAccountId = prefs?.DefaultInventoryAccountId,
            CogsAccountId = prefs?.DefaultCogsAccountId
        });
    }

    /// <summary>
    /// Persists a new inventory item. The item name is normalized to title-case on save
    /// so the list view is consistently formatted regardless of how staff typed it. The
    /// legacy string Category field is populated from the selected InventoryCategoryLookup
    /// to keep older code paths that read Category by string working without migration.
    /// </summary>
    [HttpPost]
    [ValidateAntiForgeryToken]
    public async Task<IActionResult> Create(CreateInventoryItemDto dto)
    {
        if (!ModelState.IsValid)
        {
            await PopulateDropdowns();
            return View(dto);
        }

        var category = dto.InventoryCategoryId.HasValue
            ? await _unitOfWork.InventoryCategoryLookups.GetByIdAsync(dto.InventoryCategoryId.Value)
            : null;
        var duplicate = await FindInventoryDuplicateAsync(
            dto.SKU,
            dto.Manufacturer,
            dto.ManufacturerPartNumber,
            dto.ColorName,
            category?.IsCoating == true);

        if (duplicate != null &&
            (duplicate.MatchType == InventoryDuplicateMatchType.Sku ||
             dto.DuplicateOverrideInventoryItemId != duplicate.Item.Id))
        {
            ModelState.AddModelError(
                duplicate.MatchType == InventoryDuplicateMatchType.Sku ? nameof(dto.SKU) : string.Empty,
                BuildDuplicateMessage(duplicate));
            await PopulateDropdowns();
            return View(dto);
        }

        try
        {
            var item = _mapper.Map<InventoryItem>(dto);
            item.CreatedAt = DateTime.UtcNow;
            item.IsActive = true;
            item.Name = ToTitleCase(item.Name);

            // Populate legacy Category field from lookup table
            if (category != null)
                item.Category = category.DisplayName;

            // Link to the platform catalog row when this item's identity matches one, so the detail
            // screen can show manufacturer-level status (discontinued / cannot reorder) and quotes
            // can use the current catalog price.
            var catalogMatch = await FindCatalogMatchAsync(item.Manufacturer, item.ManufacturerPartNumber);
            if (catalogMatch != null)
            {
                item.PowderCatalogItemId = catalogMatch.Id;
                if (catalogMatch.UnitPrice > 0)
                {
                    item.CatalogReferencePrice = catalogMatch.UnitPrice;
                    item.CatalogPriceUpdatedAt = DateTime.UtcNow;
                }
            }

            await _unitOfWork.InventoryItems.AddAsync(item);
            await _unitOfWork.SaveChangesAsync();

            // Record opening stock as an Initial transaction
            if (item.QuantityOnHand > 0)
            {
                var txn = new InventoryTransaction
                {
                    InventoryItemId = item.Id,
                    TransactionType = InventoryTransactionType.Initial,
                    Quantity = item.QuantityOnHand,
                    UnitCost = item.UnitCost,
                    TotalCost = item.QuantityOnHand * item.UnitCost,
                    TransactionDate = DateTime.UtcNow,
                    BalanceAfter = item.QuantityOnHand,
                    Notes = "Opening stock on item creation"
                };
                await _unitOfWork.InventoryTransactions.AddAsync(txn);
                await _unitOfWork.SaveChangesAsync();
            }

            // Contribute/sync to the platform powder catalog if we have enough identity data.
            // Runs silently — a failure here never blocks the inventory save.
            if (!string.IsNullOrWhiteSpace(dto.Manufacturer) && !string.IsNullOrWhiteSpace(dto.ManufacturerPartNumber))
            {
                var catalogResult = new InventoryAiLookupResult
                {
                    Manufacturer           = dto.Manufacturer,
                    ManufacturerPartNumber = dto.ManufacturerPartNumber,
                    ColorName              = dto.ColorName ?? item.Name,
                    Finish                 = dto.Finish,
                    CureTemperatureF       = dto.CureTemperatureF,
                    CureTimeMinutes        = dto.CureTimeMinutes,
                    ColorFamilies          = dto.ColorFamilies,
                    RequiresClearCoat      = dto.RequiresClearCoat ? true : (bool?)null,
                    CoverageSqFtPerLb      = dto.CoverageSqFtPerLb,
                    SpecificGravity        = dto.SpecificGravity,
                    TransferEfficiency     = dto.TransferEfficiency,
                    UnitCostPerLb          = dto.UnitCost > 0 ? dto.UnitCost : null,
                    SpecPageUrl            = dto.SpecPageUrl,
                    ImageUrl               = dto.ImageUrl,
                    SdsUrl                 = dto.SdsUrl,
                    TdsUrl                 = dto.TdsUrl,
                };
                await EnrichFromCatalogAsync(catalogResult, autoContribute: true);
            }

            TempData["Success"] = "Inventory item created successfully.";
            return RedirectToAction(nameof(Details), new { id = item.Id });
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Error creating inventory item");
            TempData["Error"] = "An error occurred while creating the inventory item.";
            await PopulateDropdowns();
            return View(dto);
        }
    }

    /// <summary>
    /// Renders the inventory item edit form, pre-populated via AutoMapper. Dropdowns and
    /// measurement unit labels are reloaded the same way as <see cref="Create"/> so the
    /// form is always consistent regardless of any lookup-table changes since the item
    /// was created.
    /// </summary>
    public async Task<IActionResult> Edit(int? id)
    {
        if (id == null)
        {
            return NotFound();
        }

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

            var dto = _mapper.Map<UpdateInventoryItemDto>(item);
            dto.CoverageSqFtPerLb ??= 30;
            dto.TransferEfficiency ??= 65;
            await PopulateDropdowns();

            // Set measurement unit labels based on company preference
            var useMetric = await _tenantContext.UseMetricSystemAsync();
            ViewBag.UseMetric = useMetric;
            ViewBag.CoverageUnit = _measurementService.GetCoverageUnitLabel(useMetric);

            return View(dto);
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Error retrieving inventory item {ItemId} for edit", id);
            TempData["Error"] = "An error occurred while loading the inventory item.";
            return RedirectToAction(nameof(Index));
        }
    }

    /// <summary>
    /// Persists edits to an existing inventory item. Mirrors the Create POST behavior for
    /// name normalization and legacy Category field population. If the user clears the
    /// category selection, Category is set to an empty string rather than left stale, so
    /// category-based filters in <see cref="Index"/> work correctly.
    /// </summary>
    [HttpPost]
    [ValidateAntiForgeryToken]
    public async Task<IActionResult> Edit(int id, UpdateInventoryItemDto dto)
    {
        if (id != dto.Id)
        {
            return NotFound();
        }

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

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

            var previousQty = item.QuantityOnHand;
            _mapper.Map(dto, item);
            item.UpdatedAt = DateTime.UtcNow;
            item.Name = ToTitleCase(item.Name);

            // Populate legacy Category field from lookup table
            if (item.InventoryCategoryId.HasValue)
            {
                var category = await _unitOfWork.InventoryCategoryLookups.GetByIdAsync(item.InventoryCategoryId.Value);
                if (category != null)
                    item.Category = category.DisplayName;
                else
                    item.Category = string.Empty;
            }
            else
            {
                item.Category = string.Empty;
            }

            await _unitOfWork.InventoryItems.UpdateAsync(item);
            await _unitOfWork.SaveChangesAsync();

            // Record an Adjustment transaction when quantity on hand changes
            var qtyDelta = item.QuantityOnHand - previousQty;
            if (qtyDelta != 0)
            {
                var txn = new InventoryTransaction
                {
                    InventoryItemId = item.Id,
                    TransactionType = InventoryTransactionType.Adjustment,
                    Quantity = qtyDelta,
                    UnitCost = item.UnitCost,
                    TotalCost = Math.Abs(qtyDelta) * item.UnitCost,
                    TransactionDate = DateTime.UtcNow,
                    BalanceAfter = item.QuantityOnHand,
                    Notes = "Manual quantity adjustment via edit"
                };
                await _unitOfWork.InventoryTransactions.AddAsync(txn);
                await _unitOfWork.SaveChangesAsync();
            }

            TempData["Success"] = "Inventory item updated successfully.";
            return RedirectToAction(nameof(Details), new { id = item.Id });
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Error updating inventory item {ItemId}", id);
            TempData["Error"] = "An error occurred while updating the inventory item.";
            await PopulateDropdowns();
            return View(dto);
        }
    }

    /// <summary>
    /// Renders the delete confirmation page for an inventory item, showing the item
    /// summary so staff can confirm before permanently removing access to the record.
    /// </summary>
    public async Task<IActionResult> Delete(int? id)
    {
        if (id == null)
        {
            return NotFound();
        }

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

            var itemDto = _mapper.Map<InventoryItemDto>(item);
            return View(itemDto);
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Error retrieving inventory item {ItemId} for delete", id);
            TempData["Error"] = "An error occurred while loading the inventory item.";
            return RedirectToAction(nameof(Index));
        }
    }

    /// <summary>
    /// Soft-deletes the inventory item, preserving its transaction history and any job
    /// references. Because powder items are linked to JobItem coats and invoice line items,
    /// a hard delete would break historical cost reporting; soft delete keeps the data
    /// intact and invisible to normal queries.
    /// </summary>
    [HttpPost, ActionName("Delete")]
    [ValidateAntiForgeryToken]
    public async Task<IActionResult> DeleteConfirmed(int id)
    {
        try
        {
            var item = await _unitOfWork.InventoryItems.GetByIdAsync(id);
            if (item == null)
            {
                return NotFound();
            }

            await _unitOfWork.InventoryItems.SoftDeleteAsync(item);
            await _unitOfWork.SaveChangesAsync();

            TempData["Success"] = $"Inventory item {item.Name} deleted successfully.";
            return RedirectToAction(nameof(Index));
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Error deleting inventory item {ItemId}", id);
            TempData["Error"] = "An error occurred while deleting the inventory item.";
            return RedirectToAction(nameof(Index));
        }
    }

    /// <summary>
    /// Returns a partial view listing jobs that use the specified inventory item in any
    /// coat. This is rendered as an AJAX partial inside the Details view so the main page
    /// loads quickly and the job list is fetched on demand. Capped at 500 jobs to avoid
    /// unbounded result sets on heavily-used powder colors.
    /// </summary>
    [HttpGet]
    public async Task<IActionResult> JobsUsing(int id)
    {
        try
        {
            var (jobs, _) = await _unitOfWork.Jobs.GetPagedAsync(
                pageNumber: 1,
                pageSize: 500,
                filter: j => j.JobItems.Any(ji => ji.Coats.Any(c => c.InventoryItemId == id)),
                orderBy: q => q.OrderByDescending(j => j.CreatedAt),
                j => j.Customer,
                j => j.Photos,
                j => j.JobStatus);

            return PartialView("_JobsUsingPowder", jobs.ToList());
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Error retrieving jobs using inventory item {ItemId}", id);
            return PartialView("_JobsUsingPowder", new List<PowderCoating.Core.Entities.Job>());
        }
    }

    /// <summary>
    /// Returns a paginated JSON list of job photos whose tag set contains the inventory
    /// item's color name or product name. Two-pass matching is used: the first pass
    /// queries the DB with a LIKE to narrow candidates, then an in-memory exact-token
    /// comparison rejects false positives (e.g. "Black" matching "Gloss Black"). This
    /// avoids tagging a photo with the wrong powder when similar names overlap.
    /// </summary>
    [HttpGet]
    public async Task<IActionResult> TaggedPhotos(int id, int page = 1, int pageSize = 12)
    {
        try
        {
            var item = await _unitOfWork.InventoryItems.GetByIdAsync(id);
            if (item == null)
                return Json(new { success = false });

            // Build SQL-level pre-filter using the item's identifiers
            var colorName = item.ColorName?.Trim().ToLower();
            var name      = item.Name?.Trim().ToLower();

            if (string.IsNullOrEmpty(colorName) && string.IsNullOrEmpty(name))
                return Json(new { success = true, photos = Array.Empty<object>(), totalCount = 0, page, pageSize });

            // Exact tag match (avoid "Black" matching "Gloss Black Semi-Gloss")
            var allMatches = await _unitOfWork.JobPhotos.GetTaggedPhotosAsync(colorName, name);

            var searchTerms = new[] { colorName, name }
                .Where(s => !string.IsNullOrEmpty(s))
                .Distinct()
                .ToArray();

            var matched = allMatches.Where(p =>
                p.Tags!.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
                       .Any(t => searchTerms.Any(term => t.Equals(term, StringComparison.OrdinalIgnoreCase))))
                .ToList();

            var totalCount = matched.Count;
            var photos = matched
                .Skip((page - 1) * pageSize)
                .Take(pageSize)
                .Select(p => new
                {
                    id          = p.Id,
                    jobId       = p.JobId,
                    jobNumber   = p.Job?.JobNumber ?? "",
                    customerName = p.Job?.Customer?.CompanyName ?? "",
                    caption     = p.Caption ?? "",
                    tags        = p.Tags ?? "",
                    uploadedDate = p.UploadedDate.ToString("MMM dd, yyyy")
                })
                .ToList();

            return Json(new { success = true, photos, totalCount, page, pageSize });
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Error retrieving tagged photos for inventory item {ItemId}", id);
            return Json(new { success = false });
        }
    }

    /// <summary>
    /// Returns a paginated JSON list of job photos from jobs that use this inventory item
    /// in any coat — matched via the direct InventoryItemId FK on JobItemCoat. This is
    /// more reliable than TaggedPhotos which relies on photo tags being manually set.
    /// </summary>
    [HttpGet]
    public async Task<IActionResult> PhotosByPowder(int id, int page = 1, int pageSize = 20)
    {
        try
        {
            var photos = await _unitOfWork.JobPhotos.GetPhotosByPowderItemAsync(id);

            var totalCount = photos.Count;
            var paged = photos
                .Skip((page - 1) * pageSize)
                .Take(pageSize)
                .Select(p => new
                {
                    id           = p.Id,
                    jobId        = p.JobId,
                    jobNumber    = p.Job?.JobNumber ?? "",
                    customerName = p.Job?.Customer?.CompanyName ?? p.Job?.Customer?.ContactFirstName ?? "",
                    caption      = p.Caption ?? "",
                    uploadedDate = p.UploadedDate.ToString("MMM dd, yyyy")
                })
                .ToList();

            return Json(new { success = true, photos = paged, totalCount, page, pageSize });
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Error retrieving photos by powder for inventory item {ItemId}", id);
            return Json(new { success = false });
        }
    }

    /// <summary>
    /// Generates the next available SKU for a given category in the format
    /// CODE-YYMM-#### (e.g. POWD-2604-0003). The category code is truncated or
    /// padded to exactly 4 characters. IgnoreQueryFilters is used when scanning
    /// existing SKUs so that deleted items do not leave gaps that could cause number
    /// reuse — a reused SKU on a purchase order would create accounting confusion.
    /// </summary>
    [HttpGet]
    public async Task<IActionResult> GenerateSku(int categoryId)
    {
        var category = await _unitOfWork.InventoryCategoryLookups.GetByIdAsync(categoryId);
        if (category == null) return Json(new { sku = "" });

        // Build prefix from category code: first 4 chars, uppercase, e.g. "POWD", "PRIM", "MASK"
        var code = category.CategoryCode.Length >= 4
            ? category.CategoryCode[..4].ToUpperInvariant()
            : category.CategoryCode.ToUpperInvariant().PadRight(4, 'X');

        var yearMonth = DateTime.Now.ToString("yyMM");
        var prefix = $"{code}-{yearMonth}-";

        var allItems = await _unitOfWork.InventoryItems.GetAllAsync(ignoreQueryFilters: true);
        var maxSeq = allItems
            .Where(i => i.SKU.StartsWith(prefix))
            .Select(i => int.TryParse(i.SKU[prefix.Length..], out var n) ? n : 0)
            .DefaultIfEmpty(0)
            .Max();

        return Json(new { sku = $"{prefix}{(maxSeq + 1):D4}" });
    }

    /// <summary>
    /// Invokes the AI Inventory Assist feature to look up powder coverage, chemistry, and
    /// pricing data for a product based on manufacturer/color/part-number hints. The
    /// feature is gated behind a subscription flag so only plans that include AI Inventory
    /// Assist can call the underlying Anthropic API, preventing unexpected billing on
    /// lower-tier plans.
    /// </summary>
    [HttpPost]
    public async Task<IActionResult> AiLookup(
        [FromForm] string? manufacturer,
        [FromForm] string? colorName,
        [FromForm] string? colorCode,
        [FromForm] string? partNumber)
    {
        var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
        if (!await _subscriptionService.IsAiInventoryAssistEnabledAsync(companyId))
            return Json(new { success = false, errorMessage = "AI Inventory Assist is not enabled for your account. Contact your administrator." });

        var result = await _aiLookupService.LookupAsync(manufacturer, colorName, colorCode, partNumber);
        if (result.Success)
        {
            await EnrichFromCatalogAsync(result, autoContribute: true);
            await ApplyTdsCureFallbackAsync(result, colorName);
        }
        return Json(result);
    }

    /// <summary>
    /// Augments a catalog fill with cure specs, color families, and finish by fetching the
    /// product's known URL and running it through Claude. Skips Serper — the URL is already
    /// known from the catalog record so no search step is needed. Gated behind the same
    /// AI Inventory Assist subscription flag as AiLookup.
    /// </summary>
    [HttpPost]
    public async Task<IActionResult> AiAugmentFromUrl(
        [FromForm] string? productUrl,
        [FromForm] string? colorName)
    {
        var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
        if (!await _subscriptionService.IsAiInventoryAssistEnabledAsync(companyId))
            return Json(new { success = false, errorMessage = "AI Inventory Assist is not enabled." });

        if (string.IsNullOrWhiteSpace(productUrl))
            return Json(new { success = false, errorMessage = "No product URL provided." });

        var result = await _aiLookupService.LookupByUrlAsync(productUrl, colorName);
        if (result.Success)
            await EnrichFromCatalogAsync(result, autoContribute: true);
        return Json(result);
    }

    /// <summary>
    /// Looks up <paramref name="result"/> in the platform powder catalog by SKU + manufacturer.
    /// If a match is found, catalog values overwrite Claude-inferred ones for spec fields
    /// (catalog is the authoritative source) and fill gaps for URL/price fields.
    /// If no match and <paramref name="autoContribute"/> is true, inserts a new catalog entry
    /// so future lookups resolve instantly without an API call.
    /// Returns (wasInCatalog, addedToCatalog) so callers can surface UI badges.
    /// Mutates <paramref name="result"/> in place.
    /// </summary>
    /// <summary>
    /// Finds the platform powder catalog row matching an inventory item's identity
    /// (Manufacturer + ManufacturerPartNumber), or null. Used to set
    /// <see cref="InventoryItem.PowderCatalogItemId"/> and to surface manufacturer-level status
    /// (e.g. discontinued / cannot reorder) on the detail screen.
    /// </summary>
    private async Task<PowderCatalogItem?> FindCatalogMatchAsync(string? manufacturer, string? sku)
    {
        if (string.IsNullOrWhiteSpace(manufacturer) || string.IsNullOrWhiteSpace(sku))
            return null;

        var skuLower = sku.Trim().ToLower();
        var mfrLower = manufacturer.Trim().ToLower();
        var hits = await _unitOfWork.PowderCatalog.FindAsync(p =>
            p.Sku.ToLower() == skuLower && p.VendorName.ToLower().Contains(mfrLower));
        return hits.FirstOrDefault();
    }

    private async Task<(bool wasInCatalog, bool addedToCatalog)> EnrichFromCatalogAsync(
        InventoryAiLookupResult result, bool autoContribute)
    {
        var sku          = result.ManufacturerPartNumber?.Trim();
        var manufacturer = (result.Manufacturer ?? result.VendorName)?.Trim();
        var colorName    = result.ColorName?.Trim();

        PowderCatalogItem? match = null;
        if (!string.IsNullOrEmpty(sku) && !string.IsNullOrEmpty(manufacturer))
        {
            var skuLower = sku.ToLower();
            var mfrLower = manufacturer.ToLower();
            var hits = await _unitOfWork.PowderCatalog.FindAsync(p =>
                p.Sku.ToLower() == skuLower && p.VendorName.ToLower().Contains(mfrLower));
            match = hits.FirstOrDefault();
        }

        if (match != null)
        {
            // Catalog is authoritative for spec fields — overwrite AI inference
            if (match.Finish             != null) result.Finish             = match.Finish;
            if (match.CureTemperatureF   != null) result.CureTemperatureF   = match.CureTemperatureF;
            if (match.CureTimeMinutes    != null) result.CureTimeMinutes    = match.CureTimeMinutes;
            if (match.ColorFamilies      != null) result.ColorFamilies      = match.ColorFamilies;
            if (match.RequiresClearCoat  != null) result.RequiresClearCoat  = match.RequiresClearCoat;
            if (match.CoverageSqFtPerLb  != null) result.CoverageSqFtPerLb  = match.CoverageSqFtPerLb;
            if (match.SpecificGravity    != null) result.SpecificGravity    = match.SpecificGravity;
            result.TransferEfficiency ??= GetEffectiveTransferEfficiency(match.TransferEfficiency);
            // URL / price fields: fill gaps only — AI may have found something better
            result.ImageUrl    ??= match.ImageUrl;
            result.SpecPageUrl ??= match.ProductUrl;
            result.SdsUrl      ??= match.SdsUrl;
            result.TdsUrl      ??= match.TdsUrl;
            if (match.UnitPrice > 0) result.UnitCostPerLb ??= match.UnitPrice;

            // Back-sync: fill NULL catalog fields from the incoming result so the catalog
            // gets richer over time without overwriting anything already stored.
            bool catalogDirty = false;
            if (match.Finish             == null && !string.IsNullOrWhiteSpace(result.Finish))       { match.Finish             = result.Finish;             catalogDirty = true; }
            if (match.CureTemperatureF   == null && result.CureTemperatureF  != null)                { match.CureTemperatureF   = result.CureTemperatureF;   catalogDirty = true; }
            if (match.CureTimeMinutes    == null && result.CureTimeMinutes   != null)                { match.CureTimeMinutes    = result.CureTimeMinutes;     catalogDirty = true; }
            if (match.ColorFamilies      == null && !string.IsNullOrWhiteSpace(result.ColorFamilies)){ match.ColorFamilies      = result.ColorFamilies;       catalogDirty = true; }
            if (match.RequiresClearCoat  == null && result.RequiresClearCoat != null)                { match.RequiresClearCoat  = result.RequiresClearCoat;   catalogDirty = true; }
            if (match.CoverageSqFtPerLb  == null && result.CoverageSqFtPerLb != null)               { match.CoverageSqFtPerLb  = result.CoverageSqFtPerLb;  catalogDirty = true; }
            if (match.SpecificGravity    == null && result.SpecificGravity   != null)                { match.SpecificGravity    = result.SpecificGravity;     catalogDirty = true; }
            if (match.TransferEfficiency == null && result.TransferEfficiency != null)               { match.TransferEfficiency = result.TransferEfficiency;  catalogDirty = true; }
            if (string.IsNullOrWhiteSpace(match.ImageUrl)   && !string.IsNullOrWhiteSpace(result.ImageUrl))   { match.ImageUrl   = result.ImageUrl;   catalogDirty = true; }
            if (string.IsNullOrWhiteSpace(match.ProductUrl) && !string.IsNullOrWhiteSpace(result.SpecPageUrl)){ match.ProductUrl = result.SpecPageUrl; catalogDirty = true; }
            if (string.IsNullOrWhiteSpace(match.SdsUrl)     && !string.IsNullOrWhiteSpace(result.SdsUrl))     { match.SdsUrl     = result.SdsUrl;     catalogDirty = true; }
            if (string.IsNullOrWhiteSpace(match.TdsUrl)     && !string.IsNullOrWhiteSpace(result.TdsUrl))     { match.TdsUrl     = result.TdsUrl;     catalogDirty = true; }
            if (match.UnitPrice == 0 && (result.UnitCostPerLb ?? 0) > 0)                            { match.UnitPrice  = result.UnitCostPerLb!.Value;        catalogDirty = true; }

            if (catalogDirty)
            {
                match.UpdatedAt = DateTime.UtcNow;
                try
                {
                    await _unitOfWork.PowderCatalog.UpdateAsync(match);
                    await _unitOfWork.CompleteAsync();
                    _logger.LogInformation("Back-synced catalog gaps for {VendorName} {Sku}", match.VendorName, match.Sku);
                }
                catch (Exception ex)
                {
                    _logger.LogWarning(ex, "Failed to back-sync catalog entry {Id}", match.Id);
                }
            }

            return (true, false);
        }

        if (!autoContribute
            || string.IsNullOrEmpty(sku)
            || string.IsNullOrEmpty(manufacturer)
            || string.IsNullOrEmpty(colorName))
            return (false, false);

        // Auto-contribute: insert into platform catalog so future lookups/scans resolve instantly
        try
        {
            var newItem = new PowderCatalogItem
            {
                VendorName         = manufacturer,
                Sku                = sku,
                ColorName          = colorName,
                UnitPrice          = result.UnitCostPerLb ?? 0m,
                CureTemperatureF   = result.CureTemperatureF,
                CureTimeMinutes    = result.CureTimeMinutes,
                Finish             = result.Finish,
                ColorFamilies      = result.ColorFamilies,
                RequiresClearCoat  = result.RequiresClearCoat,
                CoverageSqFtPerLb  = result.CoverageSqFtPerLb,
                SpecificGravity    = result.SpecificGravity,
                TransferEfficiency = GetEffectiveTransferEfficiency(result.TransferEfficiency),
                ImageUrl           = result.ImageUrl,
                ProductUrl         = result.SpecPageUrl,
                SdsUrl             = result.SdsUrl,
                TdsUrl             = result.TdsUrl,
                IsUserContributed  = true,
                CreatedAt          = DateTime.UtcNow,
            };
            await _unitOfWork.PowderCatalog.AddAsync(newItem);
            await _unitOfWork.CompleteAsync();
            _logger.LogInformation("Auto-contributed new catalog entry: {Manufacturer} {Sku}", manufacturer, sku);
            return (false, true);
        }
        catch (Exception ex)
        {
            // Unique constraint violation means another request beat us — not an error
            _logger.LogInformation("Catalog auto-insert skipped (likely duplicate): {Message}", ex.Message);
            return (false, false);
        }
    }

    /// <summary>
    /// When cure specs are still missing after a primary AI lookup (LookupAsync or ScanLabelAsync),
    /// fetches the TDS URL that Claude returned and asks it to extract only the cure schedule.
    /// Not used by AiAugmentFromUrl — that path uses LookupByUrlAsync which has TDS fallback built in.
    /// </summary>
    private async Task ApplyTdsCureFallbackAsync(InventoryAiLookupResult result, string? colorName)
    {
        if ((result.CureTemperatureF == null || result.CureTimeMinutes == null)
            && !string.IsNullOrEmpty(result.TdsUrl))
        {
            var tds = await _aiLookupService.FetchTdsCureSpecsAsync(result.TdsUrl, colorName);
            if (tds.Success)
            {
                if (result.CureTemperatureF == null) result.CureTemperatureF = tds.CureTemperatureF;
                if (result.CureTimeMinutes  == null) result.CureTimeMinutes  = tds.CureTimeMinutes;
            }
        }
    }

    /// <summary>
    /// Accepts a base64 label photo or a decoded QR URL from the in-browser label scanner,
    /// runs it through Claude (vision for photos, URL-fetch for QR), searches the platform
    /// catalog, and — when the product is not yet in the catalog and enough data was extracted
    /// — inserts it automatically as a user-contributed entry so future scans resolve instantly.
    /// </summary>
    [HttpPost]
    public async Task<IActionResult> ScanLabel(
        [FromForm] string? imageBase64,
        [FromForm] string? mediaType,
        [FromForm] string? qrUrl)
    {
        var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
        if (!await _subscriptionService.IsAiInventoryAssistEnabledAsync(companyId))
            return Json(new { success = false, errorMessage = "AI Inventory Assist is not enabled for your account." });

        InventoryAiLookupResult aiResult;

        if (!string.IsNullOrWhiteSpace(qrUrl))
        {
            // QR path: try to extract product identity from the URL using manufacturer patterns.
            // Prismatic Powders URLs embed both the SKU and color slug in the path
            // (e.g. /shop/powder-coating-colors/PMB-6906/fire-red), so we can feed the full
            // LookupAsync pipeline (Serper + direct URL + Claude) with real context instead of
            // relying solely on a page fetch that may fail in production.
            var activePatterns = (await _unitOfWork.ManufacturerLookupPatterns.GetAllAsync(ignoreQueryFilters: true))
                .Where(p => p.IsActive).ToList();
            var (urlMfr, urlColor, urlPart) = TryParseManufacturerUrl(qrUrl, activePatterns);

            if (!string.IsNullOrWhiteSpace(urlMfr))
            {
                aiResult = await _aiLookupService.LookupAsync(urlMfr, urlColor, null, urlPart);
                // The scanned QR URL is always the authoritative product page link — it came
                // directly from the manufacturer's bag and is always fully-qualified. Overwrite
                // whatever LookupAsync returned (which may be a scheme-less path from the template).
                if (aiResult.Success)
                    aiResult.SpecPageUrl = qrUrl;
            }
            else
            {
                // No pattern match — fall back to URL-based lookup
                aiResult = await _aiLookupService.LookupByUrlAsync(qrUrl, null);
                if (aiResult.Success && aiResult.SpecPageUrl == null)
                    aiResult.SpecPageUrl = qrUrl;
            }
        }
        else if (!string.IsNullOrWhiteSpace(imageBase64))
        {
            // Vision path: Claude reads what's printed on the label (limited to visible text)
            aiResult = await _aiLookupService.ScanLabelAsync(imageBase64, mediaType ?? "image/jpeg");

            // Follow-up web lookup so we get SDS/TDS URLs, product page, image, description,
            // and any specs not printed on the label. Label values are kept as-is (authoritative);
            // the full lookup only fills fields that are still null.
            if (aiResult.Success)
            {
                var mfr = aiResult.Manufacturer ?? aiResult.VendorName;
                if (!string.IsNullOrWhiteSpace(mfr) &&
                    (!string.IsNullOrWhiteSpace(aiResult.ColorName) || !string.IsNullOrWhiteSpace(aiResult.ManufacturerPartNumber)))
                {
                    var full = await _aiLookupService.LookupAsync(
                        mfr, aiResult.ColorName, aiResult.ColorCode, aiResult.ManufacturerPartNumber);
                    if (full.Success)
                    {
                        aiResult.Description            ??= full.Description;
                        aiResult.SdsUrl                 ??= full.SdsUrl;
                        aiResult.TdsUrl                 ??= full.TdsUrl;
                        aiResult.ImageUrl               ??= full.ImageUrl;
                        aiResult.SpecPageUrl            ??= full.SpecPageUrl;
                        aiResult.UnitCostPerLb          ??= full.UnitCostPerLb;
                        aiResult.VendorName             ??= full.VendorName;
                        aiResult.ColorFamilies          ??= full.ColorFamilies;
                        aiResult.Finish                 ??= full.Finish;
                        aiResult.CureTemperatureF       ??= full.CureTemperatureF;
                        aiResult.CureTimeMinutes        ??= full.CureTimeMinutes;
                        aiResult.RequiresClearCoat      ??= full.RequiresClearCoat;
                        aiResult.CoverageSqFtPerLb      ??= full.CoverageSqFtPerLb;
                        aiResult.SpecificGravity        ??= full.SpecificGravity;
                        aiResult.TransferEfficiency     ??= GetEffectiveTransferEfficiency(full.TransferEfficiency);
                        aiResult.ManufacturerPartNumber ??= full.ManufacturerPartNumber;
                        aiResult.ColorName              ??= full.ColorName;
                        aiResult.ColorCode              ??= full.ColorCode;
                    }
                }
            }
        }
        else
        {
            return Json(new { success = false, errorMessage = "Provide either a label image or a QR code URL." });
        }

        if (!aiResult.Success)
            return Json(new { success = false, errorMessage = aiResult.ErrorMessage });

        var sku          = aiResult.ManufacturerPartNumber?.Trim();
        var manufacturer = (aiResult.Manufacturer ?? aiResult.VendorName)?.Trim();
        var colorName    = aiResult.ColorName?.Trim();

        // Catalog lookup, merge, and auto-contribute — same logic as AiLookup button
        var (wasInCatalog, addedToCatalog) = await EnrichFromCatalogAsync(aiResult, autoContribute: true);

        // TDS cure fallback — same logic as AiLookup button
        await ApplyTdsCureFallbackAsync(aiResult, colorName);

        var duplicate = await FindInventoryDuplicateAsync(
            null,
            manufacturer,
            sku,
            colorName,
            isCoating: true);

        return Json(new
        {
            success                = true,
            manufacturer           = manufacturer,
            manufacturerPartNumber = sku,
            colorName              = colorName,
            description            = aiResult.Description,
            finish                 = aiResult.Finish,
            cureTemperatureF       = aiResult.CureTemperatureF,
            cureTimeMinutes        = aiResult.CureTimeMinutes,
            colorFamilies          = aiResult.ColorFamilies,
            requiresClearCoat      = aiResult.RequiresClearCoat,
            coverageSqFtPerLb      = aiResult.CoverageSqFtPerLb,
            specificGravity        = aiResult.SpecificGravity,
            transferEfficiency     = aiResult.TransferEfficiency ?? DefaultTransferEfficiency,
            unitPrice              = aiResult.UnitCostPerLb ?? 0m,
            imageUrl               = aiResult.ImageUrl,
            productUrl             = aiResult.SpecPageUrl,
            sdsUrl                 = aiResult.SdsUrl,
            tdsUrl                 = aiResult.TdsUrl,
            vendorName             = manufacturer,
            wasInCatalog           = wasInCatalog,
            addedToCatalog         = addedToCatalog,
            existingInventoryId    = duplicate?.Item.Id,
            existingInventoryName  = duplicate?.Item.Name,
            existingQuantityOnHand = duplicate?.Item.QuantityOnHand,
            existingUnitOfMeasure  = duplicate?.Item.UnitOfMeasure,
            duplicateMatchType     = duplicate?.MatchType.ToString(),
            reasoning              = aiResult.Reasoning,
        });
    }

    /// <summary>
    /// Checks the current tenant's active inventory for an existing SKU or powder identity.
    /// Uses the same matcher as label scanning and repeats the tenant boundary explicitly.
    /// </summary>
    [HttpGet]
    public async Task<IActionResult> CheckDuplicate(
        string? sku,
        int? categoryId,
        string? manufacturer,
        string? manufacturerPartNumber,
        string? colorName,
        int? currentId = null)
    {
        var category = categoryId.HasValue
            ? await _unitOfWork.InventoryCategoryLookups.GetByIdAsync(categoryId.Value)
            : null;

        var duplicate = await FindInventoryDuplicateAsync(
            sku,
            manufacturer,
            manufacturerPartNumber,
            colorName,
            category?.IsCoating == true,
            currentId);

        if (duplicate == null)
            return Json(new { hasDuplicate = false });

        return Json(new
        {
            hasDuplicate = true,
            isBlocking = duplicate.MatchType == InventoryDuplicateMatchType.Sku,
            matchType = duplicate.MatchType.ToString(),
            message = BuildDuplicateMessage(duplicate),
            existingInventoryId = duplicate.Item.Id,
            existingInventoryName = duplicate.Item.Name,
            existingSku = duplicate.Item.SKU,
            existingManufacturer = duplicate.Item.Manufacturer,
            existingColorName = duplicate.Item.ColorName,
            existingQuantityOnHand = duplicate.Item.QuantityOnHand,
            existingUnitOfMeasure = duplicate.Item.UnitOfMeasure,
        });
    }

    /// <summary>
    /// Adds stock to an existing inventory item from the shared duplicate prompt.
    /// Creates a Purchase transaction and updates QuantityOnHand without navigating away.
    /// </summary>
    [HttpPost]
    public async Task<IActionResult> AddStock(int inventoryItemId, decimal quantity, decimal? unitCost, string? notes)
    {
        try
        {
            if (quantity <= 0)
                return Json(new { success = false, errorMessage = "Quantity must be greater than zero." });

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

            var cost = (unitCost.HasValue && unitCost.Value > 0) ? unitCost.Value : item.UnitCost;

            item.QuantityOnHand   += quantity;
            item.LastPurchaseDate  = DateTime.UtcNow;
            if (unitCost.HasValue && unitCost.Value > 0)
            {
                item.LastPurchasePrice = unitCost.Value;
                item.UnitCost          = unitCost.Value;
            }
            item.UpdatedAt = DateTime.UtcNow;
            await _unitOfWork.InventoryItems.UpdateAsync(item);

            var txn = new InventoryTransaction
            {
                InventoryItemId = item.Id,
                TransactionType = InventoryTransactionType.Purchase,
                Quantity        = quantity,
                UnitCost        = cost,
                TotalCost       = quantity * cost,
                TransactionDate = DateTime.UtcNow,
                BalanceAfter    = item.QuantityOnHand,
                Notes           = !string.IsNullOrWhiteSpace(notes) ? notes.Trim() : "Added via label scan",
            };
            await _unitOfWork.InventoryTransactions.AddAsync(txn);
            await _unitOfWork.SaveChangesAsync();

            _logger.LogInformation("Label scan added {Qty} {UOM} to inventory item {Id} ({Name})",
                quantity, item.UnitOfMeasure, item.Id, item.Name);

            return Json(new
            {
                success           = true,
                newQuantityOnHand = item.QuantityOnHand,
                unitOfMeasure     = item.UnitOfMeasure,
                itemName          = item.Name,
            });
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Error adding stock via label scan to inventory item {ItemId}", inventoryItemId);
            return Json(new { success = false, errorMessage = "An error occurred. Please try again." });
        }
    }

    /// <summary>
    /// Searches the platform-level PowderCatalogItems table by SKU or color name.
    /// Excludes catalog entries already present in the company's inventory (by ManufacturerPartNumber).
    /// Pass currentId when editing an existing item so its own catalog entry is not filtered out.
    /// Called by the inventory Create/Edit form before falling back to AI Lookup.
    /// </summary>
    [HttpGet]
    public async Task<IActionResult> CatalogLookup(string? q, string? vendor, int? currentId = null)
    {
        if (string.IsNullOrWhiteSpace(q) || q.Length < 2)
            return Json(Array.Empty<object>());

        var term       = q.Trim().ToLower();
        var vendorTerm = vendor?.Trim().ToLower();

        // Build a set of SKUs already in this company's inventory so we can exclude them.
        // When editing, the current item's own SKU is re-included so its catalog entry still appears.
        var skuCompanyId = _tenantContext.GetCurrentCompanyId() ?? 0;
        var existingItems = await _unitOfWork.InventoryItems.FindAsync(i => i.CompanyId == skuCompanyId);
        var existingSkus = existingItems
            .Where(i => !string.IsNullOrWhiteSpace(i.ManufacturerPartNumber) && i.Id != (currentId ?? 0))
            .Select(i => i.ManufacturerPartNumber!.Trim().ToLower())
            .ToHashSet();

        // Single query — all partial color/SKU matches across all vendors.
        // Results are ranked: exact vendor + exact color (isExact=true) sorts first and
        // triggers auto-fill in the JS. Everything else goes to the picker modal.
        // This means a user who typed "Columbia Coatings" + "Lime Green" gets auto-fill
        // only when that exact product is in the catalog; otherwise they see a ranked modal
        // with same-vendor results at the top and a "Not Listed — Search Online" escape hatch.
        var matches = await _unitOfWork.PowderCatalog.FindAsync(p =>
            p.ColorName.ToLower().Contains(term) ||
            p.Sku.ToLower() == term ||
            p.Sku.ToLower().Contains(term));

        var results = matches
            .Where(p => !existingSkus.Contains(p.Sku.ToLower()))
            .Select(p =>
            {
                var vendorMatch = string.IsNullOrEmpty(vendorTerm) || p.VendorName.ToLower().Contains(vendorTerm);
                var colorExact  = p.ColorName.ToLower() == term;
                return (p, isExact: vendorMatch && colorExact, vendorMatch, colorExact);
            })
            .OrderBy(x => x.isExact ? 0 : x.vendorMatch ? 1 : x.colorExact ? 2 : 3)
            .ThenBy(x => x.p.ColorName)
            .Select(x => new
            {
                id = x.p.Id,
                vendorName = x.p.VendorName,
                sku = x.p.Sku,
                colorName = x.p.ColorName,
                description = x.p.Description,
                unitPrice = x.p.UnitPrice,
                imageUrl = x.p.ImageUrl,
                sdsUrl = x.p.SdsUrl,
                tdsUrl = x.p.TdsUrl,
                applicationGuideUrl = x.p.ApplicationGuideUrl,
                productUrl = x.p.ProductUrl,
                isDiscontinued = x.p.IsDiscontinued,
                isExact = x.isExact,
                cureTemperatureF = x.p.CureTemperatureF,
                cureTimeMinutes = x.p.CureTimeMinutes,
                finish = x.p.Finish,
                colorFamilies = x.p.ColorFamilies,
                requiresClearCoat = x.p.RequiresClearCoat,
                coverageSqFtPerLb = x.p.CoverageSqFtPerLb,
                specificGravity = x.p.SpecificGravity,
                transferEfficiency = GetEffectiveTransferEfficiency(x.p.TransferEfficiency)
            })
            .ToList();

        return Json(results);
    }

    /// <summary>
    /// Creates a 0-balance inventory item from a PowderCatalogItem record and marks it IsIncoming=true.
    /// Called by the item wizard when a staff member needs to quote a powder that has been ordered
    /// but not yet received — the inventory record enables QR code printing on the work order.
    /// Returns the new item's data in the same shape as the inventoryPowdersData list so the wizard
    /// can add it to powderData and select it immediately without a page refresh.
    /// </summary>
    [HttpPost]
    [ValidateAntiForgeryToken]
    public async Task<IActionResult> CreateIncomingFromCatalog(int catalogItemId)
    {
        try
        {
            var catalogItem = await _unitOfWork.PowderCatalog.GetByIdAsync(catalogItemId);
            if (catalogItem == null)
                return Json(new { success = false, error = "Catalog item not found." });

            // First use of this powder — lazily fill specific gravity / cure from its TDS so the new
            // inventory record (and the catalog) carry complete specs. No-op once already enriched.
            await _aiLookupService.EnsureCatalogTdsSpecsAsync(catalogItem);

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

            // Find the default coating category to assign.
            // Prefer the canonical "POWDER" category (CategoryCode == "POWDER") so catalog-sourced
            // items always land in the right bucket regardless of how many IsCoating categories exist.
            var categories = await _unitOfWork.InventoryCategoryLookups.FindAsync(c => c.CompanyId == companyId);
            var coatingCategory = categories.FirstOrDefault(c => c.IsActive && c.IsCoating
                                      && c.CategoryCode.Equals("POWDER", StringComparison.OrdinalIgnoreCase))
                               ?? categories
                                      .Where(c => c.IsActive && c.IsCoating)
                                      .OrderBy(c => c.DisplayOrder)
                                      .FirstOrDefault();

            if (coatingCategory == null)
                return Json(new { success = false, error = "No active coating category found. Please configure inventory categories first." });

            // Generate a unique SKU following the same pattern as GenerateSku: {CODE}-{YYMM}-{####}
            var code = coatingCategory.CategoryCode.Length >= 4
                ? coatingCategory.CategoryCode[..4].ToUpperInvariant()
                : coatingCategory.CategoryCode.ToUpperInvariant().PadRight(4, 'X');
            var yearMonth = DateTime.Now.ToString("yyMM");
            var prefix = $"{code}-{yearMonth}-";
            var allItems = await _unitOfWork.InventoryItems.GetAllAsync(ignoreQueryFilters: true);
            var maxSeq = allItems
                .Where(i => i.SKU.StartsWith(prefix))
                .Select(i => int.TryParse(i.SKU[prefix.Length..], out var n) ? n : 0)
                .DefaultIfEmpty(0)
                .Max();
            var sku = $"{prefix}{(maxSeq + 1):D4}";

            var item = new InventoryItem
            {
                SKU                   = sku,
                Name                  = ToTitleCase($"{catalogItem.VendorName} {catalogItem.ColorName}"),
                ColorName             = catalogItem.ColorName,
                Manufacturer          = catalogItem.VendorName,
                ManufacturerPartNumber= catalogItem.Sku,
                PowderCatalogItemId   = catalogItem.Id,
                Finish                = catalogItem.Finish,
                ColorFamilies         = catalogItem.ColorFamilies,
                RequiresClearCoat     = catalogItem.RequiresClearCoat ?? false,
                CoverageSqFtPerLb     = catalogItem.CoverageSqFtPerLb ?? 30m,
                TransferEfficiency    = GetEffectiveTransferEfficiency(catalogItem.TransferEfficiency),
                CureTemperatureF      = catalogItem.CureTemperatureF,
                CureTimeMinutes       = catalogItem.CureTimeMinutes,
                SpecificGravity       = catalogItem.SpecificGravity,
                SpecPageUrl           = catalogItem.ProductUrl,
                ImageUrl              = catalogItem.ImageUrl,
                SdsUrl                = catalogItem.SdsUrl,
                TdsUrl                = catalogItem.TdsUrl,
                UnitCost              = catalogItem.UnitPrice,
                AverageCost           = catalogItem.UnitPrice,
                LastPurchasePrice     = catalogItem.UnitPrice,
                CatalogReferencePrice = catalogItem.UnitPrice > 0 ? catalogItem.UnitPrice : (decimal?)null,
                CatalogPriceUpdatedAt = catalogItem.UnitPrice > 0 ? DateTime.UtcNow : (DateTime?)null,
                QuantityOnHand        = 0,
                UnitOfMeasure         = "lbs",
                InventoryCategoryId   = coatingCategory.Id,
                Category              = coatingCategory.DisplayName,
                IsActive              = true,
                IsIncoming            = true,
                CompanyId             = companyId,
                CreatedAt             = DateTime.UtcNow,
            };

            await _unitOfWork.InventoryItems.AddAsync(item);
            await _unitOfWork.SaveChangesAsync();

            _logger.LogInformation("Created incoming inventory item {ItemId} ({ItemName}) from catalog item {CatalogId} for company {CompanyId}",
                item.Id, item.Name, catalogItemId, companyId);

            return Json(new
            {
                success      = true,
                value        = item.Id.ToString(),
                text         = $"[INCOMING] {coatingCategory.DisplayName} - {item.Manufacturer ?? "Generic"} - {item.ColorName ?? item.Name} - {item.ManufacturerPartNumber ?? "N/A"} ({item.UnitCost:C4}/unit)",
                coverage     = item.CoverageSqFtPerLb ?? 30m,
                efficiency   = item.TransferEfficiency ?? 65m,
                unitOfMeasure= item.UnitOfMeasure,
                categoryName = coatingCategory.DisplayName,
                costPerLb    = item.CatalogReferencePrice ?? item.UnitCost,
                colorName    = item.ColorName ?? item.Name,
                colorCode    = "",
                isIncoming   = true
            });
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Failed to create incoming inventory item from catalog {CatalogItemId}", catalogItemId);
            return Json(new { success = false, error = "Failed to create inventory item. Please try again." });
        }
    }

    private async Task<InventoryDuplicateMatch?> FindInventoryDuplicateAsync(
        string? sku,
        string? manufacturer,
        string? manufacturerPartNumber,
        string? colorName,
        bool isCoating,
        int? excludeId = null)
    {
        var companyId = _tenantContext.GetCurrentCompanyId();
        if (!companyId.HasValue || companyId.Value <= 0)
            return null;

        // Explicit CompanyId predicate is intentional defense-in-depth on top of the global filter.
        var tenantInventory = await _unitOfWork.InventoryItems.FindAsync(
            i => i.CompanyId == companyId.Value,
            false,
            i => i.InventoryCategory!);

        return InventoryDuplicateMatcher.Find(
            tenantInventory,
            companyId.Value,
            sku,
            manufacturer,
            manufacturerPartNumber,
            colorName,
            isCoating,
            excludeId);
    }

    private static string BuildDuplicateMessage(InventoryDuplicateMatch duplicate)
    {
        return duplicate.MatchType switch
        {
            InventoryDuplicateMatchType.Sku =>
                $"SKU '{duplicate.Item.SKU}' is already used by '{duplicate.Item.Name}'.",
            InventoryDuplicateMatchType.ManufacturerPartNumber =>
                $"This manufacturer's part number is already recorded as '{duplicate.Item.Name}' ({duplicate.Item.SKU}).",
            _ =>
                $"{duplicate.Item.Manufacturer} {duplicate.Item.ColorName ?? duplicate.Item.Name} is already in inventory as '{duplicate.Item.Name}' ({duplicate.Item.SKU})."
        };
    }

    private static decimal GetEffectiveTransferEfficiency(decimal? transferEfficiency)
    {
        return transferEfficiency ?? DefaultTransferEfficiency;
    }

    /// <summary>
    /// Reverse-parses a scanned QR URL against known manufacturer URL templates to extract
    /// product identity (manufacturer name, color name, part number). For example, a Prismatic
    /// Powders URL like /shop/powder-coating-colors/PMB-6906/fire-red yields manufacturer
    /// "Prismatic Powders", partNumber "PMB-6906", and colorName "Fire Red". Returns nulls
    /// when no active pattern matches the URL domain.
    /// </summary>
    private static (string? manufacturer, string? colorName, string? partNumber) TryParseManufacturerUrl(
        string url, IEnumerable<Core.Entities.ManufacturerLookupPattern> patterns)
    {
        Uri? parsed;
        try { parsed = new Uri(url); } catch { return (null, null, null); }

        var host = parsed.Host.Replace("www.", "", StringComparison.OrdinalIgnoreCase);

        foreach (var pattern in patterns.Where(p => !string.IsNullOrEmpty(p.Domain) && !string.IsNullOrEmpty(p.ProductUrlTemplate)))
        {
            if (!host.Equals(pattern.Domain, StringComparison.OrdinalIgnoreCase)) continue;

            var template = pattern.ProductUrlTemplate!;
            var placeholderKeys = new[] { "{partNumber}", "{slug}", "{colorName}", "{colorCode}" };

            // Find the first placeholder to split off the static prefix
            var firstIdx = placeholderKeys
                .Select(ph => template.IndexOf(ph, StringComparison.OrdinalIgnoreCase))
                .Where(i => i >= 0)
                .DefaultIfEmpty(-1)
                .Min();

            if (firstIdx < 0) continue;

            var staticPrefix = template[..firstIdx].TrimEnd('/');
            var fullUrl = url.TrimEnd('/');
            if (!fullUrl.StartsWith(staticPrefix, StringComparison.OrdinalIgnoreCase)) continue;

            var templateSegments = template[firstIdx..].Split('/', StringSplitOptions.RemoveEmptyEntries);
            var urlSegments      = fullUrl[staticPrefix.Length..].Split('/', StringSplitOptions.RemoveEmptyEntries);

            var extracted = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
            bool matched = true;
            for (int i = 0; i < templateSegments.Length && i < urlSegments.Length; i++)
            {
                var seg = templateSegments[i];
                if (seg.StartsWith("{") && seg.EndsWith("}"))
                    extracted[seg[1..^1]] = urlSegments[i];
                else if (!seg.Equals(urlSegments[i], StringComparison.OrdinalIgnoreCase))
                { matched = false; break; }
            }
            if (!matched) continue;

            extracted.TryGetValue("partNumber", out var partNumber);
            var slug = extracted.TryGetValue("slug", out var s) ? s
                     : extracted.TryGetValue("colorName", out var cn) ? cn
                     : extracted.TryGetValue("colorCode", out var cc) ? cc : null;

            // Convert URL slug to display name: "fire-red" → "Fire Red"
            string? colorName = slug == null ? null
                : string.Join(" ", slug.Split(new[] { '-', '_' }, StringSplitOptions.RemoveEmptyEntries)
                    .Select(w => w.Length > 0 ? char.ToUpperInvariant(w[0]) + w[1..].ToLowerInvariant() : w));

            return (pattern.ManufacturerName, colorName, partNumber);
        }

        return (null, null, null);
    }

    /// <summary>
    /// Normalizes a string to title-case using the current culture's TextInfo. Applied to
    /// inventory item names on create and edit so the list view is consistently formatted
    /// regardless of how staff typed the entry (e.g. "gloss black" → "Gloss Black").
    /// </summary>
    private static string ToTitleCase(string? value)
    {
        if (string.IsNullOrWhiteSpace(value)) return value ?? string.Empty;
        return System.Globalization.CultureInfo.CurrentCulture.TextInfo.ToTitleCase(value.Trim().ToLower());
    }

    /// <summary>
    /// Shows all IsCoating inventory items with two tabs: "On Hand" (HasSamplePanel=true)
    /// and "Need to Order" (HasSamplePanel=false). Optionally filtered by manufacturer.
    /// Only coating items are included regardless of active/inactive status, so discontinued
    /// colors that are still on the wall remain visible.
    /// </summary>
    public async Task<IActionResult> SamplePanels(string? manufacturer, string? tab)
    {
        try
        {
            var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
            var allCoatings = (await _unitOfWork.InventoryItems.FindAsync(
                    i => i.CompanyId == companyId && i.InventoryCategory != null && i.InventoryCategory.IsCoating,
                    false,
                    i => i.InventoryCategory))
                .OrderBy(i => i.Manufacturer).ThenBy(i => i.ColorName).ThenBy(i => i.Name)
                .ToList();

            // Distinct manufacturer list for filter dropdown
            ViewBag.Manufacturers = allCoatings
                .Where(i => !string.IsNullOrWhiteSpace(i.Manufacturer))
                .Select(i => i.Manufacturer!)
                .Distinct(StringComparer.OrdinalIgnoreCase)
                .OrderBy(m => m)
                .ToList();

            var filtered = string.IsNullOrWhiteSpace(manufacturer)
                ? allCoatings
                : allCoatings.Where(i => string.Equals(i.Manufacturer, manufacturer, StringComparison.OrdinalIgnoreCase)).ToList();

            ViewBag.SelectedManufacturer = manufacturer;
            ViewBag.ActiveTab = tab ?? "need";
            ViewBag.OnHandItems    = filtered.Where(i => i.HasSamplePanel).ToList();
            ViewBag.NeedToOrderItems = filtered.Where(i => !i.HasSamplePanel).ToList();
            ViewBag.TotalCoatings  = allCoatings.Count;
            ViewBag.TotalOnHand    = allCoatings.Count(i => i.HasSamplePanel);
            ViewBag.TotalNeedOrder = allCoatings.Count(i => !i.HasSamplePanel);

            return View();
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Error loading sample panels view");
            TempData["Error"] = "An error occurred while loading sample panels.";
            return RedirectToAction(nameof(Index));
        }
    }

    /// <summary>
    /// AJAX POST that toggles HasSamplePanel on a coating inventory item.
    /// Returns JSON so the SamplePanels view can update the UI without a full reload.
    /// Only valid for IsCoating items; returns 400 for non-coating items to prevent misuse.
    /// </summary>
    [HttpPost]
    [ValidateAntiForgeryToken]
    public async Task<IActionResult> ToggleSamplePanel(int id, bool hasPanel)
    {
        try
        {
            var item = await _unitOfWork.InventoryItems.GetByIdAsync(id, false, i => i.InventoryCategory);
            if (item == null) return Json(new { success = false, message = "Item not found." });
            if (item.InventoryCategory?.IsCoating != true) return Json(new { success = false, message = "Not a coating item." });

            item.HasSamplePanel = hasPanel;
            item.UpdatedAt = DateTime.UtcNow;
            await _unitOfWork.InventoryItems.UpdateAsync(item);
            await _unitOfWork.SaveChangesAsync();

            return Json(new { success = true, hasPanel = item.HasSamplePanel });
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Error toggling sample panel for item {ItemId}", id);
            return Json(new { success = false, message = "An error occurred." });
        }
    }

    /// <summary>
    /// Populates all ViewBag dropdowns needed by the Create and Edit forms: vendors,
    /// category lookup (with a JSON map of categoryId → isCoating so JS can show/hide
    /// coating-specific fields), standard units of measure, and the chart-of-accounts
    /// selects for inventory asset and COGS accounts. Also sets AiInventoryAssistEnabled
    /// so the view can conditionally render the AI lookup button.
    /// </summary>
    private async Task PopulateDropdowns()
    {
        var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
        ViewBag.AiInventoryAssistEnabled = await _subscriptionService.IsAiInventoryAssistEnabledAsync(companyId);

        var vendors = (await _unitOfWork.Vendors.FindAsync(v => v.CompanyId == companyId && v.IsActive, false, v => v.Categories))
            .OrderBy(v => v.CompanyName).ToList();
        ViewBag.Vendors = new SelectList(vendors, "Id", "CompanyName");

        // Build {categoryId: [vendorId, ...]} so the inventory form can filter vendors by category
        var categoryVendorMap = new Dictionary<string, List<int>>();
        foreach (var v in vendors)
            foreach (var cat in v.Categories)
            {
                var key = cat.Id.ToString();
                if (!categoryVendorMap.ContainsKey(key)) categoryVendorMap[key] = new List<int>();
                categoryVendorMap[key].Add(v.Id);
            }
        ViewBag.CategoryVendorMapJson = System.Text.Json.JsonSerializer.Serialize(categoryVendorMap);

        // Load categories from lookup table
        var allCategories = await _unitOfWork.InventoryCategoryLookups.FindAsync(c => c.CompanyId == companyId);
        var categories = allCategories
            .Where(c => c.IsActive)
            .OrderBy(c => c.DisplayOrder)
            .ToList();
        ViewBag.Categories = new SelectList(categories, "Id", "DisplayName");

        // JSON map of categoryId → isCoating (used by JS to show/hide coating fields)
        ViewBag.CategoryIsCoatingJson = System.Text.Json.JsonSerializer.Serialize(
            categories.ToDictionary(c => c.Id.ToString(), c => c.IsCoating));

        ViewBag.UnitsOfMeasure = new List<SelectListItem>
        {
            new SelectListItem { Value = "lbs", Text = "Pounds (lbs)" },
            new SelectListItem { Value = "kg", Text = "Kilograms (kg)" },
            new SelectListItem { Value = "oz", Text = "Ounces (oz)" },
            new SelectListItem { Value = "gallons", Text = "Gallons" },
            new SelectListItem { Value = "liters", Text = "Liters" },
            new SelectListItem { Value = "units", Text = "Units" },
            new SelectListItem { Value = "boxes", Text = "Boxes" },
            new SelectListItem { Value = "rolls", Text = "Rolls" }
        };

        var accounts = await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == companyId && a.IsActive);

        // Show ALL asset accounts, not just the Inventory sub-type. Companies that created
        // their inventory account manually often land on a different asset sub-type (e.g.
        // Other Current Asset), which previously left this dropdown empty. Listing every
        // asset account lets them pick whatever they actually use; Inventory sub-type
        // accounts are surfaced first as the recommended choice.
        ViewBag.InventoryAccounts = accounts
            .Where(a => a.AccountType == AccountType.Asset)
            .OrderByDescending(a => a.AccountSubType == AccountSubType.Inventory)
            .ThenBy(a => a.AccountNumber)
            .Select(a => new SelectListItem($"{a.AccountNumber} – {a.Name}", a.Id.ToString()))
            .ToList();

        ViewBag.CogsAccounts = accounts
            .Where(a => a.AccountType == AccountType.CostOfGoods)
            .OrderBy(a => a.AccountNumber)
            .Select(a => new SelectListItem($"{a.AccountNumber} – {a.Name}", a.Id.ToString()))
            .ToList();

        // Whether the company has configured default accounts — the views use this to label the
        // blank dropdown option "(Default …)" vs "(None)".
        var prefs = await _unitOfWork.CompanyPreferences
            .FirstOrDefaultAsync(p => p.CompanyId == companyId && !p.IsDeleted);
        ViewBag.HasDefaultInventoryAccount = prefs?.DefaultInventoryAccountId != null;
        ViewBag.HasDefaultCogsAccount      = prefs?.DefaultCogsAccountId != null;
    }

    /// <summary>
    /// One-time SuperAdmin repair action that links inventory items which have a Category
    /// string but no InventoryCategoryId — typically items seeded or imported via CSV
    /// before the lookup-table migration was applied. Matches by display name first, then
    /// falls back to a built-in alias map (e.g. "Powder Coatings" → "POWDER"). Uses
    /// ignoreQueryFilters throughout so items from all companies are repaired in a single
    /// pass. Safe to run multiple times; already-linked items are simply skipped.
    /// </summary>
    [HttpPost]
    [Authorize(Policy = AppConstants.Policies.SuperAdminOnly)]
    public async Task<IActionResult> RepairCategories()
    {
        var aliases = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
        {
            { "Powder Coatings", "POWDER" }, { "Powder Coating", "POWDER" }, { "Powders", "POWDER" },
            { "Primers", "PRIMER" }, { "Cleaners", "CLEANER" },
            { "Masking", "MASKING" }, { "Masking Tape", "MASKING" },
            { "Abrasive", "ABRASIVE" }, { "Abrasives", "ABRASIVE" }, { "Blast Media", "ABRASIVE" },
            { "Chemicals", "CHEMICAL" }, { "Consumable", "CONSUMABLE" },
            { "Tools & Equipment", "TOOL" }, { "Equipment", "TOOL" },
            { "General", "OTHER" },
        };

        // Load all categories grouped by company so we match the right company's records
        var allCategories = await _unitOfWork.InventoryCategoryLookups.GetAllAsync(ignoreQueryFilters: true);
        var categoriesByCompany = allCategories
            .Where(c => !c.IsDeleted)
            .GroupBy(c => c.CompanyId)
            .ToDictionary(g => g.Key, g => g.ToList());

        // Get items with no category link (ignore tenant filter to fix all companies)
        var unlinked = (await _unitOfWork.InventoryItems.GetAllAsync(ignoreQueryFilters: true))
            .Where(i => !i.IsDeleted && i.InventoryCategoryId == null)
            .ToList();

        int repaired = 0;
        foreach (var item in unlinked)
        {
            if (string.IsNullOrWhiteSpace(item.Category)) continue;
            if (!categoriesByCompany.TryGetValue(item.CompanyId, out var companyCats)) continue;

            var byName = companyCats.ToDictionary(c => c.DisplayName, c => c, StringComparer.OrdinalIgnoreCase);
            var byCode = companyCats.ToDictionary(c => c.CategoryCode, c => c, StringComparer.OrdinalIgnoreCase);

            InventoryCategoryLookup? cat = null;
            byName.TryGetValue(item.Category, out cat);
            if (cat == null && aliases.TryGetValue(item.Category, out var code))
                byCode.TryGetValue(code, out cat);

            if (cat != null)
            {
                item.InventoryCategoryId = cat.Id;
                item.Category = cat.DisplayName;
                item.UpdatedAt = DateTime.UtcNow;
                await _unitOfWork.InventoryItems.UpdateAsync(item);
                repaired++;
            }
        }

        await _unitOfWork.CompleteAsync();

        TempData["Success"] = $"Category repair complete: {repaired} of {unlinked.Count} unlinked items updated.";
        return RedirectToAction(nameof(Index));
    }

    /// <summary>
    /// Returns a QR code PNG image encoding the scan URL for the given inventory item.
    /// Used by the label print page and the item Details page.
    /// </summary>
    [HttpGet]
    [AllowAnonymous]
    public IActionResult QrCode(int id, int size = 10)
    {
        var scanUrl = Url.Action("Scan", "Inventory", new { id }, Request.Scheme)!;
        using var qrGenerator = new QRCodeGenerator();
        var qrData = qrGenerator.CreateQrCode(scanUrl, QRCodeGenerator.ECCLevel.M);
        using var qrCode = new PngByteQRCode(qrData);
        var pngBytes = qrCode.GetGraphic(size);
        return File(pngBytes, "image/png");
    }

    /// <summary>
    /// Renders a print-optimised label for the inventory item containing the QR code,
    /// item name, SKU, and colour. Designed to be printed directly from the browser.
    /// </summary>
    public async Task<IActionResult> Label(int? id, bool embed = false)
    {
        if (id == null) return NotFound();
        var item = await _unitOfWork.InventoryItems.GetByIdAsync(id.Value);
        if (item == null) return NotFound();
        ViewBag.IsEmbed = embed;
        return View(_mapper.Map<InventoryItemDto>(item));
    }

    /// <summary>
    /// Mobile-friendly scan landing page. Shows item details, a job picker pre-filtered
    /// to the current user's active jobs, and a usage entry form.
    /// </summary>
    [HttpGet]
    public async Task<IActionResult> Scan(int? id, int? jobId)
    {
        if (id == null) return NotFound();
        var item = await _unitOfWork.InventoryItems.GetByIdAsync(id.Value);
        if (item == null) return NotFound();

        var userId = _userManager.GetUserId(User);

        var recentCutoff = DateTime.UtcNow.AddDays(-7);

        var myJobs = (await _unitOfWork.Jobs.FindAsync(
                j => (!j.JobStatus.IsTerminalStatus || j.UpdatedAt >= recentCutoff) && j.AssignedUserId == userId,
                false,
                j => j.Customer,
                j => j.JobStatus))
            .OrderBy(j => j.JobNumber)
            .Select(j => new ScanJobOption
            {
                Id = j.Id,
                JobNumber = j.JobNumber + (j.JobStatus.IsTerminalStatus ? " (completed)" : ""),
                CustomerName = j.Customer != null
                    ? (j.Customer.CompanyName ?? j.Customer.ContactFirstName + " " + j.Customer.ContactLastName)
                    : "No Customer"
            })
            .ToList();

        var myJobIds = myJobs.Select(j => j.Id).ToHashSet();
        var otherJobs = (await _unitOfWork.Jobs.FindAsync(
                j => (!j.JobStatus.IsTerminalStatus || j.UpdatedAt >= recentCutoff) && !myJobIds.Contains(j.Id),
                false,
                j => j.Customer,
                j => j.JobStatus))
            .OrderByDescending(j => j.CreatedAt)
            .Take(100)
            .Select(j => new ScanJobOption
            {
                Id = j.Id,
                JobNumber = j.JobNumber + (j.JobStatus.IsTerminalStatus ? " (completed)" : ""),
                CustomerName = j.Customer != null
                    ? (j.Customer.CompanyName ?? j.Customer.ContactFirstName + " " + j.Customer.ContactLastName)
                    : "No Customer"
            })
            .ToList();

        ViewBag.ItemDto    = _mapper.Map<InventoryItemDto>(item);
        ViewBag.MyJobs     = myJobs;
        ViewBag.OtherJobs  = otherJobs;
        ViewBag.PreselectedJobId = jobId;
        var scanError = TempData["ScanError"] as string;
        if (scanError != null) ViewBag.ScanError = scanError;
        return View();
    }

    /// <summary>
    /// Core inventory usage recording logic shared by LogUsage (scan page) and LogMaterial (modal).
    /// Deducts quantityUsed from QuantityOnHand, writes an InventoryTransaction, and posts GL entries.
    /// </summary>
    private async Task<InventoryUsageResult> RecordInventoryUsageAsync(
        int inventoryItemId, int? jobId, decimal quantityUsed,
        InventoryTransactionType transactionType, string? notes)
    {
        var item = await _unitOfWork.InventoryItems.GetByIdAsync(inventoryItemId);
        if (item == null)
            return new InventoryUsageResult(false, "Inventory item not found.", 0, "", "");

        string? reference = null;
        if (jobId.HasValue)
        {
            var job = await _unitOfWork.Jobs.GetByIdAsync(jobId.Value);
            reference = job != null ? $"Job {job.JobNumber}" : null;
        }

        item.QuantityOnHand -= quantityUsed;
        item.UpdatedAt = DateTime.UtcNow;
        await _unitOfWork.InventoryItems.UpdateAsync(item);

        // Record at the effective (weighted-average) unit cost so TotalCost equals the COGS actually
        // posted — the GL recompute reads TotalCost to reproduce the DR COGS / CR Inventory entry.
        var effectiveUnitCost = item.AverageCost > 0 ? item.AverageCost : item.UnitCost;
        var txn = new InventoryTransaction
        {
            InventoryItemId = item.Id,
            TransactionType = transactionType,
            Quantity        = -quantityUsed,
            UnitCost        = effectiveUnitCost,
            TotalCost       = quantityUsed * effectiveUnitCost,
            TransactionDate = DateTime.UtcNow,
            BalanceAfter    = item.QuantityOnHand,
            JobId           = jobId,
            Reference       = reference,
            Notes           = notes?.Trim(),
            CompanyId       = item.CompanyId,
            CreatedAt       = DateTime.UtcNow
        };
        await _unitOfWork.InventoryTransactions.AddAsync(txn);
        await _unitOfWork.CompleteAsync();

        if (item.CogsAccountId.HasValue && item.InventoryAccountId.HasValue)
        {
            var cost = txn.TotalCost;
            await _accountBalanceService.DebitAsync(item.CogsAccountId, cost);
            await _accountBalanceService.CreditAsync(item.InventoryAccountId, cost);
        }

        return new InventoryUsageResult(
            true,
            $"Logged {quantityUsed:N2} {item.UnitOfMeasure} of {item.Name}. New balance: {item.QuantityOnHand:N2} {item.UnitOfMeasure}.",
            item.QuantityOnHand,
            item.UnitOfMeasure,
            item.Name);
    }

    /// <summary>
    /// Records powder usage from the mobile scan page. Resolves the used quantity
    /// (caller already converts "remaining weight" to delta before posting) and redirects to ScanSuccess.
    /// </summary>
    [HttpPost]
    [ValidateAntiForgeryToken]
    public async Task<IActionResult> LogUsage(int inventoryItemId, int? jobId, decimal quantity,
        string transactionType, string? notes)
    {
        try
        {
            if (quantity <= 0)
            {
                TempData["ScanError"] = "Quantity must be greater than zero.";
                return RedirectToAction(nameof(Scan), new { id = inventoryItemId });
            }

            var result = await RecordInventoryUsageAsync(
                inventoryItemId, jobId, quantity,
                InventoryTransactionType.JobUsage, notes);

            if (!result.Success)
            {
                TempData["ScanError"] = result.Message;
                return RedirectToAction(nameof(Scan), new { id = inventoryItemId });
            }

            TempData["ScanSuccess"]  = result.Message;
            TempData["ScanItemId"]   = inventoryItemId.ToString();
            TempData["ScanJobId"]    = jobId?.ToString();
            TempData["ScanItemName"] = result.ItemName;
            return RedirectToAction(nameof(ScanSuccess));
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Error logging usage for inventory item {ItemId}", inventoryItemId);
            TempData["ScanError"] = "An error occurred while saving. Please try again.";
            return RedirectToAction(nameof(Scan), new { id = inventoryItemId });
        }
    }

    /// <summary>
    /// Records manual material usage from the job details modal. Accepts JSON, resolves
    /// the amount used (caller sends the already-computed used quantity), and returns JSON
    /// so the modal can close and refresh inline.
    /// </summary>
    [HttpPost]
    [ValidateAntiForgeryToken]
    public async Task<IActionResult> LogMaterial([FromBody] LogMaterialRequest req)
    {
        try
        {
            if (req.QuantityUsed <= 0)
                return Json(new { success = false, message = "Quantity used must be greater than zero." });

            var txnType = req.TransactionType == "Waste"
                ? InventoryTransactionType.Waste
                : InventoryTransactionType.JobUsage;

            var result = await RecordInventoryUsageAsync(
                req.InventoryItemId, req.JobId, req.QuantityUsed, txnType, req.Notes);

            return Json(new
            {
                success       = result.Success,
                message       = result.Message,
                newBalance    = result.NewBalance,
                unitOfMeasure = result.UnitOfMeasure,
                itemName      = result.ItemName
            });
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Error logging material for job {JobId}", req.JobId);
            return Json(new { success = false, message = "An error occurred. Please try again." });
        }
    }

    /// <summary>
    /// Success screen shown after a usage log is saved. Offers "Log Another Item for
    /// This Job" and "Done" options.
    /// </summary>
    [HttpGet]
    public IActionResult ScanSuccess()
    {
        ViewBag.Message  = TempData["ScanSuccess"] as string;
        ViewBag.ItemId   = TempData["ScanItemId"];
        ViewBag.JobId    = TempData["ScanJobId"];
        ViewBag.ItemName = TempData["ScanItemName"] as string;
        return View();
    }

    /// <summary>
    /// Applies a manual stock adjustment submitted from the Details page modal.
    /// Supports Add, Remove, and Set (exact count) modes and always records an
    /// InventoryTransaction so the change appears in the activity ledger.
    /// </summary>
    [HttpPost]
    [ValidateAntiForgeryToken]
    public async Task<IActionResult> StockAdjustment(int id, string adjustmentType, decimal quantity, string reason, string? notes)
    {
        try
        {
            var item = await _unitOfWork.InventoryItems.GetByIdAsync(id);
            if (item == null) return NotFound();

            if (quantity <= 0 && adjustmentType != "Set")
            {
                TempData["Error"] = "Quantity must be greater than zero.";
                return RedirectToAction(nameof(Details), new { id });
            }

            if (string.IsNullOrWhiteSpace(reason))
            {
                TempData["Error"] = "A reason is required for stock adjustments.";
                return RedirectToAction(nameof(Details), new { id });
            }

            var previousQty = item.QuantityOnHand;
            decimal delta;

            switch (adjustmentType)
            {
                case "Add":
                    delta = quantity;
                    item.QuantityOnHand += quantity;
                    break;
                case "Remove":
                    delta = -quantity;
                    item.QuantityOnHand -= quantity;
                    break;
                case "Set":
                    delta = quantity - item.QuantityOnHand;
                    item.QuantityOnHand = quantity;
                    break;
                default:
                    TempData["Error"] = "Invalid adjustment type.";
                    return RedirectToAction(nameof(Details), new { id });
            }

            item.UpdatedAt = DateTime.UtcNow;
            await _unitOfWork.InventoryItems.UpdateAsync(item);

            var fullNotes = string.IsNullOrWhiteSpace(notes)
                ? reason
                : $"{reason} — {notes.Trim()}";

            var txn = new InventoryTransaction
            {
                InventoryItemId = item.Id,
                TransactionType = InventoryTransactionType.Adjustment,
                Quantity = delta,
                UnitCost = item.UnitCost,
                TotalCost = Math.Abs(delta) * item.UnitCost,
                TransactionDate = DateTime.UtcNow,
                BalanceAfter = item.QuantityOnHand,
                Reference = $"Manual adjustment ({adjustmentType})",
                Notes = fullNotes
            };
            await _unitOfWork.InventoryTransactions.AddAsync(txn);
            await _unitOfWork.SaveChangesAsync();

            var direction = delta > 0 ? $"+{delta:N2}" : $"{delta:N2}";
            TempData["Success"] = $"Stock adjusted {direction} {item.UnitOfMeasure}. New balance: {item.QuantityOnHand:N2} {item.UnitOfMeasure}.";
            return RedirectToAction(nameof(Details), new { id });
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Error applying stock adjustment for item {ItemId}", id);
            TempData["Error"] = "An error occurred while saving the adjustment.";
            return RedirectToAction(nameof(Details), new { id });
        }
    }

    /// <summary>
    /// Displays the inventory activity ledger: stock transactions and powder usage logs.
    /// Optionally pre-filtered to a single item when arriving from the Details page.
    /// </summary>
    public async Task<IActionResult> Ledger(
        int? inventoryItemId,
        DateTime? dateFrom,
        DateTime? dateTo,
        string? typeFilter)
    {
        var ledgerCompanyId = _tenantContext.GetCurrentCompanyId() ?? 0;
        var allItems = await _unitOfWork.InventoryItems.FindAsync(i => i.CompanyId == ledgerCompanyId);
        var itemList = allItems
            .Where(i => i.IsActive || i.QuantityOnHand > 0)
            .OrderBy(i => i.Name)
            .Select(i => new InventoryListDto
            {
                Id = i.Id,
                SKU = i.SKU,
                Name = i.Name,
                QuantityOnHand = i.QuantityOnHand,
                UnitOfMeasure = i.UnitOfMeasure,
                IsActive = i.IsActive
            })
            .ToList();

        // Build transactions query
        InventoryTransactionType? parsedType = !string.IsNullOrWhiteSpace(typeFilter) && Enum.TryParse<InventoryTransactionType>(typeFilter, out var pt) ? pt : null;
        var transactions = await _unitOfWork.InventoryTransactions.GetForLedgerAsync(inventoryItemId, dateFrom, dateTo, parsedType);

        // Resolve JobId for legacy JobUsage transactions that stored job number in Reference but not JobId
        var unresolvedRefs = transactions
            .Where(t => t.TransactionType == InventoryTransactionType.JobUsage && t.JobId == null && !string.IsNullOrEmpty(t.Reference))
            .Select(t => t.Reference!)
            .Distinct()
            .ToList();
        var jobRefLookup = new Dictionary<string, (int Id, string JobNumber)>();
        if (unresolvedRefs.Any())
        {
            var matched = await _unitOfWork.Jobs.FindAsync(j => unresolvedRefs.Contains(j.JobNumber));
            jobRefLookup = matched.ToDictionary(j => j.JobNumber, j => (j.Id, j.JobNumber));
        }

        // Powder usage logs with dynamic date + item filters
        var usageLogs = await _unitOfWork.PowderUsageLogs.GetForLedgerAsync(inventoryItemId, dateFrom, dateTo);

        InventoryItem? selectedItem = null;
        if (inventoryItemId.HasValue)
            selectedItem = await _unitOfWork.InventoryItems.GetByIdAsync(inventoryItemId.Value);

        // Synthesize powder-usage rows for scan-based JobUsage transactions not already linked to a PowderUsageLog
        var linkedTxIds = usageLogs
            .Where(u => u.InventoryTransactionId.HasValue)
            .Select(u => u.InventoryTransactionId!.Value)
            .ToHashSet();

        var powderUsageDtos = usageLogs.Select(u => new PowderUsageLogDto
        {
            Id = u.Id,
            JobId = u.JobId,
            JobNumber = u.Job?.JobNumber ?? string.Empty,
            CustomerName = u.Job?.Customer?.CompanyName ?? $"{u.Job?.Customer?.ContactFirstName} {u.Job?.Customer?.ContactLastName}".Trim(),
            InventoryItemId = u.InventoryItemId,
            ItemName = u.InventoryItem?.Name,
            SKU = u.InventoryItem?.SKU,
            CoatColor = u.JobItemCoat?.ColorName,
            ActualLbsUsed = u.ActualLbsUsed,
            EstimatedLbs = u.EstimatedLbs,
            VarianceLbs = u.VarianceLbs,
            RecordedAt = u.RecordedAt,
            Notes = u.Notes
        }).ToList();

        // Scan-based JobUsage entries have a JobId on the transaction but no PowderUsageLog record;
        // surface them in the "Powder Usage By Job" tab so they aren't invisible.
        powderUsageDtos.AddRange(transactions
            .Where(t => t.TransactionType == InventoryTransactionType.JobUsage
                     && !linkedTxIds.Contains(t.Id)
                     && (t.JobId.HasValue || (t.Reference != null && jobRefLookup.ContainsKey(t.Reference))))
            .Select(t =>
            {
                var jobId = t.JobId ?? (t.Reference != null && jobRefLookup.TryGetValue(t.Reference, out var r) ? r.Id : 0);
                var jobNumber = t.Job?.JobNumber ?? (t.Reference != null && jobRefLookup.ContainsKey(t.Reference) ? t.Reference : string.Empty);
                var cust = t.Job?.Customer;
                var custName = cust?.CompanyName ?? $"{cust?.ContactFirstName} {cust?.ContactLastName}".Trim();
                return new PowderUsageLogDto
                {
                    Id = 0,
                    SourceTransactionId = t.Id,
                    JobId = jobId,
                    JobNumber = jobNumber,
                    CustomerName = string.IsNullOrWhiteSpace(custName) ? null : custName,
                    InventoryItemId = t.InventoryItemId,
                    ItemName = t.InventoryItem?.Name,
                    SKU = t.InventoryItem?.SKU,
                    CoatColor = null,
                    ActualLbsUsed = Math.Abs(t.Quantity),
                    EstimatedLbs = 0,
                    VarianceLbs = 0,
                    RecordedAt = t.TransactionDate,
                    Notes = t.Notes
                };
            }));

        powderUsageDtos = [.. powderUsageDtos.OrderByDescending(u => u.RecordedAt)];

        var vm = new InventoryLedgerViewModel
        {
            InventoryItemId = inventoryItemId,
            SelectedItemName = selectedItem?.Name,
            SelectedItemSku = selectedItem?.SKU,
            DateFrom = dateFrom,
            DateTo = dateTo,
            TypeFilter = typeFilter,
            AllItems = itemList,
            Transactions = transactions.Select(t => new InventoryTransactionDto
            {
                Id = t.Id,
                InventoryItemId = t.InventoryItemId,
                ItemName = t.InventoryItem?.Name ?? string.Empty,
                SKU = t.InventoryItem?.SKU ?? string.Empty,
                TransactionType = t.TransactionType.ToString(),
                Quantity = t.Quantity,
                UnitCost = t.UnitCost,
                TotalCost = t.TotalCost,
                TransactionDate = t.TransactionDate,
                Reference = t.Reference,
                Notes = t.Notes,
                BalanceAfter = t.BalanceAfter,
                PurchaseOrderId = t.PurchaseOrderId,
                PurchaseOrderNumber = t.PurchaseOrder?.PoNumber,
                JobId = t.JobId ?? (t.Reference != null && jobRefLookup.TryGetValue(t.Reference, out var resolved) ? resolved.Id : null),
                JobNumber = t.Job?.JobNumber ?? (t.Reference != null && jobRefLookup.ContainsKey(t.Reference) ? t.Reference : null)
            }).ToList(),
            PowderUsageLogs = powderUsageDtos,
            TotalPurchased = transactions
                .Where(t => t.TransactionType == InventoryTransactionType.Purchase || t.TransactionType == InventoryTransactionType.Initial)
                .Sum(t => t.Quantity),
            TotalUsed = transactions
                .Where(t => t.TransactionType == InventoryTransactionType.JobUsage || t.TransactionType == InventoryTransactionType.Sale)
                .Sum(t => Math.Abs(t.Quantity)),
            TotalAdjusted = transactions
                .Where(t => t.TransactionType == InventoryTransactionType.Adjustment)
                .Sum(t => t.Quantity)
        };

        return View(vm);
    }

    /// <summary>
    /// Returns the current values of a JobUsage InventoryTransaction plus a list of active
    /// jobs (plus the currently assigned job even if terminal) for the edit modal.
    /// </summary>
    [HttpGet]
    public async Task<IActionResult> GetUsageForEdit(int id)
    {
        var txn = await _unitOfWork.InventoryTransactions.GetByIdAsync(id, false,
            t => t.Job, t => t.InventoryItem);
        if (txn == null) return NotFound();
        if (txn.TransactionType != InventoryTransactionType.JobUsage
            && txn.TransactionType != InventoryTransactionType.Adjustment)
            return BadRequest("Only usage transactions can be edited here.");

        var allJobs = await _unitOfWork.Jobs.FindAsync(
            j => j.CompanyId == txn.CompanyId && !j.JobStatus.IsTerminalStatus,
            false,
            j => j.Customer,
            j => j.JobStatus);

        var jobs = allJobs
            .OrderByDescending(j => j.CreatedAt)
            .Take(200)
            .Select(j => new ScanJobOption
            {
                Id = j.Id,
                JobNumber = j.JobNumber,
                CustomerName = j.Customer != null
                    ? (j.Customer.CompanyName ?? $"{j.Customer.ContactFirstName} {j.Customer.ContactLastName}".Trim())
                    : "No Customer"
            })
            .ToList();

        // If the assigned job has terminal status it won't appear in the active list; insert it at the top
        // so the dropdown pre-selects correctly and the user can see the existing job assignment.
        if (txn.JobId.HasValue && jobs.All(j => j.Id != txn.JobId.Value))
        {
            var assignedJob = await _unitOfWork.Jobs.GetByIdAsync(txn.JobId.Value, false, j => j.Customer);
            if (assignedJob != null)
                jobs.Insert(0, new ScanJobOption
                {
                    Id = assignedJob.Id,
                    JobNumber = assignedJob.JobNumber,
                    CustomerName = assignedJob.Customer != null
                        ? (assignedJob.Customer.CompanyName ?? $"{assignedJob.Customer.ContactFirstName} {assignedJob.Customer.ContactLastName}".Trim())
                        : "No Customer"
                });
        }

        return Json(new
        {
            transactionId = txn.Id,
            jobId = txn.JobId,
            quantity = Math.Abs(txn.Quantity),
            notes = txn.Notes,
            transactionDate = txn.TransactionDate.ToString("yyyy-MM-ddTHH:mm"),
            itemName = txn.InventoryItem?.Name,
            jobs
        });
    }

    /// <summary>
    /// Saves edits to a JobUsage InventoryTransaction: job assignment, quantity, notes, and date.
    /// When quantity changes the InventoryItem.QuantityOnHand is adjusted by the delta so the
    /// ledger balance remains consistent.
    /// </summary>
    [HttpPost]
    [ValidateAntiForgeryToken]
    public async Task<IActionResult> EditUsageTransaction(int id, int? jobId, string? notes, DateTime transactionDate, decimal? quantity)
    {
        var txn = await _unitOfWork.InventoryTransactions.GetByIdAsync(id, false, t => t.InventoryItem);
        if (txn == null) return NotFound();
        if (txn.TransactionType != InventoryTransactionType.JobUsage
            && txn.TransactionType != InventoryTransactionType.Adjustment)
            return BadRequest();

        if (jobId.HasValue)
        {
            var job = await _unitOfWork.Jobs.GetByIdAsync(jobId.Value);
            txn.JobId = jobId.Value;
            txn.Reference = job?.JobNumber;
        }
        else
        {
            txn.JobId = null;
            txn.Reference = null;
        }

        // Promote Adjustment→JobUsage when a job is assigned so it shows in Powder Usage By Job tab
        if (jobId.HasValue && txn.TransactionType == InventoryTransactionType.Adjustment)
            txn.TransactionType = InventoryTransactionType.JobUsage;

        // Adjust inventory when the logged quantity is changed.
        // txn.Quantity is stored as a negative number for usage (e.g. -3.5 for 3.5 lbs used).
        if (quantity.HasValue && quantity.Value > 0)
        {
            var oldUsed = Math.Abs(txn.Quantity);
            var newUsed = quantity.Value;
            if (oldUsed != newUsed)
            {
                var item = txn.InventoryItem ?? await _unitOfWork.InventoryItems.GetByIdAsync(txn.InventoryItemId);
                if (item != null)
                {
                    // Positive delta means less was actually used → restore the difference to inventory.
                    item.QuantityOnHand += oldUsed - newUsed;
                    item.UpdatedAt = DateTime.UtcNow;
                    await _unitOfWork.InventoryItems.UpdateAsync(item);
                    txn.BalanceAfter = item.QuantityOnHand;
                }
                txn.Quantity = -newUsed;
                txn.TotalCost = newUsed * txn.UnitCost;
            }
        }

        txn.Notes = notes?.Trim();
        txn.TransactionDate = transactionDate.Kind == DateTimeKind.Utc
            ? transactionDate : DateTime.SpecifyKind(transactionDate, DateTimeKind.Utc);
        txn.UpdatedAt = DateTime.UtcNow;

        await _unitOfWork.InventoryTransactions.UpdateAsync(txn);
        await _unitOfWork.SaveChangesAsync();

        return Json(new { success = true });
    }
}

/// <summary>Helper projection used by the Scan action for job picker data.</summary>
public class ScanJobOption
{
    public int Id { get; set; }
    public string JobNumber { get; set; } = string.Empty;
    public string CustomerName { get; set; } = string.Empty;
}

/// <summary>Result returned by RecordInventoryUsageAsync.</summary>
public record InventoryUsageResult(
    bool Success,
    string Message,
    decimal NewBalance,
    string UnitOfMeasure,
    string ItemName);

/// <summary>JSON body for the LogMaterial endpoint (job details modal).</summary>
public class LogMaterialRequest
{
    public int     JobId           { get; set; }
    public int     InventoryItemId { get; set; }
    public decimal QuantityUsed    { get; set; }
    public string  TransactionType { get; set; } = "JobUsage";
    public string? Notes           { get; set; }
}
