using Bit.Billing.Constants;
using Bit.Billing.Models;
using Bit.Billing.Services;
using Bit.Core;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Billing.Constants;
using Bit.Core.Context;
using Bit.Core.Enums;
using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Settings;
using Bit.Core.Tools.Enums;
using Bit.Core.Tools.Models.Business;
using Bit.Core.Tools.Services;
using Bit.Core.Utilities;
using Braintree;
using Braintree.Exceptions;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Data.SqlClient;
using Microsoft.Extensions.Options;
using Stripe;
using Customer = Stripe.Customer;
using Event = Stripe.Event;
using JsonSerializer = System.Text.Json.JsonSerializer;
using Subscription = Stripe.Subscription;
using TaxRate = Bit.Core.Entities.TaxRate;
using Transaction = Bit.Core.Entities.Transaction;
using TransactionType = Bit.Core.Enums.TransactionType;

namespace Bit.Billing.Controllers;

[Route("stripe")]
public class StripeController : Controller
{
    private const string PremiumPlanId = "premium-annually";
    private const string PremiumPlanIdAppStore = "premium-annually-app";

    private readonly BillingSettings _billingSettings;
    private readonly IWebHostEnvironment _hostingEnvironment;
    private readonly IOrganizationService _organizationService;
    private readonly IValidateSponsorshipCommand _validateSponsorshipCommand;
    private readonly IOrganizationSponsorshipRenewCommand _organizationSponsorshipRenewCommand;
    private readonly IOrganizationRepository _organizationRepository;
    private readonly ITransactionRepository _transactionRepository;
    private readonly IUserService _userService;
    private readonly IMailService _mailService;
    private readonly ILogger<StripeController> _logger;
    private readonly BraintreeGateway _btGateway;
    private readonly IReferenceEventService _referenceEventService;
    private readonly ITaxRateRepository _taxRateRepository;
    private readonly IUserRepository _userRepository;
    private readonly ICurrentContext _currentContext;
    private readonly GlobalSettings _globalSettings;
    private readonly IStripeEventService _stripeEventService;
    private readonly IStripeFacade _stripeFacade;
    private readonly IFeatureService _featureService;
    private readonly IProviderRepository _providerRepository;

    public StripeController(
        GlobalSettings globalSettings,
        IOptions<BillingSettings> billingSettings,
        IWebHostEnvironment hostingEnvironment,
        IOrganizationService organizationService,
        IValidateSponsorshipCommand validateSponsorshipCommand,
        IOrganizationSponsorshipRenewCommand organizationSponsorshipRenewCommand,
        IOrganizationRepository organizationRepository,
        ITransactionRepository transactionRepository,
        IUserService userService,
        IMailService mailService,
        IReferenceEventService referenceEventService,
        ILogger<StripeController> logger,
        ITaxRateRepository taxRateRepository,
        IUserRepository userRepository,
        ICurrentContext currentContext,
        IStripeEventService stripeEventService,
        IStripeFacade stripeFacade,
        IFeatureService featureService,
        IProviderRepository providerRepository)
    {
        _billingSettings = billingSettings?.Value;
        _hostingEnvironment = hostingEnvironment;
        _organizationService = organizationService;
        _validateSponsorshipCommand = validateSponsorshipCommand;
        _organizationSponsorshipRenewCommand = organizationSponsorshipRenewCommand;
        _organizationRepository = organizationRepository;
        _transactionRepository = transactionRepository;
        _userService = userService;
        _mailService = mailService;
        _referenceEventService = referenceEventService;
        _taxRateRepository = taxRateRepository;
        _userRepository = userRepository;
        _logger = logger;
        _btGateway = new BraintreeGateway
        {
            Environment = globalSettings.Braintree.Production ?
                Braintree.Environment.PRODUCTION : Braintree.Environment.SANDBOX,
            MerchantId = globalSettings.Braintree.MerchantId,
            PublicKey = globalSettings.Braintree.PublicKey,
            PrivateKey = globalSettings.Braintree.PrivateKey
        };
        _currentContext = currentContext;
        _globalSettings = globalSettings;
        _stripeEventService = stripeEventService;
        _stripeFacade = stripeFacade;
        _featureService = featureService;
        _providerRepository = providerRepository;
    }

