using System.Globalization;
using System.Net;
using System.Text;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Infrastructure;
using Microsoft.AspNetCore.Mvc.Routing;
using Nop.Core;
using Nop.Core.Domain;
using Nop.Core.Domain.Blogs;
using Nop.Core.Domain.Catalog;
using Nop.Core.Domain.Common;
using Nop.Core.Domain.Customers;
using Nop.Core.Domain.Directory;
using Nop.Core.Domain.Forums;
using Nop.Core.Domain.Localization;
using Nop.Core.Domain.Messages;
using Nop.Core.Domain.News;
using Nop.Core.Domain.Orders;
using Nop.Core.Domain.Payments;
using Nop.Core.Domain.Shipping;
using Nop.Core.Domain.Stores;
using Nop.Core.Domain.Tax;
using Nop.Core.Domain.Vendors;
using Nop.Core.Events;
using Nop.Core.Http;
using Nop.Core.Infrastructure;
using Nop.Services.Attributes;
using Nop.Services.Blogs;
using Nop.Services.Catalog;
using Nop.Services.Common;
using Nop.Services.Customers;
using Nop.Services.Directory;
using Nop.Services.Forums;
using Nop.Services.Helpers;
using Nop.Services.Html;
using Nop.Services.Localization;
using Nop.Services.Logging;
using Nop.Services.News;
using Nop.Services.Orders;
using Nop.Services.Payments;
using Nop.Services.Seo;
using Nop.Services.Shipping;
using Nop.Services.Stores;
using Nop.Services.Vendors;

namespace Nop.Services.Messages;

/// <summary>
/// Message token provider
/// </summary>
public partial class MessageTokenProvider : IMessageTokenProvider
{
    #region Fields

    protected readonly CatalogSettings _catalogSettings;
    protected readonly CurrencySettings _currencySettings;
    protected readonly IActionContextAccessor _actionContextAccessor;
    protected readonly IAddressService _addressService;
    protected readonly IAttributeFormatter<AddressAttribute, AddressAttributeValue> _addressAttributeFormatter;
    protected readonly IAttributeFormatter<CustomerAttribute, CustomerAttributeValue> _customerAttributeFormatter;
    protected readonly IAttributeFormatter<VendorAttribute, VendorAttributeValue> _vendorAttributeFormatter;
    protected readonly IBlogService _blogService;
    protected readonly ICountryService _countryService;
    protected readonly ICurrencyService _currencyService;
    protected readonly ICustomerService _customerService;
    protected readonly IDateTimeHelper _dateTimeHelper;
    protected readonly IEventPublisher _eventPublisher;
    protected readonly IGenericAttributeService _genericAttributeService;
    protected readonly IGiftCardService _giftCardService;
    protected readonly IHtmlFormatter _htmlFormatter;
    protected readonly ILanguageService _languageService;
    protected readonly ILocalizationService _localizationService;
    protected readonly ILogger _logger;
    protected readonly INewsService _newsService;
    protected readonly IOrderService _orderService;
    protected readonly IPaymentPluginManager _paymentPluginManager;
    protected readonly IPaymentService _paymentService;
    protected readonly IPriceFormatter _priceFormatter;
    protected readonly IProductService _productService;
    protected readonly IRewardPointService _rewardPointService;
    protected readonly IShipmentService _shipmentService;
    protected readonly IStateProvinceService _stateProvinceService;
    protected readonly IStoreContext _storeContext;
    protected readonly IStoreService _storeService;
    protected readonly IUrlHelperFactory _urlHelperFactory;
    protected readonly IUrlRecordService _urlRecordService;
    protected readonly IWorkContext _workContext;
    protected readonly MessageTemplatesSettings _templatesSettings;
    protected readonly PaymentSettings _paymentSettings;
    protected readonly StoreInformationSettings _storeInformationSettings;
    protected readonly TaxSettings _taxSettings;

    protected Dictionary<string, IEnumerable<string>> _allowedTokens;

    #endregion

    #region Ctor

    public MessageTokenProvider(CatalogSettings catalogSettings,
        CurrencySettings currencySettings,
        IActionContextAccessor actionContextAccessor,
        IAddressService addressService,
        IAttributeFormatter<AddressAttribute, AddressAttributeValue> addressAttributeFormatter,
        IAttributeFormatter<CustomerAttribute, CustomerAttributeValue> customerAttributeFormatter,
        IAttributeFormatter<VendorAttribute, VendorAttributeValue> vendorAttributeFormatter,
        IBlogService blogService,
        ICountryService countryService,
        ICurrencyService currencyService,
        ICustomerService customerService,
        IDateTimeHelper dateTimeHelper,
        IEventPublisher eventPublisher,
        IGenericAttributeService genericAttributeService,
        IGiftCardService giftCardService,
        IHtmlFormatter htmlFormatter,
        ILanguageService languageService,
        ILocalizationService localizationService,
        ILogger logger,
        INewsService newsService,
        IOrderService orderService,
        IPaymentPluginManager paymentPluginManager,
        IPaymentService paymentService,
        IPriceFormatter priceFormatter,
        IProductService productService,
        IRewardPointService rewardPointService,
        IShipmentService shipmentService,
        IStateProvinceService stateProvinceService,
        IStoreContext storeContext,
        IStoreService storeService,
        IUrlHelperFactory urlHelperFactory,
        IUrlRecordService urlRecordService,
        IWorkContext workContext,
        MessageTemplatesSettings templatesSettings,
        PaymentSettings paymentSettings,
        StoreInformationSettings storeInformationSettings,
        TaxSettings taxSettings)
    {
        _catalogSettings = catalogSettings;
        _currencySettings = currencySettings;
        _actionContextAccessor = actionContextAccessor;
        _addressService = addressService;
        _addressAttributeFormatter = addressAttributeFormatter;
        _customerAttributeFormatter = customerAttributeFormatter;
        _vendorAttributeFormatter = vendorAttributeFormatter;
        _blogService = blogService;
        _countryService = countryService;
        _currencyService = currencyService;
        _customerService = customerService;
        _dateTimeHelper = dateTimeHelper;
        _eventPublisher = eventPublisher;
        _genericAttributeService = genericAttributeService;
        _giftCardService = giftCardService;
        _htmlFormatter = htmlFormatter;
        _languageService = languageService;
        _localizationService = localizationService;
        _logger = logger;
        _newsService = newsService;
        _orderService = orderService;
        _paymentPluginManager = paymentPluginManager;
        _paymentService = paymentService;
        _priceFormatter = priceFormatter;
        _productService = productService;
        _rewardPointService = rewardPointService;
        _shipmentService = shipmentService;
        _stateProvinceService = stateProvinceService;
        _storeContext = storeContext;
        _storeService = storeService;
        _urlHelperFactory = urlHelperFactory;
        _urlRecordService = urlRecordService;
        _workContext = workContext;
        _templatesSettings = templatesSettings;
        _paymentSettings = paymentSettings;
        _storeInformationSettings = storeInformationSettings;
        _taxSettings = taxSettings;
    }

    #endregion

    #region Allowed tokens

