using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using PowderCoating.Core.Enums;
using PowderCoating.Infrastructure.Data;
using PowderCoating.Shared.Constants;
using Stripe;
using System.Text;

namespace PowderCoating.Web.Controllers;

/// <summary>
/// SuperAdmin-only controller for viewing and managing tenant company subscriptions.
/// Provides a paginated/filterable company list, per-company subscription editing,
/// trial extension, AI feature flag toggling, bulk operations, and Stripe payment history lookup.
/// All queries use <c>IgnoreQueryFilters()</c> to bypass the global EF multi-tenancy filter,
/// which would otherwise scope results to a single company context.
/// Changes are persisted directly via <see cref="ApplicationDbContext"/> (not IUnitOfWork)
/// because subscription management is a platform-level concern, not a tenant-domain concern,
/// and requires direct access to the Company entity which lives outside the tenant data layer.
/// </summary>
// Intentional exception: cross-tenant Company management with raw SQL audit log writes that bypass the tenant pipeline; platform-level concern outside IUnitOfWork scope. See docs/DATA_ACCESS_ARCHITECTURE.md — Permanent Exceptions.
[Authorize(Policy = AppConstants.Policies.SuperAdminOnly)]
public class SubscriptionManagementController : Controller
{
    private readonly ApplicationDbContext _db;
    private readonly IConfiguration _configuration;
    private readonly ILogger<SubscriptionManagementController> _logger;

    public SubscriptionManagementController(
        ApplicationDbContext db,
        IConfiguration configuration,
        ILogger<SubscriptionManagementController> logger)
    {
        _db = db;
        _configuration = configuration;
        _logger = logger;
    }

    /// <summary>
    /// Displays the paginated, sortable, filterable list of all tenant companies with their
    /// current subscription state.  Also loads all active <c>SubscriptionPlanConfig</c> records
    /// so the view can render human-readable plan names in the filter dropdown and data table.
    /// Page size is whitelisted to {10, 25, 50, 100} — any other submitted value is normalised
    /// to 25 to prevent memory exhaustion from arbitrarily large page requests.
    /// <c>IgnoreQueryFilters()</c> is required because deleted companies should be invisible
    /// here (excluded by explicit <c>!c.IsDeleted</c>) while active companies from all tenants
    /// must all be visible to the SuperAdmin, bypassing the single-tenant global filter.
    /// </summary>
    /// <param name="search">Optional text filter applied to company name, email, and Stripe customer ID.</param>
    /// <param name="status">Optional <see cref="SubscriptionStatus"/> enum name to filter by.</param>
    /// <param name="plan">Optional subscription plan integer to filter by.</param>
    /// <param name="sortCol">Column name to sort by (default: "CompanyName").</param>
    /// <param name="sortDir">Sort direction: "asc" or "desc" (default: "asc").</param>
    /// <param name="page">1-based page number (minimum 1).</param>
    /// <param name="pageSize">Rows per page; must be 10, 25, 50, or 100.</param>
    public async Task<IActionResult> Index(
        string? search,
        string? status,
        string? plan,
        string sortCol = "CompanyName",
        string sortDir = "asc",
        int page = 1,
        int pageSize = 25)
    {
        pageSize = pageSize is 10 or 25 or 50 or 100 ? pageSize : 25;
        page = Math.Max(1, page);

        var query = _db.Companies.AsNoTracking().IgnoreQueryFilters().Where(c => !c.IsDeleted);

        if (!string.IsNullOrWhiteSpace(search))
            query = query.Where(c =>
                c.CompanyName.Contains(search) ||
                (c.PrimaryContactEmail != null && c.PrimaryContactEmail.Contains(search)) ||
                (c.StripeCustomerId != null && c.StripeCustomerId.Contains(search)));

        if (!string.IsNullOrWhiteSpace(status) && Enum.TryParse<SubscriptionStatus>(status, out var statusEnum))
            query = query.Where(c => c.SubscriptionStatus == statusEnum);

        if (!string.IsNullOrWhiteSpace(plan) && int.TryParse(plan, out var planInt))
            query = query.Where(c => c.SubscriptionPlan == planInt);

        query = (sortCol, sortDir) switch
        {
            ("CompanyName", "desc") => query.OrderByDescending(c => c.CompanyName),
            ("Plan", "asc")        => query.OrderBy(c => c.SubscriptionPlan),
            ("Plan", "desc")       => query.OrderByDescending(c => c.SubscriptionPlan),
            ("Status", "asc")      => query.OrderBy(c => c.SubscriptionStatus),
            ("Status", "desc")     => query.OrderByDescending(c => c.SubscriptionStatus),
            ("EndDate", "asc")     => query.OrderBy(c => c.SubscriptionEndDate),
            ("EndDate", "desc")    => query.OrderByDescending(c => c.SubscriptionEndDate),
            ("Active", "asc")      => query.OrderBy(c => c.IsActive),
            ("Active", "desc")     => query.OrderByDescending(c => c.IsActive),
            _                      => query.OrderBy(c => c.CompanyName)
        };

        var totalCount = await query.CountAsync();
        var companies = await query.Skip((page - 1) * pageSize).Take(pageSize).ToListAsync();

        var planConfigs = await _db.SubscriptionPlanConfigs.AsNoTracking().IgnoreQueryFilters()
            .Where(p => p.IsActive).OrderBy(p => p.SortOrder).ToListAsync();

        ViewBag.PlanConfigs = planConfigs;
        ViewBag.Search = search;
        ViewBag.StatusFilter = status;
        ViewBag.PlanFilter = plan;
        ViewBag.SortCol = sortCol;
        ViewBag.SortDir = sortDir;
        ViewBag.Page = page;
        ViewBag.PageSize = pageSize;
        ViewBag.TotalCount = totalCount;
        ViewBag.TotalPages = (int)Math.Ceiling(totalCount / (double)pageSize);

        return View(companies);
    }

