using AutoMapper;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using PowderCoating.Application.DTOs.Company;
using PowderCoating.Application.DTOs.Lookup;
using PowderCoating.Application.DTOs.Notification;
using PowderCoating.Application.DTOs.PrepService;
using PowderCoating.Application.Interfaces;
using PowderCoating.Application.Services;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Enums;
using PowderCoating.Core.Interfaces;
using PowderCoating.Core.Interfaces.Services;
using PowderCoating.Infrastructure.Data;
using PowderCoating.Shared.Constants;
using System.Security.Claims;

namespace PowderCoating.Web.Controllers;

[Authorize(Policy = AppConstants.Policies.CompanyAdminOnly)]
public class CompanySettingsController : Controller
{
    private readonly IUnitOfWork _unitOfWork;
    private readonly IMapper _mapper;
    private readonly ITenantContext _tenantContext;
    private readonly ILogger<CompanySettingsController> _logger;
    private readonly ICompanyLogoService _logoService;
    private readonly ILookupCacheService _lookupCache;
    private readonly IStripeConnectService _stripeConnect;
    private readonly IConfiguration _configuration;
    private readonly IAuditLogService _auditLog;
    private readonly UserManager<ApplicationUser> _userManager;
    private readonly SignInManager<ApplicationUser> _signInManager;
    private readonly IAzureBlobStorageService _blobStorage;
    private readonly ICustomFormulaAiService _formulaAiService;
    private readonly IFormulaLibraryService _formulaLibraryService;

    public CompanySettingsController(
        IUnitOfWork unitOfWork,
        IMapper mapper,
        ITenantContext tenantContext,
        ILogger<CompanySettingsController> logger,
        ICompanyLogoService logoService,
        ILookupCacheService lookupCache,
        IStripeConnectService stripeConnect,
        IConfiguration configuration,
        IAuditLogService auditLog,
        UserManager<ApplicationUser> userManager,
        SignInManager<ApplicationUser> signInManager,
        IAzureBlobStorageService blobStorage,
        ICustomFormulaAiService formulaAiService,
        IFormulaLibraryService formulaLibraryService)
    {
        _unitOfWork = unitOfWork;
        _mapper = mapper;
        _tenantContext = tenantContext;
        _logger = logger;
        _logoService = logoService;
        _lookupCache = lookupCache;
        _stripeConnect = stripeConnect;
        _configuration = configuration;
        _auditLog = auditLog;
        _userManager = userManager;
        _signInManager = signInManager;
        _blobStorage = blobStorage;
        _formulaAiService = formulaAiService;
        _formulaLibraryService = formulaLibraryService;
    }

    /// <summary>
    /// Diagnostic endpoint that dumps the current user's claims as JSON. Used during development and
    /// support investigations to verify that Identity claims (CompanyId, CompanyRole, system role) are
    /// correctly populated after login. Restricted to SuperAdmin so it is never exposed to tenant users.
    /// </summary>
    // GET: CompanySettings/TestClaims - Debug endpoint (SuperAdmin only)
    [Authorize(Policy = AppConstants.Policies.SuperAdminOnly)]
    public IActionResult TestClaims()
    {
        var claims = User.Claims.Select(c => new { c.Type, c.Value }).ToList();
        return Json(new
        {
            isAuthenticated = User.Identity?.IsAuthenticated,
            userName = User.Identity?.Name,
            claims = claims,
            companyId = User.FindFirst("CompanyId")?.Value,
            companyRole = User.FindFirst("CompanyRole")?.Value
        });
    }

    /// <summary>
    /// Renders the Company Settings hub page with all configuration tabs pre-loaded.
    /// Resolves the tenant from <see cref="ITenantContext"/> — if the claim is missing or malformed
    /// (e.g. Base-64 decode failure after a cookie format change) the user is bounced to Home rather
    /// than shown a hard error. Notification templates are lazily seeded on first visit via
    /// <see cref="EnsureNotificationTemplatesSeededAsync"/> so the database never starts empty.
    /// The <c>AllowOnlinePayments</c> flag is read from <c>SubscriptionPlanConfig</c> (not hardcoded)
    /// so plan changes take effect without a deployment.
    /// </summary>
    // GET: CompanySettings
    public async Task<IActionResult> Index()
    {
        try
        {
            // Log user information for debugging
            var userName = User.Identity?.Name;
            var companyRole = User.FindFirst("CompanyRole")?.Value;
            var companyIdClaim = User.FindFirst("CompanyId")?.Value;

            _logger.LogInformation("CompanySettings accessed by user: {UserName}, CompanyRole: {CompanyRole}, CompanyIdClaim: {CompanyIdClaim}",
                userName, companyRole, companyIdClaim);

            var companyId = _tenantContext.GetCurrentCompanyId();
            if (companyId == null)
            {
                _logger.LogWarning("User {UserName} does not have a company ID. CompanyRole: {CompanyRole}", userName, companyRole);
                TempData["ErrorMessage"] = "Your account is not associated with a company. Please contact support.";
                return RedirectToAction("Index", "Home");
            }

            // Load company with operating costs and preferences
            var company = await _unitOfWork.Companies.GetByIdAsync(
                companyId.Value,
                false,
                c => c.OperatingCosts,
                c => c.Preferences);

            if (company == null)
            {
                _logger.LogWarning("Company {CompanyId} not found for user {UserName}", companyId, userName);
                TempData["ErrorMessage"] = "Company not found.";
                return RedirectToAction("Index", "Home");
            }

            var dto = _mapper.Map<CompanySettingsDto>(company);

            // Populate plan-gated feature flags
            var planConfig = await _unitOfWork.SubscriptionPlanConfigs.FirstOrDefaultAsync(p => p.Plan == company.SubscriptionPlan);
            dto.AllowOnlinePayments = planConfig?.AllowOnlinePayments ?? false;
            dto.AllowSms            = planConfig?.AllowSms ?? false;
            ViewBag.AllowCustomFormulas = AllowCustomFormulas();
            dto.SmsEnabled          = company.SmsEnabled;
            dto.SmsDisabledByAdmin  = company.SmsDisabledByAdmin;
            dto.SmsTermsVersion     = AppConstants.SmsTermsVersion;
            dto.HasCurrentSmsAgreement = await _unitOfWork.CompanySmsAgreements
                .AnyAsync(a => a.CompanyId == companyId.Value && a.TermsVersion == AppConstants.SmsTermsVersion);

            // Timeclock settings
            dto.TimeclockEnabled                  = company.TimeclockEnabled;
            dto.TimeclockAllowMultiplePunchesPerDay = company.TimeclockAllowMultiplePunchesPerDay;
            dto.TimeclockAutoClockOutHours         = company.TimeclockAutoClockOutHours;
            var kioskDevices = await _unitOfWork.TimeclockKioskDevices.FindAsync(d => d.CompanyId == companyId.Value);
            ViewBag.TimeclockKioskDevices = kioskDevices.OrderBy(d => d.ActivatedAt).ToList();

            // Flag whether Stripe Connect is configured (non-placeholder client ID)
            var connectClientId = _configuration["Stripe:Connect:ConnectClientId"];
            ViewBag.StripeConnectConfigured = !string.IsNullOrWhiteSpace(connectClientId)
                && !connectClientId.Contains("your_connect_client_id_here", StringComparison.OrdinalIgnoreCase);

            // Load notification templates for inline tab
            var existing = await _unitOfWork.NotificationTemplates.FindAsync(t => t.CompanyId == companyId.Value);
            var seeded = await EnsureNotificationTemplatesSeededAsync(companyId.Value, existing.ToList());
            if (seeded > 0)
                existing = await _unitOfWork.NotificationTemplates.FindAsync(t => t.CompanyId == companyId.Value);

            dto.NotificationTemplates = existing
                .OrderBy(t => (int)t.NotificationType).ThenBy(t => (int)t.Channel)
                .Select(t => new NotificationTemplateDto
                {
                    Id = t.Id,
                    NotificationType = t.NotificationType,
                    Channel = t.Channel,
                    DisplayName = t.DisplayName,
                    Subject = t.Subject,
                    Body = t.Body,
                    UpdatedAt = t.UpdatedAt
                }).ToList();

            ViewBag.BookLockedThrough = company.BookLockedThrough.HasValue
                ? (DateTime?)company.BookLockedThrough.Value.ToLocalTime()
                : null;

            ViewBag.IsDemoCompany = company.CompanyCode == "DEMO";

            return View(dto);
        }
        catch (FormatException fex)
        {
            _logger.LogError(fex, "Format exception in CompanySettings - likely Base-64 decode issue");
            TempData["ErrorMessage"] = "An authentication error occurred. Please try logging out and logging back in.";
            return RedirectToAction("Index", "Home");
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Error loading company settings for user {UserName}", User.Identity?.Name);
            TempData["ErrorMessage"] = $"An error occurred: {ex.Message}";
            return RedirectToAction("Index", "Home");
        }
    }

    /// <summary>
    /// Persists basic company profile fields (name, address, contact info, timezone) from an AJAX call.
    /// Uses AutoMapper to apply only the mapped fields so unrelated Company properties
    /// (logo, subscription plan, Stripe keys) are never accidentally overwritten.
    /// Returns a JSON envelope so the view can display an inline success/error toast without a full reload.
    /// </summary>
    // POST: CompanySettings/UpdateCompanyInfo
    [HttpPost]
    public async Task<IActionResult> UpdateCompanyInfo([FromBody] UpdateCompanySettingsDto dto)
    {
        try
        {
            if (!ModelState.IsValid)
            {
                var errors = ModelState.Values
                    .SelectMany(v => v.Errors)
                    .Select(e => e.ErrorMessage)
                    .ToList();
                return Json(new { success = false, message = string.Join(", ", errors) });
            }

            var companyId = _tenantContext.GetCurrentCompanyId();
            if (companyId == null)
            {
                return Json(new { success = false, message = "User does not have a company ID." });
            }

            var company = await _unitOfWork.Companies.GetByIdAsync(companyId.Value);
            if (company == null)
            {
                return Json(new { success = false, message = "Company not found." });
            }

            // Explicit assignment avoids AutoMapper quirks with tracked EF entities
            company.CompanyName         = dto.CompanyName.Trim();
            company.CompanyCode         = string.IsNullOrWhiteSpace(dto.CompanyCode) ? null : dto.CompanyCode.Trim();
            company.PrimaryContactName  = dto.PrimaryContactName.Trim();
            company.PrimaryContactEmail = dto.PrimaryContactEmail.Trim();
            company.Phone               = string.IsNullOrWhiteSpace(dto.Phone)     ? null : dto.Phone.Trim();
            company.Address             = string.IsNullOrWhiteSpace(dto.Address)   ? null : dto.Address.Trim();
            company.City                = string.IsNullOrWhiteSpace(dto.City)      ? null : dto.City.Trim();
            company.State               = string.IsNullOrWhiteSpace(dto.State)     ? null : dto.State.Trim();
            company.ZipCode             = string.IsNullOrWhiteSpace(dto.ZipCode)   ? null : dto.ZipCode.Trim();
            company.TimeZone            = string.IsNullOrWhiteSpace(dto.TimeZone)  ? null : dto.TimeZone.Trim();
            company.AccountingMethod    = dto.AccountingMethod;

            await _unitOfWork.CompleteAsync();

            _logger.LogInformation("Company {CompanyId} settings updated by user", companyId);

            return Json(new { success = true, message = "Company information updated successfully." });
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Error updating company information");
            return Json(new { success = false, message = "An error occurred while updating company information." });
        }
    }

    /// <summary>
    /// Locks the books through the given date, preventing new or edited accounting entries
    /// (JEs, bills, expenses) from being dated on or before this date. Null clears the lock.
    /// </summary>
    [HttpPost]
    [ValidateAntiForgeryToken]
    public async Task<IActionResult> SetPeriodLock(DateTime? lockThrough)
    {
        var companyId = _tenantContext.GetCurrentCompanyId();
        if (companyId == null) return BadRequest();

        var company = await _unitOfWork.Companies.GetByIdAsync(companyId.Value);
        if (company == null) return NotFound();

        company.BookLockedThrough = lockThrough.HasValue
            ? DateTime.SpecifyKind(lockThrough.Value.Date, DateTimeKind.Utc)
            : null;
        company.UpdatedAt = DateTime.UtcNow;
        await _unitOfWork.Companies.UpdateAsync(company);
        await _unitOfWork.CompleteAsync();

        TempData["Success"] = lockThrough.HasValue
            ? $"Books locked through {lockThrough.Value:MMMM d, yyyy}."
            : "Period lock cleared — all periods are now open.";

        return RedirectToAction(nameof(Index), null, "company-info");
    }

    /// <summary>
    /// Serves the current company's logo as a binary file response. Logos are stored on the filesystem
    /// via <see cref="ICompanyLogoService"/> (primary) or as raw bytes in <c>Company.LogoData</c>
    /// (legacy). On first access of a legacy DB-stored logo the file is automatically migrated to the
    /// filesystem and the DB columns are cleared, so the migration happens transparently without a
    /// separate migration script. The 1-hour response cache prevents redundant DB hits on pages that
    /// display the logo repeatedly (e.g. every request to the sidebar layout).
    /// </summary>
    // GET: CompanySettings/Logo
    [HttpGet]
    [ResponseCache(Duration = 3600, Location = ResponseCacheLocation.Any, VaryByHeader = "Accept")]
    public async Task<IActionResult> Logo()
    {
        var companyId = _tenantContext.GetCurrentCompanyId();
        if (companyId == null) return NotFound();

        var company = await _unitOfWork.Companies.GetByIdAsync(companyId.Value);
        if (company == null) return NotFound();

        // Try filesystem first
        if (!string.IsNullOrEmpty(company.LogoFilePath))
        {
            var (success, fileContent, contentType, errorMessage) = await _logoService.GetCompanyLogoAsync(company.LogoFilePath);
            if (success)
            {
                return File(fileContent, contentType);
            }
        }

        // Fallback to database (legacy)
        if (company.LogoData != null && company.LogoData.Length > 0)
        {
            // Migrate to filesystem on access
            var tempFile = new FormFile(
                new MemoryStream(company.LogoData),
                0,
                company.LogoData.Length,
                "logo",
                $"logo{Path.GetExtension(company.LogoContentType ?? ".png")}"
            );

            var (migrationSuccess, filePath, _) = await _logoService.SaveCompanyLogoAsync(tempFile, companyId.Value);
            if (migrationSuccess)
            {
                company.LogoFilePath = filePath;
                await _unitOfWork.Companies.UpdateAsync(company);
                await _unitOfWork.CompleteAsync();
                _logger.LogInformation("Migrated company {CompanyId} logo from database to filesystem", companyId);
            }

            return File(company.LogoData, company.LogoContentType ?? "image/png");
        }

        return NotFound();
    }

    /// <summary>
    /// Accepts a multipart logo upload, saves it to the filesystem via <see cref="ICompanyLogoService"/>,
    /// and updates <c>Company.LogoFilePath</c>. Any previously stored filesystem file is deleted first
    /// to prevent orphaned files accumulating over repeated uploads. The legacy DB columns
    /// (<c>LogoData</c> and <c>LogoContentType</c>) are explicitly nulled on every new upload so that
    /// the old binary storage path is permanently retired for this tenant.
    /// Returns a JSON envelope with the new logo URL so the view can hot-swap the img src without reload.
    /// </summary>
    // POST: CompanySettings/UploadLogo
    [HttpPost]
    [ValidateAntiForgeryToken]
    public async Task<IActionResult> UploadLogo(IFormFile logoFile)
    {
        try
        {
            if (logoFile == null || logoFile.Length == 0)
            {
                return Json(new { success = false, message = "No file was uploaded." });
            }

            var companyId = _tenantContext.GetCurrentCompanyId();
            if (companyId == null)
            {
                return Json(new { success = false, message = "User does not have a company ID." });
            }

            var company = await _unitOfWork.Companies.GetByIdAsync(companyId.Value);
            if (company == null)
            {
                return Json(new { success = false, message = "Company not found." });
            }

            // Delete old logo from filesystem if it exists
            if (!string.IsNullOrEmpty(company.LogoFilePath))
            {
                await _logoService.DeleteCompanyLogoAsync(company.LogoFilePath);
            }

            // Save new logo to filesystem
            var (success, filePath, errorMessage) = await _logoService.SaveCompanyLogoAsync(logoFile, companyId.Value);
            if (!success)
            {
                return Json(new { success = false, message = errorMessage });
            }

            // Update company record
            company.LogoFilePath = filePath;
            company.LogoData = null;  // Clear legacy database storage
            company.LogoContentType = null;  // Clear legacy database storage
            company.UpdatedAt = DateTime.UtcNow;

            await _unitOfWork.Companies.UpdateAsync(company);
            await _unitOfWork.CompleteAsync();

            _logger.LogInformation("Company {CompanyId} logo uploaded to filesystem", companyId);

            return Json(new
            {
                success = true,
                message = "Logo uploaded successfully.",
                logoUrl = Url.Action("Logo", "CompanySettings")
            });
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Error uploading company logo");
            return Json(new { success = false, message = "An error occurred while uploading the logo." });
        }
    }

