using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Security.Claims;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Cache;
using Umbraco.Cms.Core.Configuration.Grid;
using Umbraco.Cms.Core.Configuration.Models;
using Umbraco.Cms.Core.Hosting;
using Umbraco.Cms.Core.Manifest;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Security;
using Umbraco.Cms.Core.Serialization;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Core.WebAssets;
using Umbraco.Cms.Infrastructure.WebAssets;
using Umbraco.Cms.Web.BackOffice.ActionResults;
using Umbraco.Cms.Web.BackOffice.Filters;
using Umbraco.Cms.Web.BackOffice.Security;
using Umbraco.Cms.Web.Common.ActionsResults;
using Umbraco.Cms.Web.Common.Attributes;
using Umbraco.Cms.Web.Common.Authorization;
using Umbraco.Cms.Web.Common.Controllers;
using Umbraco.Cms.Web.Common.DependencyInjection;
using Umbraco.Cms.Web.Common.Filters;
using Umbraco.Extensions;
using Constants = Umbraco.Cms.Core.Constants;
using SignInResult = Microsoft.AspNetCore.Identity.SignInResult;

namespace Umbraco.Cms.Web.BackOffice.Controllers
{
    [DisableBrowserCache]
    [UmbracoRequireHttps]
    [PluginController(Constants.Web.Mvc.BackOfficeArea)]
    [IsBackOffice]
    public class BackOfficeController : UmbracoController
    {
        // See here for examples of what a lot of this is doing: https://github.com/dotnet/aspnetcore/blob/main/src/Identity/samples/IdentitySample.Mvc/Controllers/AccountController.cs
        // along with our AuthenticationController

        // NOTE: Each action must either be explicitly authorized or explicitly [AllowAnonymous], the latter is optional because
        // this controller itself doesn't require authz but it's more clear what the intention is.

        private readonly IBackOfficeUserManager _userManager;
        private readonly IRuntimeState _runtimeState;
        private readonly IRuntimeMinifier _runtimeMinifier;
        private GlobalSettings _globalSettings;
        private readonly IHostingEnvironment _hostingEnvironment;
        private readonly ILocalizedTextService _textService;
        private readonly IGridConfig _gridConfig;
        private readonly BackOfficeServerVariables _backOfficeServerVariables;
        private readonly AppCaches _appCaches;
        private readonly IBackOfficeSignInManager _signInManager;
        private readonly IBackOfficeSecurityAccessor _backofficeSecurityAccessor;
        private readonly ILogger<BackOfficeController> _logger;
        private readonly IJsonSerializer _jsonSerializer;
        private readonly IBackOfficeExternalLoginProviders _externalLogins;
        private readonly IHttpContextAccessor _httpContextAccessor;
        private readonly IBackOfficeTwoFactorOptions _backOfficeTwoFactorOptions;
        private readonly IManifestParser _manifestParser;
        private readonly ServerVariablesParser _serverVariables;
        private readonly IOptions<SecuritySettings> _securitySettings;