    /// <summary>
    /// Renders the per-company subscription management detail page.
    /// Loads current usage counts (users, jobs, customers) so the SuperAdmin can see whether
    /// the company is approaching plan limits before adjusting the subscription.
    /// Also loads all active plan configs so the plan dropdown is populated correctly.
    /// </summary>
    /// <param name="id">Primary key of the company to manage.</param>
    public async Task<IActionResult> Manage(int id)
    {
        var company = await _db.Companies.IgnoreQueryFilters().FirstOrDefaultAsync(c => c.Id == id);
        if (company == null) return NotFound();

        var planConfigs = await _db.SubscriptionPlanConfigs.AsNoTracking().IgnoreQueryFilters()
            .Where(p => p.IsActive).OrderBy(p => p.SortOrder).ToListAsync();

        var userCount = await _db.Users.CountAsync(u => u.CompanyId == id);
        var jobCount = await _db.Jobs.IgnoreQueryFilters().CountAsync(j => j.CompanyId == id && !j.IsDeleted);
        var customerCount = await _db.Customers.IgnoreQueryFilters().CountAsync(c => c.CompanyId == id && !c.IsDeleted);

        ViewBag.PlanConfigs = planConfigs;
        ViewBag.UserCount = userCount;
        ViewBag.JobCount = jobCount;
        ViewBag.CustomerCount = customerCount;

        return View(company);
    }