    /// <summary>
    /// Removes the company logo from both the filesystem and all DB columns (filesystem path and the
    /// legacy binary fields). After this call the sidebar will fall back to the platform PCL logo.
    /// Both storage locations are always cleared in one transaction so the DB and filesystem never
    /// get out of sync regardless of which path currently holds the logo.
    /// </summary>
    // POST: CompanySettings/DeleteLogo
    [HttpPost]
    public async Task<IActionResult> DeleteLogo()
    {
        try
        {
            var companyId = _tenantContext.GetCurrentCompanyId();
            if (companyId == null)
            {
                return Json(new { success = false, message = "User does not have a company ID." });
            }

            var company = await _unitOfWork.Companies.GetByIdAsync(companyId.Value);
            if (company == null)
            {
                return Json(new { success = false, message = "Company not found." });
            }

            // Delete from filesystem if it exists
            if (!string.IsNullOrEmpty(company.LogoFilePath))
            {
                await _logoService.DeleteCompanyLogoAsync(company.LogoFilePath);
            }

            // Clear database fields (both legacy and new)
            company.LogoFilePath = null;
            company.LogoData = null;
            company.LogoContentType = null;
            company.UpdatedAt = DateTime.UtcNow;

            await _unitOfWork.Companies.UpdateAsync(company);
            await _unitOfWork.CompleteAsync();

            _logger.LogInformation("Company {CompanyId} logo deleted", companyId);

            return Json(new { success = true, message = "Logo deleted successfully." });
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Error deleting company logo");
            return Json(new { success = false, message = "An error occurred while deleting the logo." });
        }
    }

    /// <summary>
    /// Generic helper that persists any <c>CompanyPreferences</c>-mapped DTO via upsert. Because all
    /// preferences live on a single child entity (<see cref="CompanyPreferences"/>) that may not exist
    /// for newly onboarded companies, this method creates the row if absent rather than failing with a
    /// null-reference. All public preference endpoints (<see cref="UpdateAppDefaults"/>,
    /// <see cref="UpdateJobDefaults"/>, <see cref="UpdateNotifications"/>, etc.) delegate here so the
    /// upsert logic is maintained in one place.
    /// </summary>
    private async Task<IActionResult> UpdatePreferences<TDto>(TDto dto, string successMessage)
    {
        try
        {
            if (!ModelState.IsValid)
            {
                var errors = ModelState.Values.SelectMany(v => v.Errors).Select(e => e.ErrorMessage);
                return Json(new { success = false, message = string.Join(", ", errors) });
            }

            var companyId = _tenantContext.GetCurrentCompanyId();
            if (companyId == null)
                return Json(new { success = false, message = "User does not have a company ID." });

            var company = await _unitOfWork.Companies.GetByIdAsync(companyId.Value, false, c => c.Preferences);
            if (company == null)
                return Json(new { success = false, message = "Company not found." });

            if (company.Preferences == null)
            {
                company.Preferences = new CompanyPreferences { CompanyId = companyId.Value };
                _mapper.Map(dto, company.Preferences);
                await _unitOfWork.CompanyPreferences.AddAsync(company.Preferences);
            }
            else
            {
                _mapper.Map(dto, company.Preferences);
                company.Preferences.UpdatedAt = DateTime.UtcNow;
                await _unitOfWork.CompanyPreferences.UpdateAsync(company.Preferences);
            }

            await _unitOfWork.CompleteAsync();
            return Json(new { success = true, message = successMessage });
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Error updating company preferences");
            return Json(new { success = false, message = "An error occurred while saving settings." });
        }
    }

    /// <summary>
    /// Saves application-level defaults (currency, date format, default payment terms, quote validity days)
    /// to <see cref="CompanyPreferences"/>. Delegates to <see cref="UpdatePreferences{TDto}"/>.
    /// </summary>
    // POST: CompanySettings/UpdateAppDefaults
    [HttpPost]
    public Task<IActionResult> UpdateAppDefaults([FromBody] UpdateAppDefaultsDto dto) =>
        UpdatePreferences(dto, "Application defaults saved successfully.");

    /// <summary>
    /// Saves job and workflow defaults (default turnaround days, auto-status transitions, shop access code
    /// requirements) to <see cref="CompanyPreferences"/>. Delegates to <see cref="UpdatePreferences{TDto}"/>.
    /// </summary>
    // POST: CompanySettings/UpdateJobDefaults
    [HttpPost]
    public Task<IActionResult> UpdateJobDefaults([FromBody] UpdateJobDefaultsDto dto) =>
        UpdatePreferences(dto, "Job/workflow defaults saved successfully.");

    /// <summary>
    /// Saves notification channel preferences (email enabled, SMS enabled, in-app enabled) to
    /// <see cref="CompanyPreferences"/>. Delegates to <see cref="UpdatePreferences{TDto}"/>.
    /// </summary>
    // POST: CompanySettings/UpdateNotifications
    [HttpPost]
    public Task<IActionResult> UpdateNotifications([FromBody] UpdateNotificationsDto dto) =>
        UpdatePreferences(dto, "Notification settings saved successfully.");

    /// <summary>
    /// Saves data-retention policy preferences (how long to keep soft-deleted records, completed jobs, etc.)
    /// to <see cref="CompanyPreferences"/>. Delegates to <see cref="UpdatePreferences{TDto}"/>.
    /// </summary>
    // POST: CompanySettings/UpdateDataRetention
    [HttpPost]
    public Task<IActionResult> UpdateDataRetention([FromBody] UpdateDataRetentionDto dto) =>
        UpdatePreferences(dto, "Data retention settings saved successfully.");

    /// <summary>
    /// Saves Quote PDF layout preferences (footer text, T&amp;C blurb, show/hide columns, signature line)
    /// to <see cref="CompanyPreferences"/>. Delegates to <see cref="UpdatePreferences{TDto}"/>.
    /// </summary>
    // POST: CompanySettings/UpdateQuoteTemplate
    [HttpPost]
    public Task<IActionResult> UpdateQuoteTemplate([FromBody] UpdateQuoteTemplateDto dto) =>
        UpdatePreferences(dto, "Quote PDF settings saved successfully.");

    /// <summary>
    /// Saves Invoice PDF layout preferences (payment instructions, bank details, footer text)
    /// to <see cref="CompanyPreferences"/>. Delegates to <see cref="UpdatePreferences{TDto}"/>.
    /// </summary>
    // POST: CompanySettings/UpdateInvoiceTemplate
    [HttpPost]
    public Task<IActionResult> UpdateInvoiceTemplate([FromBody] UpdateInvoiceTemplateDto dto) =>
        UpdatePreferences(dto, "Invoice PDF settings saved successfully.");

    // POST: CompanySettings/UpdateWorkOrderTemplate
    [HttpPost]
    public Task<IActionResult> UpdateWorkOrderTemplate([FromBody] UpdateWorkOrderTemplateDto dto) =>
        UpdatePreferences(dto, "Work order settings saved successfully.");

    /// <summary>
    /// Saves kiosk intake output preference ("Quote" or "Job") to <see cref="CompanyPreferences"/>.
    /// Delegates to <see cref="UpdatePreferences{TDto}"/>.
    /// </summary>
    // POST: CompanySettings/UpdateKioskSettings
    [HttpPost]
    public Task<IActionResult> UpdateKioskSettings([FromBody] UpdateKioskSettingsDto dto) =>
        UpdatePreferences(dto, "Kiosk settings saved successfully.");

    /// <summary>
    /// Persists the company's pricing model parameters — labor rates, sandblasting/masking multipliers,
    /// oven cost per hour, overhead admin/facility percentages, profit margin, and default tax rate —
    /// into <see cref="CompanyOperatingCosts"/>. These values feed the <c>IPricingCalculationService</c>
    /// for every quote and job costing calculation, so changes here take effect on all new quotes
    /// immediately. Uses an upsert pattern identical to <see cref="UpdatePreferences{TDto}"/>.
    /// </summary>
    // POST: CompanySettings/UpdateOperatingCosts
    [HttpPost]
    public async Task<IActionResult> UpdateOperatingCosts([FromBody] UpdateOperatingCostsDto dto)
    {
        try
        {
            if (!ModelState.IsValid)
            {
                var errors = ModelState.Values
                    .SelectMany(v => v.Errors)
                    .Select(e => e.ErrorMessage)
                    .ToList();
                return Json(new { success = false, message = string.Join(", ", errors) });
            }

            var companyId = _tenantContext.GetCurrentCompanyId();
            if (companyId == null)
            {
                return Json(new { success = false, message = "User does not have a company ID." });
            }

            var company = await _unitOfWork.Companies.GetByIdAsync(
                companyId.Value,
                false,
                c => c.OperatingCosts);

            if (company == null)
            {
                return Json(new { success = false, message = "Company not found." });
            }

            // Create or update operating costs
            if (company.OperatingCosts == null)
            {
                company.OperatingCosts = new CompanyOperatingCosts
                {
                    CompanyId = companyId.Value
                };
                _mapper.Map(dto, company.OperatingCosts);
                await _unitOfWork.CompanyOperatingCosts.AddAsync(company.OperatingCosts);
            }
            else
            {
                _mapper.Map(dto, company.OperatingCosts);
                company.OperatingCosts.UpdatedAt = DateTime.UtcNow;
                await _unitOfWork.CompanyOperatingCosts.UpdateAsync(company.OperatingCosts);
            }

            await _unitOfWork.CompleteAsync();

            _logger.LogInformation("Company {CompanyId} operating costs updated", companyId);

            return Json(new { success = true, message = "Operating costs updated successfully." });
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Error updating operating costs");
            return Json(new { success = false, message = "An error occurred while updating operating costs." });
        }
    }

    /// <summary>
    /// Saves the free-text AI context profile to <c>CompanyOperatingCosts.AiContextProfile</c>.
    /// This text is injected into the system prompt by <c>AiQuoteService.BuildSystemPrompt(context)</c>
    /// so the Anthropic Claude model receives company-specific context (specialties, common item types,
    /// pricing philosophy) before analyzing photos. This is one of three layers in the per-company AI
    /// learning approach (manual context, auto pricing config, few-shot accepted predictions).
    /// Leading/trailing whitespace is trimmed before storage to avoid invisible formatting issues in prompts.
    /// </summary>
    // POST: CompanySettings/UpdateAiProfile
    [HttpPost]
    public async Task<IActionResult> UpdateAiProfile([FromBody] UpdateAiProfileDto dto)
    {
        try
        {
            var companyId = _tenantContext.GetCurrentCompanyId();
            if (companyId == null)
                return Json(new { success = false, message = "User does not have a company ID." });

            var company = await _unitOfWork.Companies.GetByIdAsync(companyId.Value, false, c => c.OperatingCosts);
            if (company == null)
                return Json(new { success = false, message = "Company not found." });

            if (company.OperatingCosts == null)
            {
                company.OperatingCosts = new CompanyOperatingCosts { CompanyId = companyId.Value };
                company.OperatingCosts.AiContextProfile = dto.AiContextProfile?.Trim();
                await _unitOfWork.CompanyOperatingCosts.AddAsync(company.OperatingCosts);
            }
            else
            {
                company.OperatingCosts.AiContextProfile = dto.AiContextProfile?.Trim();
                company.OperatingCosts.UpdatedAt = DateTime.UtcNow;
                await _unitOfWork.CompanyOperatingCosts.UpdateAsync(company.OperatingCosts);
            }

            await _unitOfWork.CompleteAsync();
            _logger.LogInformation("Company {CompanyId} AI profile updated", companyId);
            return Json(new { success = true, message = "AI profile saved successfully." });
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Error updating AI profile");
            return Json(new { success = false, message = "An error occurred while saving the AI profile." });
        }
    }

    /// <summary>
    /// Toggles the company-level SMS opt-in flag. When enabling and no current-version agreement
    /// exists, the request must include AgreedToTerms=true and a matching TermsVersion — the
    /// acceptance is then recorded as a <see cref="CompanySmsAgreement"/> audit row.
    /// Disabling never requires agreement.
    /// </summary>
    // POST: CompanySettings/UpdateSmsPreferences
    [HttpPost]
    public async Task<IActionResult> UpdateSmsPreferences([FromBody] UpdateSmsPreferencesDto dto)
    {
        try
        {
            var companyId = _tenantContext.GetCurrentCompanyId();
            if (companyId == null)
                return Json(new { success = false, message = "User does not have a company ID." });

            var company = await _unitOfWork.Companies.GetByIdAsync(companyId.Value);
            if (company == null)
                return Json(new { success = false, message = "Company not found." });

            if (dto.SmsEnabled)
            {
                var hasAgreement = await _unitOfWork.CompanySmsAgreements
                    .AnyAsync(a => a.CompanyId == companyId.Value && a.TermsVersion == AppConstants.SmsTermsVersion);

                if (!hasAgreement)
                {
                    // Require explicit acceptance of the current terms version
                    if (!dto.AgreedToTerms || dto.TermsVersion != AppConstants.SmsTermsVersion)
                        return Json(new { success = false, requiresAgreement = true, message = "You must accept the SMS terms of service to enable SMS notifications." });

                    var userId   = User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value ?? string.Empty;
                    var userName = User.Identity?.Name ?? string.Empty;
                    var ip       = HttpContext.Connection.RemoteIpAddress?.ToString();
                    var ua       = Request.Headers.UserAgent.ToString();

                    var agreement = new CompanySmsAgreement
                    {
                        CompanyId         = companyId.Value,
                        AgreedByUserId    = userId,
                        AgreedByUserName  = userName,
                        AgreedAt          = DateTime.UtcNow,
                        IpAddress         = ip,
                        UserAgent         = ua,
                        TermsVersion      = AppConstants.SmsTermsVersion,
                        CreatedAt         = DateTime.UtcNow
                    };

                    await _unitOfWork.CompanySmsAgreements.AddAsync(agreement);
                    _logger.LogInformation("Company {CompanyId} accepted SMS terms v{Version} by user {UserId} from {Ip}",
                        companyId, AppConstants.SmsTermsVersion, userId, ip);
                }
            }

            company.SmsEnabled = dto.SmsEnabled;
            await _unitOfWork.Companies.UpdateAsync(company);
            await _unitOfWork.CompleteAsync();

            _logger.LogInformation("Company {CompanyId} SMS opt-in set to {SmsEnabled}", companyId, dto.SmsEnabled);
            return Json(new { success = true, message = dto.SmsEnabled ? "SMS notifications enabled." : "SMS notifications disabled." });
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Error updating SMS preferences");
            return Json(new { success = false, message = "An error occurred while saving SMS preferences." });
        }
    }

    // POST: CompanySettings/UpdateTimeclockSettings
    /// <summary>
    /// Saves company-level timeclock settings (enabled toggle, multiple-punches-per-day, auto clock-out hours).
    /// </summary>
    [HttpPost]
    public async Task<IActionResult> UpdateTimeclockSettings([FromBody] UpdateTimeclockSettingsDto dto)
    {
        try
        {
            var companyId = _tenantContext.GetCurrentCompanyId();
            if (companyId == null)
                return Json(new { success = false, message = "User does not have a company ID." });

            var company = await _unitOfWork.Companies.GetByIdAsync(companyId.Value);
            if (company == null)
                return Json(new { success = false, message = "Company not found." });

            company.TimeclockEnabled                   = dto.TimeclockEnabled;
            company.TimeclockAllowMultiplePunchesPerDay = dto.TimeclockAllowMultiplePunchesPerDay;
            company.TimeclockAutoClockOutHours          = dto.TimeclockAutoClockOutHours;

            await _unitOfWork.Companies.UpdateAsync(company);
            await _unitOfWork.CompleteAsync();

            _logger.LogInformation("Company {CompanyId} timeclock settings updated", companyId);
            return Json(new { success = true, message = "Timeclock settings saved." });
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Error updating timeclock settings");
            return Json(new { success = false, message = "An error occurred while saving timeclock settings." });
        }
    }