    [HttpPost("webhook")]
    public async Task<IActionResult> PostWebhook([FromQuery] string key)
    {
        if (!CoreHelpers.FixedTimeEquals(key, _billingSettings.StripeWebhookKey))
        {
            return new BadRequestResult();
        }

        var parsedEvent = await TryParseEventFromRequestBodyAsync();
        if (parsedEvent is null)
        {
            return Ok();
        }

        if (StripeConfiguration.ApiVersion != parsedEvent.ApiVersion)
        {
            _logger.LogWarning(
                "Stripe {WebhookType} webhook's API version ({WebhookAPIVersion}) does not match SDK API Version ({SDKAPIVersion})",
                parsedEvent.Type,
                parsedEvent.ApiVersion,
                StripeConfiguration.ApiVersion);

            return new OkResult();
        }

        if (string.IsNullOrWhiteSpace(parsedEvent?.Id))
        {
            _logger.LogWarning("No event id.");
            return new BadRequestResult();
        }

        if (_hostingEnvironment.IsProduction() && !parsedEvent.Livemode)
        {
            _logger.LogWarning("Getting test events in production.");
            return new BadRequestResult();
        }

        // If the customer and server cloud regions don't match, early return 200 to avoid unnecessary errors
        if (!await _stripeEventService.ValidateCloudRegion(parsedEvent))
        {
            return new OkResult();
        }

        switch (parsedEvent.Type)
        {
            case HandledStripeWebhook.SubscriptionDeleted:
                {
                    await HandleCustomerSubscriptionDeletedEventAsync(parsedEvent);
                    return Ok();
                }
            case HandledStripeWebhook.SubscriptionUpdated:
                {
                    await HandleCustomerSubscriptionUpdatedEventAsync(parsedEvent);
                    return Ok();
                }
            case HandledStripeWebhook.UpcomingInvoice:
                {
                    await HandleUpcomingInvoiceEventAsync(parsedEvent);
                    return Ok();
                }
            case HandledStripeWebhook.ChargeSucceeded:
                {
                    await HandleChargeSucceededEventAsync(parsedEvent);
                    return Ok();
                }
            case HandledStripeWebhook.ChargeRefunded:
                {
                    await HandleChargeRefundedEventAsync(parsedEvent);
                    return Ok();
                }
            case HandledStripeWebhook.PaymentSucceeded:
                {
                    await HandlePaymentSucceededEventAsync(parsedEvent);
                    return Ok();
                }
            case HandledStripeWebhook.PaymentFailed:
                {
                    await HandlePaymentFailedEventAsync(parsedEvent);
                    return Ok();
                }
            case HandledStripeWebhook.InvoiceCreated:
                {
                    await HandleInvoiceCreatedEventAsync(parsedEvent);
                    return Ok();
                }
            case HandledStripeWebhook.PaymentMethodAttached:
                {
                    await HandlePaymentMethodAttachedAsync(parsedEvent);
                    return Ok();
                }
            case HandledStripeWebhook.CustomerUpdated:
                {
                    await HandleCustomerUpdatedEventAsync(parsedEvent);
                    return Ok();
                }
            default:
                {
                    _logger.LogWarning("Unsupported event received. {EventType}", parsedEvent.Type);
                    return Ok();
                }
        }
    }

    /// <summary>
    /// Handles the <see cref="HandledStripeWebhook.SubscriptionUpdated"/> event type from Stripe.
    /// </summary>
    /// <param name="parsedEvent"></param>
    private async Task HandleCustomerSubscriptionUpdatedEventAsync(Event parsedEvent)
    {
        var subscription = await _stripeEventService.GetSubscription(parsedEvent, true, ["customer", "discounts"]);
        var (organizationId, userId, providerId) = GetIdsFromMetadata(subscription.Metadata);

        switch (subscription.Status)
        {
            case StripeSubscriptionStatus.Unpaid or StripeSubscriptionStatus.IncompleteExpired
                when organizationId.HasValue:
                {
                    await _organizationService.DisableAsync(organizationId.Value, subscription.CurrentPeriodEnd);
                    break;
                }
            case StripeSubscriptionStatus.Unpaid or StripeSubscriptionStatus.IncompleteExpired:
                {
                    if (!userId.HasValue)
                    {
                        break;
                    }

                    if (subscription.Status is StripeSubscriptionStatus.Unpaid &&
                        subscription.Items.Any(i => i.Price.Id is PremiumPlanId or PremiumPlanIdAppStore))
                    {
                        await CancelSubscription(subscription.Id);
                        await VoidOpenInvoices(subscription.Id);
                    }

                    await _userService.DisablePremiumAsync(userId.Value, subscription.CurrentPeriodEnd);

                    break;
                }
            case StripeSubscriptionStatus.Active when organizationId.HasValue:
                {
                    await _organizationService.EnableAsync(organizationId.Value);
                    break;
                }
            case StripeSubscriptionStatus.Active:
                {
                    if (userId.HasValue)
                    {
                        await _userService.EnablePremiumAsync(userId.Value, subscription.CurrentPeriodEnd);
                    }

                    break;
                }
        }

        if (organizationId.HasValue)
        {
            await _organizationService.UpdateExpirationDateAsync(organizationId.Value, subscription.CurrentPeriodEnd);
            if (IsSponsoredSubscription(subscription))
            {
                await _organizationSponsorshipRenewCommand.UpdateExpirationDateAsync(organizationId.Value, subscription.CurrentPeriodEnd);
            }

            await RemovePasswordManagerCouponIfRemovingSecretsManagerTrialAsync(parsedEvent, subscription);
        }
        else if (userId.HasValue)
        {
            await _userService.UpdatePremiumExpirationAsync(userId.Value, subscription.CurrentPeriodEnd);
        }
    }

    /// <summary>
    /// Removes the Password Manager coupon if the organization is removing the Secrets Manager trial.
    /// Only applies to organizations that have a subscription from the Secrets Manager trial.
    /// </summary>
    /// <param name="parsedEvent"></param>
    /// <param name="subscription"></param>
    private async Task RemovePasswordManagerCouponIfRemovingSecretsManagerTrialAsync(Event parsedEvent,
        Subscription subscription)
    {
        if (parsedEvent.Data.PreviousAttributes?.items is null)
        {
            return;
        }

        var previousSubscription = parsedEvent.Data
            .PreviousAttributes
            .ToObject<Subscription>() as Subscription;

        // This being false doesn't necessarily mean that the organization doesn't subscribe to Secrets Manager.
        // If there are changes to any subscription item, Stripe sends every item in the subscription, both
        // changed and unchanged.
        var previousSubscriptionHasSecretsManager = previousSubscription?.Items is not null &&
                                                    previousSubscription.Items.Any(previousItem =>
                                                        StaticStore.Plans.Any(p =>
                                                            p.SecretsManager is not null &&
                                                            p.SecretsManager.StripeSeatPlanId ==
                                                            previousItem.Plan.Id));

        var currentSubscriptionHasSecretsManager = subscription.Items.Any(i =>
            StaticStore.Plans.Any(p =>
                p.SecretsManager is not null &&
                p.SecretsManager.StripeSeatPlanId == i.Plan.Id));

        if (!previousSubscriptionHasSecretsManager || currentSubscriptionHasSecretsManager)
        {
            return;
        }

        var customerHasSecretsManagerTrial = subscription.Customer
            ?.Discount
            ?.Coupon
            ?.Id == "sm-standalone";

        var subscriptionHasSecretsManagerTrial = subscription.Discount
            ?.Coupon
            ?.Id == "sm-standalone";

        if (customerHasSecretsManagerTrial)
        {
            await _stripeFacade.DeleteCustomerDiscount(subscription.CustomerId);
        }

        if (subscriptionHasSecretsManagerTrial)
        {
            await _stripeFacade.DeleteSubscriptionDiscount(subscription.Id);
        }
    }