    /// <summary>
    /// Saves subscription changes for a single company and writes a manual entry to the
    /// <c>AuditLogs</c> table describing exactly what changed.
    /// Per-entity limit overrides are stored as nullable integers; a form value of 0 is
    /// converted to <c>null</c> via <see cref="NullIfZero"/> so the system falls back to the
    /// plan default rather than enforcing a hard zero limit.
    /// The audit log is written via raw SQL (not EF) to avoid routing audit writes through
    /// the multi-tenant <c>ApplicationDbContext</c> save pipeline, which applies global filters
    /// and could inadvertently scope the log entry to the wrong tenant.
    /// </summary>
    /// <param name="id">Primary key of the company to update.</param>
    /// <param name="subscriptionPlan">New plan integer identifier.</param>
    /// <param name="subscriptionStatus">New <see cref="SubscriptionStatus"/> value.</param>
    /// <param name="isActive">Whether the company account is active.</param>
    /// <param name="isComped">Whether the company receives complimentary (free) access.</param>
    /// <param name="subscriptionEndDate">New subscription end/expiry date.</param>
    /// <param name="subscriptionNotes">Internal notes visible only to SuperAdmins.</param>
    /// <param name="notes">Reason for this change; appended to the audit log entry.</param>
    /// <param name="maxUsersOverride">Per-company user limit override; 0 = use plan default.</param>
    /// <param name="maxActiveJobsOverride">Per-company active jobs limit override; 0 = use plan default.</param>
    /// <param name="maxCustomersOverride">Per-company customer limit override; 0 = use plan default.</param>
    /// <param name="maxQuotesOverride">Per-company monthly quote limit override; 0 = use plan default.</param>
    /// <param name="maxCatalogItemsOverride">Per-company catalog item limit override; 0 = use plan default.</param>
    /// <param name="maxJobPhotosOverride">Per-company job photo limit override; 0 = use plan default.</param>
    /// <param name="maxQuotePhotosOverride">Per-company quote photo limit override; 0 = use plan default.</param>
    [HttpPost, ValidateAntiForgeryToken]
    public async Task<IActionResult> UpdateSubscription(int id,
        int subscriptionPlan,
        SubscriptionStatus subscriptionStatus,
        bool isActive,
        bool isComped,
        DateTime? subscriptionEndDate,
        string? subscriptionNotes,
        string? notes,
        int? maxUsersOverride,
        int? maxActiveJobsOverride,
        int? maxCustomersOverride,
        int? maxQuotesOverride,
        int? maxCatalogItemsOverride,
        int? maxJobPhotosOverride,
        int? maxQuotePhotosOverride)
    {
        var company = await _db.Companies.IgnoreQueryFilters().FirstOrDefaultAsync(c => c.Id == id);
        if (company == null) return NotFound();

        var userName = User.Identity?.Name ?? "SuperAdmin";

        // Build audit description of changes
        var changes = new List<string>();
        if (company.SubscriptionPlan != subscriptionPlan)
            changes.Add($"Plan: {company.SubscriptionPlan} → {subscriptionPlan}");
        if (company.SubscriptionStatus != subscriptionStatus)
            changes.Add($"Status: {company.SubscriptionStatus} → {subscriptionStatus}");
        if (company.IsActive != isActive)
            changes.Add($"Active: {company.IsActive} → {isActive}");
        if (company.IsComped != isComped)
            changes.Add($"Comped: {company.IsComped} → {isComped}");
        if (company.SubscriptionEndDate != subscriptionEndDate)
            changes.Add($"EndDate: {company.SubscriptionEndDate:d} → {subscriptionEndDate:d}");

        company.SubscriptionPlan = subscriptionPlan;
        company.SubscriptionStatus = subscriptionStatus;
        company.IsActive = isActive;
        company.IsComped = isComped;
        company.SubscriptionEndDate = subscriptionEndDate;
        company.SubscriptionNotes = subscriptionNotes;
        company.MaxUsersOverride = NullIfZero(maxUsersOverride);
        company.MaxActiveJobsOverride = NullIfZero(maxActiveJobsOverride);
        company.MaxCustomersOverride = NullIfZero(maxCustomersOverride);
        company.MaxQuotesOverride = NullIfZero(maxQuotesOverride);
        company.MaxCatalogItemsOverride = NullIfZero(maxCatalogItemsOverride);
        company.MaxJobPhotosOverride = NullIfZero(maxJobPhotosOverride);
        company.MaxQuotePhotosOverride = NullIfZero(maxQuotePhotosOverride);
        company.UpdatedAt = DateTime.UtcNow;
        company.UpdatedBy = userName;

        await _db.SaveChangesAsync();

        // Write a manual audit entry
        if (changes.Count > 0)
        {
            var summary = string.Join("; ", changes);
            if (!string.IsNullOrWhiteSpace(notes)) summary += $". Notes: {notes}";

            await _db.Database.ExecuteSqlRawAsync("""
                INSERT INTO AuditLogs (UserId, UserName, CompanyId, CompanyName, Action, EntityType, EntityId,
                    EntityDescription, OldValues, NewValues, IpAddress, Timestamp)
                VALUES ({0},{1},{2},{3},{4},{5},{6},{7},{8},{9},{10},{11})
                """,
                (object?)User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value,
                userName,
                (object?)null,
                (object?)null,
                "ManualChange",
                "Company",
                id.ToString(),
                company.CompanyName,
                (object?)null,
                summary,
                (object?)HttpContext.Connection.RemoteIpAddress?.ToString(),
                DateTime.UtcNow);
        }

        TempData["Success"] = $"Subscription updated for {company.CompanyName}.";
        return RedirectToAction(nameof(Manage), new { id });
    }

