using System.Diagnostics;
using System.Reflection;
using System.Runtime.InteropServices;
using System.Security.Claims;
using System.Text.Json;
using System.Text.Json.Serialization;
using Fast.Shared.LocalMode;
using Fast.Shared.Logic.FileService;
using Hangfire;
using Hangfire.Dashboard;
using Hangfire.PostgreSql;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
using Microsoft.AspNetCore.Authorization.Policy;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc.ApplicationParts;
using Microsoft.AspNetCore.Mvc.Authorization;
using Microsoft.AspNetCore.OData;
using Microsoft.IdentityModel.Logging;
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
using Microsoft.Net.Http.Headers;
using Microsoft.OData.Edm;
using Npgsql;
using NpgsqlTypes;
using Serilog;
using Serilog.Events;
using Serilog.Sinks.PostgreSQL;

namespace Fast.Shared;

public static class ProgramShared
{
    //use IsAuthenticationEnabled to disable or enable authentication
    public static bool IsAuthenticationEnabled { get; set; } = true;
    public const int AuthExpirationDays = 30;
    public const string LoginHintCookieName = "LoginHintCookie";
    public const string FastAppBffCookieName = "FastAppBffCookie";
    public static bool UseLocalFiles { get; set; } = false;

    // When enabled, uses an in-memory database with seeded fake data instead of PostgreSQL.
    // Authentication is also disabled when this is enabled.
    public static bool IsLocalModeEnabled { get; private set; } = false;

    // Database name for local mode in-memory database
    private const string LocalModeDbName = "LocalModeDb";

    //use to force enable serilog in development, by default it is enabled in production and disabled in development
    public static bool ForceUseSerilog { get; set; } = false;
    public static string DevPort { get; set; } = "";
    public static IEdmModel? CustomEdmModel { get; set; }
    public static string[] DirectoriesToCreate { get; set; } = Array.Empty<string>();
    public static LockoutCache LockoutCache { get; set; } = null!;
    public static AuthCache AuthCache { get; set; } = null!;
    public static ScreenCache ScreenCache { get; set; } = null!;

    // public static TokenValidationParameters TokenValidationParameters { get; set; } = new();

    private static WebApplication? app;
    public static bool IsDevOrTest { get { return IsDevEnvOrDb || IsTestDb; } }
    public static bool IsDevEnvOrDb { get { return IsDevEnv || IsDevDb; } }
    public static bool IsDevEnv { get; set; } = false;
    public static bool IsDevDb { get; private set; } = false;
    public static bool IsTestDb { get; private set; } = false;
    private static string ConnectionString { get; set; } = "";
    private static DbContextOptions<MyDbContext> dbOptions = null!;
    private static DbContextOptions<MyDbContext> dbOptionsLogging = null!;
    public static NpgsqlDataSource DataSource { get; private set; } = null!;

    //used to disable or enable hangfire
    public static bool IsHangfireEnabled
    {
        get
        {
            if (FastValues.ModuleName.EndsWith("jobs", StringComparison.OrdinalIgnoreCase))
                return true;
            else
                return false;
        }
    }

    public static void Configure(string[] args, Type? JobsHelperClassType)
    {
        try
        {
            var webAppOps = new WebApplicationOptions() { Args = args };
            var builder = WebApplication.CreateBuilder(webAppOps);
            SetupDatabaseConnection();

            if (IsLocalModeEnabled)
                SeedLocalModeDatabase();

            FillCaches();
            ConfigureEnvProps(builder.Environment);
            // AddHttpContextAccessor() is needed for Enrich.WithClientIp, SerilogUserAgentEnricher, and HangfireAuthorizationFilter
            builder.Services.AddHttpContextAccessor();
            WriteMessage("Setting up web host");
            ConfigureBuilder(builder);
            app = builder.Build();
            if (!IsDevEnvOrDb || ForceUseSerilog)
                SetupSerilog();
            ConfigureWebHost();
            CreateHangfireJobs(builder.Environment, builder.Configuration, JobsHelperClassType);
            InitializeFileServiceDirectories();
        }
        catch (Exception ex)
        {
            var msg = $"Error: {Util.String.GetExceptionMessage(ex)}";
            Console.WriteLine(msg);
            Debug.WriteLine(msg);
            Log.Fatal(ex, "Host terminated unexpectedly");
            Log.CloseAndFlush();
        }
    }

    private static void SeedLocalModeDatabase()
    {
        Console.WriteLine("Seeding local mode database...");
        using var db = CreateContext();
        LocalDataSeeder.SeedAsync(db).GetAwaiter().GetResult();
    }

    static void InitializeFileServiceDirectories()
    {
        _ = Task.Run(async () =>
        {
            try
            {
                var config = new FileServiceConfig();
                var fileService = new FileService(config);
                await Task.CompletedTask;
                foreach (var directoryName in DirectoriesToCreate)
                    await fileService.CreateDirectoryAsync(directoryName);
                WriteMessage("File service directories initialized");
            }
            catch (Exception ex)
            {
                var msg = $"Error: {Util.String.GetExceptionMessage(ex)}";
                WriteMessage(msg);
            }
        });
    }

