using System.Security.Claims;
using Hangfire;
using Hangfire.PostgreSql;
using Prometheus;
using FluentValidation;
using foodmarket.Api.Infrastructure.RateLimiting;
using foodmarket.Api.Infrastructure.Tenancy;
using foodmarket.Api.Seed;
using foodmarket.Application.Common.Tenancy;
using foodmarket.Infrastructure.Identity;
using foodmarket.Infrastructure.Persistence;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using OpenIddict.Validation.AspNetCore;
using Serilog;
using static OpenIddict.Abstractions.OpenIddictConstants;

Log.Logger = new LoggerConfiguration()
    .WriteTo.Console()
    .CreateBootstrapLogger();

try
{
    var builder = WebApplication.CreateBuilder(args);

    builder.Host.UseSerilog((ctx, services, cfg) =>
        cfg.ReadFrom.Configuration(ctx.Configuration).ReadFrom.Services(services));

    const string CorsPolicy = "food-market-cors";
    builder.Services.AddCors(opts => opts.AddPolicy(CorsPolicy, p =>
    {
        var origins = builder.Configuration.GetSection("Cors:AllowedOrigins").Get<string[]>()
                      ?? ["http://localhost:5173"];
        p.WithOrigins(origins).AllowAnyHeader().AllowAnyMethod().AllowCredentials();
    }));

    builder.Services.AddHttpContextAccessor();
    builder.Services.AddScoped<ITenantContext, HttpContextTenantContext>();
    // ClaimsTransformation для SuperAdmin override: добавляет роли Admin/
    // Storekeeper/Cashier при наличии X-Org-Override, чтобы [Authorize(Roles=...)]
    // не отшивал edit-mode мутации SuperAdmin'а. См. SuperAdminOverrideClaimsTransformer.
    builder.Services.AddScoped<Microsoft.AspNetCore.Authentication.IClaimsTransformation,
                               foodmarket.Api.Infrastructure.Tenancy.SuperAdminOverrideClaimsTransformer>();

    // Prometheus metrics: singleton-интерсептор EF засекает длительность каждого
    // SQL-запроса (см. food_market_db_query_duration_seconds). HTTP-метрики
    // включаются ниже через UseHttpMetrics(). /metrics endpoint доступен всем
    // (стандартная практика для Prometheus scrape) — на prod закрываем nginx'ом.
    builder.Services.AddSingleton<foodmarket.Api.Infrastructure.Observability.DbMetricsInterceptor>();
    // OrgAuditInterceptor — scoped (зависит от ITenantContext). EF тащит его
    // через AddInterceptors на каждое создание DbContext (DbContext тоже scoped).
    builder.Services.AddScoped<foodmarket.Infrastructure.Persistence.OrgAuditInterceptor>();
    builder.Services.AddDbContext<AppDbContext>((sp, opts) =>
    {
        opts.UseNpgsql(builder.Configuration.GetConnectionString("Default"),
            npg => npg.MigrationsAssembly(typeof(AppDbContext).Assembly.GetName().Name));
        opts.UseOpenIddict();
        opts.AddInterceptors(sp.GetRequiredService<
            foodmarket.Api.Infrastructure.Observability.DbMetricsInterceptor>());
        opts.AddInterceptors(sp.GetRequiredService<
            foodmarket.Infrastructure.Persistence.OrgAuditInterceptor>());
    });

    builder.Services.AddIdentity<User, Role>(opts =>
        {
            opts.Password.RequireDigit = true;
            opts.Password.RequiredLength = 8;
            opts.Password.RequireNonAlphanumeric = false;
            opts.Password.RequireUppercase = false;
            opts.User.RequireUniqueEmail = true;
        })
        .AddEntityFrameworkStores<AppDbContext>()
        .AddDefaultTokenProviders();

    builder.Services.Configure<IdentityOptions>(opts =>
    {
        opts.ClaimsIdentity.UserNameClaimType = Claims.Name;
        opts.ClaimsIdentity.UserIdClaimType = Claims.Subject;
        opts.ClaimsIdentity.RoleClaimType = Claims.Role;
    });

    builder.Services.AddOpenIddict()
        .AddCore(opts => opts.UseEntityFrameworkCore().UseDbContext<AppDbContext>())
        .AddServer(opts =>
        {
            opts.SetTokenEndpointUris("/connect/token");
            opts.AllowPasswordFlow().AllowRefreshTokenFlow();
            opts.AcceptAnonymousClients();
            opts.RegisterScopes(Scopes.OpenId, Scopes.Profile, Scopes.Email, Scopes.Roles, "api");

            // Ключи подписи/шифрования: dev — persistent RSA-ключ в App_Data;
            // prod/stage — X509-сертификаты из конфига (OpenIddict:SigningCertPath /
            // EncryptionCertPath), persistent self-signed если файла нет.
            // Подробности и dev-инвариант — в OpenIddictKeyConfigurator.
            foodmarket.Api.Infrastructure.Security.OpenIddictKeyConfigurator
                .ConfigureSigningAndEncryption(opts, builder.Configuration, builder.Environment);

            opts.UseAspNetCore()
                .EnableTokenEndpointPassthrough()
                .DisableTransportSecurityRequirement();

            opts.SetAccessTokenLifetime(TimeSpan.Parse(
                builder.Configuration["OpenIddict:AccessTokenLifetime"] ?? "01:00:00"));
            opts.SetRefreshTokenLifetime(TimeSpan.Parse(
                builder.Configuration["OpenIddict:RefreshTokenLifetime"] ?? "30.00:00:00"));
            // Rolling refresh включён по умолчанию: каждый refresh гасит старый
            // токен (Redeemed) и выдаёт новый. Но по умолчанию OpenIddict даёт
            // 30-секундный reuse-leeway — окно, в котором погашенный refresh ещё
            // принимается (для гонок/ретраев). Для розничной админки это дыра:
            // утёкший refresh остаётся рабочим 30с после ротации. Обнуляем окно —
            // ротация инвалидирует старый refresh немедленно.
            opts.SetRefreshTokenReuseLeeway(TimeSpan.Zero);
            // Явный Issuer = публичный URL админки. Без него OpenIddict
            // вычисляет issuer из текущего HTTP-запроса, что ломается за
            // nginx-прокси если Host/X-Forwarded-Proto не доходят. Берём
            // из конфигурации (OpenIddict:Issuer / docker compose env).
            if (builder.Configuration["OpenIddict:Issuer"] is { Length: > 0 } issuer)
            {
                opts.SetIssuer(new Uri(issuer));
            }
        })
        .AddValidation(opts =>
        {
            opts.UseLocalServer();
            opts.UseAspNetCore();
        });

    // Force OpenIddict validation to handle ALL [Authorize] challenges — otherwise AddIdentity's
    // cookie scheme takes over and 401 becomes a 302 redirect to /Account/Login for API calls.
    builder.Services.AddAuthentication(options =>
    {
        options.DefaultScheme = OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme;
        options.DefaultAuthenticateScheme = OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme;
        options.DefaultChallengeScheme = OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme;
    });
    builder.Services.AddAuthorization(opts =>
    {
        // Check the "role" claim explicitly — robust against role-claim-type mismatches between
        // OpenIddict validation identity and the default ClaimTypes.Role uri.
        opts.AddPolicy("AdminAccess", p => p.RequireAssertion(ctx =>
            ctx.User.HasClaim(c => c.Type == Claims.Role && (c.Value == "Admin" || c.Value == "SuperAdmin"))));
    });
    // Permission-based авторизация: [RequiresPermission("...")] → policy perm:* →
    // PermissionRequirement → проверка флага RolePermissions роли сотрудника.
    builder.Services.AddSingleton<Microsoft.AspNetCore.Authorization.IAuthorizationPolicyProvider,
        foodmarket.Api.Infrastructure.Authorization.PermissionAuthorizationPolicyProvider>();
    builder.Services.AddScoped<Microsoft.AspNetCore.Authorization.IAuthorizationHandler,
        foodmarket.Api.Infrastructure.Authorization.PermissionAuthorizationHandler>();

    builder.Services.AddScoped<foodmarket.Api.Infrastructure.Tenancy.SuperAdminEditAuditFilter>();

    // Anti-brute-force на /connect/token и /api/auth/signup (5/мин + 20/час на IP).
    // Лимиты конфигурируемы через RateLimiting:* (PerMinute/PerHour/Enabled).
    builder.Services.AddAuthRateLimiting(builder.Configuration);

    // Health-пробы: liveness (процесс жив) и readiness (БД + миграции применены).
    // Readiness-чек помечен тегом "ready", чтобы /health/live его не запускал.
    builder.Services.AddHealthChecks()
        .AddCheck<foodmarket.Api.Infrastructure.Health.DatabaseReadyHealthCheck>(
            "database", tags: ["ready"]);

    // Email-отправка через MailKit. Singleton — внутри открывает scope для
    // свежего DbContext'а, поэтому конфиг (PlatformSettings) перечитывается
    // на каждой отправке без рестарта приложения.
    builder.Services.AddSingleton<foodmarket.Application.Common.Email.IEmailSender,
        foodmarket.Infrastructure.Email.MailKitEmailSender>();
    // EmailTemplates загружает embedded HTML и подставляет {{key}} —
    // см. Resources/EmailTemplates/*.html. Singleton с in-memory cache.
    builder.Services.AddSingleton<foodmarket.Api.Infrastructure.Email.EmailTemplates>();
    builder.Services.AddDataProtection();
    // MediatR (TD-1 partial CQRS): сканирует food-market.application для
    // IRequest/IRequestHandler. Образцы: CreateSupplyCommand,
    // PostRetailSaleCommand, GetSalesReportQuery. Контроллеры пока не
    // переписаны под mediator — это паттерн-показ, не полный рефакторинг.
    builder.Services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(
        typeof(foodmarket.Application.Inventory.IStockService).Assembly));
    // Заглушки для абстракций CQRS-handler'ов: handler'ы пока не подключены
    // к контроллерам (поэтапная миграция). Реальная имплементация появится
    // когда контроллер начнёт отправлять команду через IMediator. Сейчас
    // нужны только чтобы DI-validation на старте не падала.
    builder.Services.AddTransient<foodmarket.Application.Purchases.Commands.ISupplyWriter,
        foodmarket.Api.Infrastructure.Cqrs.UnusedSupplyWriter>();
    builder.Services.AddTransient<foodmarket.Application.Sales.Commands.IRetailSalePoster,
        foodmarket.Api.Infrastructure.Cqrs.UnusedRetailSalePoster>();
    // FluentValidation: автоматическая регистрация валидаторов из сборки api.
    // ValidationFilter гоняет валидаторы на каждом контроллер-action перед
    // вызовом — fail возвращает 400 ValidationProblemDetails (RFC 7807).
    builder.Services.AddValidatorsFromAssemblyContaining<Program>();
    builder.Services.AddScoped<foodmarket.Api.Infrastructure.Validation.ValidationFilter>();
    builder.Services.AddControllers(o =>
    {
        // Глобальный action filter — пишет audit-log при успешных мутациях
        // в режиме «SuperAdmin открыть как… + edit-mode» (Phase 3).
        o.Filters.AddService<foodmarket.Api.Infrastructure.Tenancy.SuperAdminEditAuditFilter>();
        o.Filters.AddService<foodmarket.Api.Infrastructure.Validation.ValidationFilter>();
    });
    builder.Services.AddEndpointsApiExplorer();
    builder.Services.AddSwaggerGen(opts =>
    {
        opts.SwaggerDoc("v1", new Microsoft.OpenApi.Models.OpenApiInfo
        {
            Title = "food-market API",
            Version = "v1",
            Description = "Multi-tenant POS/inventory backend. Все запросы " +
                          "ограничены организацией текущего JWT (claim `org_id`).",
        });
        // Bearer JWT через OpenIddict. Swagger UI «Authorize» подставит в Authorization.
        var bearer = new Microsoft.OpenApi.Models.OpenApiSecurityScheme
        {
            Type = Microsoft.OpenApi.Models.SecuritySchemeType.Http,
            Scheme = "bearer",
            BearerFormat = "JWT",
            In = Microsoft.OpenApi.Models.ParameterLocation.Header,
            Description = "Access token, полученный через POST /connect/token.",
        };
        opts.AddSecurityDefinition("Bearer", bearer);
        opts.AddSecurityRequirement(new Microsoft.OpenApi.Models.OpenApiSecurityRequirement
        {
            [new Microsoft.OpenApi.Models.OpenApiSecurityScheme
            {
                Reference = new Microsoft.OpenApi.Models.OpenApiReference
                {
                    Type = Microsoft.OpenApi.Models.ReferenceType.SecurityScheme,
                    Id = "Bearer",
                },
            }] = Array.Empty<string>(),
        });
        // Стабильные operationId для генерации TS-клиентов:
        // <Controller>_<Verb><Action>. Verb включён чтобы избежать коллизии
        // когда ASP.NET стрипает Async-суффикс и два метода (WipeAll, WipeAllAsync)
        // получают одинаковое имя action → одинаковый operationId → duplicate.
        opts.CustomOperationIds(api =>
        {
            var ctrl = api.ActionDescriptor.RouteValues["controller"];
            var action = api.ActionDescriptor.RouteValues["action"];
            var verb = api.HttpMethod is { Length: > 0 } m ? char.ToUpper(m[0]) + m[1..].ToLowerInvariant() : "";
            return $"{ctrl}_{verb}{action}";
        });
        // У нас есть одноимённые nested record'ы в разных контроллерах
        // (например, StockRow в StockController и StockReportController).
        // Включаем имя контроллера в schemaId через FullName-suffix чтобы не
        // словить duplicate schemaId — Swashbuckle падает на этом по умолчанию.
        opts.CustomSchemaIds(t => t.FullName!
            .Replace("foodmarket.Api.Controllers.", "")
            .Replace("foodmarket.Application.", "")
            .Replace("foodmarket.Domain.", "")
            .Replace("+", "_")
            .Replace(".", "_"));
    });

    // MoySklad import integration. Auto-decompress gzip responses from MoySklad's edge.
    // BaseAddress берётся из конфигурации (MoySklad:BaseUrl) с дефолтом на боевой
    // api.moysklad.ru — так интеграцию можно навести на mock-сервер в e2e/интеграционных
    // тестах, не трогая прод. Трейлинг-слэш обязателен (RFC 3986 §5.3, см. MoySkladClient).
    builder.Services.AddHttpClient<foodmarket.Infrastructure.Integrations.MoySklad.MoySkladClient>(http =>
        {
            var baseUrl = builder.Configuration["MoySklad:BaseUrl"];
            if (!string.IsNullOrWhiteSpace(baseUrl))
                http.BaseAddress = new Uri(baseUrl);
        })
        .ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler
        {
            AutomaticDecompression = System.Net.DecompressionMethods.GZip | System.Net.DecompressionMethods.Deflate,
        });
    builder.Services.AddScoped<foodmarket.Infrastructure.Integrations.MoySklad.MoySkladImportService>();
    builder.Services.AddSingleton<foodmarket.Infrastructure.Integrations.MoySklad.ImportJobRegistry>();

    // Inventory
    builder.Services.AddScoped<foodmarket.Application.Inventory.IStockService, foodmarket.Infrastructure.Inventory.StockService>();

    // Hangfire — фоновые джобы и UI-дашборд. Хранилище — наш Postgres
    // (тот же ConnectionString:Default). Запускаем фактический сервер только
    // когда приложение действительно работает (не в тестах): чтобы тестовые
    // прогоны не плодили tables/jobs в одноразовом контейнере. Регистрация
    // recurring jobs — после Build() в IHostedService HangfireJobsConfigurator.
    var enableHangfireServer = builder.Configuration.GetValue("Hangfire:Enabled", true);
    builder.Services.AddHangfire(cfg => cfg
        .SetDataCompatibilityLevel(Hangfire.CompatibilityLevel.Version_180)
        .UseSimpleAssemblyNameTypeSerializer()
        .UseRecommendedSerializerSettings()
        .UsePostgreSqlStorage(opts => opts.UseNpgsqlConnection(
            builder.Configuration.GetConnectionString("Default"))));
    if (enableHangfireServer)
    {
        builder.Services.AddHangfireServer(opts =>
        {
            opts.WorkerCount = 2;
            opts.Queues = new[] { "default" };
        });
        builder.Services.AddHostedService<foodmarket.Api.Background.HangfireJobsConfigurator>();
    }
    builder.Services.AddScoped<foodmarket.Api.Background.HousekeepingJobs>();
    builder.Services.AddScoped<foodmarket.Api.Background.EmailNotificationJobs>();

    builder.Services.AddHostedService<OpenIddictClientSeeder>();
    builder.Services.AddHostedService<SystemReferenceSeeder>();
    builder.Services.AddHostedService<DevDataSeeder>();
    builder.Services.AddHostedService<foodmarket.Api.Background.ReferencePriceRefreshJob>();
    // DemoCatalogSeeder disabled: real catalog is imported from MoySklad.
    // Keep the file as reference for anyone starting without MoySklad access —
    // just re-register here to turn demo data back on.
    // builder.Services.AddHostedService<DemoCatalogSeeder>();

    var app = builder.Build();

    app.UseSerilogRequestLogging();
    app.UseCors(CorsPolicy);
    // Prometheus HTTP-метрики (http_requests_received_total, http_request_duration_seconds).
    // Включаем сразу после CORS и до аутентификации, чтобы видеть и 401/403
    // (это сигнал об атаке/неверной конфигурации). prometheus-net уже зашивает
    // reserved-лейблы code/method/controller/action из route template'а —
    // дополнительной кастомизации не нужно, и она запрещена (см. ValidateMappings).
    app.UseHttpMetrics();
    // До аутентификации: лимитируем перебор пароля ещё на входе, не доводя
    // до проверки credential'ов в БД.
    app.UseRateLimiter();
    app.UseAuthentication();
    app.UseAuthorization();
    // После аутентификации, до контроллеров: вытягиваем OrgId/UserId из ClaimsPrincipal
    // и кладём в Serilog LogContext вместе с CorrelationId — каждая ILogger.Log
    // в пайплайне автоматически получит эти лейблы.
    app.UseMiddleware<foodmarket.Api.Infrastructure.Observability.LogEnrichmentMiddleware>();
    // SuperAdmin «открыть как…» — тот же tenant как у выбранной орги, но
    // только GET. Любая мутация → 403, кроме /api/super-admin/* и /connect/*.
    app.UseMiddleware<foodmarket.Api.Infrastructure.Tenancy.ReadonlyOverrideMiddleware>();

    // Статика товарных изображений: физически /app/uploads (volume в compose),
    // публичный URL /uploads/... — раздаются public, без auth.
    var uploadsDir = System.IO.Path.Combine(app.Environment.ContentRootPath, "uploads");
    System.IO.Directory.CreateDirectory(uploadsDir);
    app.UseStaticFiles(new Microsoft.AspNetCore.Builder.StaticFileOptions
    {
        FileProvider = new Microsoft.Extensions.FileProviders.PhysicalFileProvider(uploadsDir),
        RequestPath = "/uploads",
    });

    if (app.Environment.IsDevelopment())
    {
        // Swagger/OpenAPI: только в Development. /swagger/v1/swagger.json — JSON-документ,
        // /swagger — UI. На prod не раскрываем (sensitive endpoint enumeration), на dev
        // используется фронтом для генерации TS-клиента (`pnpm run gen:api`).
        app.UseSwagger();
        app.UseSwaggerUI(opts =>
        {
            opts.DocumentTitle = "food-market API";
            opts.RoutePrefix = "swagger";
        });
    }

    app.MapControllers();

    // /metrics — текстовый Prometheus exposition format. Скрейпится prometheus-сервером
    // (rate=15s типично). Доступ без авторизации — стандартная практика;
    // на prod ограничиваем nginx-уровнем (allow 10.0.0.0/8, deny all) или basic-auth.
    app.MapMetrics("/metrics");

    // Hangfire Dashboard на /hangfire — только SuperAdmin. Auth-фильтр
    // проверяет System.Security.Claims.ClaimsPrincipal (стандартный
    // OpenIddict-токен в Authorization-заголовке). Не вешаем UseHangfireServer —
    // он уже стартует через AddHangfireServer выше.
    if (enableHangfireServer)
    {
        app.UseHangfireDashboard("/hangfire", new Hangfire.DashboardOptions
        {
            Authorization = new[] { new foodmarket.Api.Background.SuperAdminHangfireFilter() },
            // Не показываем команды Delete/Requeue по умолчанию из UI чтобы случайные клики
            // не разрушили scheduled — все джобы декларативные, перерегистрация делает их
            // идемпотентной.
            IgnoreAntiforgeryToken = false,
        });
    }

    // Liveness: процесс отвечает — без обращения к зависимостям (Predicate=false
    // => ни один чек не запускается). Используется для рестарта зависшего контейнера.
    app.MapHealthChecks("/health/live", new Microsoft.AspNetCore.Diagnostics.HealthChecks.HealthCheckOptions
    {
        Predicate = _ => false,
        ResponseWriter = WriteHealthResponse,
    });

    // Readiness: запускает чеки с тегом "ready" (БД + миграции). 503 если не готово.
    app.MapHealthChecks("/health/ready", new Microsoft.AspNetCore.Diagnostics.HealthChecks.HealthCheckOptions
    {
        Predicate = check => check.Tags.Contains("ready"),
        ResponseWriter = WriteHealthResponse,
    });

    // Backward-compat: /health = liveness (Dockerfile/nginx исторически бьют сюда).
    app.MapGet("/health", () => Results.Ok(new { status = "ok", time = DateTime.UtcNow }));

    app.MapGet("/api/_debug/whoami", (HttpContext ctx) =>
    {
        var identity = ctx.User.Identity as System.Security.Claims.ClaimsIdentity;
        return Results.Ok(new
        {
            isAuthenticated = ctx.User.Identity?.IsAuthenticated,
            authType = ctx.User.Identity?.AuthenticationType,
            nameClaimType = identity?.NameClaimType,
            roleClaimType = identity?.RoleClaimType,
            isInRoleAdmin = ctx.User.IsInRole("Admin"),
            hasAdminRoleClaim = ctx.User.HasClaim(c => c.Type == Claims.Role && c.Value == "Admin"),
            claims = ctx.User.Claims.Select(c => new { c.Type, c.Value }),
        });
    }).RequireAuthorization();

    app.MapGet("/api/me", async (HttpContext ctx, AppDbContext db) =>
    {
        var user = ctx.User;
        var roles = user.FindAll(Claims.Role).Select(c => c.Value).ToList();
        var orgIdRaw = user.FindFirst(HttpContextTenantContext.OrganizationClaim)?.Value;
        var subRaw = user.FindFirst(Claims.Subject)?.Value;

        // Дополнительные сигналы, чтобы фронт точно знал в каком состоянии юзер
        // находится: orphan AppUser без живой org или без активного Employee
        // получает /no-organization fallback вместо белого экрана на /dashboard.
        bool hasLiveOrg = false;
        bool hasActiveEmployee = false;
        if (Guid.TryParse(orgIdRaw, out var orgId))
        {
            hasLiveOrg = await db.Organizations.IgnoreQueryFilters()
                .AnyAsync(o => o.Id == orgId && !o.IsArchived);
            if (Guid.TryParse(subRaw, out var sub))
            {
                hasActiveEmployee = await db.Employees.IgnoreQueryFilters()
                    .AnyAsync(e => e.OrganizationId == orgId && e.UserId == sub && e.IsActive);
            }
        }

        return Results.Ok(new
        {
            sub = subRaw,
            name = user.Identity?.Name,
            email = user.FindFirst(Claims.Email)?.Value,
            roles,
            orgId = orgIdRaw,
            hasLiveOrg,
            hasActiveEmployee,
        });
    }).RequireAuthorization();

    // Apply migrations on every startup (idempotent). Without this, fresh
    // stage/prod deploys land on an empty DB and OpenIddict seeders fail.
    using (var scope = app.Services.CreateScope())
    {
        var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
        db.Database.Migrate();
    }

    app.Run();
}
catch (Exception ex)
{
    Log.Fatal(ex, "Application terminated unexpectedly");
}
finally
{
    Log.CloseAndFlush();
}

// JSON-ответ health-проб: общий статус + по каждому чеку. text/plain по умолчанию
// неудобен для мониторинга; отдаём структурировано.
static Task WriteHealthResponse(HttpContext context,
    Microsoft.Extensions.Diagnostics.HealthChecks.HealthReport report)
{
    context.Response.ContentType = "application/json";
    return context.Response.WriteAsJsonAsync(new
    {
        status = report.Status.ToString(),
        totalDurationMs = report.TotalDuration.TotalMilliseconds,
        checks = report.Entries.Select(e => new
        {
            name = e.Key,
            status = e.Value.Status.ToString(),
            description = e.Value.Description,
        }),
    });
}

// Делает сгенерированный из top-level statements класс Program видимым для
// интеграционных тестов (WebApplicationFactory&lt;Program&gt;).
public partial class Program;