    /// <summary>
    /// Extends a single company's subscription end date by the specified number of days and
    /// ensures the account is marked Active with a status of <see cref="SubscriptionStatus.Active"/>.
    /// Extension is calculated from the later of the existing end date or now, so adding days
    /// to an already-future date compounds correctly rather than resetting to today + N days.
    /// This covers common support scenarios such as granting a grace period to a customer whose
    /// payment is late but who has contacted support.
    /// </summary>
    /// <param name="id">Primary key of the company to extend.</param>
    /// <param name="days">Number of days to add to the current end date (or today if already expired).</param>
    [HttpPost, ValidateAntiForgeryToken]
    public async Task<IActionResult> ExtendTrial(int id, int days)
    {
        var company = await _db.Companies.IgnoreQueryFilters().FirstOrDefaultAsync(c => c.Id == id);
        if (company == null) return NotFound();

        var baseDate = company.SubscriptionEndDate.HasValue && company.SubscriptionEndDate > DateTime.UtcNow
            ? company.SubscriptionEndDate.Value
            : DateTime.UtcNow;

        company.SubscriptionEndDate = baseDate.AddDays(days);
        company.SubscriptionStatus = SubscriptionStatus.Active;
        company.IsActive = true;
        company.UpdatedAt = DateTime.UtcNow;
        company.UpdatedBy = User.Identity?.Name;

        await _db.SaveChangesAsync();

        TempData["Success"] = $"Extended subscription by {days} days. New end date: {company.SubscriptionEndDate:d}.";
        return RedirectToAction(nameof(Manage), new { id });
    }