        [ActivatorUtilitiesConstructor]
        public BackOfficeController(
            IBackOfficeUserManager userManager,
            IRuntimeState runtimeState,
            IRuntimeMinifier runtimeMinifier,
            IOptionsSnapshot<GlobalSettings> globalSettings,
            IHostingEnvironment hostingEnvironment,
            ILocalizedTextService textService,
            IGridConfig gridConfig,
            BackOfficeServerVariables backOfficeServerVariables,
            AppCaches appCaches,
            IBackOfficeSignInManager signInManager,
            IBackOfficeSecurityAccessor backofficeSecurityAccessor,
            ILogger<BackOfficeController> logger,
            IJsonSerializer jsonSerializer,
            IBackOfficeExternalLoginProviders externalLogins,
            IHttpContextAccessor httpContextAccessor,
            IBackOfficeTwoFactorOptions backOfficeTwoFactorOptions,
            IManifestParser manifestParser,
            ServerVariablesParser serverVariables,
            IOptions<SecuritySettings> securitySettings)
        {
            _userManager = userManager;
            _runtimeState = runtimeState;
            _runtimeMinifier = runtimeMinifier;
            _globalSettings = globalSettings.Value;
            _hostingEnvironment = hostingEnvironment;
            _textService = textService;
            _gridConfig = gridConfig ?? throw new ArgumentNullException(nameof(gridConfig));
            _backOfficeServerVariables = backOfficeServerVariables;
            _appCaches = appCaches;
            _signInManager = signInManager;
            _backofficeSecurityAccessor = backofficeSecurityAccessor;
            _logger = logger;
            _jsonSerializer = jsonSerializer;
            _externalLogins = externalLogins;
            _httpContextAccessor = httpContextAccessor;
            _backOfficeTwoFactorOptions = backOfficeTwoFactorOptions;
            _manifestParser = manifestParser;
            _serverVariables = serverVariables;
            _securitySettings = securitySettings;
        }

        [HttpGet]
        [AllowAnonymous]
        public async Task<IActionResult> Default()
        {
            // Check if we not are in an run state, if so we need to redirect
            if (_runtimeState.Level != RuntimeLevel.Run)
            {
                return Redirect("/");
            }

            // force authentication to occur since this is not an authorized endpoint
            AuthenticateResult result = await this.AuthenticateBackOfficeAsync();

            var viewPath = Path.Combine(Constants.SystemDirectories.Umbraco, Constants.Web.Mvc.BackOfficeArea, nameof(Default) + ".cshtml")
                .Replace("\\", "/"); // convert to forward slashes since it's a virtual path

            return await RenderDefaultOrProcessExternalLoginAsync(
                result,
                () => View(viewPath),
                () => View(viewPath));
        }

        [HttpGet]
        [AllowAnonymous]
        public async Task<IActionResult> VerifyInvite(string invite)
        {
            var authenticate = await this.AuthenticateBackOfficeAsync();

            //if you are hitting VerifyInvite, you're already signed in as a different user, and the token is invalid
            //you'll exit on one of the return RedirectToAction(nameof(Default)) but you're still logged in so you just get
            //dumped at the default admin view with no detail
            if (authenticate.Succeeded)
            {
                await _signInManager.SignOutAsync();
            }

            if (invite == null)
            {
                _logger.LogWarning("VerifyUser endpoint reached with invalid token: NULL");
                return RedirectToAction(nameof(Default));
            }

            var parts = System.Net.WebUtility.UrlDecode(invite).Split('|');

            if (parts.Length != 2)
            {
                _logger.LogWarning("VerifyUser endpoint reached with invalid token: {Invite}", invite);
                return RedirectToAction(nameof(Default));
            }

            var token = parts[1];

            var decoded = token.FromUrlBase64();
            if (decoded.IsNullOrWhiteSpace())
            {
                _logger.LogWarning("VerifyUser endpoint reached with invalid token: {Invite}", invite);
                return RedirectToAction(nameof(Default));
            }

            var id = parts[0];

            var identityUser = await _userManager.FindByIdAsync(id);
            if (identityUser == null)
            {
                _logger.LogWarning("VerifyUser endpoint reached with non existing user: {UserId}", id);
                return RedirectToAction(nameof(Default));
            }

            var result = await _userManager.ConfirmEmailAsync(identityUser, decoded!);

            if (result.Succeeded == false)
            {
                _logger.LogWarning("Could not verify email, Error: {Errors}, Token: {Invite}", result.Errors.ToErrorMessage(), invite);
                return new RedirectResult(Url.Action(nameof(Default)) + "#/login/false?invite=3");
            }

            //sign the user in
            DateTime? previousLastLoginDate = identityUser.LastLoginDateUtc;
            await _signInManager.SignInAsync(identityUser, false);
            //reset the lastlogindate back to previous as the user hasn't actually logged in, to add a flag or similar to BackOfficeSignInManager would be a breaking change
            identityUser.LastLoginDateUtc = previousLastLoginDate;
            await _userManager.UpdateAsync(identityUser);

            return new RedirectResult(Url.Action(nameof(Default)) + "#/login/false?invite=1");
        }

