using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using JobTrackerApi.Controllers;
using JobTrackerApi.Data;
using System.Data.Common;
using MySqlConnector;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.IdentityModel.Tokens;
using JobTrackerApi.Models;
using JobTrackerApi.Services;
using System.Diagnostics;
using System.IdentityModel.Tokens.Jwt;
using System.Net;
using System.IO;
using System.Security.Cryptography;
using System.Threading.RateLimiting;
using JobTrackerApi.Services.JobImport;
using JobTrackerApi.Services.JobImport.Plugins;
using JobTrackerApi.Services.JobImport.Translation;

var builder = WebApplication.CreateBuilder(args);
var externalOrigin = ExternalOrigin.FromConfiguration(builder.Configuration, builder.Environment.IsProduction());

// Avoid Windows EventLog provider issues in local dev environments.
builder.Logging.ClearProviders();
if (builder.Environment.IsProduction()) builder.Logging.AddJsonConsole();
else
{
    builder.Logging.AddSimpleConsole();
    builder.Logging.AddDebug();
}

builder.Services.AddHttpContextAccessor();
builder.Services.AddSingleton(externalOrigin);
builder.Services.AddSingleton<AiPrivacyPolicy>();
builder.Services.AddSingleton<AiOperationExecutionScope>();
builder.Services.AddSingleton<AiUsageExecutionScope>();
builder.Services.AddTransient<AiPrivacyHeaderHandler>();
builder.Services.AddScoped<CurrentUserService>();
builder.Services.AddScoped<ICurrentUserService>(sp => sp.GetRequiredService<CurrentUserService>());
builder.Services.AddSingleton<BackgroundTenantRunner>();
builder.Services.AddSingleton(TimeProvider.System);
builder.Services.AddScoped<UserOperationStore>();
builder.Services.AddScoped<EmailSendAttemptStore>();
builder.Services.AddScoped<AccountOwnedFileInventory>();
builder.Services.AddScoped<AccountDataExportService>();
builder.Services.AddSingleton<AccountDeletionTombstoneStore>();
builder.Services.AddScoped<IAiSidecarCachePurger, AiSidecarCachePurger>();
builder.Services.AddScoped<AccountDeletionService>();
builder.Services.AddScoped<AiOperationAdmission>();
builder.Services.AddScoped<AiUsageMeter>();
builder.Services.AddSingleton<IStripeBillingGateway, StripeBillingGateway>();
builder.Services.AddScoped<StrategySnapshotService>();
builder.Services.AddSingleton<IAiOperationHandler, StrategySnapshotOperationHandler>();
builder.Services.AddSingleton<IAiOperationHandler, CvProcessingOperationHandler>();
builder.Services.AddSingleton<AiOperationWorker>();
builder.Services.AddScoped<UserNotificationStore>();
builder.Services.AddScoped<IEmailSettingsResolver, EmailSettingsResolver>();
builder.Services.AddScoped<IAppEmailSender, SmtpEmailSender>();
builder.Services.AddScoped<ICvProcessingQueue, CvProcessingQueue>();
builder.Services.AddTransient<ProfileCvController>();
builder.Services.AddSingleton<ICvTemplateRenderer, CvTemplateRenderer>();
builder.Services.AddSingleton<IThemedCvRenderer, ThemedCvRenderer>();
builder.Services.AddSingleton<ICvPdfExporter, PlaywrightCvPdfExporter>();
builder.Services.AddScoped<ICareerProfileService, CareerProfileService>();
builder.Services.AddSingleton<ICvProfileDiffService, CvProfileDiffService>();
builder.Services.AddScoped<ICvVariantService, CvVariantService>();
builder.Services.AddScoped<IAiWorkspaceService, AiWorkspaceService>();
builder.Services.AddScoped<IApplicationWorkspaceService, ApplicationWorkspaceService>();
builder.Services.AddScoped<IApplicationChecklistService, ApplicationChecklistService>();
builder.Services.AddScoped<IApplicationTimelineService, ApplicationTimelineService>();
builder.Services.AddScoped<IApplicationIntelligenceService, ApplicationIntelligenceService>();
builder.Services.AddScoped<IApplicationAssetsService, ApplicationAssetsService>();
builder.Services.AddScoped<IInterviewPrepService, InterviewPrepService>();