    /// <summary>
    /// AJAX endpoint that toggles a named boolean feature on a <c>SubscriptionPlanConfig</c> record.
    /// Currently only <c>AllowOnlinePayments</c> is supported; unknown feature names return
    /// <c>400 Bad Request</c> to prevent silent no-ops from misspelled feature identifiers.
    /// Returns JSON so the Manage view can update the UI toggle without a full page reload.
    /// </summary>
    /// <param name="planId">Primary key of the <c>SubscriptionPlanConfig</c> to update.</param>
    /// <param name="feature">Feature name string (e.g. "AllowOnlinePayments").</param>
    /// <param name="enabled">New boolean value for the feature flag.</param>
    [HttpPost, ValidateAntiForgeryToken]
    public async Task<IActionResult> TogglePlanFeature(int planId, string feature, bool enabled)
    {
        var config = await _db.SubscriptionPlanConfigs.IgnoreQueryFilters()
            .FirstOrDefaultAsync(p => p.Id == planId);
        if (config == null) return NotFound();

        switch (feature)
        {
            case "AllowOnlinePayments":
                config.AllowOnlinePayments = enabled;
                break;
            case "AllowCustomFormulas":
                config.AllowCustomFormulas = enabled;
                break;
            default:
                return BadRequest("Unknown feature.");
        }

        config.UpdatedAt = DateTime.UtcNow;
        _db.Update(config);
        await _db.SaveChangesAsync();

        _logger.LogInformation("Plan {PlanId} ({Name}): {Feature} set to {Value} by {User}",
            planId, config.DisplayName, feature, enabled, User.Identity?.Name);

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

    // ── Bulk Operations ───────────────────────────────────────────────────

    /// <summary>
    /// Extends the subscription end date for multiple companies in a single operation,
    /// applying the same day-extension logic as <see cref="ExtendTrial"/>: dates in the future
    /// are extended from their current end date; expired dates are extended from today.
    /// Validates that at least one company is selected and that the day count is positive
    /// before issuing any database writes, to prevent accidental empty or no-op submissions.
    /// </summary>
    /// <param name="ids">Array of company primary keys to extend.</param>
    /// <param name="days">Number of days to add to each company's end date.</param>
    [HttpPost, ValidateAntiForgeryToken]
    public async Task<IActionResult> BulkExtendTrial(int[] ids, int days)
    {
        if (ids.Length == 0 || days <= 0)
        {
            TempData["Error"] = "Select at least one company and a valid number of days.";
            return RedirectToAction(nameof(Index));
        }

        var companies = await _db.Companies.IgnoreQueryFilters()
            .Where(c => ids.Contains(c.Id)).ToListAsync();

        foreach (var company in companies)
        {
            var baseDate = company.SubscriptionEndDate.HasValue && company.SubscriptionEndDate > DateTime.UtcNow
                ? company.SubscriptionEndDate.Value
                : DateTime.UtcNow;
            company.SubscriptionEndDate = baseDate.AddDays(days);
            company.SubscriptionStatus = SubscriptionStatus.Active;
            company.IsActive = true;
            company.UpdatedAt = DateTime.UtcNow;
            company.UpdatedBy = User.Identity?.Name;
        }

        await _db.SaveChangesAsync();
        TempData["Success"] = $"Extended {companies.Count} company subscription(s) by {days} days.";
        return RedirectToAction(nameof(Index));
    }

    /// <summary>
    /// Activates or deactivates multiple companies in a single batch.
    /// Deactivating a company prevents its users from logging in (the subscription middleware
    /// and login pipeline check <c>Company.IsActive</c>).
    /// Requires at least one company to be selected; an empty array returns an error to prevent
    /// accidental empty-selection submissions from having no visible effect.
    /// </summary>
    /// <param name="ids">Array of company primary keys to toggle.</param>
    /// <param name="active">Target active state: <c>true</c> to activate, <c>false</c> to deactivate.</param>
    [HttpPost, ValidateAntiForgeryToken]
    public async Task<IActionResult> BulkToggleActive(int[] ids, bool active)
    {
        if (ids.Length == 0)
        {
            TempData["Error"] = "Select at least one company.";
            return RedirectToAction(nameof(Index));
        }

        var companies = await _db.Companies.IgnoreQueryFilters()
            .Where(c => ids.Contains(c.Id)).ToListAsync();

        foreach (var company in companies)
        {
            company.IsActive = active;
            company.UpdatedAt = DateTime.UtcNow;
            company.UpdatedBy = User.Identity?.Name;
        }

        await _db.SaveChangesAsync();
        TempData["Success"] = $"{(active ? "Activated" : "Deactivated")} {companies.Count} company(ies).";
        return RedirectToAction(nameof(Index));
    }

    /// <summary>
    /// Exports the company subscription list to a CSV file, applying the same search, status,
    /// and plan filters as the <see cref="Index"/> view but without pagination.
    /// Plan names are resolved from the <c>SubscriptionPlanConfig</c> lookup table so the CSV
    /// contains human-readable plan names (e.g. "Professional") rather than raw integer plan codes.
    /// The CSV is returned as a byte array (not a stream) to avoid partial-write issues on slow clients.
    /// </summary>
    /// <param name="search">Optional text filter applied to company name and email.</param>
    /// <param name="status">Optional <see cref="SubscriptionStatus"/> enum name to filter by.</param>
    /// <param name="plan">Optional subscription plan integer to filter by.</param>
    public async Task<IActionResult> ExportCsv(string? search, string? status, string? plan)
    {
        var query = _db.Companies.AsNoTracking().IgnoreQueryFilters().Where(c => !c.IsDeleted);

        if (!string.IsNullOrWhiteSpace(search))
            query = query.Where(c => c.CompanyName.Contains(search) ||
                (c.PrimaryContactEmail != null && c.PrimaryContactEmail.Contains(search)));

        if (!string.IsNullOrWhiteSpace(status) && Enum.TryParse<SubscriptionStatus>(status, out var statusEnum))
            query = query.Where(c => c.SubscriptionStatus == statusEnum);

        if (!string.IsNullOrWhiteSpace(plan) && int.TryParse(plan, out var planInt))
            query = query.Where(c => c.SubscriptionPlan == planInt);

        var planConfigs = await _db.SubscriptionPlanConfigs.AsNoTracking().IgnoreQueryFilters()
            .Where(p => p.IsActive).ToListAsync();
        var planNames = planConfigs.ToDictionary(p => p.Plan, p => p.DisplayName);

        var companies = await query.OrderBy(c => c.CompanyName).ToListAsync();

        var sb = new StringBuilder();
        sb.AppendLine("Company,Contact,Email,Phone,Plan,Status,Active,Comped,End Date,Stripe Customer,Created");
        foreach (var c in companies)
        {
            sb.AppendLine(string.Join(",",
                CsvEscape(c.CompanyName),
                CsvEscape(c.PrimaryContactName),
                CsvEscape(c.PrimaryContactEmail),
                CsvEscape(c.Phone ?? ""),
                CsvEscape(planNames.GetValueOrDefault(c.SubscriptionPlan, c.SubscriptionPlan.ToString())),
                c.SubscriptionStatus.ToString(),
                c.IsActive ? "Yes" : "No",
                c.IsComped ? "Yes" : "No",
                c.SubscriptionEndDate.HasValue ? c.SubscriptionEndDate.Value.ToString("MM/dd/yyyy") : "",
                CsvEscape(c.StripeCustomerId ?? ""),
                c.CreatedAt.ToString("MM/dd/yyyy")));
        }

        var bytes = Encoding.UTF8.GetBytes(sb.ToString());
        return File(bytes, "text/csv", $"companies-{DateTime.UtcNow:yyyyMMdd}.csv");
    }

    /// <summary>
    /// RFC 4180-compliant CSV field escaper for string values.
    /// Wraps the value in double-quotes and escapes embedded double-quotes when the value
    /// contains a comma, double-quote, or newline.  Unlike the overload in the export controllers,
    /// this version accepts only non-null strings because all callers in this controller
    /// guarantee non-null input (using null-coalescing before calling).
    /// </summary>
    private static string CsvEscape(string value) =>
        value.Contains(',') || value.Contains('"') || value.Contains('\n')
            ? $"\"{value.Replace("\"", "\"\"")}\""
            : value;

    /// <summary>
    /// Updates per-company AI feature flags for a single tenant company.
    /// Feature flags control which AI capabilities are enabled beyond the plan default
    /// (e.g., AI photo quoting, AI inventory assist).  The monthly photo quote override allows
    /// granting a specific company more AI calls than their plan tier normally permits.
    /// Zero is treated as "use plan default" via <see cref="NullIfZero"/>, matching the same
    /// convention used for the limit overrides in <see cref="UpdateSubscription"/>.
    /// </summary>
    /// <param name="id">Primary key of the company to update.</param>
    /// <param name="aiPhotoQuotesEnabled">Whether AI photo quoting is enabled for this company.</param>
    /// <param name="aiInventoryAssistEnabled">Whether AI inventory assistance is enabled for this company.</param>
    /// <param name="aiCatalogPriceCheckEnabled">Whether AI catalog price check is enabled for this company.</param>
    /// <param name="maxAiPhotoQuotesPerMonthOverride">Monthly AI photo quote limit override; 0 = plan default.</param>
    [HttpPost, ValidateAntiForgeryToken]
    public async Task<IActionResult> UpdateFeatureFlags(
        int id,
        bool aiPhotoQuotesEnabled,
        bool aiInventoryAssistEnabled,
        bool aiCatalogPriceCheckEnabled,
        int? maxAiPhotoQuotesPerMonthOverride)
    {
        var company = await _db.Companies.IgnoreQueryFilters().FirstOrDefaultAsync(c => c.Id == id);
        if (company == null) return NotFound();

        company.AiPhotoQuotesEnabled = aiPhotoQuotesEnabled;
        company.AiInventoryAssistEnabled = aiInventoryAssistEnabled;
        company.AiCatalogPriceCheckEnabled = aiCatalogPriceCheckEnabled;
        company.MaxAiPhotoQuotesPerMonthOverride = NullIfZero(maxAiPhotoQuotesPerMonthOverride);
        company.UpdatedAt = DateTime.UtcNow;
        company.UpdatedBy = User.Identity?.Name;

        await _db.SaveChangesAsync();

        TempData["Success"] = $"Feature flags updated for {company.CompanyName}.";
        return RedirectToAction(nameof(Manage), new { id });
    }

    /// <summary>
    /// Returns the last 24 Stripe charges for a company's Stripe customer as JSON,
    /// allowing the Manage view to render a payment history panel (with refund buttons) via AJAX.
    /// Charges are used rather than invoices because in Stripe.net 50.x the Invoice object no
    /// longer exposes <c>PaymentIntentId</c> or a direct <c>Charge</c> reference — the new
    /// <c>Invoice.Payments</c> collection requires additional expansion round-trips.
    /// The Charge object has <c>Amount</c>, <c>AmountRefunded</c>, <c>ReceiptUrl</c>, and
    /// <c>Id</c> (the charge ID used as the refund target), which is everything the refund
    /// workflow needs. The API key is read at call time to support key rotation without a restart.
    /// Returns a JSON error object (not an HTTP error status) on failures.
    /// </summary>
    /// <param name="id">Primary key of the company whose Stripe charge history to fetch.</param>
    public async Task<IActionResult> PaymentHistory(int id)
    {
        var company = await _db.Companies.IgnoreQueryFilters().AsNoTracking()
            .FirstOrDefaultAsync(c => c.Id == id);
        if (company == null) return NotFound();

        if (string.IsNullOrEmpty(company.StripeCustomerId))
            return Json(new { error = "No Stripe customer ID on record for this company." });

        try
        {
            StripeConfiguration.ApiKey = _configuration["Stripe:SecretKey"];

            var chargeService = new ChargeService();
            var charges = await chargeService.ListAsync(new ChargeListOptions
            {
                Customer = company.StripeCustomerId,
                Limit    = 24
            });

            var rows = charges.Data.Select(ch =>
            {
                var amountCents         = ch.Amount;
                var amountRefundedCents = ch.AmountRefunded;
                var refundableCents     = Math.Max(0L, amountCents - amountRefundedCents);
                return new
                {
                    id                  = ch.Id,
                    created             = ch.Created.ToString("MM/dd/yyyy"),
                    amount              = (amountCents / 100m).ToString("C"),
                    amountRefunded      = amountRefundedCents > 0 ? (amountRefundedCents / 100m).ToString("C") : (string?)null,
                    refundableCents,
                    status              = ch.Status,
                    receipt             = ch.ReceiptUrl,
                    description         = ch.Description
                };
            });

            return Json(new { success = true, charges = rows });
        }
        catch (StripeException ex)
        {
            _logger.LogWarning(ex, "Stripe charge lookup failed for company {Id}", id);
            return Json(new { error = ex.StripeError?.Message ?? ex.Message });
        }
    }

    /// <summary>
    /// Issues a full or partial refund against a Stripe subscription payment on behalf of a
    /// SuperAdmin. The refund is created via Stripe's <c>RefundService</c> against the invoice's
    /// <c>chargeId</c> (Stripe Charge ID); partial refunds are supported by passing an explicit
    /// <paramref name="amount"/> in dollars (converted to cents for Stripe).
    /// The action is written to <c>AuditLogs</c> with the Stripe refund ID so there is a
    /// durable record of who authorized the refund and when. Returns JSON so the Manage view
    /// can show an inline result without a full page reload.
    /// </summary>
    /// <param name="id">Company primary key — used for audit logging only.</param>
    /// <param name="chargeId">Stripe Charge ID for the payment being refunded.</param>
    /// <param name="amount">Refund amount in dollars; must be &gt; 0 and ≤ remaining refundable amount.</param>
    /// <param name="reason">Human-readable reason recorded in the audit log.</param>
    [HttpPost, ValidateAntiForgeryToken]
    public async Task<IActionResult> IssueSubscriptionRefund(int id, string chargeId, decimal amount, string reason)
    {
        var company = await _db.Companies.IgnoreQueryFilters().AsNoTracking()
            .FirstOrDefaultAsync(c => c.Id == id);
        if (company == null) return Json(new { error = "Company not found." });

        if (string.IsNullOrWhiteSpace(chargeId))
            return Json(new { error = "Charge ID is required." });

        if (amount <= 0)
            return Json(new { error = "Refund amount must be greater than zero." });

        try
        {
            StripeConfiguration.ApiKey = _configuration["Stripe:SecretKey"];

            var refundService = new RefundService();
            var refund = await refundService.CreateAsync(new RefundCreateOptions
            {
                Charge = chargeId,
                Amount = (long)Math.Round(amount * 100),  // dollars → cents
                Reason = RefundReasons.RequestedByCustomer
            });

            var userName = User.Identity?.Name ?? "SuperAdmin";
            _logger.LogInformation(
                "SuperAdmin {User} issued {Amount} subscription refund for company {CompanyId} ({CompanyName}). " +
                "ChargeId={ChargeId} StripeRefundId={RefundId} Reason={Reason}",
                userName, amount.ToString("C"), id, company.CompanyName,
                chargeId, refund.Id, reason);

            // Durable audit trail so the action is visible without opening Stripe
            await _db.Database.ExecuteSqlRawAsync("""
                INSERT INTO AuditLogs (UserId, UserName, CompanyId, CompanyName, Action, EntityType, EntityId,
                    EntityDescription, OldValues, NewValues, IpAddress, Timestamp)
                VALUES ({0},{1},{2},{3},{4},{5},{6},{7},{8},{9},{10},{11})
                """,
                (object?)User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value,
                userName,
                (object?)null,
                (object?)null,
                "SubscriptionRefund",
                "Company",
                id.ToString(),
                company.CompanyName,
                (object?)null,
                $"Issued {amount:C} subscription refund. Reason: {reason}. Stripe Charge: {chargeId}. Stripe Refund ID: {refund.Id}.",
                (object?)HttpContext.Connection.RemoteIpAddress?.ToString(),
                DateTime.UtcNow);

            return Json(new { success = true, refundId = refund.Id, amountFormatted = amount.ToString("C") });
        }
        catch (StripeException ex)
        {
            _logger.LogWarning(ex, "Stripe refund failed for company {Id}: {Message}", id, ex.StripeError?.Message);
            return Json(new { error = ex.StripeError?.Message ?? ex.Message });
        }
    }

    /// <summary>
    /// Converts 0 to <c>null</c> for nullable integer limit override fields.
    /// HTML number inputs cannot represent <c>null</c> — they submit 0 when left empty — so
    /// this helper normalises 0 back to <c>null</c>, which the application interprets as
    /// "use the plan's default limit" rather than "enforce a hard zero limit".
    /// Any non-zero value passes through unchanged.
    /// </summary>
    /// <param name="value">Raw form-submitted integer override value.</param>
    /// <returns><c>null</c> when <paramref name="value"/> is 0; otherwise the original value.</returns>
    private static int? NullIfZero(int? value) => value == 0 ? null : value;
}