    /// <summary>
    /// Handles the <see cref="HandledStripeWebhook.SubscriptionDeleted"/> event type from Stripe.
    /// </summary>
    /// <param name="parsedEvent"></param>
    private async Task HandleCustomerSubscriptionDeletedEventAsync(Event parsedEvent)
    {
        var subscription = await _stripeEventService.GetSubscription(parsedEvent, true);
        var (organizationId, userId, providerId) = GetIdsFromMetadata(subscription.Metadata);
        var subCanceled = subscription.Status == StripeSubscriptionStatus.Canceled;

        if (!subCanceled)
        {
            return;
        }

        if (organizationId.HasValue)
        {
            await _organizationService.DisableAsync(organizationId.Value, subscription.CurrentPeriodEnd);
        }
        else if (userId.HasValue)
        {
            await _userService.DisablePremiumAsync(userId.Value, subscription.CurrentPeriodEnd);
        }
    }

    /// <summary>
    /// Handles the <see cref="HandledStripeWebhook.CustomerUpdated"/> event type from Stripe.
    /// </summary>
    /// <param name="parsedEvent"></param>
    private async Task HandleCustomerUpdatedEventAsync(Event parsedEvent)
    {
        var customer = await _stripeEventService.GetCustomer(parsedEvent, true, ["subscriptions"]);
        if (customer.Subscriptions == null || !customer.Subscriptions.Any())
        {
            return;
        }

        var subscription = customer.Subscriptions.First();

        var (organizationId, _, providerId) = GetIdsFromMetadata(subscription.Metadata);

        if (!organizationId.HasValue)
        {
            return;
        }

        var organization = await _organizationRepository.GetByIdAsync(organizationId.Value);
        organization.BillingEmail = customer.Email;
        await _organizationRepository.ReplaceAsync(organization);

        await _referenceEventService.RaiseEventAsync(
            new ReferenceEvent(ReferenceEventType.OrganizationEditedInStripe, organization, _currentContext));
    }

    /// <summary>
    /// Handles the <see cref="HandledStripeWebhook.InvoiceCreated"/> event type from Stripe.
    /// </summary>
    /// <param name="parsedEvent"></param>
    private async Task HandleInvoiceCreatedEventAsync(Event parsedEvent)
    {
        var invoice = await _stripeEventService.GetInvoice(parsedEvent, true);
        if (invoice.Paid || !ShouldAttemptToPayInvoice(invoice))
        {
            return;
        }

        await AttemptToPayInvoiceAsync(invoice);
    }

    /// <summary>
    /// Handles the <see cref="HandledStripeWebhook.PaymentSucceeded"/> event type from Stripe.
    /// </summary>
    /// <param name="parsedEvent"></param>
    private async Task HandlePaymentSucceededEventAsync(Event parsedEvent)
    {
        var invoice = await _stripeEventService.GetInvoice(parsedEvent, true);
        if (!invoice.Paid || invoice.BillingReason != "subscription_create")
        {
            return;
        }

        var subscription = await _stripeFacade.GetSubscription(invoice.SubscriptionId);
        if (subscription?.Status != StripeSubscriptionStatus.Active)
        {
            return;
        }

        if (DateTime.UtcNow - invoice.Created < TimeSpan.FromMinutes(1))
        {
            await Task.Delay(5000);
        }

        var (organizationId, userId, providerId) = GetIdsFromMetadata(subscription.Metadata);

        if (providerId.HasValue)
        {
            var provider = await _providerRepository.GetByIdAsync(providerId.Value);

            if (provider == null)
            {
                _logger.LogError(
                    "Received invoice.payment_succeeded webhook ({EventID}) for Provider ({ProviderID}) that does not exist",
                    parsedEvent.Id,
                    providerId.Value);

                return;
            }

            var teamsMonthly = StaticStore.GetPlan(PlanType.TeamsMonthly);

            var enterpriseMonthly = StaticStore.GetPlan(PlanType.EnterpriseMonthly);

            var teamsMonthlyLineItem =
                subscription.Items.Data.FirstOrDefault(item =>
                    item.Plan.Id == teamsMonthly.PasswordManager.StripeSeatPlanId);

            var enterpriseMonthlyLineItem =
                subscription.Items.Data.FirstOrDefault(item =>
                    item.Plan.Id == enterpriseMonthly.PasswordManager.StripeSeatPlanId);

            if (teamsMonthlyLineItem == null || enterpriseMonthlyLineItem == null)
            {
                _logger.LogError("invoice.payment_succeeded webhook ({EventID}) for Provider ({ProviderID}) indicates missing subscription line items",
                    parsedEvent.Id,
                    provider.Id);

                return;
            }

            await _referenceEventService.RaiseEventAsync(new ReferenceEvent
            {
                Type = ReferenceEventType.Rebilled,
                Source = ReferenceEventSource.Provider,
                Id = provider.Id,
                PlanType = PlanType.TeamsMonthly,
                Seats = (int)teamsMonthlyLineItem.Quantity
            });

            await _referenceEventService.RaiseEventAsync(new ReferenceEvent
            {
                Type = ReferenceEventType.Rebilled,
                Source = ReferenceEventSource.Provider,
                Id = provider.Id,
                PlanType = PlanType.EnterpriseMonthly,
                Seats = (int)enterpriseMonthlyLineItem.Quantity
            });
        }
        else if (organizationId.HasValue)
        {
            if (!subscription.Items.Any(i =>
                    StaticStore.Plans.Any(p => p.PasswordManager.StripePlanId == i.Plan.Id)))
            {
                return;
            }

            await _organizationService.EnableAsync(organizationId.Value, subscription.CurrentPeriodEnd);
            var organization = await _organizationRepository.GetByIdAsync(organizationId.Value);

            await _referenceEventService.RaiseEventAsync(
                new ReferenceEvent(ReferenceEventType.Rebilled, organization, _currentContext)
                {
                    PlanName = organization?.Plan,
                    PlanType = organization?.PlanType,
                    Seats = organization?.Seats,
                    Storage = organization?.MaxStorageGb,
                });
        }
        else if (userId.HasValue)
        {
            if (subscription.Items.All(i => i.Plan.Id != PremiumPlanId))
            {
                return;
            }

            await _userService.EnablePremiumAsync(userId.Value, subscription.CurrentPeriodEnd);

            var user = await _userRepository.GetByIdAsync(userId.Value);
            await _referenceEventService.RaiseEventAsync(
                new ReferenceEvent(ReferenceEventType.Rebilled, user, _currentContext)
                {
                    PlanName = PremiumPlanId,
                    Storage = user?.MaxStorageGb,
                });
        }
    }