builder.Services.AddSingleton<AppPaths>();
builder.Services.AddSingleton<IAttachmentStorage, AttachmentStorage>();
builder.Services.AddSingleton<IStartupReadiness, StartupReadiness>();

// Add DbContext
builder.Services.AddDbContext<JobTrackerContext>((sp, options) =>
{
    var cfg = sp.GetRequiredService<IConfiguration>();
    var paths = sp.GetRequiredService<AppPaths>();

    var provider = (cfg["Database:Provider"] ?? "sqlite").Trim().ToLowerInvariant();
    var cs = cfg.GetConnectionString("JobTracker");
    if (string.IsNullOrWhiteSpace(cs))
    {
        cs = $"Data Source={paths.GetDbPath()}";
        provider = "sqlite";
    }

    if (provider is "mysql" or "mariadb")
    {
        // Avoid ServerVersion.AutoDetect here because it forces an immediate DB connection
        // during service registration, which can crash the API if MariaDB is temporarily
        // unavailable or on a different network during deploy startup.
        options.UseMySql(cs, new MariaDbServerVersion(new Version(11, 0, 0)), mysql =>
        {
            mysql.MigrationsAssembly("JobTrackerApi");
        });
    }
    else
    {
        options.UseSqlite(cs, sqlite =>
        {
            sqlite.MigrationsAssembly("JobTrackerApi");
        });
    }

    // We create Identity tables on startup in environments where `dotnet ef` isn't available.
    // That can cause EF to detect "pending model changes" and throw on Migrate(). Ignore it.
    options.ConfigureWarnings(w =>
    {
        w.Ignore(RelationalEventId.PendingModelChangesWarning);
        w.Ignore(CoreEventId.PossibleIncorrectRequiredNavigationWithQueryFilterInteractionWarning);
    });
});

// Enable CORS (allowlist by default)
builder.Services.AddCors(options =>
{
    options.AddPolicy("AllowReact", policy =>
    {
        var origins = builder.Configuration.GetSection("Cors:Origins").Get<string[]>() ?? Array.Empty<string>();
        if (origins.Length == 0)
        {
            origins = new[] { "http://localhost:3000" };
        }

        if (origins.Any(x => x.Trim() == "*"))
            throw new InvalidOperationException("Cors:Origins cannot contain wildcard when credentialed requests are enabled.");

        policy.WithOrigins(origins.Select(x => x.Trim()).Where(x => x.Length > 0).ToArray())
            .AllowAnyMethod()
            .AllowAnyHeader()
            .AllowCredentials();
    });
});

// Add controllers
builder.Services.AddControllers();
builder.Services.AddProblemDetails(options =>
{
    options.CustomizeProblemDetails = context =>
        context.ProblemDetails.Extensions["traceId"] = context.HttpContext.TraceIdentifier;
});
builder.Services.AddExceptionHandler<AiUsageLimitExceptionHandler>();
builder.Services.AddOpenApi();
var dataRoot = (builder.Configuration["Data:Root"] ?? "").Trim();
if (string.IsNullOrWhiteSpace(dataRoot))
{
    dataRoot = builder.Environment.ContentRootPath;
}
if (!Path.IsPathRooted(dataRoot))
{
    dataRoot = Path.Combine(builder.Environment.ContentRootPath, dataRoot);
}
Directory.CreateDirectory(dataRoot);
var dataProtectionKeysPath = Path.Combine(dataRoot, "keys");
Directory.CreateDirectory(dataProtectionKeysPath);

builder.Services.AddDataProtection()
    .PersistKeysToFileSystem(new DirectoryInfo(dataProtectionKeysPath))
    .SetApplicationName("JobTracker");
builder.Services.AddSingleton<IDatabaseBackupRunner, SqliteDatabaseBackupRunner>();
builder.Services.AddHostedService<DatabaseBackupHostedService>();
builder.Services.AddHostedService<RulesHostedService>();
builder.Services.AddHostedService<FollowUpReminderHostedService>();
builder.Services.AddHostedService<DailyExportHostedService>();
builder.Services.AddHostedService<JobEnrichmentHostedService>();
builder.Services.AddHostedService<SummarizerProbeHostedService>();
builder.Services.AddHostedService<AiOperationHostedService>();
builder.Services.AddHostedService<EmailSendAttemptRecoveryHostedService>();
builder.Services.AddHostedService<AccountDeletionHostedService>();