    /// <summary>
    /// Builds a suggested AI profile draft from existing company configuration — company name/location,
    /// named ovens, sandblasting capability, shop worker roles, coating inventory categories, and
    /// operating cost rates. Returns a pre-filled paragraph the user can review and edit before saving.
    /// </summary>
    // GET: CompanySettings/GenerateAiProfileDraft
    [HttpGet]
    public async Task<IActionResult> GenerateAiProfileDraft()
    {
        try
        {
            var companyId = _tenantContext.GetCurrentCompanyId();
            if (companyId == null)
                return Json(new { success = false, message = "No company found." });

            var company = await _unitOfWork.Companies.GetByIdAsync(companyId.Value, false, c => c.OperatingCosts);
            if (company == null)
                return Json(new { success = false, message = "Company not found." });

            var costs = company.OperatingCosts;

            var ovens   = (await _unitOfWork.OvenCosts.FindAsync(o => o.IsActive && o.CompanyId == companyId.Value)).OrderBy(o => o.DisplayOrder).ToList();
            var coatingCategories = (await _unitOfWork.InventoryCategoryLookups.FindAsync(c => c.IsCoating && c.CompanyId == companyId.Value)).ToList();

            var sb = new System.Text.StringBuilder();

            // Opening line
            var location = new[] { company.City, company.State }.Where(s => !string.IsNullOrWhiteSpace(s));
            var locationStr = string.Join(", ", location);
            sb.Append(company.CompanyName);
            if (!string.IsNullOrWhiteSpace(locationStr))
                sb.Append($" is a powder coating shop based in {locationStr}.");
            else
                sb.Append(" is a powder coating shop.");
            sb.AppendLine();
            sb.AppendLine();

            // Shop size
            if (costs != null)
            {
                var tierLabel = costs.ShopCapabilityTier switch
                {
                    ShopCapabilityTier.Garage => "garage/hobbyist",
                    ShopCapabilityTier.Small  => "small",
                    ShopCapabilityTier.Medium => "medium-sized",
                    ShopCapabilityTier.Large  => "high-volume",
                    _ => "small"
                };
                sb.AppendLine($"We are a {tierLabel} operation.");
            }

            // Ovens
            if (ovens.Any())
            {
                sb.AppendLine();
                sb.AppendLine("Our curing ovens:");
                foreach (var oven in ovens)
                {
                    var parts = new List<string>();
                    if (oven.MaxLoadSqFt.HasValue && oven.MaxLoadSqFt > 0)
                        parts.Add($"{oven.MaxLoadSqFt:0} sq ft capacity");
                    if (oven.DefaultCycleMinutes.HasValue && oven.DefaultCycleMinutes > 0)
                        parts.Add($"{oven.DefaultCycleMinutes} min cure cycle");
                    var detail = parts.Any() ? $" ({string.Join(", ", parts)})" : "";
                    sb.AppendLine($"• {oven.Label}{detail}");
                }
            }

            // Equipment capabilities inferred from rates
            if (costs != null)
            {
                var capabilities = new List<string>();
                if (costs.SandblasterCostPerHour > 0)
                    capabilities.Add("sandblasting / media blasting");
                if (costs.CoatingBoothCostPerHour > 0)
                    capabilities.Add("powder coating booth");
                if (capabilities.Any())
                {
                    sb.AppendLine();
                    sb.AppendLine($"We have in-house {string.Join(" and ", capabilities)} capability.");
                }
            }

            // Powder/coating categories
            if (coatingCategories.Any())
            {
                sb.AppendLine();
                var catNames = coatingCategories.Select(c => c.DisplayName).ToList();
                sb.AppendLine($"Powder categories we stock: {string.Join(", ", catNames)}.");
            }

            // Rates hint
            if (costs != null && costs.StandardLaborRate > 0)
            {
                sb.AppendLine();
                sb.AppendLine($"Our standard labor rate is ${costs.StandardLaborRate:0.00}/hr. " +
                    $"We target approximately {costs.GeneralMarkupPercentage:0}% markup on all jobs.");
            }

            sb.AppendLine();
            sb.AppendLine("(Edit this profile to add detail about the types of parts you typically coat, " +
                          "any brands of powder you prefer, your cure temperature, or anything else that " +
                          "helps the AI understand your shop better.)");

            return Json(new { success = true, draft = sb.ToString().Trim() });
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Error generating AI profile draft");
            return Json(new { success = false, message = "An error occurred while generating the draft." });
        }
    }

    /// <summary>
    /// Saves the Quoting Calibration / Shop Capability profile. Maps equipment fields onto
    /// <see cref="CompanyOperatingCosts"/> and returns the freshly derived blast and coating
    /// rates so the UI can update the "estimated rate" display without a page reload.
    /// </summary>
    // POST: CompanySettings/UpdateBlastProfile
    [HttpPost]
    public async Task<IActionResult> UpdateBlastProfile([FromBody] UpdateBlastProfileDto dto)
    {
        try
        {
            if (!ModelState.IsValid)
            {
                var errors = ModelState.Values.SelectMany(v => v.Errors).Select(e => e.ErrorMessage);
                return Json(new { success = false, message = string.Join(", ", errors) });
            }

            var companyId = _tenantContext.GetCurrentCompanyId();
            if (companyId == null)
                return Json(new { success = false, message = "User does not have a company ID." });

            var company = await _unitOfWork.Companies.GetByIdAsync(companyId.Value, false, c => c.OperatingCosts);
            if (company == null)
                return Json(new { success = false, message = "Company not found." });

            if (company.OperatingCosts == null)
            {
                company.OperatingCosts = new CompanyOperatingCosts { CompanyId = companyId.Value };
                _mapper.Map(dto, company.OperatingCosts);
                await _unitOfWork.CompanyOperatingCosts.AddAsync(company.OperatingCosts);
            }
            else
            {
                _mapper.Map(dto, company.OperatingCosts);
                company.OperatingCosts.UpdatedAt = DateTime.UtcNow;
                await _unitOfWork.CompanyOperatingCosts.UpdateAsync(company.OperatingCosts);
            }

            await _unitOfWork.CompleteAsync();
            _logger.LogInformation("Company {CompanyId} blast profile updated — tier={Tier}, CFM={Cfm}, nozzle=#{Nozzle}",
                companyId, dto.ShopCapabilityTier, dto.CompressorCfm, dto.BlastNozzleSize);

            var blastRate   = ShopCapabilityCalculator.GetBlastRateSqFtPerHour(company.OperatingCosts);
            var coatingRate = ShopCapabilityCalculator.GetCoatingRateSqFtPerHour(company.OperatingCosts);

            return Json(new
            {
                success = true,
                message = "Quoting calibration saved successfully.",
                derivedBlastRate = blastRate,
                derivedCoatingRate = coatingRate
            });
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Error updating blast profile");
            return Json(new { success = false, message = "An error occurred while saving the quoting calibration." });
        }
    }

    #region Job Status Lookups

    /// <summary>
    /// Returns all job statuses for the current company ordered by <c>DisplayOrder</c>, with a live
    /// job-count badge per status. The counts are fetched individually per status rather than via a
    /// GROUP BY because the global multi-tenant query filter on <c>Jobs</c> is automatically applied,
    /// guaranteeing the counts reflect only this tenant's data without extra filtering code.
    /// </summary>
    [HttpGet]
    public async Task<IActionResult> GetJobStatuses()
    {
        try
        {
            var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
            var statuses = await _unitOfWork.JobStatusLookups.FindAsync(s => s.CompanyId == companyId);
            var sortedStatuses = statuses.OrderBy(s => s.DisplayOrder).ToList();

            var dtos = _mapper.Map<List<JobStatusLookupDto>>(sortedStatuses);

            // Add job counts
            foreach (var dto in dtos)
            {
                dto.JobCount = await _unitOfWork.Jobs.CountAsync(j => j.JobStatusId == dto.Id);
            }

            return Json(dtos);
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Error loading job statuses");
            return Json(new { success = false, message = "Error loading job statuses." });
        }
    }

    /// <summary>
    /// Creates a new custom job status for the current company and immediately invalidates the
    /// <see cref="ILookupCacheService"/> so subsequent status dropdowns reflect the addition without
    /// a restart. StatusCode uniqueness is enforced at the application level (not just the DB) to
    /// return a user-friendly message rather than a constraint violation exception.
    /// System-defined statuses cannot be overridden by this endpoint — tenant customization is additive only.
    /// </summary>
    [HttpPost]
    public async Task<IActionResult> CreateJobStatus([FromBody] CreateJobStatusLookupDto dto)
    {
        try
        {
            if (!ModelState.IsValid)
                return Json(new { success = false, message = "Invalid data" });

            var companyId = _tenantContext.GetCurrentCompanyId();
            if (companyId == null)
                return Json(new { success = false, message = "Company not found" });

            // Check if status code already exists for this company
            var exists = await _unitOfWork.JobStatusLookups
                .AnyAsync(s => s.StatusCode == dto.StatusCode);
            if (exists)
                return Json(new { success = false, message = "Status code already exists" });

            var status = _mapper.Map<JobStatusLookup>(dto);
            status.CompanyId = companyId.Value;
            await _unitOfWork.JobStatusLookups.AddAsync(status);
            await _unitOfWork.CompleteAsync();
            _lookupCache.InvalidateCompanyCache(companyId.Value);

            _logger.LogInformation("Job status {StatusCode} created for company {CompanyId}", dto.StatusCode, companyId);

            return Json(new { success = true, message = "Status created successfully", id = status.Id });
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Error creating job status");
            return Json(new { success = false, message = "An error occurred while creating the status." });
        }
    }

    /// <summary>
    /// Updates label, color, and display properties of a custom job status. System-defined statuses
    /// (those where <c>IsSystemDefined == true</c>) are blocked from modification because the core
    /// job lifecycle logic (e.g. terminal-status detection for billing) depends on their fixed identity.
    /// Cache is invalidated after save so live dropdowns pick up the rename immediately.
    /// </summary>
    [HttpPost]
    public async Task<IActionResult> UpdateJobStatus([FromBody] UpdateJobStatusLookupDto dto)
    {
        try
        {
            if (!ModelState.IsValid)
                return Json(new { success = false, message = "Invalid data" });

            var status = await _unitOfWork.JobStatusLookups.GetByIdAsync(dto.Id);
            if (status == null)
                return Json(new { success = false, message = "Status not found" });

            if (status.IsSystemDefined)
                return Json(new { success = false, message = "System-defined statuses cannot be modified" });

            _mapper.Map(dto, status);
            await _unitOfWork.JobStatusLookups.UpdateAsync(status);
            await _unitOfWork.CompleteAsync();
            _lookupCache.InvalidateCompanyCache(status.CompanyId);

            _logger.LogInformation("Job status {StatusId} updated", dto.Id);

            return Json(new { success = true, message = "Status updated successfully" });
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Error updating job status");
            return Json(new { success = false, message = "An error occurred while updating the status." });
        }
    }

    /// <summary>
    /// Soft-deletes a custom job status after verifying it is not currently assigned to any job.
    /// A referential integrity check is performed before the soft-delete rather than relying on a DB
    /// constraint because soft-deleted statuses remain in the table and a constraint would not fire.
    /// System-defined statuses are always protected regardless of job-count.
    /// </summary>
    [HttpPost]
    public async Task<IActionResult> DeleteJobStatus(int id)
    {
        try
        {
            var status = await _unitOfWork.JobStatusLookups.GetByIdAsync(id);
            if (status == null)
                return Json(new { success = false, message = "Status not found" });

            if (status.IsSystemDefined)
                return Json(new { success = false, message = "Cannot delete system-defined status" });

            // Check if status is in use
            var inUse = await _unitOfWork.Jobs.AnyAsync(j => j.JobStatusId == id);
            if (inUse)
                return Json(new { success = false, message = "Status is in use and cannot be deleted" });

            await _unitOfWork.JobStatusLookups.SoftDeleteAsync(id);
            await _unitOfWork.CompleteAsync();
            _lookupCache.InvalidateCompanyCache(status.CompanyId);

            _logger.LogInformation("Job status {StatusId} deleted", id);

            return Json(new { success = true, message = "Status deleted successfully" });
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Error deleting job status");
            return Json(new { success = false, message = "An error occurred while deleting the status." });
        }
    }

    /// <summary>
    /// Bulk-updates <c>DisplayOrder</c> for all job statuses according to the drag-sorted ID list sent
    /// from the UI. Each status is updated individually (no bulk UPDATE) because the generic repository
    /// does not expose a batch-update overload. Cache is invalidated after save so the new order
    /// appears in kanban boards and dropdowns on the next request.
    /// </summary>
    [HttpPost]
    public async Task<IActionResult> ReorderJobStatuses([FromBody] ReorderLookupDto dto)
    {
        try
        {
            if (!ModelState.IsValid)
                return Json(new { success = false, message = "Invalid data" });

            var companyId = _tenantContext.GetCurrentCompanyId();
            var statuses = await _unitOfWork.JobStatusLookups.FindAsync(s => s.CompanyId == (companyId ?? 0));

            for (int i = 0; i < dto.OrderedIds.Count; i++)
            {
                var status = statuses.FirstOrDefault(s => s.Id == dto.OrderedIds[i]);
                if (status != null)
                {
                    status.DisplayOrder = i + 1;
                    await _unitOfWork.JobStatusLookups.UpdateAsync(status);
                }
            }

            await _unitOfWork.CompleteAsync();
            if (companyId.HasValue) _lookupCache.InvalidateCompanyCache(companyId.Value);

            _logger.LogInformation("Job statuses reordered");

            return Json(new { success = true, message = "Order updated successfully" });
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Error reordering job statuses");
            return Json(new { success = false, message = "An error occurred while reordering statuses." });
        }
    }

    #endregion

    #region Job Priority Lookups

    /// <summary>
    /// Returns all job priorities for the current company ordered by <c>DisplayOrder</c>, including
    /// a live job-count per priority. Counts rely on the tenant-scoped global query filter so no
    /// explicit CompanyId filter is needed. Used by the Settings UI to show which priorities are
    /// actively in use before allowing rename or delete.
    /// </summary>
    [HttpGet]
    public async Task<IActionResult> GetJobPriorities()
    {
        try
        {
            var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
            var priorities = await _unitOfWork.JobPriorityLookups.FindAsync(p => p.CompanyId == companyId);
            var sortedPriorities = priorities.OrderBy(p => p.DisplayOrder).ToList();

            var dtos = _mapper.Map<List<JobPriorityLookupDto>>(sortedPriorities);

            // Add job counts
            foreach (var dto in dtos)
            {
                dto.JobCount = await _unitOfWork.Jobs.CountAsync(j => j.JobPriorityId == dto.Id);
            }

            return Json(dtos);
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Error loading job priorities");
            return Json(new { success = false, message = "Error loading job priorities." });
        }
    }

    /// <summary>
    /// Creates a new custom job priority with a unique PriorityCode for the current company.
    /// Priorities appear in the job create/edit dropdowns and drive color-coded badges across the UI.
    /// Unlike job statuses, priorities do not require cache invalidation via <see cref="ILookupCacheService"/>
    /// because the priority list is not currently cached — this should be added if performance requires it.
    /// </summary>
    [HttpPost]
    public async Task<IActionResult> CreateJobPriority([FromBody] CreateJobPriorityLookupDto dto)
    {
        try
        {
            if (!ModelState.IsValid)
                return Json(new { success = false, message = "Invalid data" });

            var companyId = _tenantContext.GetCurrentCompanyId();
            if (companyId == null)
                return Json(new { success = false, message = "Company not found" });

            // Check if priority code already exists for this company
            var exists = await _unitOfWork.JobPriorityLookups
                .AnyAsync(p => p.PriorityCode == dto.PriorityCode);
            if (exists)
                return Json(new { success = false, message = "Priority code already exists" });

            var priority = _mapper.Map<JobPriorityLookup>(dto);
            priority.CompanyId = companyId.Value;
            await _unitOfWork.JobPriorityLookups.AddAsync(priority);
            await _unitOfWork.CompleteAsync();

            _logger.LogInformation("Job priority {PriorityCode} created for company {CompanyId}", dto.PriorityCode, companyId);

            return Json(new { success = true, message = "Priority created successfully", id = priority.Id });
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Error creating job priority");
            return Json(new { success = false, message = "An error occurred while creating the priority." });
        }
    }

    /// <summary>
    /// Updates the label, color, and icon of a custom job priority. System-defined priorities are
    /// protected from modification to keep core features (e.g. Rush job surcharges, SLA alerting)
    /// working against stable codes. AutoMapper applies only the mapped fields so internal flags
    /// (<c>IsSystemDefined</c>, <c>PriorityCode</c>) remain unchanged.
    /// </summary>
    [HttpPost]
    public async Task<IActionResult> UpdateJobPriority([FromBody] UpdateJobPriorityLookupDto dto)
    {
        try
        {
            if (!ModelState.IsValid)
                return Json(new { success = false, message = "Invalid data" });

            var priority = await _unitOfWork.JobPriorityLookups.GetByIdAsync(dto.Id);
            if (priority == null)
                return Json(new { success = false, message = "Priority not found" });

            if (priority.IsSystemDefined)
                return Json(new { success = false, message = "System-defined priorities cannot be modified" });

            _mapper.Map(dto, priority);
            await _unitOfWork.JobPriorityLookups.UpdateAsync(priority);
            await _unitOfWork.CompleteAsync();

            _logger.LogInformation("Job priority {PriorityId} updated", dto.Id);

            return Json(new { success = true, message = "Priority updated successfully" });
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Error updating job priority");
            return Json(new { success = false, message = "An error occurred while updating the priority." });
        }
    }

    /// <summary>
    /// Soft-deletes a custom job priority after confirming it is not assigned to any existing job.
    /// System-defined priorities cannot be deleted. Soft delete is used (rather than hard delete) so
    /// historical job records that referenced the priority retain a coherent display value.
    /// </summary>
    [HttpPost]
    public async Task<IActionResult> DeleteJobPriority(int id)
    {
        try
        {
            var priority = await _unitOfWork.JobPriorityLookups.GetByIdAsync(id);
            if (priority == null)
                return Json(new { success = false, message = "Priority not found" });

            if (priority.IsSystemDefined)
                return Json(new { success = false, message = "Cannot delete system-defined priority" });

            // Check if priority is in use
            var inUse = await _unitOfWork.Jobs.AnyAsync(j => j.JobPriorityId == id);
            if (inUse)
                return Json(new { success = false, message = "Priority is in use and cannot be deleted" });

            await _unitOfWork.JobPriorityLookups.SoftDeleteAsync(id);
            await _unitOfWork.CompleteAsync();

            _logger.LogInformation("Job priority {PriorityId} deleted", id);

            return Json(new { success = true, message = "Priority deleted successfully" });
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Error deleting job priority");
            return Json(new { success = false, message = "An error occurred while deleting the priority." });
        }
    }