    /// <summary>
    /// Handles the <see cref="HandledStripeWebhook.ChargeRefunded"/> event type from Stripe.
    /// </summary>
    /// <param name="parsedEvent"></param>
    private async Task HandleChargeRefundedEventAsync(Event parsedEvent)
    {
        var charge = await _stripeEventService.GetCharge(parsedEvent, true, ["refunds"]);
        var parentTransaction = await _transactionRepository.GetByGatewayIdAsync(GatewayType.Stripe, charge.Id);
        if (parentTransaction == null)
        {
            // Attempt to create a transaction for the charge if it doesn't exist
            var (organizationId, userId, providerId) = await GetEntityIdsFromChargeAsync(charge);
            var tx = FromChargeToTransaction(charge, organizationId, userId, providerId);
            try
            {
                parentTransaction = await _transactionRepository.CreateAsync(tx);
            }
            catch (SqlException e) when (e.Number == 547) // FK constraint violation
            {
                _logger.LogWarning(
                    "Charge refund could not create transaction as entity may have been deleted. {ChargeId}",
                    charge.Id);
                return;
            }
        }

        var amountRefunded = charge.AmountRefunded / 100M;

        if (parentTransaction.Refunded.GetValueOrDefault() ||
            parentTransaction.RefundedAmount.GetValueOrDefault() >= amountRefunded)
        {
            _logger.LogWarning(
                "Charge refund amount doesn't match parent transaction's amount or parent has already been refunded. {ChargeId}",
                charge.Id);
            return;
        }

        parentTransaction.RefundedAmount = amountRefunded;
        if (charge.Refunded)
        {
            parentTransaction.Refunded = true;
        }

        await _transactionRepository.ReplaceAsync(parentTransaction);

        foreach (var refund in charge.Refunds)
        {
            var refundTransaction = await _transactionRepository.GetByGatewayIdAsync(
                GatewayType.Stripe, refund.Id);
            if (refundTransaction != null)
            {
                continue;
            }

            await _transactionRepository.CreateAsync(new Transaction
            {
                Amount = refund.Amount / 100M,
                CreationDate = refund.Created,
                OrganizationId = parentTransaction.OrganizationId,
                UserId = parentTransaction.UserId,
                ProviderId = parentTransaction.ProviderId,
                Type = TransactionType.Refund,
                Gateway = GatewayType.Stripe,
                GatewayId = refund.Id,
                PaymentMethodType = parentTransaction.PaymentMethodType,
                Details = parentTransaction.Details
            });
        }
    }

    /// <summary>
    /// Handles the <see cref="HandledStripeWebhook.ChargeSucceeded"/> event type from Stripe.
    /// </summary>
    /// <param name="parsedEvent"></param>
    private async Task HandleChargeSucceededEventAsync(Event parsedEvent)
    {
        var charge = await _stripeEventService.GetCharge(parsedEvent);
        var existingTransaction = await _transactionRepository.GetByGatewayIdAsync(GatewayType.Stripe, charge.Id);
        if (existingTransaction is not null)
        {
            _logger.LogInformation("Charge success already processed. {ChargeId}", charge.Id);
            return;
        }

        var (organizationId, userId, providerId) = await GetEntityIdsFromChargeAsync(charge);
        if (!organizationId.HasValue && !userId.HasValue && !providerId.HasValue)
        {
            _logger.LogWarning("Charge success has no subscriber ids. {ChargeId}", charge.Id);
            return;
        }

        var transaction = FromChargeToTransaction(charge, organizationId, userId, providerId);
        if (!transaction.PaymentMethodType.HasValue)
        {
            _logger.LogWarning("Charge success from unsupported source/method. {ChargeId}", charge.Id);
            return;
        }

        try
        {
            await _transactionRepository.CreateAsync(transaction);
        }
        catch (SqlException e) when (e.Number == 547)
        {
            _logger.LogWarning(
                "Charge success could not create transaction as entity may have been deleted. {ChargeId}",
                charge.Id);
        }
    }