    /// <summary>
    /// Get all available tokens by token groups
    /// </summary>
    protected Dictionary<string, IEnumerable<string>> AllowedTokens
    {
        get
        {
            if (_allowedTokens != null)
                return _allowedTokens;

            _allowedTokens = new Dictionary<string, IEnumerable<string>>
            {
                //store tokens
                {
                    TokenGroupNames.StoreTokens,
                    new[]
                    {
                        "%Store.Name%",
                        "%Store.URL%",
                        "%Store.Email%",
                        "%Store.CompanyName%",
                        "%Store.CompanyAddress%",
                        "%Store.CompanyPhoneNumber%",
                        "%Store.CompanyVat%",
                        "%Facebook.URL%",
                        "%Twitter.URL%",
                        "%YouTube.URL%",
                        "%Instagram.URL%"
                    }
                },

                //customer tokens
                {
                    TokenGroupNames.CustomerTokens,
                    new[]
                    {
                        "%Customer.Email%",
                        "%Customer.Username%",
                        "%Customer.FullName%",
                        "%Customer.FirstName%",
                        "%Customer.LastName%",
                        "%Customer.VatNumber%",
                        "%Customer.VatNumberStatus%",
                        "%Customer.CustomAttributes%",
                        "%Customer.PasswordRecoveryURL%",
                        "%Customer.AccountActivationURL%",
                        "%Customer.EmailRevalidationURL%",
                        "%Wishlist.URLForCustomer%"
                    }
                },

                //order tokens
                {
                    TokenGroupNames.OrderTokens,
                    new[]
                    {
                        "%Order.OrderNumber%",
                        "%Order.CustomerFullName%",
                        "%Order.CustomerEmail%",
                        "%Order.BillingFirstName%",
                        "%Order.BillingLastName%",
                        "%Order.BillingPhoneNumber%",
                        "%Order.BillingEmail%",
                        "%Order.BillingFaxNumber%",
                        "%Order.BillingCompany%",
                        "%Order.BillingAddress1%",
                        "%Order.BillingAddress2%",
                        "%Order.BillingCity%",
                        "%Order.BillingCounty%",
                        "%Order.BillingStateProvince%",
                        "%Order.BillingZipPostalCode%",
                        "%Order.BillingCountry%",
                        "%Order.BillingCustomAttributes%",
                        "%Order.BillingAddressLine%",
                        "%Order.Shippable%",
                        "%Order.ShippingMethod%",
                        "%Order.ShippingFirstName%",
                        "%Order.ShippingLastName%",
                        "%Order.ShippingPhoneNumber%",
                        "%Order.ShippingEmail%",
                        "%Order.ShippingFaxNumber%",
                        "%Order.ShippingCompany%",
                        "%Order.ShippingAddress1%",
                        "%Order.ShippingAddress2%",
                        "%Order.ShippingCity%",
                        "%Order.ShippingCounty%",
                        "%Order.ShippingStateProvince%",
                        "%Order.ShippingZipPostalCode%",
                        "%Order.ShippingCountry%",
                        "%Order.ShippingCustomAttributes%",
                        "%Order.ShippingAddressLine%",
                        "%Order.PaymentMethod%",
                        "%Order.VatNumber%",
                        "%Order.CustomValues%",
                        "%Order.Product(s)%",
                        "%Order.CreatedOn%",
                        "%Order.OrderURLForCustomer%",
                        "%Order.PickupInStore%",
                        "%Order.OrderId%",
                        "%Order.IsCompletelyShipped%",
                        "%Order.IsCompletelyReadyForPickup%",
                        "%Order.IsCompletelyDelivered%"
                    }
                },

                //shipment tokens
                {
                    TokenGroupNames.ShipmentTokens,
                    new[]
                    {
                        "%Shipment.ShipmentNumber%",
                        "%Shipment.TrackingNumber%",
                        "%Shipment.TrackingNumberURL%",
                        "%Shipment.Product(s)%",
                        "%Shipment.URLForCustomer%"
                    }
                },

                //refunded order tokens
                {
                    TokenGroupNames.RefundedOrderTokens,
                    new[]
                    {
                        "%Order.AmountRefunded%"
                    }
                },

                //order note tokens
                {
                    TokenGroupNames.OrderNoteTokens,
                    new[]
                    {
                        "%Order.NewNoteText%",
                        "%Order.OrderNoteAttachmentUrl%"
                    }
                },

                //recurring payment tokens
                {
                    TokenGroupNames.RecurringPaymentTokens,
                    new[]
                    {
                        "%RecurringPayment.ID%",
                        "%RecurringPayment.CancelAfterFailedPayment%",
                        "%RecurringPayment.RecurringPaymentType%"
                    }
                },

                //newsletter subscription tokens
                {
                    TokenGroupNames.SubscriptionTokens,
                    new[]
                    {
                        "%NewsLetterSubscription.Email%",
                        "%NewsLetterSubscription.ActivationUrl%",
                        "%NewsLetterSubscription.DeactivationUrl%"
                    }
                },

                //product tokens
                {
                    TokenGroupNames.ProductTokens,
                    new[]
                    {
                        "%Product.ID%",
                        "%Product.Name%",
                        "%Product.ShortDescription%",
                        "%Product.ProductURLForCustomer%",
                        "%Product.SKU%",
                        "%Product.StockQuantity%"
                    }
                },

                //return request tokens
                {
                    TokenGroupNames.ReturnRequestTokens,
                    new[]
                    {
                        "%ReturnRequest.CustomNumber%",
                        "%ReturnRequest.OrderId%",
                        "%ReturnRequest.Product.Quantity%",
                        "%ReturnRequest.Product.Name%",
                        "%ReturnRequest.Reason%",
                        "%ReturnRequest.RequestedAction%",
                        "%ReturnRequest.CustomerComment%",
                        "%ReturnRequest.StaffNotes%",
                        "%ReturnRequest.Status%"
                    }
                },

                //forum tokens
                {
                    TokenGroupNames.ForumTokens,
                    new[]
                    {
                        "%Forums.ForumURL%",
                        "%Forums.ForumName%"
                    }
                },

                //forum topic tokens
                {
                    TokenGroupNames.ForumTopicTokens,
                    new[]
                    {
                        "%Forums.TopicURL%",
                        "%Forums.TopicName%"
                    }
                },

                //forum post tokens
                {
                    TokenGroupNames.ForumPostTokens,
                    new[]
                    {
                        "%Forums.PostAuthor%",
                        "%Forums.PostBody%"
                    }
                },

                //private message tokens
                {
                    TokenGroupNames.PrivateMessageTokens,
                    new[]
                    {
                        "%PrivateMessage.Subject%",
                        "%PrivateMessage.Text%"
                    }
                },

                //vendor tokens
                {
                    TokenGroupNames.VendorTokens,
                    new[]
                    {
                        "%Vendor.Name%",
                        "%Vendor.Email%",
                        "%Vendor.VendorAttributes%"
                    }
                },

                //gift card tokens
                {
                    TokenGroupNames.GiftCardTokens,
                    new[]
                    {
                        "%GiftCard.SenderName%",
                        "%GiftCard.SenderEmail%",
                        "%GiftCard.RecipientName%",
                        "%GiftCard.RecipientEmail%",
                        "%GiftCard.Amount%",
                        "%GiftCard.CouponCode%",
                        "%GiftCard.Message%"
                    }
                },

                //product review tokens
                {
                    TokenGroupNames.ProductReviewTokens,
                    new[]
                    {
                        "%ProductReview.ProductName%",
                        "%ProductReview.Title%",
                        "%ProductReview.IsApproved%",
                        "%ProductReview.ReviewText%",
                        "%ProductReview.ReplyText%"
                    }
                },

                //attribute combination tokens
                {
                    TokenGroupNames.AttributeCombinationTokens,
                    new[]
                    {
                        "%AttributeCombination.Formatted%",
                        "%AttributeCombination.SKU%",
                        "%AttributeCombination.StockQuantity%"
                    }
                },

                //blog comment tokens
                {
                    TokenGroupNames.BlogCommentTokens,
                    new[]
                    {
                        "%BlogComment.BlogPostTitle%"
                    }
                },

                //news comment tokens
                {
                    TokenGroupNames.NewsCommentTokens,
                    new[]
                    {
                        "%NewsComment.NewsTitle%"
                    }
                },

                //product back in stock tokens
                {
                    TokenGroupNames.ProductBackInStockTokens,
                    new[]
                    {
                        "%BackInStockSubscription.ProductName%",
                        "%BackInStockSubscription.ProductUrl%"
                    }
                },

                //email a friend tokens
                {
                    TokenGroupNames.EmailAFriendTokens,
                    new[]
                    {
                        "%EmailAFriend.PersonalMessage%",
                        "%EmailAFriend.Email%"
                    }
                },

                //wishlist to friend tokens
                {
                    TokenGroupNames.WishlistToFriendTokens,
                    new[]
                    {
                        "%Wishlist.PersonalMessage%",
                        "%Wishlist.Email%"
                    }
                },

                //VAT validation tokens
                {
                    TokenGroupNames.VatValidation,
                    new[]
                    {
                        "%VatValidationResult.Name%",
                        "%VatValidationResult.Address%"
                    }
                },

                //contact us tokens
                {
                    TokenGroupNames.ContactUs,
                    new[]
                    {
                        "%ContactUs.SenderEmail%",
                        "%ContactUs.SenderName%",
                        "%ContactUs.Body%"
                    }
                },

                //contact vendor tokens
                {
                    TokenGroupNames.ContactVendor,
                    new[]
                    {
                        "%ContactUs.SenderEmail%",
                        "%ContactUs.SenderName%",
                        "%ContactUs.Body%"
                    }
                }
            };

            return _allowedTokens;
        }
    }

    #endregion

    #region Utilities

    /// <summary>
    /// Convert a collection to a HTML table
    /// </summary>
    /// <param name="order">Order</param>
    /// <param name="languageId">Language identifier</param>
    /// <param name="vendorId">Vendor identifier (used to limit products by vendor</param>
    /// <returns>
    /// A task that represents the asynchronous operation
    /// The task result contains the hTML table of products
    /// </returns>
    protected virtual async Task<string> ProductListToHtmlTableAsync(Order order, int languageId, int vendorId)
    {
        var language = await _languageService.GetLanguageByIdAsync(languageId);

        var sb = new StringBuilder();
        sb.AppendLine("<table border=\"0\" style=\"width:100%;\">");

        sb.AppendLine($"<tr style=\"background-color:{_templatesSettings.Color1};text-align:center;\">");
        sb.AppendLine($"<th>{await _localizationService.GetResourceAsync("Messages.Order.Product(s).Name", languageId)}</th>");
        sb.AppendLine($"<th>{await _localizationService.GetResourceAsync("Messages.Order.Product(s).Price", languageId)}</th>");
        sb.AppendLine($"<th>{await _localizationService.GetResourceAsync("Messages.Order.Product(s).Quantity", languageId)}</th>");
        sb.AppendLine($"<th>{await _localizationService.GetResourceAsync("Messages.Order.Product(s).Total", languageId)}</th>");
        sb.AppendLine("</tr>");

        var table = await _orderService.GetOrderItemsAsync(order.Id, vendorId: vendorId);
        for (var i = 0; i <= table.Count - 1; i++)
        {
            var orderItem = table[i];

            var product = await _productService.GetProductByIdAsync(orderItem.ProductId);

            if (product == null)
                continue;

            sb.AppendLine($"<tr style=\"background-color: {_templatesSettings.Color2};text-align: center;\">");
            //product name
            var productName = await _localizationService.GetLocalizedAsync(product, x => x.Name, languageId);

            sb.AppendLine("<td style=\"padding: 0.6em 0.4em;text-align: left;\">" + WebUtility.HtmlEncode(productName));

            //add download link
            if (await _orderService.IsDownloadAllowedAsync(orderItem))
            {
                var downloadUrl = await RouteUrlAsync(order.StoreId, NopRouteNames.Standard.GET_DOWNLOAD, new { orderItemId = orderItem.OrderItemGuid });
                var downloadLink = $"<a class=\"link\" href=\"{downloadUrl}\">{await _localizationService.GetResourceAsync("Messages.Order.Product(s).Download", languageId)}</a>";
                sb.AppendLine("<br />");
                sb.AppendLine(downloadLink);
            }
            //add download link
            if (await _orderService.IsLicenseDownloadAllowedAsync(orderItem))
            {
                var licenseUrl = await RouteUrlAsync(order.StoreId, NopRouteNames.Standard.GET_LICENSE, new { orderItemId = orderItem.OrderItemGuid });
                var licenseLink = $"<a class=\"link\" href=\"{licenseUrl}\">{await _localizationService.GetResourceAsync("Messages.Order.Product(s).License", languageId)}</a>";
                sb.AppendLine("<br />");
                sb.AppendLine(licenseLink);
            }
            //attributes
            if (!string.IsNullOrEmpty(orderItem.AttributeDescription))
            {
                sb.AppendLine("<br />");
                sb.AppendLine(orderItem.AttributeDescription);
            }
            //rental info
            if (product.IsRental)
            {
                var rentalStartDate = orderItem.RentalStartDateUtc.HasValue
                    ? _productService.FormatRentalDate(product, orderItem.RentalStartDateUtc.Value) : string.Empty;
                var rentalEndDate = orderItem.RentalEndDateUtc.HasValue
                    ? _productService.FormatRentalDate(product, orderItem.RentalEndDateUtc.Value) : string.Empty;
                var rentalInfo = string.Format(await _localizationService.GetResourceAsync("Order.Rental.FormattedDate", languageId),
                    rentalStartDate, rentalEndDate);
                sb.AppendLine("<br />");
                sb.AppendLine(rentalInfo);
            }
            //SKU
            if (_catalogSettings.ShowSkuOnProductDetailsPage)
            {
                var sku = await _productService.FormatSkuAsync(product, orderItem.AttributesXml);
                if (!string.IsNullOrEmpty(sku))
                {
                    sb.AppendLine("<br />");
                    sb.AppendLine(string.Format(await _localizationService.GetResourceAsync("Messages.Order.Product(s).SKU", languageId), WebUtility.HtmlEncode(sku)));
                }
            }

            sb.AppendLine("</td>");

            string unitPriceStr;
            if (order.CustomerTaxDisplayType == TaxDisplayType.IncludingTax)
            {
                //including tax
                var unitPriceInclTaxInCustomerCurrency = _currencyService.ConvertCurrency(orderItem.UnitPriceInclTax, order.CurrencyRate);
                unitPriceStr = await _priceFormatter.FormatPriceAsync(unitPriceInclTaxInCustomerCurrency, true, order.CustomerCurrencyCode, languageId, true);
            }
            else
            {
                //excluding tax
                var unitPriceExclTaxInCustomerCurrency = _currencyService.ConvertCurrency(orderItem.UnitPriceExclTax, order.CurrencyRate);
                unitPriceStr = await _priceFormatter.FormatPriceAsync(unitPriceExclTaxInCustomerCurrency, true, order.CustomerCurrencyCode, languageId, false);
            }

            sb.AppendLine($"<td style=\"padding: 0.6em 0.4em;text-align: right;\">{unitPriceStr}</td>");

            sb.AppendLine($"<td style=\"padding: 0.6em 0.4em;text-align: center;\">{orderItem.Quantity}</td>");

            string priceStr;
            if (order.CustomerTaxDisplayType == TaxDisplayType.IncludingTax)
            {
                //including tax
                var priceInclTaxInCustomerCurrency = _currencyService.ConvertCurrency(orderItem.PriceInclTax, order.CurrencyRate);
                priceStr = await _priceFormatter.FormatPriceAsync(priceInclTaxInCustomerCurrency, true, order.CustomerCurrencyCode, languageId, true);
            }
            else
            {
                //excluding tax
                var priceExclTaxInCustomerCurrency = _currencyService.ConvertCurrency(orderItem.PriceExclTax, order.CurrencyRate);
                priceStr = await _priceFormatter.FormatPriceAsync(priceExclTaxInCustomerCurrency, true, order.CustomerCurrencyCode, languageId, false);
            }

            sb.AppendLine($"<td style=\"padding: 0.6em 0.4em;text-align: right;\">{priceStr}</td>");

            sb.AppendLine("</tr>");
        }

        if (vendorId == 0)
        {
            //we render checkout attributes and totals only for store owners (hide for vendors)

            if (!string.IsNullOrEmpty(order.CheckoutAttributeDescription))
            {
                sb.AppendLine("<tr><td style=\"text-align:right;\" colspan=\"1\">&nbsp;</td><td colspan=\"3\" style=\"text-align:right\">");
                sb.AppendLine(order.CheckoutAttributeDescription);
                sb.AppendLine("</td></tr>");
            }

            //totals
            await WriteTotalsAsync(order, language, sb);
        }

        sb.AppendLine("</table>");
        var result = sb.ToString();
        return result;
    }