    public static MyDbContext CreateContext(bool enableLogging = false)
    {
        if (enableLogging)
            return new MyDbContext(dbOptionsLogging);
        else
            return new MyDbContext(dbOptions);
    }

    public static void CreateHangfireJobs(IHostEnvironment env, IConfiguration builtConfiguration, Type? JobsHelperClassType)
    {
        if (IsHangfireEnabled)
        {
            WriteMessage("Setting up Hangfire");
            var args = new object[] { new RecurringJobManager(), env, builtConfiguration };
            if (JobsHelperClassType == null)
                throw new Exception("JobsHelperClassType not found");

            var jobsHelper = Activator.CreateInstance(JobsHelperClassType, args) ?? throw new Exception("Could not create instance of JobsHelper");
            var method = JobsHelperClassType.GetMethod("CreateJobs") ?? throw new Exception("Could not find CreateJobs method of JobsHelper");
            WriteMessage("Creating Hangfire jobs");
            method.Invoke(jobsHelper, null);
        }
    }

    public static void WriteMessage(string message)
    {
        if (!IsDevEnvOrDb || ForceUseSerilog)
            Log.Information(message);
        else
            Debug.WriteLine(message);
    }

    public static void SetupDatabaseConnection()
    {
        var configuration = new ConfigurationBuilder()
                                .AddJsonFile("appsettings.json", optional: false, reloadOnChange: true)
                                .Build();

        if (IsLocalModeEnabled)
        {
            Console.WriteLine("=== LOCAL MODE ENABLED ===");
            Console.WriteLine("Using in-memory database with seeded data.");
            Console.WriteLine("Authentication is disabled.");

            // Auto-disable authentication in local mode
            IsAuthenticationEnabled = false;

            BuildLocalModeDbObjects();
        }
        else
        {
            var connString = configuration.GetConnectionString("Database") ?? throw new Exception("Connection string not found");
            ConnectionString = connString;

            BuildDbObjects();
        }
    }

    public static void ResetDatabaseConnection()
    {
        var oldDataSource = DataSource;
        try
        {
            BuildDbObjects();
            Console.WriteLine("Database connection and options have been reset with a new DataSource.");

            // Also reset Serilog if it is enabled, as it uses its own connection pool that needs refreshing
            if (!IsDevEnvOrDb || ForceUseSerilog)
            {
                SetupSerilog();
                Console.WriteLine("Serilog logger has been re-configured.");
            }
        }
        catch (Exception ex)
        {
            Console.WriteLine($"Error resetting database connection: {ex.Message}");
        }

        // Dispose the old source asynchronously to avoid blocking if connections are busy
        if (oldDataSource != null)
        {
            Task.Run(async () =>
            {
                try
                {
                    await oldDataSource.DisposeAsync();
                }
                catch
                {
                    // Ignore disposal errors
                }
            });
        }
    }

    private static void BuildDbObjects()
    {
        var dataSourceBuilder = new NpgsqlDataSourceBuilder(ConnectionString);
        DataSource = dataSourceBuilder.Build();

        var builder = new DbContextOptionsBuilder<MyDbContext>();
        AddDbOptions(builder);
        dbOptions = builder.Options;

        // Build an identical options, but with logging enabled
        var builderLogging = new DbContextOptionsBuilder<MyDbContext>();
        AddDbOptionsWithLogging(builderLogging);
        dbOptionsLogging = builderLogging.Options;
    }

    private static void BuildLocalModeDbObjects()
    {
        // Use a named in-memory database - EF Core will share the same database for all contexts using the same name
        var builder = new DbContextOptionsBuilder<MyDbContext>();
        builder.UseInMemoryDatabase(LocalModeDbName);
        dbOptions = builder.Options;

        // For local mode, logging options use the same in-memory database
        var builderLogging = new DbContextOptionsBuilder<MyDbContext>();
        builderLogging.UseInMemoryDatabase(LocalModeDbName);
        builderLogging.EnableSensitiveDataLogging();
        builderLogging.LogTo(Console.WriteLine, LogLevel.Information);
        dbOptionsLogging = builderLogging.Options;
    }

    private static void FillCaches()
    {
        LockoutCache = new LockoutCache(TimeSpan.FromMinutes(5));
        AuthCache = new AuthCache(TimeSpan.FromMinutes(5));
        ScreenCache = new ScreenCache(TimeSpan.FromMinutes(10));
    }