    /// <summary>
    /// Bulk-updates <c>DisplayOrder</c> for job priorities to match the drag-sorted ID list from the UI.
    /// The 1-based ordering (i+1) ensures DisplayOrder 0 is never stored, which could break ascending
    /// sorts that treat 0 as "unset".
    /// </summary>
    [HttpPost]
    public async Task<IActionResult> ReorderJobPriorities([FromBody] ReorderLookupDto dto)
    {
        try
        {
            if (!ModelState.IsValid)
                return Json(new { success = false, message = "Invalid data" });

            var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
            var priorities = await _unitOfWork.JobPriorityLookups.FindAsync(p => p.CompanyId == companyId);

            for (int i = 0; i < dto.OrderedIds.Count; i++)
            {
                var priority = priorities.FirstOrDefault(p => p.Id == dto.OrderedIds[i]);
                if (priority != null)
                {
                    priority.DisplayOrder = i + 1;
                    await _unitOfWork.JobPriorityLookups.UpdateAsync(priority);
                }
            }

            await _unitOfWork.CompleteAsync();

            _logger.LogInformation("Job priorities reordered");

            return Json(new { success = true, message = "Order updated successfully" });
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Error reordering job priorities");
            return Json(new { success = false, message = "An error occurred while reordering priorities." });
        }
    }

    #endregion

    #region Quote Status Lookups

    /// <summary>
    /// Returns all quote statuses for the current company ordered by <c>DisplayOrder</c>, including a
    /// live count of quotes in each status. The counts inform the UI about which statuses are safe to
    /// delete versus which need to be retired via rename or deactivation instead.
    /// </summary>
    [HttpGet]
    public async Task<IActionResult> GetQuoteStatuses()
    {
        try
        {
            var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
            var statuses = await _unitOfWork.QuoteStatusLookups.FindAsync(s => s.CompanyId == companyId);
            var sortedStatuses = statuses.OrderBy(s => s.DisplayOrder).ToList();

            var dtos = _mapper.Map<List<QuoteStatusLookupDto>>(sortedStatuses);

            // Add quote counts
            foreach (var dto in dtos)
            {
                dto.QuoteCount = await _unitOfWork.Quotes.CountAsync(q => q.QuoteStatusId == dto.Id);
            }

            return Json(dtos);
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Error loading quote statuses");
            return Json(new { success = false, message = "Error loading quote statuses." });
        }
    }

    /// <summary>
    /// Creates a new custom quote status for the company. Enforces two critical singleton business rules:
    /// only one status may be flagged <c>IsApprovedStatus</c> (the flag used by the quote-to-job
    /// conversion path) and only one may be flagged <c>IsConvertedStatus</c> (used to mark quotes that
    /// generated a job). Allowing multiple "approved" statuses would break the conversion workflow by
    /// making the trigger condition ambiguous.
    /// </summary>
    [HttpPost]
    public async Task<IActionResult> CreateQuoteStatus([FromBody] CreateQuoteStatusLookupDto dto)
    {
        try
        {
            if (!ModelState.IsValid)
                return Json(new { success = false, message = "Invalid data" });

            var companyId = _tenantContext.GetCurrentCompanyId();
            if (companyId == null)
                return Json(new { success = false, message = "Company not found" });

            // Check if status code already exists for this company
            var exists = await _unitOfWork.QuoteStatusLookups
                .AnyAsync(s => s.StatusCode == dto.StatusCode);
            if (exists)
                return Json(new { success = false, message = "Status code already exists" });

            // Validate business logic flags
            if (dto.IsApprovedStatus)
            {
                var hasApproved = await _unitOfWork.QuoteStatusLookups
                    .AnyAsync(s => s.IsApprovedStatus);
                if (hasApproved)
                    return Json(new { success = false, message = "Only one status can be marked as 'Approved'" });
            }

            if (dto.IsConvertedStatus)
            {
                var hasConverted = await _unitOfWork.QuoteStatusLookups
                    .AnyAsync(s => s.IsConvertedStatus);
                if (hasConverted)
                    return Json(new { success = false, message = "Only one status can be marked as 'Converted'" });
            }

            var status = _mapper.Map<QuoteStatusLookup>(dto);
            status.CompanyId = companyId.Value;
            await _unitOfWork.QuoteStatusLookups.AddAsync(status);
            await _unitOfWork.CompleteAsync();

            _logger.LogInformation("Quote status {StatusCode} created for company {CompanyId}", dto.StatusCode, companyId);

            return Json(new { success = true, message = "Status created successfully", id = status.Id });
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Error creating quote status");
            return Json(new { success = false, message = "An error occurred while creating the status." });
        }
    }

    /// <summary>
    /// Updates a custom quote status. Re-validates the <c>IsApprovedStatus</c> and <c>IsConvertedStatus</c>
    /// singleton constraints on every update, but only fires the uniqueness check when the flag is being
    /// newly set (changing from false → true). This avoids a false positive when saving the existing
    /// approved status without modifying the flag.
    /// System-defined statuses are read-only regardless of the DTO content.
    /// </summary>
    [HttpPost]
    public async Task<IActionResult> UpdateQuoteStatus([FromBody] UpdateQuoteStatusLookupDto dto)
    {
        try
        {
            if (!ModelState.IsValid)
                return Json(new { success = false, message = "Invalid data" });

            var status = await _unitOfWork.QuoteStatusLookups.GetByIdAsync(dto.Id);
            if (status == null)
                return Json(new { success = false, message = "Status not found" });

            if (status.IsSystemDefined)
                return Json(new { success = false, message = "System-defined statuses cannot be modified" });

            // Validate business logic flags
            if (dto.IsApprovedStatus && !status.IsApprovedStatus)
            {
                var hasApproved = await _unitOfWork.QuoteStatusLookups
                    .AnyAsync(s => s.Id != dto.Id && s.IsApprovedStatus);
                if (hasApproved)
                    return Json(new { success = false, message = "Only one status can be marked as 'Approved'" });
            }

            if (dto.IsConvertedStatus && !status.IsConvertedStatus)
            {
                var hasConverted = await _unitOfWork.QuoteStatusLookups
                    .AnyAsync(s => s.Id != dto.Id && s.IsConvertedStatus);
                if (hasConverted)
                    return Json(new { success = false, message = "Only one status can be marked as 'Converted'" });
            }

            _mapper.Map(dto, status);
            await _unitOfWork.QuoteStatusLookups.UpdateAsync(status);
            await _unitOfWork.CompleteAsync();

            _logger.LogInformation("Quote status {StatusId} updated", dto.Id);

            return Json(new { success = true, message = "Status updated successfully" });
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Error updating quote status");
            return Json(new { success = false, message = "An error occurred while updating the status." });
        }
    }

    /// <summary>
    /// Soft-deletes a custom quote status after confirming it is not referenced by any existing quote.
    /// System-defined statuses are always protected. Soft delete preserves the status row for historical
    /// quotes that already have this status assigned, avoiding orphaned foreign key references.
    /// </summary>
    [HttpPost]
    public async Task<IActionResult> DeleteQuoteStatus(int id)
    {
        try
        {
            var status = await _unitOfWork.QuoteStatusLookups.GetByIdAsync(id);
            if (status == null)
                return Json(new { success = false, message = "Status not found" });

            if (status.IsSystemDefined)
                return Json(new { success = false, message = "Cannot delete system-defined status" });

            // Check if status is in use
            var inUse = await _unitOfWork.Quotes.AnyAsync(q => q.QuoteStatusId == id);
            if (inUse)
                return Json(new { success = false, message = "Status is in use and cannot be deleted" });

            await _unitOfWork.QuoteStatusLookups.SoftDeleteAsync(id);
            await _unitOfWork.CompleteAsync();

            _logger.LogInformation("Quote status {StatusId} deleted", id);

            return Json(new { success = true, message = "Status deleted successfully" });
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Error deleting quote status");
            return Json(new { success = false, message = "An error occurred while deleting the status." });
        }
    }

    /// <summary>
    /// Bulk-updates <c>DisplayOrder</c> for quote statuses to match the drag-sorted ID list from the UI.
    /// Uses 1-based ordering to ensure DisplayOrder is never 0, consistent with
    /// <see cref="ReorderJobStatuses"/>.
    /// </summary>
    [HttpPost]
    public async Task<IActionResult> ReorderQuoteStatuses([FromBody] ReorderLookupDto dto)
    {
        try
        {
            if (!ModelState.IsValid)
                return Json(new { success = false, message = "Invalid data" });

            var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
            var statuses = await _unitOfWork.QuoteStatusLookups.FindAsync(s => s.CompanyId == companyId);

            for (int i = 0; i < dto.OrderedIds.Count; i++)
            {
                var status = statuses.FirstOrDefault(s => s.Id == dto.OrderedIds[i]);
                if (status != null)
                {
                    status.DisplayOrder = i + 1;
                    await _unitOfWork.QuoteStatusLookups.UpdateAsync(status);
                }
            }

            await _unitOfWork.CompleteAsync();

            _logger.LogInformation("Quote statuses reordered");

            return Json(new { success = true, message = "Order updated successfully" });
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Error reordering quote statuses");
            return Json(new { success = false, message = "An error occurred while reordering statuses." });
        }
    }

    #endregion

    #region Prep Service Lookups

    /// <summary>
    /// Returns all prep services (sandblasting, media blast, chemical strip, etc.) for the current
    /// company ordered by <c>DisplayOrder</c>. Prep services are selectable line items on quote and job
    /// items that add cost to the pricing calculation; this list populates the wizard's Step 4 checkboxes.
    /// </summary>
    [HttpGet]
    public async Task<IActionResult> GetPrepServices()
    {
        try
        {
            var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
            var services = await _unitOfWork.PrepServices.FindAsync(s => s.CompanyId == companyId);
            var sortedServices = services.OrderBy(s => s.DisplayOrder).ToList();

            var dtos = _mapper.Map<List<PrepServiceDto>>(sortedServices);

            return Json(dtos);
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Error loading prep services");
            return Json(new { success = false, message = "Error loading prep services." });
        }
    }

    /// <summary>
    /// Creates a new prep service entry for the current company. Unlike job/quote status lookups,
    /// prep services do not have system-defined entries or uniqueness constraints on a code field —
    /// companies can add multiple services with similar names. The new service is immediately available
    /// in the quote and job item wizards for this tenant.
    /// </summary>
    [HttpPost]
    public async Task<IActionResult> CreatePrepService([FromBody] CreatePrepServiceDto dto)
    {
        try
        {
            if (!ModelState.IsValid)
                return Json(new { success = false, message = "Invalid data" });

            var companyId = _tenantContext.GetCurrentCompanyId();
            if (companyId == null)
                return Json(new { success = false, message = "Company not found" });

            var service = _mapper.Map<PrepService>(dto);
            service.CompanyId = companyId.Value;
            await _unitOfWork.PrepServices.AddAsync(service);
            await _unitOfWork.CompleteAsync();

            _logger.LogInformation("Prep service {ServiceName} created for company {CompanyId}", dto.ServiceName, companyId);

            return Json(new { success = true, message = "Prep service created successfully", id = service.Id });
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Error creating prep service");
            return Json(new { success = false, message = "An error occurred while creating the prep service." });
        }
    }

    /// <summary>
    /// Updates the name, description, and pricing of an existing prep service. Unlike status lookups,
    /// there are no system-defined prep services that must be protected — all are tenant-owned.
    /// Changes take effect immediately for new quotes; existing quotes that already selected this service
    /// retain their stored price snapshot and are unaffected.
    /// </summary>
    [HttpPost]
    public async Task<IActionResult> UpdatePrepService([FromBody] UpdatePrepServiceDto dto)
    {
        try
        {
            if (!ModelState.IsValid)
                return Json(new { success = false, message = "Invalid data" });

            var service = await _unitOfWork.PrepServices.GetByIdAsync(dto.Id);
            if (service == null)
                return Json(new { success = false, message = "Prep service not found" });

            _mapper.Map(dto, service);
            await _unitOfWork.PrepServices.UpdateAsync(service);
            await _unitOfWork.CompleteAsync();

            _logger.LogInformation("Prep service {ServiceId} updated", dto.Id);

            return Json(new { success = true, message = "Prep service updated successfully" });
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Error updating prep service");
            return Json(new { success = false, message = "An error occurred while updating the prep service." });
        }
    }

    /// <summary>
    /// Soft-deletes a prep service. No in-use check is performed here because prep service selections
    /// are stored as a navigation collection on job/quote items, not a FK column — removing the lookup
    /// row does not break existing records. Soft delete ensures the service name remains resolvable for
    /// historical display in old quotes and jobs.
    /// </summary>
    [HttpPost]
    public async Task<IActionResult> DeletePrepService(int id)
    {
        try
        {
            var service = await _unitOfWork.PrepServices.GetByIdAsync(id);
            if (service == null)
                return Json(new { success = false, message = "Prep service not found" });

            await _unitOfWork.PrepServices.SoftDeleteAsync(id);
            await _unitOfWork.CompleteAsync();

            _logger.LogInformation("Prep service {ServiceId} deleted", id);

            return Json(new { success = true, message = "Prep service deleted successfully" });
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Error deleting prep service");
            return Json(new { success = false, message = "An error occurred while deleting the prep service." });
        }
    }

    /// <summary>
    /// Bulk-updates <c>DisplayOrder</c> for prep services to match the drag-sorted ID list from the UI.
    /// Display order controls the sequence in which prep service checkboxes appear in the item wizard.
    /// </summary>
    [HttpPost]
    public async Task<IActionResult> ReorderPrepServices([FromBody] ReorderLookupDto dto)
    {
        try
        {
            if (!ModelState.IsValid)
                return Json(new { success = false, message = "Invalid data" });

            var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
            var services = await _unitOfWork.PrepServices.FindAsync(s => s.CompanyId == companyId);

            for (int i = 0; i < dto.OrderedIds.Count; i++)
            {
                var service = services.FirstOrDefault(s => s.Id == dto.OrderedIds[i]);
                if (service != null)
                {
                    service.DisplayOrder = i + 1;
                    await _unitOfWork.PrepServices.UpdateAsync(service);
                }
            }

            await _unitOfWork.CompleteAsync();

            _logger.LogInformation("Prep services reordered");

            return Json(new { success = true, message = "Order updated successfully" });
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Error reordering prep services");
            return Json(new { success = false, message = "An error occurred while reordering prep services." });
        }
    }

    #endregion

    #region Blast Setups

    /// <summary>
    /// Single authoritative blast-rate calculation endpoint. Takes equipment parameters and
    /// returns the sqft/hr rate using the same ShopCapabilityCalculator formula the AI uses.
    /// The modal live preview calls this instead of duplicating the formula in JavaScript.
    /// </summary>
    [HttpGet]
    public IActionResult DeriveBlastRate(decimal cfm, int nozzle, int setupType, int substrate, decimal? rateOverride)
    {
        var setup = new CompanyBlastSetup
        {
            CompressorCfm                = cfm,
            BlastNozzleSize              = nozzle,
            SetupType                    = (BlastSetupType)setupType,
            PrimarySubstrate             = (BlastSubstrateType)substrate,
            BlastRateSqFtPerHourOverride = rateOverride > 0 ? rateOverride : null
        };
        var rate = ShopCapabilityCalculator.GetBlastRateSqFtPerHour(setup);
        return Json(new { rate });
    }

    /// <summary>Returns all active blast setups for the current company with their derived rates.</summary>
    [HttpGet]
    public async Task<IActionResult> GetBlastSetups()
    {
        try
        {
            var companyId = _tenantContext.GetCurrentCompanyId();
            var setups = await _unitOfWork.BlastSetups.FindAsync(b => b.CompanyId == companyId);
            var result = setups.OrderBy(b => b.DisplayOrder).Select(b => new BlastSetupDto
            {
                Id                        = b.Id,
                Name                      = b.Name,
                SetupType                 = b.SetupType,
                CompressorCfm             = b.CompressorCfm,
                BlastNozzleSize           = b.BlastNozzleSize,
                PrimarySubstrate          = b.PrimarySubstrate,
                BlastRateSqFtPerHourOverride = b.BlastRateSqFtPerHourOverride,
                IsDefault                 = b.IsDefault,
                IsActive                  = b.IsActive,
                DisplayOrder              = b.DisplayOrder,
                DerivedRate               = ShopCapabilityCalculator.GetBlastRateSqFtPerHour(b)
            }).ToList();
            return Json(new { success = true, setups = result });
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Error loading blast setups");
            return Json(new { success = false, message = "Failed to load blast setups." });
        }
    }