builder.Services.AddHttpClient("jobimport")
    .ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler
    {
        AutomaticDecompression = DecompressionMethods.All,
        AllowAutoRedirect = false
    });

// Local AI service (FastAPI). Supports summarization and OCR/text extraction.
// Every caller goes through this named client, so the shared-secret header is set once here.
builder.Services.AddHttpClient("ai-service", client =>
{
    var baseUrl = builder.Configuration["Ai:BaseUrl"]
        ?? builder.Configuration["Summarizer:BaseUrl"]
        ?? "http://127.0.0.1:8001";
    client.BaseAddress = new Uri(baseUrl);
    client.Timeout = TimeSpan.FromSeconds(30);

    var serviceToken = builder.Configuration["Ai:ServiceToken"];
    if (!string.IsNullOrWhiteSpace(serviceToken))
    {
        client.DefaultRequestHeaders.Add("X-Ai-Service-Token", serviceToken);
    }
}).AddHttpMessageHandler<AiPrivacyHeaderHandler>();

builder.Services.AddMemoryCache();
builder.Services.AddScoped<AnalyticsService>();
builder.Services.AddSingleton<SummarizerService>();
builder.Services.AddScoped<ISummarizerService, MeteredSummarizerService>();
builder.Services.AddSingleton<IJobCvMatchService, JobCvMatchService>();
builder.Services.AddSingleton<ICvAiClassifier, CvAiClassifier>();
builder.Services.AddSingleton<ICvAiNormalizer, CvAiNormalizer>();
builder.Services.AddSingleton<IGoogleTokenValidator, GoogleTokenValidator>();
builder.Services.AddSingleton<IMicrosoftTokenValidator, MicrosoftTokenValidator>();
builder.Services.AddScoped<IGmailOAuthService, GmailOAuthService>();
builder.Services.AddSingleton<IGmailJobMatchingService, GmailJobMatchingService>();
builder.Services.AddSingleton<IGmailCorrespondenceEnrichmentService, NoOpGmailCorrespondenceEnrichmentService>();
builder.Services.AddScoped<IMicrosoftGraphOAuthService, MicrosoftGraphOAuthService>();
builder.Services.AddScoped<IImapService, ImapService>();

// Provider-neutral email seam (multi-provider: Gmail + Microsoft Graph + IMAP today; manual next).
builder.Services.AddScoped<JobTrackerApi.Services.EmailProviders.IEmailProvider, JobTrackerApi.Services.EmailProviders.GmailProvider>();
builder.Services.AddScoped<JobTrackerApi.Services.EmailProviders.IEmailProvider, JobTrackerApi.Services.EmailProviders.MicrosoftGraphProvider>();
builder.Services.AddScoped<JobTrackerApi.Services.EmailProviders.IEmailProvider, JobTrackerApi.Services.EmailProviders.ImapProvider>();
builder.Services.AddScoped<JobTrackerApi.Services.EmailProviders.IEmailProviderRegistry, JobTrackerApi.Services.EmailProviders.EmailProviderRegistry>();

builder.Services.AddIdentityCore<ApplicationUser>(options =>
    {
        options.User.RequireUniqueEmail = true;
        options.Password.RequireDigit = true;
        options.Password.RequireLowercase = true;
        options.Password.RequireUppercase = false;
        options.Password.RequireNonAlphanumeric = false;
        options.Password.RequiredLength = 8;
        options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(15);
        options.Lockout.MaxFailedAccessAttempts = 5;
        options.Lockout.AllowedForNewUsers = true;
    })
    .AddRoles<IdentityRole>()
    .AddEntityFrameworkStores<JobTrackerContext>()
    .AddDefaultTokenProviders()
    .AddSignInManager();

builder.Services.AddScoped<ITokenService, TokenService>();
builder.Services.AddSingleton<ITwoFactorPendingTokenService, TwoFactorPendingTokenService>();