    /// <summary>
    /// Write order totals
    /// </summary>
    /// <param name="order">Order</param>
    /// <param name="language">Language</param>
    /// <param name="sb">StringBuilder</param>
    /// <returns>A task that represents the asynchronous operation</returns>
    protected virtual async Task WriteTotalsAsync(Order order, Language language, StringBuilder sb)
    {
        //subtotal
        string cusSubTotal;
        var displaySubTotalDiscount = false;
        var cusSubTotalDiscount = string.Empty;
        var languageId = language.Id;
        if (order.CustomerTaxDisplayType == TaxDisplayType.IncludingTax && !_taxSettings.ForceTaxExclusionFromOrderSubtotal)
        {
            //including tax

            //subtotal
            var orderSubtotalInclTaxInCustomerCurrency = _currencyService.ConvertCurrency(order.OrderSubtotalInclTax, order.CurrencyRate);
            cusSubTotal = await _priceFormatter.FormatPriceAsync(orderSubtotalInclTaxInCustomerCurrency, true, order.CustomerCurrencyCode, languageId, true);
            //discount (applied to order subtotal)
            var orderSubTotalDiscountInclTaxInCustomerCurrency = _currencyService.ConvertCurrency(order.OrderSubTotalDiscountInclTax, order.CurrencyRate);
            if (orderSubTotalDiscountInclTaxInCustomerCurrency > decimal.Zero)
            {
                cusSubTotalDiscount = await _priceFormatter.FormatPriceAsync(-orderSubTotalDiscountInclTaxInCustomerCurrency, true, order.CustomerCurrencyCode, languageId, true);
                displaySubTotalDiscount = true;
            }
        }
        else
        {
            //excluding tax

            //subtotal
            var orderSubtotalExclTaxInCustomerCurrency = _currencyService.ConvertCurrency(order.OrderSubtotalExclTax, order.CurrencyRate);
            cusSubTotal = await _priceFormatter.FormatPriceAsync(orderSubtotalExclTaxInCustomerCurrency, true, order.CustomerCurrencyCode, languageId, false);
            //discount (applied to order subtotal)
            var orderSubTotalDiscountExclTaxInCustomerCurrency = _currencyService.ConvertCurrency(order.OrderSubTotalDiscountExclTax, order.CurrencyRate);
            if (orderSubTotalDiscountExclTaxInCustomerCurrency > decimal.Zero)
            {
                cusSubTotalDiscount = await _priceFormatter.FormatPriceAsync(-orderSubTotalDiscountExclTaxInCustomerCurrency, true, order.CustomerCurrencyCode, languageId, false);
                displaySubTotalDiscount = true;
            }
        }

        //shipping, payment method fee
        string cusShipTotal;
        string cusPaymentMethodAdditionalFee;
        var taxRates = new SortedDictionary<decimal, decimal>();
        var cusTaxTotal = string.Empty;
        var cusDiscount = string.Empty;
        if (order.CustomerTaxDisplayType == TaxDisplayType.IncludingTax)
        {
            //including tax

            //shipping
            var orderShippingInclTaxInCustomerCurrency = _currencyService.ConvertCurrency(order.OrderShippingInclTax, order.CurrencyRate);
            cusShipTotal = await _priceFormatter.FormatShippingPriceAsync(orderShippingInclTaxInCustomerCurrency, true, order.CustomerCurrencyCode, languageId, true);
            //payment method additional fee
            var paymentMethodAdditionalFeeInclTaxInCustomerCurrency = _currencyService.ConvertCurrency(order.PaymentMethodAdditionalFeeInclTax, order.CurrencyRate);
            cusPaymentMethodAdditionalFee = await _priceFormatter.FormatPaymentMethodAdditionalFeeAsync(paymentMethodAdditionalFeeInclTaxInCustomerCurrency, true, order.CustomerCurrencyCode, languageId, true);
        }
        else
        {
            //excluding tax

            //shipping
            var orderShippingExclTaxInCustomerCurrency = _currencyService.ConvertCurrency(order.OrderShippingExclTax, order.CurrencyRate);
            cusShipTotal = await _priceFormatter.FormatShippingPriceAsync(orderShippingExclTaxInCustomerCurrency, true, order.CustomerCurrencyCode, languageId, false);
            //payment method additional fee
            var paymentMethodAdditionalFeeExclTaxInCustomerCurrency = _currencyService.ConvertCurrency(order.PaymentMethodAdditionalFeeExclTax, order.CurrencyRate);
            cusPaymentMethodAdditionalFee = await _priceFormatter.FormatPaymentMethodAdditionalFeeAsync(paymentMethodAdditionalFeeExclTaxInCustomerCurrency, true, order.CustomerCurrencyCode, languageId, false);
        }

        //shipping
        var displayShipping = order.ShippingStatus != ShippingStatus.ShippingNotRequired;

        //payment method fee
        var displayPaymentMethodFee = order.PaymentMethodAdditionalFeeExclTax > decimal.Zero;

        //tax
        bool displayTax;
        bool displayTaxRates;
        if (_taxSettings.HideTaxInOrderSummary && order.CustomerTaxDisplayType == TaxDisplayType.IncludingTax)
        {
            displayTax = false;
            displayTaxRates = false;
        }
        else
        {
            if (order.OrderTax == 0 && _taxSettings.HideZeroTax)
            {
                displayTax = false;
                displayTaxRates = false;
            }
            else
            {
                taxRates = new SortedDictionary<decimal, decimal>();
                foreach (var tr in _orderService.ParseTaxRates(order, order.TaxRates))
                    taxRates.Add(tr.Key, _currencyService.ConvertCurrency(tr.Value, order.CurrencyRate));

                displayTaxRates = _taxSettings.DisplayTaxRates && taxRates.Any();
                displayTax = !displayTaxRates;

                var orderTaxInCustomerCurrency = _currencyService.ConvertCurrency(order.OrderTax, order.CurrencyRate);
                var taxStr = await _priceFormatter.FormatPriceAsync(orderTaxInCustomerCurrency, true, order.CustomerCurrencyCode,
                    false, languageId);
                cusTaxTotal = taxStr;
            }
        }

        //discount
        var displayDiscount = false;
        if (order.OrderDiscount > decimal.Zero)
        {
            var orderDiscountInCustomerCurrency = _currencyService.ConvertCurrency(order.OrderDiscount, order.CurrencyRate);
            cusDiscount = await _priceFormatter.FormatPriceAsync(-orderDiscountInCustomerCurrency, true, order.CustomerCurrencyCode, false, languageId);
            displayDiscount = true;
        }

        //total
        var orderTotalInCustomerCurrency = _currencyService.ConvertCurrency(order.OrderTotal, order.CurrencyRate);
        var cusTotal = await _priceFormatter.FormatPriceAsync(orderTotalInCustomerCurrency, true, order.CustomerCurrencyCode, false, languageId);

        //subtotal
        sb.AppendLine($"<tr style=\"text-align:right;\"><td>&nbsp;</td><td colspan=\"2\" style=\"background-color: {_templatesSettings.Color3};padding:0.6em 0.4em;\"><strong>{await _localizationService.GetResourceAsync("Messages.Order.SubTotal", languageId)}</strong></td> <td style=\"background-color: {_templatesSettings.Color3};padding:0.6em 0.4em;\"><strong>{cusSubTotal}</strong></td></tr>");

        //discount (applied to order subtotal)
        if (displaySubTotalDiscount)
        {
            sb.AppendLine($"<tr style=\"text-align:right;\"><td>&nbsp;</td><td colspan=\"2\" style=\"background-color: {_templatesSettings.Color3};padding:0.6em 0.4em;\"><strong>{await _localizationService.GetResourceAsync("Messages.Order.SubTotalDiscount", languageId)}</strong></td> <td style=\"background-color: {_templatesSettings.Color3};padding:0.6em 0.4em;\"><strong>{cusSubTotalDiscount}</strong></td></tr>");
        }

        //shipping
        if (displayShipping)
        {
            sb.AppendLine($"<tr style=\"text-align:right;\"><td>&nbsp;</td><td colspan=\"2\" style=\"background-color: {_templatesSettings.Color3};padding:0.6em 0.4em;\"><strong>{await _localizationService.GetResourceAsync("Messages.Order.Shipping", languageId)}</strong></td> <td style=\"background-color: {_templatesSettings.Color3};padding:0.6em 0.4em;\"><strong>{cusShipTotal}</strong></td></tr>");
        }

        //payment method fee
        if (displayPaymentMethodFee)
        {
            var paymentMethodFeeTitle = await _localizationService.GetResourceAsync("Messages.Order.PaymentMethodAdditionalFee", languageId);
            sb.AppendLine($"<tr style=\"text-align:right;\"><td>&nbsp;</td><td colspan=\"2\" style=\"background-color: {_templatesSettings.Color3};padding:0.6em 0.4em;\"><strong>{paymentMethodFeeTitle}</strong></td> <td style=\"background-color: {_templatesSettings.Color3};padding:0.6em 0.4em;\"><strong>{cusPaymentMethodAdditionalFee}</strong></td></tr>");
        }

        //tax
        if (displayTax)
        {
            sb.AppendLine($"<tr style=\"text-align:right;\"><td>&nbsp;</td><td colspan=\"2\" style=\"background-color: {_templatesSettings.Color3};padding:0.6em 0.4em;\"><strong>{await _localizationService.GetResourceAsync("Messages.Order.Tax", languageId)}</strong></td> <td style=\"background-color: {_templatesSettings.Color3};padding:0.6em 0.4em;\"><strong>{cusTaxTotal}</strong></td></tr>");
        }

        if (displayTaxRates)
        {
            foreach (var item in taxRates)
            {
                var taxRate = string.Format(await _localizationService.GetResourceAsync("Messages.Order.TaxRateLine"),
                    _priceFormatter.FormatTaxRate(item.Key));
                var taxValue = await _priceFormatter.FormatPriceAsync(item.Value, true, order.CustomerCurrencyCode, false, languageId);
                sb.AppendLine($"<tr style=\"text-align:right;\"><td>&nbsp;</td><td colspan=\"2\" style=\"background-color: {_templatesSettings.Color3};padding:0.6em 0.4em;\"><strong>{taxRate}</strong></td> <td style=\"background-color: {_templatesSettings.Color3};padding:0.6em 0.4em;\"><strong>{taxValue}</strong></td></tr>");
            }
        }

        //discount
        if (displayDiscount)
        {
            sb.AppendLine($"<tr style=\"text-align:right;\"><td>&nbsp;</td><td colspan=\"2\" style=\"background-color: {_templatesSettings.Color3};padding:0.6em 0.4em;\"><strong>{await _localizationService.GetResourceAsync("Messages.Order.TotalDiscount", languageId)}</strong></td> <td style=\"background-color: {_templatesSettings.Color3};padding:0.6em 0.4em;\"><strong>{cusDiscount}</strong></td></tr>");
        }

        //gift cards
        foreach (var gcuh in await _giftCardService.GetGiftCardUsageHistoryAsync(order))
        {
            var giftCardText = string.Format(await _localizationService.GetResourceAsync("Messages.Order.GiftCardInfo", languageId),
                WebUtility.HtmlEncode((await _giftCardService.GetGiftCardByIdAsync(gcuh.GiftCardId))?.GiftCardCouponCode));
            var giftCardAmount = await _priceFormatter.FormatPriceAsync(-_currencyService.ConvertCurrency(gcuh.UsedValue, order.CurrencyRate), true, order.CustomerCurrencyCode,
                false, languageId);
            sb.AppendLine($"<tr style=\"text-align:right;\"><td>&nbsp;</td><td colspan=\"2\" style=\"background-color: {_templatesSettings.Color3};padding:0.6em 0.4em;\"><strong>{giftCardText}</strong></td> <td style=\"background-color: {_templatesSettings.Color3};padding:0.6em 0.4em;\"><strong>{giftCardAmount}</strong></td></tr>");
        }

        //reward points
        if (order.RedeemedRewardPointsEntryId.HasValue && await _rewardPointService.GetRewardPointsHistoryEntryByIdAsync(order.RedeemedRewardPointsEntryId.Value) is RewardPointsHistory redeemedRewardPointsEntry)
        {
            var rpTitle = string.Format(await _localizationService.GetResourceAsync("Messages.Order.RewardPoints", languageId),
                -redeemedRewardPointsEntry.Points);
            var rpAmount = await _priceFormatter.FormatPriceAsync(-_currencyService.ConvertCurrency(redeemedRewardPointsEntry.UsedAmount, order.CurrencyRate), true,
                order.CustomerCurrencyCode, false, languageId);
            sb.AppendLine($"<tr style=\"text-align:right;\"><td>&nbsp;</td><td colspan=\"2\" style=\"background-color: {_templatesSettings.Color3};padding:0.6em 0.4em;\"><strong>{rpTitle}</strong></td> <td style=\"background-color: {_templatesSettings.Color3};padding:0.6em 0.4em;\"><strong>{rpAmount}</strong></td></tr>");
        }

        //total
        sb.AppendLine($"<tr style=\"text-align:right;\"><td>&nbsp;</td><td colspan=\"2\" style=\"background-color: {_templatesSettings.Color3};padding:0.6em 0.4em;\"><strong>{await _localizationService.GetResourceAsync("Messages.Order.OrderTotal", languageId)}</strong></td> <td style=\"background-color: {_templatesSettings.Color3};padding:0.6em 0.4em;\"><strong>{cusTotal}</strong></td></tr>");
    }