    /// <summary>
    /// Handles the <see cref="HandledStripeWebhook.UpcomingInvoice"/> event type from Stripe.
    /// </summary>
    /// <param name="parsedEvent"></param>
    /// <exception cref="Exception"></exception>
    private async Task HandleUpcomingInvoiceEventAsync(Event parsedEvent)
    {
        var invoice = await _stripeEventService.GetInvoice(parsedEvent);
        if (string.IsNullOrEmpty(invoice.SubscriptionId))
        {
            _logger.LogWarning("Received 'invoice.upcoming' Event with ID '{eventId}' that did not include a Subscription ID", parsedEvent.Id);
            return;
        }

        var subscription = await _stripeFacade.GetSubscription(invoice.SubscriptionId);

        if (subscription == null)
        {
            throw new Exception(
                $"Received null Subscription from Stripe for ID '{invoice.SubscriptionId}' while processing Event with ID '{parsedEvent.Id}'");
        }

        var pm5766AutomaticTaxIsEnabled = _featureService.IsEnabled(FeatureFlagKeys.PM5766AutomaticTax);
        if (pm5766AutomaticTaxIsEnabled)
        {
            var customerGetOptions = new CustomerGetOptions();
            customerGetOptions.AddExpand("tax");
            var customer = await _stripeFacade.GetCustomer(subscription.CustomerId, customerGetOptions);
            if (!subscription.AutomaticTax.Enabled &&
                customer.Tax?.AutomaticTax == StripeConstants.AutomaticTaxStatus.Supported)
            {
                subscription = await _stripeFacade.UpdateSubscription(subscription.Id,
                    new SubscriptionUpdateOptions
                    {
                        DefaultTaxRates = [],
                        AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true }
                    });
            }
        }

        var updatedSubscription = pm5766AutomaticTaxIsEnabled
            ? subscription
            : await VerifyCorrectTaxRateForCharge(invoice, subscription);

        var (organizationId, userId, providerId) = GetIdsFromMetadata(updatedSubscription.Metadata);

        var invoiceLineItemDescriptions = invoice.Lines.Select(i => i.Description).ToList();

        if (organizationId.HasValue)
        {
            if (IsSponsoredSubscription(updatedSubscription))
            {
                await _validateSponsorshipCommand.ValidateSponsorshipAsync(organizationId.Value);
            }

            var organization = await _organizationRepository.GetByIdAsync(organizationId.Value);

            if (organization == null || !OrgPlanForInvoiceNotifications(organization))
            {
                return;
            }

            await SendEmails(new List<string> { organization.BillingEmail });

            /*
             * TODO: https://bitwarden.atlassian.net/browse/PM-4862
             * Disabling this as part of a hot fix. It needs to check whether the organization
             * belongs to a Reseller provider and only send an email to the organization owners if it does.
             * It also requires a new email template as the current one contains too much billing information.
             */

            // var ownerEmails = await _organizationRepository.GetOwnerEmailAddressesById(organization.Id);

            // await SendEmails(ownerEmails);
        }
        else if (userId.HasValue)
        {
            var user = await _userService.GetUserByIdAsync(userId.Value);

            if (user?.Premium == true)
            {
                await SendEmails(new List<string> { user.Email });
            }
        }
        else if (providerId.HasValue)
        {
            var provider = await _providerRepository.GetByIdAsync(providerId.Value);

            if (provider == null)
            {
                _logger.LogError(
                    "Received invoice.Upcoming webhook ({EventID}) for Provider ({ProviderID}) that does not exist",
                    parsedEvent.Id,
                    providerId.Value);

                return;
            }

            await SendEmails(new List<string> { provider.BillingEmail });

        }

        return;