        /// <summary>
        /// This Action is used by the installer when an upgrade is detected but the admin user is not logged in. We need to
        /// ensure the user is authenticated before the install takes place so we redirect here to show the standard login screen.
        /// </summary>
        /// <returns></returns>
        [HttpGet]
        [StatusCodeResult(System.Net.HttpStatusCode.ServiceUnavailable)]
        [AllowAnonymous]
        public async Task<IActionResult> AuthorizeUpgrade()
        {
            // force authentication to occur since this is not an authorized endpoint
            var result = await this.AuthenticateBackOfficeAsync();

            var viewPath = Path.Combine(Constants.SystemDirectories.Umbraco, Constants.Web.Mvc.BackOfficeArea, nameof(AuthorizeUpgrade) + ".cshtml");

            return await RenderDefaultOrProcessExternalLoginAsync(
                result,
                //The default view to render when there is no external login info or errors
                () => View(viewPath),
                //The IActionResult to perform if external login is successful
                () => Redirect("/"));
        }

        /// <summary>
        /// Returns the JavaScript main file including all references found in manifests
        /// </summary>
        /// <returns></returns>
        [MinifyJavaScriptResult(Order = 0)]
        [HttpGet]
        [AllowAnonymous]
        public async Task<IActionResult> Application()
        {
            var result = await _runtimeMinifier.GetScriptForLoadingBackOfficeAsync(
                _globalSettings,
                _hostingEnvironment,
                _manifestParser);

            return new JavaScriptResult(result);
        }

        /// <summary>
        /// Get the json localized text for a given culture or the culture for the current user
        /// </summary>
        /// <param name="culture"></param>
        /// <returns></returns>
        [HttpGet]
        [AllowAnonymous]
        public async Task<Dictionary<string, Dictionary<string, string>>> LocalizedText(string? culture = null)
        {
            CultureInfo? cultureInfo;
            if (string.IsNullOrWhiteSpace(culture))
            {
                // Force authentication to occur since this is not an authorized endpoint, we need this to get a user.
                AuthenticateResult authenticationResult = await this.AuthenticateBackOfficeAsync();
                // We have to get the culture from the Identity, we can't rely on thread culture
                // It's entirely likely for a user to have a different culture in the backoffice, than their system.
                var user = authenticationResult.Principal?.Identity;

                cultureInfo = (authenticationResult.Succeeded && user is not null)
                    ? user.GetCulture()
                    : CultureInfo.GetCultureInfo(_globalSettings.DefaultUILanguage);
            }
            else
            {
                cultureInfo = CultureInfo.GetCultureInfo(culture);
            }

            var allValues = _textService.GetAllStoredValues(cultureInfo!);
            var pathedValues = allValues.Select(kv =>
            {
                var slashIndex = kv.Key.IndexOf('/');
                var areaAlias = kv.Key.Substring(0, slashIndex);
                var valueAlias = kv.Key.Substring(slashIndex + 1);
                return new
                {
                    areaAlias,
                    valueAlias,
                    value = kv.Value
                };
            });

            var nestedDictionary = pathedValues
                .GroupBy(pv => pv.areaAlias)
                .ToDictionary(pv => pv.Key, pv =>
                    pv.ToDictionary(pve => pve.valueAlias, pve => pve.value));

            return nestedDictionary;
        }

        [Authorize(Policy = AuthorizationPolicies.BackOfficeAccess)]
        [AngularJsonOnlyConfiguration]
        [HttpGet]
        public IEnumerable<IGridEditorConfig> GetGridConfig()
        {
            return _gridConfig.EditorsConfig.Editors;
        }