    /// <summary>
    /// Convert a collection to a HTML table
    /// </summary>
    /// <param name="shipment">Shipment</param>
    /// <param name="languageId">Language identifier</param>
    /// <returns>
    /// A task that represents the asynchronous operation
    /// The task result contains the hTML table of products
    /// </returns>
    protected virtual async Task<string> ProductListToHtmlTableAsync(Shipment shipment, int languageId)
    {
        var sb = new StringBuilder();
        sb.AppendLine("<table border=\"0\" style=\"width:100%;\">");

        sb.AppendLine($"<tr style=\"background-color:{_templatesSettings.Color1};text-align:center;\">");
        sb.AppendLine($"<th>{await _localizationService.GetResourceAsync("Messages.Order.Product(s).Name", languageId)}</th>");
        sb.AppendLine($"<th>{await _localizationService.GetResourceAsync("Messages.Order.Product(s).Quantity", languageId)}</th>");
        sb.AppendLine("</tr>");

        var table = await _shipmentService.GetShipmentItemsByShipmentIdAsync(shipment.Id);
        for (var i = 0; i <= table.Count - 1; i++)
        {
            var si = table[i];
            var orderItem = await _orderService.GetOrderItemByIdAsync(si.OrderItemId);

            if (orderItem == null)
                continue;

            var product = await _productService.GetProductByIdAsync(orderItem.ProductId);

            if (product == null)
                continue;

            sb.AppendLine($"<tr style=\"background-color: {_templatesSettings.Color2};text-align: center;\">");
            //product name
            var productName = await _localizationService.GetLocalizedAsync(product, x => x.Name, languageId);

            sb.AppendLine("<td style=\"padding: 0.6em 0.4em;text-align: left;\">" + WebUtility.HtmlEncode(productName));

            //attributes
            if (!string.IsNullOrEmpty(orderItem.AttributeDescription))
            {
                sb.AppendLine("<br />");
                sb.AppendLine(orderItem.AttributeDescription);
            }

            //rental info
            if (product.IsRental)
            {
                var rentalStartDate = orderItem.RentalStartDateUtc.HasValue
                    ? _productService.FormatRentalDate(product, orderItem.RentalStartDateUtc.Value) : string.Empty;
                var rentalEndDate = orderItem.RentalEndDateUtc.HasValue
                    ? _productService.FormatRentalDate(product, orderItem.RentalEndDateUtc.Value) : string.Empty;
                var rentalInfo = string.Format(await _localizationService.GetResourceAsync("Order.Rental.FormattedDate"),
                    rentalStartDate, rentalEndDate);
                sb.AppendLine("<br />");
                sb.AppendLine(rentalInfo);
            }

            //SKU
            if (_catalogSettings.ShowSkuOnProductDetailsPage)
            {
                var sku = await _productService.FormatSkuAsync(product, orderItem.AttributesXml);
                if (!string.IsNullOrEmpty(sku))
                {
                    sb.AppendLine("<br />");
                    sb.AppendLine(string.Format(await _localizationService.GetResourceAsync("Messages.Order.Product(s).SKU", languageId), WebUtility.HtmlEncode(sku)));
                }
            }

            sb.AppendLine("</td>");

            sb.AppendLine($"<td style=\"padding: 0.6em 0.4em;text-align: center;\">{si.Quantity}</td>");

            sb.AppendLine("</tr>");
        }

        sb.AppendLine("</table>");
        var result = sb.ToString();
        return result;
    }

    /// <summary>
    /// Generates an absolute URL for the specified store, routeName and route values
    /// </summary>
    /// <param name="storeId">Store identifier; Pass 0 to load URL of the current store</param>
    /// <param name="routeName">The name of the route that is used to generate URL</param>
    /// <param name="routeValues">An object that contains route values</param>
    /// <returns>
    /// A task that represents the asynchronous operation
    /// The task result contains the generated URL
    /// </returns>
    protected virtual async Task<string> RouteUrlAsync(int storeId = 0, string routeName = null, object routeValues = null)
    {
        try
        {
            //try to get a store by the passed identifier
            var store = await _storeService.GetStoreByIdAsync(storeId) ?? await _storeContext.GetCurrentStoreAsync()
                ?? throw new Exception("No store could be loaded");

            //ensure that the store URL is specified
            if (string.IsNullOrEmpty(store.Url))
                throw new Exception("Store URL cannot be empty");

            //generate the relative URL
            var urlHelper = _urlHelperFactory.GetUrlHelper(_actionContextAccessor.ActionContext);
            var url = urlHelper.RouteUrl(routeName, routeValues);

            //compose the result
            return new Uri(new Uri(store.Url), url).AbsoluteUri;
        }
        catch (Exception exception)
        {
            var warning = $"When sending a notification, an error occurred while creating a link for '{routeName}', ensure that URL of the store #{storeId} is correct.";
            await _logger.WarningAsync(warning, exception);

            return string.Empty;
        }
    }

    #endregion

    #region Methods

    /// <summary>
    /// Add store tokens
    /// </summary>
    /// <param name="tokens">List of already added tokens</param>
    /// <param name="store">Store</param>
    /// <param name="emailAccount">Email account</param>
    /// <param name="languageId">Language identifier</param>
    /// <returns>A task that represents the asynchronous operation</returns>
    public virtual async Task AddStoreTokensAsync(IList<Token> tokens, Store store, EmailAccount emailAccount, int languageId)
    {
        ArgumentNullException.ThrowIfNull(emailAccount);

        tokens.Add(new Token("Store.Name", await _localizationService.GetLocalizedAsync(store, x => x.Name, languageId)));
        tokens.Add(new Token("Store.URL", store.Url, true));
        tokens.Add(new Token("Store.Email", emailAccount.Email));
        tokens.Add(new Token("Store.CompanyName", store.CompanyName));
        tokens.Add(new Token("Store.CompanyAddress", store.CompanyAddress));
        tokens.Add(new Token("Store.CompanyPhoneNumber", store.CompanyPhoneNumber));
        tokens.Add(new Token("Store.CompanyVat", store.CompanyVat));

        tokens.Add(new Token("Facebook.URL", _storeInformationSettings.FacebookLink));
        tokens.Add(new Token("Twitter.URL", _storeInformationSettings.TwitterLink));
        tokens.Add(new Token("YouTube.URL", _storeInformationSettings.YoutubeLink));
        tokens.Add(new Token("Instagram.URL", _storeInformationSettings.InstagramLink));

        //event notification
        await _eventPublisher.EntityTokensAddedAsync(store, tokens);
    }