        /*
         * Sends emails to the given email addresses.
         */
        async Task SendEmails(IEnumerable<string> emails)
        {
            var validEmails = emails.Where(e => !string.IsNullOrEmpty(e));

            if (invoice.NextPaymentAttempt.HasValue)
            {
                await _mailService.SendInvoiceUpcoming(
                    validEmails,
                    invoice.AmountDue / 100M,
                    invoice.NextPaymentAttempt.Value,
                    invoiceLineItemDescriptions,
                    true);
            }
        }
    }

    /// <summary>
    /// Gets the organization or user ID from the metadata of a Stripe Charge object.
    /// </summary>
    /// <param name="charge"></param>
    /// <returns></returns>
    private async Task<(Guid?, Guid?, Guid?)> GetEntityIdsFromChargeAsync(Charge charge)
    {
        Guid? organizationId = null;
        Guid? userId = null;
        Guid? providerId = null;

        if (charge.InvoiceId != null)
        {
            var invoice = await _stripeFacade.GetInvoice(charge.InvoiceId);
            if (invoice?.SubscriptionId != null)
            {
                var subscription = await _stripeFacade.GetSubscription(invoice.SubscriptionId);
                (organizationId, userId, providerId) = GetIdsFromMetadata(subscription?.Metadata);
            }
        }

        if (organizationId.HasValue || userId.HasValue || providerId.HasValue)
        {
            return (organizationId, userId, providerId);
        }

        var subscriptions = await _stripeFacade.ListSubscriptions(new SubscriptionListOptions
        {
            Customer = charge.CustomerId
        });

        foreach (var subscription in subscriptions)
        {
            if (subscription.Status is StripeSubscriptionStatus.Canceled or StripeSubscriptionStatus.IncompleteExpired)
            {
                continue;
            }

            (organizationId, userId, providerId) = GetIdsFromMetadata(subscription.Metadata);

            if (organizationId.HasValue || userId.HasValue || providerId.HasValue)
            {
                return (organizationId, userId, providerId);
            }
        }

        return (null, null, null);
    }

    /// <summary>
    /// Converts a Stripe Charge object to a Bitwarden Transaction object.
    /// </summary>
    /// <param name="charge"></param>
    /// <param name="organizationId"></param>
    /// <param name="userId"></param>
    /// /// <param name="providerId"></param>
    /// <returns></returns>
    private static Transaction FromChargeToTransaction(Charge charge, Guid? organizationId, Guid? userId, Guid? providerId)
    {
        var transaction = new Transaction
        {
            Amount = charge.Amount / 100M,
            CreationDate = charge.Created,
            OrganizationId = organizationId,
            UserId = userId,
            ProviderId = providerId,
            Type = TransactionType.Charge,
            Gateway = GatewayType.Stripe,
            GatewayId = charge.Id
        };

        switch (charge.Source)
        {
            case Card card:
                {
                    transaction.PaymentMethodType = PaymentMethodType.Card;
                    transaction.Details = $"{card.Brand}, *{card.Last4}";
                    break;
                }
            case BankAccount bankAccount:
                {
                    transaction.PaymentMethodType = PaymentMethodType.BankAccount;
                    transaction.Details = $"{bankAccount.BankName}, *{bankAccount.Last4}";
                    break;
                }
            case Source { Card: not null } source:
                {
                    transaction.PaymentMethodType = PaymentMethodType.Card;
                    transaction.Details = $"{source.Card.Brand}, *{source.Card.Last4}";
                    break;
                }
            case Source { AchDebit: not null } source:
                {
                    transaction.PaymentMethodType = PaymentMethodType.BankAccount;
                    transaction.Details = $"{source.AchDebit.BankName}, *{source.AchDebit.Last4}";
                    break;
                }
            case Source source:
                {
                    if (source.AchCreditTransfer == null)
                    {
                        break;
                    }

                    var achCreditTransfer = source.AchCreditTransfer;

                    transaction.PaymentMethodType = PaymentMethodType.BankAccount;
                    transaction.Details = $"ACH => {achCreditTransfer.BankName}, {achCreditTransfer.AccountNumber}";

                    break;
                }
            default:
                {
                    if (charge.PaymentMethodDetails == null)
                    {
                        break;
                    }

                    if (charge.PaymentMethodDetails.Card != null)
                    {
                        var card = charge.PaymentMethodDetails.Card;
                        transaction.PaymentMethodType = PaymentMethodType.Card;
                        transaction.Details = $"{card.Brand?.ToUpperInvariant()}, *{card.Last4}";
                    }
                    else if (charge.PaymentMethodDetails.AchDebit != null)
                    {
                        var achDebit = charge.PaymentMethodDetails.AchDebit;
                        transaction.PaymentMethodType = PaymentMethodType.BankAccount;
                        transaction.Details = $"{achDebit.BankName}, *{achDebit.Last4}";
                    }
                    else if (charge.PaymentMethodDetails.AchCreditTransfer != null)
                    {
                        var achCreditTransfer = charge.PaymentMethodDetails.AchCreditTransfer;
                        transaction.PaymentMethodType = PaymentMethodType.BankAccount;
                        transaction.Details = $"ACH => {achCreditTransfer.BankName}, {achCreditTransfer.AccountNumber}";
                    }

                    break;
                }
        }

        return transaction;
    }

    /// <summary>
    /// Handles the <see cref="HandledStripeWebhook.PaymentMethodAttached"/> event type from Stripe.
    /// </summary>
    /// <param name="parsedEvent"></param>
    private async Task HandlePaymentMethodAttachedAsync(Event parsedEvent)
    {
        var paymentMethod = await _stripeEventService.GetPaymentMethod(parsedEvent);
        if (paymentMethod is null)
        {
            _logger.LogWarning("Attempted to handle the event payment_method.attached but paymentMethod was null");
            return;
        }

        var subscriptionListOptions = new SubscriptionListOptions
        {
            Customer = paymentMethod.CustomerId,
            Status = StripeSubscriptionStatus.Unpaid,
            Expand = ["data.latest_invoice"]
        };

        StripeList<Subscription> unpaidSubscriptions;
        try
        {
            unpaidSubscriptions = await _stripeFacade.ListSubscriptions(subscriptionListOptions);
        }
        catch (Exception e)
        {
            _logger.LogError(e,
                "Attempted to get unpaid invoices for customer {CustomerId} but encountered an error while calling Stripe",
                paymentMethod.CustomerId);

            return;
        }

        foreach (var unpaidSubscription in unpaidSubscriptions)
        {
            await AttemptToPayOpenSubscriptionAsync(unpaidSubscription);
        }
    }

    private async Task AttemptToPayOpenSubscriptionAsync(Subscription unpaidSubscription)
    {
        var latestInvoice = unpaidSubscription.LatestInvoice;

        if (unpaidSubscription.LatestInvoice is null)
        {
            _logger.LogWarning(
                "Attempted to pay unpaid subscription {SubscriptionId} but latest invoice didn't exist",
                unpaidSubscription.Id);

            return;
        }

        if (latestInvoice.Status != StripeInvoiceStatus.Open)
        {
            _logger.LogWarning(
                "Attempted to pay unpaid subscription {SubscriptionId} but latest invoice wasn't \"open\"",
                unpaidSubscription.Id);

            return;
        }

        try
        {
            await AttemptToPayInvoiceAsync(latestInvoice, true);
        }
        catch (Exception e)
        {
            _logger.LogError(e,
                "Attempted to pay open invoice {InvoiceId} on unpaid subscription {SubscriptionId} but encountered an error",
                latestInvoice.Id, unpaidSubscription.Id);
            throw;
        }
    }

    /// <summary>
    /// Gets the organizationId, userId, or providerId from the metadata of a Stripe Subscription object.
    /// </summary>
    /// <param name="metadata"></param>
    /// <returns></returns>
    private static Tuple<Guid?, Guid?, Guid?> GetIdsFromMetadata(Dictionary<string, string> metadata)
    {
        if (metadata == null || metadata.Count == 0)
        {
            return new Tuple<Guid?, Guid?, Guid?>(null, null, null);
        }

        metadata.TryGetValue("organizationId", out var orgIdString);
        metadata.TryGetValue("userId", out var userIdString);
        metadata.TryGetValue("providerId", out var providerIdString);

        orgIdString ??= metadata.FirstOrDefault(x =>
            x.Key.Equals("organizationId", StringComparison.OrdinalIgnoreCase)).Value;

        userIdString ??= metadata.FirstOrDefault(x =>
            x.Key.Equals("userId", StringComparison.OrdinalIgnoreCase)).Value;

        providerIdString ??= metadata.FirstOrDefault(x =>
            x.Key.Equals("providerId", StringComparison.OrdinalIgnoreCase)).Value;

        Guid? organizationId = string.IsNullOrWhiteSpace(orgIdString) ? null : new Guid(orgIdString);
        Guid? userId = string.IsNullOrWhiteSpace(userIdString) ? null : new Guid(userIdString);
        Guid? providerId = string.IsNullOrWhiteSpace(providerIdString) ? null : new Guid(providerIdString);

        return new Tuple<Guid?, Guid?, Guid?>(organizationId, userId, providerId);
    }

    private static bool OrgPlanForInvoiceNotifications(Organization org) => StaticStore.GetPlan(org.PlanType).IsAnnual;

    private async Task<bool> AttemptToPayInvoiceAsync(Invoice invoice, bool attemptToPayWithStripe = false)
    {
        var customer = await _stripeFacade.GetCustomer(invoice.CustomerId);

        if (customer?.Metadata?.ContainsKey("btCustomerId") ?? false)
        {
            return await AttemptToPayInvoiceWithBraintreeAsync(invoice, customer);
        }

        if (attemptToPayWithStripe)
        {
            return await AttemptToPayInvoiceWithStripeAsync(invoice);
        }

        return false;
    }

    private async Task<bool> AttemptToPayInvoiceWithBraintreeAsync(Invoice invoice, Customer customer)
    {
        _logger.LogDebug("Attempting to pay invoice with Braintree");
        if (!customer?.Metadata?.ContainsKey("btCustomerId") ?? true)
        {
            _logger.LogWarning(
                "Attempted to pay invoice with Braintree but btCustomerId wasn't on Stripe customer metadata");
            return false;
        }

        var subscription = await _stripeFacade.GetSubscription(invoice.SubscriptionId);
        var (organizationId, userId, providerId) = GetIdsFromMetadata(subscription?.Metadata);
        if (!organizationId.HasValue && !userId.HasValue)
        {
            _logger.LogWarning(
                "Attempted to pay invoice with Braintree but Stripe subscription metadata didn't contain either a organizationId or userId");
            return false;
        }

        var orgTransaction = organizationId.HasValue;
        var btObjIdField = orgTransaction ? "organization_id" : "user_id";
        var btObjId = organizationId ?? userId.Value;
        var btInvoiceAmount = invoice.AmountDue / 100M;

        var existingTransactions = orgTransaction ?
            await _transactionRepository.GetManyByOrganizationIdAsync(organizationId.Value) :
            await _transactionRepository.GetManyByUserIdAsync(userId.Value);
        var duplicateTimeSpan = TimeSpan.FromHours(24);
        var now = DateTime.UtcNow;
        var duplicateTransaction = existingTransactions?
            .FirstOrDefault(t => (now - t.CreationDate) < duplicateTimeSpan);
        if (duplicateTransaction != null)
        {
            _logger.LogWarning("There is already a recent PayPal transaction ({0}). " +
                "Do not charge again to prevent possible duplicate.", duplicateTransaction.GatewayId);
            return false;
        }

        Result<Braintree.Transaction> transactionResult;
        try
        {
            transactionResult = await _btGateway.Transaction.SaleAsync(
                new Braintree.TransactionRequest
                {
                    Amount = btInvoiceAmount,
                    CustomerId = customer.Metadata["btCustomerId"],
                    Options = new Braintree.TransactionOptionsRequest
                    {
                        SubmitForSettlement = true,
                        PayPal = new Braintree.TransactionOptionsPayPalRequest
                        {
                            CustomField =
                                $"{btObjIdField}:{btObjId},region:{_globalSettings.BaseServiceUri.CloudRegion}"
                        }
                    },
                    CustomFields = new Dictionary<string, string>
                    {
                        [btObjIdField] = btObjId.ToString(),
                        ["region"] = _globalSettings.BaseServiceUri.CloudRegion
                    }
                });
        }
        catch (NotFoundException e)
        {
            _logger.LogError(e,
                "Attempted to make a payment with Braintree, but customer did not exist for the given btCustomerId present on the Stripe metadata");
            throw;
        }

        if (!transactionResult.IsSuccess())
        {
            if (invoice.AttemptCount < 4)
            {
                await _mailService.SendPaymentFailedAsync(customer.Email, btInvoiceAmount, true);
            }
            return false;
        }

        try
        {
            await _stripeFacade.UpdateInvoice(invoice.Id, new InvoiceUpdateOptions
            {
                Metadata = new Dictionary<string, string>
                {
                    ["btTransactionId"] = transactionResult.Target.Id,
                    ["btPayPalTransactionId"] =
                        transactionResult.Target.PayPalDetails?.AuthorizationId
                }
            });
            await _stripeFacade.PayInvoice(invoice.Id, new InvoicePayOptions { PaidOutOfBand = true });
        }
        catch (Exception e)
        {
            await _btGateway.Transaction.RefundAsync(transactionResult.Target.Id);
            if (e.Message.Contains("Invoice is already paid"))
            {
                await _stripeFacade.UpdateInvoice(invoice.Id, new InvoiceUpdateOptions
                {
                    Metadata = invoice.Metadata
                });
            }
            else
            {
                throw;
            }
        }

        return true;
    }

    private async Task<bool> AttemptToPayInvoiceWithStripeAsync(Invoice invoice)
    {
        try
        {
            await _stripeFacade.PayInvoice(invoice.Id);
            return true;
        }
        catch (Exception e)
        {
            _logger.LogWarning(
                e,
                "Exception occurred while trying to pay Stripe invoice with Id: {InvoiceId}",
                invoice.Id);

            throw;
        }
    }

    private static bool ShouldAttemptToPayInvoice(Invoice invoice) =>
        invoice is
        {
            AmountDue: > 0,
            Paid: false,
            CollectionMethod: "charge_automatically",
            BillingReason: "subscription_cycle" or "automatic_pending_invoice_item_invoice",
            SubscriptionId: not null
        };

    private async Task<Subscription> VerifyCorrectTaxRateForCharge(Invoice invoice, Subscription subscription)
    {
        if (string.IsNullOrWhiteSpace(invoice?.CustomerAddress?.Country) ||
            string.IsNullOrWhiteSpace(invoice?.CustomerAddress?.PostalCode))
        {
            return subscription;
        }

        var localBitwardenTaxRates = await _taxRateRepository.GetByLocationAsync(
            new TaxRate()
            {
                Country = invoice.CustomerAddress.Country,
                PostalCode = invoice.CustomerAddress.PostalCode
            }
        );

        if (!localBitwardenTaxRates.Any())
        {
            return subscription;
        }

        var stripeTaxRate = await _stripeFacade.GetTaxRate(localBitwardenTaxRates.First().Id);
        if (stripeTaxRate == null || subscription.DefaultTaxRates.Any(x => x == stripeTaxRate))
        {
            return subscription;
        }

        subscription.DefaultTaxRates = [stripeTaxRate];

        var subscriptionOptions = new SubscriptionUpdateOptions { DefaultTaxRates = [stripeTaxRate.Id] };
        subscription = await _stripeFacade.UpdateSubscription(subscription.Id, subscriptionOptions);

        return subscription;
    }

    private static bool IsSponsoredSubscription(Subscription subscription) =>
        StaticStore.SponsoredPlans.Any(p => p.StripePlanId == subscription.Id);

    /// <summary>
    /// Handles the <see cref="HandledStripeWebhook.PaymentFailed"/> event type from Stripe.
    /// </summary>
    /// <param name="parsedEvent"></param>
    private async Task HandlePaymentFailedEventAsync(Event parsedEvent)
    {
        var invoice = await _stripeEventService.GetInvoice(parsedEvent, true);
        if (invoice.Paid || invoice.AttemptCount <= 1 || !ShouldAttemptToPayInvoice(invoice))
        {
            return;
        }

        var subscription = await _stripeFacade.GetSubscription(invoice.SubscriptionId);
        // attempt count 4 = 11 days after initial failure
        if (invoice.AttemptCount <= 3 ||
            !subscription.Items.Any(i => i.Price.Id is PremiumPlanId or PremiumPlanIdAppStore))
        {
            await AttemptToPayInvoiceAsync(invoice);
        }
    }

    private async Task CancelSubscription(string subscriptionId) =>
        await _stripeFacade.CancelSubscription(subscriptionId, new SubscriptionCancelOptions());

    private async Task VoidOpenInvoices(string subscriptionId)
    {
        var options = new InvoiceListOptions
        {
            Status = StripeInvoiceStatus.Open,
            Subscription = subscriptionId
        };
        var invoices = await _stripeFacade.ListInvoices(options);
        foreach (var invoice in invoices)
        {
            await _stripeFacade.VoidInvoice(invoice.Id);
        }
    }

    private string PickStripeWebhookSecret(string webhookBody)
    {
        var versionContainer = JsonSerializer.Deserialize<StripeWebhookVersionContainer>(webhookBody);

        return versionContainer.ApiVersion switch
        {
            "2023-10-16" => _billingSettings.StripeWebhookSecret20231016,
            "2022-08-01" => _billingSettings.StripeWebhookSecret,
            _ => HandleDefault(versionContainer.ApiVersion)
        };

        string HandleDefault(string version)
        {
            _logger.LogWarning(
                "Stripe webhook contained an recognized 'api_version': {ApiVersion}",
                version);

            return null;
        }
    }

    /// <summary>
    /// Attempts to pick the Stripe webhook secret from the JSON payload.
    /// </summary>
    /// <returns>Returns the event if the event was parsed, otherwise, null</returns>
    private async Task<Event> TryParseEventFromRequestBodyAsync()
    {
        using var sr = new StreamReader(HttpContext.Request.Body);

        var json = await sr.ReadToEndAsync();
        var webhookSecret = PickStripeWebhookSecret(json);

        if (string.IsNullOrEmpty(webhookSecret))
        {
            _logger.LogDebug("Unable to parse event. No webhook secret.");
            return null;
        }

        var parsedEvent = EventUtility.ConstructEvent(
            json,
            Request.Headers["Stripe-Signature"],
            webhookSecret,
            throwOnApiVersionMismatch: false);

        if (parsedEvent is not null)
        {
            return parsedEvent;
        }

        _logger.LogDebug("Stripe-Signature request header doesn't match configured Stripe webhook secret");
        return null;
    }
}