        /// <summary>
        /// Returns the JavaScript object representing the static server variables javascript object
        /// </summary>
        [Authorize(Policy = AuthorizationPolicies.BackOfficeAccess)]
        [MinifyJavaScriptResult(Order = 1)]
        public async Task<JavaScriptResult> ServerVariables()
        {
            // cache the result if debugging is disabled
            var serverVars = await _serverVariables.ParseAsync(await _backOfficeServerVariables.GetServerVariablesAsync());
            var result = _hostingEnvironment.IsDebugMode
                ? serverVars
                : _appCaches.RuntimeCache.GetCacheItem<string>(
                    typeof(BackOfficeController) + "ServerVariables",
                    () => serverVars,
                    new TimeSpan(0, 10, 0));

            return new JavaScriptResult(result);
        }

        [HttpPost]
        [AllowAnonymous]
        public ActionResult ExternalLogin(string provider, string? redirectUrl = null)
        {
            if (redirectUrl == null || Uri.TryCreate(redirectUrl, UriKind.Absolute, out _))
            {
                redirectUrl = Url.Action(nameof(Default), this.GetControllerName());
            }

            // Configures the redirect URL and user identifier for the specified external login
            var properties = _signInManager.ConfigureExternalAuthenticationProperties(provider, redirectUrl);

            return Challenge(properties, provider);
        }

        /// <summary>
        /// Called when a user links an external login provider in the back office
        /// </summary>
        /// <param name="provider"></param>
        /// <returns></returns>
        [Authorize(Policy = AuthorizationPolicies.BackOfficeAccess)]
        [HttpPost]
        public ActionResult LinkLogin(string provider)
        {
            // Request a redirect to the external login provider to link a login for the current user
            var redirectUrl = Url.Action(nameof(ExternalLinkLoginCallback), this.GetControllerName());

            // Configures the redirect URL and user identifier for the specified external login including xsrf data
            var properties = _signInManager.ConfigureExternalAuthenticationProperties(provider, redirectUrl, _userManager.GetUserId(User));

            return Challenge(properties, provider);
        }

        [HttpGet]
        [AllowAnonymous]
        public async Task<IActionResult> ValidatePasswordResetCode([Bind(Prefix = "u")]int userId, [Bind(Prefix = "r")]string resetCode)
        {
            var user = await _userManager.FindByIdAsync(userId.ToString(CultureInfo.InvariantCulture));
            if (user != null)
            {
                var result = await _userManager.VerifyUserTokenAsync(user, "Default", "ResetPassword", resetCode);
                if (result)
                {
                    //Add a flag and redirect for it to be displayed
                    TempData[ViewDataExtensions.TokenPasswordResetCode] =  _jsonSerializer.Serialize(new ValidatePasswordResetCodeModel { UserId = userId, ResetCode = resetCode });
                    return RedirectToLocal(Url.Action(nameof(Default), this.GetControllerName()));
                }
            }

            //Add error and redirect for it to be displayed
            TempData[ViewDataExtensions.TokenPasswordResetCode] = new[] { _textService.Localize("login","resetCodeExpired") };
            return RedirectToLocal(Url.Action(nameof(Default), this.GetControllerName()));
        }