    /// <summary>Creates or updates a named blast setup.</summary>
    [HttpPost]
    public async Task<IActionResult> SaveBlastSetup([FromBody] SaveBlastSetupDto dto)
    {
        try
        {
            if (!ModelState.IsValid)
                return Json(new { success = false, message = "Invalid data." });

            var companyId = _tenantContext.GetCurrentCompanyId();

            CompanyBlastSetup setup;
            bool isNew;

            if (dto.Id.HasValue && dto.Id.Value > 0)
            {
                setup = await _unitOfWork.BlastSetups.GetByIdAsync(dto.Id.Value);
                if (setup == null || setup.CompanyId != (companyId ?? 0))
                    return Json(new { success = false, message = "Blast setup not found." });
                isNew = false;
            }
            else
            {
                setup = new CompanyBlastSetup { CompanyId = companyId ?? 0, CreatedAt = DateTime.UtcNow };
                isNew = true;
            }

            setup.Name                        = dto.Name;
            setup.SetupType                   = dto.SetupType;
            setup.CompressorCfm               = dto.CompressorCfm;
            setup.BlastNozzleSize             = dto.BlastNozzleSize;
            setup.PrimarySubstrate            = dto.PrimarySubstrate;
            setup.BlastRateSqFtPerHourOverride = dto.BlastRateSqFtPerHourOverride;
            setup.IsActive                    = dto.IsActive;
            setup.UpdatedAt                   = DateTime.UtcNow;

            // Enforce single default — clear others when this one becomes default
            if (dto.IsDefault)
            {
                var others = await _unitOfWork.BlastSetups.FindAsync(b => b.CompanyId == companyId && b.IsDefault);
                foreach (var other in others.Where(o => o.Id != (dto.Id ?? 0)))
                {
                    other.IsDefault = false;
                    await _unitOfWork.BlastSetups.UpdateAsync(other);
                }
            }
            setup.IsDefault = dto.IsDefault;

            if (isNew)
            {
                // Assign display order = max + 1
                var existing = await _unitOfWork.BlastSetups.FindAsync(b => b.CompanyId == companyId);
                setup.DisplayOrder = existing.Any() ? existing.Max(b => b.DisplayOrder) + 1 : 1;
                await _unitOfWork.BlastSetups.AddAsync(setup);
            }
            else
            {
                await _unitOfWork.BlastSetups.UpdateAsync(setup);
            }

            await _unitOfWork.CompleteAsync();

            var derivedRate = ShopCapabilityCalculator.GetBlastRateSqFtPerHour(setup);
            _logger.LogInformation("{Action} blast setup {Id} '{Name}' for company {CompanyId}",
                isNew ? "Created" : "Updated", setup.Id, setup.Name, companyId);

            return Json(new { success = true, id = setup.Id, derivedRate, message = isNew ? "Blast setup added." : "Blast setup updated." });
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Error saving blast setup");
            return Json(new { success = false, message = "An error occurred while saving the blast setup." });
        }
    }

    /// <summary>Soft-deletes a named blast setup.</summary>
    [HttpPost]
    public async Task<IActionResult> DeleteBlastSetup(int id)
    {
        try
        {
            var companyId = _tenantContext.GetCurrentCompanyId();
            var setup = await _unitOfWork.BlastSetups.GetByIdAsync(id);
            if (setup == null || setup.CompanyId != (companyId ?? 0))
                return Json(new { success = false, message = "Blast setup not found." });

            await _unitOfWork.BlastSetups.SoftDeleteAsync(id);
            await _unitOfWork.CompleteAsync();

            _logger.LogInformation("Blast setup {Id} deleted", id);
            return Json(new { success = true, message = "Blast setup deleted." });
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Error deleting blast setup");
            return Json(new { success = false, message = "An error occurred while deleting the blast setup." });
        }
    }

    #endregion

    #region Appointment Type Lookups

    /// <summary>
    /// Returns all appointment type lookups for the current company ordered by <c>DisplayOrder</c>,
    /// with a live appointment count per type. Appointment types classify customer drop-off, pick-up,
    /// and estimate visits; the counts help admins see which types are actively in use.
    /// </summary>
    [HttpGet]
    public async Task<IActionResult> GetAppointmentTypes()
    {
        try
        {
            var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
            var types = await _unitOfWork.AppointmentTypeLookups.FindAsync(t => t.CompanyId == companyId);
            var sortedTypes = types.OrderBy(t => t.DisplayOrder).ToList();

            var dtos = _mapper.Map<List<AppointmentTypeLookupDto>>(sortedTypes);

            // Add appointment counts
            foreach (var dto in dtos)
            {
                dto.AppointmentCount = await _unitOfWork.Appointments.CountAsync(a => a.AppointmentTypeId == dto.Id);
            }

            return Json(dtos);
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Error loading appointment types");
            return Json(new { success = false, message = "Error loading appointment types." });
        }
    }

    /// <summary>
    /// Creates a new appointment type for the current company with a unique TypeCode. TypeCode
    /// uniqueness is enforced at application level to provide a friendly error message; the DB does
    /// not have a unique constraint on this column because the global query filter makes the UNIQUE
    /// INDEX per-company rather than global.
    /// </summary>
    [HttpPost]
    public async Task<IActionResult> CreateAppointmentType([FromBody] CreateAppointmentTypeLookupDto dto)
    {
        try
        {
            if (!ModelState.IsValid)
                return Json(new { success = false, message = "Invalid data" });

            var companyId = _tenantContext.GetCurrentCompanyId();
            if (companyId == null)
                return Json(new { success = false, message = "Company not found" });

            // Check if type code already exists for this company
            var exists = await _unitOfWork.AppointmentTypeLookups
                .AnyAsync(t => t.TypeCode == dto.TypeCode);
            if (exists)
                return Json(new { success = false, message = "Type code already exists" });

            var type = _mapper.Map<AppointmentTypeLookup>(dto);
            type.CompanyId = companyId.Value;
            await _unitOfWork.AppointmentTypeLookups.AddAsync(type);
            await _unitOfWork.CompleteAsync();

            _logger.LogInformation("Appointment type {TypeCode} created for company {CompanyId}", dto.TypeCode, companyId);

            return Json(new { success = true, message = "Type created successfully", id = type.Id });
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Error creating appointment type");
            return Json(new { success = false, message = "An error occurred while creating the type." });
        }
    }

    /// <summary>
    /// Updates editable fields (color, display name, icon, description) of an appointment type.
    /// Unlike job/quote statuses, system-defined appointment types CAN be renamed and recolored —
    /// only TypeCode and IsSystemDefined are protected (ignored by AutoMapper in the reverse mapping).
    /// This design allows businesses to brand standard types (e.g. rename "Drop Off" to "Check In")
    /// without breaking the underlying workflow logic that uses TypeCode.
    /// </summary>
    [HttpPost]
    public async Task<IActionResult> UpdateAppointmentType([FromBody] UpdateAppointmentTypeLookupDto dto)
    {
        try
        {
            if (!ModelState.IsValid)
                return Json(new { success = false, message = "Invalid data" });

            var type = await _unitOfWork.AppointmentTypeLookups.GetByIdAsync(dto.Id);
            if (type == null)
                return Json(new { success = false, message = "Type not found" });

            // System-defined types can be edited (color, display name, icon, description)
            // but TypeCode and IsSystemDefined are protected by AutoMapper (ignored in mapping)
            _mapper.Map(dto, type);
            await _unitOfWork.AppointmentTypeLookups.UpdateAsync(type);
            await _unitOfWork.CompleteAsync();

            _logger.LogInformation("Appointment type {TypeId} updated", dto.Id);

            return Json(new { success = true, message = "Type updated successfully" });
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Error updating appointment type");
            return Json(new { success = false, message = "An error occurred while updating the type." });
        }
    }

    /// <summary>
    /// Soft-deletes an appointment type after confirming it is not referenced by any existing appointment.
    /// System-defined types cannot be deleted even when they have zero appointments, because platform
    /// features (e.g. automated pick-up reminders) may reference them by TypeCode.
    /// </summary>
    [HttpPost]
    public async Task<IActionResult> DeleteAppointmentType(int id)
    {
        try
        {
            var type = await _unitOfWork.AppointmentTypeLookups.GetByIdAsync(id);
            if (type == null)
                return Json(new { success = false, message = "Type not found" });

            if (type.IsSystemDefined)
                return Json(new { success = false, message = "Cannot delete system-defined type" });

            // Check if type is in use
            var inUse = await _unitOfWork.Appointments.AnyAsync(a => a.AppointmentTypeId == id);
            if (inUse)
                return Json(new { success = false, message = "Type is in use and cannot be deleted" });

            await _unitOfWork.AppointmentTypeLookups.SoftDeleteAsync(id);
            await _unitOfWork.CompleteAsync();

            _logger.LogInformation("Appointment type {TypeId} deleted", id);

            return Json(new { success = true, message = "Type deleted successfully" });
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Error deleting appointment type");
            return Json(new { success = false, message = "An error occurred while deleting the type." });
        }
    }

    /// <summary>
    /// Bulk-updates <c>DisplayOrder</c> for appointment types to match the drag-sorted ID list from the UI.
    /// Display order controls the sequence in appointment scheduling dropdowns.
    /// </summary>
    [HttpPost]
    public async Task<IActionResult> ReorderAppointmentTypes([FromBody] ReorderLookupDto dto)
    {
        try
        {
            if (!ModelState.IsValid)
                return Json(new { success = false, message = "Invalid data" });

            var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
            var types = await _unitOfWork.AppointmentTypeLookups.FindAsync(t => t.CompanyId == companyId);

            for (int i = 0; i < dto.OrderedIds.Count; i++)
            {
                var type = types.FirstOrDefault(t => t.Id == dto.OrderedIds[i]);
                if (type != null)
                {
                    type.DisplayOrder = i + 1;
                    await _unitOfWork.AppointmentTypeLookups.UpdateAsync(type);
                }
            }

            await _unitOfWork.CompleteAsync();

            _logger.LogInformation("Appointment types reordered");

            return Json(new { success = true, message = "Order updated successfully" });
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Error reordering appointment types");
            return Json(new { success = false, message = "An error occurred while reordering types." });
        }
    }

    #endregion

    #region Inventory Category Lookups

    /// <summary>
    /// Returns all inventory categories for the current company ordered by <c>DisplayOrder</c>,
    /// with a live count of inventory items in each category. Categories classify items (powders,
    /// primers, masking supplies, etc.) for filtering and reporting; the counts prevent deleting
    /// categories that are still actively organizing stock.
    /// </summary>
    [HttpGet]
    public async Task<IActionResult> GetInventoryCategories()
    {
        try
        {
            var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
            var categories = await _unitOfWork.InventoryCategoryLookups.FindAsync(c => c.CompanyId == companyId);
            var sortedCategories = categories.OrderBy(c => c.DisplayOrder).ToList();

            var dtos = _mapper.Map<List<InventoryCategoryLookupDto>>(sortedCategories);

            // Add item counts
            foreach (var dto in dtos)
            {
                dto.ItemCount = await _unitOfWork.InventoryItems.CountAsync(i => i.InventoryCategoryId == dto.Id);
            }

            return Json(dtos);
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Error loading inventory categories");
            return Json(new { success = false, message = "Error loading inventory categories." });
        }
    }

    /// <summary>
    /// Creates a new inventory category with a unique CategoryCode for the current company.
    /// CategoryCode uniqueness prevents duplicate categories (e.g. two "POWDER" codes) that would
    /// confuse inventory filtering. The check uses the repository's <c>AnyAsync</c> which applies
    /// the global tenant filter, so only this company's codes are checked.
    /// </summary>
    [HttpPost]
    public async Task<IActionResult> CreateInventoryCategory([FromBody] CreateInventoryCategoryLookupDto dto)
    {
        try
        {
            if (!ModelState.IsValid)
                return Json(new { success = false, message = "Invalid data" });

            var companyId = _tenantContext.GetCurrentCompanyId();
            if (companyId == null)
                return Json(new { success = false, message = "Company not found" });

            // Check if category code already exists for this company
            var exists = await _unitOfWork.InventoryCategoryLookups
                .AnyAsync(c => c.CategoryCode == dto.CategoryCode);
            if (exists)
                return Json(new { success = false, message = "Category code already exists" });

            var category = _mapper.Map<InventoryCategoryLookup>(dto);
            category.CompanyId = companyId.Value;
            await _unitOfWork.InventoryCategoryLookups.AddAsync(category);
            await _unitOfWork.CompleteAsync();

            _logger.LogInformation("Inventory category {CategoryCode} created for company {CompanyId}", dto.CategoryCode, companyId);

            return Json(new { success = true, message = "Category created successfully", id = category.Id });
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Error creating inventory category");
            return Json(new { success = false, message = "An error occurred while creating the category." });
        }
    }

    /// <summary>
    /// Updates the display name, color, and icon of an inventory category. There are no system-defined
    /// inventory categories, so all categories are fully editable without restriction.
    /// </summary>
    [HttpPost]
    public async Task<IActionResult> UpdateInventoryCategory([FromBody] UpdateInventoryCategoryLookupDto dto)
    {
        try
        {
            if (!ModelState.IsValid)
                return Json(new { success = false, message = "Invalid data" });

            var category = await _unitOfWork.InventoryCategoryLookups.GetByIdAsync(dto.Id);
            if (category == null)
                return Json(new { success = false, message = "Category not found" });

            _mapper.Map(dto, category);
            await _unitOfWork.InventoryCategoryLookups.UpdateAsync(category);
            await _unitOfWork.CompleteAsync();

            _logger.LogInformation("Inventory category {CategoryId} updated", dto.Id);

            return Json(new { success = true, message = "Category updated successfully" });
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Error updating inventory category");
            return Json(new { success = false, message = "An error occurred while updating the category." });
        }
    }

    /// <summary>
    /// Soft-deletes an inventory category after confirming no inventory items are currently assigned to it.
    /// Unlike status lookups there is no system-defined protection — all inventory categories are
    /// tenant-owned and can be deleted when empty.
    /// </summary>
    [HttpPost]
    public async Task<IActionResult> DeleteInventoryCategory(int id)
    {
        try
        {
            var category = await _unitOfWork.InventoryCategoryLookups.GetByIdAsync(id);
            if (category == null)
                return Json(new { success = false, message = "Category not found" });

            // Check if category is in use
            var inUse = await _unitOfWork.InventoryItems.AnyAsync(i => i.InventoryCategoryId == id);
            if (inUse)
                return Json(new { success = false, message = "Category is in use and cannot be deleted" });

            await _unitOfWork.InventoryCategoryLookups.SoftDeleteAsync(id);
            await _unitOfWork.CompleteAsync();

            _logger.LogInformation("Inventory category {CategoryId} deleted", id);

            return Json(new { success = true, message = "Category deleted successfully" });
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Error deleting inventory category");
            return Json(new { success = false, message = "An error occurred while deleting the category." });
        }
    }

    /// <summary>
    /// Bulk-updates <c>DisplayOrder</c> for inventory categories to match the drag-sorted ID list.
    /// Display order controls the sequence in inventory filtering tabs and reporting groupings.
    /// </summary>
    [HttpPost]
    public async Task<IActionResult> ReorderInventoryCategories([FromBody] ReorderLookupDto dto)
    {
        try
        {
            if (!ModelState.IsValid)
                return Json(new { success = false, message = "Invalid data" });

            var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
            var categories = await _unitOfWork.InventoryCategoryLookups.FindAsync(c => c.CompanyId == companyId);

            for (int i = 0; i < dto.OrderedIds.Count; i++)
            {
                var category = categories.FirstOrDefault(c => c.Id == dto.OrderedIds[i]);
                if (category != null)
                {
                    category.DisplayOrder = i + 1;
                    await _unitOfWork.InventoryCategoryLookups.UpdateAsync(category);
                }
            }

            await _unitOfWork.CompleteAsync();

            _logger.LogInformation("Inventory categories reordered");

            return Json(new { success = true, message = "Order updated successfully" });
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Error reordering inventory categories");
            return Json(new { success = false, message = "An error occurred while reordering categories." });
        }
    }

    #endregion

    // =========================================================================
    // OVEN COSTS
    // =========================================================================
    #region Oven Costs

    /// <summary>
    /// Returns all named ovens (OvenCost records) for the current company ordered by display order then
    /// label. An explicit <c>CompanyId</c> filter is used here rather than relying solely on the global
    /// query filter because <c>OvenCost</c> did not originally participate in the multi-tenancy filter
    /// and the explicit check provides a defense-in-depth guarantee. Each oven exposes
    /// <c>MaxLoadSqFt</c> and <c>DefaultCycleMinutes</c> used by the Oven Scheduler for capacity planning.
    /// </summary>
    [HttpGet]
    public async Task<IActionResult> GetOvenCosts()
    {
        try
        {
            var companyId = _tenantContext.GetCurrentCompanyId();
            if (companyId == null) return Json(new { success = false, message = "Company not found." });

            var ovens = (await _unitOfWork.OvenCosts.FindAsync(o => o.CompanyId == companyId.Value))
                .OrderBy(o => o.DisplayOrder).ThenBy(o => o.Label);

            var result = ovens.Select(o => new
            {
                id                  = o.Id,
                label               = o.Label,
                costPerHour         = o.CostPerHour,
                isActive            = o.IsActive,
                displayOrder        = o.DisplayOrder,
                maxLoadSqFt         = o.MaxLoadSqFt,
                defaultCycleMinutes = o.DefaultCycleMinutes
            });

            return Json(new { success = true, data = result });
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Error retrieving oven costs");
            return Json(new { success = false, message = "An error occurred while loading oven costs." });
        }
    }