builder.Services.AddSingleton<UniversalJobParser>();
builder.Services.AddSingleton<IHostAddressResolver, DnsHostAddressResolver>();
builder.Services.AddSingleton<IJobSitePlugin, FinnPlugin>();
builder.Services.AddSingleton<IJobSitePlugin, NavPlugin>();
builder.Services.AddSingleton<IJobSitePlugin, LinkedInPlugin>();
builder.Services.AddSingleton<IJobSitePlugin, JobbnorgePlugin>();

var translationProvider = (builder.Configuration["Translation:Provider"] ?? "none").Trim().ToLowerInvariant();
builder.Services.AddSingleton<ITranslationService>(sp =>
{
    return translationProvider switch
    {
        "libretranslate" => new LibreTranslateService(sp.GetRequiredService<IHttpClientFactory>(), sp.GetRequiredService<IConfiguration>()),
        _ => new NoOpTranslationService()
    };
});
builder.Services.AddScoped<JobImportService>();

var requireAuth = builder.Configuration.GetValue("Auth:Require", false);
var googleClientId = (builder.Configuration["Auth:GoogleClientId"] ?? "").Trim();
var microsoftClientId = (builder.Configuration["Auth:MicrosoftClientId"] ?? "").Trim();
var microsoftTenantWasDefaulted = !string.IsNullOrWhiteSpace(microsoftClientId)
    && string.IsNullOrWhiteSpace(builder.Configuration["Auth:MicrosoftTenant"])
    && !builder.Environment.IsProduction();
if (!string.IsNullOrWhiteSpace(microsoftClientId))
    MicrosoftTenantPolicy.Parse(builder.Configuration["Auth:MicrosoftTenant"], builder.Environment.IsProduction());

var jwtKey = (builder.Configuration["Auth:JwtKey"] ?? "").Trim();
var ephemeralJwtKey = false;
if (string.IsNullOrWhiteSpace(jwtKey))
{
    if (requireAuth)
        throw new InvalidOperationException("Auth is required but Auth:JwtKey is not configured.");

    jwtKey = Convert.ToBase64String(RandomNumberGenerator.GetBytes(64));
    builder.Configuration.AddInMemoryCollection(new Dictionary<string, string?> { ["Auth:JwtKey"] = jwtKey });
    ephemeralJwtKey = true;
}

var issuer = (builder.Configuration["Auth:JwtIssuer"] ?? "JobTrackerApi").Trim();
var audience = (builder.Configuration["Auth:JwtAudience"] ?? "job-tracker-ui").Trim();