        /// <summary>
        /// Callback path when the user initiates a link login request from the back office to the external provider from the <see cref="LinkLogin(string)"/> action
        /// </summary>
        /// <remarks>
        /// An example of this is here https://github.com/dotnet/aspnetcore/blob/main/src/Identity/samples/IdentitySample.Mvc/Controllers/AccountController.cs#L155
        /// which this is based on
        /// </remarks>
        [Authorize(Policy = AuthorizationPolicies.BackOfficeAccess)]
        [HttpGet]
        public async Task<IActionResult> ExternalLinkLoginCallback()
        {
            BackOfficeIdentityUser user = await _userManager.GetUserAsync(User);
            if (user == null)
            {
                // ... this should really not happen
                TempData[ViewDataExtensions.TokenExternalSignInError] = new[] { "Local user does not exist" };
                return RedirectToLocal(Url.Action(nameof(Default), this.GetControllerName()));
            }

            ExternalLoginInfo? info = await _signInManager.GetExternalLoginInfoAsync(await _userManager.GetUserIdAsync(user));

            if (info == null)
            {
                //Add error and redirect for it to be displayed
                TempData[ViewDataExtensions.TokenExternalSignInError] = new[] { "An error occurred, could not get external login info" };
                return RedirectToLocal(Url.Action(nameof(Default), this.GetControllerName()));
            }

            IdentityResult addLoginResult = await _userManager.AddLoginAsync(user, info);
            if (addLoginResult.Succeeded)
            {
                // Update any authentication tokens if succeeded
                await _signInManager.UpdateExternalAuthenticationTokensAsync(info);

                return RedirectToLocal(Url.Action(nameof(Default), this.GetControllerName()));
            }

            //Add errors and redirect for it to be displayed
            TempData[ViewDataExtensions.TokenExternalSignInError] = addLoginResult.Errors;
            return RedirectToLocal(Url.Action(nameof(Default), this.GetControllerName()));
        }

        /// <summary>
        /// Used by Default and AuthorizeUpgrade to render as per normal if there's no external login info,
        /// otherwise process the external login info.
        /// </summary>
        /// <returns></returns>
        private async Task<IActionResult> RenderDefaultOrProcessExternalLoginAsync(
            AuthenticateResult authenticateResult,
            Func<IActionResult> defaultResponse,
            Func<IActionResult> externalSignInResponse)
        {
            if (defaultResponse is null) throw new ArgumentNullException(nameof(defaultResponse));
            if (externalSignInResponse is null) throw new ArgumentNullException(nameof(externalSignInResponse));

            ViewData.SetUmbracoPath(_globalSettings.GetUmbracoMvcArea(_hostingEnvironment));

            //check if there is the TempData or cookies with the any token name specified, if so, assign to view bag and render the view
            if (ViewData.FromBase64CookieData<BackOfficeExternalLoginProviderErrors>(_httpContextAccessor.HttpContext, ViewDataExtensions.TokenExternalSignInError, _jsonSerializer) ||
                ViewData.FromTempData(TempData, ViewDataExtensions.TokenExternalSignInError) ||
                ViewData.FromTempData(TempData, ViewDataExtensions.TokenPasswordResetCode))
            {
                return defaultResponse();
            }

            //First check if there's external login info, if there's not proceed as normal
            var loginInfo = await _signInManager.GetExternalLoginInfoAsync();

            if (loginInfo == null || loginInfo.Principal == null)
            {
                // if the user is not logged in, check if there's any auto login redirects specified
                if (!authenticateResult.Succeeded)
                {
                    var oauthRedirectAuthProvider = _externalLogins.GetAutoLoginProvider();
                    if (!oauthRedirectAuthProvider.IsNullOrWhiteSpace())
                    {
                        return ExternalLogin(oauthRedirectAuthProvider!);
                    }
                }

                return defaultResponse();
            }

            //we're just logging in with an external source, not linking accounts
            return await ExternalSignInAsync(loginInfo, externalSignInResponse);
        }