    /// <summary>
    /// Creates a new named oven (<see cref="PowderCoating.Core.Entities.OvenCost"/>) for the current
    /// company. The oven is immediately selectable in the Oven Scheduler for batch assignment. Fully
    /// qualified DTO type is used to avoid an import ambiguity with other <c>CreateOvenCostDto</c>
    /// classes that may exist in different namespaces.
    /// <c>MaxLoadSqFt</c> and <c>DefaultCycleMinutes</c> are optional — the scheduler degrades
    /// gracefully when they are null (no capacity enforcement).
    /// </summary>
    [HttpPost]
    public async Task<IActionResult> CreateOvenCost([FromBody] PowderCoating.Application.DTOs.Company.CreateOvenCostDto dto)
    {
        try
        {
            if (!ModelState.IsValid)
            {
                var errors = ModelState.Values.SelectMany(v => v.Errors).Select(e => e.ErrorMessage);
                return Json(new { success = false, message = string.Join(", ", errors) });
            }

            var companyId = _tenantContext.GetCurrentCompanyId();
            if (companyId == null) return Json(new { success = false, message = "Company not found." });

            var oven = new PowderCoating.Core.Entities.OvenCost
            {
                Label               = dto.Label.Trim(),
                CostPerHour         = dto.CostPerHour,
                IsActive            = dto.IsActive,
                DisplayOrder        = dto.DisplayOrder,
                MaxLoadSqFt         = dto.MaxLoadSqFt,
                DefaultCycleMinutes = dto.DefaultCycleMinutes,
                CompanyId           = companyId.Value,
                CreatedAt           = DateTime.UtcNow
            };

            await _unitOfWork.OvenCosts.AddAsync(oven);
            await _unitOfWork.CompleteAsync();

            return Json(new { success = true, message = "Oven added successfully.", id = oven.Id });
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Error creating oven cost");
            return Json(new { success = false, message = "An error occurred while adding the oven." });
        }
    }

    /// <summary>
    /// Updates an existing oven's label, hourly cost, capacity, and cycle time. An explicit
    /// <c>CompanyId</c> ownership check (<c>oven.CompanyId != companyId.Value</c>) is performed in
    /// addition to the global query filter to prevent tenant A from editing tenant B's oven if the
    /// filter were ever bypassed (defense-in-depth). Deactivating an oven (<c>IsActive = false</c>)
    /// hides it from the Oven Scheduler dropdown without deleting historical batch records.
    /// </summary>
    [HttpPost]
    public async Task<IActionResult> UpdateOvenCost([FromBody] PowderCoating.Application.DTOs.Company.UpdateOvenCostDto dto)
    {
        try
        {
            if (!ModelState.IsValid)
            {
                var errors = ModelState.Values.SelectMany(v => v.Errors).Select(e => e.ErrorMessage);
                return Json(new { success = false, message = string.Join(", ", errors) });
            }

            var companyId = _tenantContext.GetCurrentCompanyId();
            if (companyId == null) return Json(new { success = false, message = "Company not found." });

            var oven = await _unitOfWork.OvenCosts.GetByIdAsync(dto.Id);
            if (oven == null || oven.CompanyId != companyId.Value)
                return Json(new { success = false, message = "Oven not found." });

            oven.Label               = dto.Label.Trim();
            oven.CostPerHour         = dto.CostPerHour;
            oven.IsActive            = dto.IsActive;
            oven.DisplayOrder        = dto.DisplayOrder;
            oven.MaxLoadSqFt         = dto.MaxLoadSqFt;
            oven.DefaultCycleMinutes = dto.DefaultCycleMinutes;
            oven.UpdatedAt           = DateTime.UtcNow;

            await _unitOfWork.OvenCosts.UpdateAsync(oven);
            await _unitOfWork.CompleteAsync();

            return Json(new { success = true, message = "Oven updated successfully." });
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Error updating oven cost {OvenCostId}", dto.Id);
            return Json(new { success = false, message = "An error occurred while updating the oven." });
        }
    }

    /// <summary>
    /// Soft-deletes a named oven after verifying it is not referenced by any existing quotes.
    /// Quotes store <c>OvenCostId</c> as a FK for cost-snapshot purposes; if any quote references
    /// the oven the user is directed to deactivate it instead to preserve those references.
    /// The ownership check mirrors <see cref="UpdateOvenCost"/> for defense-in-depth.
    /// </summary>
    [HttpPost]
    public async Task<IActionResult> DeleteOvenCost([FromBody] int id)
    {
        try
        {
            var companyId = _tenantContext.GetCurrentCompanyId();
            if (companyId == null) return Json(new { success = false, message = "Company not found." });

            var oven = await _unitOfWork.OvenCosts.GetByIdAsync(id);
            if (oven == null || oven.CompanyId != companyId.Value)
                return Json(new { success = false, message = "Oven not found." });

            // Check if any quotes reference this oven
            var usageCount = await _unitOfWork.Quotes.CountAsync(q => q.OvenCostId == id);
            if (usageCount > 0)
                return Json(new { success = false, message = $"Cannot delete: {usageCount} quote(s) reference this oven. Deactivate it instead." });

            await _unitOfWork.OvenCosts.SoftDeleteAsync(oven);
            await _unitOfWork.CompleteAsync();

            return Json(new { success = true, message = "Oven deleted successfully." });
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Error deleting oven cost {OvenCostId}", id);
            return Json(new { success = false, message = "An error occurred while deleting the oven." });
        }
    }

    #endregion

    // =========================================================================
    // NOTIFICATION TEMPLATES
    // =========================================================================
    #region Notification Templates

    /// <summary>
    /// Renders the standalone Notification Templates management page (separate from the main Settings
    /// hub). Calls <see cref="EnsureNotificationTemplatesSeededAsync"/> so that any newly added
    /// canonical notification types are automatically provisioned with default templates on first visit,
    /// without requiring a migration or manual seeding step.
    /// </summary>
    // GET: CompanySettings/NotificationTemplates
    [HttpGet]
    public async Task<IActionResult> NotificationTemplates()
    {
        var companyId = _tenantContext.GetCurrentCompanyId();
        if (companyId == null) return RedirectToAction(nameof(Index));

        // Load all existing templates for this company
        var existing = await _unitOfWork.NotificationTemplates.FindAsync(t => t.CompanyId == companyId.Value);

        // Auto-seed any missing canonical combinations
        var seeded = await EnsureNotificationTemplatesSeededAsync(companyId.Value, existing.ToList());
        if (seeded > 0)
            existing = await _unitOfWork.NotificationTemplates.FindAsync(t => t.CompanyId == companyId.Value);

        var dtos = existing.OrderBy(t => (int)t.NotificationType).ThenBy(t => (int)t.Channel)
            .Select(t => new NotificationTemplateDto
            {
                Id = t.Id,
                NotificationType = t.NotificationType,
                Channel = t.Channel,
                DisplayName = t.DisplayName,
                Subject = t.Subject,
                Body = t.Body,
                UpdatedAt = t.UpdatedAt
            }).ToList();

        return View(dtos);
    }

    /// <summary>
    /// Returns a single notification template as a JSON object for the inline template editor modal.
    /// Includes the applicable placeholder list (built by <see cref="GetApplicablePlaceholders"/>) so
    /// the editor can display context-sensitive insertion buttons without a separate round-trip.
    /// The <c>isEmail</c> flag drives whether the Subject field is shown in the editor UI.
    /// </summary>
    // GET: CompanySettings/GetTemplateJson/{id}
    [HttpGet]
    public async Task<IActionResult> GetTemplateJson(int id)
    {
        var template = await _unitOfWork.NotificationTemplates.GetByIdAsync(id);
        if (template == null) return NotFound();

        var placeholders = GetApplicablePlaceholders(template.NotificationType)
            .Select(p => new { placeholder = p.Placeholder, description = p.Description })
            .ToList();

        return Json(new
        {
            id = template.Id,
            displayName = template.DisplayName,
            notificationType = template.NotificationType.ToString(),
            isEmail = template.Channel == NotificationChannel.Email,
            subject = template.Subject ?? string.Empty,
            body = template.Body ?? string.Empty,
            placeholders
        });
    }

    /// <summary>
    /// Persists inline edits to a notification template from the Settings hub modal editor.
    /// Subject is only stored for Email channel templates — SMS templates do not have a subject line,
    /// so it is explicitly nulled to avoid stale values leaking if a template is ever converted
    /// between channel types. Returns a JSON envelope so the modal can show a success toast without reload.
    /// </summary>
    // POST: CompanySettings/SaveTemplateJson
    [HttpPost]
    public async Task<IActionResult> SaveTemplateJson([FromBody] SaveTemplateJsonRequest req)
    {
        var template = await _unitOfWork.NotificationTemplates.GetByIdAsync(req.Id);
        if (template == null) return Json(new { success = false, message = "Template not found." });

        template.Subject = template.Channel == NotificationChannel.Email ? req.Subject : null;
        template.Body = req.Body ?? string.Empty;
        template.UpdatedAt = DateTime.UtcNow;

        await _unitOfWork.NotificationTemplates.UpdateAsync(template);
        await _unitOfWork.CompleteAsync();

        _logger.LogInformation("Notification template {TemplateId} updated via inline editor", template.Id);
        return Json(new { success = true, message = $"Template \"{template.DisplayName}\" saved successfully." });
    }

    /// <summary>
    /// Resets a notification template to its factory defaults from the inline Settings hub modal editor.
    /// Default content is sourced from <c>SeedData.BuildDefaultNotificationTemplates</c> — the same
    /// source used during initial company seeding — so defaults are always in sync with seed data changes.
    /// Returns the new subject and body in the JSON response so the modal can refresh its editor
    /// fields without a page reload.
    /// </summary>
    // POST: CompanySettings/ResetTemplateJson/{id}
    [HttpPost]
    public async Task<IActionResult> ResetTemplateJson(int id)
    {
        var template = await _unitOfWork.NotificationTemplates.GetByIdAsync(id);
        if (template == null) return Json(new { success = false, message = "Template not found." });

        var defaults = SeedData.BuildDefaultNotificationTemplates(template.CompanyId);
        var defaultTemplate = defaults.FirstOrDefault(d =>
            d.NotificationType == template.NotificationType && d.Channel == template.Channel);

        if (defaultTemplate == null)
            return Json(new { success = false, message = "No default found for this template." });

        template.Subject = defaultTemplate.Subject;
        template.Body = defaultTemplate.Body;
        template.UpdatedAt = DateTime.UtcNow;

        await _unitOfWork.NotificationTemplates.UpdateAsync(template);
        await _unitOfWork.CompleteAsync();

        _logger.LogInformation("Notification template {TemplateId} reset to defaults via inline editor", template.Id);
        return Json(new { success = true, message = $"Template \"{template.DisplayName}\" reset to defaults.", newSubject = template.Subject ?? string.Empty, newBody = template.Body });
    }

    /// <summary>
    /// Renders the dedicated full-page template editor for a single notification template. Populates
    /// <c>ViewBag.Placeholders</c> with the context-sensitive list from <see cref="GetApplicablePlaceholders"/>
    /// so the Razor view can render insertion buttons without a separate AJAX call.
    /// If the template is not found the user is redirected back to the Settings hub's notification tab
    /// rather than shown a 404, to maintain the single-page settings UX.
    /// </summary>
    // GET: CompanySettings/EditTemplate/{id}
    [HttpGet]
    public async Task<IActionResult> EditTemplate(int id)
    {
        var template = await _unitOfWork.NotificationTemplates.GetByIdAsync(id);
        if (template == null) return Redirect(Url.Action(nameof(Index))! + "#notification-templates");

        var dto = new NotificationTemplateDto
        {
            Id = template.Id,
            NotificationType = template.NotificationType,
            Channel = template.Channel,
            DisplayName = template.DisplayName,
            Subject = template.Subject,
            Body = template.Body,
            UpdatedAt = template.UpdatedAt
        };

        ViewBag.Placeholders = GetApplicablePlaceholders(template.NotificationType);
        return View(dto);
    }

    /// <summary>
    /// Persists edits from the full-page template editor. On validation failure the view is re-rendered
    /// with the user's in-progress content (not the stored version) so edits are not lost. Redirects
    /// back to the Settings hub's notification tab on success using a fragment URL to restore the tab
    /// state without JavaScript, using TempData for the success toast message.
    /// </summary>
    // POST: CompanySettings/EditTemplate/{id}
    [HttpPost]
    [ValidateAntiForgeryToken]
    public async Task<IActionResult> EditTemplate(int id, UpdateNotificationTemplateDto dto)
    {
        var template = await _unitOfWork.NotificationTemplates.GetByIdAsync(id);
        if (template == null) return Redirect(Url.Action(nameof(Index))! + "#notification-templates");

        if (!ModelState.IsValid)
        {
            var viewDto = new NotificationTemplateDto
            {
                Id = template.Id,
                NotificationType = template.NotificationType,
                Channel = template.Channel,
                DisplayName = template.DisplayName,
                Subject = dto.Subject,
                Body = dto.Body,
                UpdatedAt = template.UpdatedAt
            };
            ViewBag.Placeholders = GetApplicablePlaceholders(template.NotificationType);
            return View(viewDto);
        }

        template.Subject = template.Channel == NotificationChannel.Email ? dto.Subject : null;
        template.Body = dto.Body ?? string.Empty;
        template.UpdatedAt = DateTime.UtcNow;

        await _unitOfWork.NotificationTemplates.UpdateAsync(template);
        await _unitOfWork.CompleteAsync();

        _logger.LogInformation("Notification template {TemplateId} updated for company {CompanyId}",
            id, template.CompanyId);

        TempData["SuccessMessage"] = $"Template \"{template.DisplayName}\" saved successfully.";
        return Redirect(Url.Action(nameof(Index))! + "#notification-templates");
    }

    /// <summary>
    /// Resets a template to factory defaults from the full-page editor. Unlike <see cref="ResetTemplateJson"/>
    /// (which is AJAX-based), this is a standard POST that redirects after completion so the full-page
    /// editor's form state is cleanly replaced. If no default is found for the type+channel combination
    /// (e.g. a future notification type without a seed entry) the redirect still occurs silently rather
    /// than erroring, leaving the existing customized template intact.
    /// </summary>
    // POST: CompanySettings/ResetTemplate/{id}
    [HttpPost]
    [ValidateAntiForgeryToken]
    public async Task<IActionResult> ResetTemplate(int id)
    {
        var template = await _unitOfWork.NotificationTemplates.GetByIdAsync(id);
        if (template == null) return Redirect(Url.Action(nameof(Index))! + "#notification-templates");

        // Find the canonical default for this type+channel combination
        var defaults = SeedData.BuildDefaultNotificationTemplates(template.CompanyId);
        var defaultTemplate = defaults.FirstOrDefault(d =>
            d.NotificationType == template.NotificationType && d.Channel == template.Channel);

        if (defaultTemplate != null)
        {
            template.Subject = defaultTemplate.Subject;
            template.Body = defaultTemplate.Body;
            template.UpdatedAt = DateTime.UtcNow;

            await _unitOfWork.NotificationTemplates.UpdateAsync(template);
            await _unitOfWork.CompleteAsync();

            _logger.LogInformation("Notification template {TemplateId} reset to defaults", id);
            TempData["SuccessMessage"] = $"Template \"{template.DisplayName}\" has been reset to defaults.";
        }

        return Redirect(Url.Action(nameof(Index))! + "#notification-templates");
    }

    /// <summary>
    /// Ensures every canonical notification type+channel combination has a template row for the given
    /// company. Called on every visit to the Settings Index and NotificationTemplates pages so new
    /// notification types added to <c>SeedData.BuildDefaultNotificationTemplates</c> are automatically
    /// provisioned without requiring a migration or a manual "Seed Data" action by the platform admin.
    /// Also removes stale rows (no longer in the canonical list) that have never been customised
    /// (UpdatedAt == null), so retired notification types disappear from the UI automatically.
    /// Returns the count of changes so the caller can decide whether to reload from the DB.
    /// </summary>
    private async Task<int> EnsureNotificationTemplatesSeededAsync(
        int companyId, List<NotificationTemplate> existing)
    {
        var allDefaults = SeedData.BuildDefaultNotificationTemplates(companyId);

        var toAdd = allDefaults
            .Where(d => !existing.Any(e =>
                e.NotificationType == d.NotificationType && e.Channel == d.Channel))
            .ToList();

        // Remove rows that are no longer canonical and have never been customised.
        var toRemove = existing
            .Where(e => !allDefaults.Any(d =>
                d.NotificationType == e.NotificationType && d.Channel == e.Channel)
                && e.UpdatedAt == null)
            .ToList();

        foreach (var t in toAdd)
            await _unitOfWork.NotificationTemplates.AddAsync(t);

        foreach (var t in toRemove)
            await _unitOfWork.NotificationTemplates.DeleteAsync(t);

        if (toAdd.Count > 0 || toRemove.Count > 0)
            await _unitOfWork.CompleteAsync();

        return toAdd.Count + toRemove.Count;
    }