    /// <summary>
    /// Add order tokens
    /// </summary>
    /// <param name="tokens">List of already added tokens</param>
    /// <param name="order"></param>
    /// <param name="languageId">Language identifier</param>
    /// <param name="vendorId">Vendor identifier</param>
    /// <returns>A task that represents the asynchronous operation</returns>
    public virtual async Task AddOrderTokensAsync(IList<Token> tokens, Order order, int languageId, int vendorId = 0)
    {
        //lambda expression for choosing correct order address
        async Task<Address> orderAddress(Order o) => await _addressService.GetAddressByIdAsync((o.PickupInStore ? o.PickupAddressId : o.ShippingAddressId) ?? 0);

        var billingAddress = await _addressService.GetAddressByIdAsync(order.BillingAddressId);
        var (billingAddressLine, _) = await _addressService.FormatAddressAsync(billingAddress, languageId);
        var (shippingAddressLine, _) = await _addressService.FormatAddressAsync(await orderAddress(order), languageId);

        tokens.Add(new Token("Order.OrderId", order.Id));
        tokens.Add(new Token("Order.OrderNumber", order.CustomOrderNumber));

        tokens.Add(new Token("Order.CustomerFullName", $"{billingAddress.FirstName} {billingAddress.LastName}"));
        tokens.Add(new Token("Order.CustomerEmail", billingAddress.Email));

        tokens.Add(new Token("Order.BillingFirstName", billingAddress.FirstName));
        tokens.Add(new Token("Order.BillingLastName", billingAddress.LastName));
        tokens.Add(new Token("Order.BillingPhoneNumber", billingAddress.PhoneNumber));
        tokens.Add(new Token("Order.BillingEmail", billingAddress.Email));
        tokens.Add(new Token("Order.BillingFaxNumber", billingAddress.FaxNumber));
        tokens.Add(new Token("Order.BillingCompany", billingAddress.Company));
        tokens.Add(new Token("Order.BillingAddress1", billingAddress.Address1));
        tokens.Add(new Token("Order.BillingAddress2", billingAddress.Address2));
        tokens.Add(new Token("Order.BillingCity", billingAddress.City));
        tokens.Add(new Token("Order.BillingCounty", billingAddress.County));
        tokens.Add(new Token("Order.BillingStateProvince", await _stateProvinceService.GetStateProvinceByAddressAsync(billingAddress) is StateProvince billingStateProvince ? await _localizationService.GetLocalizedAsync(billingStateProvince, x => x.Name, languageId) : string.Empty));
        tokens.Add(new Token("Order.BillingZipPostalCode", billingAddress.ZipPostalCode));
        tokens.Add(new Token("Order.BillingCountry", await _countryService.GetCountryByAddressAsync(billingAddress) is Country billingCountry ? await _localizationService.GetLocalizedAsync(billingCountry, x => x.Name, languageId) : string.Empty));
        tokens.Add(new Token("Order.BillingCustomAttributes", await _addressAttributeFormatter.FormatAttributesAsync(billingAddress.CustomAttributes), true));
        tokens.Add(new Token("Order.BillingAddressLine", billingAddressLine));
        tokens.Add(new Token("Order.Shippable", !string.IsNullOrEmpty(order.ShippingMethod)));
        tokens.Add(new Token("Order.ShippingMethod", order.ShippingMethod));
        tokens.Add(new Token("Order.PickupInStore", order.PickupInStore));
        tokens.Add(new Token("Order.ShippingFirstName", (await orderAddress(order))?.FirstName ?? string.Empty));
        tokens.Add(new Token("Order.ShippingLastName", (await orderAddress(order))?.LastName ?? string.Empty));
        tokens.Add(new Token("Order.ShippingPhoneNumber", (await orderAddress(order))?.PhoneNumber ?? string.Empty));
        tokens.Add(new Token("Order.ShippingEmail", (await orderAddress(order))?.Email ?? string.Empty));
        tokens.Add(new Token("Order.ShippingFaxNumber", (await orderAddress(order))?.FaxNumber ?? string.Empty));
        tokens.Add(new Token("Order.ShippingCompany", (await orderAddress(order))?.Company ?? string.Empty));
        tokens.Add(new Token("Order.ShippingAddress1", (await orderAddress(order))?.Address1 ?? string.Empty));
        tokens.Add(new Token("Order.ShippingAddress2", (await orderAddress(order))?.Address2 ?? string.Empty));
        tokens.Add(new Token("Order.ShippingCity", (await orderAddress(order))?.City ?? string.Empty));
        tokens.Add(new Token("Order.ShippingCounty", (await orderAddress(order))?.County ?? string.Empty));
        tokens.Add(new Token("Order.ShippingStateProvince", await _stateProvinceService.GetStateProvinceByAddressAsync(await orderAddress(order)) is StateProvince shippingStateProvince ? await _localizationService.GetLocalizedAsync(shippingStateProvince, x => x.Name, languageId) : string.Empty));
        tokens.Add(new Token("Order.ShippingZipPostalCode", (await orderAddress(order))?.ZipPostalCode ?? string.Empty));
        tokens.Add(new Token("Order.ShippingCountry", await _countryService.GetCountryByAddressAsync(await orderAddress(order)) is Country orderCountry ? await _localizationService.GetLocalizedAsync(orderCountry, x => x.Name, languageId) : string.Empty));
        tokens.Add(new Token("Order.ShippingCustomAttributes", await _addressAttributeFormatter.FormatAttributesAsync((await orderAddress(order))?.CustomAttributes ?? string.Empty), true));
        tokens.Add(new Token("Order.ShippingAddressLine", shippingAddressLine));
        tokens.Add(new Token("Order.IsCompletelyShipped", !order.PickupInStore && order.ShippingStatus == ShippingStatus.Shipped));
        tokens.Add(new Token("Order.IsCompletelyReadyForPickup", order.PickupInStore && !await _orderService.HasItemsToAddToShipmentAsync(order) && !await _orderService.HasItemsToReadyForPickupAsync(order)));
        tokens.Add(new Token("Order.IsCompletelyDelivered", order.ShippingStatus == ShippingStatus.Delivered));

        var paymentMethod = await _paymentPluginManager.LoadPluginBySystemNameAsync(order.PaymentMethodSystemName);
        var paymentMethodName = paymentMethod != null ? await _localizationService.GetLocalizedFriendlyNameAsync(paymentMethod, languageId) : order.PaymentMethodSystemName;
        tokens.Add(new Token("Order.PaymentMethod", paymentMethodName));
        tokens.Add(new Token("Order.VatNumber", order.VatNumber));
        var sbCustomValues = new StringBuilder();
        var customValues = new CustomValues();
        customValues.FillByXml(order.CustomValuesXml, true);

        foreach (var item in customValues)
        {
            sbCustomValues.AppendFormat("{0}: {1}", WebUtility.HtmlEncode(item.Name), WebUtility.HtmlEncode(item.Value ?? string.Empty));
            sbCustomValues.Append("<br />");
        }

        tokens.Add(new Token("Order.CustomValues", sbCustomValues.ToString(), true));

        tokens.Add(new Token("Order.Product(s)", await ProductListToHtmlTableAsync(order, languageId, vendorId), true));

        var language = await _languageService.GetLanguageByIdAsync(languageId);
        if (language != null && !string.IsNullOrEmpty(language.LanguageCulture))
        {
            var customer = await _customerService.GetCustomerByIdAsync(order.CustomerId);
            var createdOn = _dateTimeHelper.ConvertToUserTime(order.CreatedOnUtc, TimeZoneInfo.Utc, await _dateTimeHelper.GetCustomerTimeZoneAsync(customer));
            tokens.Add(new Token("Order.CreatedOn", createdOn.ToString("D", new CultureInfo(language.LanguageCulture))));
        }
        else
        {
            tokens.Add(new Token("Order.CreatedOn", order.CreatedOnUtc.ToString("D")));
        }

        var orderUrl = await RouteUrlAsync(order.StoreId, NopRouteNames.Standard.ORDER_DETAILS, new { orderId = order.Id });
        tokens.Add(new Token("Order.OrderURLForCustomer", orderUrl, true));

        //event notification
        await _eventPublisher.EntityTokensAddedAsync(order, tokens);
    }

    /// <summary>
    /// Add refunded order tokens
    /// </summary>
    /// <param name="tokens">List of already added tokens</param>
    /// <param name="order">Order</param>
    /// <param name="refundedAmount">Refunded amount of order</param>
    /// <returns>A task that represents the asynchronous operation</returns>
    public virtual async Task AddOrderRefundedTokensAsync(IList<Token> tokens, Order order, decimal refundedAmount)
    {
        //should we convert it to customer currency?
        //most probably, no. It can cause some rounding or legal issues
        //furthermore, exchange rate could be changed
        //so let's display it the primary store currency

        var primaryStoreCurrencyCode = (await _currencyService.GetCurrencyByIdAsync(_currencySettings.PrimaryStoreCurrencyId)).CurrencyCode;
        var refundedAmountStr = await _priceFormatter.FormatPriceAsync(refundedAmount, true, primaryStoreCurrencyCode, false, (await _workContext.GetWorkingLanguageAsync()).Id);

        tokens.Add(new Token("Order.AmountRefunded", refundedAmountStr));

        //event notification
        await _eventPublisher.EntityTokensAddedAsync(order, tokens);
    }

    /// <summary>
    /// Add shipment tokens
    /// </summary>
    /// <param name="tokens">List of already added tokens</param>
    /// <param name="shipment">Shipment item</param>
    /// <param name="languageId">Language identifier</param>
    /// <returns>A task that represents the asynchronous operation</returns>
    public virtual async Task AddShipmentTokensAsync(IList<Token> tokens, Shipment shipment, int languageId)
    {
        tokens.Add(new Token("Shipment.ShipmentNumber", shipment.Id));
        tokens.Add(new Token("Shipment.TrackingNumber", shipment.TrackingNumber));
        var trackingNumberUrl = string.Empty;
        if (!string.IsNullOrEmpty(shipment.TrackingNumber))
        {
            var shipmentTracker = await _shipmentService.GetShipmentTrackerAsync(shipment);
            if (shipmentTracker != null)
                trackingNumberUrl = await shipmentTracker.GetUrlAsync(shipment.TrackingNumber, shipment);
        }

        tokens.Add(new Token("Shipment.TrackingNumberURL", trackingNumberUrl, true));
        tokens.Add(new Token("Shipment.Product(s)", await ProductListToHtmlTableAsync(shipment, languageId), true));

        var shipmentUrl = await RouteUrlAsync((await _orderService.GetOrderByIdAsync(shipment.OrderId)).StoreId, NopRouteNames.Standard.SHIPMENT_DETAILS, new { shipmentId = shipment.Id });
        tokens.Add(new Token("Shipment.URLForCustomer", shipmentUrl, true));

        //event notification
        await _eventPublisher.EntityTokensAddedAsync(shipment, tokens);
    }