    private static void SetupSerilog()
    {
        var colOptions = new ColumnOptions();
        IDictionary<string, ColumnWriterBase> columnOptions = new Dictionary<string, ColumnWriterBase>
        {
            { "message", new RenderedMessageColumnWriter(NpgsqlDbType.Text) },
            { "message_template", new MessageTemplateColumnWriter(NpgsqlDbType.Text) },
            { "level", new LevelColumnWriter(true, NpgsqlDbType.Varchar) },
            { "raise_date", new TimestampColumnWriter(NpgsqlDbType.Timestamp) },
            { "exception", new ExceptionColumnWriter(NpgsqlDbType.Text) },
            { "properties", new LogEventSerializedColumnWriter(NpgsqlDbType.Jsonb) },
            { "props_test", new PropertiesColumnWriter(NpgsqlDbType.Jsonb) },
            { "machine_name", new SinglePropertyColumnWriter("MachineName", PropertyWriteMethod.ToString, NpgsqlDbType.Text, "l") },
            { "module", new SinglePropertyColumnWriter("Module", PropertyWriteMethod.Raw, NpgsqlDbType.Text) },
            { "user_id", new SinglePropertyColumnWriter("UserID", PropertyWriteMethod.Raw, NpgsqlDbType.Numeric) },
            { "client_ip", new SinglePropertyColumnWriter("ClientIp", PropertyWriteMethod.Raw, NpgsqlDbType.Text) },
            { "client_agent", new SinglePropertyColumnWriter("ClientAgent", PropertyWriteMethod.Raw, NpgsqlDbType.Text) },
            { "requent_method", new SinglePropertyColumnWriter("RequestMethod", PropertyWriteMethod.Raw, NpgsqlDbType.Text) },
            { "elapsed", new SinglePropertyColumnWriter("Elapsed", PropertyWriteMethod.Raw, NpgsqlDbType.Numeric) }
        };

        Log.Logger = new LoggerConfiguration()
            .Filter.ByExcluding(logEvent =>
            {
                var requestPath = logEvent.Properties.ContainsKey("RequestPath") ? logEvent.Properties["RequestPath"].ToString() : "";
                var isWsPath = requestPath == @"""/ws""";
                var isHangfirePath = requestPath.StartsWith(@"""/hangfire");
                if (isWsPath || isHangfirePath)
                    return true;
                else
                    return false;
            })
            .Enrich.WithMachineName()
            .Enrich.WithProperty("Module", FastValues.ModuleName) //custom constant property
            .Enrich.WithClientIp()
            .Enrich.With(new SerilogUserAgentEnricher(app!.Services.GetRequiredService<IHttpContextAccessor>()))
            .Enrich.FromLogContext() //allows custom variable properties from LogContext.PushProperty
            .WriteTo.Console()
            .WriteTo.File(
                path: "Logs/log.txt",
                fileSizeLimitBytes: 20971520, //20MB
                rollingInterval: RollingInterval.Day,
                retainedFileCountLimit: 31,
                restrictedToMinimumLevel: LogEventLevel.Error
            )
            .WriteTo.PostgreSQL(
                connectionString: ConnectionString,
                tableName: "app_log",
                columnOptions: columnOptions,
                restrictedToMinimumLevel: LogEventLevel.Information,
                period: TimeSpan.FromSeconds(5),
                formatProvider: null,
                batchSizeLimit: 50,
                levelSwitch: null,
                useCopy: true,
                schemaName: "public",
                needAutoCreateTable: true,
                respectCase: false
            )
            .MinimumLevel.Information()
            .MinimumLevel.Override("Microsoft", LogEventLevel.Information)
            .MinimumLevel.Override("Microsoft.AspNetCore", LogEventLevel.Warning) //this only prevents information level items from Microsoft.AspNetCore, general information messages generated by requests are still logged
            .MinimumLevel.Override("Microsoft.EntityFrameworkCore", LogEventLevel.Warning) //this only prevents information level items from Microsoft.EntityFrameworkCore, general information messages generated by requests are still logged
            .MinimumLevel.Override("Microsoft.Hosting.Lifetime", LogEventLevel.Information)
            .CreateLogger();
    }

    private static void ConfigureBuilder(WebApplicationBuilder builder)
    {
        builder.WebHost.UseSetting("https_port", "443");

        //EnableForHttps is not a security risk since Web APIs don't have the XSRF vulnerabilities that server side apps have
        //https://learn.microsoft.com/en-us/aspnet/core/performance/response-compression?view=aspnetcore-9.0
        //https://learn.microsoft.com/en-us/answers/questions/1825530/anti-forgery-tokens-implementation-in-asp-net-web
        builder.Services.AddResponseCompression(options => { options.EnableForHttps = true; });

        if (IsDevEnv)
        {
            //for dev environment: this moves the appsettings.json location to Fast.Shared
            var parent = Directory.GetParent(builder.Environment.ContentRootPath);
            if (parent?.FullName != null)
            {
                var path = Path.Join(parent.FullName, "Fast.Shared", "appsettings.json");
                builder.Configuration.AddJsonFile(path, optional: false, reloadOnChange: true);
            }
        }

        if (!IsDevEnvOrDb || ForceUseSerilog)
            builder.Host.UseSerilog();

        if (IsDevEnv)
        {
            var fastPortSuffix = GetFastPortSuffixAsync().Result;
            DevPort = $"543{fastPortSuffix}";
            var originUrl = $"https://127.0.0.1:{DevPort}";

            builder.Services.AddCors(options =>
            {
                options.AddDefaultPolicy(
                    policy =>
                    {
                        policy.WithOrigins(originUrl)
                              .AllowAnyHeader()
                              .AllowAnyMethod()
                              .AllowCredentials();
                    });
            });
        }

        var mvcBuilder = builder.Services.AddControllers()
            .AddOData(opt => opt.SetMaxTop(1000).AddRouteComponents("odata", CustomEdmModel));

        // Skip Hangfire in local mode (requires PostgreSQL)
        if (IsHangfireEnabled && !IsLocalModeEnabled)
        {
            //Serilog version mismatches may cause the below error:
            //"One or more errors occurred. (Could not load type 'Serilog.ConsoleLoggerConfigurationExtensions' from assembly 'Serilog.Sinks.Debug, Version=2.0.0.0, Culture=neutral, PublicKeyToken=24c2f752a8e58a10'.)"
            //The workaround might be to downgrade the app's Serilog.AspNetCore version to 3.4.0
            //Serilog.Enrichers.ClientInfo and Serilog.Sinks.MSSqlServer can be newer
            builder.Services.AddHangfire(hangFireConfig => hangFireConfig
                .SetDataCompatibilityLevel(CompatibilityLevel.Version_180)
                .UseSimpleAssemblyNameTypeSerializer()
                .UseRecommendedSerializerSettings()
                .UsePostgreSqlStorage(c =>
                {
                    c.UseNpgsqlConnection(ConnectionString);
                }, new PostgreSqlStorageOptions
                {
                    TransactionSynchronisationTimeout = TimeSpan.FromMilliseconds(500),
                    InvisibilityTimeout = TimeSpan.FromHours(1),
                    DistributedLockTimeout = TimeSpan.FromMinutes(10),
                    QueuePollInterval = TimeSpan.FromSeconds(15),
                    EnableLongPolling = true,
                    UseSlidingInvisibilityTimeout = true
                })
            );

            builder.Services.AddHangfireServer();
        }

        //this is needeed since controllers are split between two projects, Shared and Web
        var assembly1 = Assembly.Load("Fast.Web");
        var assembly2 = Assembly.Load("Fast.Shared");
        var appParts = mvcBuilder.PartManager.ApplicationParts;
        appParts.Add(new AssemblyPart(assembly1));
        appParts.Add(new AssemblyPart(assembly2));

        if (IsAuthenticationEnabled)
        {
            builder.Services.AddScoped<UserSynchronizationService>();

            var db = Main.CreateContext();
            var appSettings = db.AppSettings.AsNoTracking().ToList();
            string? GetValue(string name) => appSettings.FirstOrDefault(x => x.Name == name)?.Value;

            var instance = "https://login.microsoftonline.com/";
            var domain = GetValue("AzureAd-Domain");
            var tenantId = GetValue("AzureAd-TenantId") ?? GetValue("Graph-TenantID");
            var clientId = GetValue("AzureAd-ClientId") ?? GetValue("Graph-ClientID");
            var clientSecret = GetValue("AzureAd-ClientSecret") ?? GetValue("Graph-ClientSecret");
            var apiApplicationIdUri = clientId;

            builder.Services.AddAuthentication(options =>
            {
                options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
                options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme;
                options.DefaultSignInScheme = CookieAuthenticationDefaults.AuthenticationScheme;
            })
            .AddCookie(CookieAuthenticationDefaults.AuthenticationScheme, options =>
            {
                options.Cookie.Name = FastAppBffCookieName;
                options.Cookie.HttpOnly = true;
                options.Cookie.SameSite = Microsoft.AspNetCore.Http.SameSiteMode.Lax;
                options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
                options.ExpireTimeSpan = TimeSpan.FromDays(AuthExpirationDays);
                options.SlidingExpiration = true;

                options.Events.OnSigningIn = context =>
                {
                    var rootDomain = GetRootDomain(context.Request.Host);

                    if (!string.IsNullOrEmpty(rootDomain))
                    {
                        context.CookieOptions.Domain = rootDomain;
                    }
                    return Task.CompletedTask;
                };

                options.Events.OnValidatePrincipal = async context =>
                {
                    // This event is important for re-validating the cookie (e.g., against session expiry or changed claims)
                    // You can refresh tokens here if the access token is close to expiry
                    AppUser? appUser = null;
                    try
                    {
                        appUser = await UserSynchronizationService.FindAppUserByEntraEmailsAsync(context.Principal);
                    }
                    catch (InvalidCastException ex) when (ex.Message.Contains("DataTypeName '-'"))
                    {
                        // This error occurs when the database was overwritten while the app was running.
                        // Npgsql caches type mappings that become stale after a database restore.
                        // Clear all connection pools to force fresh connections with the new database.
                        NpgsqlConnection.ClearAllPools();
                        Console.WriteLine("Cleared Npgsql connection pools due to stale type mappings after database restore.");

                        // Reject the principal so the user can refresh and retry with fresh connections
                        context.RejectPrincipal();
                        return;
                    }
                    Console.WriteLine($"BFF Cookie Validate Principal: {appUser?.Email ?? string.Empty}");

                    // If you want to automatically refresh tokens using the refresh token stored in the cookie
                    // you would retrieve it from the context.Properties and call MSAL or similar.
                    // For simplicity here, we'll assume the token has a sufficient lifetime for the session.
                    // For production, consider using something like Microsoft.Identity.Web.TokenCacheProviders.Session
                    // or a custom persistent token store.

                    if (appUser == null)
                    {
                        // If the user no longer exists in your app DB, sign them out.
                        context.RejectPrincipal();
                        await context.HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
                        await context.HttpContext.SignOutAsync(OpenIdConnectDefaults.AuthenticationScheme);
                    }
                    return;
                };

                options.Events.OnRedirectToLogin = context =>
                {
                    if (context.Request.Path.StartsWithSegments("/api"))
                    {
                        // For API calls, return 401 instead of redirecting
                        context.Response.StatusCode = StatusCodes.Status401Unauthorized;
                    }
                    return Task.CompletedTask;
                };

                options.Events.OnRedirectToAccessDenied = context =>
                {
                    if (context.Request.Path.StartsWithSegments("/api"))
                    {
                        context.Response.StatusCode = StatusCodes.Status403Forbidden;
                    }
                    return Task.CompletedTask;
                };
            })
            .AddOpenIdConnect(OpenIdConnectDefaults.AuthenticationScheme, options =>
            {
                options.Authority = $"{instance}{tenantId}/v2.0/"; // Azure AD v2.0 endpoint
                options.ClientId = clientId;
                options.ResponseType = OpenIdConnectResponseType.Code; // PKCE flow
                options.ResponseMode = OpenIdConnectResponseMode.FormPost; // Recommended for SPAs
                options.SaveTokens = false; // Reduce cookie size
                options.GetClaimsFromUserInfoEndpoint = false;

                options.Scope.Add("openid");
                options.Scope.Add("profile");
                options.Scope.Add("email");
                options.Scope.Add("offline_access"); // Ensure the Scope includes "offline_access" if you need refresh tokens (optional but common in BFF)
                options.Scope.Add(apiApplicationIdUri + "/.default");

                options.CallbackPath = "/signin-oidc"; // Default callback path
                // options.SignedOutCallbackPath = "/signout-callback-oidc"; // Default signout callback path

                options.ClientSecret = clientSecret;

                options.TokenValidationParameters.NameClaimType = "name"; // Use 'name' from Azure AD as primary name claim
                options.TokenValidationParameters.RoleClaimType = ClaimTypes.Role;

                options.Events = new OpenIdConnectEvents
                {
                    OnRedirectToIdentityProvider = context =>
                    {
                        var storedLoginHintEmail = context.HttpContext.Request.Cookies[LoginHintCookieName];
                        if (!string.IsNullOrEmpty(storedLoginHintEmail))
                        {
                            context.ProtocolMessage.LoginHint = storedLoginHintEmail;
                        }
                        return Task.CompletedTask;
                    },
                    OnTokenValidated = async context =>
                    {
                        // This event fires after ID Token validation and before creating the ClaimsPrincipal.
                        // Access, ID, and Refresh tokens are saved by `SaveTokens = true`.
                        // They are stored within the authentication cookie in encrypted form.

                        var appUser = await UserSynchronizationService.FindAppUserByEntraEmailsAsync(context.Principal);
                        var email = appUser?.Email ?? string.Empty;

                        if (appUser == null)
                        {
                            // User exists in Entra ID but not in our application database.
                            // Reject authentication to prevent access.
                            context.Fail($"User '{email}' not found in application database.");
                            return;
                        }

                        // Store app_user.Id as the primary NameIdentifier and custom claim
                        if (context.Principal!.Identity is ClaimsIdentity claimsIdentity)
                        {
                            var existingNameIdentifier = claimsIdentity.FindFirst(ClaimTypes.NameIdentifier);
                            if (existingNameIdentifier != null) claimsIdentity.RemoveClaim(existingNameIdentifier);
                            var existingAppUserIdClaim = claimsIdentity.FindFirst(Util.AppUserIdClaimType);
                            if (existingAppUserIdClaim != null) claimsIdentity.RemoveClaim(existingAppUserIdClaim);

                            claimsIdentity.AddClaim(new Claim(ClaimTypes.NameIdentifier, appUser.Id.ToString(), ClaimValueTypes.Integer32));
                            claimsIdentity.AddClaim(new Claim(Util.AppUserIdClaimType, appUser.Id.ToString(), ClaimValueTypes.Integer32));

                            Console.WriteLine($"BFF OIDC Token Validated: Mapped Entra user '{email}' to app_user ID: {appUser.Id}");

                            RemoveUnneededClaims(claimsIdentity);

                            if (context.SecurityToken.RawData != null)
                            {
                                claimsIdentity.AddClaim(new Claim("id_token", context.SecurityToken.RawData));
                            }

                            // Set the login_hint cookie for future logins
                            var rootDomain = GetRootDomain(context.HttpContext.Request.Host);
                            var cookieOptions = new CookieOptions
                            {
                                Expires = DateTimeOffset.UtcNow.AddDays(AuthExpirationDays),
                                HttpOnly = false,
                                Secure = true,
                                SameSite = Microsoft.AspNetCore.Http.SameSiteMode.Lax
                            };
                            if (!string.IsNullOrEmpty(rootDomain))
                            {
                                cookieOptions.Domain = rootDomain;
                            }
                            context.HttpContext.Response.Cookies.Append(LoginHintCookieName, email, cookieOptions);
                            Console.WriteLine($"LoginHintCookie set (via BFF OIDC) for user: {email} on domain: {rootDomain ?? "current host"}");
                        }
                    },
                    OnAuthenticationFailed = context =>
                    {
                        Console.Error.WriteLine($"BFF OIDC Authentication Failed: {context.Exception.Message}");
                        context.HandleResponse(); // Prevent default error page

                        // Instead of /error, redirect to your BFF's Login action,
                        // passing a return URL so Angular knows where to go after successful login.
                        var returnUrl = Uri.EscapeDataString(context.Properties?.RedirectUri ?? "/");
                        var message = Uri.EscapeDataString(context.Exception.Message);
                        var redirectUrl = IsDevEnv
                            ? $"https://127.0.0.1:{DevPort}/api/Auth/Login?returnUrl={returnUrl}&message={message}"
                            : $"/api/Auth/Login?returnUrl={returnUrl}&message={message}";
                        context.Response.Redirect(redirectUrl);

                        return Task.CompletedTask;

                        // For debugging
                        // context.Response.StatusCode = 500;
                        // context.Response.ContentType = "text/plain";
                        // return context.Response.WriteAsync($"Authentication Failed: {context.Exception.ToString()}");
                    },
                    OnAccessDenied = context =>
                    {
                        Console.WriteLine($"BFF OIDC Access Denied: {context.Result?.ToString()}");
                        context.HandleResponse();
                        context.Response.Redirect("/accessdenied");
                        return Task.CompletedTask;
                    },
                    OnRemoteFailure = context =>
                    {
                        Console.Error.WriteLine($"BFF OIDC Remote Failure: {context.Failure?.Message}");
                        context.HandleResponse();

                        var returnUrl = Uri.EscapeDataString(context.Properties?.RedirectUri ?? "/");
                        var message = Uri.EscapeDataString(context.Failure?.Message ?? "Unknown remote authentication error");
                        var redirectUrl = IsDevEnv
                            ? $"https://127.0.0.1:{DevPort}/api/Auth/Login?returnUrl={returnUrl}&message={message}"
                            : $"/api/Auth/Login?returnUrl={returnUrl}&message={message}";
                        context.Response.Redirect(redirectUrl);

                        return Task.CompletedTask;

                        // For debugging
                        // context.Response.StatusCode = 500;
                        // context.Response.ContentType = "text/plain";
                        // return context.Response.WriteAsync($"Remote Failure: {context.Failure?.Message ?? "Unknown error"}");
                    },
                    OnTicketReceived = async context =>
                    {
                        // This is where you might perform any post-authentication user synchronization.
                        await Task.CompletedTask;
                    }
                };
            });

            builder.Services.AddScoped<IAuthorizationHandler, PermissionHandler>();
        }

        //https://learn.microsoft.com/en-us/aspnet/core/host-and-deploy/proxy-load-balancer#forward-the-scheme-for-linux-and-non-iis-reverse-proxies
        //this configures the forwarded header options which will later be used by app.UseForwardedHeaders
        builder.Services.Configure<ForwardedHeadersOptions>(options =>
        {
            options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
            options.KnownIPNetworks.Clear();
            options.KnownProxies.Clear();
        });

        builder.Services.AddSingleton<IAuthorizationPolicyProvider, AuthorizationPolicyProvider>();
        if (!IsAuthenticationEnabled)
            builder.Services.AddSingleton<IPolicyEvaluator, DisableAuthenticationPolicyEvaluator>();

        builder.Services.AddScoped<HangfireAuthorizationFilter>();

        builder.Services.AddMvc(ops =>
        {
            if (IsAuthenticationEnabled)
            {
                var policy = new AuthorizationPolicyBuilder()
                    .RequireAuthenticatedUser()
                    .RequireAssertion(async (ctx) =>
                    {
                        if (!ctx.User.Claims.Any())
                            return true; // Anonymous users are allowed so they can access anonymous endpoints

                        var appUserId = Util.GetAppUserId(ctx.User);
                        var isLockedOut = await LockoutCache.IsUserLockedOut(appUserId);
                        return !isLockedOut;
                    }).Build();
                ops.Filters.Add(new AuthorizeFilter(policy));
            }
            else
            {
                ops.Filters.Add(new AllowAnonymousFilter());
            }
        })
        .AddNewtonsoftJson(options =>
        {
            options.SerializerSettings.DateFormatHandling = Newtonsoft.Json.DateFormatHandling.IsoDateFormat;
            options.SerializerSettings.DateTimeZoneHandling = Newtonsoft.Json.DateTimeZoneHandling.Local;
            //ReferenceLoopHandling is required sometimes to avoid $http:baddata errors due to invalid Json being returned with linq .include entities with certain FK refs
            options.SerializerSettings.ReferenceLoopHandling = Newtonsoft.Json.ReferenceLoopHandling.Ignore;
        });

        if (!IsDevEnvOrDb || ForceUseSerilog)
            builder.Services.PostConfigure<ApiBehaviorOptions>(SerilogPostConfigure.Configure);

        // Register DbContext with appropriate options
        if (IsLocalModeEnabled)
        {
            // In local mode, use in-memory database
            builder.Services.AddDbContext<MyDbContext>(options =>
            {
                options.UseInMemoryDatabase(LocalModeDbName);
            });

            // In local mode, use ephemeral data protection (keys stored in memory)
            builder.Services.AddDataProtection()
                .SetApplicationName("FastAppShared");
        }
        else
        {
            // In normal mode, use PostgreSQL
            // Since we are passing a singleton `NpgsqlDataSource DataSource` to UseNpgsql,
            // connection pooling is still happening, despite the fact that we are calling AddDbContext
            // rather than AddDbContextPool
            builder.Services.AddDbContext<MyDbContext>(AddDbOptions);

            // Persist data protection keys to DB to prevent warning when running inside a docker container
            // SetApplicationName ensures all subdomain apps share the same encryption keys for cookies
            builder.Services.AddDataProtection()
                .PersistKeysToDbContext<MyDbContext>()
                .SetApplicationName("FastAppShared");
        }

        //use AddIdentityCore rather than AddIdentity since adding roles will break token authentication
        builder.Services.AddIdentityCore<AppUser>(options =>
        {
            options.Lockout = new LockoutOptions
            {
                AllowedForNewUsers = true,
                DefaultLockoutTimeSpan = TimeSpan.FromMinutes(5),
                MaxFailedAccessAttempts = 5
            };
            options.SignIn = new SignInOptions { RequireConfirmedEmail = false, RequireConfirmedPhoneNumber = false };
            options.User = new UserOptions
            {
                AllowedUserNameCharacters = @"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._@+ ",
                RequireUniqueEmail = false
            };
            options.Password = new PasswordOptions
            {
                RequireDigit = false,
                RequireLowercase = false,
                RequireUppercase = false,
                RequireNonAlphanumeric = false,
                RequiredLength = 7
            };
        })
        .AddEntityFrameworkStores<MyDbContext>();
    }

    private static void AddDbOptions(DbContextOptionsBuilder ops)
    {
        ops.UseNpgsql(DataSource, npgOpsBuilder =>
        {
            npgOpsBuilder.EnableRetryOnFailure(4); //default 30 seconds between each retry
            npgOpsBuilder.UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery);
        });
    }

    private static void AddDbOptionsWithLogging(DbContextOptionsBuilder ops)
    {
        AddDbOptions(ops);

        ops.EnableSensitiveDataLogging(); // Optional: Enables logging of parameter values
        ops.LogTo(Console.WriteLine, LogLevel.Information); // Logs SQL to the console
    }

    public static void ConfigureWebHost()
    {
        if (app == null)
            throw new Exception("app is null");

        app.UseResponseCompression();

        IdentityModelEventSource.ShowPII = true;
        if (IsDevEnvOrDb)
            app.UseDeveloperExceptionPage();

        //for linux reverse proxy hosting
        app.UseForwardedHeaders();

        //this causes a "too many redidirects" error when running in a docker container
        //but is needed for automatic redirects if running directly from IIS on Windows
        if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
            app.UseHttpsRedirection();

        app.UseCors();

        if (IsAuthenticationEnabled)
            app.UseAuthentication();

        if (!IsDevEnvOrDb || ForceUseSerilog)
        {
            app.UseMiddleware<SerilogMiddleware>();
            app.UseSerilogRequestLogging();
        }

        // Handle Npgsql type mapping errors that occur when database is restored while app is running
        // NpgsqlErrorHandlerMiddleware MUST be registered AFTER SerilogMiddleware because SerilogMiddleware swallows exceptions.
        app.UseMiddleware<NpgsqlErrorHandlerMiddleware>();

        StaticFileOptions staticOps = new()
        {
            OnPrepareResponse = ctx =>
            {
                if (ctx.File.Name == "index.html")
                {
                    //"no-cache" allows caching but forces revalidation with the server before reuse
                    //"no-cache" is alternative way to say "must-revalidate, max-age=0"
                    //the ETag will still be employed to check if the resource has changed
                    //"no-store" would prevent caching altogether, so it is not used
                    ctx.Context.Response.Headers[HeaderNames.CacheControl] = "no-cache";
                }
            }
        };

        app.MapStaticAssets();
        app.MapFallbackToFile("index.html", staticOps);

        app.UseAuthorization();

        // Skip Hangfire dashboard in local mode
        if (IsHangfireEnabled && !IsLocalModeEnabled)
            app.UseHangfireDashboard("/hangfire", new DashboardOptions
            {
                AppPath = null,
                // The AsyncAuthorization expects an IEnumerable<IDashboardAsyncAuthorizationFilter>
                // We need to provide a single instance that, when its AuthorizeAsync is called,
                // can correctly resolve scoped services for that request.
                AsyncAuthorization = new IDashboardAsyncAuthorizationFilter[]
                {
                    // Create an anonymous class that implements IDashboardAsyncAuthorizationFilter
                    // and gets the required services from RequestServices for each call to AuthorizeAsync.
                    new DelegateHangfireAuthorizationFilter(app.Services) // Pass root service provider
                }
            });

        app.MapDefaultControllerRoute();
    }

    public static async Task RunAsync()
    {
        try
        {
            if (app == null)
                throw new Exception("Web host not built");

            WriteMessage("Starting web host");
            await app.StartAsync();
            WriteMessage("Web host started");
            await app.WaitForShutdownAsync();
        }
        catch (Exception ex)
        {
            var msg = $"Error: {Util.String.GetExceptionMessage(ex)}";
            Console.WriteLine(msg);
            Debug.WriteLine(msg);
            Log.Fatal(ex, "Host terminated unexpectedly");
        }
        finally
        {
            Log.CloseAndFlush();
            if (app != null)
                await app.StopAsync();
        }
    }

    private static void ConfigureEnvProps(IWebHostEnvironment env)
    {
        IsDevEnv = env.IsDevelopment();

        if (IsLocalModeEnabled)
        {
            // In local mode, treat as dev environment
            IsDevDb = true;
            IsTestDb = false;
        }
        else
        {
            using var db = CreateContext();
#pragma warning disable CA1862 // Prefer 'string.Equals(string, StringComparison)' for case-insensitive comparison
            string dbMetadata = db.AppSettings.FirstOrDefault(x => x.Name.ToLower() == "homeurl")?.Metadata ?? "";
#pragma warning restore CA1862
            IsDevDb = dbMetadata.Contains("dev", StringComparison.OrdinalIgnoreCase);
            IsTestDb = dbMetadata.Contains("test", StringComparison.OrdinalIgnoreCase);
        }
    }

    static async Task<string?> GetFastPortSuffixAsync()
    {
        var settingsFilePath = Path.GetFullPath(Path.Join(
            Environment.CurrentDirectory,
            $"..{Path.DirectorySeparatorChar}.vscode{Path.DirectorySeparatorChar}settings.json"));

        if (!File.Exists(settingsFilePath))
        {
            return null;
        }

        try
        {
            var jsonContent = await File.ReadAllTextAsync(settingsFilePath);
            var settings = JsonSerializer.Deserialize<VscodeSettings>(jsonContent);

            if (settings?.FastPortSuffix is not null)
            {
                Debug.WriteLine($"Successfully read 'fast.port.suffix': '{settings.FastPortSuffix}' from '{settingsFilePath}'.");
                return settings.FastPortSuffix;
            }
        }
        catch (Exception ex)
        {
            Debug.WriteLine($"Error reading VSCode settings from '{settingsFilePath}': {ex.Message}");
        }

        return null;
    }

    /// <summary>
    /// Attempts to determine the root domain from the current host.
    /// Handles common scenarios like localhost, IP addresses, and standard domain structures.
    /// </summary>
    /// <param name="host">The HostString from HttpContext.Request.Host.</param>
    /// <returns>The root domain (e.g., "example.com") or null if it cannot be determined reliably.</returns>
    public static string? GetRootDomain(HostString host)
    {
        var hostname = host.Host;

        // Handle localhost and IP addresses - cookies typically won't work cross-subdomain for these anyway.
        if (hostname.Equals("localhost", StringComparison.OrdinalIgnoreCase)
            || System.Net.IPAddress.TryParse(hostname, out _))
        {
            return null; // For localhost or IPs, no domain attribute means it's for the current host only.
        }

        // Split the hostname into parts
        var parts = hostname.Split('.');

        // For common TLDs, a 2-part root domain (e.g., example.com) is common.
        // For sub.example.com, parts will be ["sub", "example", "com"].
        // We want to reconstruct "example.com".
        if (parts.Length >= 2)
        {
            // Join the last two parts to form the root domain.
            var rootDomain = string.Join(".", parts.Skip(parts.Length - 2).Take(2));
            return rootDomain; // Return without the leading dot
        }

        // If it's a single part (e.g., just "myhost"), it's likely a local network name or malformed.
        return null;
    }

    public class VscodeSettings
    {
        [JsonPropertyName("fast.port.suffix")]
        public string? FastPortSuffix { get; set; }
    }

    private static void RemoveUnneededClaims(ClaimsIdentity identity)
    {
        // Remove unneeded claims to reduce cookie size
        var claimTypesToRemove = new[] { "aio", "uti", "nbf", "exp", "iat", "rh", "wids" };
        foreach (var type in claimTypesToRemove)
        {
            var claim = identity.FindFirst(type);
            if (claim != null)
                identity.RemoveClaim(claim);
        }
    }
}

public class DelegateHangfireAuthorizationFilter(IServiceProvider applicationServices) : IDashboardAsyncAuthorizationFilter
{
    private readonly IServiceProvider _applicationServices = applicationServices; // Root application service provider

    public async Task<bool> AuthorizeAsync(DashboardContext context)
    {
        // Get a request-scoped service provider
        // The HttpContextAccessor makes HttpContext available in the DI container.
        // We need to use the IHttpContextAccessor to get the HttpContext and its RequestServices
        var httpContextAccessor = _applicationServices.GetRequiredService<IHttpContextAccessor>();
        var httpContext = httpContextAccessor.HttpContext;

        if (httpContext == null)
        {
            Console.WriteLine("Hangfire: HttpContext not available for filter authorization. Denying access.");
            return false;
        }

        var requestServices = httpContext.RequestServices;

        // Use ActivatorUtilities.CreateInstance to create the actual scoped filter instance
        // for *this specific request*, resolving its dependencies from requestServices.
        var scopedFilter = ActivatorUtilities.CreateInstance<HangfireAuthorizationFilter>(requestServices);

        // Now, call the AuthorizeAsync on the properly instantiated (and scoped) filter
        return await scopedFilter.AuthorizeAsync(context);
    }
}