    /// <summary>
    /// Returns the list of Handlebars-style placeholder tokens applicable to a given notification type.
    /// The base set (<c>{{companyName}}</c>, <c>{{customerName}}</c>) is common to all types; additional
    /// tokens (quote number, approval URL, job status, payment details, etc.) are added conditionally
    /// based on the notification type so the editor only shows relevant tokens. Tokens are rendered in
    /// email/SMS bodies by the notification delivery service via simple string replacement at send time.
    /// Note: <c>{{quoteExpiry}}</c> and similar optional-phrase tokens expand to an empty string when
    /// the underlying field is null — template authors should never wrap them in conditional logic.
    /// </summary>
    private static List<(string Placeholder, string Description)> GetApplicablePlaceholders(
        NotificationType type)
    {
        var list = new List<(string, string)>
        {
            ("{{companyName}}", "Your company name"),
            ("{{customerName}}", "The customer's or recipient's name")
        };

        // Quote types
        if (type is NotificationType.QuoteSent or NotificationType.QuoteApproved
            or NotificationType.QuoteApprovedByCustomer or NotificationType.QuoteDeclinedByCustomer)
            list.Add(("{{quoteNumber}}", "Quote number (e.g. QT-2501-0001)"));

        if (type == NotificationType.QuoteSent)
        {
            list.Add(("{{quoteTotal}}", "Quote total amount (formatted as currency)"));
            list.Add(("{{quoteExpiry}}", "Expiry phrase, e.g. \" valid until January 1, 2026\" — blank if no expiry date is set"));
            list.Add(("{{approvalUrl}}", "Unique URL for the customer to view and approve the quote online"));
        }

        if (type is NotificationType.QuoteApprovedByCustomer or NotificationType.QuoteDeclinedByCustomer)
        {
            list.Add(("{{response}}", "Customer's response — either \"APPROVED\" or \"DECLINED\""));
        }

        if (type == NotificationType.QuoteDeclinedByCustomer)
        {
            list.Add(("{{declineReasonSection}}", "HTML block containing the decline reason — blank when the customer approved"));
        }

        // Job types
        if (type is NotificationType.JobStatusChanged or NotificationType.JobReadyForPickup
            or NotificationType.JobCompleted)
            list.Add(("{{jobNumber}}", "Job number (e.g. JOB-2501-0001)"));

        if (type == NotificationType.JobStatusChanged)
        {
            list.Add(("{{jobStatus}}", "Current job status name"));
            list.Add(("{{jobDueDate}}", "Due date phrase, e.g. \" Expected completion: January 1, 2026.\" — blank if not set"));
        }

        if (type == NotificationType.JobCompleted)
            list.Add(("{{finalPrice}}", "Final job price (formatted as currency)"));

        // Invoice types
        if (type is NotificationType.InvoiceSent or NotificationType.PaymentReceived
            or NotificationType.PaymentReminder)
            list.Add(("{{invoiceNumber}}", "Invoice number (e.g. INV-2501-0001)"));

        if (type == NotificationType.InvoiceSent)
        {
            list.Add(("{{invoiceTotal}}", "Invoice total amount (formatted as currency)"));
            list.Add(("{{invoiceDueDate}}", "Due date phrase, e.g. \" Due by January 1, 2026.\" — blank if no due date is set"));
            list.Add(("{{viewUrl}}", "Permanent link for the customer to view the invoice online (used in SMS)"));
        }

        if (type == NotificationType.PaymentReceived)
        {
            list.Add(("{{paymentAmount}}", "Amount paid (formatted as currency)"));
            list.Add(("{{paymentDate}}", "Date payment was recorded (e.g. January 1, 2026)"));
            list.Add(("{{balanceDue}}", "Remaining balance phrase — blank when paid in full"));
        }

        if (type == NotificationType.PaymentReminder)
        {
            list.Add(("{{invoiceTotal}}", "Invoice total amount (formatted as currency)"));
            list.Add(("{{balanceDue}}", "Outstanding balance (formatted as currency)"));
            list.Add(("{{dueDate}}", "Original due date (e.g. January 1, 2026)"));
            list.Add(("{{daysOverdue}}", "Number of days the invoice is overdue"));
        }

        return list;
    }

    #endregion

    // ── Role-Based Labor Rates ────────────────────────────────────────────────

    // ─── Stripe Connect ───────────────────────────────────────────────────────

    /// <summary>
    /// Initiates the Stripe Connect OAuth flow by redirecting the browser to Stripe's authorization URL.
    /// Guards against unconfigured environments by detecting the placeholder client ID string and showing
    /// a friendly error rather than redirecting to a broken Stripe URL. The <c>state</c> parameter passed
    /// to <see cref="IStripeConnectService.GetOAuthUrl"/> is the companyId (as a string) so the callback
    /// can identify which tenant completed the flow without relying on session state, which is not
    /// reliable across Stripe's redirect.
    /// </summary>
    [HttpGet]
    public IActionResult ConnectStripe()
    {
        var companyId = _tenantContext.GetCurrentCompanyId();
        if (companyId == null) return Forbid();

        var connectClientId = _configuration["Stripe:Connect:ConnectClientId"];
        if (string.IsNullOrWhiteSpace(connectClientId) ||
            connectClientId.Contains("your_connect_client_id_here", StringComparison.OrdinalIgnoreCase))
        {
            TempData["Error"] = "Stripe Connect is not configured. A platform administrator must set Stripe:Connect:ConnectClientId in appsettings.json before companies can connect their Stripe accounts.";
            return RedirectToAction("Index", "CompanySettings", new { tab = "online-payments" });
        }

        var redirectUri = Url.Action("StripeCallback", "CompanySettings", null, Request.Scheme)!;
        var url = _stripeConnect.GetOAuthUrl(companyId.Value, redirectUri);
        return Redirect(url);
    }

    /// <summary>
    /// Handles the OAuth redirect callback from Stripe Connect. Stripe sends the authorization
    /// <paramref name="code"/> and the <paramref name="state"/> value (company ID) we passed in
    /// <see cref="ConnectStripe"/>. If the user declined on Stripe's side, the <paramref name="error"/>
    /// parameter is non-null and the flow is aborted gracefully. On success the connected Stripe
    /// account ID is stored on the company record by <c>HandleOAuthCallbackAsync</c>, enabling
    /// payment link generation for that tenant. Always redirects to the online-payments tab regardless
    /// of outcome so the user sees the updated connection status.
    /// </summary>
    [HttpGet]
    public async Task<IActionResult> StripeCallback(string code, string state, string? error = null)
    {
        try
        {
            if (!string.IsNullOrEmpty(error))
            {
                TempData["Error"] = $"Stripe connection was declined: {error}";
                return RedirectToAction("Index", "CompanySettings", new { tab = "online-payments" });
            }

            if (!int.TryParse(state, out var companyId))
            {
                TempData["Error"] = "Invalid callback state.";
                return RedirectToAction("Index", "CompanySettings", new { tab = "online-payments" });
            }

            var (success, errorMsg) = await _stripeConnect.HandleOAuthCallbackAsync(code, companyId);
            if (success)
                TempData["Success"] = "Stripe account connected successfully! You can now send payment links to customers.";
            else
                TempData["Error"] = $"Could not connect Stripe: {errorMsg}";
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Unhandled exception in StripeCallback");
            TempData["Error"] = "An unexpected error occurred while connecting your Stripe account. Please try again.";
        }

        return RedirectToAction("Index", "CompanySettings", new { tab = "online-payments" });
    }

    /// <summary>
    /// Revokes the Stripe Connect OAuth link for the current company. After disconnection the tenant
    /// can no longer send payment links via Stripe until they reconnect via <see cref="ConnectStripe"/>.
    /// The underlying <c>DisconnectAsync</c> also deauthorizes the token on Stripe's side, not just
    /// clearing the stored account ID — this complies with Stripe's platform requirements for handling
    /// user-initiated disconnections.
    /// </summary>
    [HttpPost]
    public async Task<IActionResult> DisconnectStripe()
    {
        var companyId = _tenantContext.GetCurrentCompanyId();
        if (companyId == null) return Forbid();

        var (success, errorMsg) = await _stripeConnect.DisconnectAsync(companyId.Value);
        if (success)
            return Json(new { success = true, message = "Stripe account disconnected." });

        return Json(new { success = false, message = errorMsg ?? "Failed to disconnect." });
    }

    /// <summary>
    /// Saves the online payment surcharge configuration for the current company. Enforces a hard cap
    /// of 3% on percentage-based surcharges to comply with Visa/Mastercard card network rules, which
    /// prohibit merchants from passing more than 3% of the transaction amount as a surcharge to
    /// cardholders. The <c>SurchargeAcknowledged</c> flag is stored so the UI can track whether the
    /// admin has read and accepted the surcharge disclosure notice.
    /// </summary>
    [HttpPost]
    public async Task<IActionResult> SaveOnlinePaymentSettings([FromBody] SaveOnlinePaymentSettingsDto dto)
    {
        try
        {
            var companyId = _tenantContext.GetCurrentCompanyId();
            if (companyId == null) return Forbid();

            // Cap surcharge at 3% to comply with card network rules
            if (dto.SurchargeType == OnlinePaymentSurchargeType.Percent && dto.SurchargeValue > 3m)
                return Json(new { success = false, message = "Surcharge percentage cannot exceed 3% per card network rules." });

            var company = await _unitOfWork.Companies.GetByIdAsync(companyId.Value);
            if (company == null) return NotFound();

            company.OnlinePaymentSurchargeType = dto.SurchargeType;
            company.OnlinePaymentSurchargeValue = dto.SurchargeValue;
            company.OnlineSurchargeAcknowledged = dto.SurchargeAcknowledged;
            company.UpdatedAt = DateTime.UtcNow;

            await _unitOfWork.Companies.UpdateAsync(company);
            await _unitOfWork.CompleteAsync();

            return Json(new { success = true, message = "Online payment settings saved." });
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Error saving online payment settings");
            return Json(new { success = false, message = "An error occurred saving settings." });
        }
    }

    // ─── Account Deletion ────────────────────────────────────────────────────

    /// <summary>
    /// Shows the self-service account deletion confirmation page. Loads a summary of the company's
    /// data counts so the admin can see exactly what will be affected before committing.
    /// </summary>
    [HttpGet]
    public async Task<IActionResult> DeleteAccount()
    {
        var companyId = _tenantContext.GetCurrentCompanyId();
        if (companyId == null) return RedirectToAction(nameof(Index));

        var company = await _unitOfWork.Companies.GetByIdAsync(companyId.Value);
        if (company == null) return NotFound();

        var userCount  = await _userManager.Users.CountAsync(u => u.CompanyId == companyId.Value);
        var jobCount   = await _unitOfWork.Jobs.CountAsync(j => j.CompanyId == companyId.Value);
        var quoteCount = await _unitOfWork.Quotes.CountAsync(q => q.CompanyId == companyId.Value);
        var custCount  = await _unitOfWork.Customers.CountAsync(c => c.CompanyId == companyId.Value);
        var invCount   = await _unitOfWork.Invoices.CountAsync(i => i.CompanyId == companyId.Value);

        ViewBag.CompanyName = company.CompanyName;
        ViewBag.UserCount   = userCount;
        ViewBag.JobCount    = jobCount;
        ViewBag.QuoteCount  = quoteCount;
        ViewBag.CustomerCount = custCount;
        ViewBag.InvoiceCount  = invCount;

        return View();
    }

    /// <summary>
    /// Processes the self-service account deletion request. Requires the admin to type "DELETE" and
    /// check the acknowledgement checkbox. On success: soft-deletes the company, deactivates every
    /// user account, writes a full audit log entry, signs the requesting user out, then redirects to
    /// the login page with a confirmation message. All data is retained in the database under soft-delete
    /// so SuperAdmins can audit or recover records; no rows are physically removed here.
    /// </summary>
    [HttpPost]
    [ValidateAntiForgeryToken]
    public async Task<IActionResult> DeleteAccount(string confirmationWord, bool acknowledged)
    {
        var companyId = _tenantContext.GetCurrentCompanyId();
        if (companyId == null) return RedirectToAction(nameof(Index));

        // ── Validate inputs ──────────────────────────────────────────────────
        if (!acknowledged)
        {
            TempData["Error"] = "You must check the acknowledgement checkbox to proceed.";
            return RedirectToAction(nameof(DeleteAccount));
        }

        if (!string.Equals(confirmationWord?.Trim(), "DELETE", StringComparison.Ordinal))
        {
            TempData["Error"] = "Confirmation word did not match. Please type DELETE (all capitals) to confirm.";
            return RedirectToAction(nameof(DeleteAccount));
        }

        var requestingUserId = User.FindFirstValue(ClaimTypes.NameIdentifier);
        var requestingUserName = User.Identity?.Name ?? "Unknown";

        try
        {
            var company = await _unitOfWork.Companies.GetByIdAsync(companyId.Value, false, c => c.Users);
            if (company == null) return NotFound();

            var companyName = company.CompanyName;

            // ── Gather counts for the audit snapshot ─────────────────────────
            var userCount  = company.Users.Count;
            var jobCount   = await _unitOfWork.Jobs.CountAsync(j => j.CompanyId == companyId.Value);
            var quoteCount = await _unitOfWork.Quotes.CountAsync(q => q.CompanyId == companyId.Value);
            var custCount  = await _unitOfWork.Customers.CountAsync(c => c.CompanyId == companyId.Value);
            var invCount   = await _unitOfWork.Invoices.CountAsync(i => i.CompanyId == companyId.Value);

            // ── Soft-delete the company ───────────────────────────────────────
            var now = DateTime.UtcNow;
            company.IsDeleted = true;
            company.IsActive  = false;
            company.UpdatedAt = now;

            // ── Deactivate all user accounts ─────────────────────────────────
            foreach (var user in company.Users)
            {
                user.IsActive  = false;
                user.UpdatedAt = now;
                await _userManager.UpdateAsync(user);
            }

            await _unitOfWork.CompleteAsync();

            // ── Write audit log ───────────────────────────────────────────────
            await _auditLog.LogAsync(new AuditLog
            {
                UserId            = requestingUserId,
                UserName          = requestingUserName,
                CompanyId         = companyId.Value,
                CompanyName       = companyName,
                Action            = "SelfServiceAccountDeletion",
                EntityType        = "Company",
                EntityId          = companyId.Value.ToString(),
                EntityDescription = companyName,
                NewValues         = $"Company soft-deleted via self-service. " +
                                    $"Users deactivated: {userCount}. " +
                                    $"Jobs: {jobCount}, Quotes: {quoteCount}, " +
                                    $"Customers: {custCount}, Invoices: {invCount}. " +
                                    $"Requested by: {requestingUserName} ({requestingUserId}).",
                Timestamp = now,
                IpAddress = HttpContext.Connection.RemoteIpAddress?.ToString()
            });

            _logger.LogWarning(
                "Self-service account deletion: company {CompanyName} (ID:{CompanyId}) deleted by {User}. " +
                "Users:{UserCount} Jobs:{JobCount} Quotes:{QuoteCount} Customers:{CustCount} Invoices:{InvCount}",
                companyName, companyId.Value, requestingUserName,
                userCount, jobCount, quoteCount, custCount, invCount);

            // ── Sign out and redirect ─────────────────────────────────────────
            await _signInManager.SignOutAsync();

            TempData["AccountDeleted"] = "true";
            TempData["DeletedCompanyName"] = companyName;
            return RedirectToPage("/Account/Login", new { area = "Identity" });
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Error during self-service account deletion for company {CompanyId}", companyId);
            TempData["Error"] = "An error occurred while deleting your account. Please contact support.";
            return RedirectToAction(nameof(DeleteAccount));
        }
    }

    // ─── Custom Formula Item Templates ──────────────────────────────────────────

    private bool AllowCustomFormulas() => HttpContext.Items["AllowCustomFormulas"] as bool? ?? false;

    /// <summary>Returns all active + inactive formula templates for the current company.</summary>
    [HttpGet]
    public async Task<IActionResult> GetCustomItemTemplate(int id)
    {
        if (!AllowCustomFormulas()) return Json(new { success = false, message = "Custom Formulas are not available on your current plan." });
        var companyId = _tenantContext.GetCurrentCompanyId()!.Value;
        var entity = await _unitOfWork.CustomItemTemplates.GetByIdAsync(id);
        if (entity == null || entity.CompanyId != companyId)
            return Json(new { success = false, message = "Template not found." });
        var dto = _mapper.Map<CustomItemTemplateDto>(entity);
        return Json(new { success = true, template = dto });
    }

    /// <summary>Returns all active + inactive formula templates for the current company.</summary>
    [HttpGet]
    public async Task<IActionResult> GetCustomItemTemplates()
    {
        if (!AllowCustomFormulas()) return Json(new { success = false, message = "Custom Formulas are not available on your current plan." });
        var companyId = _tenantContext.GetCurrentCompanyId()!.Value;
        var templates = await _unitOfWork.CustomItemTemplates.FindAsync(
            t => t.CompanyId == companyId);
        var dtos = _mapper.Map<List<CustomItemTemplateListDto>>(templates.OrderBy(t => t.DisplayOrder).ThenBy(t => t.Name));
        return Json(new { success = true, templates = dtos });
    }

    /// <summary>Downloads all formula templates as a portable JSON backup file.</summary>
    [HttpGet]
    public async Task<IActionResult> ExportCustomItemTemplates()
    {
        if (!AllowCustomFormulas()) return Forbid();
        var companyId = _tenantContext.GetCurrentCompanyId()!.Value;
        var templates = await _unitOfWork.CustomItemTemplates.FindAsync(t => t.CompanyId == companyId);

        // Parse FieldsJson into a real JsonElement so it is embedded as a proper JSON array
        // in the export file rather than as an escaped string. This makes the file human-readable
        // and avoids round-trip corruption when files are manually edited.
        static System.Text.Json.JsonElement ParseFields(string? raw)
        {
            try { return System.Text.Json.JsonDocument.Parse(raw ?? "[]").RootElement.Clone(); }
            catch { return System.Text.Json.JsonDocument.Parse("[]").RootElement.Clone(); }
        }

        var export = new
        {
            exportedAt = DateTime.UtcNow,
            version = 1,
            templates = templates
                .OrderBy(t => t.DisplayOrder).ThenBy(t => t.Name)
                .Select(t => new
                {
                    t.Name,
                    t.Description,
                    t.OutputMode,
                    Fields = ParseFields(t.FieldsJson),
                    t.Formula,
                    t.DefaultRate,
                    t.RateLabel,
                    t.Notes,
                    t.DisplayOrder,
                    t.IsActive
                })
        };

        var json = System.Text.Json.JsonSerializer.Serialize(export,
            new System.Text.Json.JsonSerializerOptions
            {
                WriteIndented = true,
                PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase
            });
        var filename = $"formula-templates-{DateTime.UtcNow:yyyyMMdd}.json";
        return File(System.Text.Encoding.UTF8.GetBytes(json), "application/json", filename);
    }