    /// <summary>
    /// Add order note tokens
    /// </summary>
    /// <param name="tokens">List of already added tokens</param>
    /// <param name="orderNote">Order note</param>
    /// <returns>A task that represents the asynchronous operation</returns>
    public virtual async Task AddOrderNoteTokensAsync(IList<Token> tokens, OrderNote orderNote)
    {
        var order = await _orderService.GetOrderByIdAsync(orderNote.OrderId);

        tokens.Add(new Token("Order.NewNoteText", _orderService.FormatOrderNoteText(orderNote), true));
        var orderNoteAttachmentUrl = await RouteUrlAsync(order.StoreId, NopRouteNames.Standard.GET_ORDER_NOTE_FILE, new { ordernoteid = orderNote.Id });
        tokens.Add(new Token("Order.OrderNoteAttachmentUrl", orderNoteAttachmentUrl, true));

        //event notification
        await _eventPublisher.EntityTokensAddedAsync(orderNote, tokens);
    }

    /// <summary>
    /// Add recurring payment tokens
    /// </summary>
    /// <param name="tokens">List of already added tokens</param>
    /// <param name="recurringPayment">Recurring payment</param>
    /// <returns>A task that represents the asynchronous operation</returns>
    public virtual async Task AddRecurringPaymentTokensAsync(IList<Token> tokens, RecurringPayment recurringPayment)
    {
        tokens.Add(new Token("RecurringPayment.ID", recurringPayment.Id));
        tokens.Add(new Token("RecurringPayment.CancelAfterFailedPayment",
            recurringPayment.LastPaymentFailed && _paymentSettings.CancelRecurringPaymentsAfterFailedPayment));
        if (await _orderService.GetOrderByIdAsync(recurringPayment.InitialOrderId) is Order order)
            tokens.Add(new Token("RecurringPayment.RecurringPaymentType", (await _paymentService.GetRecurringPaymentTypeAsync(order.PaymentMethodSystemName)).ToString()));

        //event notification
        await _eventPublisher.EntityTokensAddedAsync(recurringPayment, tokens);
    }

    /// <summary>
    /// Add return request tokens
    /// </summary>
    /// <param name="tokens">List of already added tokens</param>
    /// <param name="returnRequest">Return request</param>
    /// <param name="orderItem">Order item</param>
    /// <param name="languageId">Language identifier</param>
    /// <returns>A task that represents the asynchronous operation</returns>
    public virtual async Task AddReturnRequestTokensAsync(IList<Token> tokens, ReturnRequest returnRequest, OrderItem orderItem, int languageId)
    {
        var product = await _productService.GetProductByIdAsync(orderItem.ProductId);

        tokens.Add(new Token("ReturnRequest.CustomNumber", returnRequest.CustomNumber));
        tokens.Add(new Token("ReturnRequest.OrderId", orderItem.OrderId));
        tokens.Add(new Token("ReturnRequest.Product.Quantity", returnRequest.Quantity));
        tokens.Add(new Token("ReturnRequest.Product.Name", await _localizationService.GetLocalizedAsync(product, x => x.Name, languageId)));
        tokens.Add(new Token("ReturnRequest.Reason", returnRequest.ReasonForReturn));
        tokens.Add(new Token("ReturnRequest.RequestedAction", returnRequest.RequestedAction));
        tokens.Add(new Token("ReturnRequest.CustomerComment", _htmlFormatter.FormatText(returnRequest.CustomerComments, false, true, false, false, false, false), true));
        tokens.Add(new Token("ReturnRequest.StaffNotes", _htmlFormatter.FormatText(returnRequest.StaffNotes, false, true, false, false, false, false), true));
        tokens.Add(new Token("ReturnRequest.Status", await _localizationService.GetLocalizedEnumAsync(returnRequest.ReturnRequestStatus, languageId)));

        //event notification
        await _eventPublisher.EntityTokensAddedAsync(returnRequest, tokens);
    }

    /// <summary>
    /// Add gift card tokens
    /// </summary>
    /// <param name="tokens">List of already added tokens</param>
    /// <param name="giftCard">Gift card</param>
    /// <param name="languageId">Language identifier</param>
    /// <returns>A task that represents the asynchronous operation</returns>
    public virtual async Task AddGiftCardTokensAsync(IList<Token> tokens, GiftCard giftCard, int languageId)
    {
        tokens.Add(new Token("GiftCard.SenderName", giftCard.SenderName));
        tokens.Add(new Token("GiftCard.SenderEmail", giftCard.SenderEmail));
        tokens.Add(new Token("GiftCard.RecipientName", giftCard.RecipientName));
        tokens.Add(new Token("GiftCard.RecipientEmail", giftCard.RecipientEmail));

        var primaryStoreCurrency = await _currencyService.GetCurrencyByIdAsync(_currencySettings.PrimaryStoreCurrencyId);
        tokens.Add(new Token("GiftCard.Amount", await _priceFormatter.FormatPriceAsync(giftCard.Amount, true, primaryStoreCurrency.CurrencyCode, false, languageId)));
        tokens.Add(new Token("GiftCard.CouponCode", giftCard.GiftCardCouponCode));

        var giftCardMessage = !string.IsNullOrWhiteSpace(giftCard.Message) ?
            _htmlFormatter.FormatText(giftCard.Message, false, true, false, false, false, false) : string.Empty;

        tokens.Add(new Token("GiftCard.Message", giftCardMessage, true));

        //event notification
        await _eventPublisher.EntityTokensAddedAsync(giftCard, tokens);
    }

    /// <summary>
    /// Add customer tokens
    /// </summary>
    /// <param name="tokens">List of already added tokens</param>
    /// <param name="customerId">Customer identifier</param>
    /// <returns>A task that represents the asynchronous operation</returns>
    public virtual async Task AddCustomerTokensAsync(IList<Token> tokens, int customerId)
    {
        if (customerId <= 0)
            throw new ArgumentOutOfRangeException(nameof(customerId));

        var customer = await _customerService.GetCustomerByIdAsync(customerId);

        await AddCustomerTokensAsync(tokens, customer);
    }

    /// <summary>
    /// Add customer tokens
    /// </summary>
    /// <param name="tokens">List of already added tokens</param>
    /// <param name="customer">Customer</param>
    /// <returns>A task that represents the asynchronous operation</returns>
    public virtual async Task AddCustomerTokensAsync(IList<Token> tokens, Customer customer)
    {
        tokens.Add(new Token("Customer.Email", customer.Email));
        tokens.Add(new Token("Customer.Username", customer.Username));
        tokens.Add(new Token("Customer.FullName", await _customerService.GetCustomerFullNameAsync(customer)));
        tokens.Add(new Token("Customer.FirstName", customer.FirstName));
        tokens.Add(new Token("Customer.LastName", customer.LastName));
        tokens.Add(new Token("Customer.VatNumber", customer.VatNumber));
        tokens.Add(new Token("Customer.VatNumberStatus", ((VatNumberStatus)customer.VatNumberStatusId).ToString()));

        var customAttributesXml = customer.CustomCustomerAttributesXML;
        tokens.Add(new Token("Customer.CustomAttributes", await _customerAttributeFormatter.FormatAttributesAsync(customAttributesXml), true));

        //note: we do not use SEO friendly URLS for these links because we can get errors caused by having .(dot) in the URL (from the email address)
        var passwordRecoveryUrl = await RouteUrlAsync(routeName: NopRouteNames.Standard.PASSWORD_RECOVERY_CONFIRM, routeValues: new { token = await _genericAttributeService.GetAttributeAsync<string>(customer, NopCustomerDefaults.PasswordRecoveryTokenAttribute), guid = customer.CustomerGuid });
        var accountActivationUrl = await RouteUrlAsync(routeName: NopRouteNames.Standard.ACCOUNT_ACTIVATION, routeValues: new { token = await _genericAttributeService.GetAttributeAsync<string>(customer, NopCustomerDefaults.AccountActivationTokenAttribute), guid = customer.CustomerGuid });
        var emailRevalidationUrl = await RouteUrlAsync(routeName: NopRouteNames.Standard.EMAIL_REVALIDATION, routeValues: new { token = await _genericAttributeService.GetAttributeAsync<string>(customer, NopCustomerDefaults.EmailRevalidationTokenAttribute), guid = customer.CustomerGuid });
        
        tokens.Add(new Token("Customer.PasswordRecoveryURL", passwordRecoveryUrl, true));
        tokens.Add(new Token("Customer.AccountActivationURL", accountActivationUrl, true));
        tokens.Add(new Token("Customer.EmailRevalidationURL", emailRevalidationUrl, true));
        tokens.Add(new Token("Customer.Company", customer.Company));

        //event notification
        await _eventPublisher.EntityTokensAddedAsync(customer, tokens);
    }

    /// <summary>
    /// Add vendor tokens
    /// </summary>
    /// <param name="tokens">List of already added tokens</param>
    /// <param name="vendor">Vendor</param>
    /// <returns>A task that represents the asynchronous operation</returns>
    public virtual async Task AddVendorTokensAsync(IList<Token> tokens, Vendor vendor)
    {
        tokens.Add(new Token("Vendor.Name", vendor.Name));
        tokens.Add(new Token("Vendor.Email", vendor.Email));

        var vendorAttributesXml = await _genericAttributeService.GetAttributeAsync<string>(vendor, NopVendorDefaults.VendorAttributes);
        tokens.Add(new Token("Vendor.VendorAttributes", await _vendorAttributeFormatter.FormatAttributesAsync(vendorAttributesXml), true));

        //event notification
        await _eventPublisher.EntityTokensAddedAsync(vendor, tokens);
    }

    /// <summary>
    /// Add newsletter subscription tokens
    /// </summary>
    /// <param name="tokens">List of already added tokens</param>
    /// <param name="subscription">Newsletter subscription</param>
    /// <returns>A task that represents the asynchronous operation</returns>
    public virtual async Task AddNewsLetterSubscriptionTokensAsync(IList<Token> tokens, NewsLetterSubscription subscription)
    {
        tokens.Add(new Token("NewsLetterSubscription.Email", subscription.Email));

        var activationUrl = await RouteUrlAsync(routeName: NopRouteNames.Standard.NEWSLETTER_ACTIVATION, routeValues: new { token = subscription.NewsLetterSubscriptionGuid, active = "true" });
        tokens.Add(new Token("NewsLetterSubscription.ActivationUrl", activationUrl, true));

        var deactivationUrl = await RouteUrlAsync(routeName: NopRouteNames.Standard.NEWSLETTER_ACTIVATION, routeValues: new { token = subscription.NewsLetterSubscriptionGuid, active = "false" });
        tokens.Add(new Token("NewsLetterSubscription.DeactivationUrl", deactivationUrl, true));

        //event notification
        await _eventPublisher.EntityTokensAddedAsync(subscription, tokens);
    }