        private async Task<IActionResult> ExternalSignInAsync(ExternalLoginInfo loginInfo, Func<IActionResult> response)
        {
            if (loginInfo == null) throw new ArgumentNullException(nameof(loginInfo));
            if (response == null) throw new ArgumentNullException(nameof(response));

            // Sign in the user with this external login provider (which auto links, etc...)
            SignInResult result = await _signInManager.ExternalLoginSignInAsync(loginInfo, isPersistent: false, bypassTwoFactor: _securitySettings.Value.UserBypassTwoFactorForExternalLogins);

            var errors = new List<string>();

            if (result == SignInResult.Success)
            {
                // Update any authentication tokens if succeeded
                await _signInManager.UpdateExternalAuthenticationTokensAsync(loginInfo);

                // Check if we are in an upgrade state, if so we need to redirect
                if (_runtimeState.Level == Core.RuntimeLevel.Upgrade)
                {
                    // redirect to the the installer
                    return Redirect("/");
                }
            }
            else if (result == SignInResult.TwoFactorRequired)
            {

                var attemptedUser = await _userManager.FindByLoginAsync(loginInfo.LoginProvider, loginInfo.ProviderKey);
                if (attemptedUser == null)
                {
                    return new ValidationErrorResult($"No local user found for the login provider {loginInfo.LoginProvider} - {loginInfo.ProviderKey}");
                }

                var twofactorView = _backOfficeTwoFactorOptions.GetTwoFactorView(attemptedUser.UserName);
                if (twofactorView.IsNullOrWhiteSpace())
                {
                    return new ValidationErrorResult($"The registered {typeof(IBackOfficeTwoFactorOptions)} of type {_backOfficeTwoFactorOptions.GetType()} did not return a view for two factor auth ");
                }

                // create a with information to display a custom two factor send code view
                var verifyResponse = new ObjectResult(new
                {
                    twoFactorView = twofactorView,
                    userId = attemptedUser.Id
                })
                {
                    StatusCode = StatusCodes.Status402PaymentRequired
                };

                return verifyResponse;

            }
            else if (result == SignInResult.LockedOut)
            {
                errors.Add($"The local user {loginInfo.Principal.Identity?.Name} for the external provider {loginInfo.ProviderDisplayName} is locked out.");
            }
            else if (result == SignInResult.NotAllowed)
            {
                // This occurs when SignInManager.CanSignInAsync fails which is when RequireConfirmedEmail , RequireConfirmedPhoneNumber or RequireConfirmedAccount fails
                // however since we don't enforce those rules (yet) this shouldn't happen.
                errors.Add($"The user {loginInfo.Principal.Identity?.Name} for the external provider {loginInfo.ProviderDisplayName} has not confirmed their details and cannot sign in.");
            }
            else if (result == SignInResult.Failed)
            {
                // Failed only occurs when the user does not exist
                errors.Add("The requested provider (" + loginInfo.LoginProvider + ") has not been linked to an account, the provider must be linked from the back office.");
            }
            else if (result == ExternalLoginSignInResult.NotAllowed)
            {
                // This occurs when the external provider has approved the login but custom logic in OnExternalLogin has denined it.
                errors.Add($"The user {loginInfo.Principal.Identity?.Name} for the external provider {loginInfo.ProviderDisplayName} has not been accepted and cannot sign in.");
            }
            else if (result == AutoLinkSignInResult.FailedNotLinked)
            {
                errors.Add("The requested provider (" + loginInfo.LoginProvider + ") has not been linked to an account, the provider must be linked from the back office.");
            }
            else if (result == AutoLinkSignInResult.FailedNoEmail)
            {
                errors.Add($"The requested provider ({loginInfo.LoginProvider}) has not provided the email claim {ClaimTypes.Email}, the account cannot be linked.");
            }
            else if (result is AutoLinkSignInResult autoLinkSignInResult && autoLinkSignInResult.Errors.Count > 0)
            {
                errors.AddRange(autoLinkSignInResult.Errors);
            }
            else if (!result.Succeeded)
            {
                // this shouldn't occur, the above should catch the correct error but we'll be safe just in case
                errors.Add($"An unknown error with the requested provider ({loginInfo.LoginProvider}) occurred.");
            }

            if (errors.Count > 0)
            {
                ViewData.SetExternalSignInProviderErrors(
                    new BackOfficeExternalLoginProviderErrors(
                        loginInfo.LoginProvider,
                        errors));
            }

            return response();
        }

        private IActionResult RedirectToLocal(string? returnUrl)
        {
            if (Url.IsLocalUrl(returnUrl))
            {
                return Redirect(returnUrl);
            }
            return Redirect("/");
        }


    }
}