    /// <summary>
    /// Imports formula templates from a JSON backup file produced by ExportCustomItemTemplates.
    /// Templates whose name already exists in the company are skipped; all others are created.
    /// </summary>
    [HttpPost]
    [ValidateAntiForgeryToken]
    public async Task<IActionResult> ImportCustomItemTemplates(IFormFile file)
    {
        if (!AllowCustomFormulas()) return Json(new { success = false, message = "Custom Formulas are not available on your current plan." });
        if (file == null || file.Length == 0) return Json(new { success = false, message = "No file selected." });
        if (!file.FileName.EndsWith(".json", StringComparison.OrdinalIgnoreCase))
            return Json(new { success = false, message = "File must be a .json export file." });
        if (file.Length > 512 * 1024)
            return Json(new { success = false, message = "File is too large (max 512 KB)." });

        string json;
        using (var reader = new System.IO.StreamReader(file.OpenReadStream()))
            json = await reader.ReadToEndAsync();

        System.Text.Json.JsonElement root;
        try
        {
            root = System.Text.Json.JsonDocument.Parse(json).RootElement;
        }
        catch
        {
            return Json(new { success = false, message = "Could not parse file — make sure it is a valid formula export." });
        }

        if (!root.TryGetProperty("templates", out var templatesEl) || templatesEl.ValueKind != System.Text.Json.JsonValueKind.Array)
            return Json(new { success = false, message = "Invalid export format: missing \"templates\" array." });

        var companyId = _tenantContext.GetCurrentCompanyId()!.Value;
        var existing = await _unitOfWork.CustomItemTemplates.FindAsync(t => t.CompanyId == companyId);
        // Track names already in DB + names imported within this same file to prevent intra-file duplicates
        var usedNames = existing.Select(t => t.Name.ToLowerInvariant()).ToHashSet();

        int imported = 0, skipped = 0;
        var skippedNames = new List<string>();
        var errors = new List<string>();

        foreach (var item in templatesEl.EnumerateArray())
        {
            try
            {
                var name = item.TryGetProperty("name", out var nEl) ? nEl.GetString() ?? "" : "";
                if (string.IsNullOrWhiteSpace(name)) { errors.Add("Skipped one template with no name."); continue; }

                if (usedNames.Contains(name.ToLowerInvariant()))
                {
                    skipped++;
                    skippedNames.Add(name);
                    continue;
                }

                var dto = new CreateCustomItemTemplateDto
                {
                    Name = name,
                    Description = item.TryGetProperty("description", out var d) ? d.GetString() : null,
                    OutputMode = item.TryGetProperty("outputMode", out var om) ? om.GetString() ?? "FixedRate" : "FixedRate",
                    // "fields" is a real JSON array in the export; GetRawText() reconstructs the string
                    FieldsJson = item.TryGetProperty("fields", out var fj) ? fj.GetRawText() : "[]",
                    Formula = item.TryGetProperty("formula", out var f) ? f.GetString() ?? "" : "",
                    DefaultRate = item.TryGetProperty("defaultRate", out var dr) && dr.ValueKind == System.Text.Json.JsonValueKind.Number ? dr.GetDecimal() : null,
                    RateLabel = item.TryGetProperty("rateLabel", out var rl) ? rl.GetString() : null,
                    Notes = item.TryGetProperty("notes", out var n) ? n.GetString() : null,
                    DisplayOrder = item.TryGetProperty("displayOrder", out var dord) && dord.ValueKind == System.Text.Json.JsonValueKind.Number ? dord.GetInt32() : 0,
                    IsActive = true,
                };

                var fieldError = ValidateTemplateFields(dto.FieldsJson);
                if (fieldError != null) { errors.Add($"\"{name}\": {fieldError}"); continue; }

                var (normalizedFormula, formulaError) = _formulaAiService.NormalizeAndValidate(dto.Formula);
                if (formulaError != null) { errors.Add($"\"{name}\": formula error &mdash; {formulaError}"); continue; }
                dto.Formula = normalizedFormula;

                var entity = _mapper.Map<CustomItemTemplate>(dto);
                entity.CompanyId = companyId;
                entity.CreatedAt = DateTime.UtcNow;
                await _unitOfWork.CustomItemTemplates.AddAsync(entity);

                usedNames.Add(name.ToLowerInvariant());
                imported++;
            }
            catch (Exception ex)
            {
                errors.Add($"Unexpected error on one template: {ex.Message}");
            }
        }

        if (imported > 0)
            await _unitOfWork.CompleteAsync();

        return Json(new { success = true, imported, skipped, skippedNames, errors });
    }

    /// <summary>Creates a new formula template for the current company.</summary>
    [HttpPost]
    public async Task<IActionResult> CreateCustomItemTemplate([FromBody] CreateCustomItemTemplateDto dto)
    {
        if (!AllowCustomFormulas()) return Json(new { success = false, message = "Custom Formulas are not available on your current plan." });
        if (!ModelState.IsValid)
            return Json(new { success = false, message = "Invalid data." });

        var fieldError = ValidateTemplateFields(dto.FieldsJson);
        if (fieldError != null) return Json(new { success = false, message = fieldError });

        var (normalizedFormula, formulaError) = _formulaAiService.NormalizeAndValidate(dto.Formula);
        if (formulaError != null) return Json(new { success = false, message = $"Formula error: {formulaError}" });
        dto.Formula = normalizedFormula;

        var companyId = _tenantContext.GetCurrentCompanyId()!.Value;
        var entity = _mapper.Map<CustomItemTemplate>(dto);
        entity.CompanyId = companyId;
        entity.CreatedAt = DateTime.UtcNow;

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

        return Json(new { success = true, id = entity.Id });
    }

    /// <summary>Updates an existing formula template owned by the current company.</summary>
    [HttpPost]
    public async Task<IActionResult> UpdateCustomItemTemplate([FromBody] UpdateCustomItemTemplateDto dto)
    {
        if (!AllowCustomFormulas()) return Json(new { success = false, message = "Custom Formulas are not available on your current plan." });
        if (!ModelState.IsValid)
            return Json(new { success = false, message = "Invalid data." });

        var fieldError = ValidateTemplateFields(dto.FieldsJson);
        if (fieldError != null) return Json(new { success = false, message = fieldError });

        var (normalizedFormula, formulaError) = _formulaAiService.NormalizeAndValidate(dto.Formula);
        if (formulaError != null) return Json(new { success = false, message = $"Formula error: {formulaError}" });
        dto.Formula = normalizedFormula;

        var companyId = _tenantContext.GetCurrentCompanyId()!.Value;
        var entity = await _unitOfWork.CustomItemTemplates.GetByIdAsync(dto.Id);
        if (entity == null || entity.CompanyId != companyId)
            return Json(new { success = false, message = "Template not found." });

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

        // If this was imported from the library, mark it as modified so the share button appears
        if (entity.SourceFormulaLibraryItemId.HasValue)
            entity.IsModifiedFromSource = true;

        await _unitOfWork.CompleteAsync();

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

    /// <summary>Soft-deletes a formula template owned by the current company.</summary>
    [HttpPost]
    public async Task<IActionResult> DeleteCustomItemTemplate(int id)
    {
        if (!AllowCustomFormulas()) return Json(new { success = false, message = "Custom Formulas are not available on your current plan." });
        var companyId = _tenantContext.GetCurrentCompanyId()!.Value;
        var entity = await _unitOfWork.CustomItemTemplates.GetByIdAsync(id);
        if (entity == null || entity.CompanyId != companyId)
            return Json(new { success = false, message = "Template not found." });

        await _unitOfWork.CustomItemTemplates.SoftDeleteAsync(id);
        await _unitOfWork.CompleteAsync();
        return Json(new { success = true });
    }

    // ── Community Library: share / unshare / status ───────────────────────

    /// <summary>
    /// Returns the community library status for a given template: whether it is published,
    /// eligible to share, and where it was originally imported from if applicable.
    /// </summary>
    [HttpGet]
    public async Task<IActionResult> FormulaLibraryStatus(int templateId)
    {
        if (!AllowCustomFormulas()) return Json(new { canShare = false });
        var companyId = _tenantContext.GetCurrentCompanyId()!.Value;
        var status = await _formulaLibraryService.GetTemplateLibraryStatusAsync(templateId, companyId);
        return Json(status);
    }

    /// <summary>
    /// Publishes a company template to the community library (or re-publishes after unshare).
    /// Only templates that are original creations or modified imports may be shared.
    /// </summary>
    [HttpPost]
    public async Task<IActionResult> ShareFormula([FromBody] PowderCoating.Application.DTOs.Company.ShareFormulaRequest request)
    {
        if (!AllowCustomFormulas()) return Json(new { success = false, message = "Custom Formulas are not available on your current plan." });
        var companyId = _tenantContext.GetCurrentCompanyId()!.Value;
        var userId = User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value ?? "";

        try
        {
            var libraryItemId = await _formulaLibraryService.ShareAsync(companyId, userId, request);
            return Json(new { success = true, libraryItemId });
        }
        catch (InvalidOperationException ex)
        {
            return Json(new { success = false, message = ex.Message });
        }
    }

    /// <summary>Removes a template from the community library. Existing company imports are unaffected.</summary>
    [HttpPost]
    public async Task<IActionResult> UnshareFormula(int libraryItemId)
    {
        var companyId = _tenantContext.GetCurrentCompanyId()!.Value;
        await _formulaLibraryService.UnshareAsync(libraryItemId, companyId);
        return Json(new { success = true });
    }

    /// <summary>
    /// Uploads a diagram image for a template to blob storage container
    /// <c>formulatemplate-diagrams/{companyId}/{templateId}/diagram.{ext}</c>.
    /// Returns the blob path for storage on the entity.
    /// </summary>
    [HttpPost]
    public async Task<IActionResult> UploadTemplateDiagram(int templateId, IFormFile diagramFile)
    {
        if (!AllowCustomFormulas()) return Json(new { success = false, message = "Custom Formulas are not available on your current plan." });
        var companyId = _tenantContext.GetCurrentCompanyId()!.Value;
        var entity = await _unitOfWork.CustomItemTemplates.GetByIdAsync(templateId);
        if (entity == null || entity.CompanyId != companyId)
            return Json(new { success = false, message = "Template not found." });

        var allowedTypes = new[] { "image/jpeg", "image/png", "image/gif", "image/webp" };
        if (!allowedTypes.Contains(diagramFile.ContentType.ToLowerInvariant()))
            return Json(new { success = false, message = "Only JPEG, PNG, GIF, or WebP images are allowed." });

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

        var ext = Path.GetExtension(diagramFile.FileName).ToLowerInvariant().TrimStart('.');
        var blobPath = $"{companyId}/{templateId}/diagram.{ext}";

        using var stream = diagramFile.OpenReadStream();
        var (ok, err) = await _blobStorage.UploadAsync("formulatemplate-diagrams", blobPath, stream, diagramFile.ContentType);
        if (!ok)
            return Json(new { success = false, message = err });

        entity.DiagramImagePath = blobPath;
        entity.UpdatedAt = DateTime.UtcNow;
        await _unitOfWork.CompleteAsync();

        return Json(new { success = true, diagramImagePath = blobPath });
    }

    /// <summary>
    /// Serves a template diagram image from blob storage. The path is tenant-scoped
    /// so cross-company access is prevented by checking CompanyId on the template.
    /// </summary>
    [HttpGet]
    public async Task<IActionResult> TemplateDiagram(int templateId)
    {
        var companyId = _tenantContext.GetCurrentCompanyId()!.Value;
        var entity = await _unitOfWork.CustomItemTemplates.GetByIdAsync(templateId);
        if (entity == null || entity.CompanyId != companyId || string.IsNullOrEmpty(entity.DiagramImagePath))
            return NotFound();

        var (ok, bytes, contentType, _) = await _blobStorage.DownloadAsync("formulatemplate-diagrams", entity.DiagramImagePath);
        if (!ok || bytes == null || bytes.Length == 0) return NotFound();
        return File(bytes, contentType ?? "image/jpeg");
    }

    /// <summary>
    /// Evaluates a NCalc formula with the supplied variable values, automatically injecting
    /// three read-only shop-rate variables sourced from the company's operating costs:
    /// <c>standard_labor_rate</c>, <c>additional_coat_labor_pct</c>, and <c>markup_pct</c>.
    /// User-supplied variables take precedence so the test panel can override them.
    /// </summary>
    [HttpPost]
    public async Task<IActionResult> EvaluateFormula([FromBody] EvaluateFormulaRequest req)
    {
        if (!AllowCustomFormulas()) return Json(new { success = false, message = "Custom Formulas are not available on your current plan." });

        // Inject shop-rate system variables; user-supplied values win if the same key appears in both.
        var companyId = _tenantContext.GetCurrentCompanyId();
        if (companyId != null)
        {
            var costs = await _unitOfWork.CompanyOperatingCosts.FirstOrDefaultAsync(c => c.CompanyId == companyId.Value);
            if (costs != null)
                req = InjectShopRateVariables(req, costs);
        }

        var result = _formulaAiService.EvaluateFormula(req);
        return Json(result);
    }

    /// <summary>
    /// Merges <c>standard_labor_rate</c>, <c>additional_coat_labor_pct</c>, and <c>markup_pct</c>
    /// from <paramref name="costs"/> into the request's variable map without overwriting any key
    /// the caller already set (so the test panel can still override these values explicitly).
    /// </summary>
    private static EvaluateFormulaRequest InjectShopRateVariables(
        EvaluateFormulaRequest req, CompanyOperatingCosts costs)
    {
        var vars = System.Text.Json.JsonSerializer
            .Deserialize<Dictionary<string, System.Text.Json.JsonElement>>(req.VariablesJson ?? "{}") ?? new();

        void Inject(string key, decimal value)
        {
            if (!vars.ContainsKey(key))
                vars[key] = System.Text.Json.JsonDocument.Parse(value.ToString("G")).RootElement.Clone();
        }

        Inject("standard_labor_rate",       costs.StandardLaborRate);
        Inject("additional_coat_labor_pct", costs.AdditionalCoatLaborPercent);
        Inject("markup_pct",                costs.GeneralMarkupPercentage);

        return new EvaluateFormulaRequest
        {
            Formula      = req.Formula,
            VariablesJson = System.Text.Json.JsonSerializer.Serialize(vars)
        };
    }

    /// <summary>
    /// Calls Claude to generate a formula template from a natural-language description
    /// and an optional diagram image uploaded in the same multipart form.
    /// </summary>
    [HttpPost]
    public async Task<IActionResult> GenerateFormulaFromAi([FromForm] string description, IFormFile? diagramImage)
    {
        if (!AllowCustomFormulas()) return Json(new { success = false, error = "Custom Formulas are not available on your current plan." });
        if (string.IsNullOrWhiteSpace(description))
            return Json(new { success = false, error = "Description is required." });

        byte[]? imageBytes = null;
        string? imageContentType = null;
        if (diagramImage is { Length: > 0 })
        {
            using var ms = new MemoryStream();
            await diagramImage.CopyToAsync(ms);
            imageBytes = ms.ToArray();
            imageContentType = diagramImage.ContentType;
        }

        var result = await _formulaAiService.GenerateFormulaAsync(
            new GenerateFormulaFromAiRequest { Description = description },
            imageBytes,
            imageContentType);

        return Json(result);
    }

    /// <summary>
    /// Validates field variable names in a fieldsJson array against NCalc identifier rules:
    /// must start with a letter, contain only letters/digits/underscores, and not use the
    /// reserved name "rate" (which is auto-populated from the template's Default Rate).
    /// Returns an error message string on failure, or null if all names are valid.
    /// </summary>
    private static string? ValidateTemplateFields(string? fieldsJson)
    {
        if (string.IsNullOrWhiteSpace(fieldsJson)) return null;

        List<System.Text.Json.JsonElement>? fields;
        try
        {
            fields = System.Text.Json.JsonSerializer.Deserialize<List<System.Text.Json.JsonElement>>(fieldsJson);
        }
        catch { return "Invalid fields JSON."; }

        if (fields == null) return null;

        var nameRegex = new System.Text.RegularExpressions.Regex(@"^[a-zA-Z][a-zA-Z0-9_]*$");
        var seen = new HashSet<string>(StringComparer.Ordinal);

        foreach (var field in fields)
        {
            var name = field.TryGetProperty("name", out var nameProp) ? nameProp.GetString() ?? "" : "";
            if (string.IsNullOrEmpty(name))
                return "All fields must have a variable name.";
            if (name == "rate")
                return $"\"rate\" is a reserved variable name — it is pre-populated from the template's Default Rate.";
            if (!nameRegex.IsMatch(name))
                return $"Invalid field name \"{name}\": must start with a letter and contain only letters, digits, or underscores.";
            if (!seen.Add(name))
                return $"Duplicate field name \"{name}\".";
        }

        return null;
    }
}

public record SaveTemplateJsonRequest(int Id, string? Subject, string? Body);
public record SaveOnlinePaymentSettingsDto(
    OnlinePaymentSurchargeType SurchargeType,
    decimal SurchargeValue,
    bool SurchargeAcknowledged);