    /// <summary>
    /// Add product review tokens
    /// </summary>
    /// <param name="tokens">List of already added tokens</param>
    /// <param name="productReview">Product review</param>
    /// <returns>A task that represents the asynchronous operation</returns>
    public virtual async Task AddProductReviewTokensAsync(IList<Token> tokens, ProductReview productReview)
    {
        tokens.Add(new Token("ProductReview.ProductName", (await _productService.GetProductByIdAsync(productReview.ProductId))?.Name));
        tokens.Add(new Token("ProductReview.Title", productReview.Title));
        tokens.Add(new Token("ProductReview.IsApproved", productReview.IsApproved));
        tokens.Add(new Token("ProductReview.ReviewText", productReview.ReviewText));
        tokens.Add(new Token("ProductReview.ReplyText", productReview.ReplyText));

        //event notification
        await _eventPublisher.EntityTokensAddedAsync(productReview, tokens);
    }

    /// <summary>
    /// Add blog comment tokens
    /// </summary>
    /// <param name="tokens">List of already added tokens</param>
    /// <param name="blogComment">Blog post comment</param>
    /// <returns>A task that represents the asynchronous operation</returns>
    public virtual async Task AddBlogCommentTokensAsync(IList<Token> tokens, BlogComment blogComment)
    {
        var blogPost = await _blogService.GetBlogPostByIdAsync(blogComment.BlogPostId);

        tokens.Add(new Token("BlogComment.BlogPostTitle", blogPost.Title));

        //event notification
        await _eventPublisher.EntityTokensAddedAsync(blogComment, tokens);
    }

    /// <summary>
    /// Add news comment tokens
    /// </summary>
    /// <param name="tokens">List of already added tokens</param>
    /// <param name="newsComment">News comment</param>
    /// <returns>A task that represents the asynchronous operation</returns>
    public virtual async Task AddNewsCommentTokensAsync(IList<Token> tokens, NewsComment newsComment)
    {
        var newsItem = await _newsService.GetNewsByIdAsync(newsComment.NewsItemId);

        tokens.Add(new Token("NewsComment.NewsTitle", newsItem.Title));

        //event notification
        await _eventPublisher.EntityTokensAddedAsync(newsComment, tokens);
    }

    /// <summary>
    /// Add product tokens
    /// </summary>
    /// <param name="tokens">List of already added tokens</param>
    /// <param name="product">Product</param>
    /// <param name="languageId">Language identifier</param>
    /// <returns>A task that represents the asynchronous operation</returns>
    public virtual async Task AddProductTokensAsync(IList<Token> tokens, Product product, int languageId)
    {
        tokens.Add(new Token("Product.ID", product.Id));
        tokens.Add(new Token("Product.Name", await _localizationService.GetLocalizedAsync(product, x => x.Name, languageId)));
        tokens.Add(new Token("Product.ShortDescription", await _localizationService.GetLocalizedAsync(product, x => x.ShortDescription, languageId), true));
        tokens.Add(new Token("Product.SKU", product.Sku));
        tokens.Add(new Token("Product.StockQuantity", await _productService.GetTotalStockQuantityAsync(product)));

        var seName = await _urlRecordService.GetSeNameAsync(product);
        var productUrl = await RouteUrlAsync(routeName: "ProductDetails", routeValues: new { SeName = seName });
        tokens.Add(new Token("Product.ProductURLForCustomer", productUrl, true));

        //event notification
        await _eventPublisher.EntityTokensAddedAsync(product, tokens);
    }

    /// <summary>
    /// Add product attribute combination tokens
    /// </summary>
    /// <param name="tokens">List of already added tokens</param>
    /// <param name="combination">Product attribute combination</param>
    /// <param name="languageId">Language identifier</param>
    /// <returns>A task that represents the asynchronous operation</returns>
    public virtual async Task AddAttributeCombinationTokensAsync(IList<Token> tokens, ProductAttributeCombination combination, int languageId)
    {
        //attributes
        //we cannot inject IProductAttributeFormatter into constructor because it'll cause circular references.
        //that's why we resolve it here this way
        var productAttributeFormatter = EngineContext.Current.Resolve<IProductAttributeFormatter>();

        var product = await _productService.GetProductByIdAsync(combination.ProductId);
        var currentCustomer = await _workContext.GetCurrentCustomerAsync();
        var currentStore = await _storeContext.GetCurrentStoreAsync();

        var attributes = await productAttributeFormatter.FormatAttributesAsync(product,
            combination.AttributesXml,
            currentCustomer,
            currentStore,
            renderPrices: false);

        tokens.Add(new Token("AttributeCombination.Formatted", attributes, true));
        tokens.Add(new Token("AttributeCombination.SKU", await _productService.FormatSkuAsync(await _productService.GetProductByIdAsync(combination.ProductId), combination.AttributesXml)));
        tokens.Add(new Token("AttributeCombination.StockQuantity", combination.StockQuantity));

        //event notification
        await _eventPublisher.EntityTokensAddedAsync(combination, tokens);
    }

    /// <summary>
    /// Add forum topic tokens
    /// </summary>
    /// <param name="tokens">List of already added tokens</param>
    /// <param name="forumTopic">Forum topic</param>
    /// <param name="friendlyForumTopicPageIndex">Friendly (starts with 1) forum topic page to use for URL generation</param>
    /// <param name="appendedPostIdentifierAnchor">Forum post identifier</param>
    /// <returns>A task that represents the asynchronous operation</returns>
    public virtual async Task AddForumTopicTokensAsync(IList<Token> tokens, ForumTopic forumTopic,
        int? friendlyForumTopicPageIndex = null, int? appendedPostIdentifierAnchor = null)
    {
        //attributes
        //we cannot inject IForumService into constructor because it'll cause circular references.
        //that's why we resolve it here this way
        var forumService = EngineContext.Current.Resolve<IForumService>();

        string topicUrl;
        if (friendlyForumTopicPageIndex.HasValue && friendlyForumTopicPageIndex.Value > 1)
            topicUrl = await RouteUrlAsync(routeName: NopRouteNames.Standard.TOPIC_SLUG_PAGED, routeValues: new { id = forumTopic.Id, slug = await forumService.GetTopicSeNameAsync(forumTopic), pageNumber = friendlyForumTopicPageIndex.Value });
        else
            topicUrl = await RouteUrlAsync(routeName: NopRouteNames.Standard.TOPIC_SLUG, routeValues: new { id = forumTopic.Id, slug = await forumService.GetTopicSeNameAsync(forumTopic) });
        if (appendedPostIdentifierAnchor.HasValue && appendedPostIdentifierAnchor.Value > 0)
            topicUrl = $"{topicUrl}#{appendedPostIdentifierAnchor.Value}";
        tokens.Add(new Token("Forums.TopicURL", topicUrl, true));
        tokens.Add(new Token("Forums.TopicName", forumTopic.Subject));

        //event notification
        await _eventPublisher.EntityTokensAddedAsync(forumTopic, tokens);
    }

    /// <summary>
    /// Add forum post tokens
    /// </summary>
    /// <param name="tokens">List of already added tokens</param>
    /// <param name="forumPost">Forum post</param>
    /// <returns>A task that represents the asynchronous operation</returns>
    public virtual async Task AddForumPostTokensAsync(IList<Token> tokens, ForumPost forumPost)
    {
        //attributes
        //we cannot inject IForumService into constructor because it'll cause circular references.
        //that's why we resolve it here this way
        var forumService = EngineContext.Current.Resolve<IForumService>();

        var customer = await _customerService.GetCustomerByIdAsync(forumPost.CustomerId);

        tokens.Add(new Token("Forums.PostAuthor", await _customerService.FormatUsernameAsync(customer)));
        tokens.Add(new Token("Forums.PostBody", forumService.FormatPostText(forumPost), true));

        //event notification
        await _eventPublisher.EntityTokensAddedAsync(forumPost, tokens);
    }

    /// <summary>
    /// Add forum tokens
    /// </summary>
    /// <param name="tokens">List of already added tokens</param>
    /// <param name="forum">Forum</param>
    /// <returns>A task that represents the asynchronous operation</returns>
    public virtual async Task AddForumTokensAsync(IList<Token> tokens, Forum forum)
    {
        //attributes
        //we cannot inject IForumService into constructor because it'll cause circular references.
        //that's why we resolve it here this way
        var forumService = EngineContext.Current.Resolve<IForumService>();

        var forumUrl = await RouteUrlAsync(routeName: NopRouteNames.Standard.FORUM_SLUG, routeValues: new { id = forum.Id, slug = await forumService.GetForumSeNameAsync(forum) });
        tokens.Add(new Token("Forums.ForumURL", forumUrl, true));
        tokens.Add(new Token("Forums.ForumName", forum.Name));

        //event notification
        await _eventPublisher.EntityTokensAddedAsync(forum, tokens);
    }

    /// <summary>
    /// Add private message tokens
    /// </summary>
    /// <param name="tokens">List of already added tokens</param>
    /// <param name="privateMessage">Private message</param>
    /// <returns>A task that represents the asynchronous operation</returns>
    public virtual async Task AddPrivateMessageTokensAsync(IList<Token> tokens, PrivateMessage privateMessage)
    {
        //attributes
        //we cannot inject IForumService into constructor because it'll cause circular references.
        //that's why we resolve it here this way
        var forumService = EngineContext.Current.Resolve<IForumService>();

        tokens.Add(new Token("PrivateMessage.Subject", privateMessage.Subject));
        tokens.Add(new Token("PrivateMessage.Text", forumService.FormatPrivateMessageText(privateMessage), true));

        //event notification
        await _eventPublisher.EntityTokensAddedAsync(privateMessage, tokens);
    }

    /// <summary>
    /// Add tokens of BackInStock subscription
    /// </summary>
    /// <param name="tokens">List of already added tokens</param>
    /// <param name="subscription">BackInStock subscription</param>
    /// <returns>A task that represents the asynchronous operation</returns>
    public virtual async Task AddBackInStockTokensAsync(IList<Token> tokens, BackInStockSubscription subscription)
    {
        var product = await _productService.GetProductByIdAsync(subscription.ProductId);

        tokens.Add(new Token("BackInStockSubscription.ProductName", product.Name));
        var productUrl = await RouteUrlAsync(subscription.StoreId, "ProductDetails", new { SeName = await _urlRecordService.GetSeNameAsync(product) });
        tokens.Add(new Token("BackInStockSubscription.ProductUrl", productUrl, true));

        //event notification
        await _eventPublisher.EntityTokensAddedAsync(subscription, tokens);
    }

    /// <summary>
    /// Get collection of allowed (supported) message tokens for campaigns
    /// </summary>
    /// <returns>
    /// A task that represents the asynchronous operation
    /// The task result contains the collection of allowed (supported) message tokens for campaigns
    /// </returns>
    public virtual async Task<IEnumerable<string>> GetListOfCampaignAllowedTokensAsync()
    {
        var additionalTokens = new CampaignAdditionalTokensAddedEvent();
        await _eventPublisher.PublishAsync(additionalTokens);

        var allowedTokens = (await GetListOfAllowedTokensAsync(new[] { TokenGroupNames.StoreTokens, TokenGroupNames.SubscriptionTokens })).ToList();
        allowedTokens.AddRange(additionalTokens.AdditionalTokens);

        return allowedTokens.Distinct();
    }