builder.Services.AddAuthentication(options =>
    {
        options.DefaultScheme = "smart";
        options.DefaultChallengeScheme = "smart";
    })
    .AddPolicyScheme("smart", "Smart JWT", options =>
    {
        options.ForwardDefaultSelector = ctx =>
        {
            if (string.IsNullOrWhiteSpace(googleClientId))
                return "local";

            var auth = ctx.Request.Headers.Authorization.ToString();
            if (!auth.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
                return "local";

            var token = auth["Bearer ".Length..].Trim();
            var handler = new JwtSecurityTokenHandler();
            if (!handler.CanReadToken(token))
                return "local";

            try
            {
                var jwt = handler.ReadJwtToken(token);
                var iss = jwt.Issuer ?? "";
                if (!string.IsNullOrWhiteSpace(googleClientId) && iss is "accounts.google.com" or "https://accounts.google.com")
                    return "google";
                return "local";
            }
            catch
            {
                return "local";
            }
        };
    })
    .AddJwtBearer("local", options =>
    {
        options.Events = new JwtBearerEvents
        {
            OnMessageReceived = context =>
            {
                if (!string.IsNullOrWhiteSpace(context.Token))
                {
                    return Task.CompletedTask;
                }

                if (context.Request.Cookies.TryGetValue(AuthSessionOptions.SessionCookieName, out var cookieToken) && !string.IsNullOrWhiteSpace(cookieToken))
                {
                    context.Token = cookieToken;
                }

                return Task.CompletedTask;
            },
            OnTokenValidated = async context =>
            {
                var userId = LocalAuthIdentity.GetRequiredUserId(context.Principal);
                if (userId is null)
                {
                    context.Fail("Local tokens must include a subject/nameidentifier claim.");
                    return;
                }

                // Resolve a fresh scoped JobTrackerContext for this one lookup -- OnTokenValidated
                // runs outside the request's normal DI-constructor scope, so RequestServices (the
                // per-request scope) must be used directly rather than a captured/singleton one.
                // Fail closed if the session row is missing/revoked/expired (including tokens
                // with no "sid" claim at all -- see LocalSessionValidator for why: every JWT
                // issued going forward carries one, so a token without it is either pre-deploy
                // (forces a single re-login for anyone already signed in when this ships --
                // acceptable, same additive-forward cost the 2FA/trusted-device features on this
                // branch already paid) or forged, and either way isn't proof of a live session.
                var db = context.HttpContext.RequestServices.GetRequiredService<JobTrackerContext>();
                if (!await LocalSessionValidator.IsValidAsync(
                    db,
                    context.Principal,
                    DateTimeOffset.UtcNow,
                    builder.Configuration.GetValue("Auth:RequireEmailVerification", false)))
                {
                    context.Fail("Session has been revoked or expired.");
                }
            }
        };
        options.TokenValidationParameters = new TokenValidationParameters
        {
            ValidateIssuer = true,
            ValidIssuer = issuer,
            ValidateAudience = true,
            ValidAudience = audience,
            ValidateIssuerSigningKey = true,
            IssuerSigningKey = new SymmetricSecurityKey(System.Text.Encoding.UTF8.GetBytes(jwtKey)),
            ValidateLifetime = true,
            ClockSkew = TimeSpan.FromMinutes(2),
            NameClaimType = System.Security.Claims.ClaimTypes.Name,
            RoleClaimType = System.Security.Claims.ClaimTypes.Role,
        };
    });

if (!string.IsNullOrWhiteSpace(googleClientId))
{
    builder.Services.AddAuthentication().AddJwtBearer("google", options =>
    {
        // Validate Google ID tokens (sent from the frontend) as bearer tokens.
        options.Authority = "https://accounts.google.com";
        options.TokenValidationParameters = new TokenValidationParameters
        {
            ValidateIssuer = true,
            ValidIssuers = new[] { "accounts.google.com", "https://accounts.google.com" },
            ValidateAudience = true,
            ValidAudience = googleClientId,
            ValidateLifetime = true,
        };
    });
}

builder.Services.AddAuthorization(options =>
{
    options.AddPolicy(ProEntitlement.Policy, policy =>
        policy.AddRequirements(new ProEntitlementRequirement()));
    if (requireAuth)
    {
        options.FallbackPolicy = new AuthorizationPolicyBuilder()
            .RequireAuthenticatedUser()
            .Build();
    }
});
builder.Services.AddScoped<IAuthorizationHandler, ProEntitlementHandler>();
builder.Services.AddSingleton<IAuthorizationMiddlewareResultHandler, ProEntitlementAuthorizationResultHandler>();

builder.Services.AddRateLimiter(options =>
{
    options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;

    options.AddPolicy("auth-login", context =>
        RateLimitPartition.GetFixedWindowLimiter(
            partitionKey: $"login:{context.Connection.RemoteIpAddress?.ToString() ?? "unknown"}",
            factory: _ => new FixedWindowRateLimiterOptions
            {
                PermitLimit = 10,
                Window = TimeSpan.FromMinutes(5),
                QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
                QueueLimit = 0,
            }));

    options.AddPolicy("auth-email", context =>
        RateLimitPartition.GetFixedWindowLimiter(
            partitionKey: $"email:{context.Connection.RemoteIpAddress?.ToString() ?? "unknown"}",
            factory: _ => new FixedWindowRateLimiterOptions
            {
                PermitLimit = 5,
                Window = TimeSpan.FromMinutes(15),
                QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
                QueueLimit = 0,
            }));

    options.AddPolicy("email-send", context =>
        RateLimitPartition.GetFixedWindowLimiter(
            partitionKey: $"email-send:{context.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value ?? context.User.FindFirst("sub")?.Value ?? context.Connection.RemoteIpAddress?.ToString() ?? "unknown"}",
            factory: _ => new FixedWindowRateLimiterOptions
            {
                PermitLimit = 10,
                Window = TimeSpan.FromMinutes(1),
                QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
                QueueLimit = 0,
            }));

    options.AddPolicy("public-pdf", context =>
        RateLimitPartition.GetFixedWindowLimiter(
            partitionKey: RateLimitPartitionKeys.PublicPdf(context),
            factory: _ => new FixedWindowRateLimiterOptions
            {
                PermitLimit = 3,
                Window = TimeSpan.FromMinutes(1),
                QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
                QueueLimit = 0,
            }));

    options.AddPolicy("account-data", context =>
        RateLimitPartition.GetFixedWindowLimiter(
            partitionKey: $"account-data:{context.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value ?? context.User.FindFirst("sub")?.Value ?? context.Connection.RemoteIpAddress?.ToString() ?? "unknown"}",
            factory: _ => new FixedWindowRateLimiterOptions
            {
                PermitLimit = 2,
                Window = TimeSpan.FromHours(1),
                QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
                QueueLimit = 0,
            }));

    // Brute-forcing a 6-digit TOTP code (1e6 space) is far more feasible than a password, so
    // this gets a tighter window than auth-login.
    options.AddPolicy("auth-2fa-challenge", context =>
        RateLimitPartition.GetFixedWindowLimiter(
            partitionKey: $"2fa:{context.Connection.RemoteIpAddress?.ToString() ?? "unknown"}",
            factory: _ => new FixedWindowRateLimiterOptions
            {
                PermitLimit = 5,
                Window = TimeSpan.FromMinutes(5),
                QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
                QueueLimit = 0,
            }));
});

var app = builder.Build();

if (ephemeralJwtKey)
{
    app.Logger.LogWarning("Auth:JwtKey was not configured. Generated an ephemeral key; local login tokens will be invalid after restart.");
}

var enableHttpsRedirect = app.Configuration.GetValue("HttpsRedirection:Enabled", false);
var enableHsts = app.Configuration.GetValue("HttpsRedirection:Hsts", false);
if (app.Configuration.GetValue("Proxy:TrustForwardedHeaders", false))
{
    app.UseForwardedHeaders(ForwardedProxyConfiguration.Build(app.Configuration));
}
if (microsoftTenantWasDefaulted)
{
    app.Logger.LogWarning("Auth:MicrosoftTenant was not configured; Development/Test defaults to common. Production requires an explicit value.");
}

if (app.Environment.IsProduction())
{
    app.Use(async (ctx, next) =>
    {
        if (!externalOrigin.AllowsRequest(ctx.Request.Host, ctx.Request.Path))
        {
            ctx.Response.StatusCode = StatusCodes.Status400BadRequest;
            await ctx.Response.WriteAsync("Unknown host.");
            return;
        }

        await next();
    });
}
if (enableHsts) app.UseHsts();
if (enableHttpsRedirect) app.UseHttpsRedirection();

app.UseExceptionHandler();

// Structured request logging for easy diagnosis.
app.Use(async (ctx, next) =>
{
    var sw = Stopwatch.StartNew();
    try
    {
        await next();
        sw.Stop();

        var sub = ctx.User?.Claims?.FirstOrDefault(c => c.Type is "sub" or "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier")?.Value;
        app.Logger.LogInformation(
            "HTTP {Method} {Path} {StatusCode} {ElapsedMs}ms trace={TraceId} sub={Sub}",
            ctx.Request.Method,
            ctx.Request.Path.Value ?? "",
            ctx.Response.StatusCode,
            sw.ElapsedMilliseconds,
            ctx.TraceIdentifier,
            sub ?? ""
        );
    }
    catch (Exception ex)
    {
        sw.Stop();
        app.Logger.LogError(
            ex,
            "HTTP {Method} {Path} 500 {ElapsedMs}ms trace={TraceId}",
            ctx.Request.Method,
            ctx.Request.Path.Value ?? "",
            sw.ElapsedMilliseconds,
            ctx.TraceIdentifier
        );
        throw;
    }
});

await app.InitializeJobTrackerAsync();

await using (var deletionReplayScope = app.Services.CreateAsyncScope())
{
    var staged = await deletionReplayScope.ServiceProvider.GetRequiredService<AccountDeletionService>().StageRestoredAccountsAsync(CancellationToken.None);
    if (staged > 0) app.Logger.LogWarning("Staged {Count} restored deleted accounts for mandatory tombstone replay.", staged);
}

await using (var attachmentScope = app.Services.CreateAsyncScope())
{
    var result = await attachmentScope.ServiceProvider.GetRequiredService<IAttachmentStorage>()
        .ReconcileAsync(attachmentScope.ServiceProvider.GetRequiredService<JobTrackerContext>(), CancellationToken.None);
    if (result.Missing > 0 || result.UnknownOrphans > 0 || result.UnsafePaths > 0 || result.Failures > 0)
    {
        app.Logger.LogWarning(
            "Attachment reconciliation completed with missing={Missing}, unknownOrphans={UnknownOrphans}, unsafePaths={UnsafePaths}, failures={Failures}; unknown files were not removed.",
            result.Missing, result.UnknownOrphans, result.UnsafePaths, result.Failures);
    }
    else if (result.Promoted > 0 || result.Restored > 0 || result.Purged > 0)
    {
        app.Logger.LogInformation(
            "Attachment reconciliation recovered promoted={Promoted}, restored={Restored}, purged={Purged} files.",
            result.Promoted, result.Restored, result.Purged);
    }
}

app.UseCors("AllowReact");
app.UseRateLimiter();

app.Use(async (ctx, next) =>
{
    if (HttpMethods.IsGet(ctx.Request.Method) || HttpMethods.IsHead(ctx.Request.Method) || HttpMethods.IsOptions(ctx.Request.Method) || HttpMethods.IsTrace(ctx.Request.Method))
    {
        await next();
        return;
    }

    if (!ctx.Request.Cookies.ContainsKey(AuthSessionOptions.SessionCookieName))
    {
        await next();
        return;
    }

    if (ctx.Request.Path.StartsWithSegments("/api/auth/login")
        || ctx.Request.Path.StartsWithSegments("/api/auth/register")
        || ctx.Request.Path.StartsWithSegments("/api/auth/logout")
        || ctx.Request.Path.StartsWithSegments("/api/auth/google/exchange")
        || ctx.Request.Path.StartsWithSegments("/api/auth/request-password-reset")
        || ctx.Request.Path.StartsWithSegments("/api/auth/reset-password")
        || ctx.Request.Path.StartsWithSegments("/api/auth/csrf"))
    {
        await next();
        return;
    }

    var csrfCookie = ctx.Request.Cookies[AuthSessionOptions.CsrfCookieName];
    var csrfHeader = ctx.Request.Headers[AuthSessionOptions.CsrfHeaderName].ToString();
    if (string.IsNullOrWhiteSpace(csrfCookie) || string.IsNullOrWhiteSpace(csrfHeader) || !string.Equals(csrfCookie, csrfHeader, StringComparison.Ordinal))
    {
        ctx.Response.StatusCode = StatusCodes.Status403Forbidden;
        await ctx.Response.WriteAsync("CSRF validation failed.");
        return;
    }

    await next();
});

app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();

// Liveness probe for the container health check. Deliberately cheap and anonymous: it answers "is
// this process up and serving?" and nothing else. It does NOT touch the database — a health check
// that queried MariaDB would restart a perfectly healthy backend whenever the database blipped, and
// would also be a free unauthenticated way to probe database availability.
// docs/production-readiness-review.md.
// Version comes from configuration (App:Version), which is how docker-compose supplies it as
// App__Version. Reading the APP_VERSION environment variable directly meant /health always
// reported "unknown" in a container. Falls back to the assembly version for local development.
app.MapGet("/health", (IConfiguration cfg) => Results.Ok(new
{
    status = "ok",
    version = BuildMetadata.ResolveVersion(cfg),
})).AllowAnonymous();

// API schema for tooling/docs. Dev-only: not exposed in production deployments.
if (app.Environment.IsDevelopment())
{
    app.MapOpenApi().AllowAnonymous();
}

app.Run();