    /// <summary>
    /// Get collection of allowed (supported) message tokens
    /// </summary>
    /// <param name="tokenGroups">Collection of token groups; pass null to get all available tokens</param>
    /// <returns>
    /// A task that represents the asynchronous operation
    /// The task result contains the collection of allowed message tokens
    /// </returns>
    public virtual async Task<IEnumerable<string>> GetListOfAllowedTokensAsync(IList<string> tokenGroups = null)
    {
        var additionalTokens = new AdditionalTokensAddedEvent
        {
            TokenGroups = tokenGroups ?? new List<string>()
        };
        await _eventPublisher.PublishAsync(additionalTokens);

        var allowedTokens = AllowedTokens.Where(x => tokenGroups == null || tokenGroups.Contains(x.Key))
            .SelectMany(x => x.Value).ToList();

        allowedTokens.AddRange(additionalTokens.AdditionalTokens);

        return allowedTokens.Distinct();
    }

    /// <summary>
    /// Get token groups of message template
    /// </summary>
    /// <param name="messageTemplate">Message template</param>
    /// <returns>Collection of token group names</returns>
    public virtual IEnumerable<string> GetTokenGroups(MessageTemplate messageTemplate)
    {
        //groups depend on which tokens are added at the appropriate methods in IWorkflowMessageService
        return messageTemplate.Name switch
        {
            MessageTemplateSystemNames.CUSTOMER_REGISTERED_STORE_OWNER_NOTIFICATION or
            MessageTemplateSystemNames.CUSTOMER_WELCOME_MESSAGE or
            MessageTemplateSystemNames.CUSTOMER_EMAIL_VALIDATION_MESSAGE or
            MessageTemplateSystemNames.CUSTOMER_EMAIL_REVALIDATION_MESSAGE or
            MessageTemplateSystemNames.CUSTOMER_PASSWORD_RECOVERY_MESSAGE or
            MessageTemplateSystemNames.DELETE_CUSTOMER_REQUEST_STORE_OWNER_NOTIFICATION => new[] { TokenGroupNames.StoreTokens, TokenGroupNames.CustomerTokens },

            MessageTemplateSystemNames.ORDER_PLACED_VENDOR_NOTIFICATION or
            MessageTemplateSystemNames.ORDER_PLACED_STORE_OWNER_NOTIFICATION or
            MessageTemplateSystemNames.ORDER_PLACED_AFFILIATE_NOTIFICATION or
            MessageTemplateSystemNames.ORDER_PAID_STORE_OWNER_NOTIFICATION or
            MessageTemplateSystemNames.ORDER_PAID_CUSTOMER_NOTIFICATION or
            MessageTemplateSystemNames.ORDER_PAID_VENDOR_NOTIFICATION or
            MessageTemplateSystemNames.ORDER_PAID_AFFILIATE_NOTIFICATION or
            MessageTemplateSystemNames.ORDER_PLACED_CUSTOMER_NOTIFICATION or
            MessageTemplateSystemNames.ORDER_PROCESSING_CUSTOMER_NOTIFICATION or
            MessageTemplateSystemNames.ORDER_COMPLETED_CUSTOMER_NOTIFICATION or
            MessageTemplateSystemNames.ORDER_COMPLETED_STORE_OWNER_NOTIFICATION or
            MessageTemplateSystemNames.ORDER_CANCELLED_VENDOR_NOTIFICATION or
            MessageTemplateSystemNames.ORDER_CANCELLED_CUSTOMER_NOTIFICATION or
            MessageTemplateSystemNames.ORDER_CANCELLED_STORE_OWNER_NOTIFICATION=> [TokenGroupNames.StoreTokens, TokenGroupNames.OrderTokens, TokenGroupNames.CustomerTokens],

            MessageTemplateSystemNames.SHIPMENT_SENT_CUSTOMER_NOTIFICATION or
            MessageTemplateSystemNames.SHIPMENT_READY_FOR_PICKUP_CUSTOMER_NOTIFICATION or
            MessageTemplateSystemNames.SHIPMENT_DELIVERED_CUSTOMER_NOTIFICATION => [TokenGroupNames.StoreTokens, TokenGroupNames.ShipmentTokens, TokenGroupNames.OrderTokens, TokenGroupNames.CustomerTokens],

            MessageTemplateSystemNames.ORDER_REFUNDED_STORE_OWNER_NOTIFICATION or
            MessageTemplateSystemNames.ORDER_REFUNDED_CUSTOMER_NOTIFICATION => [TokenGroupNames.StoreTokens, TokenGroupNames.OrderTokens, TokenGroupNames.RefundedOrderTokens, TokenGroupNames.CustomerTokens],

            MessageTemplateSystemNames.NEW_ORDER_NOTE_ADDED_CUSTOMER_NOTIFICATION => [TokenGroupNames.StoreTokens, TokenGroupNames.OrderNoteTokens, TokenGroupNames.OrderTokens, TokenGroupNames.CustomerTokens],

            MessageTemplateSystemNames.RECURRING_PAYMENT_CANCELLED_STORE_OWNER_NOTIFICATION or
            MessageTemplateSystemNames.RECURRING_PAYMENT_CANCELLED_CUSTOMER_NOTIFICATION or
            MessageTemplateSystemNames.RECURRING_PAYMENT_FAILED_CUSTOMER_NOTIFICATION => [TokenGroupNames.StoreTokens, TokenGroupNames.OrderTokens, TokenGroupNames.CustomerTokens, TokenGroupNames.RecurringPaymentTokens],

            MessageTemplateSystemNames.NEWSLETTER_SUBSCRIPTION_ACTIVATION_MESSAGE or
            MessageTemplateSystemNames.NEWSLETTER_SUBSCRIPTION_DEACTIVATION_MESSAGE => [TokenGroupNames.StoreTokens, TokenGroupNames.SubscriptionTokens],

            MessageTemplateSystemNames.EMAIL_A_FRIEND_MESSAGE => [TokenGroupNames.StoreTokens, TokenGroupNames.CustomerTokens, TokenGroupNames.ProductTokens, TokenGroupNames.EmailAFriendTokens],
            MessageTemplateSystemNames.WISHLIST_TO_FRIEND_MESSAGE => [TokenGroupNames.StoreTokens, TokenGroupNames.CustomerTokens, TokenGroupNames.WishlistToFriendTokens],

            MessageTemplateSystemNames.NEW_RETURN_REQUEST_STORE_OWNER_NOTIFICATION or
            MessageTemplateSystemNames.NEW_RETURN_REQUEST_CUSTOMER_NOTIFICATION or
            MessageTemplateSystemNames.RETURN_REQUEST_STATUS_CHANGED_CUSTOMER_NOTIFICATION => [TokenGroupNames.StoreTokens, TokenGroupNames.OrderTokens, TokenGroupNames.CustomerTokens, TokenGroupNames.ReturnRequestTokens],

            MessageTemplateSystemNames.NEW_FORUM_TOPIC_MESSAGE => [TokenGroupNames.StoreTokens, TokenGroupNames.ForumTopicTokens, TokenGroupNames.ForumTokens, TokenGroupNames.CustomerTokens],
            MessageTemplateSystemNames.NEW_FORUM_POST_MESSAGE => [TokenGroupNames.StoreTokens, TokenGroupNames.ForumPostTokens, TokenGroupNames.ForumTopicTokens, TokenGroupNames.ForumTokens, TokenGroupNames.CustomerTokens],
            MessageTemplateSystemNames.PRIVATE_MESSAGE_NOTIFICATION => [TokenGroupNames.StoreTokens, TokenGroupNames.PrivateMessageTokens, TokenGroupNames.CustomerTokens],
            MessageTemplateSystemNames.NEW_VENDOR_ACCOUNT_APPLY_STORE_OWNER_NOTIFICATION => [TokenGroupNames.StoreTokens, TokenGroupNames.CustomerTokens, TokenGroupNames.VendorTokens],
            MessageTemplateSystemNames.VENDOR_INFORMATION_CHANGE_STORE_OWNER_NOTIFICATION => [TokenGroupNames.StoreTokens, TokenGroupNames.VendorTokens],
            MessageTemplateSystemNames.GIFT_CARD_NOTIFICATION => [TokenGroupNames.StoreTokens, TokenGroupNames.GiftCardTokens],

            MessageTemplateSystemNames.PRODUCT_REVIEW_STORE_OWNER_NOTIFICATION or
            MessageTemplateSystemNames.PRODUCT_REVIEW_REPLY_CUSTOMER_NOTIFICATION => [TokenGroupNames.StoreTokens, TokenGroupNames.ProductReviewTokens, TokenGroupNames.CustomerTokens],

            MessageTemplateSystemNames.QUANTITY_BELOW_STORE_OWNER_NOTIFICATION => [TokenGroupNames.StoreTokens, TokenGroupNames.ProductTokens],
            MessageTemplateSystemNames.QUANTITY_BELOW_VENDOR_NOTIFICATION => [TokenGroupNames.StoreTokens, TokenGroupNames.ProductTokens],
            MessageTemplateSystemNames.QUANTITY_BELOW_ATTRIBUTE_COMBINATION_STORE_OWNER_NOTIFICATION => [TokenGroupNames.StoreTokens, TokenGroupNames.ProductTokens, TokenGroupNames.AttributeCombinationTokens],
            MessageTemplateSystemNames.QUANTITY_BELOW_ATTRIBUTE_COMBINATION_VENDOR_NOTIFICATION => [TokenGroupNames.StoreTokens, TokenGroupNames.ProductTokens, TokenGroupNames.AttributeCombinationTokens],
            MessageTemplateSystemNames.NEW_VAT_SUBMITTED_STORE_OWNER_NOTIFICATION => [TokenGroupNames.StoreTokens, TokenGroupNames.CustomerTokens, TokenGroupNames.VatValidation],
            MessageTemplateSystemNames.BLOG_COMMENT_STORE_OWNER_NOTIFICATION => [TokenGroupNames.StoreTokens, TokenGroupNames.BlogCommentTokens, TokenGroupNames.CustomerTokens],
            MessageTemplateSystemNames.NEWS_COMMENT_STORE_OWNER_NOTIFICATION => [TokenGroupNames.StoreTokens, TokenGroupNames.NewsCommentTokens, TokenGroupNames.CustomerTokens],
            MessageTemplateSystemNames.BACK_IN_STOCK_NOTIFICATION => [TokenGroupNames.StoreTokens, TokenGroupNames.CustomerTokens, TokenGroupNames.ProductBackInStockTokens],
            MessageTemplateSystemNames.CONTACT_US_MESSAGE => [TokenGroupNames.StoreTokens, TokenGroupNames.ContactUs],
            MessageTemplateSystemNames.CONTACT_VENDOR_MESSAGE => [TokenGroupNames.StoreTokens, TokenGroupNames.ContactVendor],
            _ => [],
        };
    }

    #endregion
}